@idp.global/sdk 19.5.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 +34 -0
- package/dist_ts/00_commitinfo_data.js +1 -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 +67 -12
- package/dist_ts_relyingparty/classes.relyingpartysessions.js +179 -41
- package/dist_ts_relyingparty/classes.rpmodels.d.ts +21 -4
- package/dist_ts_relyingparty/classes.rpmodels.js +35 -8
- 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 +31 -0
- package/dist_ts_relyingparty/testing.js +8 -4
- package/package.json +3 -3
- package/readme.md +132 -32
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts_relyingparty/classes.idpoidcprovider.ts +183 -17
- package/ts_relyingparty/classes.relyingpartysessions.ts +225 -44
- package/ts_relyingparty/classes.rpmodels.ts +27 -4
- package/ts_relyingparty/helpers.cookies.ts +13 -0
- package/ts_relyingparty/interfaces.ts +32 -0
- 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 {
|
|
@@ -45,6 +46,7 @@ import type {
|
|
|
45
46
|
IRelyingPartyBearerOptions,
|
|
46
47
|
IRelyingPartyBearerReturnTargets,
|
|
47
48
|
IRelyingPartyBearerTokens,
|
|
49
|
+
IRelyingPartyCookiePolicy,
|
|
48
50
|
IRelyingPartyEvent,
|
|
49
51
|
IRelyingPartyHooks,
|
|
50
52
|
IRelyingPartyAdmission,
|
|
@@ -87,6 +89,10 @@ const MAX_SESSIONS_CURSOR_LENGTH = 512;
|
|
|
87
89
|
export const BEARER_HANDOFF_MAX_AGE_SECONDS = 60;
|
|
88
90
|
/** How long a bearer access token lives unless `bearer.accessTokenMaxAgeSeconds` says otherwise. */
|
|
89
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;
|
|
90
96
|
/** The codes and tokens of bearer mode are 43 characters; anything far longer is none of them. */
|
|
91
97
|
const MAX_BEARER_TOKEN_LENGTH = 256;
|
|
92
98
|
/**
|
|
@@ -135,6 +141,14 @@ export interface IRelyingPartySessionsOptions<TPrincipal extends object | undefi
|
|
|
135
141
|
session?: string;
|
|
136
142
|
intent?: string;
|
|
137
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;
|
|
138
152
|
/** the prefix of the login routes; a login never returns into it */
|
|
139
153
|
loginPathPrefix?: string;
|
|
140
154
|
/**
|
|
@@ -152,14 +166,15 @@ export interface IRelyingPartySessionsOptions<TPrincipal extends object | undefi
|
|
|
152
166
|
* sign in through bearer hand-off and nothing else. The relying party never
|
|
153
167
|
* reads or sets a cookie — not even the clearing intent cookie of a callback
|
|
154
168
|
* that is no bearer login — so it has no CSRF token and takes no CSRF key:
|
|
155
|
-
* `csrfKey`, `csrfLabel` and `
|
|
169
|
+
* `csrfKey`, `csrfLabel`, `cookieNames` and `cookiePolicy` are refused, not
|
|
170
|
+
* ignored. The
|
|
156
171
|
* cookie steps (`openSession`, `startLogin`, `completeLogin`, `abandonLogin`,
|
|
157
172
|
* `finalizeLogin`, `clearSessionCookie`, `clearIntentCookie`) throw
|
|
158
173
|
* `RelyingPartyError('login_method_unavailable')` and `resolveCookieSession`
|
|
159
174
|
* answers `null`.
|
|
160
175
|
*/
|
|
161
176
|
export interface IRelyingPartyBearerOnlySessionsOptions<TPrincipal extends object | undefined = undefined>
|
|
162
|
-
extends Omit<IRelyingPartySessionsOptions<TPrincipal>, 'provider' | 'csrfKey' | 'csrfLabel' | 'cookieNames' | 'cookieSessions' | 'bearer'> {
|
|
177
|
+
extends Omit<IRelyingPartySessionsOptions<TPrincipal>, 'provider' | 'csrfKey' | 'csrfLabel' | 'cookieNames' | 'cookiePolicy' | 'cookieSessions' | 'bearer'> {
|
|
163
178
|
cookieSessions: false;
|
|
164
179
|
/** every bearer session comes from a live provider login, directly or through a hand-off opened from one */
|
|
165
180
|
provider: IOidcProvider;
|
|
@@ -167,6 +182,7 @@ export interface IRelyingPartyBearerOnlySessionsOptions<TPrincipal extends objec
|
|
|
167
182
|
csrfKey?: never;
|
|
168
183
|
csrfLabel?: never;
|
|
169
184
|
cookieNames?: never;
|
|
185
|
+
cookiePolicy?: never;
|
|
170
186
|
}
|
|
171
187
|
|
|
172
188
|
type TSealedTokens = Pick<RelyingPartySessionDoc, 'sealedAccessToken' | 'sealedRefreshToken'>;
|
|
@@ -181,6 +197,8 @@ interface ICookieSessionsMode {
|
|
|
181
197
|
interface IBearerMode {
|
|
182
198
|
returnTargets: Readonly<Required<IRelyingPartyBearerReturnTargets>>;
|
|
183
199
|
accessTokenMaxAgeSeconds: number;
|
|
200
|
+
/** `0`: a spent refresh token that comes back is always reuse */
|
|
201
|
+
refreshReuseGraceMs: number;
|
|
184
202
|
models: IRelyingPartyBearerModels;
|
|
185
203
|
}
|
|
186
204
|
|
|
@@ -207,9 +225,18 @@ const bearerModeOf = (
|
|
|
207
225
|
) {
|
|
208
226
|
throw new Error(`bearer.accessTokenMaxAgeSeconds must be a whole number from 1 to ${optionsArg.sessionMaxAgeSeconds}`);
|
|
209
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
|
+
}
|
|
210
236
|
return {
|
|
211
237
|
returnTargets: normalizeBearerReturnTargets(bearerArg.returnTargets, optionsArg.publicOrigin, optionsArg.loginPathPrefix),
|
|
212
238
|
accessTokenMaxAgeSeconds,
|
|
239
|
+
refreshReuseGraceMs: refreshReuseGraceSeconds * 1_000,
|
|
213
240
|
models: { BearerHandoff, BearerToken },
|
|
214
241
|
};
|
|
215
242
|
};
|
|
@@ -286,6 +313,34 @@ const cookieNameFor = (
|
|
|
286
313
|
return configuredArg;
|
|
287
314
|
};
|
|
288
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
|
+
|
|
289
344
|
/**
|
|
290
345
|
* The relying-party session layer: provider logins through the authorization
|
|
291
346
|
* code flow with PKCE, HttpOnly cookie sessions with a session-bound CSRF
|
|
@@ -312,6 +367,8 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
312
367
|
public readonly secureCookies: boolean;
|
|
313
368
|
public readonly sessionCookieName: string;
|
|
314
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>>;
|
|
315
372
|
public readonly loginPathPrefix: string;
|
|
316
373
|
public readonly sessionMaxAgeSeconds: number;
|
|
317
374
|
readonly #models: IRelyingPartyModels;
|
|
@@ -341,7 +398,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
341
398
|
throw new Error('csrfKey must be 32 bytes');
|
|
342
399
|
}
|
|
343
400
|
} else {
|
|
344
|
-
for (const name of ['csrfKey', 'csrfLabel', 'cookieNames'] as const) {
|
|
401
|
+
for (const name of ['csrfKey', 'csrfLabel', 'cookieNames', 'cookiePolicy'] as const) {
|
|
345
402
|
if (optionsArg[name] !== undefined) {
|
|
346
403
|
throw new Error(`${name} has no use with cookieSessions: false, which reads and sets no cookie`);
|
|
347
404
|
}
|
|
@@ -369,6 +426,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
369
426
|
this.secureCookies,
|
|
370
427
|
'intent',
|
|
371
428
|
);
|
|
429
|
+
this.cookiePolicy = cookiePolicyOf(optionsArg.cookiePolicy, this.secureCookies);
|
|
372
430
|
this.loginPathPrefix = optionsArg.loginPathPrefix ?? RELYING_PARTY_DEFAULT_LOGIN_PATH_PREFIX;
|
|
373
431
|
if (!this.loginPathPrefix.startsWith('/') || !this.loginPathPrefix.endsWith('/')) {
|
|
374
432
|
throw new Error('loginPathPrefix must start and end with a slash');
|
|
@@ -561,6 +619,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
561
619
|
/**
|
|
562
620
|
* Ends every live session of a principal but `keepSessionId` (a password
|
|
563
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.
|
|
564
623
|
*/
|
|
565
624
|
public async endSessionsOfPrincipal(
|
|
566
625
|
principalIdArg: string,
|
|
@@ -570,6 +629,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
570
629
|
const sessions = await this.#models.Session.getInstances({
|
|
571
630
|
userId: principalIdArg,
|
|
572
631
|
revokedAt: { $exists: false },
|
|
632
|
+
expiresAt: { $gt: this.#now() },
|
|
573
633
|
});
|
|
574
634
|
for (const session of sessions) {
|
|
575
635
|
if (session.id === optionsArg.keepSessionId) {
|
|
@@ -670,20 +730,12 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
670
730
|
|
|
671
731
|
public clearSessionCookie(): string {
|
|
672
732
|
this.requireCookieSessions();
|
|
673
|
-
return serializeClearedCookie(this.sessionCookieName,
|
|
674
|
-
httpOnly: true,
|
|
675
|
-
secure: this.secureCookies,
|
|
676
|
-
sameSite: 'Lax',
|
|
677
|
-
});
|
|
733
|
+
return serializeClearedCookie(this.sessionCookieName, this.cookieAttributes());
|
|
678
734
|
}
|
|
679
735
|
|
|
680
736
|
public clearIntentCookie(): string {
|
|
681
737
|
this.requireCookieSessions();
|
|
682
|
-
return serializeClearedCookie(this.intentCookieName,
|
|
683
|
-
httpOnly: true,
|
|
684
|
-
secure: this.secureCookies,
|
|
685
|
-
sameSite: 'Lax',
|
|
686
|
-
});
|
|
738
|
+
return serializeClearedCookie(this.intentCookieName, this.cookieAttributes());
|
|
687
739
|
}
|
|
688
740
|
|
|
689
741
|
// ==========================================================================
|
|
@@ -717,9 +769,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
717
769
|
return {
|
|
718
770
|
authorizationUrl,
|
|
719
771
|
intentCookie: serializeCookie(this.intentCookieName, bindingToken, {
|
|
720
|
-
|
|
721
|
-
secure: this.secureCookies,
|
|
722
|
-
sameSite: 'Lax',
|
|
772
|
+
...this.cookieAttributes(),
|
|
723
773
|
maxAgeSeconds: LOGIN_ATTEMPT_MAX_AGE_SECONDS,
|
|
724
774
|
}),
|
|
725
775
|
};
|
|
@@ -1057,16 +1107,23 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1057
1107
|
if (!live) {
|
|
1058
1108
|
throw new RelyingPartyError('invalid_bearer_handoff');
|
|
1059
1109
|
}
|
|
1060
|
-
return this.issueBearerTokens(bearer, live.document);
|
|
1110
|
+
return (await this.issueBearerTokens(bearer, live.document)).tokens;
|
|
1061
1111
|
}
|
|
1062
1112
|
|
|
1063
1113
|
/**
|
|
1064
1114
|
* Rotates a bearer session's tokens: the refresh token is spent, a new
|
|
1065
1115
|
* access and refresh token answer, and the old access token stops working.
|
|
1066
|
-
*
|
|
1067
|
-
*
|
|
1068
|
-
*
|
|
1069
|
-
*
|
|
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
|
|
1070
1127
|
* unreachable provider fails with `AuthorityTokenError('authority_unavailable')`
|
|
1071
1128
|
* and spends nothing. A session `openBearerHandoff` opened has no provider
|
|
1072
1129
|
* login (no `idpSubject` is stored) and rotates without the provider.
|
|
@@ -1082,13 +1139,20 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1082
1139
|
if (!current) {
|
|
1083
1140
|
throw new RelyingPartyError('invalid_bearer_grant');
|
|
1084
1141
|
}
|
|
1085
|
-
|
|
1142
|
+
const lostSuccessor = current.rotatedAt !== null ? await this.lostSuccessorOf(bearer, current) : null;
|
|
1143
|
+
if (current.rotatedAt !== null && !lostSuccessor) {
|
|
1086
1144
|
await this.bearerCredentialReused(current.sessionId, 'refresh_token_reused');
|
|
1087
1145
|
throw new RelyingPartyError('invalid_bearer_grant');
|
|
1088
1146
|
}
|
|
1089
1147
|
if (current.expiresAt <= this.#now()) {
|
|
1090
1148
|
throw new RelyingPartyError('invalid_bearer_grant');
|
|
1091
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
|
+
}
|
|
1092
1156
|
const live = await this.liveSession(await this.#models.Session.getInstance({ id: current.sessionId }));
|
|
1093
1157
|
if (!live) {
|
|
1094
1158
|
throw new RelyingPartyError('invalid_bearer_grant');
|
|
@@ -1105,20 +1169,105 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1105
1169
|
throw error;
|
|
1106
1170
|
}
|
|
1107
1171
|
}
|
|
1172
|
+
if (lostSuccessor) {
|
|
1173
|
+
return this.reissueLostRotation(bearer, current, lostSuccessor, live.document);
|
|
1174
|
+
}
|
|
1108
1175
|
// the next generation first: a rotation that fails half way leaves an
|
|
1109
1176
|
// unknown generation behind, never a session without a current one
|
|
1110
1177
|
const issued = await this.issueBearerTokens(bearer, live.document);
|
|
1111
1178
|
const rotated = await bearer.models.BearerToken.atomicUpdate(
|
|
1112
1179
|
{ refreshTokenHash, rotatedAt: null },
|
|
1113
|
-
{ $set: { rotatedAt: this.#now() } },
|
|
1180
|
+
{ $set: { rotatedAt: this.#now(), successorId: issued.generationId } },
|
|
1114
1181
|
);
|
|
1115
1182
|
if (rotated.matchedCount !== 1) {
|
|
1116
|
-
// a concurrent refresh spent it first
|
|
1117
|
-
//
|
|
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
|
|
1118
1186
|
await this.bearerCredentialReused(current.sessionId, 'refresh_token_reused');
|
|
1119
1187
|
throw new RelyingPartyError('invalid_bearer_grant');
|
|
1120
1188
|
}
|
|
1121
|
-
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;
|
|
1122
1271
|
}
|
|
1123
1272
|
|
|
1124
1273
|
/**
|
|
@@ -1136,7 +1285,11 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1136
1285
|
return null;
|
|
1137
1286
|
}
|
|
1138
1287
|
const live = await this.liveSession(await this.#models.Session.getInstance({ id: generation.sessionId }));
|
|
1139
|
-
|
|
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 };
|
|
1140
1293
|
}
|
|
1141
1294
|
|
|
1142
1295
|
/** `resolveBearerAccessToken` for an `Authorization: Bearer <token>` header. */
|
|
@@ -1149,7 +1302,9 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1149
1302
|
/**
|
|
1150
1303
|
* Ends the session of a bearer token — a live access token or the current
|
|
1151
1304
|
* refresh token — like `endSession`. A refresh token that was already spent
|
|
1152
|
-
* 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.
|
|
1153
1308
|
*/
|
|
1154
1309
|
public async endBearerSession(tokenArg: unknown): Promise<boolean> {
|
|
1155
1310
|
const token = this.#bearer ? bearerCredentialOf(tokenArg) : null;
|
|
@@ -1168,7 +1323,8 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1168
1323
|
if (!byRefreshToken) {
|
|
1169
1324
|
return false;
|
|
1170
1325
|
}
|
|
1171
|
-
|
|
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))) {
|
|
1172
1328
|
return this.bearerCredentialReused(byRefreshToken.sessionId, 'refresh_token_reused');
|
|
1173
1329
|
}
|
|
1174
1330
|
return this.endSession(byRefreshToken.sessionId, 'logout');
|
|
@@ -1490,6 +1646,16 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1490
1646
|
return session;
|
|
1491
1647
|
}
|
|
1492
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
|
+
|
|
1493
1659
|
private csrfTokenOf(tokenArg: string): string {
|
|
1494
1660
|
const { csrfKey, csrfLabel } = this.requireCookieSessions();
|
|
1495
1661
|
return deriveCsrfToken(tokenArg, csrfKey, csrfLabel);
|
|
@@ -1500,9 +1666,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1500
1666
|
session: this.snapshot(sessionArg),
|
|
1501
1667
|
csrfToken: this.csrfTokenOf(tokenArg),
|
|
1502
1668
|
sessionCookie: serializeCookie(this.sessionCookieName, tokenArg, {
|
|
1503
|
-
|
|
1504
|
-
secure: this.secureCookies,
|
|
1505
|
-
sameSite: 'Lax',
|
|
1669
|
+
...this.cookieAttributes(),
|
|
1506
1670
|
maxAgeSeconds: maxAgeSecondsArg,
|
|
1507
1671
|
}),
|
|
1508
1672
|
};
|
|
@@ -1637,8 +1801,11 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1637
1801
|
return { code, expiresAt: handoff.expiresAt };
|
|
1638
1802
|
}
|
|
1639
1803
|
|
|
1640
|
-
/** Stores the next token generation of a live session and answers its tokens;
|
|
1641
|
-
private async issueBearerTokens(
|
|
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 }> {
|
|
1642
1809
|
const accessToken = randomToken(32);
|
|
1643
1810
|
const refreshToken = randomToken(32);
|
|
1644
1811
|
const now = this.#now();
|
|
@@ -1651,14 +1818,19 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1651
1818
|
generation.accessTokenExpiresAt = Math.min(now + bearerArg.accessTokenMaxAgeSeconds * 1_000, sessionArg.expiresAt);
|
|
1652
1819
|
generation.expiresAt = sessionArg.expiresAt;
|
|
1653
1820
|
generation.rotatedAt = null;
|
|
1821
|
+
generation.firstUsedAt = null;
|
|
1822
|
+
generation.successorId = null;
|
|
1654
1823
|
await generation.save();
|
|
1655
1824
|
return {
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
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
|
+
},
|
|
1662
1834
|
};
|
|
1663
1835
|
}
|
|
1664
1836
|
|
|
@@ -1940,9 +2112,13 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1940
2112
|
* page per login start and per session the application opens. An ended
|
|
1941
2113
|
* session's expiry is its revocation at the latest, so the expiry index
|
|
1942
2114
|
* finds expired and revoked sessions alike. Each of them ended already: the
|
|
1943
|
-
* row goes without an end, a hook or a provider call.
|
|
1944
|
-
*
|
|
1945
|
-
*
|
|
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
|
|
1946
2122
|
* sign-in.
|
|
1947
2123
|
*/
|
|
1948
2124
|
private async sweepEndedSessions(): Promise<void> {
|
|
@@ -1954,7 +2130,12 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1954
2130
|
sortDirection: 'asc',
|
|
1955
2131
|
limit: LOGIN_ATTEMPT_SWEEP_BATCH,
|
|
1956
2132
|
});
|
|
2133
|
+
const bearerModels = this.#bearer?.models;
|
|
1957
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
|
+
}
|
|
1958
2139
|
await this.#models.Session.atomicDelete({ id: session.id, expiresAt: { $lt: cutoff } });
|
|
1959
2140
|
}
|
|
1960
2141
|
} catch (error) {
|
|
@@ -129,7 +129,9 @@ export class RelyingPartyLoginAttemptDoc extends plugins.nosqldb.SmartDataDbDoc<
|
|
|
129
129
|
/**
|
|
130
130
|
* The single-use code a bearer login hands its client. Only the hash of the
|
|
131
131
|
* code is stored; the code is bound to the client's S256 challenge, lives at
|
|
132
|
-
* most a minute and is consumed by one conditional update.
|
|
132
|
+
* most a minute and is consumed by one conditional update. The `sessionId`
|
|
133
|
+
* index is what the session sweep and the erase delete a session's hand-offs
|
|
134
|
+
* by.
|
|
133
135
|
*/
|
|
134
136
|
export class RelyingPartyBearerHandoffDoc extends plugins.nosqldb.SmartDataDbDoc<RelyingPartyBearerHandoffDoc, RelyingPartyBearerHandoffDoc> {
|
|
135
137
|
@plugins.nosqldb.unI()
|
|
@@ -141,6 +143,7 @@ export class RelyingPartyBearerHandoffDoc extends plugins.nosqldb.SmartDataDbDoc
|
|
|
141
143
|
|
|
142
144
|
/** the session the login opened; the code is its only way out */
|
|
143
145
|
@plugins.nosqldb.svDb()
|
|
146
|
+
@plugins.nosqldb.index()
|
|
144
147
|
public sessionId: string = '';
|
|
145
148
|
|
|
146
149
|
@plugins.nosqldb.svDb()
|
|
@@ -163,8 +166,9 @@ export class RelyingPartyBearerHandoffDoc extends plugins.nosqldb.SmartDataDbDoc
|
|
|
163
166
|
* One generation of a bearer session's tokens: an access token and the
|
|
164
167
|
* refresh token that replaces both. Only hashes are stored. Exactly one
|
|
165
168
|
* generation of a session is current (`rotatedAt` `null`); a rotated one is
|
|
166
|
-
* kept until
|
|
167
|
-
* recognized as reuse.
|
|
169
|
+
* kept until its session is swept, so its refresh token coming back is
|
|
170
|
+
* recognized as reuse. The `sessionId` index is what the session sweep and
|
|
171
|
+
* the erase delete a session's generations by.
|
|
168
172
|
*/
|
|
169
173
|
export class RelyingPartyBearerTokenDoc extends plugins.nosqldb.SmartDataDbDoc<RelyingPartyBearerTokenDoc, RelyingPartyBearerTokenDoc> {
|
|
170
174
|
@plugins.nosqldb.unI()
|
|
@@ -177,6 +181,7 @@ export class RelyingPartyBearerTokenDoc extends plugins.nosqldb.SmartDataDbDoc<R
|
|
|
177
181
|
public refreshTokenHash: string = '';
|
|
178
182
|
|
|
179
183
|
@plugins.nosqldb.svDb()
|
|
184
|
+
@plugins.nosqldb.index()
|
|
180
185
|
public sessionId: string = '';
|
|
181
186
|
|
|
182
187
|
@plugins.nosqldb.svDb()
|
|
@@ -190,9 +195,27 @@ export class RelyingPartyBearerTokenDoc extends plugins.nosqldb.SmartDataDbDoc<R
|
|
|
190
195
|
@plugins.nosqldb.index()
|
|
191
196
|
public expiresAt: number = 0;
|
|
192
197
|
|
|
193
|
-
/** `null` while this generation is current; when its refresh token was spent otherwise */
|
|
198
|
+
/** `null` while this generation is current; when its refresh token was spent, or the generation revoked, otherwise */
|
|
194
199
|
@plugins.nosqldb.svDb()
|
|
195
200
|
public rotatedAt: number | null = null;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* When its access token or its refresh token was first presented; `null`
|
|
204
|
+
* until then (and on a generation stored before 19.6.0). A rotated
|
|
205
|
+
* generation whose successor was never presented may come back within the
|
|
206
|
+
* reuse grace window as a lost answer.
|
|
207
|
+
*/
|
|
208
|
+
@plugins.nosqldb.svDb()
|
|
209
|
+
public firstUsedAt: number | null = null;
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The generation that replaced this one: set by its rotation and moved to
|
|
213
|
+
* the new one when a lost answer is issued again. `null` on a current
|
|
214
|
+
* generation, on one that was revoked unpresented, and on one stored before
|
|
215
|
+
* 19.6.0.
|
|
216
|
+
*/
|
|
217
|
+
@plugins.nosqldb.svDb()
|
|
218
|
+
public successorId: string | null = null;
|
|
196
219
|
}
|
|
197
220
|
|
|
198
221
|
export const RELYING_PARTY_DEFAULT_COLLECTION_NAMES = {
|
|
@@ -3,6 +3,12 @@ export interface ICookieOptions {
|
|
|
3
3
|
maxAgeSeconds?: number;
|
|
4
4
|
sameSite?: 'Lax' | 'Strict' | 'None';
|
|
5
5
|
secure?: boolean;
|
|
6
|
+
/**
|
|
7
|
+
* `Partitioned` (CHIPS): the browser keeps the cookie per top-level site, so
|
|
8
|
+
* a page framed on another site gets a cookie jar of its own there. Needs
|
|
9
|
+
* `secure`; a cookie set partitioned is also cleared partitioned.
|
|
10
|
+
*/
|
|
11
|
+
partitioned?: boolean;
|
|
6
12
|
}
|
|
7
13
|
|
|
8
14
|
// Control characters, space, DEL and the cookie delimiters are never valid.
|
|
@@ -60,6 +66,13 @@ export const serializeCookie = (
|
|
|
60
66
|
if (optionsArg.sameSite) {
|
|
61
67
|
parts.push('SameSite=' + optionsArg.sameSite);
|
|
62
68
|
}
|
|
69
|
+
if (optionsArg.partitioned) {
|
|
70
|
+
if (!optionsArg.secure) {
|
|
71
|
+
// browsers drop a partitioned cookie that is not set Secure
|
|
72
|
+
throw new Error('A Partitioned cookie needs Secure');
|
|
73
|
+
}
|
|
74
|
+
parts.push('Partitioned');
|
|
75
|
+
}
|
|
63
76
|
if (optionsArg.maxAgeSeconds !== undefined) {
|
|
64
77
|
if (!Number.isSafeInteger(optionsArg.maxAgeSeconds) || optionsArg.maxAgeSeconds < 0) {
|
|
65
78
|
throw new Error('Invalid cookie max age');
|
|
@@ -45,6 +45,27 @@ export interface IResolvedRelyingPartySession<TPrincipal extends object | undefi
|
|
|
45
45
|
csrfToken: string;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* How the session cookie and the login's intent cookie are sent across
|
|
50
|
+
* sites; one policy for both, on every cookie they are set and cleared with.
|
|
51
|
+
*/
|
|
52
|
+
export interface IRelyingPartyCookiePolicy {
|
|
53
|
+
/**
|
|
54
|
+
* `Lax`, the default: the browser sends the cookies with this origin's own
|
|
55
|
+
* requests and with a top-level navigation to it, never into a frame on
|
|
56
|
+
* another site. `None`: also for a page of this origin framed on another
|
|
57
|
+
* site; it needs an https `publicOrigin`, because a `SameSite=None` cookie
|
|
58
|
+
* must be `Secure`.
|
|
59
|
+
*/
|
|
60
|
+
sameSite?: 'Lax' | 'None';
|
|
61
|
+
/**
|
|
62
|
+
* `Partitioned` (CHIPS): the browser keeps the cookies per top-level site,
|
|
63
|
+
* so the framed application has a jar of its own under each site that frames
|
|
64
|
+
* it. Only with `sameSite: 'None'`. `false` by default.
|
|
65
|
+
*/
|
|
66
|
+
partitioned?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
48
69
|
/** A session just opened: the answer, the CSRF token and the `Set-Cookie` value that carries it. */
|
|
49
70
|
export interface IRelyingPartySessionOpening {
|
|
50
71
|
session: IRelyingPartySession;
|
|
@@ -119,6 +140,15 @@ export interface IRelyingPartyBearerOptions {
|
|
|
119
140
|
returnTargets: IRelyingPartyBearerReturnTargets;
|
|
120
141
|
/** how long an access token lives, in seconds; 900 by default, never past its session */
|
|
121
142
|
accessTokenMaxAgeSeconds?: number;
|
|
143
|
+
/**
|
|
144
|
+
* How long after a rotation its spent refresh token may come back as a lost
|
|
145
|
+
* answer instead of reuse, in whole seconds: 60 by default, 0 turns it off,
|
|
146
|
+
* at most 300. Within it, a spent refresh token whose successor was never
|
|
147
|
+
* presented (neither its access token nor its refresh token) revokes that
|
|
148
|
+
* successor and answers a new pair; once the successor was presented, or
|
|
149
|
+
* after the window, it is reuse and ends the session.
|
|
150
|
+
*/
|
|
151
|
+
refreshReuseGraceSeconds?: number;
|
|
122
152
|
}
|
|
123
153
|
|
|
124
154
|
/** What a bearer client sends to start its login. Every field is checked as untrusted input. */
|
|
@@ -310,6 +340,8 @@ export type TRelyingPartyEventName =
|
|
|
310
340
|
| 'session_sweep_failed'
|
|
311
341
|
/** a bearer hand-off code or refresh token came back after its use; `reason` names which, the session ended */
|
|
312
342
|
| 'bearer_credential_reused'
|
|
343
|
+
/** a spent refresh token came back within `refreshReuseGraceSeconds` before its successor was presented: the successor was revoked and a new pair issued */
|
|
344
|
+
| 'bearer_refresh_reissued'
|
|
313
345
|
/** an application hook threw; the step it belonged to ran on regardless */
|
|
314
346
|
| 'hook_failed'
|
|
315
347
|
/** a session whose opening failed could not be revoked; its provider tokens went back anyway */
|