├── .editorconfig ├── .gitattributes ├── .github ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .husky └── pre-commit ├── LICENSE ├── README.md ├── api-extractor.json ├── docs ├── index.md ├── migration.md ├── oidc-client-ts.api.md └── protocols │ ├── authorization-code-grant-with-pkce.md │ ├── authorization-code-grant.md │ ├── demonstrating-proof-of-possession.md │ ├── refresh-token-grant.md │ ├── resource-owner-password-credentials-grant.md │ └── silent-refresh-token-in-iframe-flow.md ├── eslint.config.mjs ├── jest-environment-jsdom.cjs ├── jest.config.mjs ├── package-lock.json ├── package.json ├── samples └── Parcel │ ├── .proxyrc.js │ ├── oidc.js │ ├── package-lock.json │ ├── package.json │ ├── server.js │ └── src │ ├── app.css │ ├── code-flow-duendesoftware │ ├── sample-callback.html │ ├── sample-callback.js │ ├── sample-popup-signin.html │ ├── sample-popup-signin.js │ ├── sample-popup-signout.html │ ├── sample-popup-signout.js │ ├── sample-settings.js │ ├── sample-silent.html │ ├── sample-silent.js │ ├── sample.html │ └── sample.js │ ├── index.html │ ├── oidc-client │ ├── sample-callback.html │ ├── sample-callback.js │ ├── sample-settings.js │ ├── sample.html │ └── sample.js │ └── user-manager │ ├── sample-callback.html │ ├── sample-callback.js │ ├── sample-popup-signin.html │ ├── sample-popup-signin.js │ ├── sample-popup-signout.html │ ├── sample-popup-signout.js │ ├── sample-settings.js │ ├── sample-silent.html │ ├── sample-silent.js │ ├── sample.html │ └── sample.js ├── scripts └── build.js ├── src ├── AccessTokenEvents.test.ts ├── AccessTokenEvents.ts ├── AsyncStorage.ts ├── CheckSessionIFrame.ts ├── Claims.ts ├── ClaimsService.test.ts ├── ClaimsService.ts ├── DPoPStore.ts ├── InMemoryWebStorage.ts ├── IndexedDbDPoPStore.test.ts ├── IndexedDbDPoPStore.ts ├── JsonService.test.ts ├── JsonService.ts ├── MetadataService.test.ts ├── MetadataService.ts ├── OidcClient.test.ts ├── OidcClient.ts ├── OidcClientSettings.test.ts ├── OidcClientSettings.ts ├── OidcMetadata.ts ├── RefreshState.ts ├── ResponseValidator.test.ts ├── ResponseValidator.ts ├── SessionMonitor.ts ├── SessionStatus.ts ├── SigninRequest.test.ts ├── SigninRequest.ts ├── SigninResponse.test.ts ├── SigninResponse.ts ├── SigninState.test.ts ├── SigninState.ts ├── SignoutRequest.test.ts ├── SignoutRequest.ts ├── SignoutResponse.test.ts ├── SignoutResponse.ts ├── SilentRenewService.ts ├── State.test.ts ├── State.ts ├── StateStore.ts ├── TokenClient.test.ts ├── TokenClient.ts ├── User.test.ts ├── User.ts ├── UserInfoService.test.ts ├── UserInfoService.ts ├── UserManager.test.ts ├── UserManager.ts ├── UserManagerEvents.test.ts ├── UserManagerEvents.ts ├── UserManagerSettings.test.ts ├── UserManagerSettings.ts ├── Version.ts ├── WebStorageStateStore.test.ts ├── WebStorageStateStore.ts ├── errors │ ├── ErrorDPoPNonce.ts │ ├── ErrorResponse.test.ts │ ├── ErrorResponse.ts │ ├── ErrorTimeout.ts │ └── index.ts ├── index.ts ├── navigators │ ├── AbstractChildWindow.ts │ ├── IFrameNavigator.ts │ ├── IFrameWindow.test.ts │ ├── IFrameWindow.ts │ ├── INavigator.ts │ ├── IWindow.ts │ ├── PopupNavigator.ts │ ├── PopupWindow.test.ts │ ├── PopupWindow.ts │ ├── RedirectNavigator.test.ts │ ├── RedirectNavigator.ts │ └── index.ts └── utils │ ├── CryptoUtils.test.ts │ ├── CryptoUtils.ts │ ├── Event.test.ts │ ├── Event.ts │ ├── JwtUtils.test.ts │ ├── JwtUtils.ts │ ├── Logger.test.ts │ ├── Logger.ts │ ├── PopupUtils.test.ts │ ├── PopupUtils.ts │ ├── Timer.test.ts │ ├── Timer.ts │ ├── UrlUtils.test.ts │ ├── UrlUtils.ts │ └── index.ts ├── test ├── integration │ └── OidcClient.spec.js └── setup.ts ├── tsconfig.build.json ├── tsconfig.json └── typedoc.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 4 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | quote_type = double 10 | 11 | [{package,package-lock}.json] 12 | indent_size = 2 13 | 14 | [*.{yaml,yml}] 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the language for these files to json5 to ensure GitHub doesn't show the comments as errors 2 | /.vscode/*.json linguist-language=JSON5 3 | /api-extractor.json linguist-language=JSON5 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" 9 | directories: 10 | - "/" 11 | - "/samples/Parcel" 12 | schedule: 13 | interval: "daily" 14 | open-pull-requests-limit: 20 15 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | Closes/fixes #issue 3 | 4 | ### Checklist 5 | 6 | - [ ] This PR makes changes to the public API 7 | - [ ] I have included links for closing relevant issue numbers 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-node@v3 15 | with: 16 | node-version: 20.x 17 | cache: npm 18 | 19 | - name: Cache incremental builds 20 | uses: actions/cache@v3 21 | with: 22 | path: | 23 | lib/ 24 | tsconfig.build.tsbuildinfo 25 | key: build-${{ github.head_ref }}-${{ github.sha }} 26 | restore-keys: | 27 | build-${{ github.head_ref }}- 28 | build-refs/heads/main- 29 | 30 | - run: npm ci 31 | - run: npm pack --dry-run 32 | - run: npm run typedoc 33 | 34 | - name: Samples/Parcel builds 35 | run: | 36 | npm ci 37 | npm run build 38 | working-directory: samples/Parcel 39 | 40 | test: 41 | runs-on: ubuntu-latest 42 | 43 | steps: 44 | - uses: actions/checkout@v3 45 | - uses: actions/setup-node@v3 46 | with: 47 | node-version: 20.x 48 | cache: npm 49 | 50 | - name: Cache incremental builds 51 | uses: actions/cache@v3 52 | with: 53 | path: | 54 | .eslintcache 55 | tsconfig.tsbuildinfo 56 | key: test-${{ github.head_ref }}-${{ github.sha }} 57 | restore-keys: | 58 | test-${{ github.head_ref }}- 59 | test-refs/heads/main- 60 | 61 | - run: npm ci 62 | - run: npm run lint 63 | - run: npm test 64 | 65 | - uses: codecov/codecov-action@v3 66 | with: 67 | files: ./coverage/lcov.info 68 | flags: unittests 69 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | release: 4 | types: [created] 5 | 6 | jobs: 7 | publish: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v3 12 | # Setup .npmrc file to publish to npm 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: 20.x 16 | cache: npm 17 | registry-url: https://registry.npmjs.org 18 | 19 | - name: Install dependencies 20 | run: npm ci 21 | 22 | - name: Publish package release 23 | if: "!github.event.release.prerelease" 24 | run: npm publish 25 | env: 26 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 27 | 28 | - name: Publish package pre-release 29 | if: "github.event.release.prerelease" 30 | run: npm publish --tag next 31 | env: 32 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 33 | 34 | - name: Generate API documentation 35 | run: npm run typedoc 36 | 37 | - name: Deploy pages 38 | uses: peaceiris/actions-gh-pages@v3 39 | with: 40 | github_token: ${{ secrets.GITHUB_TOKEN }} 41 | publish_dir: ./docs/pages 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | 3 | # build artifacts 4 | *.tsbuildinfo 5 | /lib/ 6 | /coverage/ 7 | /docs/pages/ 8 | 9 | **/dist/* 10 | /dist/esm/* 11 | 12 | # caches 13 | .eslintcache 14 | .cache 15 | .parcel-cache/ 16 | 17 | # dependencies 18 | node_modules/ 19 | 20 | *.log 21 | .DS_Store 22 | .vscode/ 23 | temp/ 24 | .history/ 25 | 26 | # Jetbrains IDEs 27 | .idea/ 28 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | npm run build-types -- --local 3 | git add 'docs/*.api.md' 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # oidc-client-ts 2 | 3 | [![Stable Release](https://img.shields.io/npm/v/oidc-client-ts.svg)](https://npm.im/oidc-client-ts) 4 | [![CI](https://github.com/authts/oidc-client-ts/actions/workflows/ci.yml/badge.svg)](https://github.com/authts/oidc-client-ts/actions/workflows/ci.yml) 5 | [![Codecov](https://img.shields.io/codecov/c/github/authts/oidc-client-ts)](https://app.codecov.io/gh/authts/oidc-client-ts) 6 | 7 | Library to provide OpenID Connect (OIDC) and OAuth2 protocol support for 8 | client-side, browser-based JavaScript client applications. Also included is 9 | support for user session and access token management. 10 | 11 | This project is a fork of 12 | [IdentityModel/oidc-client-js](https://github.com/IdentityModel/oidc-client-js) 13 | which halted its development in June 2021. It has since been ported to 14 | TypeScript here with a similar API for the initial 2.0 release. Going forward, 15 | this library will focus only on protocols that continue to have support in 16 | [OAuth 2.1](https://oauth.net/2.1/). As such, the implicit grant is not 17 | supported by this client. Additional migration notes from `oidc-client` are 18 | available [here](docs/migration.md). 19 | 20 | **Contributions and help are greatly appreciated!** 21 | 22 | Implements the following OAuth 2.0 protocols and supports 23 | [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html): 24 | 25 | - [Authorization Code Grant with Proof Key for Code Exchange (PKCE)](docs/protocols/authorization-code-grant-with-pkce.md) 26 | - [Authorization Code Grant](docs/protocols/authorization-code-grant.md) 27 | - [Resource Owner Password Credentials (ROPC) Grant](docs/protocols/resource-owner-password-credentials-grant.md) 28 | - [Refresh Token Grant](docs/protocols/refresh-token-grant.md) 29 | - [Silent Refresh Token in iframe Flow](docs/protocols/silent-refresh-token-in-iframe-flow.md) 30 | 31 | ## Table of Contents 32 | 33 | - [Documentation](https://authts.github.io/oidc-client-ts/) 34 | - [Installation](#installation) 35 | - [Building the Source](#building-the-source) 36 | - [Contributing](#contributing) 37 | - [License](#license) 38 | 39 | ## Installation 40 | 41 | Using [npm](https://npmjs.org/) 42 | 43 | ```sh 44 | $ npm install oidc-client-ts --save 45 | ``` 46 | 47 | ## Building the Source 48 | 49 | ```sh 50 | $ git clone https://github.com/authts/oidc-client-ts.git 51 | $ cd oidc-client-ts 52 | $ npm install 53 | $ npm run build 54 | ``` 55 | 56 | ### Running the Sample 57 | 58 | **Parcel project** 59 | 60 | ```sh 61 | $ cd samples/Parcel 62 | $ npm install 63 | $ npm run start 64 | ``` 65 | 66 | and then browse to [http://localhost:1234](http://localhost:1234). 67 | 68 | **Angular app** 69 | 70 | can be found [here](https://github.com/authts/sample-angular-oidc-client-ts). 71 | 72 | ### Running the Tests 73 | 74 | ```sh 75 | $ npm test 76 | ``` 77 | 78 | ## Contributing 79 | 80 | We appreciate feedback and contribution to this repo! 81 | 82 | ## License 83 | 84 | This project is licensed under the Apache-2.0 license. See the 85 | [LICENSE](https://github.com/authts/oidc-client-ts/blob/main/LICENSE) file for 86 | more info. 87 | 88 | -------------------------------------------------------------------------------- /docs/migration.md: -------------------------------------------------------------------------------- 1 | ## oidc-client-ts v2.4.0 → oidc-client-ts v3.0.0 2 | 3 | The API is largely backwards-compatible. 4 | 5 | The "crypto-js" software library has been removed; the native crypto/crypto.subtle module built into the browser is instead used. All modern browsers are expected to support it. If you need to support older browsers, stay with v2.4! 6 | 7 | The behavior of merging claims has been improved. 8 | 9 | ### [OidcClientSettings](https://authts.github.io/oidc-client-ts/interfaces/OidcClientSettings.html) 10 | 11 | - the following deprecated properties were **removed**: 12 | - `clockSkewInSeconds` 13 | - `userInfoJwtIssuer` 14 | - `refreshTokenCredentials` use `fetchRequestCredentials` 15 | - the `mergeClaims` has been replaced by `mergeClaimsStrategy` 16 | - if the previous behavior is required, `mergeClaimsStrategy: { array: "merge" }` comes close to it 17 | - default of `response_mode` changed from `query` → `undefined` 18 | 19 | 20 | ## oidc-client v1.11.5 → oidc-client-ts v2.0.0 21 | 22 | Ported library from JavaScript to TypeScript. The API is largely 23 | backwards-compatible. The support for the deprecated implicit flow has been 24 | removed. 25 | 26 | ### [OidcClientSettings](https://authts.github.io/oidc-client-ts/interfaces/OidcClientSettings.html) 27 | 28 | - the following properties are now **required**: `authority`, `client_id`, 29 | `redirect_uri` 30 | - the following properties were **renamed**: 31 | - `clockSkew` → `clockSkewInSeconds` 32 | - `staleStateAge` → `staleStateAgeInSeconds` 33 | - default of `loadUserInfo` changed from `true` → `false` 34 | - removed `ResponseValidatorCtor` and `MetadataServiceCtor` 35 | - if necessary, `OidcClient` / `UserManager` classes may be extended to alter 36 | their behavior 37 | - restricted `response_type` to `code` flow only. As per [OAuth 2.1](https://oauth.net/2.1/): **PKCE is required** for all OAuth clients using the authorization `code` flow 38 | - as in oidc-client 1.x, OAuth 2.0 hybrid flows are not supported 39 | - the property `signingKeys` is unused, unless the MetaDataService with this feature is used 40 | outside of this library. 41 | 42 | ### [UserManagerSettings](https://authts.github.io/oidc-client-ts/interfaces/UserManagerSettings.html) 43 | 44 | - the following properties were **renamed**: 45 | - `accessTokenExpiringNotificationTime` → 46 | `accessTokenExpiringNotificationTimeInSeconds` 47 | - `silentRequestTimeout` (milliseconds) → `silentRequestTimeoutInSeconds` 48 | - `checkSessionInterval` (milliseconds) → `checkSessionIntervalInSeconds` 49 | - `revokeAccessTokenOnSignout` → `revokeTokensOnSignout` 50 | - the following properties have new **default values**: 51 | - `automaticSilentRenew` changed from `false` → `true` 52 | - `validateSubOnSilentRenew` changed from `false` → `true` 53 | - `includeIdTokenInSilentRenew` changed from `true` → `false` 54 | - `monitorSession` changed from `true` → `false` 55 | - type of `popupWindowFeatures` changed from a string to a dictionary 56 | - additionally, its default dimensions are now responsive to the opener 57 | window's 58 | - a new property `revokeTokenTypes: ('access_token' | 'refresh_token')[]` was added 59 | - by default, `UserManager` will attempt revoking both token types when 60 | `revokeTokensOnSignout` is `true`. Compared to 1.x, sign out will now fail 61 | if revocations fail. 62 | 63 | ### [UserManager](https://authts.github.io/oidc-client-ts/classes/UserManager.html) 64 | 65 | - The shorthand for keeping the popup open after the callback with 66 | `signoutPopupCallback(true)` is no longer supported. Instead use 67 | `signoutPopupCallback(undefined, true)` or preferably, 68 | `signoutPopupCallback(location.href, true)`. 69 | - renamed `revokeAccessToken()` → `revokeTokens(types?)` 70 | - Compared to 1.x, this function will now throw if _any_ revocation of the 71 | types specified fail. Uses the `revokeTokenTypes` setting when no `types` 72 | are passed. 73 | 74 | ### [Log](https://authts.github.io/oidc-client-ts/modules/Log.html) 75 | 76 | - The getter/setters for `Log.level` and `Log.logger` have been replaced by 77 | `Log.setLevel()` and `Log.setLogger()`. 78 | 79 | ### [User](https://authts.github.io/oidc-client-ts/classes/User.html) 80 | 81 | - The getter for `User.expired` now returns `true` when `expires_at` is set to `0`. This was `false` in the previous version. 82 | -------------------------------------------------------------------------------- /docs/protocols/authorization-code-grant-with-pkce.md: -------------------------------------------------------------------------------- 1 | # Authorization Code Grant with Proof Key for Code Exchange (PKCE) 2 | 3 | The authorization code protocol is part of OAuth 2.0 (defined in [OAuth 2.0 RFC 7636](https://tools.ietf.org/html/rfc7636)). It involves the exchange of an authorization code for a token. This is the recommended authorization code flow in the [OAuth 2.1 draft](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-07#section-10). 4 | 5 | 6 | ## Principle of function 7 | ```mermaid 8 | --- 9 | title: Authorization Code Grant with Proof Key for Code Exchange (PKCE) 10 | --- 11 | sequenceDiagram 12 | actor User 13 | User->>App: Click sign-in link (1) 14 | 15 | activate App 16 | Note right of App: Generate code_verifier and
code_challenge 17 | App->>Identity Provider: Authorization code request & code_challenge (2) 18 | deactivate App 19 | 20 | Identity Provider-->>User: Redirect to login/authorization prompt (3) 21 | User-->>Identity Provider: Authenticate (3) 22 | Identity Provider->>App: Authorization code (3) 23 | 24 | activate App 25 | App->>Identity Provider: Authorization code & code verifier (4) 26 | Note right of Identity Provider: Validate authorization code &
code_verifier 27 | Identity Provider->>App: Access token and ID token (4) 28 | deactivate App 29 | 30 | App->>Your API: Request protected data with access token (5) 31 | ``` 32 | 33 | 1. The user clicks sign-in within the application. 34 | 2. `signinRedirect()` or `signinPopup()` must be used to start the flow. 35 | 3. The identity provider authenticates the user and stores the code_challenge and redirects the user back to the application with an authorization code. 36 | 4. `signinCallback()` handles this callback by sending this authorization code and code_verifier to the identity provider and receiving in return the access token and ID token. 37 | 5. The access token is now accessible via `getUser()?.access_token` and inserted into the requests to your protected API. 38 | -------------------------------------------------------------------------------- /docs/protocols/authorization-code-grant.md: -------------------------------------------------------------------------------- 1 | # Authorization Code Grant 2 | 3 | The authorization code protocol is part of OAuth 2.0 defined in ([OAuth 2.0 RFC 6749, section 4.1](https://tools.ietf.org/html/rfc6749#section-4.1)). It involves the exchange of an authorization code for a token. 4 | 5 | **NOTE**
6 | It implies some security risks, so you should only use it after a security assessment. 7 | 8 | 9 | ## Security concerns 10 | This flow can only be used for applications, which can protect the client secret: 11 | - **Native app**: Can not securely store the client secret, as it's possible to decompile the application. 12 | - **Single-page app**: Can not securely store the client secret, as the full code is exposed in the user's browser 13 | 14 | In that scenarios, [Authorization Code Grant with Proof Key for Code Exchange (PKCE)](authorization-code-grant-with-pkce.md) must be used. 15 | 16 | 17 | ## Principle of function 18 | ```mermaid 19 | --- 20 | title: Authorization Code Grant 21 | --- 22 | sequenceDiagram 23 | actor User 24 | User->>App: Click sign-in link (1) 25 | activate App 26 | App->>Identity Provider: Authorization code request (2) 27 | deactivate App 28 | 29 | Identity Provider-->>User: Redirect to login/authorization prompt (3) 30 | User-->>Identity Provider: Authenticate (3) 31 | Identity Provider->>App: Authorization code (3) 32 | 33 | activate App 34 | App->>Identity Provider: Authorization code & client secret (4) 35 | Note right of Identity Provider: Validate authorization code &
client secret 36 | Identity Provider->>App: Access token and ID token (4) 37 | deactivate App 38 | 39 | App->>Your API: Request protected data with access token (5) 40 | ``` 41 | 42 | 1. The user clicks sign-in within the application. 43 | 2. `signinRedirect()` or `signinPopup()` must be used to start the flow. 44 | 3. The identity provider authenticates the user and stores the code_challenge and redirects the user back to the application with an authorization code. 45 | 4. `signinCallback()` handles this callback by sending this authorization code and client secret to the identity provider and receiving in return the access token and ID token. 46 | 5. The access token is now accessible via `getUser()?.access_token` and inserted into the requests to your protected API. 47 | -------------------------------------------------------------------------------- /docs/protocols/demonstrating-proof-of-possession.md: -------------------------------------------------------------------------------- 1 | # Demonstrating Proof of Possession (DPoP) 2 | 3 | Demonstrating Proof of Possession (DPoP) is defined in [RFC 9449 OAuth2.0 Demonstrating Proof of Possession (DPoP)](https://datatracker.ietf.org/doc/html/rfc9449). 4 | 5 | DPoP is described as a mechanism for sender-constraining OAuth 2.0 tokens via a proof-of-possession mechanism on the application level. This mechanism allows for the detection of replay attacks with access and refresh tokens. 6 | 7 | Essentially this means that tokens are bound to a particular client device, as long as the private key used to generate 8 | the DPoP proof is not compromised. 9 | 10 | ## Usage 11 | 12 | To use the DPoP feature add the `dpop` configuration option when instantiating either the UserManager or OidcClient classes: 13 | 14 | ```typescript 15 | import { UserManager } from 'oidc-client-ts'; 16 | 17 | const settings = { 18 | authority: 'https://demo.identityserver.io', 19 | client_id: 'interactive.public', 20 | redirect_uri: 'http://localhost:8080', 21 | response_type: 'code', 22 | scope: 'openid profile email api', 23 | post_logout_redirect_uri: 'http://localhost:8080', 24 | userStore: new WebStorageStateStore({ store: window.localStorage }), 25 | dpop: { 26 | bind_authorization_code: true, 27 | store: new IndexedDbDPoPStore() 28 | } 29 | }; 30 | 31 | const userManager = new UserManager(settings); 32 | ``` 33 | ## DPoP configuration options 34 | 35 | - `bind_authorization_code` - If true, the DPoP proof will be [bound to the authorization code](https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-code-binding-) as well as subsequent token requests. This is optional and defaults to false. 36 | - `store` - The DPoP store to use. This is where the DPoP proof will be stored and must be supplied. We provide a default implementation `IndexedDbDPoPStore` which stores the DPoP proof in IndexedDb. 37 | 38 | ### IndexedDbDPoPStore 39 | 40 | The `IndexedDbDPoPStore` is a default implementation of the `DPoPStore` interface. It stores the DPoP proof in IndexedDb. 41 | 42 | IndexedDb is used as storage because it is the only storage mechanism that is available that allows for storing CryptoKeyPair objects 43 | with non-extractable private keys securely. The object itself, when retrieved from storage, is still available to perform signing operations but the key material can 44 | never be extracted directly. Storing CryptoKeyPair objects as plain text in storage mechanisms such as `localStorage` or `sessionStorage` 45 | is not recommended as it exposes the private key material to potential attackers. 46 | 47 | ## DPoP Proofs and Protected Resources 48 | 49 | Once an access token has been issued that is bound to a DPoP public key, [resource servers](https://datatracker.ietf.org/doc/html/rfc9449#name-protected-resource-access) that support DPoP 50 | require that an authenticated request include a DPoP Proof. Additionally, DPoP replaces the Bearer authentication [scheme](https://datatracker.ietf.org/doc/html/rfc9449#section-7.1): 51 | 52 | The `UserManager.dpopProof` method is provided which allows easily generating a DPoP proof for a given request. E.g. 53 | 54 | ```typescript 55 | // settings include the DPoP configuration 56 | 57 | const mgr = new UserManager(settings); 58 | 59 | // sign the user in and receive an access token 60 | 61 | // generate a DPoP proof and make a request to a protected resource 62 | const DPoPProof = await mgr.dpopProof("https://localhost:5005/api", user); 63 | const token = user?.access_token; 64 | const response = await fetch("https://localhost:5005/api", { 65 | credentials: "include", 66 | mode: "cors", 67 | method: "GET", 68 | headers: { 69 | Authorization: `DPoP ${token}`, 70 | DPoP: DPoPProof, 71 | }, 72 | }); 73 | ``` 74 | -------------------------------------------------------------------------------- /docs/protocols/refresh-token-grant.md: -------------------------------------------------------------------------------- 1 | # Refresh Token Grant 2 | 3 | This protocol is part of OAuth 2.0 (defined in [OAuth 2.0 RFC 6749, section 1.5](https://tools.ietf.org/html/rfc6749#section-1.5)). 4 | The refresh token grant is used by clients to exchange a refresh token for an access token when the access token has expired. 5 | 6 | 7 | ## Principle of function 8 | ```mermaid 9 | --- 10 | title: Refresh Token Grant 11 | --- 12 | sequenceDiagram 13 | App->>Identity Provider: Request new access token with refresh token (1) 14 | activate App 15 | Note right of Identity Provider: Validate refresh token 16 | Identity Provider->>App: Access token and optional refresh token (1) 17 | deactivate App 18 | 19 | App->>Your API: Request protected data with refreshed access token (2) 20 | ``` 21 | 22 | 1. `signinSilent()` must be used to start the flow. 23 | 2. The refreshed access token is now accessible via `getUser()?.access_token` and inserted into the requests to your protected API. 24 | -------------------------------------------------------------------------------- /docs/protocols/resource-owner-password-credentials-grant.md: -------------------------------------------------------------------------------- 1 | # Resource Owner Password Credentials (ROPC) Grant 2 | 3 | This protocol is part of OAuth 2.0 (defined in [OAuth 2.0 RFC 6749, section 4.3](https://www.rfc-editor.org/rfc/rfc6749#section-4.3)). 4 | 5 | **NOTE**
6 | It implies some security risks, so you should only use it after a security assessment. 7 | 8 | 9 | ## Security concerns 10 | 11 | To start with, this flow is not part of the [OpenID Connect standard](https://openid.net/specs/openid-connect-core-1_0.html). Furthermore, although it is part of OAuth 2.0, it has been removed in the [OAuth 2.1 draft](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-07#section-10), and there are good reasons for this: 12 | 13 | * When using this flow, the credentials of the user are exposed to the client application. The RFC mandates that ["the client application MUST discard the credentials once the access token has been obtained"](https://www.rfc-editor.org/rfc/rfc6749#section-4.3.1), but there is no technical way for the Identity Provider / Authorization Server to enforce this point. This flow MUST NOT be allowed unless the Client can be enforced to fulfill this requirement through other means (probably because both are under the same security domain, probably the same organization or similar constraints). This point is covered [several](https://www.rfc-editor.org/rfc/rfc6749#section-1.3.3) [times](https://www.rfc-editor.org/rfc/rfc6749#section-4.3) during the RFC and by some IdP implementations, such as [Auth0](https://auth0.com/docs/get-started/authentication-and-authorization-flow/resource-owner-password-flow) or [Keycloak](https://www.keycloak.org/docs/latest/securing_apps/#_resource_owner_password_credentials_flow), but it is sometimes quickly ignored by some developers. 14 | 15 | * Even if the previous point is covered, this flow is increasing the attack surface of the system. For example, if the Client Application is compromised (maybe through an XSS attack, for example), the credentials of the user are exposed, so the attacker have access to other applications accessible by the user. In comparison, for example, in the Authorization Code Flow if the Client Application is equally compromised only the access token is exposed, usually meaning that only the given application has been compromised. A different way to see this is: usually the IdP/Authorization Server are strongly protected, and the Client Applications are allowed lower levels of security auditing... but when using this flow, if any of them is exposed, the user credentials are exposed; so both of them should be equally treated regarding security. 16 | 17 | Therefore, this flow MUST NOT be used as a replacement for the Authorization Code flow. This flow can only be seen as a replacement of classic form-based user/password authentication directly in the application. 18 | 19 | Then, why are we adding this support in `oidc-client-ts`? Well... form-based user/password authentication is actually widely used in the industry, and using a standard IdP as authenticator for this architecture has some benefits (other things, such as password expiration, user management back-office, etc are provided for free by the IdP). So this flow can be an easy help in this scenario. But you MUST NOT use this flow believing that you are having all the security benefits of OpenID Connect or OAuth; you are not. 20 | 21 | 22 | ## Principle of function 23 | ```mermaid 24 | --- 25 | title: Resource Owner Password Credentials (ROPC) Grant 26 | --- 27 | sequenceDiagram 28 | actor User 29 | User->>App: Click sign-in link (1) 30 | 31 | activate App 32 | App->>Identity Provider: Authenticate with username and password (2) 33 | Note right of Identity Provider: Validate username &
password 34 | Identity Provider->>App: Access token and ID token (2) 35 | deactivate App 36 | 37 | App->>Your API: Request protected data with access token (3) 38 | ``` 39 | 40 | 1. The user clicks sign-in within the application. 41 | 2. `signinResourceOwnerCredentials()` must be used to start the flow. 42 | 3. The access token is now accessible via `getUser()?.access_token` and inserted into the requests to your protected API. 43 | -------------------------------------------------------------------------------- /docs/protocols/silent-refresh-token-in-iframe-flow.md: -------------------------------------------------------------------------------- 1 | # Silent Refresh Token in iframe Flow 2 | 3 | This flow is using the OAuth2.0 [Authorization Code Grant with Proof Key for Code Exchange (PKCE)](https://github.com/authts/oidc-client-ts/blob/main/docs/protocols/authorization-code-grant-with-pkce.md) or [Authorization Code Grant](https://github.com/authts/oidc-client-ts/blob/main/docs/protocols/authorization-code-grant.md) grants. 4 | 5 | Difference: To silently refresh the token, the server callback is handled in a hidden iframe and not in the main browsing window. 6 | 7 | Running this flow in an iframe succeeds when the user has an authenticated session with the identity provider. 8 | The identity provider is storing a session cookie during the initial authentication. This cookie must be accessible for this flow to work. 9 | 10 | 11 | ## Principle of function 12 | ```mermaid 13 | --- 14 | title: Silent Refresh Token in iframe Flow 15 | --- 16 | sequenceDiagram 17 | App->>Hidden iframe: Load silent
Authorization Code Grant
in an iframe (1) 18 | 19 | activate Hidden iframe 20 | Note right of Hidden iframe: PKCE: Generate code_verifier and
code_challenge 21 | Hidden iframe->>Identity Provider: Authorization code request (1) 22 | deactivate Hidden iframe 23 | Note right of Hidden iframe: PKCE: with code_challenge 24 | Note right of Identity Provider: Validate session cookie 25 | 26 | Identity Provider->>Hidden iframe: Authorization code (2) 27 | activate Hidden iframe 28 | Hidden iframe->>App: Notify parent window (3) 29 | deactivate Hidden iframe 30 | 31 | activate App 32 | App->>Identity Provider: Authorization code & code verifier or client secret (3) 33 | Note right of Identity Provider: Validate authorization code &
code verifier or client secret 34 | Identity Provider->>App: Access token and ID token (3) 35 | deactivate App 36 | 37 | App->>Your API: Request protected data with refreshed access token (4) 38 | ``` 39 | 40 | 1. `signinSilent()` must be used to start the flow. 41 | 2. The identity provider knows the user already by using the session cookie and redirects the user back to the application with an authorization code. 42 | 3. `signinCallback()` handles this callback by sending this authorization code and code_verifier (PKCE) or client secret to the identity provider and receiving in return the access token and ID token. 43 | 4. The access token is now accessible via `getUser()?.access_token` and inserted into the requests to your protected API. 44 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import stylistic from "@stylistic/eslint-plugin"; 2 | import typescriptEslint from "@typescript-eslint/eslint-plugin"; 3 | import testingLibrary from "eslint-plugin-testing-library"; 4 | import globals from "globals"; 5 | import path from "node:path"; 6 | import { fileURLToPath } from "node:url"; 7 | import js from "@eslint/js"; 8 | import { FlatCompat } from "@eslint/eslintrc"; 9 | 10 | const __filename = fileURLToPath(import.meta.url); 11 | const __dirname = path.dirname(__filename); 12 | const compat = new FlatCompat({ 13 | baseDirectory: __dirname, 14 | recommendedConfig: js.configs.recommended, 15 | allConfig: js.configs.all, 16 | }); 17 | 18 | export default [{ 19 | ignores: [ 20 | ".yarn/", 21 | ".pnp.*", 22 | "**/node_modules/", 23 | "**/dist/", 24 | "docs/pages/", 25 | "**/lib/", 26 | "**/samples/", 27 | ], 28 | }, ...compat.extends("eslint:recommended", "plugin:testing-library/dom"), { 29 | plugins: { 30 | "@typescript-eslint": typescriptEslint, 31 | "@stylistic": stylistic, 32 | "testing-library": testingLibrary, 33 | }, 34 | 35 | languageOptions: { 36 | globals: { 37 | ...globals.browser, 38 | ...globals.node, 39 | }, 40 | 41 | ecmaVersion: 2020, 42 | sourceType: "module", 43 | }, 44 | 45 | rules: { 46 | "comma-dangle": ["error", "always-multiline"], 47 | "consistent-return": "error", 48 | 49 | indent: ["error", 4, { 50 | SwitchCase: 1, 51 | }], 52 | 53 | quotes: "error", 54 | semi: "error", 55 | "keyword-spacing": "error", 56 | "space-before-blocks": "error", 57 | 58 | "no-multiple-empty-lines": ["error", { 59 | max: 1, 60 | maxBOF: 0, 61 | maxEOF: 0, 62 | }], 63 | 64 | "no-multi-spaces": "error", 65 | }, 66 | }, ...compat.extends( 67 | "plugin:@typescript-eslint/recommended", 68 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 69 | ).map(config => ({ 70 | ...config, 71 | files: ["**/*.ts", "**/*.tsx"], 72 | })), { 73 | files: ["**/*.ts", "**/*.tsx"], 74 | 75 | languageOptions: { 76 | parserOptions: { 77 | project: ["./tsconfig.json"], 78 | }, 79 | }, 80 | 81 | rules: { 82 | indent: "off", 83 | semi: "off", 84 | "no-return-await": "off", 85 | 86 | "@stylistic/indent": ["error", 4, { 87 | SwitchCase: 1, 88 | }], 89 | 90 | "@stylistic/member-delimiter-style": "error", 91 | "@stylistic/object-curly-spacing": ["error", "always"], 92 | "@stylistic/semi": "error", 93 | 94 | "@typescript-eslint/return-await": ["error", "always"], 95 | "@typescript-eslint/require-await": "off", 96 | "@typescript-eslint/no-unsafe-argument": "off", 97 | "@typescript-eslint/no-unsafe-assignment": "off", 98 | }, 99 | }, { 100 | files: ["src/**/*"], 101 | 102 | languageOptions: { 103 | globals: { 104 | ...Object.fromEntries(Object.entries(globals.node).map(([key]) => [key, "off"])), 105 | }, 106 | }, 107 | }, { 108 | files: ["src/**/*.test.*", "test/**/*"], 109 | 110 | languageOptions: { 111 | globals: { 112 | ...globals.jest, 113 | }, 114 | }, 115 | 116 | rules: { 117 | "@typescript-eslint/no-non-null-assertion": "off", 118 | "@typescript-eslint/unbound-method": "off", 119 | }, 120 | }]; 121 | -------------------------------------------------------------------------------- /jest-environment-jsdom.cjs: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { TextEncoder, TextDecoder } = require("util"); 4 | const { default: $JSDOMEnvironment, TestEnvironment } = require("jest-environment-jsdom"); 5 | const crypto = require("crypto"); 6 | 7 | Object.defineProperty(exports, "__esModule", { 8 | value: true, 9 | }); 10 | 11 | class JSDOMEnvironment extends $JSDOMEnvironment { 12 | constructor(...args) { 13 | const { global } = super(...args); 14 | // see https://github.com/jsdom/jsdom/issues/2524 15 | global.TextEncoder = TextEncoder; 16 | global.TextDecoder = TextDecoder; 17 | // see https://github.com/jestjs/jest/issues/9983 18 | global.Uint8Array = Uint8Array; 19 | global.crypto.subtle = crypto.subtle; 20 | global.crypto.randomUUID = crypto.randomUUID; 21 | // see https://github.com/dumbmatter/fakeIndexedDB#jsdom-often-used-with-jest 22 | global.structuredClone = structuredClone; 23 | global.BroadcastChannel = BroadcastChannel; 24 | } 25 | } 26 | 27 | exports.default = JSDOMEnvironment; 28 | exports.TestEnvironment = TestEnvironment === $JSDOMEnvironment ? 29 | JSDOMEnvironment : TestEnvironment; 30 | -------------------------------------------------------------------------------- /jest.config.mjs: -------------------------------------------------------------------------------- 1 | import yn from "yn"; 2 | 3 | const collectCoverage = yn(process.env.CI); 4 | 5 | export default { 6 | preset: "ts-jest", 7 | clearMocks: true, 8 | setupFilesAfterEnv: ["./test/setup.ts"], 9 | testMatch: ["**/{src,test}/**/*.test.ts"], 10 | testEnvironment: "./jest-environment-jsdom.cjs", 11 | collectCoverage, 12 | coverageReporters: collectCoverage ? ["lcov"] : ["lcov", "text"], 13 | moduleNameMapper: { 14 | "^jose": "jose", // map to jose cjs module otherwise jest breaks 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oidc-client-ts", 3 | "version": "3.3.0", 4 | "description": "OpenID Connect (OIDC) & OAuth2 client library", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/authts/oidc-client-ts.git" 8 | }, 9 | "homepage": "https://github.com/authts/oidc-client-ts#readme", 10 | "license": "Apache-2.0", 11 | "main": "dist/umd/oidc-client-ts.js", 12 | "types": "dist/types/oidc-client-ts.d.ts", 13 | "exports": { 14 | ".": { 15 | "types": "./dist/types/oidc-client-ts.d.ts", 16 | "import": "./dist/esm/oidc-client-ts.js", 17 | "require": "./dist/umd/oidc-client-ts.js" 18 | }, 19 | "./package.json": "./package.json" 20 | }, 21 | "files": [ 22 | "dist" 23 | ], 24 | "keywords": [ 25 | "authentication", 26 | "oauth2", 27 | "oidc", 28 | "openid", 29 | "OpenID Connect" 30 | ], 31 | "scripts": { 32 | "build": "node scripts/build.js && npm run build-types", 33 | "build-types": "tsc -p tsconfig.build.json && api-extractor run", 34 | "clean": "git clean -fdX dist lib *.tsbuildinfo", 35 | "prepack": "npm run build", 36 | "test": "tsc && jest", 37 | "typedoc": "typedoc", 38 | "lint": "eslint --max-warnings=0 --cache .", 39 | "prepare": "husky" 40 | }, 41 | "dependencies": { 42 | "jwt-decode": "^4.0.0" 43 | }, 44 | "devDependencies": { 45 | "@eslint/eslintrc": "^3.2.0", 46 | "@eslint/js": "^9.18.0", 47 | "@microsoft/api-extractor": "^7.49.1", 48 | "@stylistic/eslint-plugin": "^2.13.0", 49 | "@testing-library/jest-dom": "^6.6.3", 50 | "@types/jest": "^29.5.14", 51 | "@types/node": "^22.10.1", 52 | "@typescript-eslint/eslint-plugin": "^8.20.0", 53 | "@typescript-eslint/parser": "^8.20.0", 54 | "esbuild": "^0.25.0", 55 | "eslint": "^9.18.0", 56 | "eslint-plugin-testing-library": "^7.1.1", 57 | "fake-indexeddb": "^6.0.0", 58 | "globals": "^16.0.0", 59 | "http-proxy-middleware": "^3.0.3", 60 | "husky": "^9.1.7", 61 | "jest": "^29.7.0", 62 | "jest-environment-jsdom": "^29.7.0", 63 | "jest-mock": "^29.7.0", 64 | "jose": "^5.9.6", 65 | "lint-staged": "^16.1.0", 66 | "ts-jest": "^29.2.5", 67 | "typedoc": "^0.28.0", 68 | "typescript": "~5.8.2", 69 | "yn": "^5.0.0" 70 | }, 71 | "engines": { 72 | "node": ">=18" 73 | }, 74 | "lint-staged": { 75 | "*.{js,jsx,ts,tsx}": "eslint --cache --fix" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /samples/Parcel/.proxyrc.js: -------------------------------------------------------------------------------- 1 | const { createProxyMiddleware } = require("http-proxy-middleware"); 2 | 3 | module.exports = function (app) { 4 | app.use( 5 | "/oidc/", 6 | createProxyMiddleware({ 7 | target: "http://localhost:15000/", 8 | }) 9 | ); 10 | 11 | app.use('/code-flow-duendesoftware', (req, res, next) => { 12 | res.setHeader('Cross-Origin-Opener-Policy', 'unsafe-none'); 13 | res.setHeader('Cross-Origin-Embedder-Policy', 'unsafe-none'); 14 | next(); 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /samples/Parcel/oidc.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. 2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. 3 | 4 | var jsrsasign = require("jsrsasign"); 5 | var rsaKey = jsrsasign.KEYUTIL.generateKeypair("RSA", 1024); 6 | var e = jsrsasign.hextob64u(rsaKey.pubKeyObj.e.toString(16)); 7 | var n = jsrsasign.hextob64u(rsaKey.pubKeyObj.n.toString(16)); 8 | 9 | var path = "/oidc"; 10 | var metadataPath = path + "/.well-known/openid-configuration"; 11 | var signingKeysPath = path + "/.well-known/jwks"; 12 | var authorizationPath = path + "/connect/authorize"; 13 | var userInfoPath = path + "/connect/userinfo"; 14 | var endSessionPath = path + "/connect/endsession"; 15 | var tokenPath = path + "/connect/token"; 16 | 17 | var metadata = { 18 | issuer: path, 19 | jwks_uri: signingKeysPath, 20 | authorization_endpoint: authorizationPath, 21 | userinfo_endpoint: userInfoPath, 22 | end_session_endpoint: endSessionPath, 23 | token_endpoint: tokenPath, 24 | }; 25 | 26 | function prependBaseUrlToMetadata(baseUrl) { 27 | for (var name in metadata) { 28 | metadata[name] = baseUrl + metadata[name]; 29 | } 30 | } 31 | 32 | function encodeBase64Url(str) { 33 | return Buffer.from(str).toString('base64') 34 | .replace(/=/g, '') 35 | .replace(/\+/g, '-') 36 | .replace(/\//g, '_'); 37 | } 38 | 39 | var keys = { 40 | keys: [ 41 | { 42 | kty: "RSA", 43 | use: "sig", 44 | kid: "1", 45 | e: e, 46 | n: n 47 | } 48 | ] 49 | }; 50 | 51 | var claims = { 52 | "sub": "818727", 53 | "email": "AliceSmith@email.com", 54 | "email_verified": true, 55 | "role": ["Admin", "Geek"] 56 | }; 57 | 58 | module.exports = function(baseUrl, app) { 59 | prependBaseUrlToMetadata(baseUrl); 60 | 61 | app.get(metadataPath, function(req, res) { 62 | //res.send("

