Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DPoP Support #1495

Merged
merged 2 commits into from
Apr 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>
lesterchoi-okta marked this conversation as resolved.
Show resolved Hide resolved

#### 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****" }
mikenachbaur-okta marked this conversation as resolved.
Show resolved Hide resolved
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...

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: It would be more precise to say that the resource server is requiring a nonce (as the error_description describes) rather than providing.


##### 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 {
jaredperreault-okta marked this conversation as resolved.
Show resolved Hide resolved

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool! I wonder if there's a way to leverage this utility for step up auth error responses. Will have to experiment. I ended up having to write my own WWW-Authenticate header parser

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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const regex = /(?:,|, )?([a-zA-Z0-9!#$%&'*+\-.^_`|~]+)=(?:"([a-zA-Z0-9!#$%&'*+\-.,^_`|~ /:]+)"|([a-zA-Z0-9!#$%&'*+\-.^_`|~/:]+))/g;
const regex = /(?:,|, )?([a-zA-Z0-9!#$%&'*+\-.^_`|~]+)=(?:"([a-zA-Z0-9!#$%&'*+\-.,^_`|~ \/:]+)"|([a-zA-Z0-9!#$%&'*+\-.^_`|~\/:]+))/g;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#1495 (comment)

yarn run v1.22.19
$ eslint --ext .js,.ts,.jsx .

/Users/jared/Code/devex/auth-js/lib/errors/WWWAuthError.ts
  62:90  error  Unnecessary escape character: \/  no-useless-escape

✖ 1 problem (1 error, 0 warnings)

error Command failed with exit code 1.

I think because the character is inside a [ ] it doesn't need escaping (where - does)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me guess, it doesn't use PCRE but some weird variation? 🤷🏼‍♀️

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() {
mikenachbaur-okta marked this conversation as resolved.
Show resolved Hide resolved
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