@idp.global/sdk 19.2.1 → 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 +58 -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 +11 -1
- package/dist_ts_relyingparty/classes.errors.js +6 -1
- package/dist_ts_relyingparty/classes.relyingpartyroutes.d.ts +74 -2
- package/dist_ts_relyingparty/classes.relyingpartyroutes.js +289 -14
- package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +209 -7
- package/dist_ts_relyingparty/classes.relyingpartysessions.js +734 -87
- package/dist_ts_relyingparty/classes.rpmodels.d.ts +71 -8
- package/dist_ts_relyingparty/classes.rpmodels.js +224 -10
- 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 +48 -0
- package/dist_ts_relyingparty/helpers.bearer.js +199 -0
- package/dist_ts_relyingparty/index.d.ts +1 -0
- package/dist_ts_relyingparty/index.js +2 -1
- package/dist_ts_relyingparty/interfaces.d.ts +122 -2
- package/dist_ts_relyingparty/testing.d.ts +22 -0
- package/dist_ts_relyingparty/testing.js +42 -1
- package/package.json +9 -11
- package/readme.md +353 -8
- 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 +16 -1
- package/ts_relyingparty/classes.relyingpartyroutes.ts +340 -16
- package/ts_relyingparty/classes.relyingpartysessions.ts +907 -94
- package/ts_relyingparty/classes.rpmodels.ts +131 -9
- package/ts_relyingparty/contract.bearer.ts +99 -0
- package/ts_relyingparty/helpers.bearer.ts +259 -0
- package/ts_relyingparty/index.ts +1 -0
- package/ts_relyingparty/interfaces.ts +144 -2
- package/ts_relyingparty/testing.ts +60 -0
|
@@ -14,13 +14,36 @@ import {
|
|
|
14
14
|
type IOidcRefreshedTokens,
|
|
15
15
|
type TOidcTokenHint,
|
|
16
16
|
} from './classes.idpoidcprovider.js';
|
|
17
|
-
import type {
|
|
17
|
+
import type {
|
|
18
|
+
IRelyingPartyBearerModels,
|
|
19
|
+
IRelyingPartyModels,
|
|
20
|
+
RelyingPartyBearerHandoffDoc,
|
|
21
|
+
RelyingPartyLoginAttemptDoc,
|
|
22
|
+
RelyingPartySessionDoc,
|
|
23
|
+
} from './classes.rpmodels.js';
|
|
24
|
+
import {
|
|
25
|
+
isBearerClientKind,
|
|
26
|
+
isBearerClientState,
|
|
27
|
+
isBearerReturnTargetAllowed,
|
|
28
|
+
isPkceChallenge,
|
|
29
|
+
isPkceVerifier,
|
|
30
|
+
normalizeBearerReturnTargets,
|
|
31
|
+
parseBearerAuthorization,
|
|
32
|
+
verifyPkceChallenge,
|
|
33
|
+
} from './helpers.bearer.js';
|
|
18
34
|
import { parseCookies, serializeClearedCookie, serializeCookie } from './helpers.cookies.js';
|
|
19
35
|
import { deriveCsrfToken, randomToken, sha256, timingSafeEqualString } from './helpers.crypto.js';
|
|
20
36
|
import { sanitizeReturnPath } from './helpers.returnpath.js';
|
|
21
37
|
import type {
|
|
22
38
|
IAuthorityTokenSource,
|
|
23
39
|
IProviderToken,
|
|
40
|
+
IRelyingPartyBearerDelivery,
|
|
41
|
+
IRelyingPartyBearerHandoff,
|
|
42
|
+
IRelyingPartyBearerLoginRequest,
|
|
43
|
+
IRelyingPartyBearerLoginStart,
|
|
44
|
+
IRelyingPartyBearerOptions,
|
|
45
|
+
IRelyingPartyBearerReturnTargets,
|
|
46
|
+
IRelyingPartyBearerTokens,
|
|
24
47
|
IRelyingPartyEvent,
|
|
25
48
|
IRelyingPartyHooks,
|
|
26
49
|
IRelyingPartyAdmission,
|
|
@@ -29,6 +52,8 @@ import type {
|
|
|
29
52
|
IRelyingPartyLoginStart,
|
|
30
53
|
IRelyingPartySession,
|
|
31
54
|
IRelyingPartySessionOpening,
|
|
55
|
+
IRelyingPartySessionsPage,
|
|
56
|
+
IRelyingPartySessionsPageOptions,
|
|
32
57
|
IResolvedRelyingPartySession,
|
|
33
58
|
TLoginCallbackStage,
|
|
34
59
|
TRelyingPartyPrincipals,
|
|
@@ -46,10 +71,22 @@ const SESSION_TOUCH_INTERVAL_MS = 60_000;
|
|
|
46
71
|
export const LOGIN_ATTEMPT_MAX_AGE_SECONDS = 10 * 60;
|
|
47
72
|
/** The longest intent a login carries (`IRelyingPartyLoginOptions.intent`), in characters. */
|
|
48
73
|
export const RELYING_PARTY_MAX_INTENT_LENGTH = 4_096;
|
|
49
|
-
/** Attempts are swept once they are this much older than their expiry. */
|
|
74
|
+
/** Attempts — and bearer hand-offs, token generations and sessions — are swept once they are this much older than their expiry. */
|
|
50
75
|
const LOGIN_ATTEMPT_SWEEP_GRACE_MS = 60 * 60 * 1_000;
|
|
51
76
|
/** How many of them one sign-in removes; the next sign-in takes the following page. */
|
|
52
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;
|
|
84
|
+
/** How long a bearer hand-off code can be claimed, at most; never past its session. */
|
|
85
|
+
export const BEARER_HANDOFF_MAX_AGE_SECONDS = 60;
|
|
86
|
+
/** How long a bearer access token lives unless `bearer.accessTokenMaxAgeSeconds` says otherwise. */
|
|
87
|
+
export const RELYING_PARTY_DEFAULT_BEARER_ACCESS_TOKEN_MAX_AGE_SECONDS = 15 * 60;
|
|
88
|
+
/** The codes and tokens of bearer mode are 43 characters; anything far longer is none of them. */
|
|
89
|
+
const MAX_BEARER_TOKEN_LENGTH = 256;
|
|
53
90
|
/**
|
|
54
91
|
* How long ending a session waits for the provider to take its tokens back.
|
|
55
92
|
* The local session is already revoked when that call runs.
|
|
@@ -78,6 +115,12 @@ export interface IRelyingPartySessionsOptions<TPrincipal extends object | undefi
|
|
|
78
115
|
sessionEncryptionKey: Buffer;
|
|
79
116
|
/** 32 bytes; the CSRF HMAC key */
|
|
80
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;
|
|
81
124
|
/** the label the CSRF HMAC starts with; an application with live sessions keeps its own */
|
|
82
125
|
csrfLabel?: string;
|
|
83
126
|
sessionMaxAgeSeconds?: number;
|
|
@@ -92,13 +135,130 @@ export interface IRelyingPartySessionsOptions<TPrincipal extends object | undefi
|
|
|
92
135
|
};
|
|
93
136
|
/** the prefix of the login routes; a login never returns into it */
|
|
94
137
|
loginPathPrefix?: string;
|
|
138
|
+
/**
|
|
139
|
+
* absent: no bearer hand-off mode. Needs `provider` and the bearer models
|
|
140
|
+
* (`createRelyingPartyModels` binds them).
|
|
141
|
+
*/
|
|
142
|
+
bearer?: IRelyingPartyBearerOptions;
|
|
95
143
|
hooks?: IRelyingPartyHooks;
|
|
96
144
|
/** the clock every comparison reads; tests step it instead of sleeping */
|
|
97
145
|
now?: () => number;
|
|
98
146
|
}
|
|
99
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
|
+
|
|
100
170
|
type TSealedTokens = Pick<RelyingPartySessionDoc, 'sealedAccessToken' | 'sealedRefreshToken'>;
|
|
101
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
|
+
|
|
178
|
+
/** Bearer hand-off mode as the constructor checked it. */
|
|
179
|
+
interface IBearerMode {
|
|
180
|
+
returnTargets: Readonly<Required<IRelyingPartyBearerReturnTargets>>;
|
|
181
|
+
accessTokenMaxAgeSeconds: number;
|
|
182
|
+
models: IRelyingPartyBearerModels;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const bearerModeOf = (
|
|
186
|
+
bearerArg: IRelyingPartyBearerOptions,
|
|
187
|
+
optionsArg: { models: IRelyingPartyModels; hasProvider: boolean; publicOrigin: string; loginPathPrefix: string; sessionMaxAgeSeconds: number },
|
|
188
|
+
): IBearerMode => {
|
|
189
|
+
if (!bearerArg || typeof bearerArg !== 'object') {
|
|
190
|
+
throw new Error('bearer must be an object with returnTargets');
|
|
191
|
+
}
|
|
192
|
+
if (!optionsArg.hasProvider) {
|
|
193
|
+
throw new Error('bearer needs a provider: every bearer session is a provider login');
|
|
194
|
+
}
|
|
195
|
+
const { BearerHandoff, BearerToken } = optionsArg.models;
|
|
196
|
+
if (!BearerHandoff || !BearerToken) {
|
|
197
|
+
throw new Error('bearer needs the BearerHandoff and BearerToken models; createRelyingPartyModels binds them');
|
|
198
|
+
}
|
|
199
|
+
const accessTokenMaxAgeSeconds = bearerArg.accessTokenMaxAgeSeconds
|
|
200
|
+
?? Math.min(RELYING_PARTY_DEFAULT_BEARER_ACCESS_TOKEN_MAX_AGE_SECONDS, optionsArg.sessionMaxAgeSeconds);
|
|
201
|
+
if (
|
|
202
|
+
!Number.isSafeInteger(accessTokenMaxAgeSeconds)
|
|
203
|
+
|| accessTokenMaxAgeSeconds < 1
|
|
204
|
+
|| accessTokenMaxAgeSeconds > optionsArg.sessionMaxAgeSeconds
|
|
205
|
+
) {
|
|
206
|
+
throw new Error(`bearer.accessTokenMaxAgeSeconds must be a whole number from 1 to ${optionsArg.sessionMaxAgeSeconds}`);
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
returnTargets: normalizeBearerReturnTargets(bearerArg.returnTargets, optionsArg.publicOrigin, optionsArg.loginPathPrefix),
|
|
210
|
+
accessTokenMaxAgeSeconds,
|
|
211
|
+
models: { BearerHandoff, BearerToken },
|
|
212
|
+
};
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/** The one `state` of a callback, or `null` when it carries none or several. */
|
|
216
|
+
const singleStateOf = (callbackUrlArg: URL): string | null => {
|
|
217
|
+
const states = callbackUrlArg.searchParams.getAll('state');
|
|
218
|
+
return states.length === 1 && states[0] ? states[0] : null;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
/** A code or token of bearer mode as a client presents it, or `null` when it cannot be one. */
|
|
222
|
+
const bearerCredentialOf = (valueArg: unknown): string | null =>
|
|
223
|
+
typeof valueArg === 'string' && valueArg.length > 0 && valueArg.length <= MAX_BEARER_TOKEN_LENGTH ? valueArg : null;
|
|
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
|
+
|
|
102
262
|
const REVOKED_TOKEN_FIELDS = { sealedAccessToken: '', sealedRefreshToken: '', refreshLockUntil: 0 };
|
|
103
263
|
|
|
104
264
|
const errorMessage = (errorArg: unknown): string => (errorArg instanceof Error ? errorArg.message : String(errorArg));
|
|
@@ -130,9 +290,16 @@ const cookieNameFor = (
|
|
|
130
290
|
* token, provider tokens sealed at rest and rotated under a cross-replica
|
|
131
291
|
* lock, and session ends that hand the tokens back.
|
|
132
292
|
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
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,
|
|
136
303
|
* so it is built before the database connects.
|
|
137
304
|
*
|
|
138
305
|
* `TPrincipal` is what a `principals.resolve` answers; with `isActive`
|
|
@@ -150,21 +317,36 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
150
317
|
/** the principal of a session when it may act right now, `null` otherwise */
|
|
151
318
|
readonly #activePrincipal: (principalIdArg: string) => Promise<{ principal: TPrincipal } | null>;
|
|
152
319
|
readonly #provider?: IOidcProvider;
|
|
320
|
+
readonly #bearer?: IBearerMode;
|
|
153
321
|
readonly #secretBox: SecretBox;
|
|
154
|
-
readonly #
|
|
155
|
-
readonly #csrfLabel: string;
|
|
322
|
+
readonly #cookieSessions: ICookieSessionsMode | null;
|
|
156
323
|
readonly #hooks: IRelyingPartyHooks;
|
|
157
324
|
readonly #now: () => number;
|
|
158
325
|
/** one rotation per session in this process, however many requests need it at once */
|
|
159
326
|
readonly #refreshesInFlight = new Map<string, Promise<IProviderToken>>();
|
|
160
327
|
|
|
161
|
-
constructor(optionsArg: IRelyingPartySessionsOptions<TPrincipal>) {
|
|
328
|
+
constructor(optionsArg: IRelyingPartySessionsOptions<TPrincipal> | IRelyingPartyBearerOnlySessionsOptions<TPrincipal>) {
|
|
162
329
|
const origin = new URL(optionsArg.publicOrigin);
|
|
163
330
|
if (origin.origin !== optionsArg.publicOrigin) {
|
|
164
331
|
throw new Error('publicOrigin must be a bare origin such as https://app.example');
|
|
165
332
|
}
|
|
166
|
-
|
|
167
|
-
|
|
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
|
+
}
|
|
168
350
|
}
|
|
169
351
|
const maxAge = optionsArg.sessionMaxAgeSeconds ?? RELYING_PARTY_DEFAULT_SESSION_MAX_AGE_SECONDS;
|
|
170
352
|
if (!Number.isSafeInteger(maxAge) || maxAge < 1) {
|
|
@@ -220,9 +402,19 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
220
402
|
: null;
|
|
221
403
|
}
|
|
222
404
|
this.#provider = optionsArg.provider;
|
|
405
|
+
if (optionsArg.bearer !== undefined) {
|
|
406
|
+
this.#bearer = bearerModeOf(optionsArg.bearer, {
|
|
407
|
+
models: optionsArg.models,
|
|
408
|
+
hasProvider: optionsArg.provider !== undefined,
|
|
409
|
+
publicOrigin: this.publicOrigin,
|
|
410
|
+
loginPathPrefix: this.loginPathPrefix,
|
|
411
|
+
sessionMaxAgeSeconds: maxAge,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
223
414
|
this.#secretBox = new SecretBox(optionsArg.sessionEncryptionKey);
|
|
224
|
-
this.#
|
|
225
|
-
|
|
415
|
+
this.#cookieSessions = cookieSessions
|
|
416
|
+
? { csrfKey: Buffer.from(optionsArg.csrfKey), csrfLabel: optionsArg.csrfLabel ?? RELYING_PARTY_DEFAULT_CSRF_LABEL }
|
|
417
|
+
: null;
|
|
226
418
|
this.#hooks = optionsArg.hooks ?? {};
|
|
227
419
|
this.#now = optionsArg.now ?? (() => Date.now());
|
|
228
420
|
}
|
|
@@ -232,6 +424,21 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
232
424
|
return this.#provider !== undefined;
|
|
233
425
|
}
|
|
234
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
|
+
|
|
432
|
+
/** Whether bearer hand-off mode is offered. */
|
|
433
|
+
public get bearerLoginEnabled(): boolean {
|
|
434
|
+
return this.#bearer !== undefined;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** The return targets bearer clients may name, per kind, as configured; `undefined` without bearer mode. */
|
|
438
|
+
public get bearerReturnTargets(): Readonly<Required<IRelyingPartyBearerReturnTargets>> | undefined {
|
|
439
|
+
return this.#bearer?.returnTargets;
|
|
440
|
+
}
|
|
441
|
+
|
|
235
442
|
/**
|
|
236
443
|
* How long one replica holds the right to rotate a session's tokens. The
|
|
237
444
|
* provider detects reuse of a superseded refresh token and kills the family,
|
|
@@ -253,6 +460,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
253
460
|
* cookie with it; it is a whole number of seconds from 1 to that maximum.
|
|
254
461
|
*/
|
|
255
462
|
public async openSession(optionsArg: { principalId: string; maxAgeSeconds?: number }): Promise<IRelyingPartySessionOpening> {
|
|
463
|
+
this.requireCookieSessions();
|
|
256
464
|
if (typeof optionsArg?.principalId !== 'string' || optionsArg.principalId.length === 0) {
|
|
257
465
|
throw new Error('openSession needs a principal id');
|
|
258
466
|
}
|
|
@@ -260,16 +468,24 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
260
468
|
if (!Number.isSafeInteger(maxAgeSeconds) || maxAgeSeconds < 1 || maxAgeSeconds > this.sessionMaxAgeSeconds) {
|
|
261
469
|
throw new RangeError(`openSession maxAgeSeconds must be a whole number from 1 to ${this.sessionMaxAgeSeconds}`);
|
|
262
470
|
}
|
|
471
|
+
await this.sweepEndedSessions();
|
|
263
472
|
const token = randomToken(32);
|
|
264
473
|
const session = this.newSessionDoc(token, optionsArg.principalId, maxAgeSeconds);
|
|
265
474
|
await session.save();
|
|
266
475
|
return this.opening(session, token, maxAgeSeconds);
|
|
267
476
|
}
|
|
268
477
|
|
|
269
|
-
/**
|
|
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
|
+
*/
|
|
270
483
|
public async resolveCookieSession(
|
|
271
484
|
cookieHeaderArg: string | null | undefined,
|
|
272
485
|
): Promise<IResolvedRelyingPartySession<TPrincipal> | null> {
|
|
486
|
+
if (!this.#cookieSessions) {
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
273
489
|
const token = parseCookies(cookieHeaderArg).get(this.sessionCookieName);
|
|
274
490
|
if (!token || token.length > MAX_COOKIE_TOKEN_LENGTH) {
|
|
275
491
|
return null;
|
|
@@ -364,7 +580,46 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
364
580
|
return ended;
|
|
365
581
|
}
|
|
366
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
|
+
|
|
367
621
|
public clearSessionCookie(): string {
|
|
622
|
+
this.requireCookieSessions();
|
|
368
623
|
return serializeClearedCookie(this.sessionCookieName, {
|
|
369
624
|
httpOnly: true,
|
|
370
625
|
secure: this.secureCookies,
|
|
@@ -373,6 +628,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
373
628
|
}
|
|
374
629
|
|
|
375
630
|
public clearIntentCookie(): string {
|
|
631
|
+
this.requireCookieSessions();
|
|
376
632
|
return serializeClearedCookie(this.intentCookieName, {
|
|
377
633
|
httpOnly: true,
|
|
378
634
|
secure: this.secureCookies,
|
|
@@ -390,35 +646,22 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
390
646
|
* browser.
|
|
391
647
|
*/
|
|
392
648
|
public async startLogin(returnPathArg: unknown, optionsArg: IRelyingPartyLoginOptions = {}): Promise<IRelyingPartyLoginStart> {
|
|
649
|
+
this.requireCookieSessions();
|
|
393
650
|
const provider = this.requireProvider();
|
|
394
|
-
|
|
395
|
-
if (intent !== undefined && (typeof intent !== 'string' || intent.length === 0 || intent.length > RELYING_PARTY_MAX_INTENT_LENGTH)) {
|
|
396
|
-
throw new RangeError(`a login intent is text of 1 to ${RELYING_PARTY_MAX_INTENT_LENGTH} characters`);
|
|
397
|
-
}
|
|
398
|
-
if (prompt !== undefined && !OIDC_PROMPTS.includes(prompt)) {
|
|
399
|
-
throw new RangeError(`a login prompt is one of ${OIDC_PROMPTS.join(', ')}`);
|
|
400
|
-
}
|
|
651
|
+
this.checkLoginOptions(optionsArg);
|
|
401
652
|
await this.sweepExpiredAttempts();
|
|
402
|
-
|
|
403
|
-
const nonce = randomToken(32);
|
|
404
|
-
const pkceVerifier = randomToken(32);
|
|
653
|
+
await this.sweepEndedSessions();
|
|
405
654
|
const bindingToken = randomToken(32);
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
const attempt = new this.#models.LoginAttempt();
|
|
409
|
-
attempt.id = 'lga_' + randomToken(18);
|
|
410
|
-
attempt.stateHash = sha256(state);
|
|
655
|
+
const { attempt, state, nonce, pkceVerifier } = this.newLoginAttempt(optionsArg.intent);
|
|
411
656
|
attempt.bindingTokenHash = sha256(bindingToken);
|
|
412
|
-
attempt.sealedPkceVerifier = this.#secretBox.seal(pkceVerifier);
|
|
413
|
-
attempt.nonce = nonce;
|
|
414
657
|
attempt.returnPath = sanitizeReturnPath(returnPathArg, this.publicOrigin, this.loginPathPrefix);
|
|
415
|
-
if (intent !== undefined) {
|
|
416
|
-
attempt.sealedIntent = this.#secretBox.seal(intent);
|
|
417
|
-
}
|
|
418
|
-
attempt.createdAt = now;
|
|
419
|
-
attempt.expiresAt = now + LOGIN_ATTEMPT_MAX_AGE_SECONDS * 1_000;
|
|
420
658
|
// the URL first: a login the provider cannot start yet (before discovery) stores no attempt
|
|
421
|
-
const authorizationUrl = await provider.buildAuthorizationUrl({
|
|
659
|
+
const authorizationUrl = await provider.buildAuthorizationUrl({
|
|
660
|
+
state,
|
|
661
|
+
nonce,
|
|
662
|
+
pkceVerifier,
|
|
663
|
+
...(optionsArg.prompt ? { prompt: optionsArg.prompt } : {}),
|
|
664
|
+
});
|
|
422
665
|
await attempt.save();
|
|
423
666
|
|
|
424
667
|
return {
|
|
@@ -443,6 +686,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
443
686
|
callbackUrlArg: URL,
|
|
444
687
|
cookieHeaderArg: string | null | undefined,
|
|
445
688
|
): Promise<IRelyingPartySessionOpening> {
|
|
689
|
+
this.requireCookieSessions();
|
|
446
690
|
const provider = this.requireProvider();
|
|
447
691
|
const states = callbackUrlArg.searchParams.getAll('state');
|
|
448
692
|
if (states.length !== 1 || !states[0]) {
|
|
@@ -468,35 +712,8 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
468
712
|
throw new RelyingPartyError('invalid_or_replayed_login');
|
|
469
713
|
}
|
|
470
714
|
const attempt = claimed.document as RelyingPartyLoginAttemptDoc;
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
let stage: TLoginCallbackStage = 'token_exchange';
|
|
474
|
-
try {
|
|
475
|
-
completion = await provider.completeAuthorization({
|
|
476
|
-
callbackUrl: callbackUrlArg,
|
|
477
|
-
state: states[0],
|
|
478
|
-
nonce: attempt.nonce,
|
|
479
|
-
pkceVerifier: this.#secretBox.open(attempt.sealedPkceVerifier),
|
|
480
|
-
});
|
|
481
|
-
stage = 'principal_admission';
|
|
482
|
-
const { principalId } = await this.#admit(completion, {
|
|
483
|
-
loginAttemptId: attempt.id,
|
|
484
|
-
...(attempt.sealedIntent ? { intent: this.#secretBox.open(attempt.sealedIntent) } : {}),
|
|
485
|
-
});
|
|
486
|
-
stage = 'session_persistence';
|
|
487
|
-
return await this.createProviderSession(attempt, principalId, completion);
|
|
488
|
-
} catch (error) {
|
|
489
|
-
if (completion) {
|
|
490
|
-
await this.revokeCompletionTokens(completion);
|
|
491
|
-
}
|
|
492
|
-
if (error instanceof RelyingPartyError || error instanceof RelyingPartyRefusal) {
|
|
493
|
-
throw error;
|
|
494
|
-
}
|
|
495
|
-
if (error instanceof OidcCompletionError) {
|
|
496
|
-
throw new LoginCallbackError(error.stage, error);
|
|
497
|
-
}
|
|
498
|
-
throw new LoginCallbackError(stage, error);
|
|
499
|
-
}
|
|
715
|
+
const opened = await this.openProviderLogin(provider, callbackUrlArg, states[0], attempt);
|
|
716
|
+
return this.opening(opened.document, opened.token);
|
|
500
717
|
}
|
|
501
718
|
|
|
502
719
|
/**
|
|
@@ -505,6 +722,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
505
722
|
* matches no open attempt leaves nothing to do.
|
|
506
723
|
*/
|
|
507
724
|
public async abandonLogin(callbackUrlArg: URL, cookieHeaderArg: string | null | undefined): Promise<void> {
|
|
725
|
+
this.requireCookieSessions();
|
|
508
726
|
const states = callbackUrlArg.searchParams.getAll('state');
|
|
509
727
|
const bindingToken = parseCookies(cookieHeaderArg).get(this.intentCookieName);
|
|
510
728
|
if (
|
|
@@ -527,6 +745,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
527
745
|
* session this very attempt created.
|
|
528
746
|
*/
|
|
529
747
|
public async finalizeLogin(cookieHeaderArg: string | null | undefined): Promise<string> {
|
|
748
|
+
this.requireCookieSessions();
|
|
530
749
|
this.requireProvider();
|
|
531
750
|
const bindingToken = parseCookies(cookieHeaderArg).get(this.intentCookieName);
|
|
532
751
|
if (!bindingToken || bindingToken.length > MAX_COOKIE_TOKEN_LENGTH) {
|
|
@@ -553,6 +772,295 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
553
772
|
return sanitizeReturnPath(attempt.returnPath, this.publicOrigin, this.loginPathPrefix);
|
|
554
773
|
}
|
|
555
774
|
|
|
775
|
+
// ==========================================================================
|
|
776
|
+
// bearer hand-off
|
|
777
|
+
// ==========================================================================
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Starts a bearer login for one of the application's own clients: the same
|
|
781
|
+
* provider login as `startLogin`, but the outcome goes to the return target
|
|
782
|
+
* the client named — exactly one of the configured targets of its kind —
|
|
783
|
+
* and it sets no cookie. The login is bound to the client's S256 challenge
|
|
784
|
+
* instead of a browser: whoever claims its hand-off code has to present the
|
|
785
|
+
* verifier. Every field of the request is checked as untrusted input.
|
|
786
|
+
*/
|
|
787
|
+
public async startBearerLogin(
|
|
788
|
+
requestArg: IRelyingPartyBearerLoginRequest,
|
|
789
|
+
optionsArg: IRelyingPartyLoginOptions = {},
|
|
790
|
+
): Promise<IRelyingPartyBearerLoginStart> {
|
|
791
|
+
const provider = this.requireProvider();
|
|
792
|
+
const bearer = this.requireBearer();
|
|
793
|
+
const request: Partial<Record<keyof IRelyingPartyBearerLoginRequest, unknown>> | null =
|
|
794
|
+
requestArg && typeof requestArg === 'object' ? requestArg : null;
|
|
795
|
+
if (
|
|
796
|
+
!request
|
|
797
|
+
|| !isBearerClientKind(request.clientKind)
|
|
798
|
+
|| typeof request.returnTarget !== 'string'
|
|
799
|
+
|| !isPkceChallenge(request.codeChallenge)
|
|
800
|
+
|| request.codeChallengeMethod !== 'S256'
|
|
801
|
+
|| (request.state !== undefined && !isBearerClientState(request.state))
|
|
802
|
+
) {
|
|
803
|
+
throw new RelyingPartyError('invalid_bearer_request');
|
|
804
|
+
}
|
|
805
|
+
if (!isBearerReturnTargetAllowed(bearer.returnTargets, request.clientKind, request.returnTarget)) {
|
|
806
|
+
throw new RelyingPartyError('return_target_not_allowed');
|
|
807
|
+
}
|
|
808
|
+
this.checkLoginOptions(optionsArg);
|
|
809
|
+
await this.sweepExpiredAttempts();
|
|
810
|
+
await this.sweepBearerState();
|
|
811
|
+
await this.sweepEndedSessions();
|
|
812
|
+
const { attempt, state, nonce, pkceVerifier } = this.newLoginAttempt(optionsArg.intent);
|
|
813
|
+
// a binding token that never leaves: no cookie callback can consume this attempt
|
|
814
|
+
attempt.bindingTokenHash = sha256(randomToken(32));
|
|
815
|
+
attempt.bearerClientKind = request.clientKind;
|
|
816
|
+
attempt.bearerReturnTarget = request.returnTarget;
|
|
817
|
+
attempt.bearerCodeChallenge = request.codeChallenge;
|
|
818
|
+
if (isBearerClientState(request.state)) {
|
|
819
|
+
attempt.bearerClientState = request.state;
|
|
820
|
+
}
|
|
821
|
+
const authorizationUrl = await provider.buildAuthorizationUrl({
|
|
822
|
+
state,
|
|
823
|
+
nonce,
|
|
824
|
+
pkceVerifier,
|
|
825
|
+
...(optionsArg.prompt ? { prompt: optionsArg.prompt } : {}),
|
|
826
|
+
});
|
|
827
|
+
await attempt.save();
|
|
828
|
+
return { authorizationUrl };
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Where the outcome of a login callback goes when it belongs to a bearer
|
|
833
|
+
* login: the client kind, the return target and the client's `state`, read
|
|
834
|
+
* without consuming anything. `null` for a cookie login, an unknown state,
|
|
835
|
+
* and a return target that is no longer configured — the callback is then
|
|
836
|
+
* not answered as a bearer login at all.
|
|
837
|
+
*/
|
|
838
|
+
public async bearerDeliveryOf(callbackUrlArg: URL): Promise<IRelyingPartyBearerDelivery | null> {
|
|
839
|
+
const state = this.#bearer ? singleStateOf(callbackUrlArg) : null;
|
|
840
|
+
if (!state) {
|
|
841
|
+
return null;
|
|
842
|
+
}
|
|
843
|
+
const attempt = await this.#models.LoginAttempt.getInstance({ stateHash: sha256(state) });
|
|
844
|
+
return attempt ? this.bearerDeliveryOfAttempt(attempt) : null;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Consumes a bearer login's attempt exactly once, exchanges the code, admits
|
|
849
|
+
* the principal and opens its session — everything `completeLogin` does,
|
|
850
|
+
* with the same failures — and answers a single-use hand-off code bound to
|
|
851
|
+
* the client's challenge. The code lives `BEARER_HANDOFF_MAX_AGE_SECONDS`
|
|
852
|
+
* at most and only its hash is stored. A code nobody claims ends its session.
|
|
853
|
+
*/
|
|
854
|
+
public async completeBearerLogin(callbackUrlArg: URL): Promise<IRelyingPartyBearerHandoff> {
|
|
855
|
+
const provider = this.requireProvider();
|
|
856
|
+
const bearer = this.requireBearer();
|
|
857
|
+
const state = singleStateOf(callbackUrlArg);
|
|
858
|
+
if (!state) {
|
|
859
|
+
throw new RelyingPartyError('invalid_login_callback');
|
|
860
|
+
}
|
|
861
|
+
const now = this.#now();
|
|
862
|
+
const claimed = await this.#models.LoginAttempt.atomicFindOneAndUpdate(
|
|
863
|
+
{ stateHash: sha256(state), consumedAt: null, expiresAt: { $gt: now }, bearerClientKind: { $exists: true } },
|
|
864
|
+
{ $set: { consumedAt: now } },
|
|
865
|
+
{ returnDocument: 'after' },
|
|
866
|
+
);
|
|
867
|
+
if (claimed.status !== 'matched' || !claimed.document) {
|
|
868
|
+
throw new RelyingPartyError('invalid_or_replayed_login');
|
|
869
|
+
}
|
|
870
|
+
const attempt = claimed.document as RelyingPartyLoginAttemptDoc;
|
|
871
|
+
const delivery = this.bearerDeliveryOfAttempt(attempt);
|
|
872
|
+
if (!delivery) {
|
|
873
|
+
throw new RelyingPartyError('return_target_not_allowed');
|
|
874
|
+
}
|
|
875
|
+
const codeChallenge = attempt.bearerCodeChallenge;
|
|
876
|
+
if (!isPkceChallenge(codeChallenge)) {
|
|
877
|
+
throw new RelyingPartyError('invalid_or_replayed_login');
|
|
878
|
+
}
|
|
879
|
+
const code = randomToken(32);
|
|
880
|
+
let handoff: RelyingPartyBearerHandoffDoc | undefined;
|
|
881
|
+
const opened = await this.openProviderLogin(provider, callbackUrlArg, state, attempt, async (sessionArg) => {
|
|
882
|
+
const created = new bearer.models.BearerHandoff();
|
|
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;
|
|
892
|
+
});
|
|
893
|
+
return {
|
|
894
|
+
session: this.snapshot(opened.document),
|
|
895
|
+
delivery,
|
|
896
|
+
code,
|
|
897
|
+
expiresAt: (handoff as RelyingPartyBearerHandoffDoc).expiresAt,
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/** `abandonLogin` for a bearer login: the provider came back without a code. */
|
|
902
|
+
public async abandonBearerLogin(callbackUrlArg: URL): Promise<void> {
|
|
903
|
+
const state = singleStateOf(callbackUrlArg);
|
|
904
|
+
if (!state) {
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
await this.#models.LoginAttempt.atomicUpdate(
|
|
908
|
+
{ stateHash: sha256(state), consumedAt: null, bearerClientKind: { $exists: true } },
|
|
909
|
+
{ $set: { consumedAt: this.#now() } },
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Trades a hand-off code and the verifier of its challenge for the first
|
|
915
|
+
* access and refresh token of the session. The code is consumed by the first
|
|
916
|
+
* claim whatever its verifier: a wrong verifier ends the session, and so
|
|
917
|
+
* does the code coming back after it was claimed (RFC 6749 §4.1.2). A code
|
|
918
|
+
* that expired unclaimed ends its session too.
|
|
919
|
+
*/
|
|
920
|
+
public async claimBearerHandoff(codeArg: unknown, codeVerifierArg: unknown): Promise<IRelyingPartyBearerTokens> {
|
|
921
|
+
const bearer = this.requireBearer();
|
|
922
|
+
const code = bearerCredentialOf(codeArg);
|
|
923
|
+
if (!code || !isPkceVerifier(codeVerifierArg)) {
|
|
924
|
+
throw new RelyingPartyError('invalid_bearer_request');
|
|
925
|
+
}
|
|
926
|
+
const codeHash = sha256(code);
|
|
927
|
+
const now = this.#now();
|
|
928
|
+
const claimed = await bearer.models.BearerHandoff.atomicFindOneAndUpdate(
|
|
929
|
+
{ codeHash, consumedAt: null, expiresAt: { $gt: now } },
|
|
930
|
+
{ $set: { consumedAt: now } },
|
|
931
|
+
{ returnDocument: 'after' },
|
|
932
|
+
);
|
|
933
|
+
if (claimed.status !== 'matched' || !claimed.document) {
|
|
934
|
+
const known = await bearer.models.BearerHandoff.getInstance({ codeHash });
|
|
935
|
+
if (known?.consumedAt === null) {
|
|
936
|
+
await this.endUnclaimedHandoff(known);
|
|
937
|
+
} else if (known && known.consumedAt !== null && known.consumedAt < known.expiresAt) {
|
|
938
|
+
// a claim took it while it was valid; one taken after its expiry was
|
|
939
|
+
// ended unclaimed, and coming back late is no reuse
|
|
940
|
+
await this.bearerCredentialReused(known.sessionId, 'handoff_code_reused');
|
|
941
|
+
}
|
|
942
|
+
throw new RelyingPartyError('invalid_bearer_handoff');
|
|
943
|
+
}
|
|
944
|
+
const handoff = claimed.document as RelyingPartyBearerHandoffDoc;
|
|
945
|
+
if (!verifyPkceChallenge(codeVerifierArg, handoff.codeChallenge)) {
|
|
946
|
+
await this.endSession(handoff.sessionId, 'handoff_verifier_mismatch');
|
|
947
|
+
throw new RelyingPartyError('invalid_bearer_handoff');
|
|
948
|
+
}
|
|
949
|
+
const live = await this.liveSession(await this.#models.Session.getInstance({ id: handoff.sessionId }));
|
|
950
|
+
if (!live) {
|
|
951
|
+
throw new RelyingPartyError('invalid_bearer_handoff');
|
|
952
|
+
}
|
|
953
|
+
return this.issueBearerTokens(bearer, live.document);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Rotates a bearer session's tokens: the refresh token is spent, a new
|
|
958
|
+
* access and refresh token answer, and the old access token stops working.
|
|
959
|
+
* Rotation is strict — a client serializes its refreshes — because a
|
|
960
|
+
* refresh token that comes back after it was spent ends the whole session.
|
|
961
|
+
* The provider login is checked on the way: a login idp.global ended ends
|
|
962
|
+
* the session here (`invalid_bearer_grant`), an unreachable provider fails
|
|
963
|
+
* with `AuthorityTokenError('authority_unavailable')` and spends nothing.
|
|
964
|
+
*/
|
|
965
|
+
public async refreshBearerTokens(refreshTokenArg: unknown): Promise<IRelyingPartyBearerTokens> {
|
|
966
|
+
const bearer = this.requireBearer();
|
|
967
|
+
const refreshToken = bearerCredentialOf(refreshTokenArg);
|
|
968
|
+
if (!refreshToken) {
|
|
969
|
+
throw new RelyingPartyError('invalid_bearer_grant');
|
|
970
|
+
}
|
|
971
|
+
const refreshTokenHash = sha256(refreshToken);
|
|
972
|
+
const current = await bearer.models.BearerToken.getInstance({ refreshTokenHash });
|
|
973
|
+
if (!current) {
|
|
974
|
+
throw new RelyingPartyError('invalid_bearer_grant');
|
|
975
|
+
}
|
|
976
|
+
if (current.rotatedAt !== null) {
|
|
977
|
+
await this.bearerCredentialReused(current.sessionId, 'refresh_token_reused');
|
|
978
|
+
throw new RelyingPartyError('invalid_bearer_grant');
|
|
979
|
+
}
|
|
980
|
+
if (current.expiresAt <= this.#now()) {
|
|
981
|
+
throw new RelyingPartyError('invalid_bearer_grant');
|
|
982
|
+
}
|
|
983
|
+
const live = await this.liveSession(await this.#models.Session.getInstance({ id: current.sessionId }));
|
|
984
|
+
if (!live) {
|
|
985
|
+
throw new RelyingPartyError('invalid_bearer_grant');
|
|
986
|
+
}
|
|
987
|
+
try {
|
|
988
|
+
await this.liveProviderToken(live.document.id, live.document.userId);
|
|
989
|
+
} catch (error) {
|
|
990
|
+
if (error instanceof AuthorityTokenError && error.code === 'authority_login_required') {
|
|
991
|
+
throw new RelyingPartyError('invalid_bearer_grant');
|
|
992
|
+
}
|
|
993
|
+
throw error;
|
|
994
|
+
}
|
|
995
|
+
// the next generation first: a rotation that fails half way leaves an
|
|
996
|
+
// unknown generation behind, never a session without a current one
|
|
997
|
+
const issued = await this.issueBearerTokens(bearer, live.document);
|
|
998
|
+
const rotated = await bearer.models.BearerToken.atomicUpdate(
|
|
999
|
+
{ refreshTokenHash, rotatedAt: null },
|
|
1000
|
+
{ $set: { rotatedAt: this.#now() } },
|
|
1001
|
+
);
|
|
1002
|
+
if (rotated.matchedCount !== 1) {
|
|
1003
|
+
// a concurrent refresh spent it first; the generation just issued never
|
|
1004
|
+
// left this call and dies with the session
|
|
1005
|
+
await this.bearerCredentialReused(current.sessionId, 'refresh_token_reused');
|
|
1006
|
+
throw new RelyingPartyError('invalid_bearer_grant');
|
|
1007
|
+
}
|
|
1008
|
+
return issued;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* The live session behind a bearer access token, or `null`: the token of the
|
|
1013
|
+
* session's current generation, not expired, its session live and its
|
|
1014
|
+
* principal active. A read touches the session at most once a minute.
|
|
1015
|
+
*/
|
|
1016
|
+
public async resolveBearerAccessToken(accessTokenArg: unknown): Promise<IRelyingPartyLiveSession<TPrincipal> | null> {
|
|
1017
|
+
const accessToken = this.#bearer ? bearerCredentialOf(accessTokenArg) : null;
|
|
1018
|
+
if (!this.#bearer || !accessToken) {
|
|
1019
|
+
return null;
|
|
1020
|
+
}
|
|
1021
|
+
const generation = await this.#bearer.models.BearerToken.getInstance({ accessTokenHash: sha256(accessToken) });
|
|
1022
|
+
if (!generation || generation.rotatedAt !== null || generation.accessTokenExpiresAt <= this.#now()) {
|
|
1023
|
+
return null;
|
|
1024
|
+
}
|
|
1025
|
+
const live = await this.liveSession(await this.#models.Session.getInstance({ id: generation.sessionId }));
|
|
1026
|
+
return live ? { session: this.snapshot(live.document), principal: live.principal } : null;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/** `resolveBearerAccessToken` for an `Authorization: Bearer <token>` header. */
|
|
1030
|
+
public async resolveBearerSession(
|
|
1031
|
+
authorizationHeaderArg: string | null | undefined,
|
|
1032
|
+
): Promise<IRelyingPartyLiveSession<TPrincipal> | null> {
|
|
1033
|
+
return this.resolveBearerAccessToken(parseBearerAuthorization(authorizationHeaderArg));
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* Ends the session of a bearer token — a live access token or the current
|
|
1038
|
+
* refresh token — like `endSession`. A refresh token that was already spent
|
|
1039
|
+
* is reuse and ends the session as such. Answers whether this call ended it.
|
|
1040
|
+
*/
|
|
1041
|
+
public async endBearerSession(tokenArg: unknown): Promise<boolean> {
|
|
1042
|
+
const token = this.#bearer ? bearerCredentialOf(tokenArg) : null;
|
|
1043
|
+
if (!this.#bearer || !token) {
|
|
1044
|
+
return false;
|
|
1045
|
+
}
|
|
1046
|
+
const tokenHash = sha256(token);
|
|
1047
|
+
const byAccessToken = await this.#bearer.models.BearerToken.getInstance({ accessTokenHash: tokenHash });
|
|
1048
|
+
if (byAccessToken) {
|
|
1049
|
+
if (byAccessToken.rotatedAt !== null || byAccessToken.accessTokenExpiresAt <= this.#now()) {
|
|
1050
|
+
return false;
|
|
1051
|
+
}
|
|
1052
|
+
return this.endSession(byAccessToken.sessionId, 'logout');
|
|
1053
|
+
}
|
|
1054
|
+
const byRefreshToken = await this.#bearer.models.BearerToken.getInstance({ refreshTokenHash: tokenHash });
|
|
1055
|
+
if (!byRefreshToken) {
|
|
1056
|
+
return false;
|
|
1057
|
+
}
|
|
1058
|
+
if (byRefreshToken.rotatedAt !== null) {
|
|
1059
|
+
return this.bearerCredentialReused(byRefreshToken.sessionId, 'refresh_token_reused');
|
|
1060
|
+
}
|
|
1061
|
+
return this.endSession(byRefreshToken.sessionId, 'logout');
|
|
1062
|
+
}
|
|
1063
|
+
|
|
556
1064
|
// ==========================================================================
|
|
557
1065
|
// provider tokens
|
|
558
1066
|
// ==========================================================================
|
|
@@ -563,8 +1071,22 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
563
1071
|
* is unknown — is rotated first. The session has to belong to
|
|
564
1072
|
* `expectedPrincipalIdArg`, so a credential is never evaluated with another
|
|
565
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.
|
|
566
1084
|
*/
|
|
567
|
-
public async liveProviderToken(
|
|
1085
|
+
public async liveProviderToken(
|
|
1086
|
+
sessionIdArg: string,
|
|
1087
|
+
expectedPrincipalIdArg: string,
|
|
1088
|
+
optionsArg: { rejectedAccessToken?: string } = {},
|
|
1089
|
+
): Promise<IProviderToken> {
|
|
568
1090
|
const session = await this.#models.Session.getInstance({ id: sessionIdArg });
|
|
569
1091
|
const now = this.#now();
|
|
570
1092
|
if (
|
|
@@ -579,35 +1101,60 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
579
1101
|
const subject = session.idpSubject;
|
|
580
1102
|
const expiresAt = typeof session.accessTokenExpiresAt === 'number' ? session.accessTokenExpiresAt : 0;
|
|
581
1103
|
const accessToken = this.openSealed(session.sealedAccessToken);
|
|
1104
|
+
const forced = optionsArg.rejectedAccessToken !== undefined;
|
|
582
1105
|
// an unknown expiry is rotated too: afterwards the lifetime is known
|
|
583
|
-
const due =
|
|
1106
|
+
const due = forced
|
|
1107
|
+
|| accessToken === undefined
|
|
1108
|
+
|| expiresAt === 0
|
|
1109
|
+
|| expiresAt <= now + ACCESS_TOKEN_REFRESH_SKEW_MS;
|
|
584
1110
|
if (!due && accessToken !== undefined) {
|
|
585
1111
|
return { accessToken, subject, expiresAt };
|
|
586
1112
|
}
|
|
587
1113
|
if (!session.sealedRefreshToken) {
|
|
588
1114
|
if (accessToken !== undefined && (expiresAt === 0 || expiresAt > now)) {
|
|
589
1115
|
// nothing to rotate with, and the token is not known to be dead
|
|
1116
|
+
if (forced) {
|
|
1117
|
+
throw new AuthorityTokenError('authority_unavailable');
|
|
1118
|
+
}
|
|
590
1119
|
return { accessToken, subject, expiresAt };
|
|
591
1120
|
}
|
|
592
1121
|
await this.endProviderSession(sessionIdArg, 'access_token_unusable');
|
|
593
1122
|
throw new AuthorityTokenError('authority_login_required');
|
|
594
1123
|
}
|
|
595
|
-
return this.refreshProviderToken(sessionIdArg, subject);
|
|
1124
|
+
return this.refreshProviderToken(sessionIdArg, subject, forced);
|
|
596
1125
|
}
|
|
597
1126
|
|
|
598
|
-
|
|
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> {
|
|
599
1137
|
const running = this.#refreshesInFlight.get(sessionIdArg);
|
|
600
1138
|
if (running) {
|
|
601
|
-
|
|
1139
|
+
const joined = await running;
|
|
1140
|
+
if (forcedArg) {
|
|
1141
|
+
throw new AuthorityTokenError('authority_unavailable');
|
|
1142
|
+
}
|
|
1143
|
+
return joined;
|
|
602
1144
|
}
|
|
603
|
-
const pending = this.runProviderRefresh(sessionIdArg, subjectArg).finally(() => {
|
|
1145
|
+
const pending = this.runProviderRefresh(sessionIdArg, subjectArg, forcedArg).finally(() => {
|
|
604
1146
|
this.#refreshesInFlight.delete(sessionIdArg);
|
|
605
1147
|
});
|
|
606
1148
|
this.#refreshesInFlight.set(sessionIdArg, pending);
|
|
607
|
-
|
|
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;
|
|
608
1151
|
}
|
|
609
1152
|
|
|
610
|
-
private async runProviderRefresh(
|
|
1153
|
+
private async runProviderRefresh(
|
|
1154
|
+
sessionIdArg: string,
|
|
1155
|
+
subjectArg: string,
|
|
1156
|
+
forcedArg: boolean,
|
|
1157
|
+
): Promise<IProviderToken> {
|
|
611
1158
|
const provider = this.#provider;
|
|
612
1159
|
if (!provider) {
|
|
613
1160
|
throw new AuthorityTokenError('authority_unavailable');
|
|
@@ -626,15 +1173,21 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
626
1173
|
{ returnDocument: 'after' },
|
|
627
1174
|
);
|
|
628
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
|
+
}
|
|
629
1180
|
return this.tokenAfterForeignRefresh(sessionIdArg, subjectArg);
|
|
630
1181
|
}
|
|
631
1182
|
const session = claimed.document as RelyingPartySessionDoc;
|
|
632
1183
|
// the lock holder decides on the state it just read: a rotation that
|
|
633
1184
|
// landed in between is used as it stands, because spending a superseded
|
|
634
|
-
// 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
|
|
635
1188
|
const claimedExpiry = typeof session.accessTokenExpiresAt === 'number' ? session.accessTokenExpiresAt : 0;
|
|
636
1189
|
const claimedToken = this.openSealed(session.sealedAccessToken);
|
|
637
|
-
if (claimedToken !== undefined && claimedExpiry > this.#now() + ACCESS_TOKEN_REFRESH_SKEW_MS) {
|
|
1190
|
+
if (!forcedArg && claimedToken !== undefined && claimedExpiry > this.#now() + ACCESS_TOKEN_REFRESH_SKEW_MS) {
|
|
638
1191
|
await this.#models.Session.atomicUpdate(
|
|
639
1192
|
{ id: sessionIdArg, revokedAt: { $exists: false }, refreshLockUntil: lockUntil },
|
|
640
1193
|
{ $set: { refreshLockUntil: 0 } },
|
|
@@ -722,6 +1275,96 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
722
1275
|
return this.#provider;
|
|
723
1276
|
}
|
|
724
1277
|
|
|
1278
|
+
private requireCookieSessions(): ICookieSessionsMode {
|
|
1279
|
+
if (!this.#cookieSessions) {
|
|
1280
|
+
throw new RelyingPartyError('login_method_unavailable');
|
|
1281
|
+
}
|
|
1282
|
+
return this.#cookieSessions;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
private requireBearer(): IBearerMode {
|
|
1286
|
+
if (!this.#bearer) {
|
|
1287
|
+
throw new RelyingPartyError('login_method_unavailable');
|
|
1288
|
+
}
|
|
1289
|
+
return this.#bearer;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
private checkLoginOptions(optionsArg: IRelyingPartyLoginOptions): void {
|
|
1293
|
+
const { intent, prompt } = optionsArg;
|
|
1294
|
+
if (intent !== undefined && (typeof intent !== 'string' || intent.length === 0 || intent.length > RELYING_PARTY_MAX_INTENT_LENGTH)) {
|
|
1295
|
+
throw new RangeError(`a login intent is text of 1 to ${RELYING_PARTY_MAX_INTENT_LENGTH} characters`);
|
|
1296
|
+
}
|
|
1297
|
+
if (prompt !== undefined && !OIDC_PROMPTS.includes(prompt)) {
|
|
1298
|
+
throw new RangeError(`a login prompt is one of ${OIDC_PROMPTS.join(', ')}`);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/** An unsaved attempt with its state, nonce and sealed verifier; the caller binds it and names where it returns. */
|
|
1303
|
+
private newLoginAttempt(intentArg: string | undefined): {
|
|
1304
|
+
attempt: RelyingPartyLoginAttemptDoc;
|
|
1305
|
+
state: string;
|
|
1306
|
+
nonce: string;
|
|
1307
|
+
pkceVerifier: string;
|
|
1308
|
+
} {
|
|
1309
|
+
const state = randomToken(32);
|
|
1310
|
+
const nonce = randomToken(32);
|
|
1311
|
+
const pkceVerifier = randomToken(32);
|
|
1312
|
+
const now = this.#now();
|
|
1313
|
+
const attempt = new this.#models.LoginAttempt();
|
|
1314
|
+
attempt.id = 'lga_' + randomToken(18);
|
|
1315
|
+
attempt.stateHash = sha256(state);
|
|
1316
|
+
attempt.sealedPkceVerifier = this.#secretBox.seal(pkceVerifier);
|
|
1317
|
+
attempt.nonce = nonce;
|
|
1318
|
+
if (intentArg !== undefined) {
|
|
1319
|
+
attempt.sealedIntent = this.#secretBox.seal(intentArg);
|
|
1320
|
+
}
|
|
1321
|
+
attempt.createdAt = now;
|
|
1322
|
+
attempt.expiresAt = now + LOGIN_ATTEMPT_MAX_AGE_SECONDS * 1_000;
|
|
1323
|
+
return { attempt, state, nonce, pkceVerifier };
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
/**
|
|
1327
|
+
* The provider half of a consumed attempt: exchanges the code, admits the
|
|
1328
|
+
* principal and opens the session. Nothing of a failed login survives: its
|
|
1329
|
+
* tokens are handed back before the failure leaves.
|
|
1330
|
+
*/
|
|
1331
|
+
private async openProviderLogin(
|
|
1332
|
+
providerArg: IOidcProvider,
|
|
1333
|
+
callbackUrlArg: URL,
|
|
1334
|
+
stateArg: string,
|
|
1335
|
+
attemptArg: RelyingPartyLoginAttemptDoc,
|
|
1336
|
+
beforeOpenedArg?: (sessionArg: RelyingPartySessionDoc) => Promise<void>,
|
|
1337
|
+
): Promise<{ document: RelyingPartySessionDoc; token: string }> {
|
|
1338
|
+
let completion: IOidcCompletion | undefined;
|
|
1339
|
+
let stage: TLoginCallbackStage = 'token_exchange';
|
|
1340
|
+
try {
|
|
1341
|
+
completion = await providerArg.completeAuthorization({
|
|
1342
|
+
callbackUrl: callbackUrlArg,
|
|
1343
|
+
state: stateArg,
|
|
1344
|
+
nonce: attemptArg.nonce,
|
|
1345
|
+
pkceVerifier: this.#secretBox.open(attemptArg.sealedPkceVerifier),
|
|
1346
|
+
});
|
|
1347
|
+
stage = 'principal_admission';
|
|
1348
|
+
const { principalId } = await this.#admit(completion, {
|
|
1349
|
+
loginAttemptId: attemptArg.id,
|
|
1350
|
+
...(attemptArg.sealedIntent ? { intent: this.#secretBox.open(attemptArg.sealedIntent) } : {}),
|
|
1351
|
+
});
|
|
1352
|
+
stage = 'session_persistence';
|
|
1353
|
+
return await this.createProviderSession(attemptArg, principalId, completion, beforeOpenedArg);
|
|
1354
|
+
} catch (error) {
|
|
1355
|
+
if (completion) {
|
|
1356
|
+
await this.revokeCompletionTokens(completion);
|
|
1357
|
+
}
|
|
1358
|
+
if (error instanceof RelyingPartyError || error instanceof RelyingPartyRefusal) {
|
|
1359
|
+
throw error;
|
|
1360
|
+
}
|
|
1361
|
+
if (error instanceof OidcCompletionError) {
|
|
1362
|
+
throw new LoginCallbackError(error.stage, error);
|
|
1363
|
+
}
|
|
1364
|
+
throw new LoginCallbackError(stage, error);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
725
1368
|
private newSessionDoc(tokenArg: string, principalIdArg: string, maxAgeSecondsArg: number = this.sessionMaxAgeSeconds): RelyingPartySessionDoc {
|
|
726
1369
|
const now = this.#now();
|
|
727
1370
|
const session = new this.#models.Session();
|
|
@@ -735,7 +1378,8 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
735
1378
|
}
|
|
736
1379
|
|
|
737
1380
|
private csrfTokenOf(tokenArg: string): string {
|
|
738
|
-
|
|
1381
|
+
const { csrfKey, csrfLabel } = this.requireCookieSessions();
|
|
1382
|
+
return deriveCsrfToken(tokenArg, csrfKey, csrfLabel);
|
|
739
1383
|
}
|
|
740
1384
|
|
|
741
1385
|
private opening(sessionArg: RelyingPartySessionDoc, tokenArg: string, maxAgeSecondsArg: number = this.sessionMaxAgeSeconds): IRelyingPartySessionOpening {
|
|
@@ -790,11 +1434,17 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
790
1434
|
return { document: sessionArg, principal: active.principal };
|
|
791
1435
|
}
|
|
792
1436
|
|
|
1437
|
+
/**
|
|
1438
|
+
* Stores the session of a provider login and links it to its attempt.
|
|
1439
|
+
* `beforeOpenedArg` stores what the session is handed out with besides its
|
|
1440
|
+
* cookie; like the hook after it, a throw discards the session.
|
|
1441
|
+
*/
|
|
793
1442
|
private async createProviderSession(
|
|
794
1443
|
attemptArg: RelyingPartyLoginAttemptDoc,
|
|
795
1444
|
principalIdArg: string,
|
|
796
1445
|
completionArg: IOidcCompletion,
|
|
797
|
-
|
|
1446
|
+
beforeOpenedArg?: (sessionArg: RelyingPartySessionDoc) => Promise<void>,
|
|
1447
|
+
): Promise<{ document: RelyingPartySessionDoc; token: string }> {
|
|
798
1448
|
const token = randomToken(32);
|
|
799
1449
|
const session = this.newSessionDoc(token, principalIdArg);
|
|
800
1450
|
session.idpSubject = completionArg.subject;
|
|
@@ -814,13 +1464,13 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
814
1464
|
await this.discardUnopenedSession(session.id);
|
|
815
1465
|
throw new RelyingPartyError('invalid_or_replayed_login');
|
|
816
1466
|
}
|
|
817
|
-
const opening = this.opening(session, token);
|
|
818
1467
|
try {
|
|
819
|
-
await
|
|
1468
|
+
await beforeOpenedArg?.(session);
|
|
1469
|
+
await this.#hooks.onProviderSessionOpened?.({ session: this.snapshot(session) });
|
|
820
1470
|
} catch (error) {
|
|
821
|
-
// the cookie never leaves, so the session is
|
|
822
|
-
// and its sealed tokens erased before the
|
|
823
|
-
// the caller hands the provider tokens back
|
|
1471
|
+
// the cookie (or the hand-off code) never leaves, so the session is
|
|
1472
|
+
// unreachable: it is revoked and its sealed tokens erased before the
|
|
1473
|
+
// error goes on; the caller hands the provider tokens back
|
|
824
1474
|
try {
|
|
825
1475
|
await this.discardUnopenedSession(session.id);
|
|
826
1476
|
} catch (discardError) {
|
|
@@ -828,15 +1478,112 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
828
1478
|
}
|
|
829
1479
|
throw error;
|
|
830
1480
|
}
|
|
831
|
-
return
|
|
1481
|
+
return { document: session, token };
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
/** Where a bearer attempt's outcome goes, while its return target is still configured; `null` otherwise. */
|
|
1485
|
+
private bearerDeliveryOfAttempt(attemptArg: RelyingPartyLoginAttemptDoc): IRelyingPartyBearerDelivery | null {
|
|
1486
|
+
const clientKind = attemptArg.bearerClientKind;
|
|
1487
|
+
const returnTarget = attemptArg.bearerReturnTarget;
|
|
1488
|
+
if (
|
|
1489
|
+
!this.#bearer
|
|
1490
|
+
|| !isBearerClientKind(clientKind)
|
|
1491
|
+
|| typeof returnTarget !== 'string'
|
|
1492
|
+
|| !isBearerReturnTargetAllowed(this.#bearer.returnTargets, clientKind, returnTarget)
|
|
1493
|
+
) {
|
|
1494
|
+
return null;
|
|
1495
|
+
}
|
|
1496
|
+
return {
|
|
1497
|
+
clientKind,
|
|
1498
|
+
returnTarget,
|
|
1499
|
+
...(attemptArg.bearerClientState !== undefined ? { state: attemptArg.bearerClientState } : {}),
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/** Stores the next token generation of a live session and answers its tokens; none of them is stored in the clear. */
|
|
1504
|
+
private async issueBearerTokens(bearerArg: IBearerMode, sessionArg: RelyingPartySessionDoc): Promise<IRelyingPartyBearerTokens> {
|
|
1505
|
+
const accessToken = randomToken(32);
|
|
1506
|
+
const refreshToken = randomToken(32);
|
|
1507
|
+
const now = this.#now();
|
|
1508
|
+
const generation = new bearerArg.models.BearerToken();
|
|
1509
|
+
generation.id = 'rbt_' + randomToken(18);
|
|
1510
|
+
generation.accessTokenHash = sha256(accessToken);
|
|
1511
|
+
generation.refreshTokenHash = sha256(refreshToken);
|
|
1512
|
+
generation.sessionId = sessionArg.id;
|
|
1513
|
+
generation.createdAt = now;
|
|
1514
|
+
generation.accessTokenExpiresAt = Math.min(now + bearerArg.accessTokenMaxAgeSeconds * 1_000, sessionArg.expiresAt);
|
|
1515
|
+
generation.expiresAt = sessionArg.expiresAt;
|
|
1516
|
+
generation.rotatedAt = null;
|
|
1517
|
+
await generation.save();
|
|
1518
|
+
return {
|
|
1519
|
+
session: this.snapshot(sessionArg),
|
|
1520
|
+
issuedAt: now,
|
|
1521
|
+
accessToken,
|
|
1522
|
+
accessTokenExpiresAt: generation.accessTokenExpiresAt,
|
|
1523
|
+
refreshToken,
|
|
1524
|
+
refreshTokenExpiresAt: generation.expiresAt,
|
|
1525
|
+
};
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
/** A spent hand-off code or refresh token came back: whoever holds it, the session ends. */
|
|
1529
|
+
private async bearerCredentialReused(
|
|
1530
|
+
sessionIdArg: string,
|
|
1531
|
+
reasonArg: 'handoff_code_reused' | 'refresh_token_reused',
|
|
1532
|
+
): Promise<boolean> {
|
|
1533
|
+
this.emit({ event: 'bearer_credential_reused', sessionId: sessionIdArg, reason: reasonArg });
|
|
1534
|
+
return this.endSession(sessionIdArg, reasonArg);
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
/**
|
|
1538
|
+
* Takes a hand-off whose code expired unclaimed and ends its session, which
|
|
1539
|
+
* nothing can reach any more. The caller has read it expired; expiry never
|
|
1540
|
+
* moves, so only a claim in between can stop this.
|
|
1541
|
+
*/
|
|
1542
|
+
private async endUnclaimedHandoff(handoffArg: RelyingPartyBearerHandoffDoc): Promise<void> {
|
|
1543
|
+
const taken = await this.requireBearer().models.BearerHandoff.atomicUpdate(
|
|
1544
|
+
{ id: handoffArg.id, consumedAt: null },
|
|
1545
|
+
{ $set: { consumedAt: this.#now() } },
|
|
1546
|
+
);
|
|
1547
|
+
if (taken.matchedCount === 1) {
|
|
1548
|
+
await this.endSession(handoffArg.sessionId, 'handoff_unclaimed');
|
|
1549
|
+
}
|
|
832
1550
|
}
|
|
833
1551
|
|
|
834
1552
|
/** Revokes a session that was never handed out and erases its sealed tokens. */
|
|
835
1553
|
private async discardUnopenedSession(sessionIdArg: string): Promise<void> {
|
|
836
|
-
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(
|
|
837
1580
|
{ id: sessionIdArg, revokedAt: { $exists: false } },
|
|
838
|
-
{ $set: { revokedAt:
|
|
1581
|
+
{ $set: { revokedAt: now, ...REVOKED_TOKEN_FIELDS } },
|
|
1582
|
+
{ returnDocument: 'before' },
|
|
839
1583
|
);
|
|
1584
|
+
return pastExpiry.status === 'matched' && pastExpiry.document !== null
|
|
1585
|
+
? pastExpiry.document as RelyingPartySessionDoc
|
|
1586
|
+
: null;
|
|
840
1587
|
}
|
|
841
1588
|
|
|
842
1589
|
/**
|
|
@@ -878,17 +1625,11 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
878
1625
|
if (typeof sessionIdArg !== 'string' || sessionIdArg.length === 0) {
|
|
879
1626
|
return null;
|
|
880
1627
|
}
|
|
881
|
-
const
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
{ returnDocument: 'before' },
|
|
885
|
-
);
|
|
886
|
-
const revokedHere = revoked.status === 'matched' && revoked.document !== null;
|
|
887
|
-
this.notifySessionEnded({ sessionId: sessionIdArg, reason: reasonArg, revokedHere });
|
|
888
|
-
if (!revokedHere) {
|
|
1628
|
+
const before = await this.revokeSessionDocument(sessionIdArg);
|
|
1629
|
+
this.notifySessionEnded({ sessionId: sessionIdArg, reason: reasonArg, revokedHere: before !== null });
|
|
1630
|
+
if (!before) {
|
|
889
1631
|
return null;
|
|
890
1632
|
}
|
|
891
|
-
const before = revoked.document as RelyingPartySessionDoc;
|
|
892
1633
|
return { sealedAccessToken: before.sealedAccessToken, sealedRefreshToken: before.sealedRefreshToken };
|
|
893
1634
|
}
|
|
894
1635
|
|
|
@@ -1015,6 +1756,78 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1015
1756
|
}
|
|
1016
1757
|
}
|
|
1017
1758
|
|
|
1759
|
+
/**
|
|
1760
|
+
* One bounded page per bearer login start, oldest first: hand-offs whose
|
|
1761
|
+
* code expired unclaimed end their session, and hand-offs and token
|
|
1762
|
+
* generations an hour past their expiry are removed — an unclaimed hand-off
|
|
1763
|
+
* only after its session was ended. A sweep that cannot run never blocks a
|
|
1764
|
+
* sign-in.
|
|
1765
|
+
*/
|
|
1766
|
+
private async sweepBearerState(): Promise<void> {
|
|
1767
|
+
const bearer = this.requireBearer();
|
|
1768
|
+
try {
|
|
1769
|
+
const now = this.#now();
|
|
1770
|
+
const unclaimed = await bearer.models.BearerHandoff.getPagedInstances({
|
|
1771
|
+
filter: { consumedAt: null, expiresAt: { $lt: now } },
|
|
1772
|
+
sortField: 'expiresAt',
|
|
1773
|
+
sortDirection: 'asc',
|
|
1774
|
+
limit: LOGIN_ATTEMPT_SWEEP_BATCH,
|
|
1775
|
+
});
|
|
1776
|
+
for (const handoff of unclaimed.documents) {
|
|
1777
|
+
await this.endUnclaimedHandoff(handoff);
|
|
1778
|
+
}
|
|
1779
|
+
const staleHandoffs = await bearer.models.BearerHandoff.getPagedInstances({
|
|
1780
|
+
filter: { expiresAt: { $lt: now - LOGIN_ATTEMPT_SWEEP_GRACE_MS } },
|
|
1781
|
+
sortField: 'expiresAt',
|
|
1782
|
+
sortDirection: 'asc',
|
|
1783
|
+
limit: LOGIN_ATTEMPT_SWEEP_BATCH,
|
|
1784
|
+
});
|
|
1785
|
+
for (const handoff of staleHandoffs.documents) {
|
|
1786
|
+
if (handoff.consumedAt !== null) {
|
|
1787
|
+
await bearer.models.BearerHandoff.atomicDelete({ id: handoff.id });
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
const staleGenerations = await bearer.models.BearerToken.getPagedInstances({
|
|
1791
|
+
filter: { expiresAt: { $lt: now - LOGIN_ATTEMPT_SWEEP_GRACE_MS } },
|
|
1792
|
+
sortField: 'expiresAt',
|
|
1793
|
+
sortDirection: 'asc',
|
|
1794
|
+
limit: LOGIN_ATTEMPT_SWEEP_BATCH,
|
|
1795
|
+
});
|
|
1796
|
+
for (const generation of staleGenerations.documents) {
|
|
1797
|
+
await bearer.models.BearerToken.atomicDelete({ id: generation.id });
|
|
1798
|
+
}
|
|
1799
|
+
} catch (error) {
|
|
1800
|
+
this.emit({ event: 'bearer_sweep_failed', message: errorMessage(error) });
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
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
|
+
|
|
1018
1831
|
/**
|
|
1019
1832
|
* Reports one diagnostic. Every cleanup step reports through here, so a
|
|
1020
1833
|
* throwing sink must not end it: the event and the sink's failure go to
|