@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.
@@ -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 = randomBase64Url();
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
- url.searchParams.set('client_kind', clientKind);
382
- url.searchParams.set('return_target', returnTarget);
383
- url.searchParams.set('code_challenge', await pkceChallengeOf(codeVerifier));
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, { refreshToken: current.refreshToken }, {}, signalArg));
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 answer = await this.post(
722
- this.#paths.logout,
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: Record<string, string>,
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 is a provider login */
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 is a provider login');
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 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,
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
- const code = randomToken(32);
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
- 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;
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: (handoff as RelyingPartyBearerHandoffDoc).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 idp.global ended ends
962
- * the session here (`invalid_bearer_grant`), an unreachable provider fails
963
- * with `AuthorityTokenError('authority_unavailable')` and spends nothing.
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
- 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');
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
- await this.#hooks.onProviderSessionOpened?.({ session: this.snapshot(session) });
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
- export { RELYING_PARTY_BEARER_MESSAGE_TYPE };
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