@auth0/auth0-spa-js 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/auth0-spa-js.development.js +600 -14
- package/dist/auth0-spa-js.development.js.map +1 -1
- package/dist/auth0-spa-js.production.esm.js +1 -1
- package/dist/auth0-spa-js.production.esm.js.map +1 -1
- package/dist/auth0-spa-js.production.js +1 -1
- package/dist/auth0-spa-js.production.js.map +1 -1
- package/dist/auth0-spa-js.worker.development.js +10 -2
- package/dist/auth0-spa-js.worker.development.js.map +1 -1
- package/dist/auth0-spa-js.worker.production.js +1 -1
- package/dist/auth0-spa-js.worker.production.js.map +1 -1
- package/dist/lib/auth0-spa-js.cjs.js +641 -14
- package/dist/lib/auth0-spa-js.cjs.js.map +1 -1
- package/dist/typings/Auth0Client.d.ts +50 -0
- package/dist/typings/Auth0Client.utils.d.ts +1 -1
- package/dist/typings/api.d.ts +1 -1
- package/dist/typings/cache/shared.d.ts +1 -0
- package/dist/typings/dpop/dpop.d.ts +17 -0
- package/dist/typings/dpop/storage.d.ts +27 -0
- package/dist/typings/dpop/utils.d.ts +15 -0
- package/dist/typings/errors.d.ts +7 -0
- package/dist/typings/fetcher.d.ts +48 -0
- package/dist/typings/global.d.ts +19 -0
- package/dist/typings/http.d.ts +2 -1
- package/dist/typings/index.d.ts +2 -1
- package/dist/typings/utils.d.ts +6 -0
- package/dist/typings/version.d.ts +1 -1
- package/package.json +22 -19
- package/src/Auth0Client.ts +112 -5
- package/src/Auth0Client.utils.ts +4 -2
- package/src/api.ts +6 -1
- package/src/cache/shared.ts +1 -0
- package/src/dpop/dpop.ts +56 -0
- package/src/dpop/storage.ts +134 -0
- package/src/dpop/utils.ts +66 -0
- package/src/errors.ts +11 -0
- package/src/fetcher.ts +224 -0
- package/src/global.ts +21 -0
- package/src/http.ts +70 -5
- package/src/index.ts +4 -1
- package/src/utils.ts +15 -0
- package/src/version.ts +1 -1
- package/src/worker/token.worker.ts +11 -5
package/src/dpop/dpop.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { DpopStorage } from './storage';
|
|
2
|
+
import * as dpopUtils from './utils';
|
|
3
|
+
|
|
4
|
+
export class Dpop {
|
|
5
|
+
protected readonly storage: DpopStorage;
|
|
6
|
+
|
|
7
|
+
public constructor(clientId: string) {
|
|
8
|
+
this.storage = new DpopStorage(clientId);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
public getNonce(id?: string): Promise<string | undefined> {
|
|
12
|
+
return this.storage.findNonce(id);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
public setNonce(nonce: string, id?: string): Promise<void> {
|
|
16
|
+
return this.storage.setNonce(nonce, id);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
protected async getOrGenerateKeyPair(): Promise<dpopUtils.KeyPair> {
|
|
20
|
+
let keyPair = await this.storage.findKeyPair();
|
|
21
|
+
|
|
22
|
+
if (!keyPair) {
|
|
23
|
+
keyPair = await dpopUtils.generateKeyPair();
|
|
24
|
+
await this.storage.setKeyPair(keyPair);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return keyPair;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
public async generateProof(params: {
|
|
31
|
+
url: string;
|
|
32
|
+
method: string;
|
|
33
|
+
nonce?: string;
|
|
34
|
+
accessToken?: string;
|
|
35
|
+
}): Promise<string> {
|
|
36
|
+
const keyPair = await this.getOrGenerateKeyPair();
|
|
37
|
+
|
|
38
|
+
return dpopUtils.generateProof({
|
|
39
|
+
keyPair,
|
|
40
|
+
...params
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
public async calculateThumbprint(): Promise<string> {
|
|
45
|
+
const keyPair = await this.getOrGenerateKeyPair();
|
|
46
|
+
|
|
47
|
+
return dpopUtils.calculateThumbprint(keyPair);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
public async clear(): Promise<void> {
|
|
51
|
+
await Promise.all([
|
|
52
|
+
this.storage.clearNonces(),
|
|
53
|
+
this.storage.clearKeyPairs()
|
|
54
|
+
]);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { type KeyPair } from './utils';
|
|
2
|
+
|
|
3
|
+
const VERSION = 1;
|
|
4
|
+
const NAME = 'auth0-spa-js';
|
|
5
|
+
const TABLES = {
|
|
6
|
+
NONCE: 'nonce',
|
|
7
|
+
KEYPAIR: 'keypair'
|
|
8
|
+
} as const;
|
|
9
|
+
|
|
10
|
+
const AUTH0_NONCE_ID = 'auth0';
|
|
11
|
+
|
|
12
|
+
type Table = (typeof TABLES)[keyof typeof TABLES];
|
|
13
|
+
|
|
14
|
+
export class DpopStorage {
|
|
15
|
+
protected readonly clientId: string;
|
|
16
|
+
protected dbHandle: IDBDatabase | undefined;
|
|
17
|
+
|
|
18
|
+
constructor(clientId: string) {
|
|
19
|
+
this.clientId = clientId;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
protected getVersion(): number {
|
|
23
|
+
return VERSION;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
protected createDbHandle(): Promise<IDBDatabase> {
|
|
27
|
+
const req = window.indexedDB.open(NAME, this.getVersion());
|
|
28
|
+
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
req.onupgradeneeded = () =>
|
|
31
|
+
Object.values(TABLES).forEach(t => req.result.createObjectStore(t));
|
|
32
|
+
|
|
33
|
+
req.onerror = () => reject(req.error);
|
|
34
|
+
req.onsuccess = () => resolve(req.result);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
protected async getDbHandle(): Promise<IDBDatabase> {
|
|
39
|
+
if (!this.dbHandle) {
|
|
40
|
+
this.dbHandle = await this.createDbHandle();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return this.dbHandle;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
protected async executeDbRequest<T = unknown>(
|
|
47
|
+
table: string,
|
|
48
|
+
mode: IDBTransactionMode,
|
|
49
|
+
requestFactory: (table: IDBObjectStore) => IDBRequest<T>
|
|
50
|
+
): Promise<T> {
|
|
51
|
+
const db = await this.getDbHandle();
|
|
52
|
+
|
|
53
|
+
const txn = db.transaction(table, mode);
|
|
54
|
+
const store = txn.objectStore(table);
|
|
55
|
+
|
|
56
|
+
const request = requestFactory(store);
|
|
57
|
+
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
request.onsuccess = () => resolve(request.result);
|
|
60
|
+
request.onerror = () => reject(request.error);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
protected buildKey(id?: string): string {
|
|
65
|
+
const finalId = id
|
|
66
|
+
? `_${id}` // prefix to avoid collisions
|
|
67
|
+
: AUTH0_NONCE_ID;
|
|
68
|
+
|
|
69
|
+
return `${this.clientId}::${finalId}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
public setNonce(nonce: string, id?: string): Promise<void> {
|
|
73
|
+
return this.save(TABLES.NONCE, this.buildKey(id), nonce);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
public setKeyPair(keyPair: KeyPair): Promise<void> {
|
|
77
|
+
return this.save(TABLES.KEYPAIR, this.buildKey(), keyPair);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
protected async save(
|
|
81
|
+
table: Table,
|
|
82
|
+
key: IDBValidKey,
|
|
83
|
+
obj: unknown
|
|
84
|
+
): Promise<void> {
|
|
85
|
+
return void await this.executeDbRequest(table, 'readwrite', table =>
|
|
86
|
+
table.put(obj, key)
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
public findNonce(id?: string): Promise<string | undefined> {
|
|
91
|
+
return this.find(TABLES.NONCE, this.buildKey(id));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
public findKeyPair(): Promise<KeyPair | undefined> {
|
|
95
|
+
return this.find(TABLES.KEYPAIR, this.buildKey());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
protected find<T = unknown>(
|
|
99
|
+
table: Table,
|
|
100
|
+
key: IDBValidKey
|
|
101
|
+
): Promise<T | undefined> {
|
|
102
|
+
return this.executeDbRequest(table, 'readonly', table => table.get(key));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
protected async deleteBy(
|
|
106
|
+
table: Table,
|
|
107
|
+
predicate: (key: IDBValidKey) => boolean
|
|
108
|
+
): Promise<void> {
|
|
109
|
+
const allKeys = await this.executeDbRequest(table, 'readonly', table =>
|
|
110
|
+
table.getAllKeys()
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
allKeys
|
|
114
|
+
?.filter(predicate)
|
|
115
|
+
.map(k =>
|
|
116
|
+
this.executeDbRequest(table, 'readwrite', table => table.delete(k))
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
protected deleteByClientId(table: Table, clientId: string): Promise<void> {
|
|
121
|
+
return this.deleteBy(
|
|
122
|
+
table,
|
|
123
|
+
k => typeof k === 'string' && k.startsWith(`${clientId}::`)
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
public clearNonces(): Promise<void> {
|
|
128
|
+
return this.deleteByClientId(TABLES.NONCE, this.clientId);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
public clearKeyPairs(): Promise<void> {
|
|
132
|
+
return this.deleteByClientId(TABLES.KEYPAIR, this.clientId);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as dpopLib from 'dpop';
|
|
2
|
+
|
|
3
|
+
export const DPOP_NONCE_HEADER = 'dpop-nonce';
|
|
4
|
+
|
|
5
|
+
const KEY_PAIR_ALGORITHM: dpopLib.JWSAlgorithm = 'ES256';
|
|
6
|
+
|
|
7
|
+
const SUPPORTED_GRANT_TYPES = [
|
|
8
|
+
'authorization_code',
|
|
9
|
+
'refresh_token',
|
|
10
|
+
'urn:ietf:params:oauth:grant-type:token-exchange'
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export type KeyPair = Readonly<dpopLib.KeyPair>;
|
|
14
|
+
|
|
15
|
+
type GenerateProofParams = {
|
|
16
|
+
keyPair: KeyPair;
|
|
17
|
+
url: string;
|
|
18
|
+
method: string;
|
|
19
|
+
nonce?: string;
|
|
20
|
+
accessToken?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function generateKeyPair(): Promise<KeyPair> {
|
|
24
|
+
return dpopLib.generateKeyPair(KEY_PAIR_ALGORITHM, { extractable: false });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function calculateThumbprint(
|
|
28
|
+
keyPair: Pick<KeyPair, 'publicKey'>
|
|
29
|
+
): Promise<string> {
|
|
30
|
+
return dpopLib.calculateThumbprint(keyPair.publicKey);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeUrl(url: string): string {
|
|
34
|
+
const parsedUrl = new URL(url);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* "The HTTP target URI (...) without query and fragment parts"
|
|
38
|
+
* @see {@link https://www.rfc-editor.org/rfc/rfc9449.html#section-4.2-4.6}
|
|
39
|
+
*/
|
|
40
|
+
parsedUrl.search = '';
|
|
41
|
+
parsedUrl.hash = '';
|
|
42
|
+
|
|
43
|
+
return parsedUrl.href;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function generateProof({
|
|
47
|
+
keyPair,
|
|
48
|
+
url,
|
|
49
|
+
method,
|
|
50
|
+
nonce,
|
|
51
|
+
accessToken
|
|
52
|
+
}: GenerateProofParams): Promise<string> {
|
|
53
|
+
const normalizedUrl = normalizeUrl(url);
|
|
54
|
+
|
|
55
|
+
return dpopLib.generateProof(
|
|
56
|
+
keyPair,
|
|
57
|
+
normalizedUrl,
|
|
58
|
+
method,
|
|
59
|
+
nonce,
|
|
60
|
+
accessToken
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isGrantTypeSupported(grantType: string): boolean {
|
|
65
|
+
return SUPPORTED_GRANT_TYPES.includes(grantType);
|
|
66
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -96,6 +96,17 @@ export class MissingRefreshTokenError extends GenericError {
|
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Error thrown when the wrong DPoP nonce is used and a potential subsequent retry wasn't able to fix it.
|
|
101
|
+
*/
|
|
102
|
+
export class UseDpopNonceError extends GenericError {
|
|
103
|
+
constructor(public newDpopNonce: string | undefined) {
|
|
104
|
+
super('use_dpop_nonce', 'Server rejected DPoP proof: wrong nonce');
|
|
105
|
+
|
|
106
|
+
Object.setPrototypeOf(this, UseDpopNonceError.prototype);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
99
110
|
/**
|
|
100
111
|
* Returns an empty string when value is falsy, or when it's value is included in the exclude argument.
|
|
101
112
|
* @param value The value to check
|
package/src/fetcher.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { DPOP_NONCE_HEADER } from './dpop/utils';
|
|
2
|
+
import { UseDpopNonceError } from './errors';
|
|
3
|
+
|
|
4
|
+
export type ResponseHeaders =
|
|
5
|
+
| Record<string, string | null | undefined>
|
|
6
|
+
| [string, string][]
|
|
7
|
+
| { get(name: string): string | null | undefined };
|
|
8
|
+
|
|
9
|
+
export type CustomFetchMinimalOutput = {
|
|
10
|
+
status: number;
|
|
11
|
+
headers: ResponseHeaders;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type CustomFetchImpl<TOutput extends CustomFetchMinimalOutput> = (
|
|
15
|
+
req: Request
|
|
16
|
+
) => Promise<TOutput>;
|
|
17
|
+
|
|
18
|
+
type AccessTokenFactory = () => Promise<string>;
|
|
19
|
+
|
|
20
|
+
export type FetcherConfig<TOutput extends CustomFetchMinimalOutput> = {
|
|
21
|
+
getAccessToken?: AccessTokenFactory;
|
|
22
|
+
baseUrl?: string;
|
|
23
|
+
fetch?: CustomFetchImpl<TOutput>;
|
|
24
|
+
dpopNonceId?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type FetcherHooks = {
|
|
28
|
+
isDpopEnabled: () => boolean;
|
|
29
|
+
getAccessToken: () => Promise<string>;
|
|
30
|
+
getDpopNonce: () => Promise<string | undefined>;
|
|
31
|
+
setDpopNonce: (nonce: string) => Promise<void>;
|
|
32
|
+
generateDpopProof: (params: {
|
|
33
|
+
url: string;
|
|
34
|
+
method: string;
|
|
35
|
+
nonce?: string;
|
|
36
|
+
accessToken: string;
|
|
37
|
+
}) => Promise<string>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type FetchWithAuthCallbacks<TOutput> = {
|
|
41
|
+
onUseDpopNonceError?(): Promise<TOutput>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export class Fetcher<TOutput extends CustomFetchMinimalOutput> {
|
|
45
|
+
protected readonly config: Omit<FetcherConfig<TOutput>, 'fetch'> &
|
|
46
|
+
Required<Pick<FetcherConfig<TOutput>, 'fetch'>>;
|
|
47
|
+
|
|
48
|
+
protected readonly hooks: FetcherHooks;
|
|
49
|
+
|
|
50
|
+
constructor(config: FetcherConfig<TOutput>, hooks: FetcherHooks) {
|
|
51
|
+
this.hooks = hooks;
|
|
52
|
+
|
|
53
|
+
this.config = {
|
|
54
|
+
...config,
|
|
55
|
+
fetch:
|
|
56
|
+
config.fetch ||
|
|
57
|
+
// For easier testing and constructor compatibility with SSR.
|
|
58
|
+
((typeof window === 'undefined'
|
|
59
|
+
? fetch
|
|
60
|
+
: window.fetch.bind(window)) as () => Promise<any>)
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
protected isAbsoluteUrl(url: string): boolean {
|
|
65
|
+
// `http://example.com`, `https://example.com` or `//example.com`
|
|
66
|
+
return /^(https?:)?\/\//i.test(url);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
protected buildUrl(
|
|
70
|
+
baseUrl: string | undefined,
|
|
71
|
+
url: string | undefined
|
|
72
|
+
): string {
|
|
73
|
+
if (url) {
|
|
74
|
+
if (this.isAbsoluteUrl(url)) {
|
|
75
|
+
return url;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (baseUrl) {
|
|
79
|
+
return `${baseUrl.replace(/\/?\/$/, '')}/${url.replace(/^\/+/, '')}`;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
throw new TypeError('`url` must be absolute or `baseUrl` non-empty.');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
protected getAccessToken(): Promise<string> {
|
|
87
|
+
return this.config.getAccessToken
|
|
88
|
+
? this.config.getAccessToken()
|
|
89
|
+
: this.hooks.getAccessToken();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
protected buildBaseRequest(
|
|
93
|
+
info: RequestInfo | URL,
|
|
94
|
+
init: RequestInit | undefined
|
|
95
|
+
): Request {
|
|
96
|
+
// In the native `fetch()` behavior, `init` can override `info` and the result
|
|
97
|
+
// is the merge of both. So let's replicate that behavior by passing those into
|
|
98
|
+
// a fresh `Request` object.
|
|
99
|
+
const request = new Request(info, init);
|
|
100
|
+
|
|
101
|
+
// No `baseUrl` config, use whatever the URL the `Request` came with.
|
|
102
|
+
if (!this.config.baseUrl) {
|
|
103
|
+
return request;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return new Request(
|
|
107
|
+
this.buildUrl(this.config.baseUrl, request.url),
|
|
108
|
+
request
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
protected async setAuthorizationHeader(
|
|
113
|
+
request: Request,
|
|
114
|
+
accessToken: string
|
|
115
|
+
): Promise<void> {
|
|
116
|
+
request.headers.set(
|
|
117
|
+
'authorization',
|
|
118
|
+
`${this.config.dpopNonceId ? 'DPoP' : 'Bearer'} ${accessToken}`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
protected async setDpopProofHeader(
|
|
123
|
+
request: Request,
|
|
124
|
+
accessToken: string
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
if (!this.config.dpopNonceId) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const dpopNonce = await this.hooks.getDpopNonce();
|
|
131
|
+
|
|
132
|
+
const dpopProof = await this.hooks.generateDpopProof({
|
|
133
|
+
accessToken,
|
|
134
|
+
method: request.method,
|
|
135
|
+
nonce: dpopNonce,
|
|
136
|
+
url: request.url
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
request.headers.set('dpop', dpopProof);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
protected async prepareRequest(request: Request) {
|
|
143
|
+
const accessToken = await this.getAccessToken();
|
|
144
|
+
|
|
145
|
+
this.setAuthorizationHeader(request, accessToken);
|
|
146
|
+
|
|
147
|
+
await this.setDpopProofHeader(request, accessToken);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
protected getHeader(headers: ResponseHeaders, name: string): string {
|
|
151
|
+
if (Array.isArray(headers)) {
|
|
152
|
+
return new Headers(headers).get(name) || '';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (typeof headers.get === 'function') {
|
|
156
|
+
return headers.get(name) || '';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return (headers as Record<string, string | null | undefined>)[name] || '';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
protected hasUseDpopNonceError(response: TOutput): boolean {
|
|
163
|
+
if (response.status !== 401) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const wwwAuthHeader = this.getHeader(response.headers, 'www-authenticate');
|
|
168
|
+
|
|
169
|
+
return wwwAuthHeader.includes('use_dpop_nonce');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
protected async handleResponse(
|
|
173
|
+
response: TOutput,
|
|
174
|
+
callbacks: FetchWithAuthCallbacks<TOutput>
|
|
175
|
+
): Promise<TOutput> {
|
|
176
|
+
const newDpopNonce = this.getHeader(response.headers, DPOP_NONCE_HEADER);
|
|
177
|
+
|
|
178
|
+
if (newDpopNonce) {
|
|
179
|
+
await this.hooks.setDpopNonce(newDpopNonce);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!this.hasUseDpopNonceError(response)) {
|
|
183
|
+
return response;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// After a `use_dpop_nonce` error, if we didn't get a new DPoP nonce or we
|
|
187
|
+
// did but it still got rejected for the same reason, we have to give up.
|
|
188
|
+
if (!newDpopNonce || !callbacks.onUseDpopNonceError) {
|
|
189
|
+
throw new UseDpopNonceError(newDpopNonce);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return callbacks.onUseDpopNonceError();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
protected async internalFetchWithAuth(
|
|
196
|
+
info: RequestInfo | URL,
|
|
197
|
+
init: RequestInit | undefined,
|
|
198
|
+
callbacks: FetchWithAuthCallbacks<TOutput>
|
|
199
|
+
): Promise<TOutput> {
|
|
200
|
+
const request = this.buildBaseRequest(info, init);
|
|
201
|
+
|
|
202
|
+
await this.prepareRequest(request);
|
|
203
|
+
|
|
204
|
+
const response = await this.config.fetch(request);
|
|
205
|
+
|
|
206
|
+
return this.handleResponse(response, callbacks);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
public fetchWithAuth(
|
|
210
|
+
info: RequestInfo | URL,
|
|
211
|
+
init?: RequestInit
|
|
212
|
+
): Promise<TOutput> {
|
|
213
|
+
const callbacks: FetchWithAuthCallbacks<TOutput> = {
|
|
214
|
+
onUseDpopNonceError: () =>
|
|
215
|
+
this.internalFetchWithAuth(info, init, {
|
|
216
|
+
...callbacks,
|
|
217
|
+
// Retry on a `use_dpop_nonce` error, but just once.
|
|
218
|
+
onUseDpopNonceError: undefined
|
|
219
|
+
})
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
return this.internalFetchWithAuth(info, init, callbacks);
|
|
223
|
+
}
|
|
224
|
+
}
|
package/src/global.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ICache } from './cache';
|
|
2
|
+
import type { Dpop } from './dpop/dpop';
|
|
2
3
|
|
|
3
4
|
export interface AuthorizationParams {
|
|
4
5
|
/**
|
|
@@ -271,6 +272,15 @@ export interface Auth0ClientOptions extends BaseLoginOptions {
|
|
|
271
272
|
* **Note**: The worker is only used when `useRefreshTokens: true`, `cacheLocation: 'memory'`, and the `cache` is not custom.
|
|
272
273
|
*/
|
|
273
274
|
workerUrl?: string;
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* If `true`, DPoP (OAuth 2.0 Demonstrating Proof of Possession, RFC9449)
|
|
278
|
+
* will be used to cryptographically bind tokens to this specific browser
|
|
279
|
+
* so they can't be used from a different device in case of a leak.
|
|
280
|
+
*
|
|
281
|
+
* The default setting is `false`.
|
|
282
|
+
*/
|
|
283
|
+
useDpop?: boolean;
|
|
274
284
|
}
|
|
275
285
|
|
|
276
286
|
/**
|
|
@@ -527,11 +537,13 @@ export interface TokenEndpointOptions {
|
|
|
527
537
|
timeout?: number;
|
|
528
538
|
auth0Client: any;
|
|
529
539
|
useFormData?: boolean;
|
|
540
|
+
dpop?: Pick<Dpop, 'generateProof' | 'getNonce' | 'setNonce'>;
|
|
530
541
|
[key: string]: any;
|
|
531
542
|
}
|
|
532
543
|
|
|
533
544
|
export type TokenEndpointResponse = {
|
|
534
545
|
id_token: string;
|
|
546
|
+
token_type: string;
|
|
535
547
|
access_token: string;
|
|
536
548
|
refresh_token?: string;
|
|
537
549
|
expires_in: number;
|
|
@@ -647,6 +659,15 @@ export type FetchOptions = {
|
|
|
647
659
|
signal?: AbortSignal;
|
|
648
660
|
};
|
|
649
661
|
|
|
662
|
+
/**
|
|
663
|
+
* @ignore
|
|
664
|
+
*/
|
|
665
|
+
export type FetchResponse = {
|
|
666
|
+
ok: boolean;
|
|
667
|
+
headers: Record<string, string | undefined>;
|
|
668
|
+
json: any;
|
|
669
|
+
};
|
|
670
|
+
|
|
650
671
|
export type GetTokenSilentlyVerboseResponse = Omit<
|
|
651
672
|
TokenEndpointResponse,
|
|
652
673
|
'refresh_token'
|
package/src/http.ts
CHANGED
|
@@ -3,13 +3,17 @@ import {
|
|
|
3
3
|
DEFAULT_SILENT_TOKEN_RETRY_COUNT
|
|
4
4
|
} from './constants';
|
|
5
5
|
|
|
6
|
+
import { fromEntries } from './utils';
|
|
6
7
|
import { sendMessage } from './worker/worker.utils';
|
|
7
|
-
import { FetchOptions } from './global';
|
|
8
|
+
import { FetchOptions, FetchResponse } from './global';
|
|
8
9
|
import {
|
|
9
10
|
GenericError,
|
|
10
11
|
MfaRequiredError,
|
|
11
|
-
MissingRefreshTokenError
|
|
12
|
+
MissingRefreshTokenError,
|
|
13
|
+
UseDpopNonceError
|
|
12
14
|
} from './errors';
|
|
15
|
+
import { Dpop } from './dpop/dpop';
|
|
16
|
+
import { DPOP_NONCE_HEADER } from './dpop/utils';
|
|
13
17
|
|
|
14
18
|
export const createAbortController = () => new AbortController();
|
|
15
19
|
|
|
@@ -18,7 +22,14 @@ const dofetch = async (fetchUrl: string, fetchOptions: FetchOptions) => {
|
|
|
18
22
|
|
|
19
23
|
return {
|
|
20
24
|
ok: response.ok,
|
|
21
|
-
json: await response.json()
|
|
25
|
+
json: await response.json(),
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* This is not needed, but do it anyway so the object shape is the
|
|
29
|
+
* same as when using a Web Worker (which *does* need this, see
|
|
30
|
+
* src/worker/token.worker.ts).
|
|
31
|
+
*/
|
|
32
|
+
headers: fromEntries(response.headers)
|
|
22
33
|
};
|
|
23
34
|
};
|
|
24
35
|
|
|
@@ -102,10 +113,22 @@ export async function getJSON<T>(
|
|
|
102
113
|
scope: string,
|
|
103
114
|
options: FetchOptions,
|
|
104
115
|
worker?: Worker,
|
|
105
|
-
useFormData?: boolean
|
|
116
|
+
useFormData?: boolean,
|
|
117
|
+
dpop?: Pick<Dpop, 'generateProof' | 'getNonce' | 'setNonce'>,
|
|
118
|
+
isDpopRetry?: boolean
|
|
106
119
|
): Promise<T> {
|
|
120
|
+
if (dpop) {
|
|
121
|
+
const dpopProof = await dpop.generateProof({
|
|
122
|
+
url,
|
|
123
|
+
method: options.method || 'GET',
|
|
124
|
+
nonce: await dpop.getNonce()
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
options.headers = { ...options.headers, dpop: dpopProof };
|
|
128
|
+
}
|
|
129
|
+
|
|
107
130
|
let fetchError: null | Error = null;
|
|
108
|
-
let response
|
|
131
|
+
let response!: FetchResponse;
|
|
109
132
|
|
|
110
133
|
for (let i = 0; i < DEFAULT_SILENT_TOKEN_RETRY_COUNT; i++) {
|
|
111
134
|
try {
|
|
@@ -135,9 +158,25 @@ export async function getJSON<T>(
|
|
|
135
158
|
|
|
136
159
|
const {
|
|
137
160
|
json: { error, error_description, ...data },
|
|
161
|
+
headers,
|
|
138
162
|
ok
|
|
139
163
|
} = response;
|
|
140
164
|
|
|
165
|
+
let newDpopNonce: string | undefined;
|
|
166
|
+
|
|
167
|
+
if (dpop) {
|
|
168
|
+
/**
|
|
169
|
+
* Note that a new DPoP nonce can appear in both error and success responses!
|
|
170
|
+
*
|
|
171
|
+
* @see {@link https://www.rfc-editor.org/rfc/rfc9449.html#section-8.2-3}
|
|
172
|
+
*/
|
|
173
|
+
newDpopNonce = headers[DPOP_NONCE_HEADER];
|
|
174
|
+
|
|
175
|
+
if (newDpopNonce) {
|
|
176
|
+
await dpop.setNonce(newDpopNonce);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
141
180
|
if (!ok) {
|
|
142
181
|
const errorMessage =
|
|
143
182
|
error_description || `HTTP error. Unable to fetch ${url}`;
|
|
@@ -150,6 +189,32 @@ export async function getJSON<T>(
|
|
|
150
189
|
throw new MissingRefreshTokenError(audience, scope);
|
|
151
190
|
}
|
|
152
191
|
|
|
192
|
+
/**
|
|
193
|
+
* When DPoP is used and we get a `use_dpop_nonce` error from the server,
|
|
194
|
+
* we must retry ONCE with any new nonce received in the rejected request.
|
|
195
|
+
*
|
|
196
|
+
* If a new nonce was not received or the retry fails again, we give up and
|
|
197
|
+
* throw the error as is.
|
|
198
|
+
*/
|
|
199
|
+
if (error === 'use_dpop_nonce') {
|
|
200
|
+
if (!dpop || !newDpopNonce || isDpopRetry) {
|
|
201
|
+
throw new UseDpopNonceError(newDpopNonce);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// repeat the call but with isDpopRetry=true to avoid any more retries
|
|
205
|
+
return getJSON(
|
|
206
|
+
url,
|
|
207
|
+
timeout,
|
|
208
|
+
audience,
|
|
209
|
+
scope,
|
|
210
|
+
options,
|
|
211
|
+
worker,
|
|
212
|
+
useFormData,
|
|
213
|
+
dpop,
|
|
214
|
+
true // !
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
153
218
|
throw new GenericError(error || 'request_error', errorMessage);
|
|
154
219
|
}
|
|
155
220
|
|
package/src/index.ts
CHANGED
|
@@ -29,7 +29,8 @@ export {
|
|
|
29
29
|
PopupTimeoutError,
|
|
30
30
|
PopupCancelledError,
|
|
31
31
|
MfaRequiredError,
|
|
32
|
-
MissingRefreshTokenError
|
|
32
|
+
MissingRefreshTokenError,
|
|
33
|
+
UseDpopNonceError
|
|
33
34
|
} from './errors';
|
|
34
35
|
|
|
35
36
|
export {
|
|
@@ -45,3 +46,5 @@ export {
|
|
|
45
46
|
CacheKey,
|
|
46
47
|
CacheKeyData
|
|
47
48
|
} from './cache';
|
|
49
|
+
|
|
50
|
+
export { type FetcherConfig } from './fetcher';
|
package/src/utils.ts
CHANGED
|
@@ -246,3 +246,18 @@ export const parseNumber = (value: any): number | undefined => {
|
|
|
246
246
|
}
|
|
247
247
|
return parseInt(value, 10) || undefined;
|
|
248
248
|
};
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Ponyfill for `Object.fromEntries()`, which is not available until ES2020.
|
|
252
|
+
*
|
|
253
|
+
* When the target of this project reaches ES2020, this can be removed.
|
|
254
|
+
*/
|
|
255
|
+
export const fromEntries = <T = any>(
|
|
256
|
+
iterable: Iterable<[PropertyKey, T]>
|
|
257
|
+
): Record<PropertyKey, T> => {
|
|
258
|
+
return [...iterable].reduce((obj, [key, val]) => {
|
|
259
|
+
obj[key] = val;
|
|
260
|
+
|
|
261
|
+
return obj;
|
|
262
|
+
}, {} as Record<PropertyKey, T>);
|
|
263
|
+
};
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export default '2.
|
|
1
|
+
export default '2.4.0';
|