@idp.global/sdk 19.3.0 → 19.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog.md +40 -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 +276 -0
- package/dist_ts_browser/classes.relyingpartybearerclient.js +591 -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 +3 -1
- package/dist_ts_relyingparty/classes.errors.js +2 -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 +102 -7
- package/dist_ts_relyingparty/classes.relyingpartysessions.js +228 -29
- 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 +82 -0
- package/dist_ts_relyingparty/contract.bearer.js +9 -0
- package/dist_ts_relyingparty/helpers.bearer.d.ts +3 -3
- package/dist_ts_relyingparty/helpers.bearer.js +3 -3
- package/dist_ts_relyingparty/interfaces.d.ts +34 -24
- package/package.json +2 -1
- package/readme.md +215 -19
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts_browser/classes.relyingpartybearerclient.ts +821 -0
- package/ts_browser/index.ts +1 -0
- package/ts_relyingparty/classes.authorityobserver.ts +80 -12
- package/ts_relyingparty/classes.errors.ts +4 -1
- package/ts_relyingparty/classes.relyingpartyroutes.ts +38 -7
- package/ts_relyingparty/classes.relyingpartysessions.ts +298 -34
- package/ts_relyingparty/classes.rpmodels.ts +7 -4
- package/ts_relyingparty/contract.bearer.ts +99 -0
- package/ts_relyingparty/helpers.bearer.ts +8 -9
- package/ts_relyingparty/interfaces.ts +49 -26
|
@@ -52,6 +52,8 @@ import type {
|
|
|
52
52
|
IRelyingPartyLoginStart,
|
|
53
53
|
IRelyingPartySession,
|
|
54
54
|
IRelyingPartySessionOpening,
|
|
55
|
+
IRelyingPartySessionsPage,
|
|
56
|
+
IRelyingPartySessionsPageOptions,
|
|
55
57
|
IResolvedRelyingPartySession,
|
|
56
58
|
TLoginCallbackStage,
|
|
57
59
|
TRelyingPartyPrincipals,
|
|
@@ -69,10 +71,16 @@ const SESSION_TOUCH_INTERVAL_MS = 60_000;
|
|
|
69
71
|
export const LOGIN_ATTEMPT_MAX_AGE_SECONDS = 10 * 60;
|
|
70
72
|
/** The longest intent a login carries (`IRelyingPartyLoginOptions.intent`), in characters. */
|
|
71
73
|
export const RELYING_PARTY_MAX_INTENT_LENGTH = 4_096;
|
|
72
|
-
/** Attempts — and bearer hand-offs
|
|
74
|
+
/** Attempts — and bearer hand-offs, token generations and sessions — are swept once they are this much older than their expiry. */
|
|
73
75
|
const LOGIN_ATTEMPT_SWEEP_GRACE_MS = 60 * 60 * 1_000;
|
|
74
76
|
/** How many of them one sign-in removes; the next sign-in takes the following page. */
|
|
75
77
|
const LOGIN_ATTEMPT_SWEEP_BATCH = 100;
|
|
78
|
+
/** How many sessions one page of `sessionsOfPrincipal` holds unless its `limit` says otherwise. */
|
|
79
|
+
export const RELYING_PARTY_DEFAULT_SESSIONS_PAGE_LIMIT = 100;
|
|
80
|
+
/** The largest `limit` of `sessionsOfPrincipal`; the store reads no larger page. */
|
|
81
|
+
export const RELYING_PARTY_MAX_SESSIONS_PAGE_LIMIT = 1_000;
|
|
82
|
+
/** A `sessionsOfPrincipal` cursor is a short base64url text; anything far longer is none. */
|
|
83
|
+
const MAX_SESSIONS_CURSOR_LENGTH = 512;
|
|
76
84
|
/** How long a bearer hand-off code can be claimed, at most; never past its session. */
|
|
77
85
|
export const BEARER_HANDOFF_MAX_AGE_SECONDS = 60;
|
|
78
86
|
/** How long a bearer access token lives unless `bearer.accessTokenMaxAgeSeconds` says otherwise. */
|
|
@@ -107,6 +115,12 @@ export interface IRelyingPartySessionsOptions<TPrincipal extends object | undefi
|
|
|
107
115
|
sessionEncryptionKey: Buffer;
|
|
108
116
|
/** 32 bytes; the CSRF HMAC key */
|
|
109
117
|
csrfKey: Buffer;
|
|
118
|
+
/**
|
|
119
|
+
* `true` (the default): cookie sessions with a session-bound CSRF token,
|
|
120
|
+
* beside bearer hand-off mode when `bearer` is given. Bearer hand-off alone,
|
|
121
|
+
* without any cookie, is `IRelyingPartyBearerOnlySessionsOptions`.
|
|
122
|
+
*/
|
|
123
|
+
cookieSessions?: true;
|
|
110
124
|
/** the label the CSRF HMAC starts with; an application with live sessions keeps its own */
|
|
111
125
|
csrfLabel?: string;
|
|
112
126
|
sessionMaxAgeSeconds?: number;
|
|
@@ -131,8 +145,36 @@ export interface IRelyingPartySessionsOptions<TPrincipal extends object | undefi
|
|
|
131
145
|
now?: () => number;
|
|
132
146
|
}
|
|
133
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Bearer-only mode (`cookieSessions: false`): the application's own clients
|
|
150
|
+
* sign in through bearer hand-off and nothing else. The relying party never
|
|
151
|
+
* reads or sets a cookie — not even the clearing intent cookie of a callback
|
|
152
|
+
* that is no bearer login — so it has no CSRF token and takes no CSRF key:
|
|
153
|
+
* `csrfKey`, `csrfLabel` and `cookieNames` are refused, not ignored. The
|
|
154
|
+
* cookie steps (`openSession`, `startLogin`, `completeLogin`, `abandonLogin`,
|
|
155
|
+
* `finalizeLogin`, `clearSessionCookie`, `clearIntentCookie`) throw
|
|
156
|
+
* `RelyingPartyError('login_method_unavailable')` and `resolveCookieSession`
|
|
157
|
+
* answers `null`.
|
|
158
|
+
*/
|
|
159
|
+
export interface IRelyingPartyBearerOnlySessionsOptions<TPrincipal extends object | undefined = undefined>
|
|
160
|
+
extends Omit<IRelyingPartySessionsOptions<TPrincipal>, 'provider' | 'csrfKey' | 'csrfLabel' | 'cookieNames' | 'cookieSessions' | 'bearer'> {
|
|
161
|
+
cookieSessions: false;
|
|
162
|
+
/** every bearer session is a provider login */
|
|
163
|
+
provider: IOidcProvider;
|
|
164
|
+
bearer: IRelyingPartyBearerOptions;
|
|
165
|
+
csrfKey?: never;
|
|
166
|
+
csrfLabel?: never;
|
|
167
|
+
cookieNames?: never;
|
|
168
|
+
}
|
|
169
|
+
|
|
134
170
|
type TSealedTokens = Pick<RelyingPartySessionDoc, 'sealedAccessToken' | 'sealedRefreshToken'>;
|
|
135
171
|
|
|
172
|
+
/** Cookie sessions as the constructor checked them; absent in bearer-only mode. */
|
|
173
|
+
interface ICookieSessionsMode {
|
|
174
|
+
csrfKey: Buffer;
|
|
175
|
+
csrfLabel: string;
|
|
176
|
+
}
|
|
177
|
+
|
|
136
178
|
/** Bearer hand-off mode as the constructor checked it. */
|
|
137
179
|
interface IBearerMode {
|
|
138
180
|
returnTargets: Readonly<Required<IRelyingPartyBearerReturnTargets>>;
|
|
@@ -180,6 +222,43 @@ const singleStateOf = (callbackUrlArg: URL): string | null => {
|
|
|
180
222
|
const bearerCredentialOf = (valueArg: unknown): string | null =>
|
|
181
223
|
typeof valueArg === 'string' && valueArg.length > 0 && valueArg.length <= MAX_BEARER_TOKEN_LENGTH ? valueArg : null;
|
|
182
224
|
|
|
225
|
+
/** Where a `sessionsOfPrincipal` page ends: its last session's `createdAt` and `id`. */
|
|
226
|
+
interface ISessionsCursor {
|
|
227
|
+
sortValue: number;
|
|
228
|
+
uniqueValue: string;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const serializeSessionsCursor = (cursorArg: { sortValue: number | string; uniqueValue: string }): string =>
|
|
232
|
+
Buffer.from(JSON.stringify([cursorArg.sortValue, cursorArg.uniqueValue]), 'utf8').toString('base64url');
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The position a cursor of `sessionsOfPrincipal` names. It seeks within the
|
|
236
|
+
* principal's own sessions only, so it needs no seal; anything that is not
|
|
237
|
+
* such a position is refused.
|
|
238
|
+
*/
|
|
239
|
+
const parseSessionsCursor = (cursorArg: unknown): ISessionsCursor => {
|
|
240
|
+
if (typeof cursorArg !== 'string' || cursorArg.length > MAX_SESSIONS_CURSOR_LENGTH || !/^[A-Za-z0-9_-]+$/.test(cursorArg)) {
|
|
241
|
+
throw new RelyingPartyError('invalid_sessions_cursor');
|
|
242
|
+
}
|
|
243
|
+
let position: unknown;
|
|
244
|
+
try {
|
|
245
|
+
position = JSON.parse(Buffer.from(cursorArg, 'base64url').toString('utf8'));
|
|
246
|
+
} catch {
|
|
247
|
+
throw new RelyingPartyError('invalid_sessions_cursor');
|
|
248
|
+
}
|
|
249
|
+
if (
|
|
250
|
+
!Array.isArray(position)
|
|
251
|
+
|| position.length !== 2
|
|
252
|
+
|| !Number.isSafeInteger(position[0])
|
|
253
|
+
|| position[0] < 0
|
|
254
|
+
|| typeof position[1] !== 'string'
|
|
255
|
+
|| position[1] === ''
|
|
256
|
+
) {
|
|
257
|
+
throw new RelyingPartyError('invalid_sessions_cursor');
|
|
258
|
+
}
|
|
259
|
+
return { sortValue: position[0], uniqueValue: position[1] };
|
|
260
|
+
};
|
|
261
|
+
|
|
183
262
|
const REVOKED_TOKEN_FIELDS = { sealedAccessToken: '', sealedRefreshToken: '', refreshLockUntil: 0 };
|
|
184
263
|
|
|
185
264
|
const errorMessage = (errorArg: unknown): string => (errorArg instanceof Error ? errorArg.message : String(errorArg));
|
|
@@ -211,9 +290,16 @@ const cookieNameFor = (
|
|
|
211
290
|
* token, provider tokens sealed at rest and rotated under a cross-replica
|
|
212
291
|
* lock, and session ends that hand the tokens back.
|
|
213
292
|
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
293
|
+
* With `cookieSessions: false` it runs bearer hand-off mode alone and never
|
|
294
|
+
* touches a cookie (`IRelyingPartyBearerOnlySessionsOptions`).
|
|
295
|
+
*
|
|
296
|
+
* Nothing here runs on a timer: expired login attempts, bearer state and
|
|
297
|
+
* ended sessions are swept one bounded batch at a time when a login starts or
|
|
298
|
+
* the application opens a session, and the only timer — the revocation
|
|
299
|
+
* budget — is unreferenced and cleared on every path. Every session comes
|
|
300
|
+
* from a login start or from `openSession`, and each of those sweeps a batch
|
|
301
|
+
* of at least one first, so removal keeps pace with creation and the ended
|
|
302
|
+
* sessions kept stay bounded. Nothing here does I/O while it is constructed,
|
|
217
303
|
* so it is built before the database connects.
|
|
218
304
|
*
|
|
219
305
|
* `TPrincipal` is what a `principals.resolve` answers; with `isActive`
|
|
@@ -233,20 +319,34 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
233
319
|
readonly #provider?: IOidcProvider;
|
|
234
320
|
readonly #bearer?: IBearerMode;
|
|
235
321
|
readonly #secretBox: SecretBox;
|
|
236
|
-
readonly #
|
|
237
|
-
readonly #csrfLabel: string;
|
|
322
|
+
readonly #cookieSessions: ICookieSessionsMode | null;
|
|
238
323
|
readonly #hooks: IRelyingPartyHooks;
|
|
239
324
|
readonly #now: () => number;
|
|
240
325
|
/** one rotation per session in this process, however many requests need it at once */
|
|
241
326
|
readonly #refreshesInFlight = new Map<string, Promise<IProviderToken>>();
|
|
242
327
|
|
|
243
|
-
constructor(optionsArg: IRelyingPartySessionsOptions<TPrincipal>) {
|
|
328
|
+
constructor(optionsArg: IRelyingPartySessionsOptions<TPrincipal> | IRelyingPartyBearerOnlySessionsOptions<TPrincipal>) {
|
|
244
329
|
const origin = new URL(optionsArg.publicOrigin);
|
|
245
330
|
if (origin.origin !== optionsArg.publicOrigin) {
|
|
246
331
|
throw new Error('publicOrigin must be a bare origin such as https://app.example');
|
|
247
332
|
}
|
|
248
|
-
|
|
249
|
-
|
|
333
|
+
const cookieSessions: unknown = optionsArg.cookieSessions ?? true;
|
|
334
|
+
if (cookieSessions !== true && cookieSessions !== false) {
|
|
335
|
+
throw new Error('cookieSessions must be true or false');
|
|
336
|
+
}
|
|
337
|
+
if (cookieSessions) {
|
|
338
|
+
if (!Buffer.isBuffer(optionsArg.csrfKey) || optionsArg.csrfKey.length !== 32) {
|
|
339
|
+
throw new Error('csrfKey must be 32 bytes');
|
|
340
|
+
}
|
|
341
|
+
} else {
|
|
342
|
+
for (const name of ['csrfKey', 'csrfLabel', 'cookieNames'] as const) {
|
|
343
|
+
if (optionsArg[name] !== undefined) {
|
|
344
|
+
throw new Error(`${name} has no use with cookieSessions: false, which reads and sets no cookie`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (optionsArg.bearer === undefined) {
|
|
348
|
+
throw new Error('cookieSessions: false needs bearer: without cookies, bearer hand-off is the only way in');
|
|
349
|
+
}
|
|
250
350
|
}
|
|
251
351
|
const maxAge = optionsArg.sessionMaxAgeSeconds ?? RELYING_PARTY_DEFAULT_SESSION_MAX_AGE_SECONDS;
|
|
252
352
|
if (!Number.isSafeInteger(maxAge) || maxAge < 1) {
|
|
@@ -312,8 +412,9 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
312
412
|
});
|
|
313
413
|
}
|
|
314
414
|
this.#secretBox = new SecretBox(optionsArg.sessionEncryptionKey);
|
|
315
|
-
this.#
|
|
316
|
-
|
|
415
|
+
this.#cookieSessions = cookieSessions
|
|
416
|
+
? { csrfKey: Buffer.from(optionsArg.csrfKey), csrfLabel: optionsArg.csrfLabel ?? RELYING_PARTY_DEFAULT_CSRF_LABEL }
|
|
417
|
+
: null;
|
|
317
418
|
this.#hooks = optionsArg.hooks ?? {};
|
|
318
419
|
this.#now = optionsArg.now ?? (() => Date.now());
|
|
319
420
|
}
|
|
@@ -323,6 +424,11 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
323
424
|
return this.#provider !== undefined;
|
|
324
425
|
}
|
|
325
426
|
|
|
427
|
+
/** Whether cookie sessions are offered; `false` only in bearer-only mode (`cookieSessions: false`). */
|
|
428
|
+
public get cookieSessionsEnabled(): boolean {
|
|
429
|
+
return this.#cookieSessions !== null;
|
|
430
|
+
}
|
|
431
|
+
|
|
326
432
|
/** Whether bearer hand-off mode is offered. */
|
|
327
433
|
public get bearerLoginEnabled(): boolean {
|
|
328
434
|
return this.#bearer !== undefined;
|
|
@@ -354,6 +460,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
354
460
|
* cookie with it; it is a whole number of seconds from 1 to that maximum.
|
|
355
461
|
*/
|
|
356
462
|
public async openSession(optionsArg: { principalId: string; maxAgeSeconds?: number }): Promise<IRelyingPartySessionOpening> {
|
|
463
|
+
this.requireCookieSessions();
|
|
357
464
|
if (typeof optionsArg?.principalId !== 'string' || optionsArg.principalId.length === 0) {
|
|
358
465
|
throw new Error('openSession needs a principal id');
|
|
359
466
|
}
|
|
@@ -361,16 +468,24 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
361
468
|
if (!Number.isSafeInteger(maxAgeSeconds) || maxAgeSeconds < 1 || maxAgeSeconds > this.sessionMaxAgeSeconds) {
|
|
362
469
|
throw new RangeError(`openSession maxAgeSeconds must be a whole number from 1 to ${this.sessionMaxAgeSeconds}`);
|
|
363
470
|
}
|
|
471
|
+
await this.sweepEndedSessions();
|
|
364
472
|
const token = randomToken(32);
|
|
365
473
|
const session = this.newSessionDoc(token, optionsArg.principalId, maxAgeSeconds);
|
|
366
474
|
await session.save();
|
|
367
475
|
return this.opening(session, token, maxAgeSeconds);
|
|
368
476
|
}
|
|
369
477
|
|
|
370
|
-
/**
|
|
478
|
+
/**
|
|
479
|
+
* The live session behind the session cookie, or `null`. A read touches it
|
|
480
|
+
* at most once a minute. Always `null` in bearer-only mode, which reads no
|
|
481
|
+
* cookie.
|
|
482
|
+
*/
|
|
371
483
|
public async resolveCookieSession(
|
|
372
484
|
cookieHeaderArg: string | null | undefined,
|
|
373
485
|
): Promise<IResolvedRelyingPartySession<TPrincipal> | null> {
|
|
486
|
+
if (!this.#cookieSessions) {
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
374
489
|
const token = parseCookies(cookieHeaderArg).get(this.sessionCookieName);
|
|
375
490
|
if (!token || token.length > MAX_COOKIE_TOKEN_LENGTH) {
|
|
376
491
|
return null;
|
|
@@ -465,7 +580,46 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
465
580
|
return ended;
|
|
466
581
|
}
|
|
467
582
|
|
|
583
|
+
/**
|
|
584
|
+
* One page of a principal's live sessions, newest first: the ones neither
|
|
585
|
+
* ended nor expired, and none at all while the principal is not active —
|
|
586
|
+
* exactly the sessions `isSessionLive` confirms. Pass the page's
|
|
587
|
+
* `nextCursor` as `cursor` to read the next one; the last page has none.
|
|
588
|
+
* The cursor is opaque, and one this method did not answer is refused with
|
|
589
|
+
* `invalid_sessions_cursor`. A session opened while the pages are read is
|
|
590
|
+
* listed only when it sorts after the cursor; none is listed twice.
|
|
591
|
+
*/
|
|
592
|
+
public async sessionsOfPrincipal(
|
|
593
|
+
principalIdArg: string,
|
|
594
|
+
optionsArg: IRelyingPartySessionsPageOptions = {},
|
|
595
|
+
): Promise<IRelyingPartySessionsPage> {
|
|
596
|
+
if (typeof principalIdArg !== 'string' || principalIdArg === '') {
|
|
597
|
+
throw new Error('sessionsOfPrincipal needs a principal id');
|
|
598
|
+
}
|
|
599
|
+
const limit = optionsArg.limit ?? RELYING_PARTY_DEFAULT_SESSIONS_PAGE_LIMIT;
|
|
600
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > RELYING_PARTY_MAX_SESSIONS_PAGE_LIMIT) {
|
|
601
|
+
throw new Error(`limit must be an integer from 1 to ${RELYING_PARTY_MAX_SESSIONS_PAGE_LIMIT}`);
|
|
602
|
+
}
|
|
603
|
+
const cursor = optionsArg.cursor === undefined ? undefined : parseSessionsCursor(optionsArg.cursor);
|
|
604
|
+
if (!(await this.#activePrincipal(principalIdArg))) {
|
|
605
|
+
return { sessions: [] };
|
|
606
|
+
}
|
|
607
|
+
const page = await this.#models.Session.getPagedInstances({
|
|
608
|
+
filter: { userId: principalIdArg, revokedAt: { $exists: false }, expiresAt: { $gt: this.#now() } },
|
|
609
|
+
sortField: 'createdAt',
|
|
610
|
+
sortDirection: 'desc',
|
|
611
|
+
uniqueField: 'id',
|
|
612
|
+
limit,
|
|
613
|
+
...(cursor ? { cursor } : {}),
|
|
614
|
+
});
|
|
615
|
+
return {
|
|
616
|
+
sessions: page.documents.map((documentArg) => this.snapshot(documentArg)),
|
|
617
|
+
...(page.nextCursor ? { nextCursor: serializeSessionsCursor(page.nextCursor) } : {}),
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
468
621
|
public clearSessionCookie(): string {
|
|
622
|
+
this.requireCookieSessions();
|
|
469
623
|
return serializeClearedCookie(this.sessionCookieName, {
|
|
470
624
|
httpOnly: true,
|
|
471
625
|
secure: this.secureCookies,
|
|
@@ -474,6 +628,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
474
628
|
}
|
|
475
629
|
|
|
476
630
|
public clearIntentCookie(): string {
|
|
631
|
+
this.requireCookieSessions();
|
|
477
632
|
return serializeClearedCookie(this.intentCookieName, {
|
|
478
633
|
httpOnly: true,
|
|
479
634
|
secure: this.secureCookies,
|
|
@@ -491,9 +646,11 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
491
646
|
* browser.
|
|
492
647
|
*/
|
|
493
648
|
public async startLogin(returnPathArg: unknown, optionsArg: IRelyingPartyLoginOptions = {}): Promise<IRelyingPartyLoginStart> {
|
|
649
|
+
this.requireCookieSessions();
|
|
494
650
|
const provider = this.requireProvider();
|
|
495
651
|
this.checkLoginOptions(optionsArg);
|
|
496
652
|
await this.sweepExpiredAttempts();
|
|
653
|
+
await this.sweepEndedSessions();
|
|
497
654
|
const bindingToken = randomToken(32);
|
|
498
655
|
const { attempt, state, nonce, pkceVerifier } = this.newLoginAttempt(optionsArg.intent);
|
|
499
656
|
attempt.bindingTokenHash = sha256(bindingToken);
|
|
@@ -529,6 +686,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
529
686
|
callbackUrlArg: URL,
|
|
530
687
|
cookieHeaderArg: string | null | undefined,
|
|
531
688
|
): Promise<IRelyingPartySessionOpening> {
|
|
689
|
+
this.requireCookieSessions();
|
|
532
690
|
const provider = this.requireProvider();
|
|
533
691
|
const states = callbackUrlArg.searchParams.getAll('state');
|
|
534
692
|
if (states.length !== 1 || !states[0]) {
|
|
@@ -564,6 +722,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
564
722
|
* matches no open attempt leaves nothing to do.
|
|
565
723
|
*/
|
|
566
724
|
public async abandonLogin(callbackUrlArg: URL, cookieHeaderArg: string | null | undefined): Promise<void> {
|
|
725
|
+
this.requireCookieSessions();
|
|
567
726
|
const states = callbackUrlArg.searchParams.getAll('state');
|
|
568
727
|
const bindingToken = parseCookies(cookieHeaderArg).get(this.intentCookieName);
|
|
569
728
|
if (
|
|
@@ -586,6 +745,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
586
745
|
* session this very attempt created.
|
|
587
746
|
*/
|
|
588
747
|
public async finalizeLogin(cookieHeaderArg: string | null | undefined): Promise<string> {
|
|
748
|
+
this.requireCookieSessions();
|
|
589
749
|
this.requireProvider();
|
|
590
750
|
const bindingToken = parseCookies(cookieHeaderArg).get(this.intentCookieName);
|
|
591
751
|
if (!bindingToken || bindingToken.length > MAX_COOKIE_TOKEN_LENGTH) {
|
|
@@ -648,6 +808,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
648
808
|
this.checkLoginOptions(optionsArg);
|
|
649
809
|
await this.sweepExpiredAttempts();
|
|
650
810
|
await this.sweepBearerState();
|
|
811
|
+
await this.sweepEndedSessions();
|
|
651
812
|
const { attempt, state, nonce, pkceVerifier } = this.newLoginAttempt(optionsArg.intent);
|
|
652
813
|
// a binding token that never leaves: no cookie callback can consume this attempt
|
|
653
814
|
attempt.bindingTokenHash = sha256(randomToken(32));
|
|
@@ -910,8 +1071,22 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
910
1071
|
* is unknown — is rotated first. The session has to belong to
|
|
911
1072
|
* `expectedPrincipalIdArg`, so a credential is never evaluated with another
|
|
912
1073
|
* person's tokens.
|
|
1074
|
+
*
|
|
1075
|
+
* With `rejectedAccessToken` (`IAuthorityTokenSource`) the call rotates the
|
|
1076
|
+
* session's token whatever is stored, and only a rotation that it starts
|
|
1077
|
+
* marks its answer `rotatedAfterRejection`: one that started before it, in
|
|
1078
|
+
* this process or another replica's, says nothing about the rejection, so
|
|
1079
|
+
* the call awaits it and throws `authority_unavailable`. A refused rotation
|
|
1080
|
+
* ends the session (`refresh_token_refused`) and throws
|
|
1081
|
+
* `authority_login_required`. A session without a refresh token has nothing
|
|
1082
|
+
* to rotate with: it throws `authority_unavailable`, or ends as
|
|
1083
|
+
* `access_token_unusable` when its token is dead.
|
|
913
1084
|
*/
|
|
914
|
-
public async liveProviderToken(
|
|
1085
|
+
public async liveProviderToken(
|
|
1086
|
+
sessionIdArg: string,
|
|
1087
|
+
expectedPrincipalIdArg: string,
|
|
1088
|
+
optionsArg: { rejectedAccessToken?: string } = {},
|
|
1089
|
+
): Promise<IProviderToken> {
|
|
915
1090
|
const session = await this.#models.Session.getInstance({ id: sessionIdArg });
|
|
916
1091
|
const now = this.#now();
|
|
917
1092
|
if (
|
|
@@ -926,35 +1101,60 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
926
1101
|
const subject = session.idpSubject;
|
|
927
1102
|
const expiresAt = typeof session.accessTokenExpiresAt === 'number' ? session.accessTokenExpiresAt : 0;
|
|
928
1103
|
const accessToken = this.openSealed(session.sealedAccessToken);
|
|
1104
|
+
const forced = optionsArg.rejectedAccessToken !== undefined;
|
|
929
1105
|
// an unknown expiry is rotated too: afterwards the lifetime is known
|
|
930
|
-
const due =
|
|
1106
|
+
const due = forced
|
|
1107
|
+
|| accessToken === undefined
|
|
1108
|
+
|| expiresAt === 0
|
|
1109
|
+
|| expiresAt <= now + ACCESS_TOKEN_REFRESH_SKEW_MS;
|
|
931
1110
|
if (!due && accessToken !== undefined) {
|
|
932
1111
|
return { accessToken, subject, expiresAt };
|
|
933
1112
|
}
|
|
934
1113
|
if (!session.sealedRefreshToken) {
|
|
935
1114
|
if (accessToken !== undefined && (expiresAt === 0 || expiresAt > now)) {
|
|
936
1115
|
// nothing to rotate with, and the token is not known to be dead
|
|
1116
|
+
if (forced) {
|
|
1117
|
+
throw new AuthorityTokenError('authority_unavailable');
|
|
1118
|
+
}
|
|
937
1119
|
return { accessToken, subject, expiresAt };
|
|
938
1120
|
}
|
|
939
1121
|
await this.endProviderSession(sessionIdArg, 'access_token_unusable');
|
|
940
1122
|
throw new AuthorityTokenError('authority_login_required');
|
|
941
1123
|
}
|
|
942
|
-
return this.refreshProviderToken(sessionIdArg, subject);
|
|
1124
|
+
return this.refreshProviderToken(sessionIdArg, subject, forced);
|
|
943
1125
|
}
|
|
944
1126
|
|
|
945
|
-
|
|
1127
|
+
/**
|
|
1128
|
+
* One rotation of the session in this process at a time. A forced call that
|
|
1129
|
+
* finds one running waits for it, so a refusal still ends it, and is
|
|
1130
|
+
* unavailable otherwise: that rotation started before the call.
|
|
1131
|
+
*/
|
|
1132
|
+
private async refreshProviderToken(
|
|
1133
|
+
sessionIdArg: string,
|
|
1134
|
+
subjectArg: string,
|
|
1135
|
+
forcedArg: boolean,
|
|
1136
|
+
): Promise<IProviderToken> {
|
|
946
1137
|
const running = this.#refreshesInFlight.get(sessionIdArg);
|
|
947
1138
|
if (running) {
|
|
948
|
-
|
|
1139
|
+
const joined = await running;
|
|
1140
|
+
if (forcedArg) {
|
|
1141
|
+
throw new AuthorityTokenError('authority_unavailable');
|
|
1142
|
+
}
|
|
1143
|
+
return joined;
|
|
949
1144
|
}
|
|
950
|
-
const pending = this.runProviderRefresh(sessionIdArg, subjectArg).finally(() => {
|
|
1145
|
+
const pending = this.runProviderRefresh(sessionIdArg, subjectArg, forcedArg).finally(() => {
|
|
951
1146
|
this.#refreshesInFlight.delete(sessionIdArg);
|
|
952
1147
|
});
|
|
953
1148
|
this.#refreshesInFlight.set(sessionIdArg, pending);
|
|
954
|
-
|
|
1149
|
+
// only the call that started the rotation is told so; others that join it are not
|
|
1150
|
+
return forcedArg ? { ...(await pending), rotatedAfterRejection: true } : pending;
|
|
955
1151
|
}
|
|
956
1152
|
|
|
957
|
-
private async runProviderRefresh(
|
|
1153
|
+
private async runProviderRefresh(
|
|
1154
|
+
sessionIdArg: string,
|
|
1155
|
+
subjectArg: string,
|
|
1156
|
+
forcedArg: boolean,
|
|
1157
|
+
): Promise<IProviderToken> {
|
|
958
1158
|
const provider = this.#provider;
|
|
959
1159
|
if (!provider) {
|
|
960
1160
|
throw new AuthorityTokenError('authority_unavailable');
|
|
@@ -973,15 +1173,21 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
973
1173
|
{ returnDocument: 'after' },
|
|
974
1174
|
);
|
|
975
1175
|
if (claimed.status !== 'matched' || !claimed.document) {
|
|
1176
|
+
if (forcedArg) {
|
|
1177
|
+
// nothing tells when the other replica's rotation started
|
|
1178
|
+
throw new AuthorityTokenError('authority_unavailable');
|
|
1179
|
+
}
|
|
976
1180
|
return this.tokenAfterForeignRefresh(sessionIdArg, subjectArg);
|
|
977
1181
|
}
|
|
978
1182
|
const session = claimed.document as RelyingPartySessionDoc;
|
|
979
1183
|
// the lock holder decides on the state it just read: a rotation that
|
|
980
1184
|
// landed in between is used as it stands, because spending a superseded
|
|
981
|
-
// refresh token would be reuse and would kill the family
|
|
1185
|
+
// refresh token would be reuse and would kill the family; a forced call
|
|
1186
|
+
// rotates the stored pair however long its token would last, since only a
|
|
1187
|
+
// rotation it starts comes after the rejection
|
|
982
1188
|
const claimedExpiry = typeof session.accessTokenExpiresAt === 'number' ? session.accessTokenExpiresAt : 0;
|
|
983
1189
|
const claimedToken = this.openSealed(session.sealedAccessToken);
|
|
984
|
-
if (claimedToken !== undefined && claimedExpiry > this.#now() + ACCESS_TOKEN_REFRESH_SKEW_MS) {
|
|
1190
|
+
if (!forcedArg && claimedToken !== undefined && claimedExpiry > this.#now() + ACCESS_TOKEN_REFRESH_SKEW_MS) {
|
|
985
1191
|
await this.#models.Session.atomicUpdate(
|
|
986
1192
|
{ id: sessionIdArg, revokedAt: { $exists: false }, refreshLockUntil: lockUntil },
|
|
987
1193
|
{ $set: { refreshLockUntil: 0 } },
|
|
@@ -1069,6 +1275,13 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1069
1275
|
return this.#provider;
|
|
1070
1276
|
}
|
|
1071
1277
|
|
|
1278
|
+
private requireCookieSessions(): ICookieSessionsMode {
|
|
1279
|
+
if (!this.#cookieSessions) {
|
|
1280
|
+
throw new RelyingPartyError('login_method_unavailable');
|
|
1281
|
+
}
|
|
1282
|
+
return this.#cookieSessions;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1072
1285
|
private requireBearer(): IBearerMode {
|
|
1073
1286
|
if (!this.#bearer) {
|
|
1074
1287
|
throw new RelyingPartyError('login_method_unavailable');
|
|
@@ -1165,7 +1378,8 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1165
1378
|
}
|
|
1166
1379
|
|
|
1167
1380
|
private csrfTokenOf(tokenArg: string): string {
|
|
1168
|
-
|
|
1381
|
+
const { csrfKey, csrfLabel } = this.requireCookieSessions();
|
|
1382
|
+
return deriveCsrfToken(tokenArg, csrfKey, csrfLabel);
|
|
1169
1383
|
}
|
|
1170
1384
|
|
|
1171
1385
|
private opening(sessionArg: RelyingPartySessionDoc, tokenArg: string, maxAgeSecondsArg: number = this.sessionMaxAgeSeconds): IRelyingPartySessionOpening {
|
|
@@ -1337,10 +1551,39 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1337
1551
|
|
|
1338
1552
|
/** Revokes a session that was never handed out and erases its sealed tokens. */
|
|
1339
1553
|
private async discardUnopenedSession(sessionIdArg: string): Promise<void> {
|
|
1340
|
-
await this
|
|
1554
|
+
await this.revokeSessionDocument(sessionIdArg);
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
/**
|
|
1558
|
+
* Revokes a session not revoked yet and erases its sealed tokens; answers
|
|
1559
|
+
* the document as it was before, `null` when it was over already. Its
|
|
1560
|
+
* expiry comes forward to the revocation, never later, so the sweep finds
|
|
1561
|
+
* an ended session by its expiry alone and `revokedAt` keeps when and that
|
|
1562
|
+
* it was ended. Two conditional writes keep the earlier of the two times:
|
|
1563
|
+
* the first ends a session within its expiry, the second one past it. A
|
|
1564
|
+
* crash between them leaves an expired row unrevoked, which reads as over
|
|
1565
|
+
* and is swept; a row revoked before 19.4.0 kept its later expiry, which is
|
|
1566
|
+
* harmless because every reader checks `revokedAt` or reads live rows only,
|
|
1567
|
+
* and it ages out.
|
|
1568
|
+
*/
|
|
1569
|
+
private async revokeSessionDocument(sessionIdArg: string): Promise<RelyingPartySessionDoc | null> {
|
|
1570
|
+
const now = this.#now();
|
|
1571
|
+
const withinExpiry = await this.#models.Session.atomicFindOneAndUpdate(
|
|
1572
|
+
{ id: sessionIdArg, revokedAt: { $exists: false }, expiresAt: { $gt: now } },
|
|
1573
|
+
{ $set: { revokedAt: now, expiresAt: now, ...REVOKED_TOKEN_FIELDS } },
|
|
1574
|
+
{ returnDocument: 'before' },
|
|
1575
|
+
);
|
|
1576
|
+
if (withinExpiry.status === 'matched' && withinExpiry.document !== null) {
|
|
1577
|
+
return withinExpiry.document as RelyingPartySessionDoc;
|
|
1578
|
+
}
|
|
1579
|
+
const pastExpiry = await this.#models.Session.atomicFindOneAndUpdate(
|
|
1341
1580
|
{ id: sessionIdArg, revokedAt: { $exists: false } },
|
|
1342
|
-
{ $set: { revokedAt:
|
|
1581
|
+
{ $set: { revokedAt: now, ...REVOKED_TOKEN_FIELDS } },
|
|
1582
|
+
{ returnDocument: 'before' },
|
|
1343
1583
|
);
|
|
1584
|
+
return pastExpiry.status === 'matched' && pastExpiry.document !== null
|
|
1585
|
+
? pastExpiry.document as RelyingPartySessionDoc
|
|
1586
|
+
: null;
|
|
1344
1587
|
}
|
|
1345
1588
|
|
|
1346
1589
|
/**
|
|
@@ -1382,17 +1625,11 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1382
1625
|
if (typeof sessionIdArg !== 'string' || sessionIdArg.length === 0) {
|
|
1383
1626
|
return null;
|
|
1384
1627
|
}
|
|
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) {
|
|
1628
|
+
const before = await this.revokeSessionDocument(sessionIdArg);
|
|
1629
|
+
this.notifySessionEnded({ sessionId: sessionIdArg, reason: reasonArg, revokedHere: before !== null });
|
|
1630
|
+
if (!before) {
|
|
1393
1631
|
return null;
|
|
1394
1632
|
}
|
|
1395
|
-
const before = revoked.document as RelyingPartySessionDoc;
|
|
1396
1633
|
return { sealedAccessToken: before.sealedAccessToken, sealedRefreshToken: before.sealedRefreshToken };
|
|
1397
1634
|
}
|
|
1398
1635
|
|
|
@@ -1564,6 +1801,33 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1564
1801
|
}
|
|
1565
1802
|
}
|
|
1566
1803
|
|
|
1804
|
+
/**
|
|
1805
|
+
* Removes sessions an hour past their expiry, oldest first, one bounded
|
|
1806
|
+
* page per login start and per session the application opens. An ended
|
|
1807
|
+
* session's expiry is its revocation at the latest, so the expiry index
|
|
1808
|
+
* finds expired and revoked sessions alike. Each of them ended already: the
|
|
1809
|
+
* row goes without an end, a hook or a provider call. A row is deleted only
|
|
1810
|
+
* while its expiry is still past the cutoff, so a row whose expiry moved
|
|
1811
|
+
* after the page was read stays. A sweep that cannot run never blocks a
|
|
1812
|
+
* sign-in.
|
|
1813
|
+
*/
|
|
1814
|
+
private async sweepEndedSessions(): Promise<void> {
|
|
1815
|
+
try {
|
|
1816
|
+
const cutoff = this.#now() - LOGIN_ATTEMPT_SWEEP_GRACE_MS;
|
|
1817
|
+
const page = await this.#models.Session.getPagedInstances({
|
|
1818
|
+
filter: { expiresAt: { $lt: cutoff } },
|
|
1819
|
+
sortField: 'expiresAt',
|
|
1820
|
+
sortDirection: 'asc',
|
|
1821
|
+
limit: LOGIN_ATTEMPT_SWEEP_BATCH,
|
|
1822
|
+
});
|
|
1823
|
+
for (const session of page.documents) {
|
|
1824
|
+
await this.#models.Session.atomicDelete({ id: session.id, expiresAt: { $lt: cutoff } });
|
|
1825
|
+
}
|
|
1826
|
+
} catch (error) {
|
|
1827
|
+
this.emit({ event: 'session_sweep_failed', message: errorMessage(error) });
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1567
1831
|
/**
|
|
1568
1832
|
* Reports one diagnostic. Every cleanup step reports through here, so a
|
|
1569
1833
|
* throwing sink must not end it: the event and the sink's failure go to
|
|
@@ -3,10 +3,11 @@ import { RelyingPartyDatabaseUnavailableError } from './classes.errors.js';
|
|
|
3
3
|
import type { TRelyingPartyBearerClientKind } from './interfaces.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* One browser session of a relying party. The persisted field names
|
|
7
|
-
* types
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* One browser session of a relying party. The persisted field names and their
|
|
7
|
+
* types are the ones nevermind.finance has stored under `SessionDoc` since
|
|
8
|
+
* before this module existed, so its collection is read and written
|
|
9
|
+
* unchanged; its indexes are those and the `expiresAt` index the sweep of
|
|
10
|
+
* ended sessions reads.
|
|
10
11
|
*/
|
|
11
12
|
export class RelyingPartySessionDoc extends plugins.nosqldb.SmartDataDbDoc<RelyingPartySessionDoc, RelyingPartySessionDoc> {
|
|
12
13
|
@plugins.nosqldb.unI()
|
|
@@ -27,7 +28,9 @@ export class RelyingPartySessionDoc extends plugins.nosqldb.SmartDataDbDoc<Relyi
|
|
|
27
28
|
@plugins.nosqldb.svDb()
|
|
28
29
|
public lastSeenAt: number = 0;
|
|
29
30
|
|
|
31
|
+
/** a revocation lowers it to `revokedAt` when that is earlier, so the sweep finds every ended session by it */
|
|
30
32
|
@plugins.nosqldb.svDb()
|
|
33
|
+
@plugins.nosqldb.index()
|
|
31
34
|
public expiresAt: number = 0;
|
|
32
35
|
|
|
33
36
|
/** absent while the session is valid */
|