├── .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 | [](https://npm.im/oidc-client-ts)
4 | [](https://github.com/authts/oidc-client-ts/actions/workflows/ci.yml)
5 | [](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 |
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------