not json...

"); return; 63 | res.json(metadata); 64 | }); 65 | 66 | app.get(signingKeysPath, function(req, res) { 67 | res.json(keys); 68 | }); 69 | 70 | app.get(authorizationPath, function(req, res) { 71 | var url = new URL(req.query.redirect_uri); 72 | const paramsKey = req.query.response_mode === "fragment" ? "hash" : "search"; 73 | const params = new URLSearchParams(url[paramsKey].slice(1)); 74 | var state = req.query.state; 75 | if (state) { 76 | params.append('state', state); 77 | if (req.query.code_challenge) { 78 | params.append("code", "foo"); 79 | } 80 | } 81 | //params.append("error", "bad_stuff"); 82 | url[paramsKey] = params.toString(); 83 | 84 | if (req.query.display === 'popup') { 85 | res.status(200); 86 | res.type('text/html') 87 | res.send(` 88 | 89 | 90 | 91 | 92 | 93 |

Redirecting in 3 seconds...

94 | 95 | `) 96 | } else { 97 | res.redirect(url.href); 98 | } 99 | 100 | }); 101 | 102 | app.get(userInfoPath, function(req, res) { 103 | res.json(claims); 104 | }); 105 | 106 | app.get(endSessionPath, function(req, res) { 107 | var url = req.query.post_logout_redirect_uri; 108 | if (url) { 109 | var state = req.query.state; 110 | if (state) { 111 | url += "?state=" + state; 112 | } 113 | res.redirect(url); 114 | } 115 | else { 116 | res.send("logged out"); 117 | } 118 | }); 119 | 120 | app.post(tokenPath, function(req, res) { 121 | res.json({ 122 | access_token: 'foobar', 123 | token_type: 'Bearer', 124 | id_token: [{ alg: 'none' }, claims] 125 | .map(obj => JSON.stringify(obj)) 126 | .map(encodeBase64Url).join('.') + '.', 127 | }); 128 | }); 129 | 130 | }; 131 | -------------------------------------------------------------------------------- /samples/Parcel/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "parcel-sample", 3 | "private": true, 4 | "scripts": { 5 | "start": "concurrently --kill-others \"npm run serve\" \"npm run server\"", 6 | "serve": "parcel serve ./src/index.html", 7 | "build": "parcel build ./src/index.html", 8 | "server": "node ./server.js" 9 | }, 10 | "dependencies": { 11 | "express": "^5.1.0", 12 | "jsrsasign": "^11.1.0" 13 | }, 14 | "devDependencies": { 15 | "concurrently": "^9.2.1", 16 | "parcel": "^2.15.4" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /samples/Parcel/server.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. 2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. 3 | var port = 15000; 4 | var url = "http://localhost:" + 1234; 5 | 6 | var express = require("express"); 7 | var app = express(); 8 | 9 | var oidc = require("./oidc"); 10 | oidc(url, app); 11 | 12 | console.log("listening on " + url); 13 | app.listen(port); 14 | -------------------------------------------------------------------------------- /samples/Parcel/src/app.css: -------------------------------------------------------------------------------- 1 | pre { 2 | padding:10px; 3 | width: 100%; 4 | background-color: lightgray; 5 | } -------------------------------------------------------------------------------- /samples/Parcel/src/code-flow-duendesoftware/sample-callback.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | oidc-client test 5 | 6 | 7 | 8 |
9 | back to sample 10 |
11 |

12 |         
13 | 
14 |     
15 | 
16 | 


--------------------------------------------------------------------------------
/samples/Parcel/src/code-flow-duendesoftware/sample-callback.js:
--------------------------------------------------------------------------------
 1 | import { UserManager, settings } from "./sample-settings";
 2 | import { log } from "./sample";
 3 | 
 4 | new UserManager(settings).signinCallback().then(function(user) {
 5 |     log("signin response success", user);
 6 | }).catch(function(err) {
 7 |     console.error(err);
 8 |     log(err);
 9 | });
10 | 


--------------------------------------------------------------------------------
/samples/Parcel/src/code-flow-duendesoftware/sample-popup-signin.html:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 | 
 4 | 
 5 |     duendesoftware test
 6 |     
 7 | 
 8 | 
 9 | 
10 |     
11 | 
12 | 
13 |     
14 | 
15 | 
16 | 
17 | 
18 | 


--------------------------------------------------------------------------------
/samples/Parcel/src/code-flow-duendesoftware/sample-popup-signin.js:
--------------------------------------------------------------------------------
 1 | import { UserManager, settings } from "./sample-settings";
 2 | import { log } from "./sample";
 3 | 
 4 | new UserManager(settings).signinCallback().then(function(user) {
 5 |     log("signin response success", user);
 6 | }).catch(function(err) {
 7 |     console.error(err);
 8 |     log(err);
 9 | });
10 | 


--------------------------------------------------------------------------------
/samples/Parcel/src/code-flow-duendesoftware/sample-popup-signout.html:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 | 
 4 | 
 5 |     duendesoftware test
 6 |     
 7 | 
 8 | 
 9 | 
10 |     

Signed Out

11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /samples/Parcel/src/code-flow-duendesoftware/sample-popup-signout.js: -------------------------------------------------------------------------------- 1 | import { UserManager, settings } from "./sample-settings"; 2 | import { log } from "./sample"; 3 | 4 | // can pass true param and will keep popup window open 5 | new UserManager(settings).signoutCallback().then(function() { 6 | log("signout callback response success"); 7 | }).catch(function(err) { 8 | console.error(err); 9 | log(err); 10 | }); 11 | -------------------------------------------------------------------------------- /samples/Parcel/src/code-flow-duendesoftware/sample-settings.js: -------------------------------------------------------------------------------- 1 | import { Log, UserManager } from "../../../../src"; 2 | 3 | Log.setLogger(console); 4 | Log.setLevel(Log.INFO); 5 | 6 | const url = window.location.origin + "/code-flow-duendesoftware"; 7 | 8 | export const settings = { 9 | authority: "https://demo.duendesoftware.com", 10 | client_id: "interactive.public", 11 | //client_id: 'interactive.public.short', 12 | redirect_uri: url + "/sample.html", 13 | post_logout_redirect_uri: url + "/sample.html", 14 | response_type: "code", 15 | //response_mode: 'fragment', 16 | scope: "openid profile api", 17 | //scope: 'openid profile api offline_access', 18 | 19 | popup_redirect_uri: url + "/sample-popup-signin.html", 20 | popup_post_logout_redirect_uri: url + "/sample-popup-signout.html", 21 | 22 | silent_redirect_uri: url + "/sample-silent.html", 23 | automaticSilentRenew: false, 24 | validateSubOnSilentRenew: true, 25 | //silentRequestTimeout: 10000, 26 | 27 | loadUserInfo: true, 28 | 29 | monitorAnonymousSession: true, 30 | 31 | filterProtocolClaims: true, 32 | revokeAccessTokenOnSignout: true, 33 | 34 | //metadata: {"issuer":"https://demo.duendesoftware.com","jwks_uri":"https://demo.duendesoftware.com/.well-known/openid-configuration/jwks","authorization_endpoint":"https://demo.duendesoftware.com/connect/authorize","token_endpoint":"https://demo.duendesoftware.com/connect/token","userinfo_endpoint":"https://demo.duendesoftware.com/connect/userinfo","end_session_endpoint":"https://demo.duendesoftware.com/connect/endsession","check_session_iframe":"https://demo.duendesoftware.com/connect/checksession","revocation_endpoint":"https://demo.duendesoftware.com/connect/revocation","introspection_endpoint":"https://demo.duendesoftware.com/connect/introspect","device_authorization_endpoint":"https://demo.duendesoftware.com/connect/deviceauthorization","frontchannel_logout_supported":true,"frontchannel_logout_session_supported":true,"backchannel_logout_supported":true,"backchannel_logout_session_supported":true,"scopes_supported":["openid","profile","email","api","api.scope1","api.scope2","scope2","policyserver.runtime","policyserver.management","offline_access"],"claims_supported":["sub","name","family_name","given_name","middle_name","nickname","preferred_username","profile","picture","website","gender","birthdate","zoneinfo","locale","updated_at","email","email_verified"],"grant_types_supported":["authorization_code","client_credentials","refresh_token","implicit","password","urn:ietf:params:oauth:grant-type:device_code"],"response_types_supported":["code","token","id_token","id_token token","code id_token","code token","code id_token token"],"response_modes_supported":["form_post","query","fragment"],"token_endpoint_auth_methods_supported":["client_secret_basic","client_secret_post"],"id_token_signing_alg_values_supported":["RS256"],"subject_types_supported":["public"],"code_challenge_methods_supported":["plain","S256"],"request_parameter_supported":true}, 35 | //metadataSeed: {"some_extra_data":"some_value"}, 36 | //signingKeys:[{"kty":"RSA","use":"sig","kid":"5CCAA03EDDE26D53104CC35D0D4B299C","e":"AQAB","n":"3fbgsZuL5Kp7HyliAznS6N0kTTAqApIzYqu0tORUk4T9m2f3uW5lDomNmwwPuZ3QDn0nwN3esx2NvZjL_g5DN407Pgl0ffHhARdtydJvdvNJIpW4CmyYGnI8H4ZdHtuW4wF8GbKadIGgwpI4UqcsHuPiWKARfWZMQfPKBT08SiIPwGncavlRRDgRVX1T94AgZE_fOTJ4Odko9RX9iNXghJIzJ_wEkY9GEkoHz5lQGdHYUplxOS6fcxL8j_N9urSBlnoYjPntBOwUfPsMoNcmIDXPARcq10miWTz8SHzUYRtsiSUMqimRJ9KdCucKcCmttB_p_EAWohJQDnav-Vqi3Q","alg":"RS256"}] 37 | }; 38 | 39 | export { 40 | Log, 41 | UserManager 42 | }; 43 | -------------------------------------------------------------------------------- /samples/Parcel/src/code-flow-duendesoftware/sample-silent.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | duendesoftware test 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /samples/Parcel/src/code-flow-duendesoftware/sample-silent.js: -------------------------------------------------------------------------------- 1 | import { UserManager, settings } from "./sample-settings"; 2 | import { log } from "./sample"; 3 | 4 | new UserManager(settings).signinCallback().then(function(user) { 5 | log("signin callback response success", user); 6 | }).catch(function(err) { 7 | console.error(err); 8 | log(err); 9 | }); 10 | -------------------------------------------------------------------------------- /samples/Parcel/src/code-flow-duendesoftware/sample.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | duendesoftware test against https://demo.duendesoftware.com/ 5 | 6 | 7 | 8 |
9 | home 10 | clear url 11 |
12 |
13 | 14 | 15 | 16 | 17 | 18 |
19 |
20 | 21 | 22 |
23 |
24 | 25 |
26 |
27 | 28 | 29 |
30 |
31 | 32 | 33 |
34 | 35 |

36 | 
37 | 
38 |         a
39 |         a
40 |         a
41 |         a
42 | 
43 | 
44 |         
45 | 
46 |     
47 | 
48 | 


--------------------------------------------------------------------------------
/samples/Parcel/src/index.html:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 |     
 4 |         oidc-client test
 5 |         
 6 |     
 7 |     
 8 |         

Local fake authority (proxy 15000)

9 | 13 | 14 |

DuendeSoftware demo

15 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /samples/Parcel/src/oidc-client/sample-callback.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | oidc-client test 5 | 6 | 7 | 8 |
9 | back to sample 10 |
11 |

12 |         
13 | 
14 |     
15 | 
16 | 


--------------------------------------------------------------------------------
/samples/Parcel/src/oidc-client/sample-callback.js:
--------------------------------------------------------------------------------
 1 | import { settings } from "./sample-settings";
 2 | import { log } from "./sample";
 3 | 
 4 | new OidcClient(settings).processSigninResponse().then(function(response) {
 5 |     log("signin response success", response);
 6 | }).catch(function(err) {
 7 |     console.error(err);
 8 |     log(err);
 9 | });
10 | 


--------------------------------------------------------------------------------
/samples/Parcel/src/oidc-client/sample-settings.js:
--------------------------------------------------------------------------------
 1 | import { Log, OidcClient } from "../../../../src";
 2 | 
 3 | Log.setLogger(console);
 4 | Log.setLevel(Log.INFO);
 5 | 
 6 | const url = window.location.origin + "/oidc-client";
 7 | 
 8 | export const settings = {
 9 |     authority: "http://localhost:1234/oidc",
10 |     client_id: "js.tokenmanager",
11 |     redirect_uri: url + "/sample.html",
12 |     post_logout_redirect_uri: url + "/sample.html",
13 |     response_type: "code",
14 |     scope: "openid email roles",
15 | 
16 |     response_mode: "fragment",
17 | 
18 |     filterProtocolClaims: true
19 | };
20 | 
21 | export {
22 |     Log,
23 |     OidcClient
24 | };
25 | 


--------------------------------------------------------------------------------
/samples/Parcel/src/oidc-client/sample.html:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 |     
 4 |         oidc-client test
 5 |         
 6 |     
 7 |     
 8 |         
9 | home 10 | clear url 11 | 14 | 15 | 16 | 17 | 18 |
19 | 20 |

21 | 
22 |         a
23 | 
24 |         
25 |     
26 | 
27 | 


--------------------------------------------------------------------------------
/samples/Parcel/src/oidc-client/sample.js:
--------------------------------------------------------------------------------
  1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
  2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
  3 | 
  4 | import { Log, OidcClient, settings } from "./sample-settings";
  5 | 
  6 | ///////////////////////////////
  7 | // UI event handlers
  8 | ///////////////////////////////
  9 | document.getElementById("signin").addEventListener("click", signin, false);
 10 | document.getElementById("processSignin").addEventListener("click", processSigninResponse, false);
 11 | document.getElementById("signout").addEventListener("click", signout, false);
 12 | document.getElementById("processSignout").addEventListener("click", processSignoutResponse, false);
 13 | document.getElementById("links").addEventListener("change", toggleLinks, false);
 14 | 
 15 | ///////////////////////////////
 16 | // OidcClient config
 17 | ///////////////////////////////
 18 | 
 19 | function log() {
 20 |     document.getElementById("out").innerText = "";
 21 | 
 22 |     Array.prototype.forEach.call(arguments, function(msg) {
 23 |         if (msg instanceof Error) {
 24 |             msg = "Error: " + msg.message;
 25 |         }
 26 |         else if (typeof msg !== "string") {
 27 |             msg = JSON.stringify(msg, null, 2);
 28 |         }
 29 |         document.getElementById("out").innerHTML += msg + "\r\n";
 30 |     });
 31 | }
 32 | 
 33 | var client = new OidcClient(settings);
 34 | 
 35 | ///////////////////////////////
 36 | // functions for UI elements
 37 | ///////////////////////////////
 38 | function signin() {
 39 |     var optionalArgs = { 
 40 |         state: { bar: 15 } // state is data that you want to roundtrip, it is not used by the protocol
 41 |     };
 42 |     client.createSigninRequest(optionalArgs).then(function(req) {
 43 |         log("signin request", req, "go signin");
 44 |         if (followLinks()) {
 45 |             window.location = req.url;
 46 |         }
 47 |     }).catch(function(err) {
 48 |         console.error(err);
 49 |         log(err);
 50 |     });
 51 | }
 52 | 
 53 | var signinResponse;
 54 | function processSigninResponse() {
 55 |     client.processSigninResponse(window.location.href).then(function(response) {
 56 |         signinResponse = response;
 57 |         log("signin response", signinResponse);
 58 |     }).catch(function(err) {
 59 |         console.error(err);
 60 |         log(err);
 61 |     });
 62 | }
 63 | 
 64 | function signout() {
 65 |     var optionalArgs = { 
 66 |         state: { bar: 15 }, 
 67 |         client_id: settings.client_id 
 68 |     };
 69 | 
 70 |     client.createSignoutRequest(optionalArgs).then(function(req) {
 71 |         log("signout request", req, "go signout");
 72 |         if (followLinks()) {
 73 |             window.location = req.url;
 74 |         }
 75 |     });
 76 | }
 77 | 
 78 | function processSignoutResponse() {
 79 |     client.processSignoutResponse(window.location.href).then(function(response) {
 80 |         signinResponse = null;
 81 |         log("signout response", response);
 82 |     }).catch(function(err) {
 83 |         console.error(err);
 84 |         log(err);
 85 |     });
 86 | }
 87 | 
 88 | function toggleLinks() {
 89 |     var val = document.getElementById("links").checked;
 90 |     localStorage.setItem("follow", val);
 91 | 
 92 |     var display = val ? "none" : "";
 93 | 
 94 |     document.getElementById("processSignin").style.display = display;
 95 |     document.getElementById("processSignout").style.display = display;
 96 | }
 97 | 
 98 | function followLinks() {
 99 |     return localStorage.getItem("follow") === "true";
100 | }
101 | 
102 | var follow = followLinks();
103 | var display = follow ? "none" : "";
104 | document.getElementById("links").checked = follow;
105 | document.getElementById("processSignin").style.display = display;
106 | document.getElementById("processSignout").style.display = display;
107 | 
108 | if (followLinks()) {
109 |     if (window.location.href.indexOf("#") >= 0) {
110 |         processSigninResponse();
111 |     }
112 |     else if (window.location.href.indexOf("?") >= 0) {
113 |         processSignoutResponse();
114 |     }
115 | }
116 | 
117 | export {
118 |     log
119 | };
120 | 


--------------------------------------------------------------------------------
/samples/Parcel/src/user-manager/sample-callback.html:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 |     
 4 |         oidc-client test
 5 |         
 6 |     
 7 |     
 8 |         
9 | back to sample 10 |
11 |

12 |         
13 |     
14 | 
15 | 


--------------------------------------------------------------------------------
/samples/Parcel/src/user-manager/sample-callback.js:
--------------------------------------------------------------------------------
 1 | import { UserManager, settings } from "./sample-settings";
 2 | import { log } from "./sample";
 3 | 
 4 | new UserManager(settings).signinRedirectCallback().then(function(user) {
 5 |     console.log("signin response success", user);
 6 | }).catch(function(err) {
 7 |     log(err);
 8 |     console.log(err);
 9 | });
10 | 


--------------------------------------------------------------------------------
/samples/Parcel/src/user-manager/sample-popup-signin.html:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 | 
 4 | 
 5 |     user-manager test
 6 |     
 7 | 
 8 | 
 9 | 
10 |     
11 | 
12 | 
13 | 
14 | 


--------------------------------------------------------------------------------
/samples/Parcel/src/user-manager/sample-popup-signin.js:
--------------------------------------------------------------------------------
 1 | import { Log, UserManager, settings } from "./sample-settings";
 2 | import { log } from "./sample";
 3 | 
 4 | new UserManager(settings).signinPopupCallback().then(function() {
 5 |     log("signin popup callback response success");
 6 | }).catch(function(err) {
 7 |     console.error(err);
 8 |     log(err);
 9 | });
10 | 


--------------------------------------------------------------------------------
/samples/Parcel/src/user-manager/sample-popup-signout.html:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 | 
 4 | 
 5 |     user-manager test
 6 |     
 7 | 
 8 | 
 9 | 
10 |     

Signed Out

11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /samples/Parcel/src/user-manager/sample-popup-signout.js: -------------------------------------------------------------------------------- 1 | import { UserManager, settings } from "./sample-settings"; 2 | import { log } from "./sample"; 3 | 4 | new UserManager(settings).signoutPopupCallback(undefined, true).then(function() { 5 | log("signout popup callback response success"); 6 | }).catch(function(err) { 7 | console.error(err); 8 | log(err); 9 | }); 10 | -------------------------------------------------------------------------------- /samples/Parcel/src/user-manager/sample-settings.js: -------------------------------------------------------------------------------- 1 | import { Log, UserManager} from "../../../../src"; 2 | 3 | Log.setLogger(console); 4 | Log.setLevel(Log.INFO); 5 | 6 | const url = window.location.origin + "/user-manager"; 7 | 8 | export const settings = { 9 | authority: "http://localhost:1234/oidc", 10 | client_id: "js.tokenmanager", 11 | redirect_uri: url + "/sample.html", 12 | post_logout_redirect_uri: url + "/sample.html", 13 | response_type: "code", 14 | scope: "openid email roles", 15 | 16 | response_mode: "fragment", 17 | 18 | popup_redirect_uri: url + "/sample-popup-signin.html", 19 | popup_post_logout_redirect_uri: url + "/sample-popup-signout.html", 20 | 21 | silent_redirect_uri: url + "/sample-silent.html", 22 | automaticSilentRenew: true, 23 | //silentRequestTimeout: 10000, 24 | 25 | filterProtocolClaims: true 26 | }; 27 | 28 | export { 29 | Log, 30 | UserManager 31 | }; 32 | -------------------------------------------------------------------------------- /samples/Parcel/src/user-manager/sample-silent.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | user-manager test 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /samples/Parcel/src/user-manager/sample-silent.js: -------------------------------------------------------------------------------- 1 | import { UserManager, settings } from "./sample-settings"; 2 | import { log } from "./sample"; 3 | 4 | new UserManager(settings).signinSilentCallback().then(function() { 5 | log("signin silent callback response success"); 6 | }).catch(function(err) { 7 | console.error(err); 8 | log(err); 9 | }); 10 | -------------------------------------------------------------------------------- /samples/Parcel/src/user-manager/sample.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | user-manager test 5 | 6 | 7 | 8 |
9 | home 10 | clear url 11 |
12 |
13 | 14 | 15 | 16 |
17 |
18 | 19 | 20 |
21 |
22 | 23 |
24 |
25 | 26 | 27 |
28 |
29 | 30 | 31 |
32 | 33 |

34 | 
35 |         a
36 |         a
37 |         a
38 |         a
39 | 
40 |         
41 | 
42 |     
43 | 
44 | 


--------------------------------------------------------------------------------
/samples/Parcel/src/user-manager/sample.js:
--------------------------------------------------------------------------------
  1 | 
  2 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
  3 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
  4 | 
  5 | import { UserManager, settings } from "./sample-settings";
  6 | 
  7 | ///////////////////////////////
  8 | // UI event handlers
  9 | ///////////////////////////////
 10 | document.getElementById("clearState").addEventListener("click", clearState, false);
 11 | document.getElementById("getUser").addEventListener("click", getUser, false);
 12 | document.getElementById("removeUser").addEventListener("click", removeUser, false);
 13 | 
 14 | document.getElementById("startSigninMainWindow").addEventListener("click", startSigninMainWindow, false);
 15 | document.getElementById("endSigninMainWindow").addEventListener("click", endSigninMainWindow, false);
 16 | 
 17 | document.getElementById("popupSignin").addEventListener("click", popupSignin, false);
 18 | document.getElementById("iframeSignin").addEventListener("click", iframeSignin, false);
 19 | 
 20 | document.getElementById("startSignoutMainWindow").addEventListener("click", startSignoutMainWindow, false);
 21 | document.getElementById("endSignoutMainWindow").addEventListener("click", endSignoutMainWindow, false);
 22 | 
 23 | document.getElementById("popupSignout").addEventListener("click", popupSignout, false);
 24 | 
 25 | ///////////////////////////////
 26 | // config
 27 | ///////////////////////////////
 28 | 
 29 | function log() {
 30 |     document.getElementById("out").innerText = "";
 31 | 
 32 |     Array.prototype.forEach.call(arguments, function(msg) {
 33 |         if (msg instanceof Error) {
 34 |             msg = "Error: " + msg.message;
 35 |         }
 36 |         else if (typeof msg !== "string") {
 37 |             msg = JSON.stringify(msg, null, 2);
 38 |         }
 39 |         document.getElementById("out").innerHTML += msg + "\r\n";
 40 |     });
 41 | }
 42 | 
 43 | const mgr = new UserManager(settings);
 44 | 
 45 | ///////////////////////////////
 46 | // events
 47 | ///////////////////////////////
 48 | mgr.events.addAccessTokenExpiring(function () {
 49 |     console.log("token expiring");
 50 |     log("token expiring");
 51 | });
 52 | 
 53 | mgr.events.addAccessTokenExpired(function () {
 54 |     console.log("token expired");
 55 |     log("token expired");
 56 | });
 57 | 
 58 | mgr.events.addSilentRenewError(function (e) {
 59 |     console.log("silent renew error", e.message);
 60 |     log("silent renew error", e.message);
 61 | });
 62 | 
 63 | mgr.events.addUserLoaded(function (user) {
 64 |     console.log("user loaded", user);
 65 |     mgr.getUser().then(function() {
 66 |         console.log("getUser loaded user after userLoaded event fired");
 67 |     }, () => {});
 68 | });
 69 | 
 70 | mgr.events.addUserUnloaded(function (e) {
 71 |     console.log("user unloaded");
 72 | });
 73 | 
 74 | ///////////////////////////////
 75 | // functions for UI elements
 76 | ///////////////////////////////
 77 | function clearState() {
 78 |     mgr.clearStaleState().then(function() {
 79 |         log("clearStateState success");
 80 |     }).catch(function(err) {
 81 |         console.error(err);
 82 |         log(err);
 83 |     });
 84 | }
 85 | 
 86 | function getUser() {
 87 |     mgr.getUser().then(function(user) {
 88 |         log("got user", user);
 89 |     }).catch(function(err) {
 90 |         console.error(err);
 91 |         log(err);
 92 |     });
 93 | }
 94 | 
 95 | function removeUser() {
 96 |     mgr.removeUser().then(function() {
 97 |         log("user removed");
 98 |     }).catch(function(err) {
 99 |         console.error(err);
100 |         log(err);
101 |     });
102 | }
103 | 
104 | function startSigninMainWindow() {
105 |     mgr.signinRedirect({ state: { some: "data" } }).then(function() {
106 |         log("signinRedirect done");
107 |     }).catch(function(err) {
108 |         console.error(err);
109 |         log(err);
110 |     });
111 | }
112 | 
113 | function endSigninMainWindow() {
114 |     mgr.signinRedirectCallback().then(function(user) {
115 |         log("signed in", user);
116 |     }).catch(function(err) {
117 |         console.error(err);
118 |         log(err);
119 |     });
120 | }
121 | 
122 | function popupSignin() {
123 |     mgr.signinPopup().then(function(user) {
124 |         log("signed in", user);
125 |     }).catch(function(err) {
126 |         console.error(err);
127 |         log(err);
128 |     });
129 | }
130 | 
131 | function popupSignout() {
132 |     mgr.signoutPopup().then(function() {
133 |         log("signed out");
134 |     }).catch(function(err) {
135 |         console.error(err);
136 |         log(err);
137 |     });
138 | }
139 | 
140 | function iframeSignin() {
141 |     mgr.signinSilent().then(function(user) {
142 |         log("signed in", user);
143 |     }).catch(function(err) {
144 |         console.error(err);
145 |         log(err);
146 |     });
147 | }
148 | 
149 | function startSignoutMainWindow() {
150 |     mgr.signoutRedirect().then(function(resp) {
151 |         log("signed out", resp);
152 |     }).catch(function(err) {
153 |         console.error(err);
154 |         log(err);
155 |     });
156 | }
157 | 
158 | function endSignoutMainWindow() {
159 |     mgr.signoutRedirectCallback().then(function(resp) {
160 |         log("signed out", resp);
161 |     }).catch(function(err) {
162 |         console.error(err);
163 |         log(err);
164 |     });
165 | }
166 | 
167 | export {
168 |     log
169 | };
170 | 


--------------------------------------------------------------------------------
/scripts/build.js:
--------------------------------------------------------------------------------
 1 | #!/usr/bin/env node
 2 | const { buildSync } = require("esbuild");
 3 | const { join } = require("path");
 4 | const fs = require("fs");
 5 | 
 6 | const { dependencies, peerDependencies, version } = require("../package.json");
 7 | 
 8 | // replace all comments (//... and /*...*/)
 9 | function readJson(path) {
10 |     let data = fs.readFileSync(path, { encoding: "utf-8" });
11 |     data = data.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, g) => (g ? "" : m));
12 |     const json = JSON.parse(data);
13 |     return json;
14 | }
15 | 
16 | const tsConfig = readJson(__dirname + "/../tsconfig.json");
17 | if (!tsConfig?.compilerOptions?.target) {
18 |     throw new Error("build target not defined");
19 | }
20 | const opts = {
21 |     entryPoints: ["src/index.ts"],
22 |     absWorkingDir: join(__dirname, ".."),
23 |     target: tsConfig.compilerOptions.target,
24 |     bundle: true,
25 |     sourcemap: true,
26 | };
27 | 
28 | const external = Object.keys({ ...dependencies, ...peerDependencies });
29 | 
30 | try {
31 |     // esm
32 |     buildSync({
33 |         ...opts,
34 |         platform: "neutral",
35 |         outfile: "dist/esm/oidc-client-ts.js",
36 |         external,
37 |     });
38 |     // generate package.json for esm
39 |     const distPackageJson = { type: "module" , version };
40 |     fs.writeFileSync("dist/esm/package.json", JSON.stringify(distPackageJson, null, 2) + "\n");
41 | 
42 |     // node
43 |     buildSync({
44 |         ...opts,
45 |         platform: "node",
46 |         outfile: "dist/umd/oidc-client-ts.js",
47 |         external,
48 |     });
49 | 
50 |     // browser (self contained)
51 |     buildSync({
52 |         ...opts,
53 |         platform: "browser",
54 |         outfile: "dist/browser/oidc-client-ts.js",
55 |         globalName: "oidc",
56 |     });
57 |     // browser-min (self contained)
58 |     buildSync({
59 |         ...opts,
60 |         platform: "browser",
61 |         outfile: "dist/browser/oidc-client-ts.min.js",
62 |         globalName: "oidc",
63 |         minify: true,
64 |     });
65 | } catch {
66 |     // esbuild handles error reporting
67 |     process.exitCode = 1;
68 | }
69 | 


--------------------------------------------------------------------------------
/src/AccessTokenEvents.test.ts:
--------------------------------------------------------------------------------
  1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
  2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
  3 | 
  4 | import { Timer } from "./utils";
  5 | import { AccessTokenEvents } from "./AccessTokenEvents";
  6 | import type { User } from "./User";
  7 | 
  8 | describe("AccessTokenEvents", () => {
  9 | 
 10 |     let subject: AccessTokenEvents;
 11 |     let expiringTimer: StubTimer;
 12 |     let expiredTimer: StubTimer;
 13 | 
 14 |     beforeEach(() => {
 15 |         expiringTimer = new StubTimer("stub expiring timer");
 16 |         expiredTimer = new StubTimer("stub expired timer");
 17 | 
 18 |         subject = new AccessTokenEvents({ expiringNotificationTimeInSeconds: 60 });
 19 | 
 20 |         // access private members
 21 |         Object.assign(subject, {
 22 |             _expiringTimer: expiringTimer,
 23 |             _expiredTimer: expiredTimer,
 24 |         });
 25 |     });
 26 | 
 27 |     describe("constructor", () => {
 28 | 
 29 |         it("should use default expiringNotificationTime", () => {
 30 |             expect(subject["_expiringNotificationTimeInSeconds"]).toEqual(60);
 31 |         });
 32 | 
 33 |     });
 34 | 
 35 |     describe("load", () => {
 36 | 
 37 |         it("should cancel existing timers", async () => {
 38 |             // act
 39 |             await subject.load({} as User);
 40 | 
 41 |             // assert
 42 |             expect(expiringTimer.cancelWasCalled).toEqual(true);
 43 |             expect(expiredTimer.cancelWasCalled).toEqual(true);
 44 |         });
 45 | 
 46 |         it("should initialize timers", async () => {
 47 |             // act
 48 |             await subject.load({
 49 |                 access_token:"token",
 50 |                 expires_in : 70,
 51 |             } as User);
 52 | 
 53 |             // assert
 54 |             expect(expiringTimer.duration).toEqual(10);
 55 |             expect(expiredTimer.duration).toEqual(71);
 56 |         });
 57 | 
 58 |         it("should immediately schedule expiring timer if expiration is soon", async () => {
 59 |             // act
 60 |             await subject.load({
 61 |                 access_token:"token",
 62 |                 expires_in : 10,
 63 |             } as User);
 64 | 
 65 |             // assert
 66 |             expect(expiringTimer.duration).toEqual(1);
 67 |         });
 68 | 
 69 |         it("should not initialize expiring timer if already expired", async () => {
 70 |             // act
 71 |             await subject.load({
 72 |                 access_token:"token",
 73 |                 expires_in : 0,
 74 |             } as User);
 75 | 
 76 |             // assert
 77 |             expect(expiringTimer.duration).toEqual(undefined);
 78 |         });
 79 | 
 80 |         it("should initialize expired timer if already expired", async () => {
 81 |             // act
 82 |             await subject.load({
 83 |                 access_token:"token",
 84 |                 expires_in : 0,
 85 |             } as User);
 86 | 
 87 |             // assert
 88 |             expect(expiredTimer.duration).toEqual(1);
 89 |         });
 90 | 
 91 |         it("should not initialize timers if no access token", async () => {
 92 |             // act
 93 |             await subject.load({
 94 |                 expires_in : 70,
 95 |             } as User);
 96 | 
 97 |             // assert
 98 |             expect(expiringTimer.duration).toEqual(undefined);
 99 |             expect(expiredTimer.duration).toEqual(undefined);
100 |         });
101 | 
102 |         it("should not initialize timers if no expiration on access token", async () => {
103 |             // act
104 |             await subject.load({
105 |                 access_token:"token",
106 |             } as User);
107 | 
108 |             // assert
109 |             expect(expiringTimer.duration).toEqual(undefined);
110 |             expect(expiredTimer.duration).toEqual(undefined);
111 |         });
112 |     });
113 | 
114 |     describe("unload", () => {
115 | 
116 |         it("should cancel timers", async () => {
117 |             // act
118 |             await subject.unload();
119 | 
120 |             // assert
121 |             expect(expiringTimer.cancelWasCalled).toEqual(true);
122 |             expect(expiredTimer.cancelWasCalled).toEqual(true);
123 |         });
124 |     });
125 | });
126 | 
127 | class StubTimer extends Timer {
128 |     cancelWasCalled: boolean;
129 |     duration: number | undefined;
130 | 
131 |     constructor(name: string) {
132 |         super(name);
133 |         this.cancelWasCalled = false;
134 |         this.duration = undefined;
135 |     }
136 | 
137 |     init(duration: number) {
138 |         this.duration = duration;
139 |     }
140 | 
141 |     cancel() {
142 |         this.cancelWasCalled = true;
143 |     }
144 | 
145 |     addHandler = jest.fn();
146 |     removeHandler = jest.fn();
147 | }
148 | 


--------------------------------------------------------------------------------
/src/AccessTokenEvents.ts:
--------------------------------------------------------------------------------
 1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
 2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 3 | 
 4 | import { Logger, Timer } from "./utils";
 5 | import type { User } from "./User";
 6 | 
 7 | /**
 8 |  * @public
 9 |  */
10 | export type AccessTokenCallback = (...ev: unknown[]) => (Promise | void);
11 | 
12 | /**
13 |  * @public
14 |  */
15 | export class AccessTokenEvents {
16 |     protected readonly _logger = new Logger("AccessTokenEvents");
17 | 
18 |     private readonly _expiringTimer = new Timer("Access token expiring");
19 |     private readonly _expiredTimer = new Timer("Access token expired");
20 |     private readonly _expiringNotificationTimeInSeconds: number;
21 | 
22 |     public constructor(args: { expiringNotificationTimeInSeconds: number }) {
23 |         this._expiringNotificationTimeInSeconds = args.expiringNotificationTimeInSeconds;
24 |     }
25 | 
26 |     public async load(container: User): Promise {
27 |         const logger = this._logger.create("load");
28 |         // only register events if there's an access token and it has an expiration
29 |         if (container.access_token && container.expires_in !== undefined) {
30 |             const duration = container.expires_in;
31 |             logger.debug("access token present, remaining duration:", duration);
32 | 
33 |             if (duration > 0) {
34 |                 // only register expiring if we still have time
35 |                 let expiring = duration - this._expiringNotificationTimeInSeconds;
36 |                 if (expiring <= 0) {
37 |                     expiring = 1;
38 |                 }
39 | 
40 |                 logger.debug("registering expiring timer, raising in", expiring, "seconds");
41 |                 this._expiringTimer.init(expiring);
42 |             }
43 |             else {
44 |                 logger.debug("canceling existing expiring timer because we're past expiration.");
45 |                 this._expiringTimer.cancel();
46 |             }
47 | 
48 |             // if it's negative, it will still fire
49 |             const expired = duration + 1;
50 |             logger.debug("registering expired timer, raising in", expired, "seconds");
51 |             this._expiredTimer.init(expired);
52 |         }
53 |         else {
54 |             this._expiringTimer.cancel();
55 |             this._expiredTimer.cancel();
56 |         }
57 |     }
58 | 
59 |     public async unload(): Promise {
60 |         this._logger.debug("unload: canceling existing access token timers");
61 |         this._expiringTimer.cancel();
62 |         this._expiredTimer.cancel();
63 |     }
64 | 
65 |     /**
66 |      * Add callback: Raised prior to the access token expiring.
67 |      */
68 |     public addAccessTokenExpiring(cb: AccessTokenCallback): () => void {
69 |         return this._expiringTimer.addHandler(cb);
70 |     }
71 |     /**
72 |      * Remove callback: Raised prior to the access token expiring.
73 |      */
74 |     public removeAccessTokenExpiring(cb: AccessTokenCallback): void {
75 |         this._expiringTimer.removeHandler(cb);
76 |     }
77 | 
78 |     /**
79 |      * Add callback: Raised after the access token has expired.
80 |      */
81 |     public addAccessTokenExpired(cb: AccessTokenCallback): () => void {
82 |         return this._expiredTimer.addHandler(cb);
83 |     }
84 |     /**
85 |      * Remove callback: Raised after the access token has expired.
86 |      */
87 |     public removeAccessTokenExpired(cb: AccessTokenCallback): void {
88 |         this._expiredTimer.removeHandler(cb);
89 |     }
90 | }
91 | 


--------------------------------------------------------------------------------
/src/AsyncStorage.ts:
--------------------------------------------------------------------------------
 1 | /**
 2 |  * @public
 3 |  */
 4 | export interface AsyncStorage {
 5 |     /** Returns the number of key/value pairs. */
 6 |     readonly length: Promise;
 7 |     /**
 8 |      * Removes all key/value pairs, if there are any.
 9 |      *
10 |      * Dispatches a storage event on Window objects holding an equivalent Storage object.
11 |      */
12 |     clear(): Promise;
13 |     /** Returns the current value associated with the given key, or null if the given key does not exist. */
14 |     getItem(key: string): Promise;
15 |     /** Returns the name of the nth key, or null if n is greater than or equal to the number of key/value pairs. */
16 |     key(index: number): Promise;
17 |     /**
18 |      * Removes the key/value pair with the given key, if a key/value pair with the given key exists.
19 |      *
20 |      * Dispatches a storage event on Window objects holding an equivalent Storage object.
21 |      */
22 |     removeItem(key: string): Promise;
23 |     /**
24 |      * Sets the value of the pair identified by key to value, creating a new key/value pair if none existed for key previously.
25 |      *
26 |      * Throws a "QuotaExceededError" DOMException exception if the new value couldn't be set. (Setting could fail if, e.g., the user has disabled storage for the site, or if the quota has been exceeded.)
27 |      *
28 |      * Dispatches a storage event on Window objects holding an equivalent Storage object.
29 |      */
30 |     setItem(key: string, value: string): Promise;
31 | }
32 | 


--------------------------------------------------------------------------------
/src/CheckSessionIFrame.ts:
--------------------------------------------------------------------------------
  1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
  2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
  3 | 
  4 | import { Logger } from "./utils";
  5 | 
  6 | /**
  7 |  * @internal
  8 |  */
  9 | export class CheckSessionIFrame {
 10 |     private readonly _logger = new Logger("CheckSessionIFrame");
 11 |     private _frame_origin: string;
 12 |     private _frame: HTMLIFrameElement;
 13 |     private _timer: ReturnType | null = null;
 14 |     private _session_state: string | null = null;
 15 | 
 16 |     public constructor(
 17 |         private _callback: () => Promise,
 18 |         private _client_id: string,
 19 |         url: string,
 20 |         private _intervalInSeconds: number,
 21 |         private _stopOnError: boolean,
 22 |     ) {
 23 |         const parsedUrl = new URL(url);
 24 |         this._frame_origin = parsedUrl.origin;
 25 | 
 26 |         this._frame = window.document.createElement("iframe");
 27 | 
 28 |         // shotgun approach
 29 |         this._frame.style.visibility = "hidden";
 30 |         this._frame.style.position = "fixed";
 31 |         this._frame.style.left = "-1000px";
 32 |         this._frame.style.top = "0";
 33 |         this._frame.width = "0";
 34 |         this._frame.height = "0";
 35 |         this._frame.src = parsedUrl.href;
 36 |     }
 37 | 
 38 |     public load(): Promise {
 39 |         return new Promise((resolve) => {
 40 |             this._frame.onload = () => {
 41 |                 resolve();
 42 |             };
 43 | 
 44 |             window.document.body.appendChild(this._frame);
 45 |             window.addEventListener("message", this._message, false);
 46 |         });
 47 |     }
 48 | 
 49 |     private _message = (e: MessageEvent): void => {
 50 |         if (e.origin === this._frame_origin &&
 51 |             e.source === this._frame.contentWindow
 52 |         ) {
 53 |             if (e.data === "error") {
 54 |                 this._logger.error("error message from check session op iframe");
 55 |                 if (this._stopOnError) {
 56 |                     this.stop();
 57 |                 }
 58 |             }
 59 |             else if (e.data === "changed") {
 60 |                 this._logger.debug("changed message from check session op iframe");
 61 |                 this.stop();
 62 |                 void this._callback();
 63 |             }
 64 |             else {
 65 |                 this._logger.debug(e.data + " message from check session op iframe");
 66 |             }
 67 |         }
 68 |     };
 69 | 
 70 |     public start(session_state: string): void {
 71 |         if (this._session_state === session_state) {
 72 |             return;
 73 |         }
 74 | 
 75 |         this._logger.create("start");
 76 | 
 77 |         this.stop();
 78 | 
 79 |         this._session_state = session_state;
 80 | 
 81 |         const send = () => {
 82 |             if (!this._frame.contentWindow || !this._session_state) {
 83 |                 return;
 84 |             }
 85 | 
 86 |             this._frame.contentWindow.postMessage(this._client_id + " " + this._session_state, this._frame_origin);
 87 |         };
 88 | 
 89 |         // trigger now
 90 |         send();
 91 | 
 92 |         // and setup timer
 93 |         this._timer = setInterval(send, this._intervalInSeconds * 1000);
 94 |     }
 95 | 
 96 |     public stop(): void {
 97 |         this._logger.create("stop");
 98 |         this._session_state = null;
 99 | 
100 |         if (this._timer) {
101 | 
102 |             clearInterval(this._timer);
103 |             this._timer = null;
104 |         }
105 |     }
106 | }
107 | 


--------------------------------------------------------------------------------
/src/ClaimsService.ts:
--------------------------------------------------------------------------------
 1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
 2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 3 | 
 4 | import type { JwtClaims } from "./Claims";
 5 | import type { OidcClientSettingsStore } from "./OidcClientSettings";
 6 | import type { UserProfile } from "./User";
 7 | import { Logger } from "./utils";
 8 | 
 9 | /**
10 |  * Protocol claims that could be removed by default from profile.
11 |  * Derived from the following sets of claims:
12 |  * - {@link https://datatracker.ietf.org/doc/html/rfc7519.html#section-4.1}
13 |  * - {@link https://openid.net/specs/openid-connect-core-1_0.html#IDToken}
14 |  * - {@link https://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken}
15 |  *
16 |  * @internal
17 |  */
18 | const DefaultProtocolClaims = [
19 |     "nbf",
20 |     "jti",
21 |     "auth_time",
22 |     "nonce",
23 |     "acr",
24 |     "amr",
25 |     "azp",
26 |     "at_hash", // https://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken
27 | ] as const;
28 | 
29 | /**
30 |  * Protocol claims that should never be removed from profile.
31 |  * "sub" is needed internally and others should remain required as per the OIDC specs.
32 |  *
33 |  * @internal
34 |  */
35 | const InternalRequiredProtocolClaims = ["sub", "iss", "aud", "exp", "iat"];
36 | 
37 | /**
38 |  * @internal
39 |  */
40 | export class ClaimsService {
41 |     protected readonly _logger = new Logger("ClaimsService");
42 |     public constructor(
43 |         protected readonly _settings: OidcClientSettingsStore,
44 |     ) {}
45 | 
46 |     public filterProtocolClaims(claims: UserProfile): UserProfile {
47 |         const result = { ...claims };
48 | 
49 |         if (this._settings.filterProtocolClaims) {
50 |             let protocolClaims;
51 |             if (Array.isArray(this._settings.filterProtocolClaims)) {
52 |                 protocolClaims = this._settings.filterProtocolClaims;
53 |             } else {
54 |                 protocolClaims = DefaultProtocolClaims;
55 |             }
56 | 
57 |             for (const claim of protocolClaims) {
58 |                 if (!InternalRequiredProtocolClaims.includes(claim)) {
59 |                     delete result[claim];
60 |                 }
61 |             }
62 |         }
63 | 
64 |         return result;
65 |     }
66 | 
67 |     public mergeClaims(claims1: JwtClaims, claims2: JwtClaims): UserProfile;
68 |     public mergeClaims(claims1: UserProfile, claims2: JwtClaims): UserProfile {
69 |         const result = { ...claims1 };
70 |         for (const [claim, values] of Object.entries(claims2)) {
71 |             if (result[claim] !== values) {
72 |                 if (Array.isArray(result[claim]) || Array.isArray(values)) {
73 |                     if (this._settings.mergeClaimsStrategy.array == "replace") {
74 |                         result[claim] = values;
75 |                     } else {
76 |                         const mergedValues = Array.isArray(result[claim]) ? result[claim] as unknown[] : [result[claim]];
77 |                         for (const value of Array.isArray(values) ? values : [values]) {
78 |                             if (!mergedValues.includes(value)) {
79 |                                 mergedValues.push(value);
80 |                             }
81 |                         }
82 |                         result[claim] = mergedValues;
83 |                     }
84 |                 } else if (typeof result[claim] === "object" && typeof values === "object") {
85 |                     result[claim] = this.mergeClaims(result[claim] as JwtClaims, values as JwtClaims);
86 |                 } else {
87 |                     result[claim] = values;
88 |                 }
89 |             }
90 |         }
91 | 
92 |         return result;
93 |     }
94 | }
95 | 


--------------------------------------------------------------------------------
/src/DPoPStore.ts:
--------------------------------------------------------------------------------
 1 | /**
 2 |  * @public
 3 |  */
 4 | export interface DPoPStore {
 5 |     set(key: string, value: DPoPState): Promise;
 6 |     get(key: string): Promise;
 7 |     remove(key: string): Promise;
 8 |     getAllKeys(): Promise;
 9 | }
10 | 
11 | /**
12 |  * @public
13 |  */
14 | export class DPoPState {
15 |     public constructor(
16 |         public readonly keys: CryptoKeyPair,
17 |         public nonce?: string,
18 |     ) { }
19 | }
20 | 


--------------------------------------------------------------------------------
/src/InMemoryWebStorage.ts:
--------------------------------------------------------------------------------
 1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
 2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 3 | 
 4 | import { Logger } from "./utils";
 5 | 
 6 | /**
 7 |  * @public
 8 |  */
 9 | export class InMemoryWebStorage implements Storage {
10 |     private readonly _logger = new Logger("InMemoryWebStorage");
11 |     private _data: Record = {};
12 | 
13 |     public clear(): void {
14 |         this._logger.create("clear");
15 |         this._data = {};
16 |     }
17 | 
18 |     public getItem(key: string): string {
19 |         this._logger.create(`getItem('${key}')`);
20 |         return this._data[key];
21 |     }
22 | 
23 |     public setItem(key: string, value: string): void {
24 |         this._logger.create(`setItem('${key}')`);
25 |         this._data[key] = value;
26 |     }
27 | 
28 |     public removeItem(key: string): void {
29 |         this._logger.create(`removeItem('${key}')`);
30 |         delete this._data[key];
31 |     }
32 | 
33 |     public get length(): number {
34 |         return Object.getOwnPropertyNames(this._data).length;
35 |     }
36 | 
37 |     public key(index: number): string {
38 |         return Object.getOwnPropertyNames(this._data)[index];
39 |     }
40 | }
41 | 


--------------------------------------------------------------------------------
/src/IndexedDbDPoPStore.test.ts:
--------------------------------------------------------------------------------
  1 | import { IndexedDbDPoPStore } from "./IndexedDbDPoPStore";
  2 | import { DPoPState } from "./DPoPStore";
  3 | 
  4 | describe("DPoPStore", () => {
  5 |     const subject = new IndexedDbDPoPStore();
  6 | 
  7 |     let data: DPoPState;
  8 | 
  9 |     const createCryptoKeyPair = async () => {
 10 |         return await window.crypto.subtle.generateKey(
 11 |             {
 12 |                 name: "ECDSA",
 13 |                 namedCurve: "P-256",
 14 |             },
 15 |             false,
 16 |             ["sign", "verify"],
 17 |         );
 18 |     };
 19 | 
 20 |     beforeEach(async () => {
 21 |         const crytoKeys = await createCryptoKeyPair();
 22 |         data = new DPoPState(crytoKeys);
 23 |     });
 24 | 
 25 |     describe("set", () => {
 26 |         it("should return a promise", async () => {
 27 |             // act
 28 |             const p = subject.set("key", data);
 29 | 
 30 |             // assert
 31 |             expect(p).toBeInstanceOf(Promise);
 32 |             // eslint-disable-next-line no-empty
 33 |             try { await p; } catch {}
 34 |         });
 35 | 
 36 |         it("should store a key in IndexedDB", async () => {
 37 |             await subject.set("foo", data);
 38 |             const result = await subject.get("foo");
 39 | 
 40 |             expect(result).toEqual(data);
 41 |         });
 42 | 
 43 |         it("should store a nonce if provided in IndexedDB", async () => {
 44 |             data = new DPoPState(data.keys, "some-nonce");
 45 |             await subject.set("foo", data);
 46 | 
 47 |             const result = await subject.get("foo");
 48 | 
 49 |             expect(result).toEqual(data);
 50 |             expect(result.nonce).toEqual("some-nonce");
 51 |         });
 52 |     });
 53 | 
 54 |     describe("remove", () => {
 55 |         it("should return a promise", async () => {
 56 |             // act
 57 |             const p = subject.remove("key");
 58 | 
 59 |             // assert
 60 |             expect(p).toBeInstanceOf(Promise);
 61 |             // eslint-disable-next-line no-empty
 62 |             try { await p; } catch {}
 63 |         });
 64 | 
 65 |         it("should remove a key from IndexedDB", async () => {
 66 |             await subject.set("foo", data);
 67 |             let result = await subject.get("foo");
 68 | 
 69 |             expect(result).toEqual(data);
 70 | 
 71 |             await subject.remove("foo");
 72 |             result = await subject.get("foo");
 73 |             expect(result).toBeUndefined();
 74 |         });
 75 | 
 76 |         it("should return a value if key exists", async () => {
 77 |             await subject.set("foo", data);
 78 |             const result = await subject.remove("foo");
 79 | 
 80 |             expect(result).toEqual(data);
 81 |         });
 82 |     });
 83 | 
 84 |     describe("getAllKeys", () => {
 85 |         it("should return a promise", async () => {
 86 |             // act
 87 |             const p = subject.getAllKeys();
 88 | 
 89 |             // assert
 90 |             expect(p).toBeInstanceOf(Promise);
 91 |             // eslint-disable-next-line no-empty
 92 |             try { await p; } catch {}
 93 |         });
 94 | 
 95 |         it("should get all keys in IndexedDB", async () => {
 96 |             await subject.set("foo", data);
 97 |             const crytoKeys = await createCryptoKeyPair();
 98 |             const dataTwo = new DPoPState(crytoKeys);
 99 |             await subject.set("boo", dataTwo);
100 | 
101 |             const result = await subject.getAllKeys();
102 |             expect(result.length).toEqual(2);
103 |             expect(result).toContain("foo");
104 |             expect(result).toContain("boo");
105 |         });
106 |     });
107 | });
108 | 


--------------------------------------------------------------------------------
/src/IndexedDbDPoPStore.ts:
--------------------------------------------------------------------------------
 1 | import { DPoPState, type DPoPStore } from "./DPoPStore";
 2 | 
 3 | /**
 4 |  * Provides a default implementation of the DPoP store using IndexedDB.
 5 |  *
 6 |  * @public
 7 |  */
 8 | export class IndexedDbDPoPStore implements DPoPStore {
 9 |     readonly _dbName: string = "oidc";
10 |     readonly _storeName: string = "dpop";
11 | 
12 |     public async set(key: string, value: DPoPState): Promise {
13 |         const store = await this.createStore(this._dbName, this._storeName);
14 |         await store("readwrite", (str: IDBObjectStore) => {
15 |             str.put(value, key);
16 |             return this.promisifyRequest(str.transaction);
17 |         });
18 |     }
19 | 
20 |     public async get(key: string): Promise {
21 |         const store = await this.createStore(this._dbName, this._storeName);
22 |         return await store("readonly", (str) => {
23 |             return this.promisifyRequest(str.get(key));
24 |         }) as DPoPState;
25 |     }
26 | 
27 |     public async remove(key: string): Promise {
28 |         const item = await this.get(key);
29 |         const store = await this.createStore(this._dbName, this._storeName);
30 |         await store("readwrite", (str) => {
31 |             return this.promisifyRequest(str.delete(key));
32 |         });
33 |         return item;
34 |     }
35 | 
36 |     public async getAllKeys(): Promise {
37 |         const store = await this.createStore(this._dbName, this._storeName);
38 |         return await store("readonly", (str) => {
39 |             return this.promisifyRequest(str.getAllKeys());
40 |         }) as string[];
41 |     }
42 | 
43 |     promisifyRequest(
44 |         request: IDBRequest | IDBTransaction): Promise {
45 |         return new Promise((resolve, reject) => {
46 |             (request as IDBTransaction).oncomplete = (request as IDBRequest).onsuccess = () => resolve((request as IDBRequest).result);
47 |             (request as IDBTransaction).onabort = (request as IDBRequest).onerror = () => reject((request as IDBRequest).error as Error);
48 |         });
49 |     }
50 | 
51 |     async createStore(
52 |         dbName: string,
53 |         storeName: string,
54 |     ): Promise<(txMode: IDBTransactionMode, callback: (store: IDBObjectStore) => T | PromiseLike) => Promise> {
55 |         const request = indexedDB.open(dbName);
56 |         request.onupgradeneeded = () => request.result.createObjectStore(storeName);
57 |         const db = await this.promisifyRequest(request);
58 | 
59 |         return async (
60 |             txMode: IDBTransactionMode,
61 |             callback: (store: IDBObjectStore) => T | PromiseLike,
62 |         ) => {
63 |             const tx = db.transaction(storeName, txMode);
64 |             const store = tx.objectStore(storeName);
65 |             return await callback(store);
66 |         };
67 |     }
68 | }
69 | 


--------------------------------------------------------------------------------
/src/RefreshState.ts:
--------------------------------------------------------------------------------
 1 | // Copyright (C) AuthTS Contributors
 2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 3 | 
 4 | import type { UserProfile } from "./User";
 5 | 
 6 | /**
 7 |  * Fake state store implementation necessary for validating refresh token requests.
 8 |  *
 9 |  * @public
10 |  */
11 | export class RefreshState {
12 |     /** custom "state", which can be used by a caller to have "data" round tripped */
13 |     public readonly data?: unknown;
14 | 
15 |     public readonly refresh_token: string;
16 |     public readonly id_token?: string;
17 |     public readonly session_state: string | null;
18 |     public readonly scope?: string;
19 |     public readonly profile: UserProfile;
20 | 
21 |     constructor(args: {
22 |         refresh_token: string;
23 |         id_token?: string;
24 |         session_state: string | null;
25 |         scope?: string;
26 |         profile: UserProfile;
27 | 
28 |         state?: unknown;
29 |     }) {
30 |         this.refresh_token = args.refresh_token;
31 |         this.id_token = args.id_token;
32 |         this.session_state = args.session_state;
33 |         this.scope = args.scope;
34 |         this.profile = args.profile;
35 | 
36 |         this.data = args.state;
37 | 
38 |     }
39 | }
40 | 


--------------------------------------------------------------------------------
/src/SessionStatus.ts:
--------------------------------------------------------------------------------
 1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
 2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 3 | 
 4 | /**
 5 |  * @public
 6 |  */
 7 | export interface SessionStatus {
 8 |     /** Opaque session state used to validate if session changed (monitorSession) */
 9 |     session_state: string;
10 |     /** Subject identifier */
11 |     sub?: string;
12 | }
13 | 


--------------------------------------------------------------------------------
/src/SigninRequest.ts:
--------------------------------------------------------------------------------
  1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
  2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
  3 | 
  4 | import { Logger, URL_STATE_DELIMITER } from "./utils";
  5 | import { SigninState } from "./SigninState";
  6 | 
  7 | /**
  8 |  * @public
  9 |  * @see https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
 10 |  */
 11 | export interface SigninRequestCreateArgs {
 12 |     // mandatory
 13 |     url: string;
 14 |     authority: string;
 15 |     client_id: string;
 16 |     redirect_uri: string;
 17 |     response_type: string;
 18 |     scope: string;
 19 | 
 20 |     // optional
 21 |     response_mode?: "query" | "fragment";
 22 |     nonce?: string;
 23 |     display?: string;
 24 |     dpopJkt?: string;
 25 |     prompt?: string;
 26 |     max_age?: number;
 27 |     ui_locales?: string;
 28 |     id_token_hint?: string;
 29 |     login_hint?: string;
 30 |     acr_values?: string;
 31 | 
 32 |     // other
 33 |     resource?: string | string[];
 34 |     request?: string;
 35 |     request_uri?: string;
 36 |     request_type?: string;
 37 |     extraQueryParams?: Record;
 38 | 
 39 |     // special
 40 |     extraTokenParams?: Record;
 41 |     client_secret?: string;
 42 |     skipUserInfo?: boolean;
 43 |     disablePKCE?: boolean;
 44 |     /** custom "state", which can be used by a caller to have "data" round tripped */
 45 |     state_data?: unknown;
 46 |     url_state?: string;
 47 |     omitScopeWhenRequesting?: boolean;
 48 | }
 49 | 
 50 | /**
 51 |  * @public
 52 |  */
 53 | export class SigninRequest {
 54 |     private static readonly _logger = new Logger("SigninRequest");
 55 | 
 56 |     public readonly url: string;
 57 |     public readonly state: SigninState;
 58 | 
 59 |     private constructor(args: {
 60 |         url: string;
 61 |         state: SigninState;
 62 |     }) {
 63 |         this.url = args.url;
 64 |         this.state = args.state;
 65 |     }
 66 | 
 67 |     public static async create({
 68 |         // mandatory
 69 |         url, authority, client_id, redirect_uri, response_type, scope,
 70 |         // optional
 71 |         state_data, response_mode, request_type, client_secret, nonce, url_state,
 72 |         resource,
 73 |         skipUserInfo,
 74 |         extraQueryParams,
 75 |         extraTokenParams,
 76 |         disablePKCE,
 77 |         dpopJkt,
 78 |         omitScopeWhenRequesting,
 79 |         ...optionalParams
 80 |     }: SigninRequestCreateArgs): Promise {
 81 |         if (!url) {
 82 |             this._logger.error("create: No url passed");
 83 |             throw new Error("url");
 84 |         }
 85 |         if (!client_id) {
 86 |             this._logger.error("create: No client_id passed");
 87 |             throw new Error("client_id");
 88 |         }
 89 |         if (!redirect_uri) {
 90 |             this._logger.error("create: No redirect_uri passed");
 91 |             throw new Error("redirect_uri");
 92 |         }
 93 |         if (!response_type) {
 94 |             this._logger.error("create: No response_type passed");
 95 |             throw new Error("response_type");
 96 |         }
 97 |         if (!scope) {
 98 |             this._logger.error("create: No scope passed");
 99 |             throw new Error("scope");
100 |         }
101 |         if (!authority) {
102 |             this._logger.error("create: No authority passed");
103 |             throw new Error("authority");
104 |         }
105 | 
106 |         const state = await SigninState.create({
107 |             data: state_data,
108 |             request_type,
109 |             url_state,
110 |             code_verifier: !disablePKCE,
111 |             client_id, authority, redirect_uri,
112 |             response_mode,
113 |             client_secret, scope, extraTokenParams,
114 |             skipUserInfo,
115 |         });
116 | 
117 |         const parsedUrl = new URL(url);
118 |         parsedUrl.searchParams.append("client_id", client_id);
119 |         parsedUrl.searchParams.append("redirect_uri", redirect_uri);
120 |         parsedUrl.searchParams.append("response_type", response_type);
121 |         if (!omitScopeWhenRequesting) {
122 |             parsedUrl.searchParams.append("scope", scope);
123 |         }
124 |         if (nonce) {
125 |             parsedUrl.searchParams.append("nonce", nonce);
126 |         }
127 | 
128 |         if (dpopJkt) {
129 |             parsedUrl.searchParams.append("dpop_jkt", dpopJkt);
130 |         }
131 | 
132 |         let stateParam = state.id;
133 |         if (url_state) {
134 |             stateParam = `${stateParam}${URL_STATE_DELIMITER}${url_state}`;
135 |         }
136 |         parsedUrl.searchParams.append("state", stateParam);
137 |         if (state.code_challenge) {
138 |             parsedUrl.searchParams.append("code_challenge", state.code_challenge);
139 |             parsedUrl.searchParams.append("code_challenge_method", "S256");
140 |         }
141 | 
142 |         if (resource) {
143 |             // https://datatracker.ietf.org/doc/html/rfc8707
144 |             const resources = Array.isArray(resource) ? resource : [resource];
145 |             resources
146 |                 .forEach(r => parsedUrl.searchParams.append("resource", r));
147 |         }
148 | 
149 |         for (const [key, value] of Object.entries({ response_mode, ...optionalParams, ...extraQueryParams })) {
150 |             if (value != null) {
151 |                 parsedUrl.searchParams.append(key, value.toString());
152 |             }
153 |         }
154 | 
155 |         return new SigninRequest({
156 |             url: parsedUrl.href,
157 |             state,
158 |         });
159 |     }
160 | }
161 | 


--------------------------------------------------------------------------------
/src/SigninResponse.test.ts:
--------------------------------------------------------------------------------
  1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
  2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
  3 | 
  4 | import { SigninResponse } from "./SigninResponse";
  5 | import { Timer } from "./utils";
  6 | 
  7 | describe("SigninResponse", () => {
  8 |     let now: number;
  9 | 
 10 |     beforeEach(() => {
 11 |         now = 0;
 12 |         jest.spyOn(Timer, "getEpochTime").mockImplementation(() => now);
 13 |     });
 14 | 
 15 |     afterEach(() => {
 16 |         jest.restoreAllMocks();
 17 |     });
 18 | 
 19 |     describe("constructor", () => {
 20 |         it("should read error", () => {
 21 |             // act
 22 |             const subject = new SigninResponse(new URLSearchParams("error=foo"));
 23 | 
 24 |             // assert
 25 |             expect(subject.error).toEqual("foo");
 26 |         });
 27 | 
 28 |         it("should read error_description", () => {
 29 |             // act
 30 |             const subject = new SigninResponse(new URLSearchParams("error_description=foo"));
 31 | 
 32 |             // assert
 33 |             expect(subject.error_description).toEqual("foo");
 34 |         });
 35 | 
 36 |         it("should read error_uri", () => {
 37 |             // act
 38 |             const subject = new SigninResponse(new URLSearchParams("error_uri=foo"));
 39 | 
 40 |             // assert
 41 |             expect(subject.error_uri).toEqual("foo");
 42 |         });
 43 | 
 44 |         it("should read state", () => {
 45 |             // act
 46 |             const subject = new SigninResponse(new URLSearchParams("state=foo"));
 47 | 
 48 |             // assert
 49 |             expect(subject.state).toEqual("foo");
 50 |         });
 51 | 
 52 |         it("should read url_state", () => {
 53 |             // act
 54 |             const subject = new SigninResponse(new URLSearchParams("state=foo;bar"));
 55 | 
 56 |             // assert
 57 |             expect(subject.state).toEqual("foo");
 58 |             expect(subject.url_state).toEqual("bar");
 59 |         });
 60 | 
 61 |         it("should return url_state that uses the delimiter unmodified", () => {
 62 |             // act
 63 |             const subject = new SigninResponse(new URLSearchParams("state=foo;bar;baz"));
 64 | 
 65 |             // assert
 66 |             expect(subject.state).toEqual("foo");
 67 |             expect(subject.url_state).toEqual("bar;baz");
 68 |         });
 69 | 
 70 |         it("should read code", () => {
 71 |             // act
 72 |             const subject = new SigninResponse(new URLSearchParams("code=foo"));
 73 | 
 74 |             // assert
 75 |             expect(subject.code).toEqual("foo");
 76 |         });
 77 | 
 78 |         it("should read session_state", () => {
 79 |             // act
 80 |             const subject = new SigninResponse(new URLSearchParams("session_state=foo"));
 81 | 
 82 |             // assert
 83 |             expect(subject.session_state).toEqual("foo");
 84 |         });
 85 | 
 86 |         it("should calculate expires_at", () => {
 87 |             // act
 88 |             const subject = new SigninResponse(new URLSearchParams());
 89 |             Object.assign(subject, { expires_in: 10 });
 90 | 
 91 |             // assert
 92 |             expect(subject.expires_at).toEqual(Timer.getEpochTime() + 10);
 93 |         });
 94 | 
 95 |         it.each([
 96 |             ["foo"],
 97 |             [-10],
 98 |         ])("should not read invalid expires_in", (expires_in) => {
 99 |             // act
100 |             const subject = new SigninResponse(new URLSearchParams());
101 |             Object.assign(subject, { expires_in });
102 | 
103 |             // assert
104 |             expect(subject.expires_in).toBeUndefined();
105 |             expect(subject.expires_at).toBeUndefined();
106 |         });
107 |     });
108 | 
109 |     describe("expires_in", () => {
110 |         it("should calculate how much time left", () => {
111 |             const subject = new SigninResponse(new URLSearchParams());
112 |             Object.assign(subject, { expires_in: 100 });
113 | 
114 |             // act
115 |             now += 50;
116 | 
117 |             // assert
118 |             expect(subject.expires_in).toEqual(50);
119 |         });
120 |     });
121 | 
122 |     describe("isOpenId", () => {
123 |         it("should detect openid scope", () => {
124 |             const subject = new SigninResponse(new URLSearchParams());
125 | 
126 |             // act
127 |             Object.assign(subject, { scope: "foo openid bar" });
128 | 
129 |             // assert
130 |             expect(subject.isOpenId).toEqual(true);
131 | 
132 |             // act
133 |             Object.assign(subject, { scope: "openid foo bar" });
134 | 
135 |             // assert
136 |             expect(subject.isOpenId).toEqual(true);
137 | 
138 |             // act
139 |             Object.assign(subject, { scope: "foo bar openid" });
140 | 
141 |             // assert
142 |             expect(subject.isOpenId).toEqual(true);
143 | 
144 |             // act
145 |             Object.assign(subject, { scope: "foo bar" });
146 | 
147 |             // assert
148 |             expect(subject.isOpenId).toEqual(false);
149 |         });
150 | 
151 |         it("shoud detect id_token", () => {
152 |             const subject = new SigninResponse(new URLSearchParams());
153 | 
154 |             // act
155 |             Object.assign(subject, { id_token: undefined });
156 | 
157 |             // assert
158 |             expect(subject.isOpenId).toEqual(false);
159 | 
160 |             // act
161 |             Object.assign(subject, { id_token: "id_token" });
162 | 
163 |             // assert
164 |             expect(subject.isOpenId).toEqual(true);
165 |         });
166 |     });
167 | });
168 | 


--------------------------------------------------------------------------------
/src/SigninResponse.ts:
--------------------------------------------------------------------------------
 1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
 2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 3 | 
 4 | import { Timer, URL_STATE_DELIMITER } from "./utils";
 5 | import type { UserProfile } from "./User";
 6 | 
 7 | const OidcScope = "openid";
 8 | 
 9 | /**
10 |  * @public
11 |  * @see https://openid.net/specs/openid-connect-core-1_0.html#AuthResponse
12 |  * @see https://openid.net/specs/openid-connect-core-1_0.html#AuthError
13 |  */
14 | export class SigninResponse {
15 |     // props present in the initial callback response regardless of success
16 |     public readonly state: string | null;
17 |     /** @see {@link User.session_state} */
18 |     public session_state: string | null;
19 | 
20 |     // error props
21 |     /** @see {@link ErrorResponse.error} */
22 |     public readonly error: string | null;
23 |     /** @see {@link ErrorResponse.error_description} */
24 |     public readonly error_description: string | null;
25 |     /** @see {@link ErrorResponse.error_uri} */
26 |     public readonly error_uri: string | null;
27 | 
28 |     // success props
29 |     public readonly code: string | null;
30 | 
31 |     // props set after validation
32 |     /** @see {@link User.id_token} */
33 |     public id_token?: string;
34 |     /** @see {@link User.access_token} */
35 |     public access_token = "";
36 |     /** @see {@link User.token_type} */
37 |     public token_type = "";
38 |     /** @see {@link User.refresh_token} */
39 |     public refresh_token?: string;
40 |     /** @see {@link User.scope} */
41 |     public scope?: string;
42 |     /** @see {@link User.expires_at} */
43 |     public expires_at?: number;
44 | 
45 |     /** custom state data set during the initial signin request */
46 |     public userState: unknown;
47 |     public url_state?: string;
48 | 
49 |     /** @see {@link User.profile} */
50 |     public profile: UserProfile = {} as UserProfile;
51 | 
52 |     public constructor(params: URLSearchParams) {
53 |         this.state = params.get("state");
54 |         this.session_state = params.get("session_state");
55 |         if (this.state) {
56 |             const splitState = decodeURIComponent(this.state).split(URL_STATE_DELIMITER);
57 |             this.state = splitState[0];
58 |             if (splitState.length > 1) {
59 |                 this.url_state = splitState.slice(1).join(URL_STATE_DELIMITER);
60 |             }
61 |         }
62 | 
63 |         this.error = params.get("error");
64 |         this.error_description = params.get("error_description");
65 |         this.error_uri = params.get("error_uri");
66 | 
67 |         this.code = params.get("code");
68 |     }
69 | 
70 |     public get expires_in(): number | undefined {
71 |         if (this.expires_at === undefined) {
72 |             return undefined;
73 |         }
74 |         return this.expires_at - Timer.getEpochTime();
75 |     }
76 |     public set expires_in(value: number | undefined) {
77 |         // spec expects a number, but normalize here just in case
78 |         if (typeof value === "string") value = Number(value);
79 |         if (value !== undefined && value >= 0) {
80 |             this.expires_at = Math.floor(value) + Timer.getEpochTime();
81 |         }
82 |     }
83 | 
84 |     public get isOpenId(): boolean {
85 |         return this.scope?.split(" ").includes(OidcScope) || !!this.id_token;
86 |     }
87 | }
88 | 


--------------------------------------------------------------------------------
/src/SigninState.ts:
--------------------------------------------------------------------------------
  1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
  2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
  3 | 
  4 | import { Logger, CryptoUtils } from "./utils";
  5 | import { State } from "./State";
  6 | 
  7 | /** @public */
  8 | export interface SigninStateArgs {
  9 |     id?: string;
 10 |     data?: unknown;
 11 |     created?: number;
 12 |     request_type?: string;
 13 | 
 14 |     code_verifier?: string;
 15 |     code_challenge?: string;
 16 |     authority: string;
 17 |     client_id: string;
 18 |     redirect_uri: string;
 19 |     scope: string;
 20 |     client_secret?: string;
 21 |     extraTokenParams?: Record;
 22 |     response_mode?: "query" | "fragment";
 23 |     skipUserInfo?: boolean;
 24 |     url_state?: string;
 25 | }
 26 | 
 27 | /** @public */
 28 | export type SigninStateCreateArgs = Omit & {
 29 |     code_verifier?: string | boolean;
 30 | };
 31 | 
 32 | /**
 33 |  * @public
 34 |  */
 35 | export class SigninState extends State {
 36 |     // isCode
 37 |     /** The same code_verifier that was used to obtain the authorization_code via PKCE. */
 38 |     public readonly code_verifier: string | undefined;
 39 |     /** Used to secure authorization code grants via Proof Key for Code Exchange (PKCE). */
 40 |     public readonly code_challenge: string | undefined;
 41 | 
 42 |     // to ensure state still matches settings
 43 |     /** @see {@link OidcClientSettings.authority} */
 44 |     public readonly authority: string;
 45 |     /** @see {@link OidcClientSettings.client_id} */
 46 |     public readonly client_id: string;
 47 |     /** @see {@link OidcClientSettings.redirect_uri} */
 48 |     public readonly redirect_uri: string;
 49 |     /** @see {@link OidcClientSettings.scope} */
 50 |     public readonly scope: string;
 51 |     /** @see {@link OidcClientSettings.client_secret} */
 52 |     public readonly client_secret: string | undefined;
 53 |     /** @see {@link OidcClientSettings.extraTokenParams} */
 54 |     public readonly extraTokenParams: Record | undefined;
 55 |     /** @see {@link OidcClientSettings.response_mode} */
 56 |     public readonly response_mode: "query" | "fragment" | undefined;
 57 | 
 58 |     public readonly skipUserInfo: boolean | undefined;
 59 | 
 60 |     private constructor(args: SigninStateArgs) {
 61 |         super(args);
 62 | 
 63 |         this.code_verifier = args.code_verifier;
 64 |         this.code_challenge = args.code_challenge;
 65 |         this.authority = args.authority;
 66 |         this.client_id = args.client_id;
 67 |         this.redirect_uri = args.redirect_uri;
 68 |         this.scope = args.scope;
 69 |         this.client_secret = args.client_secret;
 70 |         this.extraTokenParams = args.extraTokenParams;
 71 | 
 72 |         this.response_mode = args.response_mode;
 73 |         this.skipUserInfo = args.skipUserInfo;
 74 |     }
 75 | 
 76 |     public static async create(args: SigninStateCreateArgs): Promise {
 77 |         const code_verifier = args.code_verifier === true ? CryptoUtils.generateCodeVerifier() : (args.code_verifier || undefined);
 78 |         const code_challenge = code_verifier ? (await CryptoUtils.generateCodeChallenge(code_verifier)) : undefined;
 79 | 
 80 |         return new SigninState({
 81 |             ...args,
 82 |             code_verifier,
 83 |             code_challenge,
 84 |         });
 85 |     }
 86 | 
 87 |     public toStorageString(): string {
 88 |         new Logger("SigninState").create("toStorageString");
 89 |         return JSON.stringify({
 90 |             id: this.id,
 91 |             data: this.data,
 92 |             created: this.created,
 93 |             request_type: this.request_type,
 94 |             url_state: this.url_state,
 95 | 
 96 |             code_verifier: this.code_verifier,
 97 |             authority: this.authority,
 98 |             client_id: this.client_id,
 99 |             redirect_uri: this.redirect_uri,
100 |             scope: this.scope,
101 |             client_secret: this.client_secret,
102 |             extraTokenParams : this.extraTokenParams,
103 |             response_mode: this.response_mode,
104 |             skipUserInfo: this.skipUserInfo,
105 |         });
106 |     }
107 | 
108 |     public static fromStorageString(storageString: string): Promise {
109 |         Logger.createStatic("SigninState", "fromStorageString");
110 |         const data = JSON.parse(storageString);
111 |         return SigninState.create(data);
112 |     }
113 | }
114 | 


--------------------------------------------------------------------------------
/src/SignoutRequest.test.ts:
--------------------------------------------------------------------------------
  1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
  2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
  3 | 
  4 | import { SignoutRequest, type SignoutRequestArgs } from "./SignoutRequest";
  5 | import { URL_STATE_DELIMITER } from "./utils";
  6 | 
  7 | describe("SignoutRequest", () => {
  8 | 
  9 |     let subject: SignoutRequest;
 10 |     let settings: SignoutRequestArgs;
 11 | 
 12 |     beforeEach(() => {
 13 |         settings = {
 14 |             url: "http://sts/signout",
 15 |             id_token_hint: "hint",
 16 |             post_logout_redirect_uri: "loggedout",
 17 |             state_data: { data: "test" },
 18 |         };
 19 |         subject = new SignoutRequest(settings);
 20 |     });
 21 | 
 22 |     describe("constructor", () => {
 23 | 
 24 |         it("should require a url param", () => {
 25 |             // arrange
 26 |             Object.assign(settings, { url: undefined });
 27 | 
 28 |             // act
 29 |             try {
 30 |                 new SignoutRequest(settings);
 31 |                 fail("should not come here");
 32 |             }
 33 |             catch (err) {
 34 |                 expect(err).toBeInstanceOf(Error);
 35 |                 expect((err as Error).message).toContain("url");
 36 |             }
 37 |         });
 38 |     });
 39 | 
 40 |     describe("url", () => {
 41 | 
 42 |         it("should include url", () => {
 43 |             // assert
 44 |             expect(subject.url.indexOf("http://sts/signout")).toEqual(0);
 45 |         });
 46 | 
 47 |         it("should include id_token_hint", () => {
 48 |             // assert
 49 |             expect(subject.url).toContain("id_token_hint=hint");
 50 |         });
 51 | 
 52 |         it("should include post_logout_redirect_uri if id_token_hint also provided", () => {
 53 |             // assert
 54 |             expect(subject.url).toContain("post_logout_redirect_uri=loggedout");
 55 |         });
 56 | 
 57 |         it("should include post_logout_redirect_uri if no id_token_hint provided", () => {
 58 |             // arrange
 59 |             delete settings.id_token_hint;
 60 | 
 61 |             // act
 62 |             subject = new SignoutRequest(settings);
 63 | 
 64 |             // assert
 65 |             expect(subject.url).toContain("post_logout_redirect_uri=loggedout");
 66 |         });
 67 | 
 68 |         it("should include state if post_logout_redirect_uri provided", () => {
 69 |             // assert
 70 |             expect(subject.state).toBeDefined();
 71 |             expect(subject.url).toContain("state=" + subject.state!.id);
 72 |         });
 73 | 
 74 |         it("should include state if post_logout_redirect_uri and url_state provided even if state_data is empty", () => {
 75 |             // arrange
 76 |             delete settings.state_data;
 77 |             settings.url_state = "foo";
 78 | 
 79 |             // act
 80 |             subject = new SignoutRequest(settings);
 81 | 
 82 |             // assert
 83 |             expect(subject.state).toBeDefined();
 84 |             expect(subject.url).toContain("state=" + subject.state!.id + encodeURIComponent(URL_STATE_DELIMITER + "foo"));
 85 |         });
 86 | 
 87 |         it("should not include state if no post_logout_redirect_uri provided", () => {
 88 |             // arrange
 89 |             delete settings.post_logout_redirect_uri;
 90 | 
 91 |             // act
 92 |             subject = new SignoutRequest(settings);
 93 | 
 94 |             // assert
 95 |             expect(subject.url).not.toContain("state=");
 96 |         });
 97 |         
 98 |         it("should include url_state", async () => {
 99 |             // arrange
100 |             settings.url_state = "foo";
101 | 
102 |             // act
103 |             subject = new SignoutRequest(settings);
104 | 
105 |             // assert
106 |             expect(subject.url).toContain("state=" + subject.state!.id + encodeURIComponent(URL_STATE_DELIMITER + "foo"));
107 |         });
108 | 
109 |         it("should not include state or url_state if no post_logout_redirect_uri provided", () => {
110 |             // arrange
111 |             delete settings.post_logout_redirect_uri;
112 |             settings.url_state = "foo";
113 | 
114 |             // act
115 |             subject = new SignoutRequest(settings);
116 | 
117 |             // assert
118 |             expect(subject.url).not.toContain("state=");
119 |         });
120 | 
121 |         it("should include id_token_hint, post_logout_redirect_uri, and state", () => {
122 |             // assert
123 |             const url = subject.url;
124 |             expect(url.indexOf("http://sts/signout?")).toEqual(0);
125 |             expect(url).toContain("id_token_hint=hint");
126 |             expect(url).toContain("post_logout_redirect_uri=loggedout");
127 |             expect(subject.state).toBeDefined();
128 |             expect(url).toContain("state=" + subject.state!.id);
129 |         });
130 | 
131 |         it("should include extra query params", () => {
132 |             // arrange
133 |             settings.extraQueryParams = {
134 |                 "TargetResource": "logouturl.com",
135 |                 "InErrorResource": "errorurl.com",
136 |             };
137 | 
138 |             // act
139 |             subject = new SignoutRequest(settings);
140 | 
141 |             // assert
142 |             expect(subject.url).toContain("TargetResource=logouturl.com&InErrorResource=errorurl.com");
143 |         });
144 | 
145 |     });
146 | });
147 | 


--------------------------------------------------------------------------------
/src/SignoutRequest.ts:
--------------------------------------------------------------------------------
 1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
 2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 3 | 
 4 | import { Logger, URL_STATE_DELIMITER } from "./utils";
 5 | import { State } from "./State";
 6 | 
 7 | /**
 8 |  * @public
 9 |  * @see https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout
10 |  */
11 | export interface SignoutRequestArgs {
12 |     // mandatory
13 |     url: string;
14 | 
15 |     // optional
16 |     id_token_hint?: string;
17 |     client_id?: string;
18 |     post_logout_redirect_uri?: string;
19 |     extraQueryParams?: Record;
20 | 
21 |     // special
22 |     request_type?: string;
23 |     /** custom "state", which can be used by a caller to have "data" round tripped */
24 |     state_data?: unknown;
25 |     url_state?: string;
26 | }
27 | 
28 | /**
29 |  * @public
30 |  */
31 | export class SignoutRequest {
32 |     private readonly _logger = new Logger("SignoutRequest");
33 | 
34 |     public readonly url: string;
35 |     public readonly state?: State;
36 | 
37 |     public constructor({
38 |         url,
39 |         state_data, id_token_hint, post_logout_redirect_uri, extraQueryParams, request_type, client_id, url_state,
40 |     }: SignoutRequestArgs) {
41 |         if (!url) {
42 |             this._logger.error("ctor: No url passed");
43 |             throw new Error("url");
44 |         }
45 | 
46 |         const parsedUrl = new URL(url);
47 |         if (id_token_hint) {
48 |             parsedUrl.searchParams.append("id_token_hint", id_token_hint);
49 |         }
50 |         if (client_id) {
51 |             parsedUrl.searchParams.append("client_id", client_id);
52 |         }
53 | 
54 |         if (post_logout_redirect_uri) {
55 |             parsedUrl.searchParams.append("post_logout_redirect_uri", post_logout_redirect_uri);
56 | 
57 |             // Add state if either data needs to be stored, or url_state set for an intermediate proxy
58 |             if (state_data || url_state) {
59 |                 this.state = new State({ data: state_data, request_type, url_state });
60 | 
61 |                 let stateParam = this.state.id;
62 |                 if (url_state) {
63 |                     stateParam = `${stateParam}${URL_STATE_DELIMITER}${url_state}`;
64 |                 }
65 |                 parsedUrl.searchParams.append("state", stateParam);
66 |             }
67 |         }
68 | 
69 |         for (const [key, value] of Object.entries({ ...extraQueryParams })) {
70 |             if (value != null) {
71 |                 parsedUrl.searchParams.append(key, value.toString());
72 |             }
73 |         }
74 | 
75 |         this.url = parsedUrl.href;
76 |     }
77 | }
78 | 


--------------------------------------------------------------------------------
/src/SignoutResponse.test.ts:
--------------------------------------------------------------------------------
 1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
 2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 3 | 
 4 | import { SignoutResponse } from "./SignoutResponse";
 5 | 
 6 | describe("SignoutResponse", () => {
 7 | 
 8 |     describe("constructor", () => {
 9 | 
10 |         it("should read error", () => {
11 |             // act
12 |             const subject = new SignoutResponse(new URLSearchParams("error=foo"));
13 | 
14 |             // assert
15 |             expect(subject.error).toEqual("foo");
16 |         });
17 | 
18 |         it("should read error_description", () => {
19 |             // act
20 |             const subject = new SignoutResponse(new URLSearchParams("error_description=foo"));
21 | 
22 |             // assert
23 |             expect(subject.error_description).toEqual("foo");
24 |         });
25 | 
26 |         it("should read error_uri", () => {
27 |             // act
28 |             const subject = new SignoutResponse(new URLSearchParams("error_uri=foo"));
29 | 
30 |             // assert
31 |             expect(subject.error_uri).toEqual("foo");
32 |         });
33 | 
34 |         it("should read state", () => {
35 |             // act
36 |             const subject = new SignoutResponse(new URLSearchParams("state=foo"));
37 | 
38 |             // assert
39 |             expect(subject.state).toEqual("foo");
40 |         });
41 |         
42 |         it("should read url_state", () => {
43 |             // act
44 |             const subject = new SignoutResponse(new URLSearchParams("state=foo;bar"));
45 | 
46 |             // assert
47 |             expect(subject.state).toEqual("foo");
48 |             expect(subject.url_state).toEqual("bar");
49 |         });
50 |     });
51 | });
52 | 


--------------------------------------------------------------------------------
/src/SignoutResponse.ts:
--------------------------------------------------------------------------------
 1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
 2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 3 | 
 4 | import { URL_STATE_DELIMITER } from "./utils";
 5 | 
 6 | /**
 7 |  * @public
 8 |  * @see https://openid.net/specs/openid-connect-core-1_0.html#AuthError
 9 |  */
10 | export class SignoutResponse {
11 |     public readonly state: string | null;
12 | 
13 |     // error props
14 |     /** @see {@link ErrorResponse.error} */
15 |     public error: string | null;
16 |     /** @see {@link ErrorResponse.error_description} */
17 |     public error_description: string | null;
18 |     /** @see {@link ErrorResponse.error_uri} */
19 |     public error_uri: string | null;
20 | 
21 |     /** custom state data set during the initial signin request */
22 |     public userState: unknown;
23 |     public url_state?: string;
24 | 
25 |     public constructor(params: URLSearchParams) {
26 |         this.state = params.get("state");
27 |         if (this.state) {
28 |             const splitState = decodeURIComponent(this.state).split(URL_STATE_DELIMITER);
29 |             this.state = splitState[0];
30 |             if (splitState.length > 1) {
31 |                 this.url_state = splitState.slice(1).join(URL_STATE_DELIMITER);
32 |             }
33 |         }
34 | 
35 |         this.error = params.get("error");
36 |         this.error_description = params.get("error_description");
37 |         this.error_uri = params.get("error_uri");
38 |     }
39 | }
40 | 


--------------------------------------------------------------------------------
/src/SilentRenewService.ts:
--------------------------------------------------------------------------------
 1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
 2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 3 | 
 4 | import { Logger, Timer } from "./utils";
 5 | import { ErrorTimeout } from "./errors";
 6 | import type { UserManager } from "./UserManager";
 7 | import type { AccessTokenCallback } from "./AccessTokenEvents";
 8 | 
 9 | /**
10 |  * @internal
11 |  */
12 | export class SilentRenewService {
13 |     protected _logger = new Logger("SilentRenewService");
14 |     private _isStarted = false;
15 |     private readonly _retryTimer = new Timer("Retry Silent Renew");
16 | 
17 |     public constructor(private _userManager: UserManager) {}
18 | 
19 |     public async start(): Promise {
20 |         const logger = this._logger.create("start");
21 |         if (!this._isStarted) {
22 |             this._isStarted = true;
23 |             this._userManager.events.addAccessTokenExpiring(this._tokenExpiring);
24 |             this._retryTimer.addHandler(this._tokenExpiring);
25 | 
26 |             // this will trigger loading of the user so the expiring events can be initialized
27 |             try {
28 |                 await this._userManager.getUser();
29 |                 // deliberate nop
30 |             }
31 |             catch (err) {
32 |                 // catch to suppress errors since we're in a ctor
33 |                 logger.error("getUser error", err);
34 |             }
35 |         }
36 |     }
37 | 
38 |     public stop(): void {
39 |         if (this._isStarted) {
40 |             this._retryTimer.cancel();
41 |             this._retryTimer.removeHandler(this._tokenExpiring);
42 |             this._userManager.events.removeAccessTokenExpiring(this._tokenExpiring);
43 |             this._isStarted = false;
44 |         }
45 |     }
46 | 
47 |     protected _tokenExpiring: AccessTokenCallback = async () => {
48 |         const logger = this._logger.create("_tokenExpiring");
49 |         try {
50 |             await this._userManager.signinSilent();
51 |             logger.debug("silent token renewal successful");
52 |         }
53 |         catch (err) {
54 |             if (err instanceof ErrorTimeout) {
55 |                 // no response from authority server, e.g. IFrame timeout, ...
56 |                 logger.warn("ErrorTimeout from signinSilent:", err, "retry in 5s");
57 |                 this._retryTimer.init(5);
58 |                 return;
59 |             }
60 | 
61 |             logger.error("Error from signinSilent:", err);
62 |             await this._userManager.events._raiseSilentRenewError(err as Error);
63 |         }
64 |     };
65 | }
66 | 


--------------------------------------------------------------------------------
/src/State.test.ts:
--------------------------------------------------------------------------------
  1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
  2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
  3 | 
  4 | import { State } from "./State";
  5 | 
  6 | import { InMemoryWebStorage } from "./InMemoryWebStorage";
  7 | import { WebStorageStateStore } from "./WebStorageStateStore";
  8 | 
  9 | describe("State", () => {
 10 |     describe("constructor", () => {
 11 | 
 12 |         it("should generate id", () => {
 13 |             // act
 14 |             const subject = new State({
 15 |                 request_type: "type",
 16 |             });
 17 | 
 18 |             // assert
 19 |             expect(subject.id).toBeDefined();
 20 |         });
 21 | 
 22 |         it("should accept id", () => {
 23 |             // act
 24 |             const subject = new State({
 25 |                 request_type: "type",
 26 |                 id: "5",
 27 |             });
 28 | 
 29 |             // assert
 30 |             expect(subject.id).toEqual("5");
 31 |         });
 32 | 
 33 |         it("should accept data", () => {
 34 |             // act
 35 |             const subject = new State({
 36 |                 request_type: "type",
 37 |                 data: "test",
 38 |             });
 39 | 
 40 |             // assert
 41 |             expect(subject.data).toEqual("test");
 42 |         });
 43 | 
 44 |         it("should accept data as objects", () => {
 45 |             // act
 46 |             const subject = new State({
 47 |                 request_type: "type",
 48 |                 data: { foo: "test" },
 49 |             });
 50 | 
 51 |             // assert
 52 |             expect(subject.data).toEqual({ foo: "test" });
 53 |         });
 54 | 
 55 |         it("should accept created", () => {
 56 |             // act
 57 |             const subject = new State({
 58 |                 request_type: "type",
 59 |                 created: 1000,
 60 |             });
 61 | 
 62 |             // assert
 63 |             expect(subject.created).toEqual(1000);
 64 |         });
 65 | 
 66 |         it("should use date.now for created", () => {
 67 |             // arrange
 68 |             const oldNow = Date.now;
 69 |             Date.now = () => {
 70 |                 return 123 * 1000; // ms
 71 |             };
 72 | 
 73 |             // act
 74 |             const subject = new State({
 75 |                 request_type: "type",
 76 |             });
 77 | 
 78 |             // assert
 79 |             expect(subject.created).toEqual(123);
 80 |             Date.now = oldNow;
 81 |         });
 82 | 
 83 |         it("should accept request_type", () => {
 84 |             // act
 85 |             const subject = new State({
 86 |                 request_type: "xoxo",
 87 |             });
 88 | 
 89 |             // assert
 90 |             expect(subject.request_type).toEqual("xoxo");
 91 |         });
 92 | 
 93 |         it("should accept url_state", () => {
 94 |             // act
 95 |             const subject = new State({
 96 |                 url_state: "foo",
 97 |             });
 98 | 
 99 |             // assert
100 |             expect(subject.url_state).toEqual("foo");
101 |         });
102 |     });
103 | 
104 |     it("can serialize and then deserialize", async () => {
105 |         // arrange
106 |         const subject1 = new State({
107 |             data: { foo: "test" }, created: 1000, request_type:"type", url_state: "foo",
108 |         });
109 | 
110 |         // act
111 |         const storage = subject1.toStorageString();
112 |         const subject2 = await State.fromStorageString(storage);
113 | 
114 |         // assert
115 |         expect(subject2).toEqual(subject1);
116 |     });
117 | 
118 |     describe("clearStaleState", () => {
119 | 
120 |         it("should remove old state entries", async () => {
121 |             // arrange
122 |             const oldNow = Date.now;
123 |             Date.now = () => {
124 |                 return 200 * 1000; // ms
125 |             };
126 | 
127 |             const prefix = "prefix.";
128 |             const inMemStore = new InMemoryWebStorage();
129 |             const store = new WebStorageStateStore({ prefix: prefix, store: inMemStore });
130 | 
131 |             const s1 = new State({ id: "s1", created: 5, request_type:"type" });
132 |             const s2 = new State({ id: "s2", created: 99, request_type:"type" });
133 |             const s3 = new State({ id: "s3", created: 100, request_type:"type" });
134 |             const s4 = new State({ id: "s4", created: 101, request_type:"type" });
135 |             const s5 = new State({ id: "s5", created: 150, request_type:"type" });
136 | 
137 |             inMemStore.setItem("junk0", "junk");
138 |             inMemStore.setItem(prefix + s1.id, s1.toStorageString());
139 |             inMemStore.setItem("junk1", "junk");
140 |             inMemStore.setItem(prefix + s2.id, s2.toStorageString());
141 |             inMemStore.setItem("junk2", "junk");
142 |             inMemStore.setItem(prefix + s3.id, s3.toStorageString());
143 |             inMemStore.setItem("junk3", "junk");
144 |             inMemStore.setItem(prefix + s4.id, s4.toStorageString());
145 |             inMemStore.setItem("junk4", "junk");
146 |             inMemStore.setItem(prefix + s5.id, s5.toStorageString());
147 |             inMemStore.setItem("junk5", "junk");
148 | 
149 |             // act
150 |             await State.clearStaleState(store, 100);
151 | 
152 |             // assert
153 |             expect(inMemStore.length).toEqual(8);
154 |             expect(inMemStore.getItem(prefix + "s4")).toBeDefined();
155 |             expect(inMemStore.getItem(prefix + "s5")).toBeDefined();
156 |             Date.now = oldNow;
157 |         });
158 |     });
159 | });
160 | 


--------------------------------------------------------------------------------
/src/State.ts:
--------------------------------------------------------------------------------
 1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
 2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 3 | 
 4 | import { Logger, CryptoUtils, Timer } from "./utils";
 5 | import type { StateStore } from "./StateStore";
 6 | 
 7 | /**
 8 |  * @public
 9 |  */
10 | export class State {
11 |     public readonly id: string;
12 |     public readonly created: number;
13 |     public readonly request_type: string | undefined;
14 |     public readonly url_state: string | undefined;
15 | 
16 |     /** custom "state", which can be used by a caller to have "data" round tripped */
17 |     public readonly data?: unknown;
18 | 
19 |     public constructor(args: {
20 |         id?: string;
21 |         data?: unknown;
22 |         created?: number;
23 |         request_type?: string;
24 |         url_state?: string;
25 |     }) {
26 |         this.id = args.id || CryptoUtils.generateUUIDv4();
27 |         this.data = args.data;
28 | 
29 |         if (args.created && args.created > 0) {
30 |             this.created = args.created;
31 |         }
32 |         else {
33 |             this.created = Timer.getEpochTime();
34 |         }
35 |         this.request_type = args.request_type;
36 |         this.url_state = args.url_state;
37 |     }
38 | 
39 |     public toStorageString(): string {
40 |         new Logger("State").create("toStorageString");
41 |         return JSON.stringify({
42 |             id: this.id,
43 |             data: this.data,
44 |             created: this.created,
45 |             request_type: this.request_type,
46 |             url_state: this.url_state,
47 |         });
48 |     }
49 | 
50 |     public static fromStorageString(storageString: string): Promise {
51 |         Logger.createStatic("State", "fromStorageString");
52 |         return Promise.resolve(new State(JSON.parse(storageString)));
53 |     }
54 | 
55 |     public static async clearStaleState(storage: StateStore, age: number): Promise {
56 |         const logger = Logger.createStatic("State", "clearStaleState");
57 |         const cutoff = Timer.getEpochTime() - age;
58 | 
59 |         const keys = await storage.getAllKeys();
60 |         logger.debug("got keys", keys);
61 | 
62 |         for (let i = 0; i < keys.length; i++) {
63 |             const key = keys[i];
64 |             const item = await storage.get(key);
65 |             let remove = false;
66 | 
67 |             if (item) {
68 |                 try {
69 |                     const state = await State.fromStorageString(item);
70 | 
71 |                     logger.debug("got item from key:", key, state.created);
72 |                     if (state.created <= cutoff) {
73 |                         remove = true;
74 |                     }
75 |                 }
76 |                 catch (err) {
77 |                     logger.error("Error parsing state for key:", key, err);
78 |                     remove = true;
79 |                 }
80 |             }
81 |             else {
82 |                 logger.debug("no item in storage for key:", key);
83 |                 remove = true;
84 |             }
85 | 
86 |             if (remove) {
87 |                 logger.debug("removed item for key:", key);
88 |                 void storage.remove(key);
89 |             }
90 |         }
91 |     }
92 | }
93 | 


--------------------------------------------------------------------------------
/src/StateStore.ts:
--------------------------------------------------------------------------------
 1 | /**
 2 |  * @public
 3 |  */
 4 | export interface StateStore {
 5 |     set(key: string, value: string): Promise;
 6 |     get(key: string): Promise;
 7 |     remove(key: string): Promise;
 8 |     getAllKeys(): Promise;
 9 | }
10 | 


--------------------------------------------------------------------------------
/src/User.test.ts:
--------------------------------------------------------------------------------
 1 | import { User } from "./User";
 2 | import { Timer } from "./utils";
 3 | 
 4 | describe("User", () => {
 5 | 
 6 |     let now: number;
 7 | 
 8 |     beforeEach(() => {
 9 |         now = 0;
10 |         jest.spyOn(Timer, "getEpochTime").mockImplementation(() => now);
11 |     });
12 | 
13 |     afterEach(() => {
14 |         jest.restoreAllMocks();
15 |     });
16 | 
17 |     describe("expired", () => {
18 |         it("should calculate how much time left", () => {
19 |             const subject = new User({ expires_at: 100 } as never);
20 |             expect(subject.expired).toEqual(false);
21 | 
22 |             // act
23 |             now += 100;
24 | 
25 |             // assert
26 |             expect(subject.expired).toEqual(true);
27 |         });
28 |     });
29 | 
30 |     describe("scopes", () => {
31 |         it("should return list of scopes", () => {
32 |             let subject = new User({ scope: "foo" } as never);
33 | 
34 |             // assert
35 |             expect(subject.scopes).toEqual(["foo"]);
36 | 
37 |             subject = new User({ scope: "foo bar" } as never);
38 | 
39 |             // assert
40 |             expect(subject.scopes).toEqual(["foo", "bar"]);
41 | 
42 |             subject = new User({ scope: "foo bar baz" } as never);
43 | 
44 |             // assert
45 |             expect(subject.scopes).toEqual(["foo", "bar", "baz"]);
46 |         });
47 |     });
48 | });
49 | 


--------------------------------------------------------------------------------
/src/User.ts:
--------------------------------------------------------------------------------
  1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
  2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
  3 | 
  4 | import { Logger, Timer } from "./utils";
  5 | import type { IdTokenClaims } from "./Claims";
  6 | 
  7 | /**
  8 |  * Holds claims represented by a combination of the `id_token` and the user info endpoint.
  9 |  *
 10 |  * @public
 11 |  */
 12 | export type UserProfile = IdTokenClaims;
 13 | 
 14 | /**
 15 |  * @public
 16 |  */
 17 | export class User {
 18 |     /**
 19 |      * A JSON Web Token (JWT). Only provided if `openid` scope was requested.
 20 |      * The application can access the data decoded by using the `profile` property.
 21 |      */
 22 |     public id_token?: string;
 23 | 
 24 |     /** The session state value returned from the OIDC provider. */
 25 |     public session_state: string | null;
 26 | 
 27 |     /**
 28 |      * The requested access token returned from the OIDC provider. The application can use this token to
 29 |      * authenticate itself to the secured resource.
 30 |      */
 31 |     public access_token: string;
 32 | 
 33 |     /**
 34 |      * An OAuth 2.0 refresh token. The app can use this token to acquire additional access tokens after the
 35 |      * current access token expires. Refresh tokens are long-lived and can be used to maintain access to resources
 36 |      * for extended periods of time.
 37 |      */
 38 |     public refresh_token?: string;
 39 | 
 40 |     /** Typically "Bearer" */
 41 |     public token_type: string;
 42 | 
 43 |     /** The scopes that the requested access token is valid for. */
 44 |     public scope?: string;
 45 | 
 46 |     /** The claims represented by a combination of the `id_token` and the user info endpoint. */
 47 |     public profile: UserProfile;
 48 | 
 49 |     /** The expires at returned from the OIDC provider. */
 50 |     public expires_at?: number;
 51 | 
 52 |     /** custom state data set during the initial signin request */
 53 |     public readonly state: unknown;
 54 |     public readonly url_state?: string;
 55 | 
 56 |     public constructor(args: {
 57 |         id_token?: string;
 58 |         session_state?: string | null;
 59 |         access_token: string;
 60 |         refresh_token?: string;
 61 |         token_type: string;
 62 |         scope?: string;
 63 |         profile: UserProfile;
 64 |         expires_at?: number;
 65 |         userState?: unknown;
 66 |         url_state?: string;
 67 |     }) {
 68 |         this.id_token = args.id_token;
 69 |         this.session_state = args.session_state ?? null;
 70 |         this.access_token = args.access_token;
 71 |         this.refresh_token = args.refresh_token;
 72 | 
 73 |         this.token_type = args.token_type;
 74 |         this.scope = args.scope;
 75 |         this.profile = args.profile;
 76 |         this.expires_at = args.expires_at;
 77 |         this.state = args.userState;
 78 |         this.url_state = args.url_state;
 79 |     }
 80 | 
 81 |     /** Computed number of seconds the access token has remaining. */
 82 |     public get expires_in(): number | undefined {
 83 |         if (this.expires_at === undefined) {
 84 |             return undefined;
 85 |         }
 86 |         return this.expires_at - Timer.getEpochTime();
 87 |     }
 88 | 
 89 |     public set expires_in(value: number | undefined) {
 90 |         if (value !== undefined) {
 91 |             this.expires_at = Math.floor(value) + Timer.getEpochTime();
 92 |         }
 93 |     }
 94 | 
 95 |     /** Computed value indicating if the access token is expired. */
 96 |     public get expired(): boolean | undefined {
 97 |         const expires_in = this.expires_in;
 98 |         if (expires_in === undefined) {
 99 |             return undefined;
100 |         }
101 |         return expires_in <= 0;
102 |     }
103 | 
104 |     /** Array representing the parsed values from the `scope`. */
105 |     public get scopes(): string[] {
106 |         return this.scope?.split(" ") ?? [];
107 |     }
108 | 
109 |     public toStorageString(): string {
110 |         new Logger("User").create("toStorageString");
111 |         return JSON.stringify({
112 |             id_token: this.id_token,
113 |             session_state: this.session_state,
114 |             access_token: this.access_token,
115 |             refresh_token: this.refresh_token,
116 |             token_type: this.token_type,
117 |             scope: this.scope,
118 |             profile: this.profile,
119 |             expires_at: this.expires_at,
120 |         });
121 |     }
122 | 
123 |     public static fromStorageString(storageString: string): User {
124 |         Logger.createStatic("User", "fromStorageString");
125 |         return new User(JSON.parse(storageString));
126 |     }
127 | }
128 | 


--------------------------------------------------------------------------------
/src/UserInfoService.test.ts:
--------------------------------------------------------------------------------
  1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
  2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
  3 | 
  4 | import { UserInfoService } from "./UserInfoService";
  5 | import { MetadataService } from "./MetadataService";
  6 | import type { JsonService } from "./JsonService";
  7 | import { OidcClientSettingsStore } from "./OidcClientSettings";
  8 | 
  9 | describe("UserInfoService", () => {
 10 |     let subject: UserInfoService;
 11 |     let metadataService: MetadataService;
 12 |     let jsonService: JsonService;
 13 | 
 14 |     beforeEach(() => {
 15 |         const settings = new OidcClientSettingsStore({
 16 |             authority: "authority",
 17 |             client_id: "client",
 18 |             redirect_uri: "redirect",
 19 |             fetchRequestCredentials: "include",
 20 |         });
 21 |         metadataService = new MetadataService(settings);
 22 | 
 23 |         subject = new UserInfoService(settings, metadataService);
 24 | 
 25 |         // access private members
 26 |         jsonService = subject["_jsonService"];
 27 |     });
 28 | 
 29 |     describe("getClaims", () => {
 30 | 
 31 |         it("should return a promise", async () => {
 32 |             // act
 33 |             const p = subject.getClaims("");
 34 | 
 35 |             // assert
 36 |             expect(p).toBeInstanceOf(Promise);
 37 |             // eslint-disable-next-line no-empty
 38 |             try { await p; } catch {}
 39 |         });
 40 | 
 41 |         it("should require a token", async () => {
 42 |             // act
 43 |             try {
 44 |                 await subject.getClaims("");
 45 |                 fail("should not come here");
 46 |             }
 47 |             catch (err) {
 48 |                 expect(err).toBeInstanceOf(Error);
 49 |                 expect((err as Error).message).toContain("token");
 50 |             }
 51 |         });
 52 | 
 53 |         it("should call userinfo endpoint and pass token", async () => {
 54 |             // arrange
 55 |             jest.spyOn(metadataService, "getUserInfoEndpoint").mockImplementation(() => Promise.resolve("http://sts/userinfo"));
 56 |             const getJsonMock = jest.spyOn(jsonService, "getJson")
 57 |                 .mockResolvedValue({ foo: "bar" });
 58 | 
 59 |             // act
 60 |             await subject.getClaims("token");
 61 | 
 62 |             // assert
 63 |             expect(getJsonMock).toHaveBeenCalledWith(
 64 |                 "http://sts/userinfo",
 65 |                 expect.objectContaining({
 66 |                     token: "token",
 67 |                 }),
 68 |             );
 69 |         });
 70 | 
 71 |         it("should fail when dependencies fail", async () => {
 72 |             // arrange
 73 |             jest.spyOn(metadataService, "getUserInfoEndpoint").mockRejectedValue(new Error("test"));
 74 | 
 75 |             // act
 76 |             try {
 77 |                 await subject.getClaims("token");
 78 |                 fail("should not come here");
 79 |             }
 80 |             catch (err) {
 81 |                 expect(err).toBeInstanceOf(Error);
 82 |                 expect((err as Error).message).toContain("test");
 83 |             }
 84 |         });
 85 | 
 86 |         it("should return claims", async () => {
 87 |             // arrange
 88 |             jest.spyOn(metadataService, "getUserInfoEndpoint").mockImplementation(() => Promise.resolve("http://sts/userinfo"));
 89 |             const expectedClaims = {
 90 |                 foo: 1, bar: "test",
 91 |                 aud:"some_aud", iss:"issuer",
 92 |                 sub:"123", email:"foo@gmail.com",
 93 |                 role:["admin", "dev"],
 94 |                 nonce:"nonce", at_hash:"athash",
 95 |                 iat:5, nbf:10, exp:20,
 96 |             };
 97 |             jest.spyOn(jsonService, "getJson").mockImplementation(() => Promise.resolve(expectedClaims));
 98 | 
 99 |             // act
100 |             const claims = await subject.getClaims("token");
101 | 
102 |             // assert
103 |             expect(claims).toEqual(expectedClaims);
104 |         });
105 | 
106 |         it("should use settings fetchRequestCredentials to set credentials on user info request", async () => {
107 |             // arrange
108 |             jest.spyOn(metadataService, "getUserInfoEndpoint").mockImplementation(() => Promise.resolve("http://sts/userinfo"));
109 |             const getJsonMock = jest.spyOn(jsonService, "getJson").mockImplementation(() => Promise.resolve({}));
110 | 
111 |             // act
112 |             await subject.getClaims("token");
113 | 
114 |             // assert
115 |             expect(getJsonMock).toHaveBeenCalledWith(
116 |                 "http://sts/userinfo",
117 |                 expect.objectContaining({
118 |                     credentials: "include",
119 |                 }),
120 |             );
121 |         });
122 |     });
123 | });
124 | 


--------------------------------------------------------------------------------
/src/UserInfoService.ts:
--------------------------------------------------------------------------------
 1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
 2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 3 | 
 4 | import { Logger, JwtUtils } from "./utils";
 5 | import { JsonService } from "./JsonService";
 6 | import type { MetadataService } from "./MetadataService";
 7 | import type { JwtClaims } from "./Claims";
 8 | import type { OidcClientSettingsStore } from "./OidcClientSettings";
 9 | 
10 | /**
11 |  * @internal
12 |  */
13 | export class UserInfoService {
14 |     protected readonly _logger = new Logger("UserInfoService");
15 |     private readonly _jsonService: JsonService;
16 | 
17 |     public constructor(private readonly _settings: OidcClientSettingsStore,
18 |         private readonly _metadataService: MetadataService,
19 |     ) {
20 |         this._jsonService = new JsonService(
21 |             undefined,
22 |             this._getClaimsFromJwt,
23 |             this._settings.extraHeaders,
24 |         );
25 |     }
26 | 
27 |     public async getClaims(token: string): Promise {
28 |         const logger = this._logger.create("getClaims");
29 |         if (!token) {
30 |             this._logger.throw(new Error("No token passed"));
31 |         }
32 | 
33 |         const url = await this._metadataService.getUserInfoEndpoint();
34 |         logger.debug("got userinfo url", url);
35 | 
36 |         const claims = await this._jsonService.getJson(url, {
37 |             token,
38 |             credentials: this._settings.fetchRequestCredentials,
39 |             timeoutInSeconds: this._settings.requestTimeoutInSeconds,
40 |         });
41 |         logger.debug("got claims", claims);
42 | 
43 |         return claims;
44 |     }
45 | 
46 |     protected _getClaimsFromJwt = async (responseText: string): Promise => {
47 |         const logger = this._logger.create("_getClaimsFromJwt");
48 |         try {
49 |             const payload = JwtUtils.decode(responseText);
50 |             logger.debug("JWT decoding successful");
51 | 
52 |             return payload;
53 |         } catch (err) {
54 |             logger.error("Error parsing JWT response");
55 |             throw err;
56 |         }
57 |     };
58 | }
59 | 


--------------------------------------------------------------------------------
/src/Version.ts:
--------------------------------------------------------------------------------
1 | // @ts-expect-error avoid enabling resolveJsonModule to keep build process simple
2 | import { version } from "../package.json";
3 | 
4 | /**
5 |  * @public
6 |  */
7 | export const Version: string = version;
8 | 


--------------------------------------------------------------------------------
/src/WebStorageStateStore.ts:
--------------------------------------------------------------------------------
 1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
 2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 3 | 
 4 | import { Logger } from "./utils";
 5 | import type { StateStore } from "./StateStore";
 6 | import type { AsyncStorage } from "./AsyncStorage";
 7 | 
 8 | /**
 9 |  * @public
10 |  */
11 | export class WebStorageStateStore implements StateStore {
12 |     private readonly _logger = new Logger("WebStorageStateStore");
13 | 
14 |     private readonly _store: AsyncStorage | Storage;
15 |     private readonly _prefix: string;
16 | 
17 |     public constructor({
18 |         prefix = "oidc.",
19 |         store = localStorage,
20 |     }: { prefix?: string; store?: AsyncStorage | Storage } = {}) {
21 |         this._store = store;
22 |         this._prefix = prefix;
23 |     }
24 | 
25 |     public async set(key: string, value: string): Promise {
26 |         this._logger.create(`set('${key}')`);
27 | 
28 |         key = this._prefix + key;
29 |         await this._store.setItem(key, value);
30 |     }
31 | 
32 |     public async get(key: string): Promise {
33 |         this._logger.create(`get('${key}')`);
34 | 
35 |         key = this._prefix + key;
36 |         const item = await this._store.getItem(key);
37 |         return item;
38 |     }
39 | 
40 |     public async remove(key: string): Promise {
41 |         this._logger.create(`remove('${key}')`);
42 | 
43 |         key = this._prefix + key;
44 |         const item = await this._store.getItem(key);
45 |         await this._store.removeItem(key);
46 |         return item;
47 |     }
48 | 
49 |     public async getAllKeys(): Promise {
50 |         this._logger.create("getAllKeys");
51 |         const len = await this._store.length;
52 | 
53 |         const keys = [];
54 |         for (let index = 0; index < len; index++) {
55 |             const key = await this._store.key(index);
56 |             if (key && key.indexOf(this._prefix) === 0) {
57 |                 keys.push(key.substr(this._prefix.length));
58 |             }
59 |         }
60 |         return keys;
61 |     }
62 | }
63 | 


--------------------------------------------------------------------------------
/src/errors/ErrorDPoPNonce.ts:
--------------------------------------------------------------------------------
 1 | export class ErrorDPoPNonce extends Error {
 2 |     /** Marker to detect class: "ErrorDPoPNonce" */
 3 |     public readonly name: string = "ErrorDPoPNonce";
 4 |     public readonly nonce: string;
 5 | 
 6 |     public constructor(nonce: string, message?: string) {
 7 |         super(message);
 8 |         this.nonce = nonce;
 9 |     }
10 | }
11 | 


--------------------------------------------------------------------------------
/src/errors/ErrorResponse.test.ts:
--------------------------------------------------------------------------------
  1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
  2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
  3 | 
  4 | import { ErrorResponse } from "./ErrorResponse";
  5 | 
  6 | describe("ErrorResponse", () => {
  7 | 
  8 |     describe("constructor", () => {
  9 | 
 10 |         it("should require a error param", () => {
 11 |             // act
 12 |             try {
 13 |                 new ErrorResponse({});
 14 |             }
 15 |             catch (err) {
 16 |                 expect(err).toBeInstanceOf(Error);
 17 |                 expect((err as Error).message).toContain("error");
 18 |                 return;
 19 |             }
 20 | 
 21 |             fail("should not come here");
 22 |         });
 23 | 
 24 |         it("should read error", () => {
 25 |             // act
 26 |             const subject = new ErrorResponse({ error:"foo" });
 27 | 
 28 |             // assert
 29 |             expect(subject.error).toEqual("foo");
 30 |         });
 31 | 
 32 |         it("should read error_description", () => {
 33 |             // act
 34 |             const subject = new ErrorResponse({ error:"error", error_description:"foo" });
 35 | 
 36 |             // assert
 37 |             expect(subject.error_description).toEqual("foo");
 38 |         });
 39 | 
 40 |         it("should read error_uri", () => {
 41 |             // act
 42 |             const subject = new ErrorResponse({ error:"error", error_uri:"foo" });
 43 | 
 44 |             // assert
 45 |             expect(subject.error_uri).toEqual("foo");
 46 |         });
 47 | 
 48 |         it("should read state", () => {
 49 |             // act
 50 |             const subject = new ErrorResponse({ error:"error", userState:"foo" });
 51 | 
 52 |             // assert
 53 |             expect(subject.state).toEqual("foo");
 54 |         });
 55 | 
 56 |         it("should read url_state", () => {
 57 |             // act
 58 |             const subject = new ErrorResponse({ error:"error", url_state:"foo" });
 59 | 
 60 |             // assert
 61 |             expect(subject.url_state).toEqual("foo");
 62 |         });
 63 |     });
 64 | 
 65 |     describe("message", () => {
 66 |         it("should be description if set", () => {
 67 |             // act
 68 |             const subject = new ErrorResponse({ error:"error", error_description:"foo" });
 69 | 
 70 |             // assert
 71 |             expect(subject.message).toEqual("foo");
 72 |         });
 73 | 
 74 |         it("should be error if description not set", () => {
 75 |             // act
 76 |             const subject = new ErrorResponse({ error:"error" });
 77 | 
 78 |             // assert
 79 |             expect(subject.message).toEqual("error");
 80 |         });
 81 |     });
 82 | 
 83 |     describe("name", () => {
 84 |         it("should be class name", () => {
 85 |             // act
 86 |             const subject = new ErrorResponse({ error:"error" });
 87 | 
 88 |             // assert
 89 |             expect(subject.name).toEqual("ErrorResponse");
 90 |         });
 91 |     });
 92 | 
 93 |     describe("stack", () => {
 94 |         it("should be set", () => {
 95 |             // act
 96 |             const subject = new ErrorResponse({ error:"error" });
 97 | 
 98 |             // assert
 99 |             expect(subject.stack).not.toBeNull();
100 |         });
101 |     });
102 | });
103 | 


--------------------------------------------------------------------------------
/src/errors/ErrorResponse.ts:
--------------------------------------------------------------------------------
 1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
 2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 3 | 
 4 | import { Logger } from "../utils";
 5 | 
 6 | /**
 7 |  * Error class thrown in case of an authentication error.
 8 |  *
 9 |  * @public
10 |  * @see https://openid.net/specs/openid-connect-core-1_0.html#AuthError
11 |  */
12 | export class ErrorResponse extends Error {
13 |     /** Marker to detect class: "ErrorResponse" */
14 |     public readonly name: string = "ErrorResponse";
15 | 
16 |     /** An error code string that can be used to classify the types of errors that occur and to respond to errors. */
17 |     public readonly error: string | null;
18 |     /** additional information that can help a developer identify the cause of the error.*/
19 |     public readonly error_description: string | null;
20 |     /**
21 |      * URI identifying a human-readable web page with information about the error, used to provide the client
22 |        developer with additional information about the error.
23 |     */
24 |     public readonly error_uri: string | null;
25 | 
26 |     /** custom state data set during the initial signin request */
27 |     public state?: unknown;
28 | 
29 |     public readonly session_state: string | null;
30 | 
31 |     public url_state?: string;
32 | 
33 |     public constructor(
34 |         args: {
35 |             error?: string | null; error_description?: string | null; error_uri?: string | null;
36 |             userState?: unknown; session_state?: string | null; url_state?: string;
37 |         },
38 |         /** The x-www-form-urlencoded request body sent to the authority server */
39 |         public readonly form?: URLSearchParams,
40 |     ) {
41 |         super(args.error_description || args.error || "");
42 | 
43 |         if (!args.error) {
44 |             Logger.error("ErrorResponse", "No error passed");
45 |             throw new Error("No error passed");
46 |         }
47 | 
48 |         this.error = args.error;
49 |         this.error_description = args.error_description ?? null;
50 |         this.error_uri = args.error_uri ?? null;
51 | 
52 |         this.state = args.userState;
53 |         this.session_state = args.session_state ?? null;
54 |         this.url_state = args.url_state;
55 |     }
56 | }
57 | 


--------------------------------------------------------------------------------
/src/errors/ErrorTimeout.ts:
--------------------------------------------------------------------------------
 1 | // Copyright (C) 2021 AuthTS Contributors
 2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 3 | 
 4 | /**
 5 |  * Error class thrown in case of network timeouts (e.g IFrame time out).
 6 |  *
 7 |  * @public
 8 |  */
 9 | export class ErrorTimeout extends Error {
10 |     /** Marker to detect class: "ErrorTimeout" */
11 |     public readonly name: string = "ErrorTimeout";
12 | 
13 |     public constructor(message?: string) {
14 |         super(message);
15 |     }
16 | }
17 | 


--------------------------------------------------------------------------------
/src/errors/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./ErrorResponse";
2 | export * from "./ErrorTimeout";
3 | 


--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
 1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
 2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 3 | 
 4 | export { ErrorResponse, ErrorTimeout } from "./errors";
 5 | export type { INavigator, IFrameWindowParams, IWindow, NavigateParams, NavigateResponse, PopupWindowParams, RedirectParams } from "./navigators";
 6 | export { Log, Logger } from "./utils";
 7 | export type { ILogger, PopupWindowFeatures } from "./utils";
 8 | export type { OidcAddressClaim, OidcStandardClaims, IdTokenClaims, JwtClaims } from "./Claims";
 9 | 
10 | export { AccessTokenEvents } from "./AccessTokenEvents";
11 | export type { AccessTokenCallback } from "./AccessTokenEvents";
12 | export { CheckSessionIFrame } from "./CheckSessionIFrame";
13 | export { InMemoryWebStorage } from "./InMemoryWebStorage";
14 | export type { AsyncStorage } from "./AsyncStorage";
15 | export { MetadataService } from "./MetadataService";
16 | export * from "./OidcClient";
17 | export { OidcClientSettingsStore } from "./OidcClientSettings";
18 | export type { OidcClientSettings, SigningKey, ExtraHeader } from "./OidcClientSettings";
19 | export type { OidcMetadata } from "./OidcMetadata";
20 | export { SessionMonitor } from "./SessionMonitor";
21 | export type { SessionStatus } from "./SessionStatus";
22 | export type { SigninRequest, SigninRequestCreateArgs } from "./SigninRequest";
23 | export type { RefreshState } from "./RefreshState";
24 | export { SigninResponse } from "./SigninResponse";
25 | export { SigninState } from "./SigninState";
26 | export type { SigninStateArgs, SigninStateCreateArgs } from "./SigninState";
27 | export type { SignoutRequest, SignoutRequestArgs } from "./SignoutRequest";
28 | export { SignoutResponse } from "./SignoutResponse";
29 | export { State } from "./State";
30 | export type { StateStore } from "./StateStore";
31 | export { User } from "./User";
32 | export type { UserProfile } from "./User";
33 | export * from "./UserManager";
34 | export type {
35 |     UserManagerEvents,
36 |     SilentRenewErrorCallback,
37 |     UserLoadedCallback,
38 |     UserSessionChangedCallback,
39 |     UserSignedInCallback,
40 |     UserSignedOutCallback,
41 |     UserUnloadedCallback,
42 | } from "./UserManagerEvents";
43 | export { UserManagerSettingsStore } from "./UserManagerSettings";
44 | export type { UserManagerSettings } from "./UserManagerSettings";
45 | export { Version } from "./Version";
46 | export { WebStorageStateStore } from "./WebStorageStateStore";
47 | export { IndexedDbDPoPStore } from "./IndexedDbDPoPStore";
48 | export { DPoPState } from "./DPoPStore";
49 | 


--------------------------------------------------------------------------------
/src/navigators/AbstractChildWindow.ts:
--------------------------------------------------------------------------------
  1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
  2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
  3 | 
  4 | import { Event, Logger, UrlUtils } from "../utils";
  5 | import type { IWindow, NavigateParams, NavigateResponse } from "./IWindow";
  6 | 
  7 | const messageSource = "oidc-client";
  8 | 
  9 | interface MessageData {
 10 |     source: string;
 11 |     url: string;
 12 |     keepOpen: boolean;
 13 | }
 14 | 
 15 | /**
 16 |  * Window implementation which resolves via communication from a child window
 17 |  * via the `Window.postMessage()` interface.
 18 |  *
 19 |  * @internal
 20 |  */
 21 | export abstract class AbstractChildWindow implements IWindow {
 22 |     protected abstract readonly _logger: Logger;
 23 |     protected readonly _abort = new Event<[reason: Error]>("Window navigation aborted");
 24 |     protected readonly _disposeHandlers = new Set<() => void>();
 25 | 
 26 |     protected _window: WindowProxy | null = null;
 27 | 
 28 |     public async navigate(params: NavigateParams): Promise {
 29 |         const logger = this._logger.create("navigate");
 30 |         if (!this._window) {
 31 |             throw new Error("Attempted to navigate on a disposed window");
 32 |         }
 33 | 
 34 |         logger.debug("setting URL in window");
 35 |         this._window.location.replace(params.url);
 36 | 
 37 |         const { url, keepOpen } = await new Promise((resolve, reject) => {
 38 |             const listener = (e: MessageEvent) => {
 39 |                 const data: MessageData | undefined = e.data;
 40 |                 const origin = params.scriptOrigin ?? window.location.origin;
 41 |                 if (e.origin !== origin || data?.source !== messageSource) {
 42 |                     // silently discard events not intended for us
 43 |                     return;
 44 |                 }
 45 |                 try {
 46 |                     const state = UrlUtils.readParams(data.url, params.response_mode).get("state");
 47 |                     if (!state) {
 48 |                         logger.warn("no state found in response url");
 49 |                     }
 50 |                     if (e.source !== this._window && state !== params.state) {
 51 |                         // MessageEvent source is a relatively modern feature, we can't rely on it
 52 |                         // so we also inspect the payload for a matching state key as an alternative
 53 |                         return;
 54 |                     }
 55 |                 }
 56 |                 catch {
 57 |                     this._dispose();
 58 |                     reject(new Error("Invalid response from window"));
 59 |                 }
 60 |                 resolve(data);
 61 |             };
 62 |             window.addEventListener("message", listener, false);
 63 |             this._disposeHandlers.add(() => window.removeEventListener("message", listener, false));
 64 |             const channel = new BroadcastChannel(`oidc-client-popup-${params.state}`);
 65 |             channel.addEventListener("message", listener, false);
 66 |             this._disposeHandlers.add(() => channel.close());
 67 |             this._disposeHandlers.add(this._abort.addHandler((reason) => {
 68 |                 this._dispose();
 69 |                 reject(reason);
 70 |             }));
 71 |         });
 72 |         logger.debug("got response from window");
 73 |         this._dispose();
 74 | 
 75 |         if (!keepOpen) {
 76 |             this.close();
 77 |         }
 78 | 
 79 |         return { url };
 80 |     }
 81 | 
 82 |     public abstract close(): void;
 83 | 
 84 |     private _dispose(): void {
 85 |         this._logger.create("_dispose");
 86 | 
 87 |         for (const dispose of this._disposeHandlers) {
 88 |             dispose();
 89 |         }
 90 |         this._disposeHandlers.clear();
 91 |     }
 92 | 
 93 |     protected static _notifyParent(parent: Window | null, url: string, keepOpen = false, targetOrigin = window.location.origin): void {
 94 |         const msgData: MessageData = {
 95 |             source: messageSource,
 96 |             url,
 97 |             keepOpen,
 98 |         };
 99 |         const logger = new Logger("_notifyParent");
100 |         if (parent) {
101 |             logger.debug("With parent. Using parent.postMessage.");
102 |             parent.postMessage(msgData, targetOrigin);
103 |         } else {
104 |             logger.debug("No parent. Using BroadcastChannel.");
105 |             const state = new URL(url).searchParams.get("state");
106 |             if (!state) {
107 |                 throw new Error("No parent and no state in URL. Can't complete notification.");
108 |             }
109 |             const channel = new BroadcastChannel(`oidc-client-popup-${state}`);
110 |             channel.postMessage(msgData);
111 |             channel.close();
112 |         }
113 |     }
114 | }
115 | 


--------------------------------------------------------------------------------
/src/navigators/IFrameNavigator.ts:
--------------------------------------------------------------------------------
 1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
 2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 3 | 
 4 | import { Logger } from "../utils";
 5 | import type { UserManagerSettingsStore } from "../UserManagerSettings";
 6 | import { IFrameWindow, type IFrameWindowParams } from "./IFrameWindow";
 7 | import type { INavigator } from "./INavigator";
 8 | 
 9 | /**
10 |  * @internal
11 |  */
12 | export class IFrameNavigator implements INavigator {
13 |     private readonly _logger = new Logger("IFrameNavigator");
14 | 
15 |     constructor(private _settings: UserManagerSettingsStore) {}
16 | 
17 |     public async prepare({
18 |         silentRequestTimeoutInSeconds = this._settings.silentRequestTimeoutInSeconds,
19 |     }: IFrameWindowParams): Promise {
20 |         return new IFrameWindow({ silentRequestTimeoutInSeconds });
21 |     }
22 | 
23 |     public async callback(url: string): Promise {
24 |         this._logger.create("callback");
25 |         IFrameWindow.notifyParent(url, this._settings.iframeNotifyParentOrigin);
26 |     }
27 | }
28 | 


--------------------------------------------------------------------------------
/src/navigators/IFrameWindow.ts:
--------------------------------------------------------------------------------
 1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
 2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 3 | 
 4 | import { Logger } from "../utils";
 5 | import { ErrorTimeout } from "../errors";
 6 | import type { NavigateParams, NavigateResponse } from "./IWindow";
 7 | import { AbstractChildWindow } from "./AbstractChildWindow";
 8 | import { DefaultSilentRequestTimeoutInSeconds } from "../UserManagerSettings";
 9 | 
10 | /**
11 |  * @public
12 |  */
13 | export interface IFrameWindowParams {
14 |     silentRequestTimeoutInSeconds?: number;
15 | }
16 | 
17 | /**
18 |  * @internal
19 |  */
20 | export class IFrameWindow extends AbstractChildWindow {
21 |     protected readonly _logger = new Logger("IFrameWindow");
22 |     private _frame: HTMLIFrameElement | null;
23 |     private _timeoutInSeconds: number;
24 | 
25 |     public constructor({
26 |         silentRequestTimeoutInSeconds = DefaultSilentRequestTimeoutInSeconds,
27 |     }: IFrameWindowParams) {
28 |         super();
29 |         this._timeoutInSeconds = silentRequestTimeoutInSeconds;
30 | 
31 |         this._frame = IFrameWindow.createHiddenIframe();
32 |         this._window = this._frame.contentWindow;
33 |     }
34 | 
35 |     private static createHiddenIframe(): HTMLIFrameElement {
36 |         const iframe = window.document.createElement("iframe");
37 | 
38 |         // shotgun approach
39 |         iframe.style.visibility = "hidden";
40 |         iframe.style.position = "fixed";
41 |         iframe.style.left = "-1000px";
42 |         iframe.style.top = "0";
43 |         iframe.width = "0";
44 |         iframe.height = "0";
45 | 
46 |         window.document.body.appendChild(iframe);
47 |         return iframe;
48 |     }
49 | 
50 |     public async navigate(params: NavigateParams): Promise {
51 |         this._logger.debug("navigate: Using timeout of:", this._timeoutInSeconds);
52 |         const timer = setTimeout(() => void this._abort.raise(new ErrorTimeout("IFrame timed out without a response")), this._timeoutInSeconds * 1000);
53 |         this._disposeHandlers.add(() => clearTimeout(timer));
54 | 
55 |         return await super.navigate(params);
56 |     }
57 | 
58 |     public close(): void {
59 |         if (this._frame) {
60 |             if (this._frame.parentNode) {
61 |                 this._frame.addEventListener("load", (ev) => {
62 |                     const frame = ev.target as HTMLIFrameElement;
63 |                     frame.parentNode?.removeChild(frame);
64 |                     void this._abort.raise(new Error("IFrame removed from DOM"));
65 |                 }, true);
66 |                 this._frame.contentWindow?.location.replace("about:blank");
67 |             }
68 |             this._frame = null;
69 |         }
70 |         this._window = null;
71 |     }
72 | 
73 |     public static notifyParent(url: string, targetOrigin?: string): void {
74 |         return super._notifyParent(window.parent, url, false, targetOrigin);
75 |     }
76 | }
77 | 


--------------------------------------------------------------------------------
/src/navigators/INavigator.ts:
--------------------------------------------------------------------------------
 1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
 2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 3 | 
 4 | import type { IWindow } from "./IWindow";
 5 | 
 6 | /**
 7 |  * @public
 8 |  */
 9 | export interface INavigator {
10 |     prepare(params: unknown): Promise;
11 | 
12 |     callback(url: string, params?: unknown): Promise;
13 | }
14 | 


--------------------------------------------------------------------------------
/src/navigators/IWindow.ts:
--------------------------------------------------------------------------------
 1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
 2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 3 | 
 4 | /**
 5 |  * @public
 6 |  */
 7 | export interface NavigateParams {
 8 |     url: string;
 9 |     /** The request "nonce" parameter. */
10 |     nonce?: string;
11 |     /** The request "state" parameter. For sign out requests, this parameter is optional. */
12 |     state?: string;
13 |     response_mode?: "query" | "fragment";
14 |     scriptOrigin?: string;
15 | }
16 | 
17 | /**
18 |  * @public
19 |  */
20 | export interface NavigateResponse {
21 |     url: string;
22 | }
23 | 
24 | /**
25 |  * @public
26 |  */
27 | export interface IWindow {
28 |     navigate(params: NavigateParams): Promise;
29 |     close(): void;
30 | }
31 | 


--------------------------------------------------------------------------------
/src/navigators/PopupNavigator.ts:
--------------------------------------------------------------------------------
 1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
 2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 3 | 
 4 | import { Logger } from "../utils";
 5 | import { PopupWindow, type PopupWindowParams } from "./PopupWindow";
 6 | import type { INavigator } from "./INavigator";
 7 | import type { UserManagerSettingsStore } from "../UserManagerSettings";
 8 | 
 9 | /**
10 |  * @internal
11 |  */
12 | export class PopupNavigator implements INavigator {
13 |     private readonly _logger = new Logger("PopupNavigator");
14 | 
15 |     constructor(private _settings: UserManagerSettingsStore) { }
16 | 
17 |     public async prepare({
18 |         popupWindowFeatures = this._settings.popupWindowFeatures,
19 |         popupWindowTarget = this._settings.popupWindowTarget,
20 |         popupSignal,
21 |     }: PopupWindowParams): Promise {
22 |         return new PopupWindow({ popupWindowFeatures, popupWindowTarget, popupSignal });
23 |     }
24 | 
25 |     public async callback(url: string, { keepOpen = false }): Promise {
26 |         this._logger.create("callback");
27 | 
28 |         PopupWindow.notifyOpener(url, keepOpen);
29 |     }
30 | }
31 | 


--------------------------------------------------------------------------------
/src/navigators/PopupWindow.ts:
--------------------------------------------------------------------------------
 1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
 2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 3 | 
 4 | import { Logger, PopupUtils, type PopupWindowFeatures } from "../utils";
 5 | import { DefaultPopupWindowFeatures, DefaultPopupTarget } from "../UserManagerSettings";
 6 | import { AbstractChildWindow } from "./AbstractChildWindow";
 7 | import type { NavigateParams, NavigateResponse } from "./IWindow";
 8 | 
 9 | const checkForPopupClosedInterval = 500;
10 | const second = 1000;
11 | 
12 | /**
13 |  * @public
14 |  */
15 | export interface PopupWindowParams {
16 |     popupWindowFeatures?: PopupWindowFeatures;
17 |     popupWindowTarget?: string;
18 |     /** An AbortSignal to set request's signal. */
19 |     popupSignal?: AbortSignal | null;
20 | }
21 | 
22 | /**
23 |  * @internal
24 |  */
25 | export class PopupWindow extends AbstractChildWindow {
26 |     protected readonly _logger = new Logger("PopupWindow");
27 | 
28 |     protected _window: WindowProxy | null;
29 | 
30 |     public constructor({
31 |         popupWindowTarget = DefaultPopupTarget,
32 |         popupWindowFeatures = {},
33 |         popupSignal,
34 |     }: PopupWindowParams) {
35 |         super();
36 |         const centeredPopup = PopupUtils.center({ ...DefaultPopupWindowFeatures, ...popupWindowFeatures });
37 |         this._window = window.open(undefined, popupWindowTarget, PopupUtils.serialize(centeredPopup));
38 | 
39 |         if (popupSignal) {
40 |             popupSignal.addEventListener("abort", () => {
41 |                 void this._abort.raise(new Error(popupSignal.reason ?? "Popup aborted"));
42 |             });
43 |         }
44 | 
45 |         if (popupWindowFeatures.closePopupWindowAfterInSeconds && popupWindowFeatures.closePopupWindowAfterInSeconds > 0) {
46 |             setTimeout(() => {
47 |                 if (!this._window || typeof this._window.closed !== "boolean" || this._window.closed) {
48 |                     void this._abort.raise(new Error("Popup blocked by user"));
49 |                     return;
50 |                 }
51 | 
52 |                 this.close();
53 |             }, popupWindowFeatures.closePopupWindowAfterInSeconds * second);
54 |         }
55 |     }
56 | 
57 |     public async navigate(params: NavigateParams): Promise {
58 |         this._window?.focus();
59 | 
60 |         const popupClosedInterval = setInterval(() => {
61 |             if (!this._window || this._window.closed) {
62 |                 this._logger.debug("Popup closed by user or isolated by redirect");
63 |                 clearPopupClosedInterval();
64 |                 this._disposeHandlers.delete(clearPopupClosedInterval);
65 |             }
66 |         }, checkForPopupClosedInterval);
67 |         const clearPopupClosedInterval = () => clearInterval(popupClosedInterval);
68 |         this._disposeHandlers.add(clearPopupClosedInterval);
69 | 
70 |         return await super.navigate(params);
71 |     }
72 | 
73 |     public close(): void {
74 |         if (this._window) {
75 |             if (!this._window.closed) {
76 |                 this._window.close();
77 |                 void this._abort.raise(new Error("Popup closed"));
78 |             }
79 |         }
80 |         this._window = null;
81 |     }
82 | 
83 |     public static notifyOpener(url: string, keepOpen: boolean): void {
84 |         super._notifyParent(window.opener, url, keepOpen);
85 |         if (!keepOpen && !window.opener) {
86 |             window.close();
87 |         }
88 |     }
89 | }
90 | 


--------------------------------------------------------------------------------
/src/navigators/RedirectNavigator.test.ts:
--------------------------------------------------------------------------------
 1 | import { mocked } from "jest-mock";
 2 | import type { UserManagerSettingsStore } from "../UserManagerSettings";
 3 | import { RedirectNavigator } from "./RedirectNavigator";
 4 | 
 5 | describe("RedirectNavigator", () => {
 6 |     const settings = { redirectMethod: "assign" } as UserManagerSettingsStore;
 7 |     const navigator = new RedirectNavigator(settings);
 8 | 
 9 |     afterEach(() => {
10 |         jest.clearAllMocks();
11 |     });
12 | 
13 |     it("should redirect to the authority server using the default redirect method", async () => {
14 |         const handle = await navigator.prepare({});
15 |         void handle.navigate({ url: "http://sts/authorize" });
16 | 
17 |         expect(window.location.assign).toHaveBeenCalledWith("http://sts/authorize");
18 |     });
19 | 
20 |     it("should redirect to the authority server using a specific redirect method", async () => {
21 |         const handle = await navigator.prepare({ redirectMethod: "replace" });
22 |         await handle.navigate({ url: "http://sts/authorize" });
23 | 
24 |         expect(window.location.replace).toHaveBeenCalledWith("http://sts/authorize");
25 |     });
26 | 
27 |     it("should redirect to the authority server from window top", async () => {
28 | 
29 |         Object.defineProperty(window, "top", {
30 |             value: {
31 |                 location: {
32 |                     assign: jest.fn(),
33 |                 },
34 |             },
35 |         });
36 | 
37 |         const handle = await navigator.prepare({ redirectTarget: "top" });
38 |         const spy = jest.fn();
39 |         void handle.navigate({ url: "http://sts/authorize" }).finally(spy);
40 | 
41 |         expect(window.location.assign).toHaveBeenCalledTimes(0);
42 |         expect(window.parent.location.assign).toHaveBeenCalledTimes(0);
43 |         expect(window.top!.location.assign).toHaveBeenCalledWith("http://sts/authorize");
44 |     });
45 | 
46 |     it("should reject when the navigation is stopped programmatically", async () => {
47 |         const handle = await navigator.prepare({});
48 |         mocked(window.location.assign).mockReturnValue(undefined);
49 |         const promise = handle.navigate({ url: "http://sts/authorize" });
50 | 
51 |         handle.close();
52 |         await expect(promise).rejects.toThrow("Redirect aborted");
53 |     });
54 | });
55 | 


--------------------------------------------------------------------------------
/src/navigators/RedirectNavigator.ts:
--------------------------------------------------------------------------------
 1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
 2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 3 | 
 4 | import { Logger } from "../utils";
 5 | import type { UserManagerSettingsStore } from "../UserManagerSettings";
 6 | import type { INavigator } from "./INavigator";
 7 | import type { IWindow, NavigateResponse } from "./IWindow";
 8 | 
 9 | /**
10 |  * @public
11 |  */
12 | export interface RedirectParams {
13 |     redirectMethod?: "replace" | "assign";
14 |     redirectTarget?: "top" | "self";
15 | }
16 | 
17 | /**
18 |  * @internal
19 |  */
20 | export class RedirectNavigator implements INavigator {
21 |     private readonly _logger = new Logger("RedirectNavigator");
22 | 
23 |     constructor(private _settings: UserManagerSettingsStore) {}
24 | 
25 |     public async prepare({
26 |         redirectMethod = this._settings.redirectMethod,
27 |         redirectTarget = this._settings.redirectTarget,
28 |     }: RedirectParams): Promise {
29 |         this._logger.create("prepare");
30 |         let targetWindow = window.self as Window;
31 | 
32 |         if (redirectTarget === "top") {
33 |             targetWindow = window.top ?? window.self;
34 |         }
35 |     
36 |         const redirect = targetWindow.location[redirectMethod].bind(targetWindow.location) as (url: string) => void;
37 |         let abort: (reason: Error) => void;
38 |         return {
39 |             navigate: async (params): Promise => {
40 |                 this._logger.create("navigate");
41 |                 const promise = new Promise((resolve, reject) => {
42 |                     abort = reject;
43 |                     window.addEventListener("pageshow", () => resolve(window.location.href));
44 |                     redirect(params.url);
45 |                 });
46 |                 return await (promise as Promise);
47 |             },
48 |             close: () => {
49 |                 this._logger.create("close");
50 |                 abort?.(new Error("Redirect aborted"));
51 |                 targetWindow.stop();
52 |             },
53 |         };
54 |     }
55 | 
56 |     public async callback(): Promise {
57 |         return;
58 |     }
59 | }
60 | 


--------------------------------------------------------------------------------
/src/navigators/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./IFrameNavigator";
2 | export * from "./IFrameWindow";
3 | export * from "./INavigator";
4 | export * from "./IWindow";
5 | export * from "./PopupNavigator";
6 | export * from "./PopupWindow";
7 | export * from "./RedirectNavigator";
8 | 


--------------------------------------------------------------------------------
/src/utils/Event.test.ts:
--------------------------------------------------------------------------------
  1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
  2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
  3 | 
  4 | import { Event } from "./Event";
  5 | 
  6 | describe("Event", () => {
  7 | 
  8 |     let subject: Event;
  9 | 
 10 |     beforeEach(() => {
 11 |         subject = new Event("test name");
 12 |     });
 13 | 
 14 |     describe("addHandler", () => {
 15 | 
 16 |         it("should allow callback to be invoked", async () => {
 17 |             // arrange
 18 |             const cb = jest.fn();
 19 | 
 20 |             // act
 21 |             subject.addHandler(cb);
 22 |             await subject.raise();
 23 | 
 24 |             // assert
 25 |             expect(cb).toHaveBeenCalled();
 26 |         });
 27 | 
 28 |         it("should allow multiple callbacks", async () => {
 29 |             // arrange
 30 |             const cb = jest.fn();
 31 | 
 32 |             // act
 33 |             subject.addHandler(cb);
 34 |             subject.addHandler(cb);
 35 |             subject.addHandler(cb);
 36 |             subject.addHandler(cb);
 37 |             await subject.raise();
 38 | 
 39 |             // assert
 40 |             expect(cb).toHaveBeenCalledTimes(4);
 41 |         });
 42 |     });
 43 | 
 44 |     describe("removeHandler", () => {
 45 | 
 46 |         it("should remove callback from being invoked", async () => {
 47 |             // arrange
 48 |             const cb = jest.fn();
 49 | 
 50 |             // act
 51 |             subject.addHandler(cb);
 52 |             subject.removeHandler(cb);
 53 |             await subject.raise();
 54 | 
 55 |             // assert
 56 |             expect(cb).toHaveBeenCalledTimes(0);
 57 |         });
 58 | 
 59 |         it("should remove individual callback", async () => {
 60 |             // arrange
 61 |             const cb1 = jest.fn();
 62 |             const cb2 = jest.fn();
 63 | 
 64 |             // act
 65 |             subject.addHandler(cb1);
 66 |             subject.addHandler(cb2);
 67 |             subject.addHandler(cb1);
 68 |             subject.removeHandler(cb1);
 69 |             subject.removeHandler(cb1);
 70 | 
 71 |             await subject.raise();
 72 | 
 73 |             // assert
 74 |             expect(cb1).toHaveBeenCalledTimes(0);
 75 |             expect(cb2).toHaveBeenCalledTimes(1);
 76 |         });
 77 |     });
 78 | 
 79 |     describe("raise", () => {
 80 | 
 81 |         it("should pass params", async () => {
 82 |             // arrange
 83 |             const typedSubject = subject as Event<[number, number, number]>;
 84 |             let a = 10;
 85 |             let b = 11;
 86 |             let c = 12;
 87 |             const cb = function (arg_a: number, arg_b: number, arg_c: number) {
 88 |                 a = arg_a;
 89 |                 b = arg_b;
 90 |                 c = arg_c;
 91 |             };
 92 |             typedSubject.addHandler(cb);
 93 | 
 94 |             // act
 95 |             await typedSubject.raise(1, 2, 3);
 96 | 
 97 |             // assert
 98 |             expect(a).toEqual(1);
 99 |             expect(b).toEqual(2);
100 |             expect(c).toEqual(3);
101 |         });
102 |     });
103 | });
104 | 


--------------------------------------------------------------------------------
/src/utils/Event.ts:
--------------------------------------------------------------------------------
 1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
 2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 3 | 
 4 | import { Logger } from "./Logger";
 5 | 
 6 | /**
 7 |  * @internal
 8 |  */
 9 | export type Callback = (...ev: EventType) => (Promise | void);
10 | 
11 | /**
12 |  * @internal
13 |  */
14 | export class Event {
15 |     protected readonly _logger: Logger;
16 | 
17 |     private readonly _callbacks: Array> = [];
18 | 
19 |     public constructor(protected readonly _name: string) {
20 |         this._logger = new Logger(`Event('${this._name}')`);
21 |     }
22 | 
23 |     public addHandler(cb: Callback): () => void {
24 |         this._callbacks.push(cb);
25 |         return () => this.removeHandler(cb);
26 |     }
27 | 
28 |     public removeHandler(cb: Callback): void {
29 |         const idx = this._callbacks.lastIndexOf(cb);
30 |         if (idx >= 0) {
31 |             this._callbacks.splice(idx, 1);
32 |         }
33 |     }
34 | 
35 |     public async raise(...ev: EventType): Promise {
36 |         this._logger.debug("raise:", ...ev);
37 |         for (const cb of this._callbacks) {
38 |             await cb(...ev);
39 |         }
40 |     }
41 | }
42 | 


--------------------------------------------------------------------------------
/src/utils/JwtUtils.test.ts:
--------------------------------------------------------------------------------
 1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
 2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 3 | 
 4 | import { JwtUtils } from "./JwtUtils";
 5 | 
 6 | describe("JwtUtils", () => {
 7 | 
 8 |     let jwt: string;
 9 | 
10 |     beforeEach(() => {
11 |         jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6ImEzck1VZ01Gdjl0UGNsTGE2eUYzekFrZnF1RSIsImtpZCI6ImEzck1VZ01Gdjl0UGNsTGE2eUYzekFrZnF1RSJ9.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDo0NDMzMy9jb3JlIiwiYXVkIjoianMudG9rZW5tYW5hZ2VyIiwiZXhwIjoxNDU5MTMwMjAxLCJuYmYiOjE0NTkxMjk5MDEsIm5vbmNlIjoiNzIyMTAwNTIwOTk3MjM4MiIsImlhdCI6MTQ1OTEyOTkwMSwiYXRfaGFzaCI6IkpnRFVDeW9hdEp5RW1HaWlXYndPaEEiLCJzaWQiOiIwYzVmMDYxZTYzOThiMWVjNmEwYmNlMmM5NDFlZTRjNSIsInN1YiI6Ijg4NDIxMTEzIiwiYXV0aF90aW1lIjoxNDU5MTI5ODk4LCJpZHAiOiJpZHNydiIsImFtciI6WyJwYXNzd29yZCJdfQ.f6S1Fdd0UQScZAFBzXwRiVsUIPQnWZLSe07kdtjANRZDZXf5A7yDtxOftgCx5W0ONQcDFVpLGPgTdhp7agZkPpCFutzmwr0Rr9G7E7mUN4xcIgAABhmRDfzDayFBEu6VM8wEWTChezSWtx2xG_2zmVJxxmNV0jvkaz0bu7iin-C_UZg6T-aI9FZDoKRGXZP9gF65FQ5pQ4bCYQxhKcvjjUfs0xSHGboL7waN6RfDpO4vvVR1Kz-PQhIRyFAJYRuoH4PdMczHYtFCb-k94r-7TxEU0vp61ww4WntbPvVWwUbCUgsEtmDzAZT-NEJVhWztNk1ip9wDPXzZ2hEhDAPJ7A";
12 |     });
13 | 
14 |     describe("decode", () => {
15 | 
16 |         it("should decode a jwt", () => {
17 |             // act
18 |             const result = JwtUtils.decode(jwt);
19 | 
20 |             // assert
21 |             expect(result).toEqual({
22 |                 "iss": "https://localhost:44333/core",
23 |                 "aud": "js.tokenmanager",
24 |                 "exp": 1459130201,
25 |                 "nbf": 1459129901,
26 |                 "nonce": "7221005209972382",
27 |                 "iat": 1459129901,
28 |                 "at_hash": "JgDUCyoatJyEmGiiWbwOhA",
29 |                 "sid": "0c5f061e6398b1ec6a0bce2c941ee4c5",
30 |                 "sub": "88421113",
31 |                 "auth_time": 1459129898,
32 |                 "idp": "idsrv",
33 |                 "amr": [
34 |                     "password",
35 |                 ],
36 |             });
37 |         });
38 | 
39 |         it("should return undefined for an invalid jwt", () => {
40 |             // act
41 |             try {
42 |                 JwtUtils.decode("junk");
43 |                 fail("should not come here");
44 |             }
45 |             catch (err) {
46 |                 expect(err).toBeInstanceOf(Error);
47 |                 expect((err as Error).message).toContain("Invalid token specified");
48 |             }
49 |         });
50 |     });
51 | 
52 |     describe("createJwt", () => {
53 |         it("should be able to create identical jwts two different ways", async () => {
54 |             const keyPair = await window.crypto.subtle.generateKey(
55 |                 {
56 |                     name: "ECDSA",
57 |                     namedCurve: "P-256",
58 |                 },
59 |                 false,
60 |                 ["sign", "verify"]);
61 | 
62 |             const jti = window.crypto.randomUUID();
63 | 
64 |             const payload: Record = {
65 |                 "jti": jti,
66 |                 "htm": "GET",
67 |                 "htu": "http://test.com",
68 |             };
69 | 
70 |             const iat = Date.now();
71 | 
72 |             const header = {
73 |                 "alg": "ES256",
74 |                 "typ": "dpop+jwt",
75 |             };
76 |             payload.iat = iat;
77 |             const jwt = await JwtUtils.generateSignedJwt(header, payload, keyPair.privateKey);
78 | 
79 |             const result = JwtUtils.decode(jwt);
80 | 
81 |             expect(result).toEqual(
82 |                 {
83 |                     "jti": jti,
84 |                     "htm": "GET",
85 |                     "htu": "http://test.com",
86 |                     "iat": iat,
87 |                 });
88 |         });
89 |     });
90 | });
91 | 


--------------------------------------------------------------------------------
/src/utils/JwtUtils.ts:
--------------------------------------------------------------------------------
 1 | import { jwtDecode } from "jwt-decode";
 2 | 
 3 | import { Logger } from "./Logger";
 4 | import type { JwtClaims } from "../Claims";
 5 | import { CryptoUtils } from "./CryptoUtils";
 6 | 
 7 | /**
 8 |  * @internal
 9 |  */
10 | export class JwtUtils {
11 |     // IMPORTANT: doesn't validate the token
12 |     public static decode(token: string): JwtClaims {
13 |         try {
14 |             return jwtDecode(token);
15 |         }
16 |         catch (err) {
17 |             Logger.error("JwtUtils.decode", err);
18 |             throw err;
19 |         }
20 |     }
21 | 
22 |     public static async generateSignedJwt(header: object, payload: object, privateKey: CryptoKey) : Promise {
23 |         const encodedHeader = CryptoUtils.encodeBase64Url(new TextEncoder().encode(JSON.stringify(header)));
24 |         const encodedPayload = CryptoUtils.encodeBase64Url(new TextEncoder().encode(JSON.stringify(payload)));
25 |         const encodedToken = `${encodedHeader}.${encodedPayload}`;
26 | 
27 |         const signature = await window.crypto.subtle.sign(
28 |             {
29 |                 name: "ECDSA",
30 |                 hash: { name: "SHA-256" },
31 |             },
32 |             privateKey,
33 |             new TextEncoder().encode(encodedToken),
34 |         );
35 | 
36 |         const encodedSignature = CryptoUtils.encodeBase64Url(new Uint8Array(signature));
37 |         return `${encodedToken}.${encodedSignature}`;
38 |     }
39 | 
40 |     public static async generateSignedJwtWithHmac(header: object, payload: object, secretKey: CryptoKey): Promise {
41 |         const encodedHeader = CryptoUtils.encodeBase64Url(new TextEncoder().encode(JSON.stringify(header)));
42 |         const encodedPayload = CryptoUtils.encodeBase64Url(new TextEncoder().encode(JSON.stringify(payload)));
43 |         const encodedToken = `${encodedHeader}.${encodedPayload}`;
44 | 
45 |         const signature = await window.crypto.subtle.sign(
46 |             "HMAC",
47 |             secretKey,
48 |             new TextEncoder().encode(encodedToken),
49 |         );
50 | 
51 |         const encodedSignature = CryptoUtils.encodeBase64Url(new Uint8Array(signature));
52 |         return `${encodedToken}.${encodedSignature}`;
53 |     }
54 | }
55 | 


--------------------------------------------------------------------------------
/src/utils/Logger.ts:
--------------------------------------------------------------------------------
  1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
  2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
  3 | 
  4 | /**
  5 |  * Native interface
  6 |  *
  7 |  * @public
  8 |  */
  9 | export interface ILogger {
 10 |     debug(...args: unknown[]): void;
 11 |     info(...args: unknown[]): void;
 12 |     warn(...args: unknown[]): void;
 13 |     error(...args: unknown[]): void;
 14 | }
 15 | 
 16 | const nopLogger: ILogger = {
 17 |     debug: () => undefined,
 18 |     info: () => undefined,
 19 |     warn: () => undefined,
 20 |     error: () => undefined,
 21 | };
 22 | 
 23 | let level: number;
 24 | let logger: ILogger;
 25 | 
 26 | /**
 27 |  * Log levels
 28 |  *
 29 |  * @public
 30 |  */
 31 | export enum Log {
 32 |     NONE,
 33 |     ERROR,
 34 |     WARN,
 35 |     INFO,
 36 |     DEBUG
 37 | }
 38 | 
 39 | /**
 40 |  * Log manager
 41 |  *
 42 |  * @public
 43 |  */
 44 | export namespace Log { // eslint-disable-line @typescript-eslint/no-namespace
 45 |     export function reset(): void {
 46 |         level = Log.INFO;
 47 |         logger = nopLogger;
 48 |     }
 49 | 
 50 |     export function setLevel(value: Log): void {
 51 |         if (!(Log.NONE <= value && value <= Log.DEBUG)) {
 52 |             throw new Error("Invalid log level");
 53 |         }
 54 |         level = value;
 55 |     }
 56 | 
 57 |     export function setLogger(value: ILogger): void {
 58 |         logger = value;
 59 |     }
 60 | }
 61 | 
 62 | /**
 63 |  * Internal logger instance
 64 |  *
 65 |  * @public
 66 |  */
 67 | export class Logger {
 68 |     private _method?: string;
 69 |     public constructor(private _name: string) {}
 70 | 
 71 |     /* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */
 72 |     public debug(...args: unknown[]): void {
 73 |         if (level >= Log.DEBUG) {
 74 |             logger.debug(Logger._format(this._name, this._method), ...args);
 75 |         }
 76 |     }
 77 |     public info(...args: unknown[]): void {
 78 |         if (level >= Log.INFO) {
 79 |             logger.info(Logger._format(this._name, this._method), ...args);
 80 |         }
 81 |     }
 82 |     public warn(...args: unknown[]): void {
 83 |         if (level >= Log.WARN) {
 84 |             logger.warn(Logger._format(this._name, this._method), ...args);
 85 |         }
 86 |     }
 87 |     public error(...args: unknown[]): void {
 88 |         if (level >= Log.ERROR) {
 89 |             logger.error(Logger._format(this._name, this._method), ...args);
 90 |         }
 91 |     }
 92 |     /* eslint-enable @typescript-eslint/no-unsafe-enum-comparison */
 93 | 
 94 |     public throw(err: Error): never {
 95 |         this.error(err);
 96 |         throw err;
 97 |     }
 98 | 
 99 |     public create(method: string): Logger {
100 |         const methodLogger: Logger = Object.create(this);
101 |         methodLogger._method = method;
102 |         methodLogger.debug("begin");
103 |         return methodLogger;
104 |     }
105 | 
106 |     public static createStatic(name: string, staticMethod: string): Logger {
107 |         const staticLogger = new Logger(`${name}.${staticMethod}`);
108 |         staticLogger.debug("begin");
109 |         return staticLogger;
110 |     }
111 | 
112 |     private static _format(name: string, method?: string) {
113 |         const prefix = `[${name}]`;
114 |         return method ? `${prefix} ${method}:` : prefix;
115 |     }
116 | 
117 |     /* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */
118 |     // helpers for static class methods
119 |     public static debug(name: string, ...args: unknown[]): void {
120 |         if (level >= Log.DEBUG) {
121 |             logger.debug(Logger._format(name), ...args);
122 |         }
123 |     }
124 |     public static info(name: string, ...args: unknown[]): void {
125 |         if (level >= Log.INFO) {
126 |             logger.info(Logger._format(name), ...args);
127 |         }
128 |     }
129 |     public static warn(name: string, ...args: unknown[]): void {
130 |         if (level >= Log.WARN) {
131 |             logger.warn(Logger._format(name), ...args);
132 |         }
133 |     }
134 |     public static error(name: string, ...args: unknown[]): void {
135 |         if (level >= Log.ERROR) {
136 |             logger.error(Logger._format(name), ...args);
137 |         }
138 |     }
139 |     /* eslint-enable @typescript-eslint/no-unsafe-enum-comparison */
140 | }
141 | 
142 | Log.reset();
143 | 


--------------------------------------------------------------------------------
/src/utils/PopupUtils.test.ts:
--------------------------------------------------------------------------------
 1 | import { PopupUtils } from "./PopupUtils";
 2 | 
 3 | describe("PopupUtils", () => {
 4 |     describe("center", () => {
 5 |         it("should center a window to integer offsets", () => {
 6 |             Object.defineProperties(window, {
 7 |                 screenX: { enumerable: true, value: 50 },
 8 |                 screenY: { enumerable: true, value: 50 },
 9 |                 outerWidth: { enumerable: true, value: 101 },
10 |                 outerHeight: { enumerable: true, value: 101 },
11 |             });
12 |             const features = PopupUtils.center({
13 |                 width: 100,
14 |                 height: 100,
15 |             });
16 | 
17 |             expect(features.left).toBe(51);
18 |             expect(features.top).toBe(51);
19 |         });
20 | 
21 |         it("should restrict window placement to nonnegative offsets", () => {
22 |             Object.defineProperties(window, {
23 |                 screenX: { enumerable: true, value: 0 },
24 |                 screenY: { enumerable: true, value: 0 },
25 |                 outerWidth: { enumerable: true, value: 50 },
26 |                 outerHeight: { enumerable: true, value: 50 },
27 |             });
28 |             const features = PopupUtils.center({
29 |                 width: 100,
30 |                 height: 100,
31 |             });
32 | 
33 |             expect(features.left).toBe(0);
34 |             expect(features.top).toBe(0);
35 |         });
36 |     });
37 | 
38 |     describe("serialize", () => {
39 |         it("should encode boolean values as yes/no", () => {
40 |             const result = PopupUtils.serialize({ foo: true, bar: false });
41 | 
42 |             expect(result).toEqual("foo=yes,bar=no");
43 |         });
44 | 
45 |         it("should omit undefined properties", () => {
46 |             const result = PopupUtils.serialize({ foo: true, bar: undefined });
47 | 
48 |             expect(result).toEqual("foo=yes");
49 |         });
50 | 
51 |         it("should preserve numerical and string values", () => {
52 |             const result = PopupUtils.serialize({ foo: "yes", bar: 0, baz: 20, quux: "" });
53 | 
54 |             expect(result).toEqual("foo=yes,bar=0,baz=20,quux=");
55 |         });
56 |     });
57 | });
58 | 


--------------------------------------------------------------------------------
/src/utils/PopupUtils.ts:
--------------------------------------------------------------------------------
 1 | /**
 2 |  *
 3 |  * @public
 4 |  * @see https://developer.mozilla.org/en-US/docs/Web/API/Window/open#window_features
 5 |  */
 6 | export interface PopupWindowFeatures {
 7 |     left?: number;
 8 |     top?: number;
 9 |     width?: number;
10 |     height?: number;
11 |     menubar?: boolean | string;
12 |     toolbar?: boolean | string;
13 |     location?: boolean | string;
14 |     status?: boolean | string;
15 |     resizable?: boolean | string;
16 |     scrollbars?: boolean | string;
17 |     /** Close popup window after time in seconds, by default it is -1. To enable this feature, set value greater than 0. */
18 |     closePopupWindowAfterInSeconds?: number;
19 | 
20 |     [k: string]: boolean | string | number | undefined;
21 | }
22 | 
23 | export class PopupUtils {
24 |     /**
25 |      * Populates a map of window features with a placement centered in front of
26 |      * the current window. If no explicit width is given, a default value is
27 |      * binned into [800, 720, 600, 480, 360] based on the current window's width.
28 |      */
29 |     static center({ ...features }: PopupWindowFeatures): PopupWindowFeatures {
30 |         if (features.width == null)
31 |             features.width = [800, 720, 600, 480].find(width => width <= window.outerWidth / 1.618) ?? 360;
32 |         features.left ??= Math.max(0, Math.round(window.screenX + (window.outerWidth - features.width) / 2));
33 |         if (features.height != null)
34 |             features.top ??= Math.max(0, Math.round(window.screenY + (window.outerHeight - features.height) / 2));
35 |         return features;
36 |     }
37 | 
38 |     static serialize(features: PopupWindowFeatures): string {
39 |         return Object.entries(features)
40 |             .filter(([, value]) => value != null)
41 |             .map(([key, value]) => `${key}=${typeof value !== "boolean" ? value as string : value ? "yes" : "no"}`)
42 |             .join(",");
43 |     }
44 | }
45 | 


--------------------------------------------------------------------------------
/src/utils/Timer.ts:
--------------------------------------------------------------------------------
 1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
 2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 3 | 
 4 | import { Event } from "./Event";
 5 | import { Logger } from "./Logger";
 6 | 
 7 | /**
 8 |  * @internal
 9 |  */
10 | export class Timer extends Event<[void]> {
11 |     protected readonly _logger = new Logger(`Timer('${this._name}')`);
12 |     private _timerHandle: ReturnType | null = null;
13 |     private _expiration = 0;
14 | 
15 |     // get the time
16 |     public static getEpochTime(): number {
17 |         return Math.floor(Date.now() / 1000);
18 |     }
19 | 
20 |     public init(durationInSeconds: number): void {
21 |         const logger = this._logger.create("init");
22 |         durationInSeconds = Math.max(Math.floor(durationInSeconds), 1);
23 |         const expiration = Timer.getEpochTime() + durationInSeconds;
24 |         if (this.expiration === expiration && this._timerHandle) {
25 |             // no need to reinitialize to same expiration, so bail out
26 |             logger.debug("skipping since already initialized for expiration at", this.expiration);
27 |             return;
28 |         }
29 | 
30 |         this.cancel();
31 | 
32 |         logger.debug("using duration", durationInSeconds);
33 |         this._expiration = expiration;
34 | 
35 |         // we're using a fairly short timer and then checking the expiration in the
36 |         // callback to handle scenarios where the browser device sleeps, and then
37 |         // the timers end up getting delayed.
38 |         const timerDurationInSeconds = Math.min(durationInSeconds, 5);
39 |         this._timerHandle = setInterval(this._callback, timerDurationInSeconds * 1000);
40 |     }
41 | 
42 |     public get expiration(): number {
43 |         return this._expiration;
44 |     }
45 | 
46 |     public cancel(): void {
47 |         this._logger.create("cancel");
48 |         if (this._timerHandle) {
49 |             clearInterval(this._timerHandle);
50 |             this._timerHandle = null;
51 |         }
52 |     }
53 | 
54 |     protected _callback = (): void => {
55 |         const diff = this._expiration - Timer.getEpochTime();
56 |         this._logger.debug("timer completes in", diff);
57 | 
58 |         if (this._expiration <= Timer.getEpochTime()) {
59 |             this.cancel();
60 |             void super.raise();
61 |         }
62 |     };
63 | }
64 | 


--------------------------------------------------------------------------------
/src/utils/UrlUtils.test.ts:
--------------------------------------------------------------------------------
 1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
 2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 3 | 
 4 | import { UrlUtils } from "./UrlUtils";
 5 | 
 6 | describe("UrlUtils", () => {
 7 | 
 8 |     describe("readUrlParams", () => {
 9 | 
10 |         it("should return query params by default", () => {
11 |             // act
12 |             const result = UrlUtils.readParams("http://app/?foo=test");
13 |             const resultObj = Object.fromEntries(result);
14 | 
15 |             // assert
16 |             expect(resultObj).toHaveProperty("foo", "test");
17 |         });
18 | 
19 |         it("should return fragment params for response_mode=fragment", () => {
20 |             // act
21 |             const result = UrlUtils.readParams("http://app/?foo=test#bar=test_fragment", "fragment");
22 |             const resultObj = Object.fromEntries(result);
23 | 
24 |             // assert
25 |             expect(resultObj).toHaveProperty("bar", "test_fragment");
26 |         });
27 | 
28 |         it("should return query params when path is relative", () => {
29 |             // act
30 |             const result = UrlUtils.readParams("/app/?foo=test");
31 |             const resultObj = Object.fromEntries(result);
32 | 
33 |             // assert
34 |             expect(resultObj).toHaveProperty("foo", "test");
35 |         });
36 | 
37 |         it("should return fragment params for response_mode=fragment when path is relative", () => {
38 |             // act
39 |             const result = UrlUtils.readParams("/app/?foo=test#bar=test_fragment", "fragment");
40 |             const resultObj = Object.fromEntries(result);
41 | 
42 |             // assert
43 |             expect(resultObj).toHaveProperty("bar", "test_fragment");
44 |         });
45 | 
46 |         it("should throw an error when url is undefined", () => {
47 |             // act
48 |             const call = () => UrlUtils.readParams("");
49 | 
50 |             // assert
51 |             expect(call).toThrow(new TypeError("Invalid URL"));
52 |         });
53 | 
54 |     });
55 | });
56 | 


--------------------------------------------------------------------------------
/src/utils/UrlUtils.ts:
--------------------------------------------------------------------------------
 1 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
 2 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 3 | 
 4 | /**
 5 |  * @internal
 6 |  */
 7 | export class UrlUtils {
 8 |     public static readParams(url: string, responseMode: "query" | "fragment" = "query"): URLSearchParams {
 9 |         if (!url) throw new TypeError("Invalid URL");
10 |         // the base URL is irrelevant, it's just here to support relative url arguments
11 |         const parsedUrl = new URL(url, "http://127.0.0.1");
12 |         const params = parsedUrl[responseMode === "fragment" ? "hash" : "search"];
13 |         return new URLSearchParams(params.slice(1));
14 |     }
15 | }
16 | 
17 | /**
18 |  * @internal
19 |  */
20 | export const URL_STATE_DELIMITER = ";";


--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./CryptoUtils";
2 | export * from "./Event";
3 | export * from "./JwtUtils";
4 | export * from "./Logger";
5 | export * from "./PopupUtils";
6 | export * from "./Timer";
7 | export * from "./UrlUtils";
8 | 


--------------------------------------------------------------------------------
/test/integration/OidcClient.spec.js:
--------------------------------------------------------------------------------
 1 | /* global IdentityModel,oidc,assert,fail */
 2 | // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
 3 | // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 4 | 
 5 | IdentityModel.Log.logger = console;
 6 | 
 7 | window.sessionStorage.clear();
 8 | window.localStorage.clear();
 9 | 
10 | describe("OidcClient", function () {
11 |     var settings;
12 |     var oidcClient;
13 | 
14 |     beforeEach(function () {
15 |         IdentityModel.Log.level = IdentityModel.Log.NONE;
16 | 
17 |         settings = {
18 |             authority: "http://localhost:5000/oidc",
19 |             metadata: oidc.metadata,
20 |             signingKeys: oidc.signingKeys,
21 | 
22 |             client_id: "js.tokenmanager",
23 |             redirect_uri: "http://localhost:5000/index.html",
24 |             post_logout_redirect_uri: "http://localhost:5000/index.html",
25 |             response_type: "id_token token",
26 |             scope: "openid email roles",
27 | 
28 |             filterProtocolClaims: true,
29 |             loadUserInfo: false,
30 |         };
31 | 
32 |         oidcClient = new IdentityModel.OidcClient(settings);
33 |     });
34 | 
35 |     it("should perform signin", function (done) {
36 |         oidcClient.createSigninRequest({ data: { foo: 1 } }).then(
37 |             function (req) {
38 |                 var url = oidc.processAuthorization(req.url);
39 | 
40 |                 oidcClient.processSigninResponse(url).then(
41 |                     function (res) {
42 |                         assert(res, "result");
43 |                         assert(res.state, "res.result");
44 |                         assert(res.state.foo === 1, "foo===1");
45 | 
46 |                         done();
47 |                     },
48 |                     function (e) {
49 |                         fail(e);
50 |                     },
51 |                 );
52 |             },
53 |             function (e) {
54 |                 fail(e);
55 |             },
56 |         );
57 |     });
58 | 
59 |     it("should perform signout", function (done) {
60 |         oidcClient.createSignoutRequest({ data: { foo: 1 } }).then(
61 |             function (req) {
62 |                 var url = oidc.processEndSession(req.url);
63 | 
64 |                 oidcClient.processSignoutResponse(url).then(
65 |                     function (res) {
66 |                         assert(res, "result");
67 |                         assert(res.state, "res.result");
68 |                         assert(res.state.foo === 1, "foo===1");
69 | 
70 |                         done();
71 |                     },
72 |                     function (e) {
73 |                         fail(e);
74 |                     },
75 |                 );
76 |             },
77 |             function (e) {
78 |                 fail(e);
79 |             },
80 |         );
81 |     });
82 | });
83 | 


--------------------------------------------------------------------------------
/test/setup.ts:
--------------------------------------------------------------------------------
 1 | import { Log } from "../src";
 2 | import "fake-indexeddb/auto";
 3 | import { TextEncoder } from "util";
 4 | import { webcrypto } from "node:crypto";
 5 | 
 6 | beforeAll(() => {
 7 |     globalThis.TextEncoder = TextEncoder;
 8 |     Object.assign(globalThis.crypto, {
 9 |         subtle: webcrypto.subtle,
10 |     });
11 |     globalThis.fetch = jest.fn();
12 | 
13 |     const pageshow = () => window.dispatchEvent(new Event("pageshow"));
14 | 
15 |     const location = Object.defineProperties({}, {
16 |         ...Object.getOwnPropertyDescriptors(window.location),
17 |         assign: {
18 |             enumerable: true,
19 |             value: jest.fn(pageshow),
20 |         },
21 |         replace: {
22 |             enumerable: true,
23 |             value: jest.fn(pageshow),
24 |         },
25 |     });
26 |     Object.defineProperty(window, "location", {
27 |         enumerable: true,
28 |         get: () => location,
29 |     });
30 | });
31 | 
32 | beforeEach(() => {
33 |     Log.setLevel(Log.NONE);
34 |     Log.setLogger(console);
35 | });
36 | 


--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "extends": "./tsconfig.json",
 3 |   "compilerOptions": {
 4 |     // match output dir to input dir. e.g. dist/index instead of dist/src/index
 5 |     "rootDir": "./src",
 6 |     "outDir": "./lib",
 7 |     // output .d.ts declaration files for consumers
 8 |     "declaration": true,
 9 |     "declarationMap": true,
10 |     "noEmit": false,
11 |     "emitDeclarationOnly": true,
12 |     "tsBuildInfoFile": "tsconfig.build.tsbuildinfo"
13 |   },
14 |   "files": ["src/index.ts"],
15 |   "include": []
16 | }
17 | 


--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs
 3 |   "compilerOptions": {
 4 |     "target": "ES2019",
 5 |     "module": "ESNext",
 6 |     "moduleResolution": "node",
 7 |     "lib": ["ES2019", "DOM", "DOM.Iterable"],
 8 |     "incremental": true,
 9 |     "noEmit": true,
10 |     "tsBuildInfoFile": "tsconfig.tsbuildinfo",
11 |     // stricter type-checking for stronger correctness. Recommended by TS
12 |     "strict": true,
13 |     // interop between ESM and CJS modules. Recommended by TS
14 |     "esModuleInterop": true,
15 |     // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS
16 |     "skipLibCheck": true,
17 |     // error out if import and file system have a casing mismatch. Recommended by TS
18 |     "forceConsistentCasingInFileNames": true,
19 |     // ensure type imports are side-effect free by enforcing that `import type` is used
20 |     "verbatimModuleSyntax": true,
21 |     // prevent the use of features that are incompatible with isolated transpilation
22 |     "isolatedModules": true
23 |   },
24 |   "include": ["src/", "test/"]
25 | }
26 | 


--------------------------------------------------------------------------------
/typedoc.json:
--------------------------------------------------------------------------------
 1 | {
 2 |     "entryPoints": [
 3 |         "src/index.ts"
 4 |     ],
 5 |     "out": "docs/pages",
 6 |     "readme": "docs/index.md",
 7 |     "excludeProtected": true,
 8 |     "excludePrivate": true,
 9 |     "excludeInternal": true
10 | }
11 | 


--------------------------------------------------------------------------------