@auth0/auth0-server-js 1.6.1 → 1.8.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/dist/index.cjs +435 -40
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +327 -14
- package/dist/index.d.ts +327 -14
- package/dist/index.js +433 -37
- package/dist/index.js.map +1 -1
- package/package.json +14 -14
package/dist/index.js
CHANGED
|
@@ -48,14 +48,55 @@ var IssuerValidationError = class extends Error {
|
|
|
48
48
|
this.name = "IssuerValidationError";
|
|
49
49
|
}
|
|
50
50
|
};
|
|
51
|
+
var SessionExpiredError = class extends Error {
|
|
52
|
+
code = "session_expired";
|
|
53
|
+
constructor(message) {
|
|
54
|
+
super(
|
|
55
|
+
message ?? "The session has expired because the upstream identity provider session ceiling was reached. The user needs to re-authenticate."
|
|
56
|
+
);
|
|
57
|
+
this.name = "SessionExpiredError";
|
|
58
|
+
}
|
|
59
|
+
};
|
|
51
60
|
|
|
52
61
|
// src/state/utils.ts
|
|
62
|
+
var SESSION_EXPIRY_LEEWAY = 30;
|
|
63
|
+
var MAX_PLAUSIBLE_UNIX_SECONDS = 1e10;
|
|
64
|
+
function isPlausibleUnixSeconds(value) {
|
|
65
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 && value < MAX_PLAUSIBLE_UNIX_SECONDS;
|
|
66
|
+
}
|
|
67
|
+
function extractSessionExpiry(claims) {
|
|
68
|
+
const value = claims?.session_expiry;
|
|
69
|
+
return isPlausibleUnixSeconds(value) ? value : void 0;
|
|
70
|
+
}
|
|
71
|
+
function isSessionExpiryReached(sessionExpiresAt, nowSeconds) {
|
|
72
|
+
if (sessionExpiresAt === void 0) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
const now = nowSeconds ?? Math.floor(Date.now() / 1e3);
|
|
76
|
+
return now >= sessionExpiresAt - SESSION_EXPIRY_LEEWAY;
|
|
77
|
+
}
|
|
53
78
|
var createUpdatedTokenSet = (audience, response) => ({
|
|
54
79
|
audience,
|
|
55
80
|
accessToken: response.accessToken,
|
|
56
81
|
scope: response.scope,
|
|
57
82
|
expiresAt: response.expiresAt
|
|
58
83
|
});
|
|
84
|
+
function isSessionExpiryInPast(sessionExpiresAt, issuedAt) {
|
|
85
|
+
if (sessionExpiresAt === void 0) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
const reference = isPlausibleUnixSeconds(issuedAt) ? issuedAt : Math.floor(Date.now() / 1e3);
|
|
89
|
+
return sessionExpiresAt <= reference + SESSION_EXPIRY_LEEWAY;
|
|
90
|
+
}
|
|
91
|
+
function applySessionExpiryAtLogin(stateData, claims) {
|
|
92
|
+
const sessionExpiresAt = extractSessionExpiry(claims);
|
|
93
|
+
if (isSessionExpiryInPast(sessionExpiresAt, claims?.iat)) {
|
|
94
|
+
throw new SessionExpiredError(
|
|
95
|
+
"The upstream identity provider session_expiry is at or before the issued-at time; refusing to create an already-expired session."
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return { ...stateData, sessionExpiresAt };
|
|
99
|
+
}
|
|
59
100
|
function updateStateData(audience, stateData, tokenEndpointResponse, context) {
|
|
60
101
|
if (stateData && tokenEndpointResponse.claims) {
|
|
61
102
|
const newSub = tokenEndpointResponse.claims.sub;
|
|
@@ -122,10 +163,24 @@ function updateStateDataForConnectionTokenSet(options, stateData, tokenEndpointR
|
|
|
122
163
|
import {
|
|
123
164
|
TokenForConnectionError,
|
|
124
165
|
AuthClient,
|
|
166
|
+
PasswordlessStartError,
|
|
167
|
+
PasswordlessVerifyError,
|
|
125
168
|
TokenByRefreshTokenError
|
|
126
169
|
} from "@auth0/auth0-auth-js";
|
|
127
170
|
|
|
128
171
|
// src/utils.ts
|
|
172
|
+
var DEFAULT_SCOPES = "openid profile email offline_access";
|
|
173
|
+
var ensureOpenIdScope = (scope) => {
|
|
174
|
+
const normalizedScope = scope?.trim();
|
|
175
|
+
if (!normalizedScope) {
|
|
176
|
+
return DEFAULT_SCOPES;
|
|
177
|
+
}
|
|
178
|
+
const scopes = normalizedScope.split(/\s+/);
|
|
179
|
+
if (!scopes.includes("openid")) {
|
|
180
|
+
scopes.unshift("openid");
|
|
181
|
+
}
|
|
182
|
+
return scopes.join(" ");
|
|
183
|
+
};
|
|
129
184
|
var compareScopes = (scopes, requiredScopes) => {
|
|
130
185
|
if (scopes === requiredScopes) {
|
|
131
186
|
return true;
|
|
@@ -149,7 +204,7 @@ function getTelemetryConfig(config) {
|
|
|
149
204
|
return {
|
|
150
205
|
enabled: true,
|
|
151
206
|
name: config?.name ?? "@auth0/auth0-server-js",
|
|
152
|
-
version: config?.version ?? "1.
|
|
207
|
+
version: config?.version ?? "1.8.0"
|
|
153
208
|
};
|
|
154
209
|
}
|
|
155
210
|
|
|
@@ -211,9 +266,12 @@ var ServerMfaClient = class {
|
|
|
211
266
|
this.#options.stateStoreIdentifier,
|
|
212
267
|
storeOptions
|
|
213
268
|
);
|
|
214
|
-
const updatedStateData =
|
|
215
|
-
|
|
216
|
-
|
|
269
|
+
const updatedStateData = applySessionExpiryAtLogin(
|
|
270
|
+
updateStateData(audience, existingStateData, tokenResponse, {
|
|
271
|
+
domain: this.#options.domain
|
|
272
|
+
}),
|
|
273
|
+
tokenResponse.claims
|
|
274
|
+
);
|
|
217
275
|
await this.#options.stateStore.set(
|
|
218
276
|
this.#options.stateStoreIdentifier,
|
|
219
277
|
updatedStateData,
|
|
@@ -233,8 +291,99 @@ var ServerMfaClient = class {
|
|
|
233
291
|
}
|
|
234
292
|
};
|
|
235
293
|
|
|
294
|
+
// src/passkey/server-passkey-client.ts
|
|
295
|
+
var ServerPasskeyClient = class {
|
|
296
|
+
#options;
|
|
297
|
+
/**
|
|
298
|
+
* @internal
|
|
299
|
+
*/
|
|
300
|
+
constructor(options) {
|
|
301
|
+
this.#options = options;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Requests a passkey signup challenge for a new user.
|
|
305
|
+
*
|
|
306
|
+
* Returns the `authSession` and the WebAuthn credential creation options
|
|
307
|
+
* (`authnParamsPublicKey`). The application must return these to the browser,
|
|
308
|
+
* pass `authnParamsPublicKey` to `navigator.credentials.create()`, and then
|
|
309
|
+
* call `getToken()` with the resulting credential to complete signup.
|
|
310
|
+
*
|
|
311
|
+
* This method does not create a session; no state is persisted.
|
|
312
|
+
*
|
|
313
|
+
* @param options User profile data and optional realm/organization.
|
|
314
|
+
* @param storeOptions Optional options used to resolve the domain (resolver mode).
|
|
315
|
+
*
|
|
316
|
+
* @throws {PasskeyRegisterError} If there was an issue requesting the signup challenge.
|
|
317
|
+
*
|
|
318
|
+
* @returns A promise resolving to the signup challenge.
|
|
319
|
+
*/
|
|
320
|
+
async register(options, storeOptions) {
|
|
321
|
+
const domain = await this.#options.resolveDomain(storeOptions);
|
|
322
|
+
const authClient = this.#options.getAuthClient(domain);
|
|
323
|
+
return authClient.passkey.register(options);
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Requests a passkey login challenge for an existing user.
|
|
327
|
+
*
|
|
328
|
+
* Returns the `authSession` and the WebAuthn credential request options
|
|
329
|
+
* (`authnParamsPublicKey`). The application must return these to the browser,
|
|
330
|
+
* pass `authnParamsPublicKey` to `navigator.credentials.get()`, and then
|
|
331
|
+
* call `getToken()` with the resulting credential to complete login.
|
|
332
|
+
*
|
|
333
|
+
* This method does not create a session; no state is persisted.
|
|
334
|
+
*
|
|
335
|
+
* @param options Optional realm/organization configuration.
|
|
336
|
+
* @param storeOptions Optional options used to resolve the domain (resolver mode).
|
|
337
|
+
*
|
|
338
|
+
* @throws {PasskeyChallengeError} If there was an issue requesting the login challenge.
|
|
339
|
+
*
|
|
340
|
+
* @returns A promise resolving to the login challenge.
|
|
341
|
+
*/
|
|
342
|
+
async challenge(options, storeOptions) {
|
|
343
|
+
const domain = await this.#options.resolveDomain(storeOptions);
|
|
344
|
+
const authClient = this.#options.getAuthClient(domain);
|
|
345
|
+
return authClient.passkey.challenge(options);
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Completes a passkey authentication flow (signup or login) by exchanging the
|
|
349
|
+
* WebAuthn credential for tokens, and persists the resulting session.
|
|
350
|
+
*
|
|
351
|
+
* Call this after obtaining a credential from `navigator.credentials.create()`
|
|
352
|
+
* (signup) or `navigator.credentials.get()` (login), passing the `authSession`
|
|
353
|
+
* returned by `register()` / `challenge()` together with the serialized credential.
|
|
354
|
+
*
|
|
355
|
+
* In resolver (multi-tenant) mode, pass the same `storeOptions` you passed to
|
|
356
|
+
* `register()` / `challenge()` so the token exchange resolves the same tenant
|
|
357
|
+
* that issued the `authSession`; otherwise the exchange will fail.
|
|
358
|
+
*
|
|
359
|
+
* @param options The auth session, serialized credential, and optional realm/scope/audience/organization.
|
|
360
|
+
* @param storeOptions Optional options used to pass to the State Store (and to resolve the domain in resolver mode).
|
|
361
|
+
*
|
|
362
|
+
* @throws {PasskeyGetTokenError} If there was an issue exchanging the credential for tokens. When the cause is `mfa_required`, use `isMfaRequiredError(error)` to narrow the error and read `cause.mfa_token`. No session is persisted in this case.
|
|
363
|
+
* @throws {OrganizationValidationError} When `organization` is passed and the returned ID token's organization claim is missing or does not match. The error is thrown before the session is written, so no session is persisted in this case.
|
|
364
|
+
*
|
|
365
|
+
* @returns A promise resolving to an object containing the authorizationDetails (when RAR was used).
|
|
366
|
+
*/
|
|
367
|
+
async getToken(options, storeOptions) {
|
|
368
|
+
const scope = ensureOpenIdScope(options.scope ?? this.#options.defaultScope);
|
|
369
|
+
const audience = options.audience ?? this.#options.defaultAudience;
|
|
370
|
+
const domain = await this.#options.resolveDomain(storeOptions);
|
|
371
|
+
const authClient = this.#options.getAuthClient(domain);
|
|
372
|
+
const tokenEndpointResponse = await authClient.passkey.getTokenByPasskey({
|
|
373
|
+
...options,
|
|
374
|
+
scope,
|
|
375
|
+
audience
|
|
376
|
+
});
|
|
377
|
+
const existingStateData = await this.#options.stateStore.get(this.#options.stateStoreIdentifier, storeOptions);
|
|
378
|
+
const stateData = updateStateData(audience ?? "default", existingStateData, tokenEndpointResponse, { domain });
|
|
379
|
+
await this.#options.stateStore.set(this.#options.stateStoreIdentifier, stateData, true, storeOptions);
|
|
380
|
+
return {
|
|
381
|
+
authorizationDetails: tokenEndpointResponse.authorizationDetails
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
|
|
236
386
|
// src/server-client.ts
|
|
237
|
-
var DEFAULT_SCOPES = "openid profile email offline_access";
|
|
238
387
|
var normalizeDomain = (value) => {
|
|
239
388
|
const trimmed = value.trim();
|
|
240
389
|
const parsed = trimmed.startsWith("http") ? new URL(trimmed) : new URL(`https://${trimmed}`);
|
|
@@ -248,16 +397,6 @@ var decodeIssuer = (token) => {
|
|
|
248
397
|
return void 0;
|
|
249
398
|
}
|
|
250
399
|
};
|
|
251
|
-
var ensureOpenIdScope = (scope) => {
|
|
252
|
-
if (!scope) {
|
|
253
|
-
return DEFAULT_SCOPES;
|
|
254
|
-
}
|
|
255
|
-
const scopes = scope.split(" ");
|
|
256
|
-
if (!scopes.includes("openid")) {
|
|
257
|
-
scopes.unshift("openid");
|
|
258
|
-
}
|
|
259
|
-
return scopes.join(" ");
|
|
260
|
-
};
|
|
261
400
|
var ServerClient = class {
|
|
262
401
|
#options;
|
|
263
402
|
#transactionStore;
|
|
@@ -268,6 +407,7 @@ var ServerClient = class {
|
|
|
268
407
|
#staticDomain;
|
|
269
408
|
#authClient;
|
|
270
409
|
#mfaClient;
|
|
410
|
+
#passkeyClient;
|
|
271
411
|
/**
|
|
272
412
|
* The underlying `authClient` instance that can be used to interact with the Auth0 Authentication API.
|
|
273
413
|
* Generally, you should prefer to use the higher-level methods exposed on the `ServerClient` instance.
|
|
@@ -302,6 +442,19 @@ var ServerClient = class {
|
|
|
302
442
|
}
|
|
303
443
|
return this.#mfaClient;
|
|
304
444
|
}
|
|
445
|
+
/**
|
|
446
|
+
* The passkey client for signing up and logging in users with WebAuthn credentials.
|
|
447
|
+
*
|
|
448
|
+
* Provides `register()` and `challenge()` to request signup/login challenges, and
|
|
449
|
+
* `getToken()` to exchange the resulting credential for tokens and persist the session.
|
|
450
|
+
*
|
|
451
|
+
* Unlike `mfa`, this property is available in both static and resolver (multi-tenant)
|
|
452
|
+
* domain modes. In resolver mode, pass the same `storeOptions` to `register()`/`challenge()`
|
|
453
|
+
* and `getToken()` so the credential is exchanged against the tenant that issued it.
|
|
454
|
+
*/
|
|
455
|
+
get passkey() {
|
|
456
|
+
return this.#passkeyClient;
|
|
457
|
+
}
|
|
305
458
|
constructor(options) {
|
|
306
459
|
this.#options = options;
|
|
307
460
|
this.#stateStoreIdentifier = this.#options.stateIdentifier || "__a0_session";
|
|
@@ -343,6 +496,14 @@ var ServerClient = class {
|
|
|
343
496
|
defaultAudience: this.#options.authorizationParams?.audience ?? "default"
|
|
344
497
|
});
|
|
345
498
|
}
|
|
499
|
+
this.#passkeyClient = new ServerPasskeyClient({
|
|
500
|
+
resolveDomain: (storeOptions) => this.#resolveDomain(storeOptions),
|
|
501
|
+
getAuthClient: (domain) => this.#getAuthClient(domain),
|
|
502
|
+
stateStore: this.#stateStore,
|
|
503
|
+
stateStoreIdentifier: this.#stateStoreIdentifier,
|
|
504
|
+
defaultScope: this.#options.authorizationParams?.scope,
|
|
505
|
+
defaultAudience: this.#options.authorizationParams?.audience
|
|
506
|
+
});
|
|
346
507
|
}
|
|
347
508
|
async #resolveDomain(storeOptions) {
|
|
348
509
|
if (typeof this.#options.domain === "function") {
|
|
@@ -436,6 +597,7 @@ var ServerClient = class {
|
|
|
436
597
|
*
|
|
437
598
|
* @throws {MissingTransactionError} When no transaction was found.
|
|
438
599
|
* @throws {TokenByCodeError} If there was an issue requesting the access token.
|
|
600
|
+
* @throws {SessionExpiredError} When the ID token's `session_expiry` is already in the past at login (the session is born expired); nothing is persisted.
|
|
439
601
|
*
|
|
440
602
|
* @returns A promise resolving to an object, containing the original appState (if present) and the authorizationDetails (when RAR was used).
|
|
441
603
|
*/
|
|
@@ -447,14 +609,18 @@ var ServerClient = class {
|
|
|
447
609
|
const domain = transactionData.domain ?? await this.#resolveDomain(storeOptions);
|
|
448
610
|
const authClient = this.#getAuthClient(domain);
|
|
449
611
|
const tokenEndpointResponse = await authClient.getTokenByCode(url, {
|
|
612
|
+
// TransactionData.codeVerifier is optional only to accommodate magic-link transactions.
|
|
450
613
|
codeVerifier: transactionData.codeVerifier
|
|
451
614
|
});
|
|
615
|
+
await this.#transactionStore.delete(this.#transactionStoreIdentifier, storeOptions);
|
|
452
616
|
const existingStateData = await this.#stateStore.get(this.#stateStoreIdentifier, storeOptions);
|
|
453
|
-
const stateData =
|
|
454
|
-
|
|
455
|
-
|
|
617
|
+
const stateData = applySessionExpiryAtLogin(
|
|
618
|
+
updateStateData(transactionData.audience ?? "default", existingStateData, tokenEndpointResponse, {
|
|
619
|
+
domain
|
|
620
|
+
}),
|
|
621
|
+
tokenEndpointResponse.claims
|
|
622
|
+
);
|
|
456
623
|
await this.#stateStore.set(this.#stateStoreIdentifier, stateData, true, storeOptions);
|
|
457
|
-
await this.#transactionStore.delete(this.#transactionStoreIdentifier, storeOptions);
|
|
458
624
|
return { appState: transactionData.appState, authorizationDetails: tokenEndpointResponse.authorizationDetails };
|
|
459
625
|
}
|
|
460
626
|
/**
|
|
@@ -464,6 +630,7 @@ var ServerClient = class {
|
|
|
464
630
|
*
|
|
465
631
|
* @throws {MissingSessionError} If there is no active session.
|
|
466
632
|
* @throws {BuildLinkUserUrlError} If there was an issue when building the Authorization URL.
|
|
633
|
+
* @throws {SessionExpiredError} When the session's `session_expiry` ceiling has been reached; the session is cleared and re-authentication is required.
|
|
467
634
|
*
|
|
468
635
|
* @returns A promise resolving to a URL object, representing the URL to redirect the user-agent to to request authorization at Auth0.
|
|
469
636
|
*/
|
|
@@ -480,6 +647,10 @@ var ServerClient = class {
|
|
|
480
647
|
throw new MissingSessionError("Session domain does not match the current domain.");
|
|
481
648
|
}
|
|
482
649
|
}
|
|
650
|
+
if (isSessionExpiryReached(stateData.sessionExpiresAt)) {
|
|
651
|
+
await this.#stateStore.delete(this.#stateStoreIdentifier, storeOptions);
|
|
652
|
+
throw new SessionExpiredError();
|
|
653
|
+
}
|
|
483
654
|
const domain = this.#getSessionDomain(stateData);
|
|
484
655
|
const authClient = this.#getAuthClient(domain);
|
|
485
656
|
const { linkUserUrl, codeVerifier } = await authClient.buildLinkUserUrl({
|
|
@@ -523,6 +694,7 @@ var ServerClient = class {
|
|
|
523
694
|
*
|
|
524
695
|
* @throws {MissingSessionError} If there is no active session.
|
|
525
696
|
* @throws {BuildUnlinkUserUrlError} If there was an issue when building the User Unlinking URL.
|
|
697
|
+
* @throws {SessionExpiredError} When the session's `session_expiry` ceiling has been reached; the session is cleared and re-authentication is required.
|
|
526
698
|
*
|
|
527
699
|
* @returns A promise resolving to a URL object, representing the URL to redirect the user-agent to to request authorization at Auth0.
|
|
528
700
|
*/
|
|
@@ -539,6 +711,10 @@ var ServerClient = class {
|
|
|
539
711
|
throw new MissingSessionError("Session domain does not match the current domain.");
|
|
540
712
|
}
|
|
541
713
|
}
|
|
714
|
+
if (isSessionExpiryReached(stateData.sessionExpiresAt)) {
|
|
715
|
+
await this.#stateStore.delete(this.#stateStoreIdentifier, storeOptions);
|
|
716
|
+
throw new SessionExpiredError();
|
|
717
|
+
}
|
|
542
718
|
const domain = this.#getSessionDomain(stateData);
|
|
543
719
|
const authClient = this.#getAuthClient(domain);
|
|
544
720
|
const { unlinkUserUrl, codeVerifier } = await authClient.buildUnlinkUserUrl({
|
|
@@ -583,6 +759,7 @@ var ServerClient = class {
|
|
|
583
759
|
* @param storeOptions Optional options used to pass to the Transaction and State Store.
|
|
584
760
|
*
|
|
585
761
|
* @throws {BackchannelAuthenticationError} If there was an issue when doing backchannel authentication.
|
|
762
|
+
* @throws {SessionExpiredError} When the ID token's `session_expiry` is already in the past at login (the session is born expired); nothing is persisted.
|
|
586
763
|
*
|
|
587
764
|
* @returns A promise resolving to an object, containing the authorizationDetails (when RAR was used).
|
|
588
765
|
*/
|
|
@@ -599,6 +776,135 @@ var ServerClient = class {
|
|
|
599
776
|
}
|
|
600
777
|
});
|
|
601
778
|
const existingStateData = await this.#stateStore.get(this.#stateStoreIdentifier, storeOptions);
|
|
779
|
+
const stateData = applySessionExpiryAtLogin(
|
|
780
|
+
updateStateData(this.#options.authorizationParams?.audience ?? "default", existingStateData, tokenEndpointResponse, {
|
|
781
|
+
domain
|
|
782
|
+
}),
|
|
783
|
+
tokenEndpointResponse.claims
|
|
784
|
+
);
|
|
785
|
+
await this.#stateStore.set(this.#stateStoreIdentifier, stateData, true, storeOptions);
|
|
786
|
+
return {
|
|
787
|
+
authorizationDetails: tokenEndpointResponse.authorizationDetails
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Starts a passwordless flow by sending a one-time code (OTP) or a magic link.
|
|
792
|
+
*
|
|
793
|
+
* Discriminated on `connection` (and, for email, `send`) to mirror the
|
|
794
|
+
* `@auth0/nextjs-auth0` `passwordless.start()` surface:
|
|
795
|
+
* - `{ connection: 'email' }` / `{ connection: 'email', send: 'code' }` — email OTP
|
|
796
|
+
* - `{ connection: 'email', send: 'link', redirectUri }` — email magic link
|
|
797
|
+
* - `{ connection: 'sms' }` — SMS OTP
|
|
798
|
+
*
|
|
799
|
+
* OTP modes are a stateless passthrough to the Authentication API (no session, no transaction);
|
|
800
|
+
* complete them with {@link ServerClient#completePasswordless}.
|
|
801
|
+
*
|
|
802
|
+
* Magic-link mode is stateful: the SDK generates an opaque anti-forgery `state`, sends the link
|
|
803
|
+
* with the OAuth parameters embedded (`redirect_uri`, `response_type=code`, `scope`, `state`),
|
|
804
|
+
* and persists a transaction carrying that `state`. NO PKCE challenge is registered, so the
|
|
805
|
+
* transaction holds no `codeVerifier`. Complete it with
|
|
806
|
+
* {@link ServerClient#completePasswordlessMagicLink}. Requires the tenant setting
|
|
807
|
+
* `allow_magiclink_verify_without_session: true` for server-side completion.
|
|
808
|
+
*
|
|
809
|
+
* @param options Discriminated start options.
|
|
810
|
+
* @param storeOptions Optional options passed to the resolver / stores.
|
|
811
|
+
*
|
|
812
|
+
* @throws {PasswordlessStartError} If the request fails, or if a magic link is requested without a `redirectUri`.
|
|
813
|
+
*
|
|
814
|
+
* @example
|
|
815
|
+
* // Email OTP
|
|
816
|
+
* await serverClient.startPasswordless({ connection: 'email', email: 'user@example.com' });
|
|
817
|
+
* // SMS OTP
|
|
818
|
+
* await serverClient.startPasswordless({ connection: 'sms', phoneNumber: '+14155550100' });
|
|
819
|
+
* // Email magic link
|
|
820
|
+
* await serverClient.startPasswordless({
|
|
821
|
+
* connection: 'email',
|
|
822
|
+
* email: 'user@example.com',
|
|
823
|
+
* send: 'link',
|
|
824
|
+
* redirectUri: 'https://app.example.com/auth/callback',
|
|
825
|
+
* });
|
|
826
|
+
*/
|
|
827
|
+
async startPasswordless(options, storeOptions) {
|
|
828
|
+
const domain = await this.#resolveDomain(storeOptions);
|
|
829
|
+
const authClient = this.#getAuthClient(domain);
|
|
830
|
+
if (options.connection === "sms") {
|
|
831
|
+
await authClient.passwordless.sendSms({
|
|
832
|
+
phoneNumber: options.phoneNumber,
|
|
833
|
+
language: options.language
|
|
834
|
+
});
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
if (options.send !== "link") {
|
|
838
|
+
await authClient.passwordless.sendEmail({
|
|
839
|
+
email: options.email,
|
|
840
|
+
send: "code",
|
|
841
|
+
language: options.language
|
|
842
|
+
});
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
if (!options.redirectUri || typeof options.redirectUri !== "string") {
|
|
846
|
+
throw new PasswordlessStartError("redirectUri is required to start a passwordless magic-link login.");
|
|
847
|
+
}
|
|
848
|
+
const state = crypto.randomUUID();
|
|
849
|
+
const scope = ensureOpenIdScope(options.scope ?? this.#options.authorizationParams?.scope);
|
|
850
|
+
const audience = options.audience ?? this.#options.authorizationParams?.audience;
|
|
851
|
+
await authClient.passwordless.sendEmail({
|
|
852
|
+
email: options.email,
|
|
853
|
+
send: "link",
|
|
854
|
+
language: options.language,
|
|
855
|
+
authParams: {
|
|
856
|
+
...options.authParams,
|
|
857
|
+
redirect_uri: options.redirectUri,
|
|
858
|
+
response_type: "code",
|
|
859
|
+
scope,
|
|
860
|
+
...audience ? { audience } : {},
|
|
861
|
+
state
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
const transactionState = {
|
|
865
|
+
audience,
|
|
866
|
+
domain,
|
|
867
|
+
state
|
|
868
|
+
};
|
|
869
|
+
await this.#transactionStore.set(this.#transactionStoreIdentifier, transactionState, false, storeOptions);
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Completes a passwordless OTP login and persists the resulting session.
|
|
873
|
+
*
|
|
874
|
+
* Discriminated on `connection` to mirror the `@auth0/nextjs-auth0` `passwordless.verify()`
|
|
875
|
+
* surface. Non-redirect flow: no PKCE and no transaction store (mirrors
|
|
876
|
+
* {@link ServerClient#loginBackchannel}). The `openid` scope is always ensured by this layer.
|
|
877
|
+
*
|
|
878
|
+
* Note: the state store is read-then-written; if your deployment performs concurrent
|
|
879
|
+
* logins for the same session identifier, use a state store with atomic/serializable
|
|
880
|
+
* writes to avoid last-write-wins races.
|
|
881
|
+
*
|
|
882
|
+
* @param options Discriminated completion options (`connection`, identifier, `verificationCode`).
|
|
883
|
+
* @param storeOptions Optional options passed to the resolver / stores.
|
|
884
|
+
*
|
|
885
|
+
* @throws {PasswordlessVerifyError} If the code is invalid, expired, or rate-limited. When the
|
|
886
|
+
* connection requires MFA, the server responds with `mfa_required`; narrow the thrown error
|
|
887
|
+
* with `isMfaRequiredError(error)` to read `cause.mfa_token`.
|
|
888
|
+
*
|
|
889
|
+
* @returns A promise resolving to the authorizationDetails (when RAR was used).
|
|
890
|
+
*/
|
|
891
|
+
async completePasswordless(options, storeOptions) {
|
|
892
|
+
const scope = ensureOpenIdScope(options.authorizationParams?.scope ?? this.#options.authorizationParams?.scope);
|
|
893
|
+
const audience = options.authorizationParams?.audience ?? this.#options.authorizationParams?.audience;
|
|
894
|
+
const domain = await this.#resolveDomain(storeOptions);
|
|
895
|
+
const authClient = this.#getAuthClient(domain);
|
|
896
|
+
const tokenEndpointResponse = options.connection === "sms" ? await authClient.getTokenByPasswordlessSms({
|
|
897
|
+
phoneNumber: options.phoneNumber,
|
|
898
|
+
code: options.verificationCode,
|
|
899
|
+
audience,
|
|
900
|
+
scope
|
|
901
|
+
}) : await authClient.getTokenByPasswordlessEmail({
|
|
902
|
+
email: options.email,
|
|
903
|
+
code: options.verificationCode,
|
|
904
|
+
audience,
|
|
905
|
+
scope
|
|
906
|
+
});
|
|
907
|
+
const existingStateData = await this.#stateStore.get(this.#stateStoreIdentifier, storeOptions);
|
|
602
908
|
const stateData = updateStateData(
|
|
603
909
|
this.#options.authorizationParams?.audience ?? "default",
|
|
604
910
|
existingStateData,
|
|
@@ -610,6 +916,49 @@ var ServerClient = class {
|
|
|
610
916
|
authorizationDetails: tokenEndpointResponse.authorizationDetails
|
|
611
917
|
};
|
|
612
918
|
}
|
|
919
|
+
/**
|
|
920
|
+
* Completes a passwordless magic-link login and persists the resulting session.
|
|
921
|
+
*
|
|
922
|
+
* Loads the transaction persisted by {@link ServerClient#startPasswordless} (magic-link mode), validates the
|
|
923
|
+
* `state` returned on the callback URL against the stored `state` (anti-forgery binding), exchanges
|
|
924
|
+
* the authorization code WITHOUT PKCE, writes the session, and deletes the transaction. The existing
|
|
925
|
+
* interactive login path ({@link ServerClient#completeInteractiveLogin}) is not used.
|
|
926
|
+
*
|
|
927
|
+
* @param url The callback URL containing the authorization `code` and `state`.
|
|
928
|
+
* @param storeOptions Optional options passed to the resolver / stores.
|
|
929
|
+
*
|
|
930
|
+
* @throws {MissingTransactionError} If no magic-link transaction was found.
|
|
931
|
+
* @throws {PasswordlessVerifyError} If the returned `state` is missing or does not match.
|
|
932
|
+
* @throws {TokenByCodeError} If the token exchange fails.
|
|
933
|
+
*
|
|
934
|
+
* @returns A promise resolving to the authorizationDetails (when RAR was used).
|
|
935
|
+
*
|
|
936
|
+
* @example
|
|
937
|
+
* const result = await serverClient.completePasswordlessMagicLink(callbackUrl, storeOptions);
|
|
938
|
+
*/
|
|
939
|
+
async completePasswordlessMagicLink(url, storeOptions) {
|
|
940
|
+
const transactionData = await this.#transactionStore.get(this.#transactionStoreIdentifier, storeOptions);
|
|
941
|
+
if (!transactionData) {
|
|
942
|
+
throw new MissingTransactionError();
|
|
943
|
+
}
|
|
944
|
+
const expectedState = typeof transactionData.state === "string" ? transactionData.state : void 0;
|
|
945
|
+
const returnedState = url.searchParams.get("state");
|
|
946
|
+
if (!returnedState || !expectedState || returnedState !== expectedState) {
|
|
947
|
+
throw new PasswordlessVerifyError("State mismatch on magic-link callback");
|
|
948
|
+
}
|
|
949
|
+
const domain = transactionData.domain ?? await this.#resolveDomain(storeOptions);
|
|
950
|
+
const authClient = this.#getAuthClient(domain);
|
|
951
|
+
const tokenEndpointResponse = await authClient.getTokenByMagicLinkCode(url, { expectedState });
|
|
952
|
+
const existingStateData = await this.#stateStore.get(this.#stateStoreIdentifier, storeOptions);
|
|
953
|
+
const stateData = updateStateData(transactionData.audience ?? "default", existingStateData, tokenEndpointResponse, {
|
|
954
|
+
domain
|
|
955
|
+
});
|
|
956
|
+
await this.#stateStore.set(this.#stateStoreIdentifier, stateData, true, storeOptions);
|
|
957
|
+
await this.#transactionStore.delete(this.#transactionStoreIdentifier, storeOptions);
|
|
958
|
+
return {
|
|
959
|
+
authorizationDetails: tokenEndpointResponse.authorizationDetails
|
|
960
|
+
};
|
|
961
|
+
}
|
|
613
962
|
/**
|
|
614
963
|
* Retrieves the user from the store, or undefined if no user found.
|
|
615
964
|
* @param storeOptions Optional options used to pass to the Transaction and State Store.
|
|
@@ -626,6 +975,10 @@ var ServerClient = class {
|
|
|
626
975
|
return;
|
|
627
976
|
}
|
|
628
977
|
}
|
|
978
|
+
if (isSessionExpiryReached(stateData.sessionExpiresAt)) {
|
|
979
|
+
await this.#stateStore.delete(this.#stateStoreIdentifier, storeOptions);
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
629
982
|
return stateData.user;
|
|
630
983
|
}
|
|
631
984
|
/**
|
|
@@ -642,6 +995,10 @@ var ServerClient = class {
|
|
|
642
995
|
return;
|
|
643
996
|
}
|
|
644
997
|
}
|
|
998
|
+
if (isSessionExpiryReached(stateData.sessionExpiresAt)) {
|
|
999
|
+
await this.#stateStore.delete(this.#stateStoreIdentifier, storeOptions);
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
645
1002
|
const { internal, ...sessionData } = stateData;
|
|
646
1003
|
return sessionData;
|
|
647
1004
|
}
|
|
@@ -649,16 +1006,30 @@ var ServerClient = class {
|
|
|
649
1006
|
/**
|
|
650
1007
|
* Retrieves the access token from the store, or calls Auth0 when the access token is expired and a refresh token is available in the store.
|
|
651
1008
|
* Also updates the store when a new token was retrieved from Auth0.
|
|
1009
|
+
*
|
|
1010
|
+
* When `options.audience` and/or `options.scope` are provided, the SDK uses the session's refresh token to
|
|
1011
|
+
* request an access token for that audience/scope (Multi-Resource Refresh Tokens). Tokens are cached per
|
|
1012
|
+
* audience and scope combination.
|
|
1013
|
+
*
|
|
1014
|
+
* @param options Optional options for requesting a specific audience/scope.
|
|
652
1015
|
* @param storeOptions Optional options used to pass to the Transaction and State Store.
|
|
653
1016
|
*
|
|
654
1017
|
* @throws {TokenByRefreshTokenError} If the refresh token was not found or there was an issue requesting the access token. When the cause is `mfa_required`, use `isMfaRequiredError(error)` to narrow the error and read `cause.mfa_token`.
|
|
1018
|
+
* @throws {SessionExpiredError} When the session's `session_expiry` ceiling has been reached; the session is cleared and no refresh is attempted — the user must re-authenticate.
|
|
655
1019
|
*
|
|
656
1020
|
* @returns The Token Set, containing the access token, as well as additional information.
|
|
657
1021
|
*/
|
|
658
|
-
async getAccessToken(storeOptions) {
|
|
659
|
-
const
|
|
660
|
-
|
|
661
|
-
|
|
1022
|
+
async getAccessToken(tokenOptionsOrStoreOptions, storeOptions) {
|
|
1023
|
+
const hasTokenOptions = (
|
|
1024
|
+
// If second arg exists, first arg must be GetAccessTokenOptions
|
|
1025
|
+
storeOptions !== void 0 || // OR if first arg has audience/scope properties
|
|
1026
|
+
!!tokenOptionsOrStoreOptions && typeof tokenOptionsOrStoreOptions === "object" && ("audience" in tokenOptionsOrStoreOptions || "scope" in tokenOptionsOrStoreOptions)
|
|
1027
|
+
);
|
|
1028
|
+
const [resolvedOptions, resolvedStoreOptions] = hasTokenOptions ? [tokenOptionsOrStoreOptions, storeOptions] : [void 0, tokenOptionsOrStoreOptions];
|
|
1029
|
+
const stateData = await this.#stateStore.get(this.#stateStoreIdentifier, resolvedStoreOptions);
|
|
1030
|
+
const requestedAudience = resolvedOptions?.audience ?? this.#options.authorizationParams?.audience;
|
|
1031
|
+
const audience = requestedAudience ?? "default";
|
|
1032
|
+
const scope = resolvedOptions?.scope ?? this.#options.authorizationParams?.scope;
|
|
662
1033
|
const sessionDomain = stateData ? this.#getSessionDomain(stateData) : this.#staticDomain;
|
|
663
1034
|
if (this.#isResolverMode()) {
|
|
664
1035
|
if (!stateData) {
|
|
@@ -667,11 +1038,15 @@ var ServerClient = class {
|
|
|
667
1038
|
if (!sessionDomain) {
|
|
668
1039
|
throw new MissingSessionError("Session domain does not match the current domain.");
|
|
669
1040
|
}
|
|
670
|
-
const resolvedDomain = await this.#resolveDomain(
|
|
1041
|
+
const resolvedDomain = await this.#resolveDomain(resolvedStoreOptions);
|
|
671
1042
|
if (sessionDomain !== resolvedDomain) {
|
|
672
1043
|
throw new MissingSessionError("Session domain does not match the current domain.");
|
|
673
1044
|
}
|
|
674
1045
|
}
|
|
1046
|
+
if (stateData && isSessionExpiryReached(stateData.sessionExpiresAt)) {
|
|
1047
|
+
await this.#stateStore.delete(this.#stateStoreIdentifier, resolvedStoreOptions);
|
|
1048
|
+
throw new SessionExpiredError();
|
|
1049
|
+
}
|
|
675
1050
|
const tokenSet = stateData?.tokenSets.find(
|
|
676
1051
|
(tokenSet2) => tokenSet2.audience === audience && (!scope || compareScopes(tokenSet2.scope, scope))
|
|
677
1052
|
);
|
|
@@ -684,14 +1059,21 @@ var ServerClient = class {
|
|
|
684
1059
|
);
|
|
685
1060
|
}
|
|
686
1061
|
const domainForSession = sessionDomain;
|
|
687
|
-
const
|
|
688
|
-
refreshToken: stateData.refreshToken
|
|
689
|
-
|
|
690
|
-
|
|
1062
|
+
const tokenByRefreshTokenOptions = {
|
|
1063
|
+
refreshToken: stateData.refreshToken,
|
|
1064
|
+
// Only forward audience/scope to Auth0 when token options were explicitly supplied, and
|
|
1065
|
+
// never send the synthetic 'default' cache-key audience as a real request parameter.
|
|
1066
|
+
...hasTokenOptions && {
|
|
1067
|
+
...requestedAudience && { audience: requestedAudience },
|
|
1068
|
+
...scope && { scope }
|
|
1069
|
+
}
|
|
1070
|
+
};
|
|
1071
|
+
const tokenEndpointResponse = await this.#getAuthClient(domainForSession).getTokenByRefreshToken(tokenByRefreshTokenOptions);
|
|
1072
|
+
const existingStateData = await this.#stateStore.get(this.#stateStoreIdentifier, resolvedStoreOptions);
|
|
691
1073
|
const updatedStateData = updateStateData(audience, existingStateData, tokenEndpointResponse, {
|
|
692
1074
|
domain: domainForSession
|
|
693
1075
|
});
|
|
694
|
-
await this.#stateStore.set(this.#stateStoreIdentifier, updatedStateData, false,
|
|
1076
|
+
await this.#stateStore.set(this.#stateStoreIdentifier, updatedStateData, false, resolvedStoreOptions);
|
|
695
1077
|
return {
|
|
696
1078
|
accessToken: tokenEndpointResponse.accessToken,
|
|
697
1079
|
scope: tokenEndpointResponse.scope,
|
|
@@ -1039,14 +1421,14 @@ var AbstractSessionStore = class extends AbstractStateStore {
|
|
|
1039
1421
|
}
|
|
1040
1422
|
/**
|
|
1041
1423
|
* calculateMaxAge calculates the max age of the session based on createdAt and the rolling and absolute durations.
|
|
1424
|
+
* When sessionExpiresAt is provided, caps the maxAge to not exceed the time until that ceiling.
|
|
1042
1425
|
*/
|
|
1043
|
-
calculateMaxAge(createdAt) {
|
|
1044
|
-
if (!this.#rolling) {
|
|
1045
|
-
return this.#absoluteDuration;
|
|
1046
|
-
}
|
|
1426
|
+
calculateMaxAge(createdAt, sessionExpiresAt) {
|
|
1047
1427
|
const now = Date.now() / 1e3 | 0;
|
|
1048
|
-
|
|
1049
|
-
|
|
1428
|
+
let maxAge = this.#rolling ? Math.min(now + this.#inactivityDuration, createdAt + this.#absoluteDuration) - now : this.#absoluteDuration;
|
|
1429
|
+
if (sessionExpiresAt !== void 0) {
|
|
1430
|
+
maxAge = Math.min(maxAge, sessionExpiresAt - now);
|
|
1431
|
+
}
|
|
1050
1432
|
return maxAge > 0 ? maxAge : 0;
|
|
1051
1433
|
}
|
|
1052
1434
|
};
|
|
@@ -1074,7 +1456,7 @@ var StatefulStateStore = class extends AbstractSessionStore {
|
|
|
1074
1456
|
sessionId = generateId();
|
|
1075
1457
|
}
|
|
1076
1458
|
sessionId ??= generateId();
|
|
1077
|
-
const maxAge = this.calculateMaxAge(stateData.internal.createdAt);
|
|
1459
|
+
const maxAge = this.calculateMaxAge(stateData.internal.createdAt, stateData.sessionExpiresAt);
|
|
1078
1460
|
const cookieOpts = this.#getCookieOptions({
|
|
1079
1461
|
maxAge
|
|
1080
1462
|
});
|
|
@@ -1139,7 +1521,7 @@ var StatelessStateStore = class extends AbstractSessionStore {
|
|
|
1139
1521
|
this.#cookieHandler = cookieHandler;
|
|
1140
1522
|
}
|
|
1141
1523
|
async set(identifier, stateData, removeIfExists, options) {
|
|
1142
|
-
const maxAge = this.calculateMaxAge(stateData.internal.createdAt);
|
|
1524
|
+
const maxAge = this.calculateMaxAge(stateData.internal.createdAt, stateData.sessionExpiresAt);
|
|
1143
1525
|
const cookieOpts = this.#getCookieOptions({
|
|
1144
1526
|
maxAge
|
|
1145
1527
|
});
|
|
@@ -1203,6 +1585,14 @@ import {
|
|
|
1203
1585
|
MfaVerifyError,
|
|
1204
1586
|
isMfaRequiredError
|
|
1205
1587
|
} from "@auth0/auth0-auth-js";
|
|
1588
|
+
|
|
1589
|
+
// src/passkey/index.ts
|
|
1590
|
+
import {
|
|
1591
|
+
PasskeyRegisterError,
|
|
1592
|
+
PasskeyChallengeError,
|
|
1593
|
+
PasskeyGetTokenError,
|
|
1594
|
+
OrganizationValidationError
|
|
1595
|
+
} from "@auth0/auth0-auth-js";
|
|
1206
1596
|
export {
|
|
1207
1597
|
AbstractStateStore,
|
|
1208
1598
|
AbstractTransactionStore,
|
|
@@ -1218,8 +1608,14 @@ export {
|
|
|
1218
1608
|
MissingRequiredArgumentError,
|
|
1219
1609
|
MissingSessionError,
|
|
1220
1610
|
MissingTransactionError,
|
|
1611
|
+
OrganizationValidationError,
|
|
1612
|
+
PasskeyChallengeError,
|
|
1613
|
+
PasskeyGetTokenError,
|
|
1614
|
+
PasskeyRegisterError,
|
|
1221
1615
|
ServerClient,
|
|
1222
1616
|
ServerMfaClient,
|
|
1617
|
+
ServerPasskeyClient,
|
|
1618
|
+
SessionExpiredError,
|
|
1223
1619
|
StartLinkUserError,
|
|
1224
1620
|
StatefulStateStore,
|
|
1225
1621
|
StatelessStateStore,
|