@idp.global/sdk 19.3.0 → 19.5.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 +66 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts_browser/classes.idpclient.js +1 -1
- package/dist_ts_browser/classes.idprequests.js +1 -1
- package/dist_ts_browser/classes.relyingpartybearerclient.d.ts +313 -0
- package/dist_ts_browser/classes.relyingpartybearerclient.js +646 -0
- package/dist_ts_browser/index.d.ts +1 -0
- package/dist_ts_browser/index.js +2 -1
- package/dist_ts_browser/plugins.js +1 -1
- package/dist_ts_browser/secret-delivery.js +1 -1
- package/dist_ts_relyingparty/classes.authorityobserver.d.ts +23 -0
- package/dist_ts_relyingparty/classes.authorityobserver.js +68 -13
- package/dist_ts_relyingparty/classes.errors.d.ts +5 -1
- package/dist_ts_relyingparty/classes.errors.js +3 -1
- package/dist_ts_relyingparty/classes.relyingpartyroutes.d.ts +12 -0
- package/dist_ts_relyingparty/classes.relyingpartyroutes.js +33 -6
- package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +161 -11
- package/dist_ts_relyingparty/classes.relyingpartysessions.js +381 -55
- package/dist_ts_relyingparty/classes.rpmodels.d.ts +6 -4
- package/dist_ts_relyingparty/classes.rpmodels.js +8 -6
- package/dist_ts_relyingparty/contract.bearer.d.ts +119 -0
- package/dist_ts_relyingparty/contract.bearer.js +13 -0
- package/dist_ts_relyingparty/helpers.bearer.d.ts +3 -4
- package/dist_ts_relyingparty/helpers.bearer.js +4 -6
- package/dist_ts_relyingparty/interfaces.d.ts +75 -26
- package/package.json +2 -1
- package/readme.md +363 -21
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts_browser/classes.relyingpartybearerclient.ts +901 -0
- package/ts_browser/index.ts +1 -0
- package/ts_relyingparty/classes.authorityobserver.ts +80 -12
- package/ts_relyingparty/classes.errors.ts +7 -1
- package/ts_relyingparty/classes.relyingpartyroutes.ts +38 -7
- package/ts_relyingparty/classes.relyingpartysessions.ts +463 -65
- package/ts_relyingparty/classes.rpmodels.ts +7 -4
- package/ts_relyingparty/contract.bearer.ts +145 -0
- package/ts_relyingparty/helpers.bearer.ts +10 -14
- package/ts_relyingparty/interfaces.ts +93 -33
|
@@ -39,6 +39,7 @@ import type {
|
|
|
39
39
|
IProviderToken,
|
|
40
40
|
IRelyingPartyBearerDelivery,
|
|
41
41
|
IRelyingPartyBearerHandoff,
|
|
42
|
+
IRelyingPartyBearerHandoffOpening,
|
|
42
43
|
IRelyingPartyBearerLoginRequest,
|
|
43
44
|
IRelyingPartyBearerLoginStart,
|
|
44
45
|
IRelyingPartyBearerOptions,
|
|
@@ -52,6 +53,9 @@ import type {
|
|
|
52
53
|
IRelyingPartyLoginStart,
|
|
53
54
|
IRelyingPartySession,
|
|
54
55
|
IRelyingPartySessionOpening,
|
|
56
|
+
IRelyingPartySessionsErasure,
|
|
57
|
+
IRelyingPartySessionsPage,
|
|
58
|
+
IRelyingPartySessionsPageOptions,
|
|
55
59
|
IResolvedRelyingPartySession,
|
|
56
60
|
TLoginCallbackStage,
|
|
57
61
|
TRelyingPartyPrincipals,
|
|
@@ -69,10 +73,16 @@ const SESSION_TOUCH_INTERVAL_MS = 60_000;
|
|
|
69
73
|
export const LOGIN_ATTEMPT_MAX_AGE_SECONDS = 10 * 60;
|
|
70
74
|
/** The longest intent a login carries (`IRelyingPartyLoginOptions.intent`), in characters. */
|
|
71
75
|
export const RELYING_PARTY_MAX_INTENT_LENGTH = 4_096;
|
|
72
|
-
/** Attempts — and bearer hand-offs
|
|
76
|
+
/** Attempts — and bearer hand-offs, token generations and sessions — are swept once they are this much older than their expiry. */
|
|
73
77
|
const LOGIN_ATTEMPT_SWEEP_GRACE_MS = 60 * 60 * 1_000;
|
|
74
78
|
/** How many of them one sign-in removes; the next sign-in takes the following page. */
|
|
75
79
|
const LOGIN_ATTEMPT_SWEEP_BATCH = 100;
|
|
80
|
+
/** How many sessions one page of `sessionsOfPrincipal` holds unless its `limit` says otherwise. */
|
|
81
|
+
export const RELYING_PARTY_DEFAULT_SESSIONS_PAGE_LIMIT = 100;
|
|
82
|
+
/** The largest `limit` of `sessionsOfPrincipal`; the store reads no larger page. */
|
|
83
|
+
export const RELYING_PARTY_MAX_SESSIONS_PAGE_LIMIT = 1_000;
|
|
84
|
+
/** A `sessionsOfPrincipal` cursor is a short base64url text; anything far longer is none. */
|
|
85
|
+
const MAX_SESSIONS_CURSOR_LENGTH = 512;
|
|
76
86
|
/** How long a bearer hand-off code can be claimed, at most; never past its session. */
|
|
77
87
|
export const BEARER_HANDOFF_MAX_AGE_SECONDS = 60;
|
|
78
88
|
/** How long a bearer access token lives unless `bearer.accessTokenMaxAgeSeconds` says otherwise. */
|
|
@@ -107,6 +117,12 @@ export interface IRelyingPartySessionsOptions<TPrincipal extends object | undefi
|
|
|
107
117
|
sessionEncryptionKey: Buffer;
|
|
108
118
|
/** 32 bytes; the CSRF HMAC key */
|
|
109
119
|
csrfKey: Buffer;
|
|
120
|
+
/**
|
|
121
|
+
* `true` (the default): cookie sessions with a session-bound CSRF token,
|
|
122
|
+
* beside bearer hand-off mode when `bearer` is given. Bearer hand-off alone,
|
|
123
|
+
* without any cookie, is `IRelyingPartyBearerOnlySessionsOptions`.
|
|
124
|
+
*/
|
|
125
|
+
cookieSessions?: true;
|
|
110
126
|
/** the label the CSRF HMAC starts with; an application with live sessions keeps its own */
|
|
111
127
|
csrfLabel?: string;
|
|
112
128
|
sessionMaxAgeSeconds?: number;
|
|
@@ -131,8 +147,36 @@ export interface IRelyingPartySessionsOptions<TPrincipal extends object | undefi
|
|
|
131
147
|
now?: () => number;
|
|
132
148
|
}
|
|
133
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Bearer-only mode (`cookieSessions: false`): the application's own clients
|
|
152
|
+
* sign in through bearer hand-off and nothing else. The relying party never
|
|
153
|
+
* reads or sets a cookie — not even the clearing intent cookie of a callback
|
|
154
|
+
* that is no bearer login — so it has no CSRF token and takes no CSRF key:
|
|
155
|
+
* `csrfKey`, `csrfLabel` and `cookieNames` are refused, not ignored. The
|
|
156
|
+
* cookie steps (`openSession`, `startLogin`, `completeLogin`, `abandonLogin`,
|
|
157
|
+
* `finalizeLogin`, `clearSessionCookie`, `clearIntentCookie`) throw
|
|
158
|
+
* `RelyingPartyError('login_method_unavailable')` and `resolveCookieSession`
|
|
159
|
+
* answers `null`.
|
|
160
|
+
*/
|
|
161
|
+
export interface IRelyingPartyBearerOnlySessionsOptions<TPrincipal extends object | undefined = undefined>
|
|
162
|
+
extends Omit<IRelyingPartySessionsOptions<TPrincipal>, 'provider' | 'csrfKey' | 'csrfLabel' | 'cookieNames' | 'cookieSessions' | 'bearer'> {
|
|
163
|
+
cookieSessions: false;
|
|
164
|
+
/** every bearer session comes from a live provider login, directly or through a hand-off opened from one */
|
|
165
|
+
provider: IOidcProvider;
|
|
166
|
+
bearer: IRelyingPartyBearerOptions;
|
|
167
|
+
csrfKey?: never;
|
|
168
|
+
csrfLabel?: never;
|
|
169
|
+
cookieNames?: never;
|
|
170
|
+
}
|
|
171
|
+
|
|
134
172
|
type TSealedTokens = Pick<RelyingPartySessionDoc, 'sealedAccessToken' | 'sealedRefreshToken'>;
|
|
135
173
|
|
|
174
|
+
/** Cookie sessions as the constructor checked them; absent in bearer-only mode. */
|
|
175
|
+
interface ICookieSessionsMode {
|
|
176
|
+
csrfKey: Buffer;
|
|
177
|
+
csrfLabel: string;
|
|
178
|
+
}
|
|
179
|
+
|
|
136
180
|
/** Bearer hand-off mode as the constructor checked it. */
|
|
137
181
|
interface IBearerMode {
|
|
138
182
|
returnTargets: Readonly<Required<IRelyingPartyBearerReturnTargets>>;
|
|
@@ -148,7 +192,7 @@ const bearerModeOf = (
|
|
|
148
192
|
throw new Error('bearer must be an object with returnTargets');
|
|
149
193
|
}
|
|
150
194
|
if (!optionsArg.hasProvider) {
|
|
151
|
-
throw new Error('bearer needs a provider: every bearer session
|
|
195
|
+
throw new Error('bearer needs a provider: every bearer session comes from a provider login');
|
|
152
196
|
}
|
|
153
197
|
const { BearerHandoff, BearerToken } = optionsArg.models;
|
|
154
198
|
if (!BearerHandoff || !BearerToken) {
|
|
@@ -180,6 +224,43 @@ const singleStateOf = (callbackUrlArg: URL): string | null => {
|
|
|
180
224
|
const bearerCredentialOf = (valueArg: unknown): string | null =>
|
|
181
225
|
typeof valueArg === 'string' && valueArg.length > 0 && valueArg.length <= MAX_BEARER_TOKEN_LENGTH ? valueArg : null;
|
|
182
226
|
|
|
227
|
+
/** Where a `sessionsOfPrincipal` page ends: its last session's `createdAt` and `id`. */
|
|
228
|
+
interface ISessionsCursor {
|
|
229
|
+
sortValue: number;
|
|
230
|
+
uniqueValue: string;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const serializeSessionsCursor = (cursorArg: { sortValue: number | string; uniqueValue: string }): string =>
|
|
234
|
+
Buffer.from(JSON.stringify([cursorArg.sortValue, cursorArg.uniqueValue]), 'utf8').toString('base64url');
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The position a cursor of `sessionsOfPrincipal` names. It seeks within the
|
|
238
|
+
* principal's own sessions only, so it needs no seal; anything that is not
|
|
239
|
+
* such a position is refused.
|
|
240
|
+
*/
|
|
241
|
+
const parseSessionsCursor = (cursorArg: unknown): ISessionsCursor => {
|
|
242
|
+
if (typeof cursorArg !== 'string' || cursorArg.length > MAX_SESSIONS_CURSOR_LENGTH || !/^[A-Za-z0-9_-]+$/.test(cursorArg)) {
|
|
243
|
+
throw new RelyingPartyError('invalid_sessions_cursor');
|
|
244
|
+
}
|
|
245
|
+
let position: unknown;
|
|
246
|
+
try {
|
|
247
|
+
position = JSON.parse(Buffer.from(cursorArg, 'base64url').toString('utf8'));
|
|
248
|
+
} catch {
|
|
249
|
+
throw new RelyingPartyError('invalid_sessions_cursor');
|
|
250
|
+
}
|
|
251
|
+
if (
|
|
252
|
+
!Array.isArray(position)
|
|
253
|
+
|| position.length !== 2
|
|
254
|
+
|| !Number.isSafeInteger(position[0])
|
|
255
|
+
|| position[0] < 0
|
|
256
|
+
|| typeof position[1] !== 'string'
|
|
257
|
+
|| position[1] === ''
|
|
258
|
+
) {
|
|
259
|
+
throw new RelyingPartyError('invalid_sessions_cursor');
|
|
260
|
+
}
|
|
261
|
+
return { sortValue: position[0], uniqueValue: position[1] };
|
|
262
|
+
};
|
|
263
|
+
|
|
183
264
|
const REVOKED_TOKEN_FIELDS = { sealedAccessToken: '', sealedRefreshToken: '', refreshLockUntil: 0 };
|
|
184
265
|
|
|
185
266
|
const errorMessage = (errorArg: unknown): string => (errorArg instanceof Error ? errorArg.message : String(errorArg));
|
|
@@ -211,10 +292,17 @@ const cookieNameFor = (
|
|
|
211
292
|
* token, provider tokens sealed at rest and rotated under a cross-replica
|
|
212
293
|
* lock, and session ends that hand the tokens back.
|
|
213
294
|
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
295
|
+
* With `cookieSessions: false` it runs bearer hand-off mode alone and never
|
|
296
|
+
* touches a cookie (`IRelyingPartyBearerOnlySessionsOptions`).
|
|
297
|
+
*
|
|
298
|
+
* Nothing here runs on a timer: expired login attempts, bearer state and
|
|
299
|
+
* ended sessions are swept one bounded batch at a time when a login starts or
|
|
300
|
+
* the application opens a session, and the only timer — the revocation
|
|
301
|
+
* budget — is unreferenced and cleared on every path. Every session comes
|
|
302
|
+
* from a login start, `openSession` or `openBearerHandoff`, and each of those
|
|
303
|
+
* sweeps a batch of at least one first, so removal keeps pace with creation
|
|
304
|
+
* and the ended sessions kept stay bounded. Nothing here does I/O while it is
|
|
305
|
+
* constructed, so it is built before the database connects.
|
|
218
306
|
*
|
|
219
307
|
* `TPrincipal` is what a `principals.resolve` answers; with `isActive`
|
|
220
308
|
* principals it stays `undefined`.
|
|
@@ -233,20 +321,34 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
233
321
|
readonly #provider?: IOidcProvider;
|
|
234
322
|
readonly #bearer?: IBearerMode;
|
|
235
323
|
readonly #secretBox: SecretBox;
|
|
236
|
-
readonly #
|
|
237
|
-
readonly #csrfLabel: string;
|
|
324
|
+
readonly #cookieSessions: ICookieSessionsMode | null;
|
|
238
325
|
readonly #hooks: IRelyingPartyHooks;
|
|
239
326
|
readonly #now: () => number;
|
|
240
327
|
/** one rotation per session in this process, however many requests need it at once */
|
|
241
328
|
readonly #refreshesInFlight = new Map<string, Promise<IProviderToken>>();
|
|
242
329
|
|
|
243
|
-
constructor(optionsArg: IRelyingPartySessionsOptions<TPrincipal>) {
|
|
330
|
+
constructor(optionsArg: IRelyingPartySessionsOptions<TPrincipal> | IRelyingPartyBearerOnlySessionsOptions<TPrincipal>) {
|
|
244
331
|
const origin = new URL(optionsArg.publicOrigin);
|
|
245
332
|
if (origin.origin !== optionsArg.publicOrigin) {
|
|
246
333
|
throw new Error('publicOrigin must be a bare origin such as https://app.example');
|
|
247
334
|
}
|
|
248
|
-
|
|
249
|
-
|
|
335
|
+
const cookieSessions: unknown = optionsArg.cookieSessions ?? true;
|
|
336
|
+
if (cookieSessions !== true && cookieSessions !== false) {
|
|
337
|
+
throw new Error('cookieSessions must be true or false');
|
|
338
|
+
}
|
|
339
|
+
if (cookieSessions) {
|
|
340
|
+
if (!Buffer.isBuffer(optionsArg.csrfKey) || optionsArg.csrfKey.length !== 32) {
|
|
341
|
+
throw new Error('csrfKey must be 32 bytes');
|
|
342
|
+
}
|
|
343
|
+
} else {
|
|
344
|
+
for (const name of ['csrfKey', 'csrfLabel', 'cookieNames'] as const) {
|
|
345
|
+
if (optionsArg[name] !== undefined) {
|
|
346
|
+
throw new Error(`${name} has no use with cookieSessions: false, which reads and sets no cookie`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (optionsArg.bearer === undefined) {
|
|
350
|
+
throw new Error('cookieSessions: false needs bearer: without cookies, bearer hand-off is the only way in');
|
|
351
|
+
}
|
|
250
352
|
}
|
|
251
353
|
const maxAge = optionsArg.sessionMaxAgeSeconds ?? RELYING_PARTY_DEFAULT_SESSION_MAX_AGE_SECONDS;
|
|
252
354
|
if (!Number.isSafeInteger(maxAge) || maxAge < 1) {
|
|
@@ -312,8 +414,9 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
312
414
|
});
|
|
313
415
|
}
|
|
314
416
|
this.#secretBox = new SecretBox(optionsArg.sessionEncryptionKey);
|
|
315
|
-
this.#
|
|
316
|
-
|
|
417
|
+
this.#cookieSessions = cookieSessions
|
|
418
|
+
? { csrfKey: Buffer.from(optionsArg.csrfKey), csrfLabel: optionsArg.csrfLabel ?? RELYING_PARTY_DEFAULT_CSRF_LABEL }
|
|
419
|
+
: null;
|
|
317
420
|
this.#hooks = optionsArg.hooks ?? {};
|
|
318
421
|
this.#now = optionsArg.now ?? (() => Date.now());
|
|
319
422
|
}
|
|
@@ -323,6 +426,11 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
323
426
|
return this.#provider !== undefined;
|
|
324
427
|
}
|
|
325
428
|
|
|
429
|
+
/** Whether cookie sessions are offered; `false` only in bearer-only mode (`cookieSessions: false`). */
|
|
430
|
+
public get cookieSessionsEnabled(): boolean {
|
|
431
|
+
return this.#cookieSessions !== null;
|
|
432
|
+
}
|
|
433
|
+
|
|
326
434
|
/** Whether bearer hand-off mode is offered. */
|
|
327
435
|
public get bearerLoginEnabled(): boolean {
|
|
328
436
|
return this.#bearer !== undefined;
|
|
@@ -354,6 +462,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
354
462
|
* cookie with it; it is a whole number of seconds from 1 to that maximum.
|
|
355
463
|
*/
|
|
356
464
|
public async openSession(optionsArg: { principalId: string; maxAgeSeconds?: number }): Promise<IRelyingPartySessionOpening> {
|
|
465
|
+
this.requireCookieSessions();
|
|
357
466
|
if (typeof optionsArg?.principalId !== 'string' || optionsArg.principalId.length === 0) {
|
|
358
467
|
throw new Error('openSession needs a principal id');
|
|
359
468
|
}
|
|
@@ -361,16 +470,24 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
361
470
|
if (!Number.isSafeInteger(maxAgeSeconds) || maxAgeSeconds < 1 || maxAgeSeconds > this.sessionMaxAgeSeconds) {
|
|
362
471
|
throw new RangeError(`openSession maxAgeSeconds must be a whole number from 1 to ${this.sessionMaxAgeSeconds}`);
|
|
363
472
|
}
|
|
473
|
+
await this.sweepEndedSessions();
|
|
364
474
|
const token = randomToken(32);
|
|
365
475
|
const session = this.newSessionDoc(token, optionsArg.principalId, maxAgeSeconds);
|
|
366
476
|
await session.save();
|
|
367
477
|
return this.opening(session, token, maxAgeSeconds);
|
|
368
478
|
}
|
|
369
479
|
|
|
370
|
-
/**
|
|
480
|
+
/**
|
|
481
|
+
* The live session behind the session cookie, or `null`. A read touches it
|
|
482
|
+
* at most once a minute. Always `null` in bearer-only mode, which reads no
|
|
483
|
+
* cookie.
|
|
484
|
+
*/
|
|
371
485
|
public async resolveCookieSession(
|
|
372
486
|
cookieHeaderArg: string | null | undefined,
|
|
373
487
|
): Promise<IResolvedRelyingPartySession<TPrincipal> | null> {
|
|
488
|
+
if (!this.#cookieSessions) {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
374
491
|
const token = parseCookies(cookieHeaderArg).get(this.sessionCookieName);
|
|
375
492
|
if (!token || token.length > MAX_COOKIE_TOKEN_LENGTH) {
|
|
376
493
|
return null;
|
|
@@ -465,7 +582,94 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
465
582
|
return ended;
|
|
466
583
|
}
|
|
467
584
|
|
|
585
|
+
/**
|
|
586
|
+
* Erases every session of a principal, live or ended, and every row that
|
|
587
|
+
* belongs to one, without waiting for a sweep: what an application runs
|
|
588
|
+
* when it deletes an account, after its own erasers and before its user
|
|
589
|
+
* record.
|
|
590
|
+
*
|
|
591
|
+
* First each session not revoked yet, an expired one too, ends as
|
|
592
|
+
* `endSession` ends it: its tokens stop working at once, `onSessionEnded`
|
|
593
|
+
* names the principal, and its provider tokens go back to idp.global,
|
|
594
|
+
* which a deleted row could no longer do. Then each session's rows go: in
|
|
595
|
+
* bearer mode its hand-offs, claimed or not, and its token generations;
|
|
596
|
+
* the login attempt a provider session came out of; and the session row
|
|
597
|
+
* last, because it is the only link from the principal to the others, so
|
|
598
|
+
* an erase that fails half way leaves a retry everything it needs. A
|
|
599
|
+
* session opened or a token issued while the erase runs is not covered;
|
|
600
|
+
* it works only while the principal is active.
|
|
601
|
+
*/
|
|
602
|
+
public async eraseSessionsOfPrincipal(principalIdArg: string): Promise<IRelyingPartySessionsErasure> {
|
|
603
|
+
if (typeof principalIdArg !== 'string' || principalIdArg === '') {
|
|
604
|
+
throw new Error('eraseSessionsOfPrincipal needs a principal id');
|
|
605
|
+
}
|
|
606
|
+
const erasure: IRelyingPartySessionsErasure = {
|
|
607
|
+
endedSessions: 0,
|
|
608
|
+
deletedSessions: 0,
|
|
609
|
+
deletedLoginAttempts: 0,
|
|
610
|
+
deletedBearerHandoffs: 0,
|
|
611
|
+
deletedBearerTokens: 0,
|
|
612
|
+
};
|
|
613
|
+
const sessions = await this.#models.Session.getInstances({ userId: principalIdArg });
|
|
614
|
+
for (const session of sessions) {
|
|
615
|
+
if (session.revokedAt === undefined && (await this.endSession(session.id))) {
|
|
616
|
+
erasure.endedSessions += 1;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
const bearerModels = this.#bearer?.models;
|
|
620
|
+
for (const session of sessions) {
|
|
621
|
+
if (bearerModels) {
|
|
622
|
+
erasure.deletedBearerHandoffs += (await bearerModels.BearerHandoff.atomicDeleteMany({ sessionId: session.id })).deletedCount;
|
|
623
|
+
erasure.deletedBearerTokens += (await bearerModels.BearerToken.atomicDeleteMany({ sessionId: session.id })).deletedCount;
|
|
624
|
+
}
|
|
625
|
+
if (session.loginAttemptId) {
|
|
626
|
+
erasure.deletedLoginAttempts += (await this.#models.LoginAttempt.atomicDelete({ id: session.loginAttemptId })).deletedCount;
|
|
627
|
+
}
|
|
628
|
+
erasure.deletedSessions += (await this.#models.Session.atomicDelete({ id: session.id })).deletedCount;
|
|
629
|
+
}
|
|
630
|
+
return erasure;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* One page of a principal's live sessions, newest first: the ones neither
|
|
635
|
+
* ended nor expired, and none at all while the principal is not active —
|
|
636
|
+
* exactly the sessions `isSessionLive` confirms. Pass the page's
|
|
637
|
+
* `nextCursor` as `cursor` to read the next one; the last page has none.
|
|
638
|
+
* The cursor is opaque, and one this method did not answer is refused with
|
|
639
|
+
* `invalid_sessions_cursor`. A session opened while the pages are read is
|
|
640
|
+
* listed only when it sorts after the cursor; none is listed twice.
|
|
641
|
+
*/
|
|
642
|
+
public async sessionsOfPrincipal(
|
|
643
|
+
principalIdArg: string,
|
|
644
|
+
optionsArg: IRelyingPartySessionsPageOptions = {},
|
|
645
|
+
): Promise<IRelyingPartySessionsPage> {
|
|
646
|
+
if (typeof principalIdArg !== 'string' || principalIdArg === '') {
|
|
647
|
+
throw new Error('sessionsOfPrincipal needs a principal id');
|
|
648
|
+
}
|
|
649
|
+
const limit = optionsArg.limit ?? RELYING_PARTY_DEFAULT_SESSIONS_PAGE_LIMIT;
|
|
650
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > RELYING_PARTY_MAX_SESSIONS_PAGE_LIMIT) {
|
|
651
|
+
throw new Error(`limit must be an integer from 1 to ${RELYING_PARTY_MAX_SESSIONS_PAGE_LIMIT}`);
|
|
652
|
+
}
|
|
653
|
+
const cursor = optionsArg.cursor === undefined ? undefined : parseSessionsCursor(optionsArg.cursor);
|
|
654
|
+
if (!(await this.#activePrincipal(principalIdArg))) {
|
|
655
|
+
return { sessions: [] };
|
|
656
|
+
}
|
|
657
|
+
const page = await this.#models.Session.getPagedInstances({
|
|
658
|
+
filter: { userId: principalIdArg, revokedAt: { $exists: false }, expiresAt: { $gt: this.#now() } },
|
|
659
|
+
sortField: 'createdAt',
|
|
660
|
+
sortDirection: 'desc',
|
|
661
|
+
uniqueField: 'id',
|
|
662
|
+
limit,
|
|
663
|
+
...(cursor ? { cursor } : {}),
|
|
664
|
+
});
|
|
665
|
+
return {
|
|
666
|
+
sessions: page.documents.map((documentArg) => this.snapshot(documentArg)),
|
|
667
|
+
...(page.nextCursor ? { nextCursor: serializeSessionsCursor(page.nextCursor) } : {}),
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
|
|
468
671
|
public clearSessionCookie(): string {
|
|
672
|
+
this.requireCookieSessions();
|
|
469
673
|
return serializeClearedCookie(this.sessionCookieName, {
|
|
470
674
|
httpOnly: true,
|
|
471
675
|
secure: this.secureCookies,
|
|
@@ -474,6 +678,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
474
678
|
}
|
|
475
679
|
|
|
476
680
|
public clearIntentCookie(): string {
|
|
681
|
+
this.requireCookieSessions();
|
|
477
682
|
return serializeClearedCookie(this.intentCookieName, {
|
|
478
683
|
httpOnly: true,
|
|
479
684
|
secure: this.secureCookies,
|
|
@@ -491,9 +696,11 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
491
696
|
* browser.
|
|
492
697
|
*/
|
|
493
698
|
public async startLogin(returnPathArg: unknown, optionsArg: IRelyingPartyLoginOptions = {}): Promise<IRelyingPartyLoginStart> {
|
|
699
|
+
this.requireCookieSessions();
|
|
494
700
|
const provider = this.requireProvider();
|
|
495
701
|
this.checkLoginOptions(optionsArg);
|
|
496
702
|
await this.sweepExpiredAttempts();
|
|
703
|
+
await this.sweepEndedSessions();
|
|
497
704
|
const bindingToken = randomToken(32);
|
|
498
705
|
const { attempt, state, nonce, pkceVerifier } = this.newLoginAttempt(optionsArg.intent);
|
|
499
706
|
attempt.bindingTokenHash = sha256(bindingToken);
|
|
@@ -529,6 +736,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
529
736
|
callbackUrlArg: URL,
|
|
530
737
|
cookieHeaderArg: string | null | undefined,
|
|
531
738
|
): Promise<IRelyingPartySessionOpening> {
|
|
739
|
+
this.requireCookieSessions();
|
|
532
740
|
const provider = this.requireProvider();
|
|
533
741
|
const states = callbackUrlArg.searchParams.getAll('state');
|
|
534
742
|
if (states.length !== 1 || !states[0]) {
|
|
@@ -564,6 +772,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
564
772
|
* matches no open attempt leaves nothing to do.
|
|
565
773
|
*/
|
|
566
774
|
public async abandonLogin(callbackUrlArg: URL, cookieHeaderArg: string | null | undefined): Promise<void> {
|
|
775
|
+
this.requireCookieSessions();
|
|
567
776
|
const states = callbackUrlArg.searchParams.getAll('state');
|
|
568
777
|
const bindingToken = parseCookies(cookieHeaderArg).get(this.intentCookieName);
|
|
569
778
|
if (
|
|
@@ -586,6 +795,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
586
795
|
* session this very attempt created.
|
|
587
796
|
*/
|
|
588
797
|
public async finalizeLogin(cookieHeaderArg: string | null | undefined): Promise<string> {
|
|
798
|
+
this.requireCookieSessions();
|
|
589
799
|
this.requireProvider();
|
|
590
800
|
const bindingToken = parseCookies(cookieHeaderArg).get(this.intentCookieName);
|
|
591
801
|
if (!bindingToken || bindingToken.length > MAX_COOKIE_TOKEN_LENGTH) {
|
|
@@ -648,6 +858,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
648
858
|
this.checkLoginOptions(optionsArg);
|
|
649
859
|
await this.sweepExpiredAttempts();
|
|
650
860
|
await this.sweepBearerState();
|
|
861
|
+
await this.sweepEndedSessions();
|
|
651
862
|
const { attempt, state, nonce, pkceVerifier } = this.newLoginAttempt(optionsArg.intent);
|
|
652
863
|
// a binding token that never leaves: no cookie callback can consume this attempt
|
|
653
864
|
attempt.bindingTokenHash = sha256(randomToken(32));
|
|
@@ -715,28 +926,85 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
715
926
|
if (!isPkceChallenge(codeChallenge)) {
|
|
716
927
|
throw new RelyingPartyError('invalid_or_replayed_login');
|
|
717
928
|
}
|
|
718
|
-
|
|
719
|
-
let handoff: RelyingPartyBearerHandoffDoc | undefined;
|
|
929
|
+
let handoff: { code: string; expiresAt: number } | undefined;
|
|
720
930
|
const opened = await this.openProviderLogin(provider, callbackUrlArg, state, attempt, async (sessionArg) => {
|
|
721
|
-
|
|
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;
|
|
931
|
+
handoff = await this.storeBearerHandoff(bearer, sessionArg, codeChallenge);
|
|
731
932
|
});
|
|
933
|
+
const stored = handoff as { code: string; expiresAt: number };
|
|
732
934
|
return {
|
|
733
935
|
session: this.snapshot(opened.document),
|
|
734
936
|
delivery,
|
|
735
|
-
code,
|
|
736
|
-
expiresAt:
|
|
937
|
+
code: stored.code,
|
|
938
|
+
expiresAt: stored.expiresAt,
|
|
737
939
|
};
|
|
738
940
|
}
|
|
739
941
|
|
|
942
|
+
/**
|
|
943
|
+
* Opens a bearer session for a device the application pairs to a person
|
|
944
|
+
* who is signed in here, and answers its single-use hand-off code, which
|
|
945
|
+
* the device claims at `bearer/claim` with the verifier of `codeChallenge`,
|
|
946
|
+
* exactly like the code of a bearer login.
|
|
947
|
+
*
|
|
948
|
+
* `openerSessionId` names the session that confirmed the pairing, taken
|
|
949
|
+
* from the application's own record, never from the device. It must be a
|
|
950
|
+
* live provider login: not ended, not expired, its principal active, and
|
|
951
|
+
* with a provider login of its own. A session the application opened
|
|
952
|
+
* itself or one opened here does not qualify, so a paired device never
|
|
953
|
+
* pairs another one. Anything else throws
|
|
954
|
+
* `RelyingPartyError('invalid_handoff_opener')`.
|
|
955
|
+
*
|
|
956
|
+
* The new session acts as the opener's principal and has no provider login
|
|
957
|
+
* of its own: it ends by its expiry, `endSession`, `endSessionsOfPrincipal`,
|
|
958
|
+
* `bearer/logout` or the reuse of one of its credentials, never by an
|
|
959
|
+
* idp.global logout, and `AuthorityObserver` refuses it
|
|
960
|
+
* (`authority_login_required`). `maxAgeSeconds` is a whole number from 1 to
|
|
961
|
+
* `sessionMaxAgeSeconds`, the default, as for `openSession`. The code lives
|
|
962
|
+
* `BEARER_HANDOFF_MAX_AGE_SECONDS` at most and never past the session, and
|
|
963
|
+
* only its hash is stored; a code nobody claims ends its session. Nothing
|
|
964
|
+
* is stored for a refused opening.
|
|
965
|
+
*/
|
|
966
|
+
public async openBearerHandoff(optionsArg: {
|
|
967
|
+
openerSessionId: string;
|
|
968
|
+
codeChallenge: string;
|
|
969
|
+
maxAgeSeconds?: number;
|
|
970
|
+
}): Promise<IRelyingPartyBearerHandoffOpening> {
|
|
971
|
+
const bearer = this.requireBearer();
|
|
972
|
+
if (!isPkceChallenge(optionsArg?.codeChallenge)) {
|
|
973
|
+
throw new RelyingPartyError('invalid_bearer_request');
|
|
974
|
+
}
|
|
975
|
+
const codeChallenge = optionsArg.codeChallenge;
|
|
976
|
+
const maxAgeSeconds = optionsArg.maxAgeSeconds ?? this.sessionMaxAgeSeconds;
|
|
977
|
+
if (!Number.isSafeInteger(maxAgeSeconds) || maxAgeSeconds < 1 || maxAgeSeconds > this.sessionMaxAgeSeconds) {
|
|
978
|
+
throw new RangeError(`openBearerHandoff maxAgeSeconds must be a whole number from 1 to ${this.sessionMaxAgeSeconds}`);
|
|
979
|
+
}
|
|
980
|
+
await this.sweepBearerState();
|
|
981
|
+
await this.sweepEndedSessions();
|
|
982
|
+
// read after the sweeps, so the opener is as live as it can be when the session is created
|
|
983
|
+
const openerSessionId = optionsArg.openerSessionId;
|
|
984
|
+
const opener = typeof openerSessionId === 'string' && openerSessionId.length > 0
|
|
985
|
+
? await this.liveSession(await this.#models.Session.getInstance({ id: openerSessionId }))
|
|
986
|
+
: null;
|
|
987
|
+
if (!opener || typeof opener.document.idpSubject !== 'string' || opener.document.idpSubject.length === 0) {
|
|
988
|
+
throw new RelyingPartyError('invalid_handoff_opener');
|
|
989
|
+
}
|
|
990
|
+
// the token is never handed out: the session is reached by its code only, as a bearer login's
|
|
991
|
+
const session = this.newSessionDoc(randomToken(32), opener.document.userId, maxAgeSeconds);
|
|
992
|
+
await session.save();
|
|
993
|
+
let handoff: { code: string; expiresAt: number };
|
|
994
|
+
try {
|
|
995
|
+
handoff = await this.storeBearerHandoff(bearer, session, codeChallenge);
|
|
996
|
+
} catch (error) {
|
|
997
|
+
// the code never leaves, so the session is unreachable: it is revoked before the error goes on
|
|
998
|
+
try {
|
|
999
|
+
await this.discardUnopenedSession(session.id);
|
|
1000
|
+
} catch (discardError) {
|
|
1001
|
+
this.emit({ event: 'session_discard_failed', sessionId: session.id, message: errorMessage(discardError) });
|
|
1002
|
+
}
|
|
1003
|
+
throw error;
|
|
1004
|
+
}
|
|
1005
|
+
return { session: this.snapshot(session), code: handoff.code, expiresAt: handoff.expiresAt };
|
|
1006
|
+
}
|
|
1007
|
+
|
|
740
1008
|
/** `abandonLogin` for a bearer login: the provider came back without a code. */
|
|
741
1009
|
public async abandonBearerLogin(callbackUrlArg: URL): Promise<void> {
|
|
742
1010
|
const state = singleStateOf(callbackUrlArg);
|
|
@@ -797,9 +1065,11 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
797
1065
|
* access and refresh token answer, and the old access token stops working.
|
|
798
1066
|
* Rotation is strict — a client serializes its refreshes — because a
|
|
799
1067
|
* refresh token that comes back after it was spent ends the whole session.
|
|
800
|
-
* The provider login is checked on the way: a login
|
|
801
|
-
* the session here (`invalid_bearer_grant`), an
|
|
802
|
-
* with `AuthorityTokenError('authority_unavailable')`
|
|
1068
|
+
* The provider login of a bearer login is checked on the way: a login
|
|
1069
|
+
* idp.global ended ends the session here (`invalid_bearer_grant`), an
|
|
1070
|
+
* unreachable provider fails with `AuthorityTokenError('authority_unavailable')`
|
|
1071
|
+
* and spends nothing. A session `openBearerHandoff` opened has no provider
|
|
1072
|
+
* login (no `idpSubject` is stored) and rotates without the provider.
|
|
803
1073
|
*/
|
|
804
1074
|
public async refreshBearerTokens(refreshTokenArg: unknown): Promise<IRelyingPartyBearerTokens> {
|
|
805
1075
|
const bearer = this.requireBearer();
|
|
@@ -823,13 +1093,17 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
823
1093
|
if (!live) {
|
|
824
1094
|
throw new RelyingPartyError('invalid_bearer_grant');
|
|
825
1095
|
}
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
1096
|
+
// decided by what the session stores alone, never by a failure: a session
|
|
1097
|
+
// with a provider subject is a provider login and always asks the provider
|
|
1098
|
+
if (typeof live.document.idpSubject === 'string') {
|
|
1099
|
+
try {
|
|
1100
|
+
await this.liveProviderToken(live.document.id, live.document.userId);
|
|
1101
|
+
} catch (error) {
|
|
1102
|
+
if (error instanceof AuthorityTokenError && error.code === 'authority_login_required') {
|
|
1103
|
+
throw new RelyingPartyError('invalid_bearer_grant');
|
|
1104
|
+
}
|
|
1105
|
+
throw error;
|
|
831
1106
|
}
|
|
832
|
-
throw error;
|
|
833
1107
|
}
|
|
834
1108
|
// the next generation first: a rotation that fails half way leaves an
|
|
835
1109
|
// unknown generation behind, never a session without a current one
|
|
@@ -910,8 +1184,22 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
910
1184
|
* is unknown — is rotated first. The session has to belong to
|
|
911
1185
|
* `expectedPrincipalIdArg`, so a credential is never evaluated with another
|
|
912
1186
|
* person's tokens.
|
|
1187
|
+
*
|
|
1188
|
+
* With `rejectedAccessToken` (`IAuthorityTokenSource`) the call rotates the
|
|
1189
|
+
* session's token whatever is stored, and only a rotation that it starts
|
|
1190
|
+
* marks its answer `rotatedAfterRejection`: one that started before it, in
|
|
1191
|
+
* this process or another replica's, says nothing about the rejection, so
|
|
1192
|
+
* the call awaits it and throws `authority_unavailable`. A refused rotation
|
|
1193
|
+
* ends the session (`refresh_token_refused`) and throws
|
|
1194
|
+
* `authority_login_required`. A session without a refresh token has nothing
|
|
1195
|
+
* to rotate with: it throws `authority_unavailable`, or ends as
|
|
1196
|
+
* `access_token_unusable` when its token is dead.
|
|
913
1197
|
*/
|
|
914
|
-
public async liveProviderToken(
|
|
1198
|
+
public async liveProviderToken(
|
|
1199
|
+
sessionIdArg: string,
|
|
1200
|
+
expectedPrincipalIdArg: string,
|
|
1201
|
+
optionsArg: { rejectedAccessToken?: string } = {},
|
|
1202
|
+
): Promise<IProviderToken> {
|
|
915
1203
|
const session = await this.#models.Session.getInstance({ id: sessionIdArg });
|
|
916
1204
|
const now = this.#now();
|
|
917
1205
|
if (
|
|
@@ -926,35 +1214,60 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
926
1214
|
const subject = session.idpSubject;
|
|
927
1215
|
const expiresAt = typeof session.accessTokenExpiresAt === 'number' ? session.accessTokenExpiresAt : 0;
|
|
928
1216
|
const accessToken = this.openSealed(session.sealedAccessToken);
|
|
1217
|
+
const forced = optionsArg.rejectedAccessToken !== undefined;
|
|
929
1218
|
// an unknown expiry is rotated too: afterwards the lifetime is known
|
|
930
|
-
const due =
|
|
1219
|
+
const due = forced
|
|
1220
|
+
|| accessToken === undefined
|
|
1221
|
+
|| expiresAt === 0
|
|
1222
|
+
|| expiresAt <= now + ACCESS_TOKEN_REFRESH_SKEW_MS;
|
|
931
1223
|
if (!due && accessToken !== undefined) {
|
|
932
1224
|
return { accessToken, subject, expiresAt };
|
|
933
1225
|
}
|
|
934
1226
|
if (!session.sealedRefreshToken) {
|
|
935
1227
|
if (accessToken !== undefined && (expiresAt === 0 || expiresAt > now)) {
|
|
936
1228
|
// nothing to rotate with, and the token is not known to be dead
|
|
1229
|
+
if (forced) {
|
|
1230
|
+
throw new AuthorityTokenError('authority_unavailable');
|
|
1231
|
+
}
|
|
937
1232
|
return { accessToken, subject, expiresAt };
|
|
938
1233
|
}
|
|
939
1234
|
await this.endProviderSession(sessionIdArg, 'access_token_unusable');
|
|
940
1235
|
throw new AuthorityTokenError('authority_login_required');
|
|
941
1236
|
}
|
|
942
|
-
return this.refreshProviderToken(sessionIdArg, subject);
|
|
1237
|
+
return this.refreshProviderToken(sessionIdArg, subject, forced);
|
|
943
1238
|
}
|
|
944
1239
|
|
|
945
|
-
|
|
1240
|
+
/**
|
|
1241
|
+
* One rotation of the session in this process at a time. A forced call that
|
|
1242
|
+
* finds one running waits for it, so a refusal still ends it, and is
|
|
1243
|
+
* unavailable otherwise: that rotation started before the call.
|
|
1244
|
+
*/
|
|
1245
|
+
private async refreshProviderToken(
|
|
1246
|
+
sessionIdArg: string,
|
|
1247
|
+
subjectArg: string,
|
|
1248
|
+
forcedArg: boolean,
|
|
1249
|
+
): Promise<IProviderToken> {
|
|
946
1250
|
const running = this.#refreshesInFlight.get(sessionIdArg);
|
|
947
1251
|
if (running) {
|
|
948
|
-
|
|
1252
|
+
const joined = await running;
|
|
1253
|
+
if (forcedArg) {
|
|
1254
|
+
throw new AuthorityTokenError('authority_unavailable');
|
|
1255
|
+
}
|
|
1256
|
+
return joined;
|
|
949
1257
|
}
|
|
950
|
-
const pending = this.runProviderRefresh(sessionIdArg, subjectArg).finally(() => {
|
|
1258
|
+
const pending = this.runProviderRefresh(sessionIdArg, subjectArg, forcedArg).finally(() => {
|
|
951
1259
|
this.#refreshesInFlight.delete(sessionIdArg);
|
|
952
1260
|
});
|
|
953
1261
|
this.#refreshesInFlight.set(sessionIdArg, pending);
|
|
954
|
-
|
|
1262
|
+
// only the call that started the rotation is told so; others that join it are not
|
|
1263
|
+
return forcedArg ? { ...(await pending), rotatedAfterRejection: true } : pending;
|
|
955
1264
|
}
|
|
956
1265
|
|
|
957
|
-
private async runProviderRefresh(
|
|
1266
|
+
private async runProviderRefresh(
|
|
1267
|
+
sessionIdArg: string,
|
|
1268
|
+
subjectArg: string,
|
|
1269
|
+
forcedArg: boolean,
|
|
1270
|
+
): Promise<IProviderToken> {
|
|
958
1271
|
const provider = this.#provider;
|
|
959
1272
|
if (!provider) {
|
|
960
1273
|
throw new AuthorityTokenError('authority_unavailable');
|
|
@@ -973,15 +1286,21 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
973
1286
|
{ returnDocument: 'after' },
|
|
974
1287
|
);
|
|
975
1288
|
if (claimed.status !== 'matched' || !claimed.document) {
|
|
1289
|
+
if (forcedArg) {
|
|
1290
|
+
// nothing tells when the other replica's rotation started
|
|
1291
|
+
throw new AuthorityTokenError('authority_unavailable');
|
|
1292
|
+
}
|
|
976
1293
|
return this.tokenAfterForeignRefresh(sessionIdArg, subjectArg);
|
|
977
1294
|
}
|
|
978
1295
|
const session = claimed.document as RelyingPartySessionDoc;
|
|
979
1296
|
// the lock holder decides on the state it just read: a rotation that
|
|
980
1297
|
// landed in between is used as it stands, because spending a superseded
|
|
981
|
-
// refresh token would be reuse and would kill the family
|
|
1298
|
+
// refresh token would be reuse and would kill the family; a forced call
|
|
1299
|
+
// rotates the stored pair however long its token would last, since only a
|
|
1300
|
+
// rotation it starts comes after the rejection
|
|
982
1301
|
const claimedExpiry = typeof session.accessTokenExpiresAt === 'number' ? session.accessTokenExpiresAt : 0;
|
|
983
1302
|
const claimedToken = this.openSealed(session.sealedAccessToken);
|
|
984
|
-
if (claimedToken !== undefined && claimedExpiry > this.#now() + ACCESS_TOKEN_REFRESH_SKEW_MS) {
|
|
1303
|
+
if (!forcedArg && claimedToken !== undefined && claimedExpiry > this.#now() + ACCESS_TOKEN_REFRESH_SKEW_MS) {
|
|
985
1304
|
await this.#models.Session.atomicUpdate(
|
|
986
1305
|
{ id: sessionIdArg, revokedAt: { $exists: false }, refreshLockUntil: lockUntil },
|
|
987
1306
|
{ $set: { refreshLockUntil: 0 } },
|
|
@@ -1069,6 +1388,13 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1069
1388
|
return this.#provider;
|
|
1070
1389
|
}
|
|
1071
1390
|
|
|
1391
|
+
private requireCookieSessions(): ICookieSessionsMode {
|
|
1392
|
+
if (!this.#cookieSessions) {
|
|
1393
|
+
throw new RelyingPartyError('login_method_unavailable');
|
|
1394
|
+
}
|
|
1395
|
+
return this.#cookieSessions;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1072
1398
|
private requireBearer(): IBearerMode {
|
|
1073
1399
|
if (!this.#bearer) {
|
|
1074
1400
|
throw new RelyingPartyError('login_method_unavailable');
|
|
@@ -1165,7 +1491,8 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1165
1491
|
}
|
|
1166
1492
|
|
|
1167
1493
|
private csrfTokenOf(tokenArg: string): string {
|
|
1168
|
-
|
|
1494
|
+
const { csrfKey, csrfLabel } = this.requireCookieSessions();
|
|
1495
|
+
return deriveCsrfToken(tokenArg, csrfKey, csrfLabel);
|
|
1169
1496
|
}
|
|
1170
1497
|
|
|
1171
1498
|
private opening(sessionArg: RelyingPartySessionDoc, tokenArg: string, maxAgeSecondsArg: number = this.sessionMaxAgeSeconds): IRelyingPartySessionOpening {
|
|
@@ -1252,7 +1579,8 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1252
1579
|
}
|
|
1253
1580
|
try {
|
|
1254
1581
|
await beforeOpenedArg?.(session);
|
|
1255
|
-
|
|
1582
|
+
const delivery = this.bearerDeliveryOfAttempt(attemptArg);
|
|
1583
|
+
await this.#hooks.onProviderSessionOpened?.({ session: this.snapshot(session), ...(delivery ? { delivery } : {}) });
|
|
1256
1584
|
} catch (error) {
|
|
1257
1585
|
// the cookie (or the hand-off code) never leaves, so the session is
|
|
1258
1586
|
// unreachable: it is revoked and its sealed tokens erased before the
|
|
@@ -1286,6 +1614,29 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1286
1614
|
};
|
|
1287
1615
|
}
|
|
1288
1616
|
|
|
1617
|
+
/**
|
|
1618
|
+
* Stores a new hand-off of a session, bound to the client's challenge, and
|
|
1619
|
+
* answers its code, which is stored only as a hash. The code lives
|
|
1620
|
+
* `BEARER_HANDOFF_MAX_AGE_SECONDS` at most and never past the session.
|
|
1621
|
+
*/
|
|
1622
|
+
private async storeBearerHandoff(
|
|
1623
|
+
bearerArg: IBearerMode,
|
|
1624
|
+
sessionArg: RelyingPartySessionDoc,
|
|
1625
|
+
codeChallengeArg: string,
|
|
1626
|
+
): Promise<{ code: string; expiresAt: number }> {
|
|
1627
|
+
const code = randomToken(32);
|
|
1628
|
+
const handoff = new bearerArg.models.BearerHandoff();
|
|
1629
|
+
handoff.id = 'rbh_' + randomToken(18);
|
|
1630
|
+
handoff.codeHash = sha256(code);
|
|
1631
|
+
handoff.sessionId = sessionArg.id;
|
|
1632
|
+
handoff.codeChallenge = codeChallengeArg;
|
|
1633
|
+
handoff.createdAt = this.#now();
|
|
1634
|
+
handoff.expiresAt = Math.min(handoff.createdAt + BEARER_HANDOFF_MAX_AGE_SECONDS * 1_000, sessionArg.expiresAt);
|
|
1635
|
+
handoff.consumedAt = null;
|
|
1636
|
+
await handoff.save();
|
|
1637
|
+
return { code, expiresAt: handoff.expiresAt };
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1289
1640
|
/** Stores the next token generation of a live session and answers its tokens; none of them is stored in the clear. */
|
|
1290
1641
|
private async issueBearerTokens(bearerArg: IBearerMode, sessionArg: RelyingPartySessionDoc): Promise<IRelyingPartyBearerTokens> {
|
|
1291
1642
|
const accessToken = randomToken(32);
|
|
@@ -1337,10 +1688,39 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1337
1688
|
|
|
1338
1689
|
/** Revokes a session that was never handed out and erases its sealed tokens. */
|
|
1339
1690
|
private async discardUnopenedSession(sessionIdArg: string): Promise<void> {
|
|
1340
|
-
await this
|
|
1691
|
+
await this.revokeSessionDocument(sessionIdArg);
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
/**
|
|
1695
|
+
* Revokes a session not revoked yet and erases its sealed tokens; answers
|
|
1696
|
+
* the document as it was before, `null` when it was over already. Its
|
|
1697
|
+
* expiry comes forward to the revocation, never later, so the sweep finds
|
|
1698
|
+
* an ended session by its expiry alone and `revokedAt` keeps when and that
|
|
1699
|
+
* it was ended. Two conditional writes keep the earlier of the two times:
|
|
1700
|
+
* the first ends a session within its expiry, the second one past it. A
|
|
1701
|
+
* crash between them leaves an expired row unrevoked, which reads as over
|
|
1702
|
+
* and is swept; a row revoked before 19.4.0 kept its later expiry, which is
|
|
1703
|
+
* harmless because every reader checks `revokedAt` or reads live rows only,
|
|
1704
|
+
* and it ages out.
|
|
1705
|
+
*/
|
|
1706
|
+
private async revokeSessionDocument(sessionIdArg: string): Promise<RelyingPartySessionDoc | null> {
|
|
1707
|
+
const now = this.#now();
|
|
1708
|
+
const withinExpiry = await this.#models.Session.atomicFindOneAndUpdate(
|
|
1709
|
+
{ id: sessionIdArg, revokedAt: { $exists: false }, expiresAt: { $gt: now } },
|
|
1710
|
+
{ $set: { revokedAt: now, expiresAt: now, ...REVOKED_TOKEN_FIELDS } },
|
|
1711
|
+
{ returnDocument: 'before' },
|
|
1712
|
+
);
|
|
1713
|
+
if (withinExpiry.status === 'matched' && withinExpiry.document !== null) {
|
|
1714
|
+
return withinExpiry.document as RelyingPartySessionDoc;
|
|
1715
|
+
}
|
|
1716
|
+
const pastExpiry = await this.#models.Session.atomicFindOneAndUpdate(
|
|
1341
1717
|
{ id: sessionIdArg, revokedAt: { $exists: false } },
|
|
1342
|
-
{ $set: { revokedAt:
|
|
1718
|
+
{ $set: { revokedAt: now, ...REVOKED_TOKEN_FIELDS } },
|
|
1719
|
+
{ returnDocument: 'before' },
|
|
1343
1720
|
);
|
|
1721
|
+
return pastExpiry.status === 'matched' && pastExpiry.document !== null
|
|
1722
|
+
? pastExpiry.document as RelyingPartySessionDoc
|
|
1723
|
+
: null;
|
|
1344
1724
|
}
|
|
1345
1725
|
|
|
1346
1726
|
/**
|
|
@@ -1348,11 +1728,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1348
1728
|
* local revocation and the provider revocation: a throw or a rejection is
|
|
1349
1729
|
* reported as `hook_failed` and the end runs on.
|
|
1350
1730
|
*/
|
|
1351
|
-
private notifySessionEnded(eventArg: {
|
|
1352
|
-
sessionId: string;
|
|
1353
|
-
reason: TRelyingPartySessionEndReason;
|
|
1354
|
-
revokedHere: boolean;
|
|
1355
|
-
}): void {
|
|
1731
|
+
private notifySessionEnded(eventArg: Parameters<NonNullable<IRelyingPartyHooks['onSessionEnded']>>[0]): void {
|
|
1356
1732
|
const hook = this.#hooks.onSessionEnded;
|
|
1357
1733
|
if (!hook) {
|
|
1358
1734
|
return;
|
|
@@ -1382,17 +1758,12 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1382
1758
|
if (typeof sessionIdArg !== 'string' || sessionIdArg.length === 0) {
|
|
1383
1759
|
return null;
|
|
1384
1760
|
}
|
|
1385
|
-
const
|
|
1386
|
-
|
|
1387
|
-
{
|
|
1388
|
-
{ returnDocument: 'before' },
|
|
1389
|
-
);
|
|
1390
|
-
const revokedHere = revoked.status === 'matched' && revoked.document !== null;
|
|
1391
|
-
this.notifySessionEnded({ sessionId: sessionIdArg, reason: reasonArg, revokedHere });
|
|
1392
|
-
if (!revokedHere) {
|
|
1761
|
+
const before = await this.revokeSessionDocument(sessionIdArg);
|
|
1762
|
+
if (!before) {
|
|
1763
|
+
this.notifySessionEnded({ sessionId: sessionIdArg, reason: reasonArg, revokedHere: false });
|
|
1393
1764
|
return null;
|
|
1394
1765
|
}
|
|
1395
|
-
|
|
1766
|
+
this.notifySessionEnded({ sessionId: sessionIdArg, reason: reasonArg, revokedHere: true, principalId: before.userId });
|
|
1396
1767
|
return { sealedAccessToken: before.sealedAccessToken, sealedRefreshToken: before.sealedRefreshToken };
|
|
1397
1768
|
}
|
|
1398
1769
|
|
|
@@ -1564,6 +1935,33 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1564
1935
|
}
|
|
1565
1936
|
}
|
|
1566
1937
|
|
|
1938
|
+
/**
|
|
1939
|
+
* Removes sessions an hour past their expiry, oldest first, one bounded
|
|
1940
|
+
* page per login start and per session the application opens. An ended
|
|
1941
|
+
* session's expiry is its revocation at the latest, so the expiry index
|
|
1942
|
+
* finds expired and revoked sessions alike. Each of them ended already: the
|
|
1943
|
+
* row goes without an end, a hook or a provider call. A row is deleted only
|
|
1944
|
+
* while its expiry is still past the cutoff, so a row whose expiry moved
|
|
1945
|
+
* after the page was read stays. A sweep that cannot run never blocks a
|
|
1946
|
+
* sign-in.
|
|
1947
|
+
*/
|
|
1948
|
+
private async sweepEndedSessions(): Promise<void> {
|
|
1949
|
+
try {
|
|
1950
|
+
const cutoff = this.#now() - LOGIN_ATTEMPT_SWEEP_GRACE_MS;
|
|
1951
|
+
const page = await this.#models.Session.getPagedInstances({
|
|
1952
|
+
filter: { expiresAt: { $lt: cutoff } },
|
|
1953
|
+
sortField: 'expiresAt',
|
|
1954
|
+
sortDirection: 'asc',
|
|
1955
|
+
limit: LOGIN_ATTEMPT_SWEEP_BATCH,
|
|
1956
|
+
});
|
|
1957
|
+
for (const session of page.documents) {
|
|
1958
|
+
await this.#models.Session.atomicDelete({ id: session.id, expiresAt: { $lt: cutoff } });
|
|
1959
|
+
}
|
|
1960
|
+
} catch (error) {
|
|
1961
|
+
this.emit({ event: 'session_sweep_failed', message: errorMessage(error) });
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1567
1965
|
/**
|
|
1568
1966
|
* Reports one diagnostic. Every cleanup step reports through here, so a
|
|
1569
1967
|
* throwing sink must not end it: the event and the sink's failure go to
|