@idp.global/sdk 19.2.1 → 19.3.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/changelog.md +18 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts_relyingparty/classes.errors.d.ts +9 -1
- package/dist_ts_relyingparty/classes.errors.js +5 -1
- package/dist_ts_relyingparty/classes.relyingpartyroutes.d.ts +62 -2
- package/dist_ts_relyingparty/classes.relyingpartyroutes.js +260 -12
- package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +108 -1
- package/dist_ts_relyingparty/classes.relyingpartysessions.js +508 -60
- package/dist_ts_relyingparty/classes.rpmodels.d.ts +65 -4
- package/dist_ts_relyingparty/classes.rpmodels.js +217 -5
- package/dist_ts_relyingparty/helpers.bearer.d.ts +48 -0
- package/dist_ts_relyingparty/helpers.bearer.js +199 -0
- package/dist_ts_relyingparty/index.d.ts +1 -0
- package/dist_ts_relyingparty/index.js +2 -1
- package/dist_ts_relyingparty/interfaces.d.ts +111 -1
- package/dist_ts_relyingparty/testing.d.ts +22 -0
- package/dist_ts_relyingparty/testing.js +42 -1
- package/package.json +8 -11
- package/readme.md +153 -4
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts_relyingparty/classes.errors.ts +13 -1
- package/ts_relyingparty/classes.relyingpartyroutes.ts +307 -14
- package/ts_relyingparty/classes.relyingpartysessions.ts +610 -61
- package/ts_relyingparty/classes.rpmodels.ts +124 -5
- package/ts_relyingparty/helpers.bearer.ts +260 -0
- package/ts_relyingparty/index.ts +1 -0
- package/ts_relyingparty/interfaces.ts +120 -1
- package/ts_relyingparty/testing.ts +60 -0
|
@@ -14,13 +14,36 @@ import {
|
|
|
14
14
|
type IOidcRefreshedTokens,
|
|
15
15
|
type TOidcTokenHint,
|
|
16
16
|
} from './classes.idpoidcprovider.js';
|
|
17
|
-
import type {
|
|
17
|
+
import type {
|
|
18
|
+
IRelyingPartyBearerModels,
|
|
19
|
+
IRelyingPartyModels,
|
|
20
|
+
RelyingPartyBearerHandoffDoc,
|
|
21
|
+
RelyingPartyLoginAttemptDoc,
|
|
22
|
+
RelyingPartySessionDoc,
|
|
23
|
+
} from './classes.rpmodels.js';
|
|
24
|
+
import {
|
|
25
|
+
isBearerClientKind,
|
|
26
|
+
isBearerClientState,
|
|
27
|
+
isBearerReturnTargetAllowed,
|
|
28
|
+
isPkceChallenge,
|
|
29
|
+
isPkceVerifier,
|
|
30
|
+
normalizeBearerReturnTargets,
|
|
31
|
+
parseBearerAuthorization,
|
|
32
|
+
verifyPkceChallenge,
|
|
33
|
+
} from './helpers.bearer.js';
|
|
18
34
|
import { parseCookies, serializeClearedCookie, serializeCookie } from './helpers.cookies.js';
|
|
19
35
|
import { deriveCsrfToken, randomToken, sha256, timingSafeEqualString } from './helpers.crypto.js';
|
|
20
36
|
import { sanitizeReturnPath } from './helpers.returnpath.js';
|
|
21
37
|
import type {
|
|
22
38
|
IAuthorityTokenSource,
|
|
23
39
|
IProviderToken,
|
|
40
|
+
IRelyingPartyBearerDelivery,
|
|
41
|
+
IRelyingPartyBearerHandoff,
|
|
42
|
+
IRelyingPartyBearerLoginRequest,
|
|
43
|
+
IRelyingPartyBearerLoginStart,
|
|
44
|
+
IRelyingPartyBearerOptions,
|
|
45
|
+
IRelyingPartyBearerReturnTargets,
|
|
46
|
+
IRelyingPartyBearerTokens,
|
|
24
47
|
IRelyingPartyEvent,
|
|
25
48
|
IRelyingPartyHooks,
|
|
26
49
|
IRelyingPartyAdmission,
|
|
@@ -46,10 +69,16 @@ const SESSION_TOUCH_INTERVAL_MS = 60_000;
|
|
|
46
69
|
export const LOGIN_ATTEMPT_MAX_AGE_SECONDS = 10 * 60;
|
|
47
70
|
/** The longest intent a login carries (`IRelyingPartyLoginOptions.intent`), in characters. */
|
|
48
71
|
export const RELYING_PARTY_MAX_INTENT_LENGTH = 4_096;
|
|
49
|
-
/** Attempts are swept once they are this much older than their expiry. */
|
|
72
|
+
/** Attempts — and bearer hand-offs and token generations — are swept once they are this much older than their expiry. */
|
|
50
73
|
const LOGIN_ATTEMPT_SWEEP_GRACE_MS = 60 * 60 * 1_000;
|
|
51
74
|
/** How many of them one sign-in removes; the next sign-in takes the following page. */
|
|
52
75
|
const LOGIN_ATTEMPT_SWEEP_BATCH = 100;
|
|
76
|
+
/** How long a bearer hand-off code can be claimed, at most; never past its session. */
|
|
77
|
+
export const BEARER_HANDOFF_MAX_AGE_SECONDS = 60;
|
|
78
|
+
/** How long a bearer access token lives unless `bearer.accessTokenMaxAgeSeconds` says otherwise. */
|
|
79
|
+
export const RELYING_PARTY_DEFAULT_BEARER_ACCESS_TOKEN_MAX_AGE_SECONDS = 15 * 60;
|
|
80
|
+
/** The codes and tokens of bearer mode are 43 characters; anything far longer is none of them. */
|
|
81
|
+
const MAX_BEARER_TOKEN_LENGTH = 256;
|
|
53
82
|
/**
|
|
54
83
|
* How long ending a session waits for the provider to take its tokens back.
|
|
55
84
|
* The local session is already revoked when that call runs.
|
|
@@ -92,6 +121,11 @@ export interface IRelyingPartySessionsOptions<TPrincipal extends object | undefi
|
|
|
92
121
|
};
|
|
93
122
|
/** the prefix of the login routes; a login never returns into it */
|
|
94
123
|
loginPathPrefix?: string;
|
|
124
|
+
/**
|
|
125
|
+
* absent: no bearer hand-off mode. Needs `provider` and the bearer models
|
|
126
|
+
* (`createRelyingPartyModels` binds them).
|
|
127
|
+
*/
|
|
128
|
+
bearer?: IRelyingPartyBearerOptions;
|
|
95
129
|
hooks?: IRelyingPartyHooks;
|
|
96
130
|
/** the clock every comparison reads; tests step it instead of sleeping */
|
|
97
131
|
now?: () => number;
|
|
@@ -99,6 +133,53 @@ export interface IRelyingPartySessionsOptions<TPrincipal extends object | undefi
|
|
|
99
133
|
|
|
100
134
|
type TSealedTokens = Pick<RelyingPartySessionDoc, 'sealedAccessToken' | 'sealedRefreshToken'>;
|
|
101
135
|
|
|
136
|
+
/** Bearer hand-off mode as the constructor checked it. */
|
|
137
|
+
interface IBearerMode {
|
|
138
|
+
returnTargets: Readonly<Required<IRelyingPartyBearerReturnTargets>>;
|
|
139
|
+
accessTokenMaxAgeSeconds: number;
|
|
140
|
+
models: IRelyingPartyBearerModels;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const bearerModeOf = (
|
|
144
|
+
bearerArg: IRelyingPartyBearerOptions,
|
|
145
|
+
optionsArg: { models: IRelyingPartyModels; hasProvider: boolean; publicOrigin: string; loginPathPrefix: string; sessionMaxAgeSeconds: number },
|
|
146
|
+
): IBearerMode => {
|
|
147
|
+
if (!bearerArg || typeof bearerArg !== 'object') {
|
|
148
|
+
throw new Error('bearer must be an object with returnTargets');
|
|
149
|
+
}
|
|
150
|
+
if (!optionsArg.hasProvider) {
|
|
151
|
+
throw new Error('bearer needs a provider: every bearer session is a provider login');
|
|
152
|
+
}
|
|
153
|
+
const { BearerHandoff, BearerToken } = optionsArg.models;
|
|
154
|
+
if (!BearerHandoff || !BearerToken) {
|
|
155
|
+
throw new Error('bearer needs the BearerHandoff and BearerToken models; createRelyingPartyModels binds them');
|
|
156
|
+
}
|
|
157
|
+
const accessTokenMaxAgeSeconds = bearerArg.accessTokenMaxAgeSeconds
|
|
158
|
+
?? Math.min(RELYING_PARTY_DEFAULT_BEARER_ACCESS_TOKEN_MAX_AGE_SECONDS, optionsArg.sessionMaxAgeSeconds);
|
|
159
|
+
if (
|
|
160
|
+
!Number.isSafeInteger(accessTokenMaxAgeSeconds)
|
|
161
|
+
|| accessTokenMaxAgeSeconds < 1
|
|
162
|
+
|| accessTokenMaxAgeSeconds > optionsArg.sessionMaxAgeSeconds
|
|
163
|
+
) {
|
|
164
|
+
throw new Error(`bearer.accessTokenMaxAgeSeconds must be a whole number from 1 to ${optionsArg.sessionMaxAgeSeconds}`);
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
returnTargets: normalizeBearerReturnTargets(bearerArg.returnTargets, optionsArg.publicOrigin, optionsArg.loginPathPrefix),
|
|
168
|
+
accessTokenMaxAgeSeconds,
|
|
169
|
+
models: { BearerHandoff, BearerToken },
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/** The one `state` of a callback, or `null` when it carries none or several. */
|
|
174
|
+
const singleStateOf = (callbackUrlArg: URL): string | null => {
|
|
175
|
+
const states = callbackUrlArg.searchParams.getAll('state');
|
|
176
|
+
return states.length === 1 && states[0] ? states[0] : null;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/** A code or token of bearer mode as a client presents it, or `null` when it cannot be one. */
|
|
180
|
+
const bearerCredentialOf = (valueArg: unknown): string | null =>
|
|
181
|
+
typeof valueArg === 'string' && valueArg.length > 0 && valueArg.length <= MAX_BEARER_TOKEN_LENGTH ? valueArg : null;
|
|
182
|
+
|
|
102
183
|
const REVOKED_TOKEN_FIELDS = { sealedAccessToken: '', sealedRefreshToken: '', refreshLockUntil: 0 };
|
|
103
184
|
|
|
104
185
|
const errorMessage = (errorArg: unknown): string => (errorArg instanceof Error ? errorArg.message : String(errorArg));
|
|
@@ -150,6 +231,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
150
231
|
/** the principal of a session when it may act right now, `null` otherwise */
|
|
151
232
|
readonly #activePrincipal: (principalIdArg: string) => Promise<{ principal: TPrincipal } | null>;
|
|
152
233
|
readonly #provider?: IOidcProvider;
|
|
234
|
+
readonly #bearer?: IBearerMode;
|
|
153
235
|
readonly #secretBox: SecretBox;
|
|
154
236
|
readonly #csrfKey: Buffer;
|
|
155
237
|
readonly #csrfLabel: string;
|
|
@@ -220,6 +302,15 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
220
302
|
: null;
|
|
221
303
|
}
|
|
222
304
|
this.#provider = optionsArg.provider;
|
|
305
|
+
if (optionsArg.bearer !== undefined) {
|
|
306
|
+
this.#bearer = bearerModeOf(optionsArg.bearer, {
|
|
307
|
+
models: optionsArg.models,
|
|
308
|
+
hasProvider: optionsArg.provider !== undefined,
|
|
309
|
+
publicOrigin: this.publicOrigin,
|
|
310
|
+
loginPathPrefix: this.loginPathPrefix,
|
|
311
|
+
sessionMaxAgeSeconds: maxAge,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
223
314
|
this.#secretBox = new SecretBox(optionsArg.sessionEncryptionKey);
|
|
224
315
|
this.#csrfKey = Buffer.from(optionsArg.csrfKey);
|
|
225
316
|
this.#csrfLabel = optionsArg.csrfLabel ?? RELYING_PARTY_DEFAULT_CSRF_LABEL;
|
|
@@ -232,6 +323,16 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
232
323
|
return this.#provider !== undefined;
|
|
233
324
|
}
|
|
234
325
|
|
|
326
|
+
/** Whether bearer hand-off mode is offered. */
|
|
327
|
+
public get bearerLoginEnabled(): boolean {
|
|
328
|
+
return this.#bearer !== undefined;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The return targets bearer clients may name, per kind, as configured; `undefined` without bearer mode. */
|
|
332
|
+
public get bearerReturnTargets(): Readonly<Required<IRelyingPartyBearerReturnTargets>> | undefined {
|
|
333
|
+
return this.#bearer?.returnTargets;
|
|
334
|
+
}
|
|
335
|
+
|
|
235
336
|
/**
|
|
236
337
|
* How long one replica holds the right to rotate a session's tokens. The
|
|
237
338
|
* provider detects reuse of a superseded refresh token and kills the family,
|
|
@@ -391,34 +492,19 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
391
492
|
*/
|
|
392
493
|
public async startLogin(returnPathArg: unknown, optionsArg: IRelyingPartyLoginOptions = {}): Promise<IRelyingPartyLoginStart> {
|
|
393
494
|
const provider = this.requireProvider();
|
|
394
|
-
|
|
395
|
-
if (intent !== undefined && (typeof intent !== 'string' || intent.length === 0 || intent.length > RELYING_PARTY_MAX_INTENT_LENGTH)) {
|
|
396
|
-
throw new RangeError(`a login intent is text of 1 to ${RELYING_PARTY_MAX_INTENT_LENGTH} characters`);
|
|
397
|
-
}
|
|
398
|
-
if (prompt !== undefined && !OIDC_PROMPTS.includes(prompt)) {
|
|
399
|
-
throw new RangeError(`a login prompt is one of ${OIDC_PROMPTS.join(', ')}`);
|
|
400
|
-
}
|
|
495
|
+
this.checkLoginOptions(optionsArg);
|
|
401
496
|
await this.sweepExpiredAttempts();
|
|
402
|
-
const state = randomToken(32);
|
|
403
|
-
const nonce = randomToken(32);
|
|
404
|
-
const pkceVerifier = randomToken(32);
|
|
405
497
|
const bindingToken = randomToken(32);
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
const attempt = new this.#models.LoginAttempt();
|
|
409
|
-
attempt.id = 'lga_' + randomToken(18);
|
|
410
|
-
attempt.stateHash = sha256(state);
|
|
498
|
+
const { attempt, state, nonce, pkceVerifier } = this.newLoginAttempt(optionsArg.intent);
|
|
411
499
|
attempt.bindingTokenHash = sha256(bindingToken);
|
|
412
|
-
attempt.sealedPkceVerifier = this.#secretBox.seal(pkceVerifier);
|
|
413
|
-
attempt.nonce = nonce;
|
|
414
500
|
attempt.returnPath = sanitizeReturnPath(returnPathArg, this.publicOrigin, this.loginPathPrefix);
|
|
415
|
-
if (intent !== undefined) {
|
|
416
|
-
attempt.sealedIntent = this.#secretBox.seal(intent);
|
|
417
|
-
}
|
|
418
|
-
attempt.createdAt = now;
|
|
419
|
-
attempt.expiresAt = now + LOGIN_ATTEMPT_MAX_AGE_SECONDS * 1_000;
|
|
420
501
|
// the URL first: a login the provider cannot start yet (before discovery) stores no attempt
|
|
421
|
-
const authorizationUrl = await provider.buildAuthorizationUrl({
|
|
502
|
+
const authorizationUrl = await provider.buildAuthorizationUrl({
|
|
503
|
+
state,
|
|
504
|
+
nonce,
|
|
505
|
+
pkceVerifier,
|
|
506
|
+
...(optionsArg.prompt ? { prompt: optionsArg.prompt } : {}),
|
|
507
|
+
});
|
|
422
508
|
await attempt.save();
|
|
423
509
|
|
|
424
510
|
return {
|
|
@@ -468,35 +554,8 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
468
554
|
throw new RelyingPartyError('invalid_or_replayed_login');
|
|
469
555
|
}
|
|
470
556
|
const attempt = claimed.document as RelyingPartyLoginAttemptDoc;
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
let stage: TLoginCallbackStage = 'token_exchange';
|
|
474
|
-
try {
|
|
475
|
-
completion = await provider.completeAuthorization({
|
|
476
|
-
callbackUrl: callbackUrlArg,
|
|
477
|
-
state: states[0],
|
|
478
|
-
nonce: attempt.nonce,
|
|
479
|
-
pkceVerifier: this.#secretBox.open(attempt.sealedPkceVerifier),
|
|
480
|
-
});
|
|
481
|
-
stage = 'principal_admission';
|
|
482
|
-
const { principalId } = await this.#admit(completion, {
|
|
483
|
-
loginAttemptId: attempt.id,
|
|
484
|
-
...(attempt.sealedIntent ? { intent: this.#secretBox.open(attempt.sealedIntent) } : {}),
|
|
485
|
-
});
|
|
486
|
-
stage = 'session_persistence';
|
|
487
|
-
return await this.createProviderSession(attempt, principalId, completion);
|
|
488
|
-
} catch (error) {
|
|
489
|
-
if (completion) {
|
|
490
|
-
await this.revokeCompletionTokens(completion);
|
|
491
|
-
}
|
|
492
|
-
if (error instanceof RelyingPartyError || error instanceof RelyingPartyRefusal) {
|
|
493
|
-
throw error;
|
|
494
|
-
}
|
|
495
|
-
if (error instanceof OidcCompletionError) {
|
|
496
|
-
throw new LoginCallbackError(error.stage, error);
|
|
497
|
-
}
|
|
498
|
-
throw new LoginCallbackError(stage, error);
|
|
499
|
-
}
|
|
557
|
+
const opened = await this.openProviderLogin(provider, callbackUrlArg, states[0], attempt);
|
|
558
|
+
return this.opening(opened.document, opened.token);
|
|
500
559
|
}
|
|
501
560
|
|
|
502
561
|
/**
|
|
@@ -553,6 +612,294 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
553
612
|
return sanitizeReturnPath(attempt.returnPath, this.publicOrigin, this.loginPathPrefix);
|
|
554
613
|
}
|
|
555
614
|
|
|
615
|
+
// ==========================================================================
|
|
616
|
+
// bearer hand-off
|
|
617
|
+
// ==========================================================================
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Starts a bearer login for one of the application's own clients: the same
|
|
621
|
+
* provider login as `startLogin`, but the outcome goes to the return target
|
|
622
|
+
* the client named — exactly one of the configured targets of its kind —
|
|
623
|
+
* and it sets no cookie. The login is bound to the client's S256 challenge
|
|
624
|
+
* instead of a browser: whoever claims its hand-off code has to present the
|
|
625
|
+
* verifier. Every field of the request is checked as untrusted input.
|
|
626
|
+
*/
|
|
627
|
+
public async startBearerLogin(
|
|
628
|
+
requestArg: IRelyingPartyBearerLoginRequest,
|
|
629
|
+
optionsArg: IRelyingPartyLoginOptions = {},
|
|
630
|
+
): Promise<IRelyingPartyBearerLoginStart> {
|
|
631
|
+
const provider = this.requireProvider();
|
|
632
|
+
const bearer = this.requireBearer();
|
|
633
|
+
const request: Partial<Record<keyof IRelyingPartyBearerLoginRequest, unknown>> | null =
|
|
634
|
+
requestArg && typeof requestArg === 'object' ? requestArg : null;
|
|
635
|
+
if (
|
|
636
|
+
!request
|
|
637
|
+
|| !isBearerClientKind(request.clientKind)
|
|
638
|
+
|| typeof request.returnTarget !== 'string'
|
|
639
|
+
|| !isPkceChallenge(request.codeChallenge)
|
|
640
|
+
|| request.codeChallengeMethod !== 'S256'
|
|
641
|
+
|| (request.state !== undefined && !isBearerClientState(request.state))
|
|
642
|
+
) {
|
|
643
|
+
throw new RelyingPartyError('invalid_bearer_request');
|
|
644
|
+
}
|
|
645
|
+
if (!isBearerReturnTargetAllowed(bearer.returnTargets, request.clientKind, request.returnTarget)) {
|
|
646
|
+
throw new RelyingPartyError('return_target_not_allowed');
|
|
647
|
+
}
|
|
648
|
+
this.checkLoginOptions(optionsArg);
|
|
649
|
+
await this.sweepExpiredAttempts();
|
|
650
|
+
await this.sweepBearerState();
|
|
651
|
+
const { attempt, state, nonce, pkceVerifier } = this.newLoginAttempt(optionsArg.intent);
|
|
652
|
+
// a binding token that never leaves: no cookie callback can consume this attempt
|
|
653
|
+
attempt.bindingTokenHash = sha256(randomToken(32));
|
|
654
|
+
attempt.bearerClientKind = request.clientKind;
|
|
655
|
+
attempt.bearerReturnTarget = request.returnTarget;
|
|
656
|
+
attempt.bearerCodeChallenge = request.codeChallenge;
|
|
657
|
+
if (isBearerClientState(request.state)) {
|
|
658
|
+
attempt.bearerClientState = request.state;
|
|
659
|
+
}
|
|
660
|
+
const authorizationUrl = await provider.buildAuthorizationUrl({
|
|
661
|
+
state,
|
|
662
|
+
nonce,
|
|
663
|
+
pkceVerifier,
|
|
664
|
+
...(optionsArg.prompt ? { prompt: optionsArg.prompt } : {}),
|
|
665
|
+
});
|
|
666
|
+
await attempt.save();
|
|
667
|
+
return { authorizationUrl };
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Where the outcome of a login callback goes when it belongs to a bearer
|
|
672
|
+
* login: the client kind, the return target and the client's `state`, read
|
|
673
|
+
* without consuming anything. `null` for a cookie login, an unknown state,
|
|
674
|
+
* and a return target that is no longer configured — the callback is then
|
|
675
|
+
* not answered as a bearer login at all.
|
|
676
|
+
*/
|
|
677
|
+
public async bearerDeliveryOf(callbackUrlArg: URL): Promise<IRelyingPartyBearerDelivery | null> {
|
|
678
|
+
const state = this.#bearer ? singleStateOf(callbackUrlArg) : null;
|
|
679
|
+
if (!state) {
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
const attempt = await this.#models.LoginAttempt.getInstance({ stateHash: sha256(state) });
|
|
683
|
+
return attempt ? this.bearerDeliveryOfAttempt(attempt) : null;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Consumes a bearer login's attempt exactly once, exchanges the code, admits
|
|
688
|
+
* the principal and opens its session — everything `completeLogin` does,
|
|
689
|
+
* with the same failures — and answers a single-use hand-off code bound to
|
|
690
|
+
* the client's challenge. The code lives `BEARER_HANDOFF_MAX_AGE_SECONDS`
|
|
691
|
+
* at most and only its hash is stored. A code nobody claims ends its session.
|
|
692
|
+
*/
|
|
693
|
+
public async completeBearerLogin(callbackUrlArg: URL): Promise<IRelyingPartyBearerHandoff> {
|
|
694
|
+
const provider = this.requireProvider();
|
|
695
|
+
const bearer = this.requireBearer();
|
|
696
|
+
const state = singleStateOf(callbackUrlArg);
|
|
697
|
+
if (!state) {
|
|
698
|
+
throw new RelyingPartyError('invalid_login_callback');
|
|
699
|
+
}
|
|
700
|
+
const now = this.#now();
|
|
701
|
+
const claimed = await this.#models.LoginAttempt.atomicFindOneAndUpdate(
|
|
702
|
+
{ stateHash: sha256(state), consumedAt: null, expiresAt: { $gt: now }, bearerClientKind: { $exists: true } },
|
|
703
|
+
{ $set: { consumedAt: now } },
|
|
704
|
+
{ returnDocument: 'after' },
|
|
705
|
+
);
|
|
706
|
+
if (claimed.status !== 'matched' || !claimed.document) {
|
|
707
|
+
throw new RelyingPartyError('invalid_or_replayed_login');
|
|
708
|
+
}
|
|
709
|
+
const attempt = claimed.document as RelyingPartyLoginAttemptDoc;
|
|
710
|
+
const delivery = this.bearerDeliveryOfAttempt(attempt);
|
|
711
|
+
if (!delivery) {
|
|
712
|
+
throw new RelyingPartyError('return_target_not_allowed');
|
|
713
|
+
}
|
|
714
|
+
const codeChallenge = attempt.bearerCodeChallenge;
|
|
715
|
+
if (!isPkceChallenge(codeChallenge)) {
|
|
716
|
+
throw new RelyingPartyError('invalid_or_replayed_login');
|
|
717
|
+
}
|
|
718
|
+
const code = randomToken(32);
|
|
719
|
+
let handoff: RelyingPartyBearerHandoffDoc | undefined;
|
|
720
|
+
const opened = await this.openProviderLogin(provider, callbackUrlArg, state, attempt, async (sessionArg) => {
|
|
721
|
+
const created = new bearer.models.BearerHandoff();
|
|
722
|
+
created.id = 'rbh_' + randomToken(18);
|
|
723
|
+
created.codeHash = sha256(code);
|
|
724
|
+
created.sessionId = sessionArg.id;
|
|
725
|
+
created.codeChallenge = codeChallenge;
|
|
726
|
+
created.createdAt = this.#now();
|
|
727
|
+
created.expiresAt = Math.min(created.createdAt + BEARER_HANDOFF_MAX_AGE_SECONDS * 1_000, sessionArg.expiresAt);
|
|
728
|
+
created.consumedAt = null;
|
|
729
|
+
await created.save();
|
|
730
|
+
handoff = created;
|
|
731
|
+
});
|
|
732
|
+
return {
|
|
733
|
+
session: this.snapshot(opened.document),
|
|
734
|
+
delivery,
|
|
735
|
+
code,
|
|
736
|
+
expiresAt: (handoff as RelyingPartyBearerHandoffDoc).expiresAt,
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** `abandonLogin` for a bearer login: the provider came back without a code. */
|
|
741
|
+
public async abandonBearerLogin(callbackUrlArg: URL): Promise<void> {
|
|
742
|
+
const state = singleStateOf(callbackUrlArg);
|
|
743
|
+
if (!state) {
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
await this.#models.LoginAttempt.atomicUpdate(
|
|
747
|
+
{ stateHash: sha256(state), consumedAt: null, bearerClientKind: { $exists: true } },
|
|
748
|
+
{ $set: { consumedAt: this.#now() } },
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Trades a hand-off code and the verifier of its challenge for the first
|
|
754
|
+
* access and refresh token of the session. The code is consumed by the first
|
|
755
|
+
* claim whatever its verifier: a wrong verifier ends the session, and so
|
|
756
|
+
* does the code coming back after it was claimed (RFC 6749 §4.1.2). A code
|
|
757
|
+
* that expired unclaimed ends its session too.
|
|
758
|
+
*/
|
|
759
|
+
public async claimBearerHandoff(codeArg: unknown, codeVerifierArg: unknown): Promise<IRelyingPartyBearerTokens> {
|
|
760
|
+
const bearer = this.requireBearer();
|
|
761
|
+
const code = bearerCredentialOf(codeArg);
|
|
762
|
+
if (!code || !isPkceVerifier(codeVerifierArg)) {
|
|
763
|
+
throw new RelyingPartyError('invalid_bearer_request');
|
|
764
|
+
}
|
|
765
|
+
const codeHash = sha256(code);
|
|
766
|
+
const now = this.#now();
|
|
767
|
+
const claimed = await bearer.models.BearerHandoff.atomicFindOneAndUpdate(
|
|
768
|
+
{ codeHash, consumedAt: null, expiresAt: { $gt: now } },
|
|
769
|
+
{ $set: { consumedAt: now } },
|
|
770
|
+
{ returnDocument: 'after' },
|
|
771
|
+
);
|
|
772
|
+
if (claimed.status !== 'matched' || !claimed.document) {
|
|
773
|
+
const known = await bearer.models.BearerHandoff.getInstance({ codeHash });
|
|
774
|
+
if (known?.consumedAt === null) {
|
|
775
|
+
await this.endUnclaimedHandoff(known);
|
|
776
|
+
} else if (known && known.consumedAt !== null && known.consumedAt < known.expiresAt) {
|
|
777
|
+
// a claim took it while it was valid; one taken after its expiry was
|
|
778
|
+
// ended unclaimed, and coming back late is no reuse
|
|
779
|
+
await this.bearerCredentialReused(known.sessionId, 'handoff_code_reused');
|
|
780
|
+
}
|
|
781
|
+
throw new RelyingPartyError('invalid_bearer_handoff');
|
|
782
|
+
}
|
|
783
|
+
const handoff = claimed.document as RelyingPartyBearerHandoffDoc;
|
|
784
|
+
if (!verifyPkceChallenge(codeVerifierArg, handoff.codeChallenge)) {
|
|
785
|
+
await this.endSession(handoff.sessionId, 'handoff_verifier_mismatch');
|
|
786
|
+
throw new RelyingPartyError('invalid_bearer_handoff');
|
|
787
|
+
}
|
|
788
|
+
const live = await this.liveSession(await this.#models.Session.getInstance({ id: handoff.sessionId }));
|
|
789
|
+
if (!live) {
|
|
790
|
+
throw new RelyingPartyError('invalid_bearer_handoff');
|
|
791
|
+
}
|
|
792
|
+
return this.issueBearerTokens(bearer, live.document);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Rotates a bearer session's tokens: the refresh token is spent, a new
|
|
797
|
+
* access and refresh token answer, and the old access token stops working.
|
|
798
|
+
* Rotation is strict — a client serializes its refreshes — because a
|
|
799
|
+
* refresh token that comes back after it was spent ends the whole session.
|
|
800
|
+
* The provider login is checked on the way: a login idp.global ended ends
|
|
801
|
+
* the session here (`invalid_bearer_grant`), an unreachable provider fails
|
|
802
|
+
* with `AuthorityTokenError('authority_unavailable')` and spends nothing.
|
|
803
|
+
*/
|
|
804
|
+
public async refreshBearerTokens(refreshTokenArg: unknown): Promise<IRelyingPartyBearerTokens> {
|
|
805
|
+
const bearer = this.requireBearer();
|
|
806
|
+
const refreshToken = bearerCredentialOf(refreshTokenArg);
|
|
807
|
+
if (!refreshToken) {
|
|
808
|
+
throw new RelyingPartyError('invalid_bearer_grant');
|
|
809
|
+
}
|
|
810
|
+
const refreshTokenHash = sha256(refreshToken);
|
|
811
|
+
const current = await bearer.models.BearerToken.getInstance({ refreshTokenHash });
|
|
812
|
+
if (!current) {
|
|
813
|
+
throw new RelyingPartyError('invalid_bearer_grant');
|
|
814
|
+
}
|
|
815
|
+
if (current.rotatedAt !== null) {
|
|
816
|
+
await this.bearerCredentialReused(current.sessionId, 'refresh_token_reused');
|
|
817
|
+
throw new RelyingPartyError('invalid_bearer_grant');
|
|
818
|
+
}
|
|
819
|
+
if (current.expiresAt <= this.#now()) {
|
|
820
|
+
throw new RelyingPartyError('invalid_bearer_grant');
|
|
821
|
+
}
|
|
822
|
+
const live = await this.liveSession(await this.#models.Session.getInstance({ id: current.sessionId }));
|
|
823
|
+
if (!live) {
|
|
824
|
+
throw new RelyingPartyError('invalid_bearer_grant');
|
|
825
|
+
}
|
|
826
|
+
try {
|
|
827
|
+
await this.liveProviderToken(live.document.id, live.document.userId);
|
|
828
|
+
} catch (error) {
|
|
829
|
+
if (error instanceof AuthorityTokenError && error.code === 'authority_login_required') {
|
|
830
|
+
throw new RelyingPartyError('invalid_bearer_grant');
|
|
831
|
+
}
|
|
832
|
+
throw error;
|
|
833
|
+
}
|
|
834
|
+
// the next generation first: a rotation that fails half way leaves an
|
|
835
|
+
// unknown generation behind, never a session without a current one
|
|
836
|
+
const issued = await this.issueBearerTokens(bearer, live.document);
|
|
837
|
+
const rotated = await bearer.models.BearerToken.atomicUpdate(
|
|
838
|
+
{ refreshTokenHash, rotatedAt: null },
|
|
839
|
+
{ $set: { rotatedAt: this.#now() } },
|
|
840
|
+
);
|
|
841
|
+
if (rotated.matchedCount !== 1) {
|
|
842
|
+
// a concurrent refresh spent it first; the generation just issued never
|
|
843
|
+
// left this call and dies with the session
|
|
844
|
+
await this.bearerCredentialReused(current.sessionId, 'refresh_token_reused');
|
|
845
|
+
throw new RelyingPartyError('invalid_bearer_grant');
|
|
846
|
+
}
|
|
847
|
+
return issued;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* The live session behind a bearer access token, or `null`: the token of the
|
|
852
|
+
* session's current generation, not expired, its session live and its
|
|
853
|
+
* principal active. A read touches the session at most once a minute.
|
|
854
|
+
*/
|
|
855
|
+
public async resolveBearerAccessToken(accessTokenArg: unknown): Promise<IRelyingPartyLiveSession<TPrincipal> | null> {
|
|
856
|
+
const accessToken = this.#bearer ? bearerCredentialOf(accessTokenArg) : null;
|
|
857
|
+
if (!this.#bearer || !accessToken) {
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
const generation = await this.#bearer.models.BearerToken.getInstance({ accessTokenHash: sha256(accessToken) });
|
|
861
|
+
if (!generation || generation.rotatedAt !== null || generation.accessTokenExpiresAt <= this.#now()) {
|
|
862
|
+
return null;
|
|
863
|
+
}
|
|
864
|
+
const live = await this.liveSession(await this.#models.Session.getInstance({ id: generation.sessionId }));
|
|
865
|
+
return live ? { session: this.snapshot(live.document), principal: live.principal } : null;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/** `resolveBearerAccessToken` for an `Authorization: Bearer <token>` header. */
|
|
869
|
+
public async resolveBearerSession(
|
|
870
|
+
authorizationHeaderArg: string | null | undefined,
|
|
871
|
+
): Promise<IRelyingPartyLiveSession<TPrincipal> | null> {
|
|
872
|
+
return this.resolveBearerAccessToken(parseBearerAuthorization(authorizationHeaderArg));
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Ends the session of a bearer token — a live access token or the current
|
|
877
|
+
* refresh token — like `endSession`. A refresh token that was already spent
|
|
878
|
+
* is reuse and ends the session as such. Answers whether this call ended it.
|
|
879
|
+
*/
|
|
880
|
+
public async endBearerSession(tokenArg: unknown): Promise<boolean> {
|
|
881
|
+
const token = this.#bearer ? bearerCredentialOf(tokenArg) : null;
|
|
882
|
+
if (!this.#bearer || !token) {
|
|
883
|
+
return false;
|
|
884
|
+
}
|
|
885
|
+
const tokenHash = sha256(token);
|
|
886
|
+
const byAccessToken = await this.#bearer.models.BearerToken.getInstance({ accessTokenHash: tokenHash });
|
|
887
|
+
if (byAccessToken) {
|
|
888
|
+
if (byAccessToken.rotatedAt !== null || byAccessToken.accessTokenExpiresAt <= this.#now()) {
|
|
889
|
+
return false;
|
|
890
|
+
}
|
|
891
|
+
return this.endSession(byAccessToken.sessionId, 'logout');
|
|
892
|
+
}
|
|
893
|
+
const byRefreshToken = await this.#bearer.models.BearerToken.getInstance({ refreshTokenHash: tokenHash });
|
|
894
|
+
if (!byRefreshToken) {
|
|
895
|
+
return false;
|
|
896
|
+
}
|
|
897
|
+
if (byRefreshToken.rotatedAt !== null) {
|
|
898
|
+
return this.bearerCredentialReused(byRefreshToken.sessionId, 'refresh_token_reused');
|
|
899
|
+
}
|
|
900
|
+
return this.endSession(byRefreshToken.sessionId, 'logout');
|
|
901
|
+
}
|
|
902
|
+
|
|
556
903
|
// ==========================================================================
|
|
557
904
|
// provider tokens
|
|
558
905
|
// ==========================================================================
|
|
@@ -722,6 +1069,89 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
722
1069
|
return this.#provider;
|
|
723
1070
|
}
|
|
724
1071
|
|
|
1072
|
+
private requireBearer(): IBearerMode {
|
|
1073
|
+
if (!this.#bearer) {
|
|
1074
|
+
throw new RelyingPartyError('login_method_unavailable');
|
|
1075
|
+
}
|
|
1076
|
+
return this.#bearer;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
private checkLoginOptions(optionsArg: IRelyingPartyLoginOptions): void {
|
|
1080
|
+
const { intent, prompt } = optionsArg;
|
|
1081
|
+
if (intent !== undefined && (typeof intent !== 'string' || intent.length === 0 || intent.length > RELYING_PARTY_MAX_INTENT_LENGTH)) {
|
|
1082
|
+
throw new RangeError(`a login intent is text of 1 to ${RELYING_PARTY_MAX_INTENT_LENGTH} characters`);
|
|
1083
|
+
}
|
|
1084
|
+
if (prompt !== undefined && !OIDC_PROMPTS.includes(prompt)) {
|
|
1085
|
+
throw new RangeError(`a login prompt is one of ${OIDC_PROMPTS.join(', ')}`);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/** An unsaved attempt with its state, nonce and sealed verifier; the caller binds it and names where it returns. */
|
|
1090
|
+
private newLoginAttempt(intentArg: string | undefined): {
|
|
1091
|
+
attempt: RelyingPartyLoginAttemptDoc;
|
|
1092
|
+
state: string;
|
|
1093
|
+
nonce: string;
|
|
1094
|
+
pkceVerifier: string;
|
|
1095
|
+
} {
|
|
1096
|
+
const state = randomToken(32);
|
|
1097
|
+
const nonce = randomToken(32);
|
|
1098
|
+
const pkceVerifier = randomToken(32);
|
|
1099
|
+
const now = this.#now();
|
|
1100
|
+
const attempt = new this.#models.LoginAttempt();
|
|
1101
|
+
attempt.id = 'lga_' + randomToken(18);
|
|
1102
|
+
attempt.stateHash = sha256(state);
|
|
1103
|
+
attempt.sealedPkceVerifier = this.#secretBox.seal(pkceVerifier);
|
|
1104
|
+
attempt.nonce = nonce;
|
|
1105
|
+
if (intentArg !== undefined) {
|
|
1106
|
+
attempt.sealedIntent = this.#secretBox.seal(intentArg);
|
|
1107
|
+
}
|
|
1108
|
+
attempt.createdAt = now;
|
|
1109
|
+
attempt.expiresAt = now + LOGIN_ATTEMPT_MAX_AGE_SECONDS * 1_000;
|
|
1110
|
+
return { attempt, state, nonce, pkceVerifier };
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* The provider half of a consumed attempt: exchanges the code, admits the
|
|
1115
|
+
* principal and opens the session. Nothing of a failed login survives: its
|
|
1116
|
+
* tokens are handed back before the failure leaves.
|
|
1117
|
+
*/
|
|
1118
|
+
private async openProviderLogin(
|
|
1119
|
+
providerArg: IOidcProvider,
|
|
1120
|
+
callbackUrlArg: URL,
|
|
1121
|
+
stateArg: string,
|
|
1122
|
+
attemptArg: RelyingPartyLoginAttemptDoc,
|
|
1123
|
+
beforeOpenedArg?: (sessionArg: RelyingPartySessionDoc) => Promise<void>,
|
|
1124
|
+
): Promise<{ document: RelyingPartySessionDoc; token: string }> {
|
|
1125
|
+
let completion: IOidcCompletion | undefined;
|
|
1126
|
+
let stage: TLoginCallbackStage = 'token_exchange';
|
|
1127
|
+
try {
|
|
1128
|
+
completion = await providerArg.completeAuthorization({
|
|
1129
|
+
callbackUrl: callbackUrlArg,
|
|
1130
|
+
state: stateArg,
|
|
1131
|
+
nonce: attemptArg.nonce,
|
|
1132
|
+
pkceVerifier: this.#secretBox.open(attemptArg.sealedPkceVerifier),
|
|
1133
|
+
});
|
|
1134
|
+
stage = 'principal_admission';
|
|
1135
|
+
const { principalId } = await this.#admit(completion, {
|
|
1136
|
+
loginAttemptId: attemptArg.id,
|
|
1137
|
+
...(attemptArg.sealedIntent ? { intent: this.#secretBox.open(attemptArg.sealedIntent) } : {}),
|
|
1138
|
+
});
|
|
1139
|
+
stage = 'session_persistence';
|
|
1140
|
+
return await this.createProviderSession(attemptArg, principalId, completion, beforeOpenedArg);
|
|
1141
|
+
} catch (error) {
|
|
1142
|
+
if (completion) {
|
|
1143
|
+
await this.revokeCompletionTokens(completion);
|
|
1144
|
+
}
|
|
1145
|
+
if (error instanceof RelyingPartyError || error instanceof RelyingPartyRefusal) {
|
|
1146
|
+
throw error;
|
|
1147
|
+
}
|
|
1148
|
+
if (error instanceof OidcCompletionError) {
|
|
1149
|
+
throw new LoginCallbackError(error.stage, error);
|
|
1150
|
+
}
|
|
1151
|
+
throw new LoginCallbackError(stage, error);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
725
1155
|
private newSessionDoc(tokenArg: string, principalIdArg: string, maxAgeSecondsArg: number = this.sessionMaxAgeSeconds): RelyingPartySessionDoc {
|
|
726
1156
|
const now = this.#now();
|
|
727
1157
|
const session = new this.#models.Session();
|
|
@@ -790,11 +1220,17 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
790
1220
|
return { document: sessionArg, principal: active.principal };
|
|
791
1221
|
}
|
|
792
1222
|
|
|
1223
|
+
/**
|
|
1224
|
+
* Stores the session of a provider login and links it to its attempt.
|
|
1225
|
+
* `beforeOpenedArg` stores what the session is handed out with besides its
|
|
1226
|
+
* cookie; like the hook after it, a throw discards the session.
|
|
1227
|
+
*/
|
|
793
1228
|
private async createProviderSession(
|
|
794
1229
|
attemptArg: RelyingPartyLoginAttemptDoc,
|
|
795
1230
|
principalIdArg: string,
|
|
796
1231
|
completionArg: IOidcCompletion,
|
|
797
|
-
|
|
1232
|
+
beforeOpenedArg?: (sessionArg: RelyingPartySessionDoc) => Promise<void>,
|
|
1233
|
+
): Promise<{ document: RelyingPartySessionDoc; token: string }> {
|
|
798
1234
|
const token = randomToken(32);
|
|
799
1235
|
const session = this.newSessionDoc(token, principalIdArg);
|
|
800
1236
|
session.idpSubject = completionArg.subject;
|
|
@@ -814,13 +1250,13 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
814
1250
|
await this.discardUnopenedSession(session.id);
|
|
815
1251
|
throw new RelyingPartyError('invalid_or_replayed_login');
|
|
816
1252
|
}
|
|
817
|
-
const opening = this.opening(session, token);
|
|
818
1253
|
try {
|
|
819
|
-
await
|
|
1254
|
+
await beforeOpenedArg?.(session);
|
|
1255
|
+
await this.#hooks.onProviderSessionOpened?.({ session: this.snapshot(session) });
|
|
820
1256
|
} catch (error) {
|
|
821
|
-
// the cookie never leaves, so the session is
|
|
822
|
-
// and its sealed tokens erased before the
|
|
823
|
-
// the caller hands the provider tokens back
|
|
1257
|
+
// the cookie (or the hand-off code) never leaves, so the session is
|
|
1258
|
+
// unreachable: it is revoked and its sealed tokens erased before the
|
|
1259
|
+
// error goes on; the caller hands the provider tokens back
|
|
824
1260
|
try {
|
|
825
1261
|
await this.discardUnopenedSession(session.id);
|
|
826
1262
|
} catch (discardError) {
|
|
@@ -828,7 +1264,75 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
828
1264
|
}
|
|
829
1265
|
throw error;
|
|
830
1266
|
}
|
|
831
|
-
return
|
|
1267
|
+
return { document: session, token };
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
/** Where a bearer attempt's outcome goes, while its return target is still configured; `null` otherwise. */
|
|
1271
|
+
private bearerDeliveryOfAttempt(attemptArg: RelyingPartyLoginAttemptDoc): IRelyingPartyBearerDelivery | null {
|
|
1272
|
+
const clientKind = attemptArg.bearerClientKind;
|
|
1273
|
+
const returnTarget = attemptArg.bearerReturnTarget;
|
|
1274
|
+
if (
|
|
1275
|
+
!this.#bearer
|
|
1276
|
+
|| !isBearerClientKind(clientKind)
|
|
1277
|
+
|| typeof returnTarget !== 'string'
|
|
1278
|
+
|| !isBearerReturnTargetAllowed(this.#bearer.returnTargets, clientKind, returnTarget)
|
|
1279
|
+
) {
|
|
1280
|
+
return null;
|
|
1281
|
+
}
|
|
1282
|
+
return {
|
|
1283
|
+
clientKind,
|
|
1284
|
+
returnTarget,
|
|
1285
|
+
...(attemptArg.bearerClientState !== undefined ? { state: attemptArg.bearerClientState } : {}),
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/** Stores the next token generation of a live session and answers its tokens; none of them is stored in the clear. */
|
|
1290
|
+
private async issueBearerTokens(bearerArg: IBearerMode, sessionArg: RelyingPartySessionDoc): Promise<IRelyingPartyBearerTokens> {
|
|
1291
|
+
const accessToken = randomToken(32);
|
|
1292
|
+
const refreshToken = randomToken(32);
|
|
1293
|
+
const now = this.#now();
|
|
1294
|
+
const generation = new bearerArg.models.BearerToken();
|
|
1295
|
+
generation.id = 'rbt_' + randomToken(18);
|
|
1296
|
+
generation.accessTokenHash = sha256(accessToken);
|
|
1297
|
+
generation.refreshTokenHash = sha256(refreshToken);
|
|
1298
|
+
generation.sessionId = sessionArg.id;
|
|
1299
|
+
generation.createdAt = now;
|
|
1300
|
+
generation.accessTokenExpiresAt = Math.min(now + bearerArg.accessTokenMaxAgeSeconds * 1_000, sessionArg.expiresAt);
|
|
1301
|
+
generation.expiresAt = sessionArg.expiresAt;
|
|
1302
|
+
generation.rotatedAt = null;
|
|
1303
|
+
await generation.save();
|
|
1304
|
+
return {
|
|
1305
|
+
session: this.snapshot(sessionArg),
|
|
1306
|
+
issuedAt: now,
|
|
1307
|
+
accessToken,
|
|
1308
|
+
accessTokenExpiresAt: generation.accessTokenExpiresAt,
|
|
1309
|
+
refreshToken,
|
|
1310
|
+
refreshTokenExpiresAt: generation.expiresAt,
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
/** A spent hand-off code or refresh token came back: whoever holds it, the session ends. */
|
|
1315
|
+
private async bearerCredentialReused(
|
|
1316
|
+
sessionIdArg: string,
|
|
1317
|
+
reasonArg: 'handoff_code_reused' | 'refresh_token_reused',
|
|
1318
|
+
): Promise<boolean> {
|
|
1319
|
+
this.emit({ event: 'bearer_credential_reused', sessionId: sessionIdArg, reason: reasonArg });
|
|
1320
|
+
return this.endSession(sessionIdArg, reasonArg);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/**
|
|
1324
|
+
* Takes a hand-off whose code expired unclaimed and ends its session, which
|
|
1325
|
+
* nothing can reach any more. The caller has read it expired; expiry never
|
|
1326
|
+
* moves, so only a claim in between can stop this.
|
|
1327
|
+
*/
|
|
1328
|
+
private async endUnclaimedHandoff(handoffArg: RelyingPartyBearerHandoffDoc): Promise<void> {
|
|
1329
|
+
const taken = await this.requireBearer().models.BearerHandoff.atomicUpdate(
|
|
1330
|
+
{ id: handoffArg.id, consumedAt: null },
|
|
1331
|
+
{ $set: { consumedAt: this.#now() } },
|
|
1332
|
+
);
|
|
1333
|
+
if (taken.matchedCount === 1) {
|
|
1334
|
+
await this.endSession(handoffArg.sessionId, 'handoff_unclaimed');
|
|
1335
|
+
}
|
|
832
1336
|
}
|
|
833
1337
|
|
|
834
1338
|
/** Revokes a session that was never handed out and erases its sealed tokens. */
|
|
@@ -1015,6 +1519,51 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1015
1519
|
}
|
|
1016
1520
|
}
|
|
1017
1521
|
|
|
1522
|
+
/**
|
|
1523
|
+
* One bounded page per bearer login start, oldest first: hand-offs whose
|
|
1524
|
+
* code expired unclaimed end their session, and hand-offs and token
|
|
1525
|
+
* generations an hour past their expiry are removed — an unclaimed hand-off
|
|
1526
|
+
* only after its session was ended. A sweep that cannot run never blocks a
|
|
1527
|
+
* sign-in.
|
|
1528
|
+
*/
|
|
1529
|
+
private async sweepBearerState(): Promise<void> {
|
|
1530
|
+
const bearer = this.requireBearer();
|
|
1531
|
+
try {
|
|
1532
|
+
const now = this.#now();
|
|
1533
|
+
const unclaimed = await bearer.models.BearerHandoff.getPagedInstances({
|
|
1534
|
+
filter: { consumedAt: null, expiresAt: { $lt: now } },
|
|
1535
|
+
sortField: 'expiresAt',
|
|
1536
|
+
sortDirection: 'asc',
|
|
1537
|
+
limit: LOGIN_ATTEMPT_SWEEP_BATCH,
|
|
1538
|
+
});
|
|
1539
|
+
for (const handoff of unclaimed.documents) {
|
|
1540
|
+
await this.endUnclaimedHandoff(handoff);
|
|
1541
|
+
}
|
|
1542
|
+
const staleHandoffs = await bearer.models.BearerHandoff.getPagedInstances({
|
|
1543
|
+
filter: { expiresAt: { $lt: now - LOGIN_ATTEMPT_SWEEP_GRACE_MS } },
|
|
1544
|
+
sortField: 'expiresAt',
|
|
1545
|
+
sortDirection: 'asc',
|
|
1546
|
+
limit: LOGIN_ATTEMPT_SWEEP_BATCH,
|
|
1547
|
+
});
|
|
1548
|
+
for (const handoff of staleHandoffs.documents) {
|
|
1549
|
+
if (handoff.consumedAt !== null) {
|
|
1550
|
+
await bearer.models.BearerHandoff.atomicDelete({ id: handoff.id });
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
const staleGenerations = await bearer.models.BearerToken.getPagedInstances({
|
|
1554
|
+
filter: { expiresAt: { $lt: now - LOGIN_ATTEMPT_SWEEP_GRACE_MS } },
|
|
1555
|
+
sortField: 'expiresAt',
|
|
1556
|
+
sortDirection: 'asc',
|
|
1557
|
+
limit: LOGIN_ATTEMPT_SWEEP_BATCH,
|
|
1558
|
+
});
|
|
1559
|
+
for (const generation of staleGenerations.documents) {
|
|
1560
|
+
await bearer.models.BearerToken.atomicDelete({ id: generation.id });
|
|
1561
|
+
}
|
|
1562
|
+
} catch (error) {
|
|
1563
|
+
this.emit({ event: 'bearer_sweep_failed', message: errorMessage(error) });
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1018
1567
|
/**
|
|
1019
1568
|
* Reports one diagnostic. Every cleanup step reports through here, so a
|
|
1020
1569
|
* throwing sink must not end it: the event and the sink's failure go to
|