Skip to content

Commit

Permalink
DPoP Support (#1495)
Browse files Browse the repository at this point in the history
OKTA-676066 feat: DPoP support
  • Loading branch information
jaredperreault-okta authored Apr 30, 2024
1 parent 4555080 commit ff5354f
Show file tree
Hide file tree
Showing 57 changed files with 2,612 additions and 502 deletions.
7 changes: 7 additions & 0 deletions .bacon.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ test_suites:
script_name: e2e-mfa
criteria: MERGE
queue_name: small
- name: e2e-dpop
script_path: ../okta-auth-js/scripts/e2e
sort_order: '5'
timeout: '10'
script_name: e2e-dpop
criteria: MERGE
queue_name: small
- name: sample-express-embedded-auth-with-sdk
script_path: ../okta-auth-js/scripts/samples
sort_order: '6'
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Features

- [#1495](https://github.com/okta/okta-auth-js/pull/1495) add: DPoP support
- [#1507](https://github.com/okta/okta-auth-js/pull/1507) add: new method `getOrRenewAccessToken`
- [#1505](https://github.com/okta/okta-auth-js/pull/1505) add: support of `revokeSessions` param for `OktaPassword` authenticator (can be used in `reset-authenticator` remediation)
- [#1512](https://github.com/okta/okta-auth-js/pull/1512) add: new service `RenewOnTabActivation`
Expand Down
142 changes: 142 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,105 @@ Additionally, if using hash routing, we recommend using PKCE and responseMode "q
2. Add tokens to the `TokenManager`: [tokenManager.setTokens](#tokenmanagersettokenstokens)
6. Read saved route and redirect to it: [getOriginalUri](#getoriginaluristate)

### Enabling DPoP
<sub><sup>*Reference: DPoP (Demonstrating Proof-of-Possession) - [RFC9449](https://datatracker.ietf.org/doc/html/rfc9449)*</sub></sup>

#### Requirements
* `DPoP` must be enabled in your Okta application ([Guide: Configure DPoP](https://developer.okta.com/docs/guides/dpop/main/))
* Only supported on web (browser)
* `https` is required. A [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts) is required for `WebCrypto.subtle`
* Targeted browsers must support `IndexedDB` ([MDN](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API), [caniuse](https://caniuse.com/indexeddb))
* :warning: IE11 (and lower) is not supported!

#### Configuration
```javascript
const config = {
// other configurations
pkce: true, // required
dpop: true,
};

const authClient = new OktaAuth(config);
```

#### Providing DPoP Proof to Resource Requests
<sub><sup>*Reference: **The DPoP Authentication Scheme** ([RFC9449](https://datatracker.ietf.org/doc/html/rfc9449#name-the-dpop-authentication-sch))*</sub></sup>

##### DPoP-Protected Resource Request ([link](https://datatracker.ietf.org/doc/html/rfc9449#name-dpop-protected-resource-req))
```
GET /protectedresource HTTP/1.1
Host: resource.example.org
Authorization: DPoP Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsIm...
```

##### Fetching DPoP-Protected Resource
```javascript
async function dpopAuthenticatedFetch (url, options) {
const { method } = options;
const dpop = await authClient.getDPoPAuthorizationHeaders({ url, method });
// dpop = { Authorization: "DPoP token****", Dpop: "proof****" }
const headers = new Headers({...options.headers, ...dpop});
return fetch(url, {...options, headers });
}
```

#### Handling `use_dpop_nonce`
<sub><sup>*Reference: **Resource Server-Provided Nonce** ([RFC9449](https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no))*</sub></sup>

> Resource servers can also choose to provide a nonce value to be included in DPoP proofs sent to them. They provide the nonce using the DPoP-Nonce header in the same way that authorization servers do...
##### Resource Server Response
```
HTTP/1.1 401 Unauthorized
WWW-Authenticate: DPoP error="use_dpop_nonce", \
error_description="Resource server requires nonce in DPoP proof"
DPoP-Nonce: eyJ7S_zG.eyJH0-Z.HX4w-7v
```
##### Handling Response
```javascript
async function dpopAuthenticatedFetch (url, options) {
// ...previous example...
const resp = await fetch(url, {...options, headers });
// resp = HTTP/1.1 401 Unauthorized...

if (!resp.ok) {
const nonce = authClient.parseUseDPoPNonceError(resp.headers);
if (nonce) {
const retryDpop = await authClient.getDPoPAuthorizationHeaders({ url, method, nonce });
const retryHeaders = new Headers({...options.headers, ...retryDpop});
return fetch(url, {...options, headers: retryHeaders });
}
}

return resp;
}
```

#### Ensure browser can support DPoP (*Recommended*)
DPoP requires certain browser features. A user using a browser without the required features will unable to complete a request for tokens. It's recommended to verify browser support during application bootstrapping.

```javascript
// App.tsx
useEffect(() => {
if (!authClient.features.isDPoPSupported()) {
// user will be unable to request tokens
navigate('/unsupported-error-page');
}
}, []);
```

#### Clear DPoP Storage (*Recommended*)
DPoP requires the generation of a `CryptoKeyPair` which needs to be persisted in storage. Methods like `signOut()` or `revokeAccessToken()` will clear the key pair, however users don't always explicitly logout. It's therefore good practice to clear storage before login to flush any orphaned key pairs generated from previously requested tokens.

```javascript
async function login (options) {
await authClient.clearDPoPStorage(); // clear possibly orphaned key pairs

return authClient.signInWithRedirect(options);
}
```

## Configuration reference

Whether you are using this SDK to implement an OIDC flow or for communicating with the [Authentication API](https://developer.okta.com/docs/api/resources/authn), the only required configuration option is `issuer`, which is the URL to an Okta [Authorization Server](https://developer.okta.com/docs/guides/customize-authz-server/overview/)
Expand Down Expand Up @@ -470,6 +569,13 @@ A client-provided string that will be passed to the server endpoint and returned

Default value is `true` which enables the [PKCE OAuth Flow](#pkce-oauth-20-flow). To use the [Implicit Flow](#implicit-oauth-20-flow) or [Authorization Code Flow](#authorization-code-flow-for-web-and-native-client-types), set `pkce` to `false`.

#### `dpop`

Default value is `false`. Set to `true` to enable `DPoP` (Demonstrating Proof-of-Possession ([RFC9449](https://datatracker.ietf.org/doc/html/rfc9449)))

See Guide: [Enabling DPoP](#enabling-dpop)


#### responseMode

When requesting tokens using [token.getWithRedirect](#tokengetwithredirectoptions) values will be returned as parameters appended to the [redirectUri](#configuration-options).
Expand Down Expand Up @@ -915,6 +1021,9 @@ The amount of time, in seconds, a tab needs to be inactive for the `RenewOnTabAc
* [tx.resume](#txresume)
* [tx.exists](#txexists)
* [transaction.status](#transactionstatus)
* [getDPoPAuthorizationHeaders](#getdpopauthorizationheaders)
* [parseUseDPoPNonceError](#parseusedpopnonceerror)
* [clearDPoPStorage](#cleardpopstorage)
* [session](#session)
* [session.setCookieAndRedirect](#sessionsetcookieandredirectsessiontoken-redirecturi)
* [session.exists](#sessionexists)
Expand Down Expand Up @@ -1270,6 +1379,39 @@ See [authn API](docs/authn.md#txexists).

See [authn API](docs/authn.md#transactionstatus).

### `getDPoPAuthorizationHeaders(params)`

> :link: web browser only <br>
> :hourglass: async <br>
Requires [dpop](#dpop) set to `true`. Returns `Authorization` and `Dpop` header values to build a DPoP protected-request.

Params: `url` and (http) `method` are required.
* `accessToken` is optional, but will be read from `tokenStorage` if not provided
* `nonce` is optional, may be provided via `use_dpop_nonce` pattern from Resource Server ([more info](#handling-use_dpop_nonce))

### `parseUseDPoPNonceError(headers)`

> :link: web browser only <br>
Utility to extract and parse the `WWW-Authenticate` and `DPoP-Nonce` headers from a network response from a DPoP-protected request. Should the response be in the following format, the `nonce` value will be returned. Otherwise returns `null`

```
HTTP/1.1 401 Unauthorized
WWW-Authenticate: DPoP error="use_dpop_nonce", \
error_description="Resource server requires nonce in DPoP proof"
DPoP-Nonce: eyJ7S_zG.eyJH0-Z.HX4w-7v
```

### `clearDPoPStorage(clearAll=false)`

> :link: web browser only <br>
> :hourglass: async <br>
Clears storage location of `CryptoKeyPair`s generated and used by DPoP. Pass `true` to remove all key pairs as it's possible for orphaned key pairs to exist. If `clearAll` is `false`, the key pair bound to the current `accessToken` in tokenStorage will be removed.

It's recommended to call this function during user login. [See Example](#clear-dpop-storage-recommended)

### `session`

#### `session.setCookieAndRedirect(sessionToken, redirectUri)`
Expand Down
1 change: 1 addition & 0 deletions jest.server.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const config = Object.assign({}, baseConfig, {
'oidc/renewToken.ts',
'oidc/renewTokens.ts',
'oidc/enrollAuthenticator',
'oidc/dpop',
'TokenManager/browser',
'SyncStorageService',
'LeaderElectionService',
Expand Down
1 change: 1 addition & 0 deletions lib/base/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface FeaturesAPI {
isTokenVerifySupported(): boolean;
isPKCESupported(): boolean;
isIE11OrLess(): boolean;
isDPoPSupported(): boolean;
}


Expand Down
11 changes: 10 additions & 1 deletion lib/errors/OAuthError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
*/

import CustomError from './CustomError';
import type { HttpResponse } from '../http';

export default class OAuthError extends CustomError {
errorCode: string;
Expand All @@ -21,7 +22,9 @@ export default class OAuthError extends CustomError {
error: string;
error_description: string;

constructor(errorCode: string, summary: string) {
resp: HttpResponse | null = null;

constructor(errorCode: string, summary: string, resp?: HttpResponse) {
super(summary);

this.name = 'OAuthError';
Expand All @@ -31,6 +34,12 @@ export default class OAuthError extends CustomError {
// for widget / idx-js backward compatibility
this.error = errorCode;
this.error_description = summary;

// an OAuth error (should) always result from a network request
// therefore include that in error for potential error handling
if (resp) {
this.resp = resp;
}
}
}

88 changes: 88 additions & 0 deletions lib/errors/WWWAuthError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*!
* Copyright (c) 2015-present, Okta, Inc. and/or its affiliates. All rights reserved.
* The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
*
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and limitations under the License.
*/


import type { HttpResponse } from '../http';
import CustomError from './CustomError';
import { isFunction } from '../util';

// Error thrown after an unsuccessful network request which requires an Authorization header
// and returns a 4XX error with a www-authenticate header. The header value is parsed to construct
// an error instance, which contains key/value pairs parsed out
export default class WWWAuthError extends CustomError {
static UNKNOWN_ERROR = 'UNKNOWN_WWW_AUTH_ERROR';

scheme: string;
parameters: Record<string, string>;
name = 'WWWAuthError';

resp: HttpResponse | null = null;

constructor(scheme: string, parameters: Record<string, string>, resp?: HttpResponse) {
// defaults to unknown error. `error` being returned in the www-authenticate header is expected
// but cannot be guaranteed. Throwing an error within a error constructor seems awkward
super(parameters.error ?? WWWAuthError.UNKNOWN_ERROR);
this.scheme = scheme;
this.parameters = parameters;

if (resp) {
this.resp = resp;
}
}

// convenience references
get error (): string { return this.parameters.error; }
get errorCode (): string { return this.error; } // parity with other error props
// eslint-disable-next-line camelcase
get error_description (): string { return this.parameters.error_description; }
// eslint-disable-next-line camelcase
get errorDescription (): string { return this.error_description; }
get errorSummary (): string { return this.errorDescription; } // parity with other error props
get realm (): string { return this.parameters.realm; }

// parses the www-authenticate header for releveant
static parseHeader (header: string): WWWAuthError | null {
// header cannot be empty string
if (!header) {
return null;
}

// example string: Bearer error="invalid_token", error_description="The access token is invalid"
// regex will match on `error="invalid_token", error_description="The access token is invalid"`
// see unit test for more examples of possible www-authenticate values
// eslint-disable-next-line max-len
const regex = /(?:,|, )?([a-zA-Z0-9!#$%&'*+\-.^_`|~]+)=(?:"([a-zA-Z0-9!#$%&'*+\-.,^_`|~ /:]+)"|([a-zA-Z0-9!#$%&'*+\-.^_`|~/:]+))/g;
const firstSpace = header.indexOf(' ');
const scheme = header.slice(0, firstSpace);
const remaining = header.slice(firstSpace + 1);
const params = {};

// Reference: foo="hello", bar="bye"
// i=0, match=[foo="hello1", foo, hello]
// i=1, match=[bar="bye", bar, bye]
let match;
while ((match = regex.exec(remaining)) !== null) {
params[match[1]] = (match[2] ?? match[3]);
}

return new WWWAuthError(scheme, params);
}

// finds the value of the `www-authenticate` header. HeadersInit allows for a few different
// representations of headers with different access patterns (.get vs [key])
static getWWWAuthenticateHeader (headers: HeadersInit = {}): string | null {
if (isFunction((headers as Headers)?.get)) {
return (headers as Headers).get('WWW-Authenticate');
}
return headers['www-authenticate'] ?? headers['WWW-Authenticate'];
}
}
9 changes: 8 additions & 1 deletion lib/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import AuthApiError from './AuthApiError';
import AuthPollStopError from './AuthPollStopError';
import AuthSdkError from './AuthSdkError';
import OAuthError from './OAuthError';
import WWWAuthError from './WWWAuthError';

function isAuthApiError(obj: any): obj is AuthApiError {
return (obj instanceof AuthApiError);
Expand All @@ -24,13 +25,19 @@ function isOAuthError(obj: any): obj is OAuthError {
return (obj instanceof OAuthError);
}

function isWWWAuthError(obj: any): obj is WWWAuthError {
return (obj instanceof WWWAuthError);
}

export {
isAuthApiError,
isOAuthError,
isWWWAuthError,
AuthApiError,
AuthPollStopError,
AuthSdkError,
OAuthError
OAuthError,
WWWAuthError
};

export * from './types';
13 changes: 12 additions & 1 deletion lib/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,17 @@ export function isPopupPostMessageSupported() {
return false;
}

export function isTokenVerifySupported() {
function isWebCryptoSubtleSupported () {
return typeof webcrypto !== 'undefined'
&& webcrypto !== null
&& typeof webcrypto.subtle !== 'undefined'
&& typeof Uint8Array !== 'undefined';
}

export function isTokenVerifySupported() {
return isWebCryptoSubtleSupported();
}

export function hasTextEncoder() {
return typeof TextEncoder !== 'undefined';
}
Expand All @@ -77,3 +81,10 @@ export function isLocalhost() {
return isBrowser() && window.location.hostname === 'localhost';
}

// For now, DPoP is only supported on browsers
export function isDPoPSupported () {
return !isIE11OrLess() &&
typeof window.indexedDB !== 'undefined' &&
hasTextEncoder() &&
isWebCryptoSubtleSupported();
}
Loading

0 comments on commit ff5354f

Please sign in to comment.