@auth0/auth0-spa-js 2.2.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 (45) hide show
  1. package/README.md +1 -1
  2. package/dist/auth0-spa-js.development.js +615 -17
  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 +656 -17
  13. package/dist/lib/auth0-spa-js.cjs.js.map +1 -1
  14. package/dist/typings/Auth0Client.d.ts +54 -4
  15. package/dist/typings/Auth0Client.utils.d.ts +1 -1
  16. package/dist/typings/TokenExchange.d.ts +3 -2
  17. package/dist/typings/api.d.ts +1 -1
  18. package/dist/typings/cache/shared.d.ts +1 -0
  19. package/dist/typings/dpop/dpop.d.ts +17 -0
  20. package/dist/typings/dpop/storage.d.ts +27 -0
  21. package/dist/typings/dpop/utils.d.ts +15 -0
  22. package/dist/typings/errors.d.ts +7 -0
  23. package/dist/typings/fetcher.d.ts +48 -0
  24. package/dist/typings/global.d.ts +21 -0
  25. package/dist/typings/http.d.ts +2 -1
  26. package/dist/typings/index.d.ts +2 -1
  27. package/dist/typings/utils.d.ts +6 -0
  28. package/dist/typings/version.d.ts +1 -1
  29. package/package.json +22 -19
  30. package/src/Auth0Client.ts +126 -11
  31. package/src/Auth0Client.utils.ts +4 -2
  32. package/src/TokenExchange.ts +3 -2
  33. package/src/api.ts +17 -3
  34. package/src/cache/shared.ts +1 -0
  35. package/src/dpop/dpop.ts +56 -0
  36. package/src/dpop/storage.ts +134 -0
  37. package/src/dpop/utils.ts +66 -0
  38. package/src/errors.ts +11 -0
  39. package/src/fetcher.ts +224 -0
  40. package/src/global.ts +23 -0
  41. package/src/http.ts +70 -5
  42. package/src/index.ts +4 -1
  43. package/src/utils.ts +15 -0
  44. package/src/version.ts +1 -1
  45. package/src/worker/token.worker.ts +11 -5
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
  /**
@@ -84,6 +85,8 @@ export interface AuthorizationParams {
84
85
  *
85
86
  * - If you provide an Organization ID (a string with the prefix `org_`), it will be validated against the `org_id` claim of your user's ID Token. The validation is case-sensitive.
86
87
  * - If you provide an Organization Name (a string *without* the prefix `org_`), it will be validated against the `org_name` claim of your user's ID Token. The validation is case-insensitive.
88
+ * To use an Organization Name you must have "Allow Organization Names in Authentication API" switched on in your Auth0 settings dashboard.
89
+ * More information is available on the [Auth0 documentation portal](https://auth0.com/docs/manage-users/organizations/configure-organizations/use-org-name-authentication-api)
87
90
  *
88
91
  */
89
92
  organization?: string;
@@ -269,6 +272,15 @@ export interface Auth0ClientOptions extends BaseLoginOptions {
269
272
  * **Note**: The worker is only used when `useRefreshTokens: true`, `cacheLocation: 'memory'`, and the `cache` is not custom.
270
273
  */
271
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;
272
284
  }
273
285
 
274
286
  /**
@@ -525,11 +537,13 @@ export interface TokenEndpointOptions {
525
537
  timeout?: number;
526
538
  auth0Client: any;
527
539
  useFormData?: boolean;
540
+ dpop?: Pick<Dpop, 'generateProof' | 'getNonce' | 'setNonce'>;
528
541
  [key: string]: any;
529
542
  }
530
543
 
531
544
  export type TokenEndpointResponse = {
532
545
  id_token: string;
546
+ token_type: string;
533
547
  access_token: string;
534
548
  refresh_token?: string;
535
549
  expires_in: number;
@@ -645,6 +659,15 @@ export type FetchOptions = {
645
659
  signal?: AbortSignal;
646
660
  };
647
661
 
662
+ /**
663
+ * @ignore
664
+ */
665
+ export type FetchResponse = {
666
+ ok: boolean;
667
+ headers: Record<string, string | undefined>;
668
+ json: any;
669
+ };
670
+
648
671
  export type GetTokenSilentlyVerboseResponse = Omit<
649
672
  TokenEndpointResponse,
650
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.2.0';
1
+ export default '2.4.0';
@@ -1,5 +1,6 @@
1
1
  import { MissingRefreshTokenError } from '../errors';
2
- import { createQueryParams } from '../utils';
2
+ import { FetchResponse } from '../global';
3
+ import { createQueryParams, fromEntries } from '../utils';
3
4
  import { WorkerRefreshTokenMessage } from './worker.types';
4
5
 
5
6
  let refreshTokens: Record<string, string> = {};
@@ -19,7 +20,7 @@ const deleteRefreshToken = (audience: string, scope: string) =>
19
20
  delete refreshTokens[cacheKey(audience, scope)];
20
21
 
21
22
  const wait = (time: number) =>
22
- new Promise(resolve => setTimeout(resolve, time));
23
+ new Promise<void>(resolve => setTimeout(resolve, time));
23
24
 
24
25
  const formDataToObject = (formData: string): Record<string, any> => {
25
26
  const queryParams = new URLSearchParams(formData);
@@ -36,6 +37,8 @@ const messageHandler = async ({
36
37
  data: { timeout, auth, fetchUrl, fetchOptions, useFormData },
37
38
  ports: [port]
38
39
  }: MessageEvent<WorkerRefreshTokenMessage>) => {
40
+ let headers: FetchResponse['headers'] = {};
41
+
39
42
  let json: {
40
43
  refresh_token?: string;
41
44
  };
@@ -72,7 +75,7 @@ const messageHandler = async ({
72
75
  fetchOptions.signal = abortController.signal;
73
76
  }
74
77
 
75
- let response: any;
78
+ let response: void | Response;
76
79
 
77
80
  try {
78
81
  response = await Promise.race([
@@ -99,6 +102,7 @@ const messageHandler = async ({
99
102
  return;
100
103
  }
101
104
 
105
+ headers = fromEntries(response.headers);
102
106
  json = await response.json();
103
107
 
104
108
  if (json.refresh_token) {
@@ -110,7 +114,8 @@ const messageHandler = async ({
110
114
 
111
115
  port.postMessage({
112
116
  ok: response.ok,
113
- json
117
+ json,
118
+ headers
114
119
  });
115
120
  } catch (error) {
116
121
  port.postMessage({
@@ -118,7 +123,8 @@ const messageHandler = async ({
118
123
  json: {
119
124
  error: error.error,
120
125
  error_description: error.message
121
- }
126
+ },
127
+ headers
122
128
  });
123
129
  }
124
130
  };