@idp.global/sdk 19.4.0 → 19.6.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 +60 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts_browser/classes.relyingpartybearerclient.d.ts +40 -3
- package/dist_ts_browser/classes.relyingpartybearerclient.js +67 -12
- package/dist_ts_relyingparty/classes.errors.d.ts +2 -0
- package/dist_ts_relyingparty/classes.errors.js +2 -1
- package/dist_ts_relyingparty/classes.idpoidcprovider.d.ts +69 -11
- package/dist_ts_relyingparty/classes.idpoidcprovider.js +127 -17
- package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +128 -18
- package/dist_ts_relyingparty/classes.relyingpartysessions.js +334 -69
- package/dist_ts_relyingparty/classes.rpmodels.d.ts +21 -4
- package/dist_ts_relyingparty/classes.rpmodels.js +35 -8
- package/dist_ts_relyingparty/contract.bearer.d.ts +37 -0
- package/dist_ts_relyingparty/contract.bearer.js +5 -1
- package/dist_ts_relyingparty/helpers.bearer.d.ts +2 -3
- package/dist_ts_relyingparty/helpers.bearer.js +4 -6
- package/dist_ts_relyingparty/helpers.cookies.d.ts +6 -0
- package/dist_ts_relyingparty/helpers.cookies.js +8 -1
- package/dist_ts_relyingparty/interfaces.d.ts +73 -3
- package/dist_ts_relyingparty/testing.js +8 -4
- package/package.json +3 -3
- package/readme.md +280 -34
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts_browser/classes.relyingpartybearerclient.ts +98 -18
- package/ts_relyingparty/classes.errors.ts +3 -0
- package/ts_relyingparty/classes.idpoidcprovider.ts +183 -17
- package/ts_relyingparty/classes.relyingpartysessions.ts +393 -78
- package/ts_relyingparty/classes.rpmodels.ts +27 -4
- package/ts_relyingparty/contract.bearer.ts +46 -0
- package/ts_relyingparty/helpers.bearer.ts +3 -6
- package/ts_relyingparty/helpers.cookies.ts +13 -0
- package/ts_relyingparty/interfaces.ts +76 -7
- package/ts_relyingparty/testing.ts +7 -3
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
IRelyingPartyBearerModels,
|
|
19
19
|
IRelyingPartyModels,
|
|
20
20
|
RelyingPartyBearerHandoffDoc,
|
|
21
|
+
RelyingPartyBearerTokenDoc,
|
|
21
22
|
RelyingPartyLoginAttemptDoc,
|
|
22
23
|
RelyingPartySessionDoc,
|
|
23
24
|
} from './classes.rpmodels.js';
|
|
@@ -31,7 +32,7 @@ import {
|
|
|
31
32
|
parseBearerAuthorization,
|
|
32
33
|
verifyPkceChallenge,
|
|
33
34
|
} from './helpers.bearer.js';
|
|
34
|
-
import { parseCookies, serializeClearedCookie, serializeCookie } from './helpers.cookies.js';
|
|
35
|
+
import { parseCookies, serializeClearedCookie, serializeCookie, type ICookieOptions } from './helpers.cookies.js';
|
|
35
36
|
import { deriveCsrfToken, randomToken, sha256, timingSafeEqualString } from './helpers.crypto.js';
|
|
36
37
|
import { sanitizeReturnPath } from './helpers.returnpath.js';
|
|
37
38
|
import type {
|
|
@@ -39,11 +40,13 @@ import type {
|
|
|
39
40
|
IProviderToken,
|
|
40
41
|
IRelyingPartyBearerDelivery,
|
|
41
42
|
IRelyingPartyBearerHandoff,
|
|
43
|
+
IRelyingPartyBearerHandoffOpening,
|
|
42
44
|
IRelyingPartyBearerLoginRequest,
|
|
43
45
|
IRelyingPartyBearerLoginStart,
|
|
44
46
|
IRelyingPartyBearerOptions,
|
|
45
47
|
IRelyingPartyBearerReturnTargets,
|
|
46
48
|
IRelyingPartyBearerTokens,
|
|
49
|
+
IRelyingPartyCookiePolicy,
|
|
47
50
|
IRelyingPartyEvent,
|
|
48
51
|
IRelyingPartyHooks,
|
|
49
52
|
IRelyingPartyAdmission,
|
|
@@ -52,6 +55,7 @@ import type {
|
|
|
52
55
|
IRelyingPartyLoginStart,
|
|
53
56
|
IRelyingPartySession,
|
|
54
57
|
IRelyingPartySessionOpening,
|
|
58
|
+
IRelyingPartySessionsErasure,
|
|
55
59
|
IRelyingPartySessionsPage,
|
|
56
60
|
IRelyingPartySessionsPageOptions,
|
|
57
61
|
IResolvedRelyingPartySession,
|
|
@@ -85,6 +89,10 @@ const MAX_SESSIONS_CURSOR_LENGTH = 512;
|
|
|
85
89
|
export const BEARER_HANDOFF_MAX_AGE_SECONDS = 60;
|
|
86
90
|
/** How long a bearer access token lives unless `bearer.accessTokenMaxAgeSeconds` says otherwise. */
|
|
87
91
|
export const RELYING_PARTY_DEFAULT_BEARER_ACCESS_TOKEN_MAX_AGE_SECONDS = 15 * 60;
|
|
92
|
+
/** How long a spent refresh token may come back as a lost answer unless `bearer.refreshReuseGraceSeconds` says otherwise. */
|
|
93
|
+
export const RELYING_PARTY_DEFAULT_REFRESH_REUSE_GRACE_SECONDS = 60;
|
|
94
|
+
/** The longest `bearer.refreshReuseGraceSeconds`: a spent token that comes back later is always reuse. */
|
|
95
|
+
export const RELYING_PARTY_MAX_REFRESH_REUSE_GRACE_SECONDS = 300;
|
|
88
96
|
/** The codes and tokens of bearer mode are 43 characters; anything far longer is none of them. */
|
|
89
97
|
const MAX_BEARER_TOKEN_LENGTH = 256;
|
|
90
98
|
/**
|
|
@@ -133,6 +141,14 @@ export interface IRelyingPartySessionsOptions<TPrincipal extends object | undefi
|
|
|
133
141
|
session?: string;
|
|
134
142
|
intent?: string;
|
|
135
143
|
};
|
|
144
|
+
/**
|
|
145
|
+
* How the session and intent cookies go across sites: `{ sameSite: 'Lax' }`
|
|
146
|
+
* by default. A page framed on another site needs `sameSite: 'None'` (an
|
|
147
|
+
* https `publicOrigin` only), with `partitioned: true` where the browser
|
|
148
|
+
* keeps third-party cookies per top-level site. The constructor refuses
|
|
149
|
+
* every other combination.
|
|
150
|
+
*/
|
|
151
|
+
cookiePolicy?: IRelyingPartyCookiePolicy;
|
|
136
152
|
/** the prefix of the login routes; a login never returns into it */
|
|
137
153
|
loginPathPrefix?: string;
|
|
138
154
|
/**
|
|
@@ -150,21 +166,23 @@ export interface IRelyingPartySessionsOptions<TPrincipal extends object | undefi
|
|
|
150
166
|
* sign in through bearer hand-off and nothing else. The relying party never
|
|
151
167
|
* reads or sets a cookie — not even the clearing intent cookie of a callback
|
|
152
168
|
* that is no bearer login — so it has no CSRF token and takes no CSRF key:
|
|
153
|
-
* `csrfKey`, `csrfLabel` and `
|
|
169
|
+
* `csrfKey`, `csrfLabel`, `cookieNames` and `cookiePolicy` are refused, not
|
|
170
|
+
* ignored. The
|
|
154
171
|
* cookie steps (`openSession`, `startLogin`, `completeLogin`, `abandonLogin`,
|
|
155
172
|
* `finalizeLogin`, `clearSessionCookie`, `clearIntentCookie`) throw
|
|
156
173
|
* `RelyingPartyError('login_method_unavailable')` and `resolveCookieSession`
|
|
157
174
|
* answers `null`.
|
|
158
175
|
*/
|
|
159
176
|
export interface IRelyingPartyBearerOnlySessionsOptions<TPrincipal extends object | undefined = undefined>
|
|
160
|
-
extends Omit<IRelyingPartySessionsOptions<TPrincipal>, 'provider' | 'csrfKey' | 'csrfLabel' | 'cookieNames' | 'cookieSessions' | 'bearer'> {
|
|
177
|
+
extends Omit<IRelyingPartySessionsOptions<TPrincipal>, 'provider' | 'csrfKey' | 'csrfLabel' | 'cookieNames' | 'cookiePolicy' | 'cookieSessions' | 'bearer'> {
|
|
161
178
|
cookieSessions: false;
|
|
162
|
-
/** every bearer session
|
|
179
|
+
/** every bearer session comes from a live provider login, directly or through a hand-off opened from one */
|
|
163
180
|
provider: IOidcProvider;
|
|
164
181
|
bearer: IRelyingPartyBearerOptions;
|
|
165
182
|
csrfKey?: never;
|
|
166
183
|
csrfLabel?: never;
|
|
167
184
|
cookieNames?: never;
|
|
185
|
+
cookiePolicy?: never;
|
|
168
186
|
}
|
|
169
187
|
|
|
170
188
|
type TSealedTokens = Pick<RelyingPartySessionDoc, 'sealedAccessToken' | 'sealedRefreshToken'>;
|
|
@@ -179,6 +197,8 @@ interface ICookieSessionsMode {
|
|
|
179
197
|
interface IBearerMode {
|
|
180
198
|
returnTargets: Readonly<Required<IRelyingPartyBearerReturnTargets>>;
|
|
181
199
|
accessTokenMaxAgeSeconds: number;
|
|
200
|
+
/** `0`: a spent refresh token that comes back is always reuse */
|
|
201
|
+
refreshReuseGraceMs: number;
|
|
182
202
|
models: IRelyingPartyBearerModels;
|
|
183
203
|
}
|
|
184
204
|
|
|
@@ -190,7 +210,7 @@ const bearerModeOf = (
|
|
|
190
210
|
throw new Error('bearer must be an object with returnTargets');
|
|
191
211
|
}
|
|
192
212
|
if (!optionsArg.hasProvider) {
|
|
193
|
-
throw new Error('bearer needs a provider: every bearer session
|
|
213
|
+
throw new Error('bearer needs a provider: every bearer session comes from a provider login');
|
|
194
214
|
}
|
|
195
215
|
const { BearerHandoff, BearerToken } = optionsArg.models;
|
|
196
216
|
if (!BearerHandoff || !BearerToken) {
|
|
@@ -205,9 +225,18 @@ const bearerModeOf = (
|
|
|
205
225
|
) {
|
|
206
226
|
throw new Error(`bearer.accessTokenMaxAgeSeconds must be a whole number from 1 to ${optionsArg.sessionMaxAgeSeconds}`);
|
|
207
227
|
}
|
|
228
|
+
const refreshReuseGraceSeconds = bearerArg.refreshReuseGraceSeconds ?? RELYING_PARTY_DEFAULT_REFRESH_REUSE_GRACE_SECONDS;
|
|
229
|
+
if (
|
|
230
|
+
!Number.isSafeInteger(refreshReuseGraceSeconds)
|
|
231
|
+
|| refreshReuseGraceSeconds < 0
|
|
232
|
+
|| refreshReuseGraceSeconds > RELYING_PARTY_MAX_REFRESH_REUSE_GRACE_SECONDS
|
|
233
|
+
) {
|
|
234
|
+
throw new Error(`bearer.refreshReuseGraceSeconds must be a whole number from 0 to ${RELYING_PARTY_MAX_REFRESH_REUSE_GRACE_SECONDS}`);
|
|
235
|
+
}
|
|
208
236
|
return {
|
|
209
237
|
returnTargets: normalizeBearerReturnTargets(bearerArg.returnTargets, optionsArg.publicOrigin, optionsArg.loginPathPrefix),
|
|
210
238
|
accessTokenMaxAgeSeconds,
|
|
239
|
+
refreshReuseGraceMs: refreshReuseGraceSeconds * 1_000,
|
|
211
240
|
models: { BearerHandoff, BearerToken },
|
|
212
241
|
};
|
|
213
242
|
};
|
|
@@ -284,6 +313,34 @@ const cookieNameFor = (
|
|
|
284
313
|
return configuredArg;
|
|
285
314
|
};
|
|
286
315
|
|
|
316
|
+
/**
|
|
317
|
+
* The cookie policy as the constructor checked it. Every combination it does
|
|
318
|
+
* not resolve is refused here, never when a cookie is written.
|
|
319
|
+
*/
|
|
320
|
+
const cookiePolicyOf = (policyArg: unknown, secureArg: boolean): Readonly<Required<IRelyingPartyCookiePolicy>> => {
|
|
321
|
+
if (policyArg === undefined) {
|
|
322
|
+
return { sameSite: 'Lax', partitioned: false };
|
|
323
|
+
}
|
|
324
|
+
if (!policyArg || typeof policyArg !== 'object' || Array.isArray(policyArg)) {
|
|
325
|
+
throw new Error('cookiePolicy must be an object');
|
|
326
|
+
}
|
|
327
|
+
const sameSite: unknown = Reflect.get(policyArg, 'sameSite') ?? 'Lax';
|
|
328
|
+
const partitioned: unknown = Reflect.get(policyArg, 'partitioned') ?? false;
|
|
329
|
+
if (sameSite !== 'Lax' && sameSite !== 'None') {
|
|
330
|
+
throw new Error("cookiePolicy.sameSite must be 'Lax' or 'None'");
|
|
331
|
+
}
|
|
332
|
+
if (typeof partitioned !== 'boolean') {
|
|
333
|
+
throw new Error('cookiePolicy.partitioned must be true or false');
|
|
334
|
+
}
|
|
335
|
+
if (sameSite === 'None' && !secureArg) {
|
|
336
|
+
throw new Error("cookiePolicy.sameSite 'None' needs an https publicOrigin: browsers drop a SameSite=None cookie that is not set Secure");
|
|
337
|
+
}
|
|
338
|
+
if (partitioned && sameSite !== 'None') {
|
|
339
|
+
throw new Error("cookiePolicy.partitioned needs sameSite 'None': only a cross-site cookie is partitioned");
|
|
340
|
+
}
|
|
341
|
+
return { sameSite, partitioned };
|
|
342
|
+
};
|
|
343
|
+
|
|
287
344
|
/**
|
|
288
345
|
* The relying-party session layer: provider logins through the authorization
|
|
289
346
|
* code flow with PKCE, HttpOnly cookie sessions with a session-bound CSRF
|
|
@@ -297,10 +354,10 @@ const cookieNameFor = (
|
|
|
297
354
|
* ended sessions are swept one bounded batch at a time when a login starts or
|
|
298
355
|
* the application opens a session, and the only timer — the revocation
|
|
299
356
|
* budget — is unreferenced and cleared on every path. Every session comes
|
|
300
|
-
* from a login start or
|
|
301
|
-
* of at least one first, so removal keeps pace with creation
|
|
302
|
-
* sessions kept stay bounded. Nothing here does I/O while it is
|
|
303
|
-
* so it is built before the database connects.
|
|
357
|
+
* from a login start, `openSession` or `openBearerHandoff`, and each of those
|
|
358
|
+
* sweeps a batch of at least one first, so removal keeps pace with creation
|
|
359
|
+
* and the ended sessions kept stay bounded. Nothing here does I/O while it is
|
|
360
|
+
* constructed, so it is built before the database connects.
|
|
304
361
|
*
|
|
305
362
|
* `TPrincipal` is what a `principals.resolve` answers; with `isActive`
|
|
306
363
|
* principals it stays `undefined`.
|
|
@@ -310,6 +367,8 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
310
367
|
public readonly secureCookies: boolean;
|
|
311
368
|
public readonly sessionCookieName: string;
|
|
312
369
|
public readonly intentCookieName: string;
|
|
370
|
+
/** how the session and intent cookies go across sites, as the constructor resolved `cookiePolicy` */
|
|
371
|
+
public readonly cookiePolicy: Readonly<Required<IRelyingPartyCookiePolicy>>;
|
|
313
372
|
public readonly loginPathPrefix: string;
|
|
314
373
|
public readonly sessionMaxAgeSeconds: number;
|
|
315
374
|
readonly #models: IRelyingPartyModels;
|
|
@@ -339,7 +398,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
339
398
|
throw new Error('csrfKey must be 32 bytes');
|
|
340
399
|
}
|
|
341
400
|
} else {
|
|
342
|
-
for (const name of ['csrfKey', 'csrfLabel', 'cookieNames'] as const) {
|
|
401
|
+
for (const name of ['csrfKey', 'csrfLabel', 'cookieNames', 'cookiePolicy'] as const) {
|
|
343
402
|
if (optionsArg[name] !== undefined) {
|
|
344
403
|
throw new Error(`${name} has no use with cookieSessions: false, which reads and sets no cookie`);
|
|
345
404
|
}
|
|
@@ -367,6 +426,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
367
426
|
this.secureCookies,
|
|
368
427
|
'intent',
|
|
369
428
|
);
|
|
429
|
+
this.cookiePolicy = cookiePolicyOf(optionsArg.cookiePolicy, this.secureCookies);
|
|
370
430
|
this.loginPathPrefix = optionsArg.loginPathPrefix ?? RELYING_PARTY_DEFAULT_LOGIN_PATH_PREFIX;
|
|
371
431
|
if (!this.loginPathPrefix.startsWith('/') || !this.loginPathPrefix.endsWith('/')) {
|
|
372
432
|
throw new Error('loginPathPrefix must start and end with a slash');
|
|
@@ -559,6 +619,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
559
619
|
/**
|
|
560
620
|
* Ends every live session of a principal but `keepSessionId` (a password
|
|
561
621
|
* change keeps the session that asked for it) and answers how many ended.
|
|
622
|
+
* A session that expired is over already and is neither ended nor counted.
|
|
562
623
|
*/
|
|
563
624
|
public async endSessionsOfPrincipal(
|
|
564
625
|
principalIdArg: string,
|
|
@@ -568,6 +629,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
568
629
|
const sessions = await this.#models.Session.getInstances({
|
|
569
630
|
userId: principalIdArg,
|
|
570
631
|
revokedAt: { $exists: false },
|
|
632
|
+
expiresAt: { $gt: this.#now() },
|
|
571
633
|
});
|
|
572
634
|
for (const session of sessions) {
|
|
573
635
|
if (session.id === optionsArg.keepSessionId) {
|
|
@@ -580,6 +642,54 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
580
642
|
return ended;
|
|
581
643
|
}
|
|
582
644
|
|
|
645
|
+
/**
|
|
646
|
+
* Erases every session of a principal, live or ended, and every row that
|
|
647
|
+
* belongs to one, without waiting for a sweep: what an application runs
|
|
648
|
+
* when it deletes an account, after its own erasers and before its user
|
|
649
|
+
* record.
|
|
650
|
+
*
|
|
651
|
+
* First each session not revoked yet, an expired one too, ends as
|
|
652
|
+
* `endSession` ends it: its tokens stop working at once, `onSessionEnded`
|
|
653
|
+
* names the principal, and its provider tokens go back to idp.global,
|
|
654
|
+
* which a deleted row could no longer do. Then each session's rows go: in
|
|
655
|
+
* bearer mode its hand-offs, claimed or not, and its token generations;
|
|
656
|
+
* the login attempt a provider session came out of; and the session row
|
|
657
|
+
* last, because it is the only link from the principal to the others, so
|
|
658
|
+
* an erase that fails half way leaves a retry everything it needs. A
|
|
659
|
+
* session opened or a token issued while the erase runs is not covered;
|
|
660
|
+
* it works only while the principal is active.
|
|
661
|
+
*/
|
|
662
|
+
public async eraseSessionsOfPrincipal(principalIdArg: string): Promise<IRelyingPartySessionsErasure> {
|
|
663
|
+
if (typeof principalIdArg !== 'string' || principalIdArg === '') {
|
|
664
|
+
throw new Error('eraseSessionsOfPrincipal needs a principal id');
|
|
665
|
+
}
|
|
666
|
+
const erasure: IRelyingPartySessionsErasure = {
|
|
667
|
+
endedSessions: 0,
|
|
668
|
+
deletedSessions: 0,
|
|
669
|
+
deletedLoginAttempts: 0,
|
|
670
|
+
deletedBearerHandoffs: 0,
|
|
671
|
+
deletedBearerTokens: 0,
|
|
672
|
+
};
|
|
673
|
+
const sessions = await this.#models.Session.getInstances({ userId: principalIdArg });
|
|
674
|
+
for (const session of sessions) {
|
|
675
|
+
if (session.revokedAt === undefined && (await this.endSession(session.id))) {
|
|
676
|
+
erasure.endedSessions += 1;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
const bearerModels = this.#bearer?.models;
|
|
680
|
+
for (const session of sessions) {
|
|
681
|
+
if (bearerModels) {
|
|
682
|
+
erasure.deletedBearerHandoffs += (await bearerModels.BearerHandoff.atomicDeleteMany({ sessionId: session.id })).deletedCount;
|
|
683
|
+
erasure.deletedBearerTokens += (await bearerModels.BearerToken.atomicDeleteMany({ sessionId: session.id })).deletedCount;
|
|
684
|
+
}
|
|
685
|
+
if (session.loginAttemptId) {
|
|
686
|
+
erasure.deletedLoginAttempts += (await this.#models.LoginAttempt.atomicDelete({ id: session.loginAttemptId })).deletedCount;
|
|
687
|
+
}
|
|
688
|
+
erasure.deletedSessions += (await this.#models.Session.atomicDelete({ id: session.id })).deletedCount;
|
|
689
|
+
}
|
|
690
|
+
return erasure;
|
|
691
|
+
}
|
|
692
|
+
|
|
583
693
|
/**
|
|
584
694
|
* One page of a principal's live sessions, newest first: the ones neither
|
|
585
695
|
* ended nor expired, and none at all while the principal is not active —
|
|
@@ -620,20 +730,12 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
620
730
|
|
|
621
731
|
public clearSessionCookie(): string {
|
|
622
732
|
this.requireCookieSessions();
|
|
623
|
-
return serializeClearedCookie(this.sessionCookieName,
|
|
624
|
-
httpOnly: true,
|
|
625
|
-
secure: this.secureCookies,
|
|
626
|
-
sameSite: 'Lax',
|
|
627
|
-
});
|
|
733
|
+
return serializeClearedCookie(this.sessionCookieName, this.cookieAttributes());
|
|
628
734
|
}
|
|
629
735
|
|
|
630
736
|
public clearIntentCookie(): string {
|
|
631
737
|
this.requireCookieSessions();
|
|
632
|
-
return serializeClearedCookie(this.intentCookieName,
|
|
633
|
-
httpOnly: true,
|
|
634
|
-
secure: this.secureCookies,
|
|
635
|
-
sameSite: 'Lax',
|
|
636
|
-
});
|
|
738
|
+
return serializeClearedCookie(this.intentCookieName, this.cookieAttributes());
|
|
637
739
|
}
|
|
638
740
|
|
|
639
741
|
// ==========================================================================
|
|
@@ -667,9 +769,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
667
769
|
return {
|
|
668
770
|
authorizationUrl,
|
|
669
771
|
intentCookie: serializeCookie(this.intentCookieName, bindingToken, {
|
|
670
|
-
|
|
671
|
-
secure: this.secureCookies,
|
|
672
|
-
sameSite: 'Lax',
|
|
772
|
+
...this.cookieAttributes(),
|
|
673
773
|
maxAgeSeconds: LOGIN_ATTEMPT_MAX_AGE_SECONDS,
|
|
674
774
|
}),
|
|
675
775
|
};
|
|
@@ -876,28 +976,85 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
876
976
|
if (!isPkceChallenge(codeChallenge)) {
|
|
877
977
|
throw new RelyingPartyError('invalid_or_replayed_login');
|
|
878
978
|
}
|
|
879
|
-
|
|
880
|
-
let handoff: RelyingPartyBearerHandoffDoc | undefined;
|
|
979
|
+
let handoff: { code: string; expiresAt: number } | undefined;
|
|
881
980
|
const opened = await this.openProviderLogin(provider, callbackUrlArg, state, attempt, async (sessionArg) => {
|
|
882
|
-
|
|
883
|
-
created.id = 'rbh_' + randomToken(18);
|
|
884
|
-
created.codeHash = sha256(code);
|
|
885
|
-
created.sessionId = sessionArg.id;
|
|
886
|
-
created.codeChallenge = codeChallenge;
|
|
887
|
-
created.createdAt = this.#now();
|
|
888
|
-
created.expiresAt = Math.min(created.createdAt + BEARER_HANDOFF_MAX_AGE_SECONDS * 1_000, sessionArg.expiresAt);
|
|
889
|
-
created.consumedAt = null;
|
|
890
|
-
await created.save();
|
|
891
|
-
handoff = created;
|
|
981
|
+
handoff = await this.storeBearerHandoff(bearer, sessionArg, codeChallenge);
|
|
892
982
|
});
|
|
983
|
+
const stored = handoff as { code: string; expiresAt: number };
|
|
893
984
|
return {
|
|
894
985
|
session: this.snapshot(opened.document),
|
|
895
986
|
delivery,
|
|
896
|
-
code,
|
|
897
|
-
expiresAt:
|
|
987
|
+
code: stored.code,
|
|
988
|
+
expiresAt: stored.expiresAt,
|
|
898
989
|
};
|
|
899
990
|
}
|
|
900
991
|
|
|
992
|
+
/**
|
|
993
|
+
* Opens a bearer session for a device the application pairs to a person
|
|
994
|
+
* who is signed in here, and answers its single-use hand-off code, which
|
|
995
|
+
* the device claims at `bearer/claim` with the verifier of `codeChallenge`,
|
|
996
|
+
* exactly like the code of a bearer login.
|
|
997
|
+
*
|
|
998
|
+
* `openerSessionId` names the session that confirmed the pairing, taken
|
|
999
|
+
* from the application's own record, never from the device. It must be a
|
|
1000
|
+
* live provider login: not ended, not expired, its principal active, and
|
|
1001
|
+
* with a provider login of its own. A session the application opened
|
|
1002
|
+
* itself or one opened here does not qualify, so a paired device never
|
|
1003
|
+
* pairs another one. Anything else throws
|
|
1004
|
+
* `RelyingPartyError('invalid_handoff_opener')`.
|
|
1005
|
+
*
|
|
1006
|
+
* The new session acts as the opener's principal and has no provider login
|
|
1007
|
+
* of its own: it ends by its expiry, `endSession`, `endSessionsOfPrincipal`,
|
|
1008
|
+
* `bearer/logout` or the reuse of one of its credentials, never by an
|
|
1009
|
+
* idp.global logout, and `AuthorityObserver` refuses it
|
|
1010
|
+
* (`authority_login_required`). `maxAgeSeconds` is a whole number from 1 to
|
|
1011
|
+
* `sessionMaxAgeSeconds`, the default, as for `openSession`. The code lives
|
|
1012
|
+
* `BEARER_HANDOFF_MAX_AGE_SECONDS` at most and never past the session, and
|
|
1013
|
+
* only its hash is stored; a code nobody claims ends its session. Nothing
|
|
1014
|
+
* is stored for a refused opening.
|
|
1015
|
+
*/
|
|
1016
|
+
public async openBearerHandoff(optionsArg: {
|
|
1017
|
+
openerSessionId: string;
|
|
1018
|
+
codeChallenge: string;
|
|
1019
|
+
maxAgeSeconds?: number;
|
|
1020
|
+
}): Promise<IRelyingPartyBearerHandoffOpening> {
|
|
1021
|
+
const bearer = this.requireBearer();
|
|
1022
|
+
if (!isPkceChallenge(optionsArg?.codeChallenge)) {
|
|
1023
|
+
throw new RelyingPartyError('invalid_bearer_request');
|
|
1024
|
+
}
|
|
1025
|
+
const codeChallenge = optionsArg.codeChallenge;
|
|
1026
|
+
const maxAgeSeconds = optionsArg.maxAgeSeconds ?? this.sessionMaxAgeSeconds;
|
|
1027
|
+
if (!Number.isSafeInteger(maxAgeSeconds) || maxAgeSeconds < 1 || maxAgeSeconds > this.sessionMaxAgeSeconds) {
|
|
1028
|
+
throw new RangeError(`openBearerHandoff maxAgeSeconds must be a whole number from 1 to ${this.sessionMaxAgeSeconds}`);
|
|
1029
|
+
}
|
|
1030
|
+
await this.sweepBearerState();
|
|
1031
|
+
await this.sweepEndedSessions();
|
|
1032
|
+
// read after the sweeps, so the opener is as live as it can be when the session is created
|
|
1033
|
+
const openerSessionId = optionsArg.openerSessionId;
|
|
1034
|
+
const opener = typeof openerSessionId === 'string' && openerSessionId.length > 0
|
|
1035
|
+
? await this.liveSession(await this.#models.Session.getInstance({ id: openerSessionId }))
|
|
1036
|
+
: null;
|
|
1037
|
+
if (!opener || typeof opener.document.idpSubject !== 'string' || opener.document.idpSubject.length === 0) {
|
|
1038
|
+
throw new RelyingPartyError('invalid_handoff_opener');
|
|
1039
|
+
}
|
|
1040
|
+
// the token is never handed out: the session is reached by its code only, as a bearer login's
|
|
1041
|
+
const session = this.newSessionDoc(randomToken(32), opener.document.userId, maxAgeSeconds);
|
|
1042
|
+
await session.save();
|
|
1043
|
+
let handoff: { code: string; expiresAt: number };
|
|
1044
|
+
try {
|
|
1045
|
+
handoff = await this.storeBearerHandoff(bearer, session, codeChallenge);
|
|
1046
|
+
} catch (error) {
|
|
1047
|
+
// the code never leaves, so the session is unreachable: it is revoked before the error goes on
|
|
1048
|
+
try {
|
|
1049
|
+
await this.discardUnopenedSession(session.id);
|
|
1050
|
+
} catch (discardError) {
|
|
1051
|
+
this.emit({ event: 'session_discard_failed', sessionId: session.id, message: errorMessage(discardError) });
|
|
1052
|
+
}
|
|
1053
|
+
throw error;
|
|
1054
|
+
}
|
|
1055
|
+
return { session: this.snapshot(session), code: handoff.code, expiresAt: handoff.expiresAt };
|
|
1056
|
+
}
|
|
1057
|
+
|
|
901
1058
|
/** `abandonLogin` for a bearer login: the provider came back without a code. */
|
|
902
1059
|
public async abandonBearerLogin(callbackUrlArg: URL): Promise<void> {
|
|
903
1060
|
const state = singleStateOf(callbackUrlArg);
|
|
@@ -950,17 +1107,26 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
950
1107
|
if (!live) {
|
|
951
1108
|
throw new RelyingPartyError('invalid_bearer_handoff');
|
|
952
1109
|
}
|
|
953
|
-
return this.issueBearerTokens(bearer, live.document);
|
|
1110
|
+
return (await this.issueBearerTokens(bearer, live.document)).tokens;
|
|
954
1111
|
}
|
|
955
1112
|
|
|
956
1113
|
/**
|
|
957
1114
|
* Rotates a bearer session's tokens: the refresh token is spent, a new
|
|
958
1115
|
* access and refresh token answer, and the old access token stops working.
|
|
959
|
-
*
|
|
960
|
-
*
|
|
961
|
-
*
|
|
962
|
-
*
|
|
963
|
-
*
|
|
1116
|
+
* A client serializes its refreshes, because a refresh token that comes
|
|
1117
|
+
* back after it was spent ends the whole session, with one exception for
|
|
1118
|
+
* an answer that never reached the client: within
|
|
1119
|
+
* `bearer.refreshReuseGraceSeconds` of its rotation (60 by default), a
|
|
1120
|
+
* spent refresh token whose successor was never presented — neither its
|
|
1121
|
+
* access token nor its refresh token — revokes that successor and answers a
|
|
1122
|
+
* new pair (`bearer_refresh_reissued`). Only hashes are stored, so the lost
|
|
1123
|
+
* pair is replaced, never repeated. Once the successor was presented, after
|
|
1124
|
+
* the window, or for a second retry that loses the race to the first, it is
|
|
1125
|
+
* reuse. The provider login of a bearer login is checked on the way: a
|
|
1126
|
+
* login idp.global ended ends the session here (`invalid_bearer_grant`), an
|
|
1127
|
+
* unreachable provider fails with `AuthorityTokenError('authority_unavailable')`
|
|
1128
|
+
* and spends nothing. A session `openBearerHandoff` opened has no provider
|
|
1129
|
+
* login (no `idpSubject` is stored) and rotates without the provider.
|
|
964
1130
|
*/
|
|
965
1131
|
public async refreshBearerTokens(refreshTokenArg: unknown): Promise<IRelyingPartyBearerTokens> {
|
|
966
1132
|
const bearer = this.requireBearer();
|
|
@@ -973,39 +1139,135 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
973
1139
|
if (!current) {
|
|
974
1140
|
throw new RelyingPartyError('invalid_bearer_grant');
|
|
975
1141
|
}
|
|
976
|
-
|
|
1142
|
+
const lostSuccessor = current.rotatedAt !== null ? await this.lostSuccessorOf(bearer, current) : null;
|
|
1143
|
+
if (current.rotatedAt !== null && !lostSuccessor) {
|
|
977
1144
|
await this.bearerCredentialReused(current.sessionId, 'refresh_token_reused');
|
|
978
1145
|
throw new RelyingPartyError('invalid_bearer_grant');
|
|
979
1146
|
}
|
|
980
1147
|
if (current.expiresAt <= this.#now()) {
|
|
981
1148
|
throw new RelyingPartyError('invalid_bearer_grant');
|
|
982
1149
|
}
|
|
1150
|
+
// presented: from now on its predecessor's refresh token is no lost answer;
|
|
1151
|
+
// one that a lost answer of its predecessor revoked just now is reuse
|
|
1152
|
+
if (!lostSuccessor && !(await this.markBearerGenerationPresented(bearer, current))) {
|
|
1153
|
+
await this.bearerCredentialReused(current.sessionId, 'refresh_token_reused');
|
|
1154
|
+
throw new RelyingPartyError('invalid_bearer_grant');
|
|
1155
|
+
}
|
|
983
1156
|
const live = await this.liveSession(await this.#models.Session.getInstance({ id: current.sessionId }));
|
|
984
1157
|
if (!live) {
|
|
985
1158
|
throw new RelyingPartyError('invalid_bearer_grant');
|
|
986
1159
|
}
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1160
|
+
// decided by what the session stores alone, never by a failure: a session
|
|
1161
|
+
// with a provider subject is a provider login and always asks the provider
|
|
1162
|
+
if (typeof live.document.idpSubject === 'string') {
|
|
1163
|
+
try {
|
|
1164
|
+
await this.liveProviderToken(live.document.id, live.document.userId);
|
|
1165
|
+
} catch (error) {
|
|
1166
|
+
if (error instanceof AuthorityTokenError && error.code === 'authority_login_required') {
|
|
1167
|
+
throw new RelyingPartyError('invalid_bearer_grant');
|
|
1168
|
+
}
|
|
1169
|
+
throw error;
|
|
992
1170
|
}
|
|
993
|
-
|
|
1171
|
+
}
|
|
1172
|
+
if (lostSuccessor) {
|
|
1173
|
+
return this.reissueLostRotation(bearer, current, lostSuccessor, live.document);
|
|
994
1174
|
}
|
|
995
1175
|
// the next generation first: a rotation that fails half way leaves an
|
|
996
1176
|
// unknown generation behind, never a session without a current one
|
|
997
1177
|
const issued = await this.issueBearerTokens(bearer, live.document);
|
|
998
1178
|
const rotated = await bearer.models.BearerToken.atomicUpdate(
|
|
999
1179
|
{ refreshTokenHash, rotatedAt: null },
|
|
1000
|
-
{ $set: { rotatedAt: this.#now() } },
|
|
1180
|
+
{ $set: { rotatedAt: this.#now(), successorId: issued.generationId } },
|
|
1001
1181
|
);
|
|
1002
1182
|
if (rotated.matchedCount !== 1) {
|
|
1003
|
-
// a concurrent refresh spent it first
|
|
1004
|
-
//
|
|
1183
|
+
// a concurrent refresh spent it first, or a lost answer of its
|
|
1184
|
+
// predecessor revoked it; the generation just issued never left this
|
|
1185
|
+
// call and dies with the session
|
|
1005
1186
|
await this.bearerCredentialReused(current.sessionId, 'refresh_token_reused');
|
|
1006
1187
|
throw new RelyingPartyError('invalid_bearer_grant');
|
|
1007
1188
|
}
|
|
1008
|
-
return issued;
|
|
1189
|
+
return issued.tokens;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* The successor of a spent generation when its answer may have been lost:
|
|
1194
|
+
* the rotation is at most `refreshReuseGraceMs` old, and the successor is
|
|
1195
|
+
* still current and was never presented. `null` otherwise, which is reuse.
|
|
1196
|
+
*/
|
|
1197
|
+
private async lostSuccessorOf(
|
|
1198
|
+
bearerArg: IBearerMode,
|
|
1199
|
+
spentArg: RelyingPartyBearerTokenDoc,
|
|
1200
|
+
): Promise<RelyingPartyBearerTokenDoc | null> {
|
|
1201
|
+
if (
|
|
1202
|
+
bearerArg.refreshReuseGraceMs === 0
|
|
1203
|
+
|| spentArg.rotatedAt === null
|
|
1204
|
+
|| typeof spentArg.successorId !== 'string'
|
|
1205
|
+
|| this.#now() - spentArg.rotatedAt > bearerArg.refreshReuseGraceMs
|
|
1206
|
+
) {
|
|
1207
|
+
return null;
|
|
1208
|
+
}
|
|
1209
|
+
const successor = await bearerArg.models.BearerToken.getInstance({ id: spentArg.successorId });
|
|
1210
|
+
if (!successor || successor.sessionId !== spentArg.sessionId || successor.rotatedAt !== null || successor.firstUsedAt !== null) {
|
|
1211
|
+
return null;
|
|
1212
|
+
}
|
|
1213
|
+
return successor;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* Issues a pair again for a spent generation whose answer was lost. The
|
|
1218
|
+
* successor that answer carried is revoked first, by one conditional write
|
|
1219
|
+
* that needs it current and unpresented: of two retries at once exactly one
|
|
1220
|
+
* takes it, and the other is reuse, as is a successor presented in the
|
|
1221
|
+
* meantime. Then the new generation is stored and the spent one names it,
|
|
1222
|
+
* so a retry after this answer is lost too finds it within the same window
|
|
1223
|
+
* (counted from the original rotation). A failure after the revocation
|
|
1224
|
+
* leaves the session without a current generation; its retry is reuse.
|
|
1225
|
+
*/
|
|
1226
|
+
private async reissueLostRotation(
|
|
1227
|
+
bearerArg: IBearerMode,
|
|
1228
|
+
spentArg: RelyingPartyBearerTokenDoc,
|
|
1229
|
+
successorArg: RelyingPartyBearerTokenDoc,
|
|
1230
|
+
sessionArg: RelyingPartySessionDoc,
|
|
1231
|
+
): Promise<IRelyingPartyBearerTokens> {
|
|
1232
|
+
const revoked = await bearerArg.models.BearerToken.atomicUpdate(
|
|
1233
|
+
{ id: successorArg.id, rotatedAt: null, firstUsedAt: null },
|
|
1234
|
+
{ $set: { rotatedAt: this.#now() } },
|
|
1235
|
+
);
|
|
1236
|
+
if (revoked.matchedCount !== 1) {
|
|
1237
|
+
await this.bearerCredentialReused(spentArg.sessionId, 'refresh_token_reused');
|
|
1238
|
+
throw new RelyingPartyError('invalid_bearer_grant');
|
|
1239
|
+
}
|
|
1240
|
+
const issued = await this.issueBearerTokens(bearerArg, sessionArg);
|
|
1241
|
+
// only the call that revoked the successor gets here, so the link is its own to move
|
|
1242
|
+
await bearerArg.models.BearerToken.atomicUpdate(
|
|
1243
|
+
{ id: spentArg.id, successorId: successorArg.id },
|
|
1244
|
+
{ $set: { successorId: issued.generationId } },
|
|
1245
|
+
);
|
|
1246
|
+
this.emit({ event: 'bearer_refresh_reissued', sessionId: spentArg.sessionId });
|
|
1247
|
+
return issued.tokens;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* Records the first presentation of a current generation's access or refresh
|
|
1252
|
+
* token, once. Answers whether the generation is still current: `false`
|
|
1253
|
+
* when a lost answer of its predecessor revoked it just now.
|
|
1254
|
+
*/
|
|
1255
|
+
private async markBearerGenerationPresented(bearerArg: IBearerMode, generationArg: RelyingPartyBearerTokenDoc): Promise<boolean> {
|
|
1256
|
+
if (generationArg.firstUsedAt !== null) {
|
|
1257
|
+
return true;
|
|
1258
|
+
}
|
|
1259
|
+
const now = this.#now();
|
|
1260
|
+
const marked = await bearerArg.models.BearerToken.atomicUpdate(
|
|
1261
|
+
{ id: generationArg.id, rotatedAt: null, firstUsedAt: null },
|
|
1262
|
+
{ $set: { firstUsedAt: now } },
|
|
1263
|
+
);
|
|
1264
|
+
if (marked.matchedCount === 1) {
|
|
1265
|
+
generationArg.firstUsedAt = now;
|
|
1266
|
+
return true;
|
|
1267
|
+
}
|
|
1268
|
+
// another request marked it first, or it was revoked in between
|
|
1269
|
+
const stored = await bearerArg.models.BearerToken.getInstance({ id: generationArg.id });
|
|
1270
|
+
return stored !== null && stored.rotatedAt === null;
|
|
1009
1271
|
}
|
|
1010
1272
|
|
|
1011
1273
|
/**
|
|
@@ -1023,7 +1285,11 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1023
1285
|
return null;
|
|
1024
1286
|
}
|
|
1025
1287
|
const live = await this.liveSession(await this.#models.Session.getInstance({ id: generation.sessionId }));
|
|
1026
|
-
|
|
1288
|
+
// presented: its predecessor's refresh token is no lost answer any more
|
|
1289
|
+
if (!live || !(await this.markBearerGenerationPresented(this.#bearer, generation))) {
|
|
1290
|
+
return null;
|
|
1291
|
+
}
|
|
1292
|
+
return { session: this.snapshot(live.document), principal: live.principal };
|
|
1027
1293
|
}
|
|
1028
1294
|
|
|
1029
1295
|
/** `resolveBearerAccessToken` for an `Authorization: Bearer <token>` header. */
|
|
@@ -1036,7 +1302,9 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1036
1302
|
/**
|
|
1037
1303
|
* Ends the session of a bearer token — a live access token or the current
|
|
1038
1304
|
* refresh token — like `endSession`. A refresh token that was already spent
|
|
1039
|
-
* is reuse and ends the session as such
|
|
1305
|
+
* is reuse and ends the session as such, unless `refreshBearerTokens` would
|
|
1306
|
+
* take it as a lost answer (`lostSuccessorOf`): then it is a logout like the
|
|
1307
|
+
* current one. Answers whether this call ended it.
|
|
1040
1308
|
*/
|
|
1041
1309
|
public async endBearerSession(tokenArg: unknown): Promise<boolean> {
|
|
1042
1310
|
const token = this.#bearer ? bearerCredentialOf(tokenArg) : null;
|
|
@@ -1055,7 +1323,8 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1055
1323
|
if (!byRefreshToken) {
|
|
1056
1324
|
return false;
|
|
1057
1325
|
}
|
|
1058
|
-
|
|
1326
|
+
// the same predicate as the refresh: a sign-out after a lost answer is no theft
|
|
1327
|
+
if (byRefreshToken.rotatedAt !== null && !(await this.lostSuccessorOf(this.#bearer, byRefreshToken))) {
|
|
1059
1328
|
return this.bearerCredentialReused(byRefreshToken.sessionId, 'refresh_token_reused');
|
|
1060
1329
|
}
|
|
1061
1330
|
return this.endSession(byRefreshToken.sessionId, 'logout');
|
|
@@ -1377,6 +1646,16 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1377
1646
|
return session;
|
|
1378
1647
|
}
|
|
1379
1648
|
|
|
1649
|
+
/** The attributes every session and intent cookie is set and cleared with: one policy for all of them. */
|
|
1650
|
+
private cookieAttributes(): Omit<ICookieOptions, 'maxAgeSeconds'> {
|
|
1651
|
+
return {
|
|
1652
|
+
httpOnly: true,
|
|
1653
|
+
secure: this.secureCookies,
|
|
1654
|
+
sameSite: this.cookiePolicy.sameSite,
|
|
1655
|
+
...(this.cookiePolicy.partitioned ? { partitioned: true } : {}),
|
|
1656
|
+
};
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1380
1659
|
private csrfTokenOf(tokenArg: string): string {
|
|
1381
1660
|
const { csrfKey, csrfLabel } = this.requireCookieSessions();
|
|
1382
1661
|
return deriveCsrfToken(tokenArg, csrfKey, csrfLabel);
|
|
@@ -1387,9 +1666,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1387
1666
|
session: this.snapshot(sessionArg),
|
|
1388
1667
|
csrfToken: this.csrfTokenOf(tokenArg),
|
|
1389
1668
|
sessionCookie: serializeCookie(this.sessionCookieName, tokenArg, {
|
|
1390
|
-
|
|
1391
|
-
secure: this.secureCookies,
|
|
1392
|
-
sameSite: 'Lax',
|
|
1669
|
+
...this.cookieAttributes(),
|
|
1393
1670
|
maxAgeSeconds: maxAgeSecondsArg,
|
|
1394
1671
|
}),
|
|
1395
1672
|
};
|
|
@@ -1466,7 +1743,8 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1466
1743
|
}
|
|
1467
1744
|
try {
|
|
1468
1745
|
await beforeOpenedArg?.(session);
|
|
1469
|
-
|
|
1746
|
+
const delivery = this.bearerDeliveryOfAttempt(attemptArg);
|
|
1747
|
+
await this.#hooks.onProviderSessionOpened?.({ session: this.snapshot(session), ...(delivery ? { delivery } : {}) });
|
|
1470
1748
|
} catch (error) {
|
|
1471
1749
|
// the cookie (or the hand-off code) never leaves, so the session is
|
|
1472
1750
|
// unreachable: it is revoked and its sealed tokens erased before the
|
|
@@ -1500,8 +1778,34 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1500
1778
|
};
|
|
1501
1779
|
}
|
|
1502
1780
|
|
|
1503
|
-
/**
|
|
1504
|
-
|
|
1781
|
+
/**
|
|
1782
|
+
* Stores a new hand-off of a session, bound to the client's challenge, and
|
|
1783
|
+
* answers its code, which is stored only as a hash. The code lives
|
|
1784
|
+
* `BEARER_HANDOFF_MAX_AGE_SECONDS` at most and never past the session.
|
|
1785
|
+
*/
|
|
1786
|
+
private async storeBearerHandoff(
|
|
1787
|
+
bearerArg: IBearerMode,
|
|
1788
|
+
sessionArg: RelyingPartySessionDoc,
|
|
1789
|
+
codeChallengeArg: string,
|
|
1790
|
+
): Promise<{ code: string; expiresAt: number }> {
|
|
1791
|
+
const code = randomToken(32);
|
|
1792
|
+
const handoff = new bearerArg.models.BearerHandoff();
|
|
1793
|
+
handoff.id = 'rbh_' + randomToken(18);
|
|
1794
|
+
handoff.codeHash = sha256(code);
|
|
1795
|
+
handoff.sessionId = sessionArg.id;
|
|
1796
|
+
handoff.codeChallenge = codeChallengeArg;
|
|
1797
|
+
handoff.createdAt = this.#now();
|
|
1798
|
+
handoff.expiresAt = Math.min(handoff.createdAt + BEARER_HANDOFF_MAX_AGE_SECONDS * 1_000, sessionArg.expiresAt);
|
|
1799
|
+
handoff.consumedAt = null;
|
|
1800
|
+
await handoff.save();
|
|
1801
|
+
return { code, expiresAt: handoff.expiresAt };
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
/** Stores the next token generation of a live session and answers its tokens and its id; no token is stored in the clear. */
|
|
1805
|
+
private async issueBearerTokens(
|
|
1806
|
+
bearerArg: IBearerMode,
|
|
1807
|
+
sessionArg: RelyingPartySessionDoc,
|
|
1808
|
+
): Promise<{ tokens: IRelyingPartyBearerTokens; generationId: string }> {
|
|
1505
1809
|
const accessToken = randomToken(32);
|
|
1506
1810
|
const refreshToken = randomToken(32);
|
|
1507
1811
|
const now = this.#now();
|
|
@@ -1514,14 +1818,19 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1514
1818
|
generation.accessTokenExpiresAt = Math.min(now + bearerArg.accessTokenMaxAgeSeconds * 1_000, sessionArg.expiresAt);
|
|
1515
1819
|
generation.expiresAt = sessionArg.expiresAt;
|
|
1516
1820
|
generation.rotatedAt = null;
|
|
1821
|
+
generation.firstUsedAt = null;
|
|
1822
|
+
generation.successorId = null;
|
|
1517
1823
|
await generation.save();
|
|
1518
1824
|
return {
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1825
|
+
generationId: generation.id,
|
|
1826
|
+
tokens: {
|
|
1827
|
+
session: this.snapshot(sessionArg),
|
|
1828
|
+
issuedAt: now,
|
|
1829
|
+
accessToken,
|
|
1830
|
+
accessTokenExpiresAt: generation.accessTokenExpiresAt,
|
|
1831
|
+
refreshToken,
|
|
1832
|
+
refreshTokenExpiresAt: generation.expiresAt,
|
|
1833
|
+
},
|
|
1525
1834
|
};
|
|
1526
1835
|
}
|
|
1527
1836
|
|
|
@@ -1591,11 +1900,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1591
1900
|
* local revocation and the provider revocation: a throw or a rejection is
|
|
1592
1901
|
* reported as `hook_failed` and the end runs on.
|
|
1593
1902
|
*/
|
|
1594
|
-
private notifySessionEnded(eventArg: {
|
|
1595
|
-
sessionId: string;
|
|
1596
|
-
reason: TRelyingPartySessionEndReason;
|
|
1597
|
-
revokedHere: boolean;
|
|
1598
|
-
}): void {
|
|
1903
|
+
private notifySessionEnded(eventArg: Parameters<NonNullable<IRelyingPartyHooks['onSessionEnded']>>[0]): void {
|
|
1599
1904
|
const hook = this.#hooks.onSessionEnded;
|
|
1600
1905
|
if (!hook) {
|
|
1601
1906
|
return;
|
|
@@ -1626,10 +1931,11 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1626
1931
|
return null;
|
|
1627
1932
|
}
|
|
1628
1933
|
const before = await this.revokeSessionDocument(sessionIdArg);
|
|
1629
|
-
this.notifySessionEnded({ sessionId: sessionIdArg, reason: reasonArg, revokedHere: before !== null });
|
|
1630
1934
|
if (!before) {
|
|
1935
|
+
this.notifySessionEnded({ sessionId: sessionIdArg, reason: reasonArg, revokedHere: false });
|
|
1631
1936
|
return null;
|
|
1632
1937
|
}
|
|
1938
|
+
this.notifySessionEnded({ sessionId: sessionIdArg, reason: reasonArg, revokedHere: true, principalId: before.userId });
|
|
1633
1939
|
return { sealedAccessToken: before.sealedAccessToken, sealedRefreshToken: before.sealedRefreshToken };
|
|
1634
1940
|
}
|
|
1635
1941
|
|
|
@@ -1806,9 +2112,13 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1806
2112
|
* page per login start and per session the application opens. An ended
|
|
1807
2113
|
* session's expiry is its revocation at the latest, so the expiry index
|
|
1808
2114
|
* finds expired and revoked sessions alike. Each of them ended already: the
|
|
1809
|
-
* row goes without an end, a hook or a provider call.
|
|
1810
|
-
*
|
|
1811
|
-
*
|
|
2115
|
+
* row goes without an end, a hook or a provider call. Its children go
|
|
2116
|
+
* first: in bearer mode its hand-offs and token generations, by their
|
|
2117
|
+
* `sessionId` index, and only then the session row, which is the only link
|
|
2118
|
+
* from a principal to them; a sweep that fails half way leaves the session
|
|
2119
|
+
* row, and the next sweep finds it and deletes the rest. Expiry never moves
|
|
2120
|
+
* later, so a session past the cutoff stays past it, and its row is deleted
|
|
2121
|
+
* only while that still holds. A sweep that cannot run never blocks a
|
|
1812
2122
|
* sign-in.
|
|
1813
2123
|
*/
|
|
1814
2124
|
private async sweepEndedSessions(): Promise<void> {
|
|
@@ -1820,7 +2130,12 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1820
2130
|
sortDirection: 'asc',
|
|
1821
2131
|
limit: LOGIN_ATTEMPT_SWEEP_BATCH,
|
|
1822
2132
|
});
|
|
2133
|
+
const bearerModels = this.#bearer?.models;
|
|
1823
2134
|
for (const session of page.documents) {
|
|
2135
|
+
if (bearerModels) {
|
|
2136
|
+
await bearerModels.BearerHandoff.atomicDeleteMany({ sessionId: session.id });
|
|
2137
|
+
await bearerModels.BearerToken.atomicDeleteMany({ sessionId: session.id });
|
|
2138
|
+
}
|
|
1824
2139
|
await this.#models.Session.atomicDelete({ id: session.id, expiresAt: { $lt: cutoff } });
|
|
1825
2140
|
}
|
|
1826
2141
|
} catch (error) {
|