@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.
Files changed (43) hide show
  1. package/README.md +1 -1
  2. package/dist/auth0-spa-js.development.js +600 -14
  3. package/dist/auth0-spa-js.development.js.map +1 -1
  4. package/dist/auth0-spa-js.production.esm.js +1 -1
  5. package/dist/auth0-spa-js.production.esm.js.map +1 -1
  6. package/dist/auth0-spa-js.production.js +1 -1
  7. package/dist/auth0-spa-js.production.js.map +1 -1
  8. package/dist/auth0-spa-js.worker.development.js +10 -2
  9. package/dist/auth0-spa-js.worker.development.js.map +1 -1
  10. package/dist/auth0-spa-js.worker.production.js +1 -1
  11. package/dist/auth0-spa-js.worker.production.js.map +1 -1
  12. package/dist/lib/auth0-spa-js.cjs.js +641 -14
  13. package/dist/lib/auth0-spa-js.cjs.js.map +1 -1
  14. package/dist/typings/Auth0Client.d.ts +50 -0
  15. package/dist/typings/Auth0Client.utils.d.ts +1 -1
  16. package/dist/typings/api.d.ts +1 -1
  17. package/dist/typings/cache/shared.d.ts +1 -0
  18. package/dist/typings/dpop/dpop.d.ts +17 -0
  19. package/dist/typings/dpop/storage.d.ts +27 -0
  20. package/dist/typings/dpop/utils.d.ts +15 -0
  21. package/dist/typings/errors.d.ts +7 -0
  22. package/dist/typings/fetcher.d.ts +48 -0
  23. package/dist/typings/global.d.ts +19 -0
  24. package/dist/typings/http.d.ts +2 -1
  25. package/dist/typings/index.d.ts +2 -1
  26. package/dist/typings/utils.d.ts +6 -0
  27. package/dist/typings/version.d.ts +1 -1
  28. package/package.json +22 -19
  29. package/src/Auth0Client.ts +112 -5
  30. package/src/Auth0Client.utils.ts +4 -2
  31. package/src/api.ts +6 -1
  32. package/src/cache/shared.ts +1 -0
  33. package/src/dpop/dpop.ts +56 -0
  34. package/src/dpop/storage.ts +134 -0
  35. package/src/dpop/utils.ts +66 -0
  36. package/src/errors.ts +11 -0
  37. package/src/fetcher.ts +224 -0
  38. package/src/global.ts +21 -0
  39. package/src/http.ts +70 -5
  40. package/src/index.ts +4 -1
  41. package/src/utils.ts +15 -0
  42. package/src/version.ts +1 -1
  43. package/src/worker/token.worker.ts +11 -5
@@ -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: any;
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.3.0';
1
+ export default '2.4.0';