@idp.global/sdk 19.4.0 → 19.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog.md +26 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts_browser/classes.relyingpartybearerclient.d.ts +40 -3
- package/dist_ts_browser/classes.relyingpartybearerclient.js +67 -12
- package/dist_ts_relyingparty/classes.errors.d.ts +2 -0
- package/dist_ts_relyingparty/classes.errors.js +2 -1
- package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +64 -9
- package/dist_ts_relyingparty/classes.relyingpartysessions.js +158 -31
- package/dist_ts_relyingparty/contract.bearer.d.ts +37 -0
- package/dist_ts_relyingparty/contract.bearer.js +5 -1
- package/dist_ts_relyingparty/helpers.bearer.d.ts +2 -3
- package/dist_ts_relyingparty/helpers.bearer.js +4 -6
- package/dist_ts_relyingparty/interfaces.d.ts +42 -3
- package/package.json +1 -1
- package/readme.md +156 -10
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts_browser/classes.relyingpartybearerclient.ts +98 -18
- package/ts_relyingparty/classes.errors.ts +3 -0
- package/ts_relyingparty/classes.relyingpartysessions.ts +170 -36
- package/ts_relyingparty/contract.bearer.ts +46 -0
- package/ts_relyingparty/helpers.bearer.ts +3 -6
- package/ts_relyingparty/interfaces.ts +44 -7
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
import {
|
|
2
|
+
isPkceVerifier,
|
|
2
3
|
RELYING_PARTY_BEARER_MESSAGE_TYPE,
|
|
4
|
+
type IRelyingPartyBearerClaimRequest,
|
|
5
|
+
type IRelyingPartyBearerLoginQuery,
|
|
3
6
|
type IRelyingPartyBearerLogoutAnswer,
|
|
7
|
+
type IRelyingPartyBearerLogoutRequest,
|
|
8
|
+
type IRelyingPartyBearerRefreshRequest,
|
|
4
9
|
type IRelyingPartyBearerTokenAnswer,
|
|
5
10
|
} from '../ts_relyingparty/contract.bearer.js';
|
|
6
11
|
|
|
7
12
|
export type {
|
|
13
|
+
IRelyingPartyBearerClaimRequest,
|
|
8
14
|
IRelyingPartyBearerErrorAnswer,
|
|
15
|
+
IRelyingPartyBearerLoginQuery,
|
|
9
16
|
IRelyingPartyBearerLogoutAnswer,
|
|
17
|
+
IRelyingPartyBearerLogoutRequest,
|
|
10
18
|
IRelyingPartyBearerMessage,
|
|
19
|
+
IRelyingPartyBearerRefreshRequest,
|
|
11
20
|
IRelyingPartyBearerSessionAnswer,
|
|
12
21
|
IRelyingPartyBearerSessionInfo,
|
|
13
22
|
IRelyingPartyBearerTokenAnswer,
|
|
@@ -49,6 +58,19 @@ export interface IRelyingPartyBearerTokenStorage {
|
|
|
49
58
|
clear(optionsArg: { signal: AbortSignal }): Promise<void>;
|
|
50
59
|
}
|
|
51
60
|
|
|
61
|
+
/**
|
|
62
|
+
* A PKCE pair (RFC 7636): the verifier the client keeps and the S256
|
|
63
|
+
* challenge it hands out. `codeVerifier` is the secret that makes a code
|
|
64
|
+
* worth anything; keep it like a pending login, never in a URL, a log or a
|
|
65
|
+
* message.
|
|
66
|
+
*/
|
|
67
|
+
export interface IRelyingPartyBearerPkce {
|
|
68
|
+
/** 43 characters from 32 random bytes */
|
|
69
|
+
codeVerifier: string;
|
|
70
|
+
/** BASE64URL(SHA-256(codeVerifier)) */
|
|
71
|
+
codeChallenge: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
52
74
|
/**
|
|
53
75
|
* A login on its way: what `createLogin` made and the receiver needs back.
|
|
54
76
|
* A `web` login leaves the page, so the application keeps it until the
|
|
@@ -149,7 +171,7 @@ export type TRelyingPartyBearerClientErrorCode =
|
|
|
149
171
|
| 'state_mismatch'
|
|
150
172
|
/** the delivery carries no code; `deliveryError` says why */
|
|
151
173
|
| 'login_failed'
|
|
152
|
-
/** this login was received once already; a code is claimed once, never again */
|
|
174
|
+
/** this login, or this PKCE pair, was received once already; a code is claimed once, never again */
|
|
153
175
|
| 'already_received'
|
|
154
176
|
/** the browser opened no popup */
|
|
155
177
|
| 'popup_blocked'
|
|
@@ -219,6 +241,12 @@ const isText = (valueArg: unknown): valueArg is string => typeof valueArg === 's
|
|
|
219
241
|
|
|
220
242
|
const isTime = (valueArg: unknown): valueArg is number => typeof valueArg === 'number' && Number.isFinite(valueArg);
|
|
221
243
|
|
|
244
|
+
/** What the client posts to the bearer routes. */
|
|
245
|
+
type TRelyingPartyBearerRequestBody =
|
|
246
|
+
| IRelyingPartyBearerClaimRequest
|
|
247
|
+
| IRelyingPartyBearerRefreshRequest
|
|
248
|
+
| IRelyingPartyBearerLogoutRequest;
|
|
249
|
+
|
|
222
250
|
/** Throws `request_failed` once a section's deadline has fired: nothing is sent or written past it. */
|
|
223
251
|
const inTime = (signalArg: AbortSignal): void => {
|
|
224
252
|
if (signalArg.aborted) {
|
|
@@ -278,7 +306,9 @@ const tokenAnswerOf = (answerArg: unknown): IRelyingPartyBearerTokenAnswer | nul
|
|
|
278
306
|
* request or write never holds the other tabs. A login replaces the stored
|
|
279
307
|
* pair and ends that pair's session. Rotation is strict at the relying
|
|
280
308
|
* party: a refresh token spent once ends the session when it comes back, so
|
|
281
|
-
* nothing here retries a claim or a refresh.
|
|
309
|
+
* nothing here retries a claim or a refresh. A code that reaches the client
|
|
310
|
+
* another way, such as a device pairing, is claimed through `createPkce` and
|
|
311
|
+
* `claimCode` under the same rules, without a window.
|
|
282
312
|
*
|
|
283
313
|
* It needs the Web Locks API and Web Crypto, both of them only in a secure
|
|
284
314
|
* context, and has no fallback for either.
|
|
@@ -296,6 +326,8 @@ export class RelyingPartyBearerClient {
|
|
|
296
326
|
readonly #now: () => number;
|
|
297
327
|
/** the states of the logins received here; a login is received once */
|
|
298
328
|
readonly #receivedStates = new Set<string>();
|
|
329
|
+
/** the challenges of the pairs `claimCode` claimed with here; a pair is claimed with once */
|
|
330
|
+
readonly #claimedChallenges = new Set<string>();
|
|
299
331
|
|
|
300
332
|
constructor(optionsArg: IRelyingPartyBearerClientOptions) {
|
|
301
333
|
const origin = optionsArg?.origin;
|
|
@@ -375,17 +407,63 @@ export class RelyingPartyBearerClient {
|
|
|
375
407
|
if (!isText(returnTarget)) {
|
|
376
408
|
throw new Error('returnTarget must name the configured return target');
|
|
377
409
|
}
|
|
378
|
-
const codeVerifier =
|
|
410
|
+
const { codeVerifier, codeChallenge } = await this.createPkce();
|
|
379
411
|
const state = randomBase64Url();
|
|
412
|
+
const query: Required<IRelyingPartyBearerLoginQuery> = {
|
|
413
|
+
client_kind: clientKind,
|
|
414
|
+
return_target: returnTarget,
|
|
415
|
+
code_challenge: codeChallenge,
|
|
416
|
+
code_challenge_method: 'S256',
|
|
417
|
+
state,
|
|
418
|
+
};
|
|
380
419
|
const url = new URL(this.#paths.login);
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
url.searchParams.set('code_challenge_method', 'S256');
|
|
385
|
-
url.searchParams.set('state', state);
|
|
420
|
+
for (const [name, value] of Object.entries(query)) {
|
|
421
|
+
url.searchParams.set(name, value);
|
|
422
|
+
}
|
|
386
423
|
return { clientKind, returnTarget, state, codeVerifier, url: url.href };
|
|
387
424
|
}
|
|
388
425
|
|
|
426
|
+
/**
|
|
427
|
+
* A fresh PKCE pair: a verifier from 32 random bytes and its S256
|
|
428
|
+
* challenge. For a code the client does not receive through its own login,
|
|
429
|
+
* such as one the application delivers to a device it pairs: the challenge
|
|
430
|
+
* goes to the application, and `claimCode` claims the code with the pair.
|
|
431
|
+
* Needs no window.
|
|
432
|
+
*/
|
|
433
|
+
public async createPkce(): Promise<IRelyingPartyBearerPkce> {
|
|
434
|
+
const codeVerifier = randomBase64Url();
|
|
435
|
+
return { codeVerifier, codeChallenge: await pkceChallengeOf(codeVerifier) };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Claims a hand-off code that was bound to `pkceArg`'s challenge and
|
|
440
|
+
* reached this client some other way than its own login, such as a device
|
|
441
|
+
* pairing (`openBearerHandoff` at the relying party). It is received like
|
|
442
|
+
* a login: one claim, never retried, under `requestTimeoutMs`, and the
|
|
443
|
+
* claimed pair replaces the stored one under the lock, whose session is
|
|
444
|
+
* ended then (`IRelyingPartyBearerLoginResult`). A pair is claimed with
|
|
445
|
+
* once: a second call with it, after a failed claim too, is
|
|
446
|
+
* `already_received` and sends nothing. A pair that is not one (a
|
|
447
|
+
* malformed verifier, a challenge that is not its S256) or a code that is
|
|
448
|
+
* not text is refused before anything is sent. Needs no window.
|
|
449
|
+
*/
|
|
450
|
+
public async claimCode(pkceArg: IRelyingPartyBearerPkce, codeArg: string): Promise<IRelyingPartyBearerLoginResult> {
|
|
451
|
+
const codeVerifier: unknown = pkceArg?.codeVerifier;
|
|
452
|
+
const codeChallenge: unknown = pkceArg?.codeChallenge;
|
|
453
|
+
if (!isPkceVerifier(codeVerifier) || typeof codeChallenge !== 'string' || codeChallenge !== await pkceChallengeOf(codeVerifier)) {
|
|
454
|
+
throw new Error('claimCode needs a PKCE pair: a verifier and its S256 challenge, as createPkce makes them');
|
|
455
|
+
}
|
|
456
|
+
if (!isText(codeArg)) {
|
|
457
|
+
throw new Error('claimCode needs the hand-off code as text');
|
|
458
|
+
}
|
|
459
|
+
if (this.#claimedChallenges.has(codeChallenge)) {
|
|
460
|
+
throw new RelyingPartyBearerClientError('already_received');
|
|
461
|
+
}
|
|
462
|
+
// taken before the claim is sent: a failed claim uses the pair up too
|
|
463
|
+
this.#claimedChallenges.add(codeChallenge);
|
|
464
|
+
return this.claim(codeArg, codeVerifier);
|
|
465
|
+
}
|
|
466
|
+
|
|
389
467
|
/**
|
|
390
468
|
* Receives a `web` login on its return target page. The delivery leaves
|
|
391
469
|
* the address first, before anything is checked, so neither a reload nor
|
|
@@ -548,9 +626,14 @@ export class RelyingPartyBearerClient {
|
|
|
548
626
|
if (!isText(codeArg)) {
|
|
549
627
|
throw new RelyingPartyBearerClientError('unexpected_answer');
|
|
550
628
|
}
|
|
629
|
+
return this.claim(codeArg, loginArg.codeVerifier);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** Claims a code once with its verifier and stores the pair in place of the one before. */
|
|
633
|
+
private async claim(codeArg: string, codeVerifierArg: string): Promise<IRelyingPartyBearerLoginResult> {
|
|
634
|
+
const body: IRelyingPartyBearerClaimRequest = { code: codeArg, codeVerifier: codeVerifierArg };
|
|
551
635
|
// the claim touches no stored pair, so it holds no lock, only its own deadline; a failed one leaves the stored pair as it was
|
|
552
|
-
const answer = tokenAnswerOf(await this.withDeadline((signalArg) =>
|
|
553
|
-
this.post(this.#paths.claim, { code: codeArg, codeVerifier: loginArg.codeVerifier }, {}, signalArg)));
|
|
636
|
+
const answer = tokenAnswerOf(await this.withDeadline((signalArg) => this.post(this.#paths.claim, body, {}, signalArg)));
|
|
554
637
|
if (!answer) {
|
|
555
638
|
throw new RelyingPartyBearerClientError('unexpected_answer', { status: 200 });
|
|
556
639
|
}
|
|
@@ -695,8 +778,9 @@ export class RelyingPartyBearerClient {
|
|
|
695
778
|
return current;
|
|
696
779
|
}
|
|
697
780
|
let answer: IRelyingPartyBearerTokenAnswer | null;
|
|
781
|
+
const body: IRelyingPartyBearerRefreshRequest = { refreshToken: current.refreshToken };
|
|
698
782
|
try {
|
|
699
|
-
answer = tokenAnswerOf(await this.post(this.#paths.refresh,
|
|
783
|
+
answer = tokenAnswerOf(await this.post(this.#paths.refresh, body, {}, signalArg));
|
|
700
784
|
} catch (error) {
|
|
701
785
|
if (error instanceof RelyingPartyBearerClientError && error.answerError === 'invalid_bearer_grant') {
|
|
702
786
|
// the session is over: this pair opens nothing any more
|
|
@@ -718,12 +802,8 @@ export class RelyingPartyBearerClient {
|
|
|
718
802
|
|
|
719
803
|
/** Ends the session of a pair through the logout route and answers whether that ended a live one. */
|
|
720
804
|
private async endSessionOf(pairArg: IRelyingPartyBearerStoredTokens, signalArg: AbortSignal): Promise<boolean> {
|
|
721
|
-
const
|
|
722
|
-
|
|
723
|
-
{ refreshToken: pairArg.refreshToken },
|
|
724
|
-
{ authorization: `Bearer ${pairArg.accessToken}` },
|
|
725
|
-
signalArg,
|
|
726
|
-
);
|
|
805
|
+
const body: IRelyingPartyBearerLogoutRequest = { refreshToken: pairArg.refreshToken };
|
|
806
|
+
const answer = await this.post(this.#paths.logout, body, { authorization: `Bearer ${pairArg.accessToken}` }, signalArg);
|
|
727
807
|
if (!isRecord(answer) || typeof answer.endedLiveSession !== 'boolean') {
|
|
728
808
|
throw new RelyingPartyBearerClientError('unexpected_answer', { status: 200 });
|
|
729
809
|
}
|
|
@@ -772,7 +852,7 @@ export class RelyingPartyBearerClient {
|
|
|
772
852
|
*/
|
|
773
853
|
private async post(
|
|
774
854
|
urlArg: string,
|
|
775
|
-
bodyArg:
|
|
855
|
+
bodyArg: TRelyingPartyBearerRequestBody,
|
|
776
856
|
headersArg: Record<string, string> = {},
|
|
777
857
|
signalArg?: AbortSignal,
|
|
778
858
|
): Promise<unknown> {
|
|
@@ -14,6 +14,8 @@ export type TRelyingPartyErrorCode =
|
|
|
14
14
|
| 'invalid_bearer_handoff'
|
|
15
15
|
/** a refresh token that is unknown, rotated, expired, or of a session that is over */
|
|
16
16
|
| 'invalid_bearer_grant'
|
|
17
|
+
/** `openBearerHandoff` named an opener that is no live provider login: unknown, ended, expired, of an inactive principal, or without a provider login of its own */
|
|
18
|
+
| 'invalid_handoff_opener'
|
|
17
19
|
/** a `sessionsOfPrincipal` cursor that no page of it answered */
|
|
18
20
|
| 'invalid_sessions_cursor';
|
|
19
21
|
|
|
@@ -27,6 +29,7 @@ const RELYING_PARTY_ERROR_STATUS: Readonly<Record<TRelyingPartyErrorCode, number
|
|
|
27
29
|
return_target_not_allowed: 400,
|
|
28
30
|
invalid_bearer_handoff: 400,
|
|
29
31
|
invalid_bearer_grant: 400,
|
|
32
|
+
invalid_handoff_opener: 400,
|
|
30
33
|
invalid_sessions_cursor: 400,
|
|
31
34
|
};
|
|
32
35
|
|
|
@@ -39,6 +39,7 @@ import type {
|
|
|
39
39
|
IProviderToken,
|
|
40
40
|
IRelyingPartyBearerDelivery,
|
|
41
41
|
IRelyingPartyBearerHandoff,
|
|
42
|
+
IRelyingPartyBearerHandoffOpening,
|
|
42
43
|
IRelyingPartyBearerLoginRequest,
|
|
43
44
|
IRelyingPartyBearerLoginStart,
|
|
44
45
|
IRelyingPartyBearerOptions,
|
|
@@ -52,6 +53,7 @@ import type {
|
|
|
52
53
|
IRelyingPartyLoginStart,
|
|
53
54
|
IRelyingPartySession,
|
|
54
55
|
IRelyingPartySessionOpening,
|
|
56
|
+
IRelyingPartySessionsErasure,
|
|
55
57
|
IRelyingPartySessionsPage,
|
|
56
58
|
IRelyingPartySessionsPageOptions,
|
|
57
59
|
IResolvedRelyingPartySession,
|
|
@@ -159,7 +161,7 @@ export interface IRelyingPartySessionsOptions<TPrincipal extends object | undefi
|
|
|
159
161
|
export interface IRelyingPartyBearerOnlySessionsOptions<TPrincipal extends object | undefined = undefined>
|
|
160
162
|
extends Omit<IRelyingPartySessionsOptions<TPrincipal>, 'provider' | 'csrfKey' | 'csrfLabel' | 'cookieNames' | 'cookieSessions' | 'bearer'> {
|
|
161
163
|
cookieSessions: false;
|
|
162
|
-
/** every bearer session
|
|
164
|
+
/** every bearer session comes from a live provider login, directly or through a hand-off opened from one */
|
|
163
165
|
provider: IOidcProvider;
|
|
164
166
|
bearer: IRelyingPartyBearerOptions;
|
|
165
167
|
csrfKey?: never;
|
|
@@ -190,7 +192,7 @@ const bearerModeOf = (
|
|
|
190
192
|
throw new Error('bearer must be an object with returnTargets');
|
|
191
193
|
}
|
|
192
194
|
if (!optionsArg.hasProvider) {
|
|
193
|
-
throw new Error('bearer needs a provider: every bearer session
|
|
195
|
+
throw new Error('bearer needs a provider: every bearer session comes from a provider login');
|
|
194
196
|
}
|
|
195
197
|
const { BearerHandoff, BearerToken } = optionsArg.models;
|
|
196
198
|
if (!BearerHandoff || !BearerToken) {
|
|
@@ -297,10 +299,10 @@ const cookieNameFor = (
|
|
|
297
299
|
* ended sessions are swept one bounded batch at a time when a login starts or
|
|
298
300
|
* the application opens a session, and the only timer — the revocation
|
|
299
301
|
* budget — is unreferenced and cleared on every path. Every session comes
|
|
300
|
-
* from a login start or
|
|
301
|
-
* of at least one first, so removal keeps pace with creation
|
|
302
|
-
* sessions kept stay bounded. Nothing here does I/O while it is
|
|
303
|
-
* so it is built before the database connects.
|
|
302
|
+
* from a login start, `openSession` or `openBearerHandoff`, and each of those
|
|
303
|
+
* sweeps a batch of at least one first, so removal keeps pace with creation
|
|
304
|
+
* and the ended sessions kept stay bounded. Nothing here does I/O while it is
|
|
305
|
+
* constructed, so it is built before the database connects.
|
|
304
306
|
*
|
|
305
307
|
* `TPrincipal` is what a `principals.resolve` answers; with `isActive`
|
|
306
308
|
* principals it stays `undefined`.
|
|
@@ -580,6 +582,54 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
580
582
|
return ended;
|
|
581
583
|
}
|
|
582
584
|
|
|
585
|
+
/**
|
|
586
|
+
* Erases every session of a principal, live or ended, and every row that
|
|
587
|
+
* belongs to one, without waiting for a sweep: what an application runs
|
|
588
|
+
* when it deletes an account, after its own erasers and before its user
|
|
589
|
+
* record.
|
|
590
|
+
*
|
|
591
|
+
* First each session not revoked yet, an expired one too, ends as
|
|
592
|
+
* `endSession` ends it: its tokens stop working at once, `onSessionEnded`
|
|
593
|
+
* names the principal, and its provider tokens go back to idp.global,
|
|
594
|
+
* which a deleted row could no longer do. Then each session's rows go: in
|
|
595
|
+
* bearer mode its hand-offs, claimed or not, and its token generations;
|
|
596
|
+
* the login attempt a provider session came out of; and the session row
|
|
597
|
+
* last, because it is the only link from the principal to the others, so
|
|
598
|
+
* an erase that fails half way leaves a retry everything it needs. A
|
|
599
|
+
* session opened or a token issued while the erase runs is not covered;
|
|
600
|
+
* it works only while the principal is active.
|
|
601
|
+
*/
|
|
602
|
+
public async eraseSessionsOfPrincipal(principalIdArg: string): Promise<IRelyingPartySessionsErasure> {
|
|
603
|
+
if (typeof principalIdArg !== 'string' || principalIdArg === '') {
|
|
604
|
+
throw new Error('eraseSessionsOfPrincipal needs a principal id');
|
|
605
|
+
}
|
|
606
|
+
const erasure: IRelyingPartySessionsErasure = {
|
|
607
|
+
endedSessions: 0,
|
|
608
|
+
deletedSessions: 0,
|
|
609
|
+
deletedLoginAttempts: 0,
|
|
610
|
+
deletedBearerHandoffs: 0,
|
|
611
|
+
deletedBearerTokens: 0,
|
|
612
|
+
};
|
|
613
|
+
const sessions = await this.#models.Session.getInstances({ userId: principalIdArg });
|
|
614
|
+
for (const session of sessions) {
|
|
615
|
+
if (session.revokedAt === undefined && (await this.endSession(session.id))) {
|
|
616
|
+
erasure.endedSessions += 1;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
const bearerModels = this.#bearer?.models;
|
|
620
|
+
for (const session of sessions) {
|
|
621
|
+
if (bearerModels) {
|
|
622
|
+
erasure.deletedBearerHandoffs += (await bearerModels.BearerHandoff.atomicDeleteMany({ sessionId: session.id })).deletedCount;
|
|
623
|
+
erasure.deletedBearerTokens += (await bearerModels.BearerToken.atomicDeleteMany({ sessionId: session.id })).deletedCount;
|
|
624
|
+
}
|
|
625
|
+
if (session.loginAttemptId) {
|
|
626
|
+
erasure.deletedLoginAttempts += (await this.#models.LoginAttempt.atomicDelete({ id: session.loginAttemptId })).deletedCount;
|
|
627
|
+
}
|
|
628
|
+
erasure.deletedSessions += (await this.#models.Session.atomicDelete({ id: session.id })).deletedCount;
|
|
629
|
+
}
|
|
630
|
+
return erasure;
|
|
631
|
+
}
|
|
632
|
+
|
|
583
633
|
/**
|
|
584
634
|
* One page of a principal's live sessions, newest first: the ones neither
|
|
585
635
|
* ended nor expired, and none at all while the principal is not active —
|
|
@@ -876,28 +926,85 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
876
926
|
if (!isPkceChallenge(codeChallenge)) {
|
|
877
927
|
throw new RelyingPartyError('invalid_or_replayed_login');
|
|
878
928
|
}
|
|
879
|
-
|
|
880
|
-
let handoff: RelyingPartyBearerHandoffDoc | undefined;
|
|
929
|
+
let handoff: { code: string; expiresAt: number } | undefined;
|
|
881
930
|
const opened = await this.openProviderLogin(provider, callbackUrlArg, state, attempt, async (sessionArg) => {
|
|
882
|
-
|
|
883
|
-
created.id = 'rbh_' + randomToken(18);
|
|
884
|
-
created.codeHash = sha256(code);
|
|
885
|
-
created.sessionId = sessionArg.id;
|
|
886
|
-
created.codeChallenge = codeChallenge;
|
|
887
|
-
created.createdAt = this.#now();
|
|
888
|
-
created.expiresAt = Math.min(created.createdAt + BEARER_HANDOFF_MAX_AGE_SECONDS * 1_000, sessionArg.expiresAt);
|
|
889
|
-
created.consumedAt = null;
|
|
890
|
-
await created.save();
|
|
891
|
-
handoff = created;
|
|
931
|
+
handoff = await this.storeBearerHandoff(bearer, sessionArg, codeChallenge);
|
|
892
932
|
});
|
|
933
|
+
const stored = handoff as { code: string; expiresAt: number };
|
|
893
934
|
return {
|
|
894
935
|
session: this.snapshot(opened.document),
|
|
895
936
|
delivery,
|
|
896
|
-
code,
|
|
897
|
-
expiresAt:
|
|
937
|
+
code: stored.code,
|
|
938
|
+
expiresAt: stored.expiresAt,
|
|
898
939
|
};
|
|
899
940
|
}
|
|
900
941
|
|
|
942
|
+
/**
|
|
943
|
+
* Opens a bearer session for a device the application pairs to a person
|
|
944
|
+
* who is signed in here, and answers its single-use hand-off code, which
|
|
945
|
+
* the device claims at `bearer/claim` with the verifier of `codeChallenge`,
|
|
946
|
+
* exactly like the code of a bearer login.
|
|
947
|
+
*
|
|
948
|
+
* `openerSessionId` names the session that confirmed the pairing, taken
|
|
949
|
+
* from the application's own record, never from the device. It must be a
|
|
950
|
+
* live provider login: not ended, not expired, its principal active, and
|
|
951
|
+
* with a provider login of its own. A session the application opened
|
|
952
|
+
* itself or one opened here does not qualify, so a paired device never
|
|
953
|
+
* pairs another one. Anything else throws
|
|
954
|
+
* `RelyingPartyError('invalid_handoff_opener')`.
|
|
955
|
+
*
|
|
956
|
+
* The new session acts as the opener's principal and has no provider login
|
|
957
|
+
* of its own: it ends by its expiry, `endSession`, `endSessionsOfPrincipal`,
|
|
958
|
+
* `bearer/logout` or the reuse of one of its credentials, never by an
|
|
959
|
+
* idp.global logout, and `AuthorityObserver` refuses it
|
|
960
|
+
* (`authority_login_required`). `maxAgeSeconds` is a whole number from 1 to
|
|
961
|
+
* `sessionMaxAgeSeconds`, the default, as for `openSession`. The code lives
|
|
962
|
+
* `BEARER_HANDOFF_MAX_AGE_SECONDS` at most and never past the session, and
|
|
963
|
+
* only its hash is stored; a code nobody claims ends its session. Nothing
|
|
964
|
+
* is stored for a refused opening.
|
|
965
|
+
*/
|
|
966
|
+
public async openBearerHandoff(optionsArg: {
|
|
967
|
+
openerSessionId: string;
|
|
968
|
+
codeChallenge: string;
|
|
969
|
+
maxAgeSeconds?: number;
|
|
970
|
+
}): Promise<IRelyingPartyBearerHandoffOpening> {
|
|
971
|
+
const bearer = this.requireBearer();
|
|
972
|
+
if (!isPkceChallenge(optionsArg?.codeChallenge)) {
|
|
973
|
+
throw new RelyingPartyError('invalid_bearer_request');
|
|
974
|
+
}
|
|
975
|
+
const codeChallenge = optionsArg.codeChallenge;
|
|
976
|
+
const maxAgeSeconds = optionsArg.maxAgeSeconds ?? this.sessionMaxAgeSeconds;
|
|
977
|
+
if (!Number.isSafeInteger(maxAgeSeconds) || maxAgeSeconds < 1 || maxAgeSeconds > this.sessionMaxAgeSeconds) {
|
|
978
|
+
throw new RangeError(`openBearerHandoff maxAgeSeconds must be a whole number from 1 to ${this.sessionMaxAgeSeconds}`);
|
|
979
|
+
}
|
|
980
|
+
await this.sweepBearerState();
|
|
981
|
+
await this.sweepEndedSessions();
|
|
982
|
+
// read after the sweeps, so the opener is as live as it can be when the session is created
|
|
983
|
+
const openerSessionId = optionsArg.openerSessionId;
|
|
984
|
+
const opener = typeof openerSessionId === 'string' && openerSessionId.length > 0
|
|
985
|
+
? await this.liveSession(await this.#models.Session.getInstance({ id: openerSessionId }))
|
|
986
|
+
: null;
|
|
987
|
+
if (!opener || typeof opener.document.idpSubject !== 'string' || opener.document.idpSubject.length === 0) {
|
|
988
|
+
throw new RelyingPartyError('invalid_handoff_opener');
|
|
989
|
+
}
|
|
990
|
+
// the token is never handed out: the session is reached by its code only, as a bearer login's
|
|
991
|
+
const session = this.newSessionDoc(randomToken(32), opener.document.userId, maxAgeSeconds);
|
|
992
|
+
await session.save();
|
|
993
|
+
let handoff: { code: string; expiresAt: number };
|
|
994
|
+
try {
|
|
995
|
+
handoff = await this.storeBearerHandoff(bearer, session, codeChallenge);
|
|
996
|
+
} catch (error) {
|
|
997
|
+
// the code never leaves, so the session is unreachable: it is revoked before the error goes on
|
|
998
|
+
try {
|
|
999
|
+
await this.discardUnopenedSession(session.id);
|
|
1000
|
+
} catch (discardError) {
|
|
1001
|
+
this.emit({ event: 'session_discard_failed', sessionId: session.id, message: errorMessage(discardError) });
|
|
1002
|
+
}
|
|
1003
|
+
throw error;
|
|
1004
|
+
}
|
|
1005
|
+
return { session: this.snapshot(session), code: handoff.code, expiresAt: handoff.expiresAt };
|
|
1006
|
+
}
|
|
1007
|
+
|
|
901
1008
|
/** `abandonLogin` for a bearer login: the provider came back without a code. */
|
|
902
1009
|
public async abandonBearerLogin(callbackUrlArg: URL): Promise<void> {
|
|
903
1010
|
const state = singleStateOf(callbackUrlArg);
|
|
@@ -958,9 +1065,11 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
958
1065
|
* access and refresh token answer, and the old access token stops working.
|
|
959
1066
|
* Rotation is strict — a client serializes its refreshes — because a
|
|
960
1067
|
* refresh token that comes back after it was spent ends the whole session.
|
|
961
|
-
* The provider login is checked on the way: a login
|
|
962
|
-
* the session here (`invalid_bearer_grant`), an
|
|
963
|
-
* with `AuthorityTokenError('authority_unavailable')`
|
|
1068
|
+
* The provider login of a bearer login is checked on the way: a login
|
|
1069
|
+
* idp.global ended ends the session here (`invalid_bearer_grant`), an
|
|
1070
|
+
* unreachable provider fails with `AuthorityTokenError('authority_unavailable')`
|
|
1071
|
+
* and spends nothing. A session `openBearerHandoff` opened has no provider
|
|
1072
|
+
* login (no `idpSubject` is stored) and rotates without the provider.
|
|
964
1073
|
*/
|
|
965
1074
|
public async refreshBearerTokens(refreshTokenArg: unknown): Promise<IRelyingPartyBearerTokens> {
|
|
966
1075
|
const bearer = this.requireBearer();
|
|
@@ -984,13 +1093,17 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
984
1093
|
if (!live) {
|
|
985
1094
|
throw new RelyingPartyError('invalid_bearer_grant');
|
|
986
1095
|
}
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1096
|
+
// decided by what the session stores alone, never by a failure: a session
|
|
1097
|
+
// with a provider subject is a provider login and always asks the provider
|
|
1098
|
+
if (typeof live.document.idpSubject === 'string') {
|
|
1099
|
+
try {
|
|
1100
|
+
await this.liveProviderToken(live.document.id, live.document.userId);
|
|
1101
|
+
} catch (error) {
|
|
1102
|
+
if (error instanceof AuthorityTokenError && error.code === 'authority_login_required') {
|
|
1103
|
+
throw new RelyingPartyError('invalid_bearer_grant');
|
|
1104
|
+
}
|
|
1105
|
+
throw error;
|
|
992
1106
|
}
|
|
993
|
-
throw error;
|
|
994
1107
|
}
|
|
995
1108
|
// the next generation first: a rotation that fails half way leaves an
|
|
996
1109
|
// unknown generation behind, never a session without a current one
|
|
@@ -1466,7 +1579,8 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1466
1579
|
}
|
|
1467
1580
|
try {
|
|
1468
1581
|
await beforeOpenedArg?.(session);
|
|
1469
|
-
|
|
1582
|
+
const delivery = this.bearerDeliveryOfAttempt(attemptArg);
|
|
1583
|
+
await this.#hooks.onProviderSessionOpened?.({ session: this.snapshot(session), ...(delivery ? { delivery } : {}) });
|
|
1470
1584
|
} catch (error) {
|
|
1471
1585
|
// the cookie (or the hand-off code) never leaves, so the session is
|
|
1472
1586
|
// unreachable: it is revoked and its sealed tokens erased before the
|
|
@@ -1500,6 +1614,29 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1500
1614
|
};
|
|
1501
1615
|
}
|
|
1502
1616
|
|
|
1617
|
+
/**
|
|
1618
|
+
* Stores a new hand-off of a session, bound to the client's challenge, and
|
|
1619
|
+
* answers its code, which is stored only as a hash. The code lives
|
|
1620
|
+
* `BEARER_HANDOFF_MAX_AGE_SECONDS` at most and never past the session.
|
|
1621
|
+
*/
|
|
1622
|
+
private async storeBearerHandoff(
|
|
1623
|
+
bearerArg: IBearerMode,
|
|
1624
|
+
sessionArg: RelyingPartySessionDoc,
|
|
1625
|
+
codeChallengeArg: string,
|
|
1626
|
+
): Promise<{ code: string; expiresAt: number }> {
|
|
1627
|
+
const code = randomToken(32);
|
|
1628
|
+
const handoff = new bearerArg.models.BearerHandoff();
|
|
1629
|
+
handoff.id = 'rbh_' + randomToken(18);
|
|
1630
|
+
handoff.codeHash = sha256(code);
|
|
1631
|
+
handoff.sessionId = sessionArg.id;
|
|
1632
|
+
handoff.codeChallenge = codeChallengeArg;
|
|
1633
|
+
handoff.createdAt = this.#now();
|
|
1634
|
+
handoff.expiresAt = Math.min(handoff.createdAt + BEARER_HANDOFF_MAX_AGE_SECONDS * 1_000, sessionArg.expiresAt);
|
|
1635
|
+
handoff.consumedAt = null;
|
|
1636
|
+
await handoff.save();
|
|
1637
|
+
return { code, expiresAt: handoff.expiresAt };
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1503
1640
|
/** Stores the next token generation of a live session and answers its tokens; none of them is stored in the clear. */
|
|
1504
1641
|
private async issueBearerTokens(bearerArg: IBearerMode, sessionArg: RelyingPartySessionDoc): Promise<IRelyingPartyBearerTokens> {
|
|
1505
1642
|
const accessToken = randomToken(32);
|
|
@@ -1591,11 +1728,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1591
1728
|
* local revocation and the provider revocation: a throw or a rejection is
|
|
1592
1729
|
* reported as `hook_failed` and the end runs on.
|
|
1593
1730
|
*/
|
|
1594
|
-
private notifySessionEnded(eventArg: {
|
|
1595
|
-
sessionId: string;
|
|
1596
|
-
reason: TRelyingPartySessionEndReason;
|
|
1597
|
-
revokedHere: boolean;
|
|
1598
|
-
}): void {
|
|
1731
|
+
private notifySessionEnded(eventArg: Parameters<NonNullable<IRelyingPartyHooks['onSessionEnded']>>[0]): void {
|
|
1599
1732
|
const hook = this.#hooks.onSessionEnded;
|
|
1600
1733
|
if (!hook) {
|
|
1601
1734
|
return;
|
|
@@ -1626,10 +1759,11 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
1626
1759
|
return null;
|
|
1627
1760
|
}
|
|
1628
1761
|
const before = await this.revokeSessionDocument(sessionIdArg);
|
|
1629
|
-
this.notifySessionEnded({ sessionId: sessionIdArg, reason: reasonArg, revokedHere: before !== null });
|
|
1630
1762
|
if (!before) {
|
|
1763
|
+
this.notifySessionEnded({ sessionId: sessionIdArg, reason: reasonArg, revokedHere: false });
|
|
1631
1764
|
return null;
|
|
1632
1765
|
}
|
|
1766
|
+
this.notifySessionEnded({ sessionId: sessionIdArg, reason: reasonArg, revokedHere: true, principalId: before.userId });
|
|
1633
1767
|
return { sealedAccessToken: before.sealedAccessToken, sealedRefreshToken: before.sealedRefreshToken };
|
|
1634
1768
|
}
|
|
1635
1769
|
|
|
@@ -41,6 +41,52 @@ export interface IRelyingPartyBearerMessage {
|
|
|
41
41
|
*/
|
|
42
42
|
export type TRelyingPartyBearerDeliveryError = 'access_denied' | 'authentication_failed' | (string & {});
|
|
43
43
|
|
|
44
|
+
/** RFC 7636 §4.1: a code verifier is 43 to 128 characters of `A-Z a-z 0-9 . _ ~ -`. */
|
|
45
|
+
const PKCE_VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/u;
|
|
46
|
+
|
|
47
|
+
/** Whether a value is a well-formed PKCE code verifier (RFC 7636 §4.1). */
|
|
48
|
+
export const isPkceVerifier = (valueArg: unknown): valueArg is string =>
|
|
49
|
+
typeof valueArg === 'string' && PKCE_VERIFIER.test(valueArg);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The query of `GET <prefix>bearer/login`, by its parameter names on the
|
|
53
|
+
* wire. The routes check every parameter as untrusted input.
|
|
54
|
+
*/
|
|
55
|
+
export interface IRelyingPartyBearerLoginQuery {
|
|
56
|
+
client_kind: TRelyingPartyBearerClientKind;
|
|
57
|
+
/** one of the configured return targets of the kind, exactly; a loopback target with the client's port */
|
|
58
|
+
return_target: string;
|
|
59
|
+
/** BASE64URL(SHA-256(code verifier)), 43 characters */
|
|
60
|
+
code_challenge: string;
|
|
61
|
+
/** only `S256` is accepted */
|
|
62
|
+
code_challenge_method: 'S256';
|
|
63
|
+
/** the client's own correlation value, 1 to 256 characters of `A-Z a-z 0-9 . _ ~ -`, handed back with the outcome */
|
|
64
|
+
state?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The JSON body of `POST <prefix>bearer/claim`. */
|
|
68
|
+
export interface IRelyingPartyBearerClaimRequest {
|
|
69
|
+
/** the hand-off code, claimed once */
|
|
70
|
+
code: string;
|
|
71
|
+
/** the verifier of the challenge the code is bound to (`isPkceVerifier`) */
|
|
72
|
+
codeVerifier: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The JSON body of `POST <prefix>bearer/refresh`. */
|
|
76
|
+
export interface IRelyingPartyBearerRefreshRequest {
|
|
77
|
+
/** the current refresh token; this call spends it */
|
|
78
|
+
refreshToken: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The JSON body of `POST <prefix>bearer/logout`. The session is named by
|
|
83
|
+
* `Authorization: Bearer <accessToken>`, or by `refreshToken` once that access
|
|
84
|
+
* token has expired.
|
|
85
|
+
*/
|
|
86
|
+
export interface IRelyingPartyBearerLogoutRequest {
|
|
87
|
+
refreshToken?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
44
90
|
/** The session a bearer answer names. */
|
|
45
91
|
export interface IRelyingPartyBearerSessionInfo {
|
|
46
92
|
sessionId: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
isPkceVerifier,
|
|
2
3
|
RELYING_PARTY_BEARER_MESSAGE_TYPE,
|
|
3
4
|
type IRelyingPartyBearerMessage,
|
|
4
5
|
type TRelyingPartyBearerClientKind,
|
|
@@ -7,7 +8,8 @@ import { sha256, timingSafeEqualString } from './helpers.crypto.js';
|
|
|
7
8
|
import { sanitizeReturnPath } from './helpers.returnpath.js';
|
|
8
9
|
import type { IRelyingPartyBearerDelivery, IRelyingPartyBearerReturnTargets } from './interfaces.js';
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
// the verifier check lives in the import-free contract, which the browser client shares
|
|
12
|
+
export { isPkceVerifier, RELYING_PARTY_BEARER_MESSAGE_TYPE };
|
|
11
13
|
|
|
12
14
|
export const RELYING_PARTY_BEARER_CLIENT_KINDS: readonly TRelyingPartyBearerClientKind[] = Object.freeze([
|
|
13
15
|
'web',
|
|
@@ -20,8 +22,6 @@ export const RELYING_PARTY_BEARER_CLIENT_KINDS: readonly TRelyingPartyBearerClie
|
|
|
20
22
|
const MAX_RETURN_TARGET_LENGTH = 2_048;
|
|
21
23
|
/** BASE64URL of a SHA-256 digest, without padding (RFC 7636 §4.2). */
|
|
22
24
|
const PKCE_CHALLENGE = /^[A-Za-z0-9_-]{43}$/u;
|
|
23
|
-
/** RFC 7636 §4.1: 43 to 128 unreserved characters. */
|
|
24
|
-
const PKCE_VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/u;
|
|
25
25
|
const CLIENT_STATE = /^[A-Za-z0-9._~-]{1,256}$/u;
|
|
26
26
|
const LOOPBACK_HOSTS: readonly string[] = ['127.0.0.1', '[::1]'];
|
|
27
27
|
/** A private-use scheme is a reverse domain name: it has a dot, and no web scheme has one (RFC 8252 §7.1). */
|
|
@@ -33,9 +33,6 @@ export const isBearerClientKind = (valueArg: unknown): valueArg is TRelyingParty
|
|
|
33
33
|
export const isPkceChallenge = (valueArg: unknown): valueArg is string =>
|
|
34
34
|
typeof valueArg === 'string' && PKCE_CHALLENGE.test(valueArg);
|
|
35
35
|
|
|
36
|
-
export const isPkceVerifier = (valueArg: unknown): valueArg is string =>
|
|
37
|
-
typeof valueArg === 'string' && PKCE_VERIFIER.test(valueArg);
|
|
38
|
-
|
|
39
36
|
export const isBearerClientState = (valueArg: unknown): valueArg is string =>
|
|
40
37
|
typeof valueArg === 'string' && CLIENT_STATE.test(valueArg);
|
|
41
38
|
|