@idp.global/sdk 18.3.1 → 19.1.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.
Files changed (31) hide show
  1. package/changelog.md +27 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts_relyingparty/classes.errors.d.ts +9 -1
  4. package/dist_ts_relyingparty/classes.errors.js +11 -2
  5. package/dist_ts_relyingparty/classes.idpoidcprovider.d.ts +10 -0
  6. package/dist_ts_relyingparty/classes.idpoidcprovider.js +3 -1
  7. package/dist_ts_relyingparty/classes.relyingpartyroutes.d.ts +6 -0
  8. package/dist_ts_relyingparty/classes.relyingpartyroutes.js +19 -1
  9. package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +8 -3
  10. package/dist_ts_relyingparty/classes.relyingpartysessions.js +34 -13
  11. package/dist_ts_relyingparty/classes.rpmodels.d.ts +2 -0
  12. package/dist_ts_relyingparty/classes.rpmodels.js +9 -2
  13. package/dist_ts_relyingparty/classes.socketbinding.d.ts +33 -14
  14. package/dist_ts_relyingparty/classes.socketbinding.js +1 -2
  15. package/dist_ts_relyingparty/interfaces.d.ts +22 -3
  16. package/dist_ts_relyingparty/plugins.d.ts +1 -2
  17. package/dist_ts_relyingparty/plugins.js +2 -3
  18. package/dist_ts_relyingparty/testing.d.ts +103 -0
  19. package/dist_ts_relyingparty/testing.js +222 -0
  20. package/package.json +8 -7
  21. package/readme.md +74 -5
  22. package/ts/00_commitinfo_data.ts +1 -1
  23. package/ts_relyingparty/classes.errors.ts +11 -0
  24. package/ts_relyingparty/classes.idpoidcprovider.ts +13 -0
  25. package/ts_relyingparty/classes.relyingpartyroutes.ts +19 -0
  26. package/ts_relyingparty/classes.relyingpartysessions.ts +37 -13
  27. package/ts_relyingparty/classes.rpmodels.ts +4 -0
  28. package/ts_relyingparty/classes.socketbinding.ts +38 -18
  29. package/ts_relyingparty/interfaces.ts +24 -3
  30. package/ts_relyingparty/plugins.ts +1 -2
  31. package/ts_relyingparty/testing.ts +306 -0
@@ -6,6 +6,7 @@ import {
6
6
  RelyingPartyRefusal,
7
7
  } from './classes.errors.js';
8
8
  import {
9
+ OIDC_PROMPTS,
9
10
  OidcCompletionError,
10
11
  OidcRefreshRefusedError,
11
12
  type IOidcCompletion,
@@ -22,7 +23,9 @@ import type {
22
23
  IProviderToken,
23
24
  IRelyingPartyEvent,
24
25
  IRelyingPartyHooks,
26
+ IRelyingPartyAdmission,
25
27
  IRelyingPartyLiveSession,
28
+ IRelyingPartyLoginOptions,
26
29
  IRelyingPartyLoginStart,
27
30
  IRelyingPartySession,
28
31
  IRelyingPartySessionOpening,
@@ -41,6 +44,8 @@ const MAX_COOKIE_TOKEN_LENGTH = 256;
41
44
  const SESSION_TOUCH_INTERVAL_MS = 60_000;
42
45
  /** A sign-in transaction has to survive an interactive login at the provider, not a working day. */
43
46
  export const LOGIN_ATTEMPT_MAX_AGE_SECONDS = 10 * 60;
47
+ /** The longest intent a login carries (`IRelyingPartyLoginOptions.intent`), in characters. */
48
+ export const RELYING_PARTY_MAX_INTENT_LENGTH = 4_096;
44
49
  /** Attempts are swept once they are this much older than their expiry. */
45
50
  const LOGIN_ATTEMPT_SWEEP_GRACE_MS = 60 * 60 * 1_000;
46
51
  /** How many of them one sign-in removes; the next sign-in takes the following page. */
@@ -141,7 +146,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
141
146
  public readonly loginPathPrefix: string;
142
147
  public readonly sessionMaxAgeSeconds: number;
143
148
  readonly #models: IRelyingPartyModels;
144
- readonly #admit: TRelyingPartyPrincipals<TPrincipal>['admit'];
149
+ readonly #admit: (completionArg: IOidcCompletion, admissionArg: IRelyingPartyAdmission) => Promise<{ principalId: string }>;
145
150
  /** the principal of a session when it may act right now, `null` otherwise */
146
151
  readonly #activePrincipal: (principalIdArg: string) => Promise<{ principal: TPrincipal } | null>;
147
152
  readonly #provider?: IOidcProvider;
@@ -195,7 +200,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
195
200
  if ((typeof resolve === 'function') === (typeof isActive === 'function')) {
196
201
  throw new Error('principals needs exactly one of isActive and resolve');
197
202
  }
198
- this.#admit = (completionArg) => principals.admit(completionArg);
203
+ this.#admit = (completionArg, admissionArg) => principals.admit(completionArg, admissionArg);
199
204
  // choose the form by the same test the check above used, so a present but
200
205
  // undefined `resolve` (a spread, a widened object) selects `isActive`
201
206
  if (typeof resolve === 'function') {
@@ -243,16 +248,22 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
243
248
 
244
249
  /**
245
250
  * Opens a session the application authenticated itself (a local password
246
- * login). It carries no provider tokens.
251
+ * login, a one-time link of its own). It carries no provider tokens.
252
+ * `maxAgeSeconds` makes it live shorter than `sessionMaxAgeSeconds`, the
253
+ * cookie with it; it is a whole number of seconds from 1 to that maximum.
247
254
  */
248
- public async openSession(optionsArg: { principalId: string }): Promise<IRelyingPartySessionOpening> {
255
+ public async openSession(optionsArg: { principalId: string; maxAgeSeconds?: number }): Promise<IRelyingPartySessionOpening> {
249
256
  if (typeof optionsArg?.principalId !== 'string' || optionsArg.principalId.length === 0) {
250
257
  throw new Error('openSession needs a principal id');
251
258
  }
259
+ const maxAgeSeconds = optionsArg.maxAgeSeconds ?? this.sessionMaxAgeSeconds;
260
+ if (!Number.isSafeInteger(maxAgeSeconds) || maxAgeSeconds < 1 || maxAgeSeconds > this.sessionMaxAgeSeconds) {
261
+ throw new RangeError(`openSession maxAgeSeconds must be a whole number from 1 to ${this.sessionMaxAgeSeconds}`);
262
+ }
252
263
  const token = randomToken(32);
253
- const session = this.newSessionDoc(token, optionsArg.principalId);
264
+ const session = this.newSessionDoc(token, optionsArg.principalId, maxAgeSeconds);
254
265
  await session.save();
255
- return this.opening(session, token);
266
+ return this.opening(session, token, maxAgeSeconds);
256
267
  }
257
268
 
258
269
  /** The live session behind the session cookie, or `null`. A read touches it at most once a minute. */
@@ -378,8 +389,15 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
378
389
  * sealed next to it, and an intent cookie that binds the callback to this
379
390
  * browser.
380
391
  */
381
- public async startLogin(returnPathArg: unknown): Promise<IRelyingPartyLoginStart> {
392
+ public async startLogin(returnPathArg: unknown, optionsArg: IRelyingPartyLoginOptions = {}): Promise<IRelyingPartyLoginStart> {
382
393
  const provider = this.requireProvider();
394
+ const { intent, prompt } = optionsArg;
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
+ }
383
401
  await this.sweepExpiredAttempts();
384
402
  const state = randomToken(32);
385
403
  const nonce = randomToken(32);
@@ -394,12 +412,15 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
394
412
  attempt.sealedPkceVerifier = this.#secretBox.seal(pkceVerifier);
395
413
  attempt.nonce = nonce;
396
414
  attempt.returnPath = sanitizeReturnPath(returnPathArg, this.publicOrigin, this.loginPathPrefix);
415
+ if (intent !== undefined) {
416
+ attempt.sealedIntent = this.#secretBox.seal(intent);
417
+ }
397
418
  attempt.createdAt = now;
398
419
  attempt.expiresAt = now + LOGIN_ATTEMPT_MAX_AGE_SECONDS * 1_000;
399
420
  await attempt.save();
400
421
 
401
422
  return {
402
- authorizationUrl: await provider.buildAuthorizationUrl({ state, nonce, pkceVerifier }),
423
+ authorizationUrl: await provider.buildAuthorizationUrl({ state, nonce, pkceVerifier, ...(prompt ? { prompt } : {}) }),
403
424
  intentCookie: serializeCookie(this.intentCookieName, bindingToken, {
404
425
  httpOnly: true,
405
426
  secure: this.secureCookies,
@@ -456,7 +477,10 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
456
477
  pkceVerifier: this.#secretBox.open(attempt.sealedPkceVerifier),
457
478
  });
458
479
  stage = 'principal_admission';
459
- const { principalId } = await this.#admit(completion);
480
+ const { principalId } = await this.#admit(completion, {
481
+ loginAttemptId: attempt.id,
482
+ ...(attempt.sealedIntent ? { intent: this.#secretBox.open(attempt.sealedIntent) } : {}),
483
+ });
460
484
  stage = 'session_persistence';
461
485
  return await this.createProviderSession(attempt, principalId, completion);
462
486
  } catch (error) {
@@ -696,7 +720,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
696
720
  return this.#provider;
697
721
  }
698
722
 
699
- private newSessionDoc(tokenArg: string, principalIdArg: string): RelyingPartySessionDoc {
723
+ private newSessionDoc(tokenArg: string, principalIdArg: string, maxAgeSecondsArg: number = this.sessionMaxAgeSeconds): RelyingPartySessionDoc {
700
724
  const now = this.#now();
701
725
  const session = new this.#models.Session();
702
726
  session.id = 'ses_' + randomToken(18);
@@ -704,7 +728,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
704
728
  session.userId = principalIdArg;
705
729
  session.createdAt = now;
706
730
  session.lastSeenAt = now;
707
- session.expiresAt = now + this.sessionMaxAgeSeconds * 1_000;
731
+ session.expiresAt = now + maxAgeSecondsArg * 1_000;
708
732
  return session;
709
733
  }
710
734
 
@@ -712,7 +736,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
712
736
  return deriveCsrfToken(tokenArg, this.#csrfKey, this.#csrfLabel);
713
737
  }
714
738
 
715
- private opening(sessionArg: RelyingPartySessionDoc, tokenArg: string): IRelyingPartySessionOpening {
739
+ private opening(sessionArg: RelyingPartySessionDoc, tokenArg: string, maxAgeSecondsArg: number = this.sessionMaxAgeSeconds): IRelyingPartySessionOpening {
716
740
  return {
717
741
  session: this.snapshot(sessionArg),
718
742
  csrfToken: this.csrfTokenOf(tokenArg),
@@ -720,7 +744,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
720
744
  httpOnly: true,
721
745
  secure: this.secureCookies,
722
746
  sameSite: 'Lax',
723
- maxAgeSeconds: this.sessionMaxAgeSeconds,
747
+ maxAgeSeconds: maxAgeSecondsArg,
724
748
  }),
725
749
  };
726
750
  }
@@ -86,6 +86,10 @@ export class RelyingPartyLoginAttemptDoc extends plugins.nosqldb.SmartDataDbDoc<
86
86
  @plugins.nosqldb.svDb()
87
87
  public returnPath: string = '/';
88
88
 
89
+ /** the application's intent for this login, sealed like the verifier (`IRelyingPartyLoginOptions.intent`); absent without one */
90
+ @plugins.nosqldb.svDb()
91
+ public sealedIntent?: string;
92
+
89
93
  @plugins.nosqldb.svDb()
90
94
  public createdAt: number = 0;
91
95
 
@@ -1,21 +1,35 @@
1
- import * as plugins from './plugins.js';
2
1
  import type { RelyingPartySessions } from './classes.relyingpartysessions.js';
3
2
  import { sha256, timingSafeEqualString } from './helpers.crypto.js';
4
3
  import type { IRelyingPartySession } from './interfaces.js';
5
4
 
6
- /** The part of a server `TypedSocket` the binding uses. */
7
- export type TRelyingPartySocketTransport = Pick<
8
- plugins.typedsocket.TypedSocket,
9
- 'setServerTag' | 'findAllTargetConnectionsByTag'
10
- >;
5
+ /**
6
+ * The WebSocket peer underneath a connection, as far as the binding reads it:
7
+ * the headers its upgrade arrived with. A SmartServe peer of any major fits.
8
+ */
9
+ export type TRelyingPartySocketPeer = {
10
+ context: { headers: { get(nameArg: string): string | null } };
11
+ };
11
12
 
12
- export type TRelyingPartySocketConnection = plugins.typedsocket.ISmartServeConnectionWrapper;
13
+ /** A server connection as the binding reads it: its peer. A TypedSocket connection wrapper of any major fits. */
14
+ export type TRelyingPartySocketConnection = {
15
+ peer: TRelyingPartySocketPeer;
16
+ };
13
17
 
14
- /** The SmartServe WebSocket peer underneath a connection, as a TypedSocket server hands it out. */
15
- export type TRelyingPartySocketPeer = TRelyingPartySocketConnection['peer'];
18
+ /** A connection or its peer; `TypedSocket.setServerTag` takes either. */
19
+ export type TRelyingPartySocketTarget<TConnection extends TRelyingPartySocketConnection = TRelyingPartySocketConnection> =
20
+ | TConnection
21
+ | TConnection['peer'];
16
22
 
17
- /** A connection wrapper or its peer; `TypedSocket.setServerTag` takes either. */
18
- export type TRelyingPartySocketTarget = TRelyingPartySocketConnection | TRelyingPartySocketPeer;
23
+ /**
24
+ * The part of the application's server `TypedSocket` the binding uses. The
25
+ * binding names no TypedSocket or SmartServe version: the application's own
26
+ * transport fits whichever major it runs, and `connectionsOfSession` answers
27
+ * its own connection type.
28
+ */
29
+ export type TRelyingPartySocketTransport<TConnection extends TRelyingPartySocketConnection = TRelyingPartySocketConnection> = {
30
+ setServerTag(targetArg: TRelyingPartySocketTarget<TConnection>, nameArg: string): void;
31
+ findAllTargetConnectionsByTag(nameArg: string): Promise<TConnection[]>;
32
+ };
19
33
 
20
34
  const peerOf = (targetArg: TRelyingPartySocketTarget): TRelyingPartySocketPeer =>
21
35
  'peer' in targetArg ? targetArg.peer : targetArg;
@@ -37,10 +51,13 @@ export type TRelyingPartySocketRegistration =
37
51
  | { registered: true; pinned: IRelyingPartyPinnedSession; expiresAt: number }
38
52
  | { registered: false; refusal: IRelyingPartySocketRefusal };
39
53
 
40
- export interface IRelyingPartySocketBindingOptions<TPrincipal extends object | undefined = undefined> {
54
+ export interface IRelyingPartySocketBindingOptions<
55
+ TPrincipal extends object | undefined = undefined,
56
+ TConnection extends TRelyingPartySocketConnection = TRelyingPartySocketConnection,
57
+ > {
41
58
  sessions: RelyingPartySessions<TPrincipal>;
42
59
  /** the transport typedserver created; `undefined` before the server runs */
43
- transport: () => TRelyingPartySocketTransport | undefined;
60
+ transport: () => TRelyingPartySocketTransport<TConnection> | undefined;
44
61
  /** the server-owned tag prefix that finds the connections of a session */
45
62
  tagPrefix?: string;
46
63
  }
@@ -54,12 +71,15 @@ export interface IRelyingPartySocketBindingOptions<TPrincipal extends object | u
54
71
  * request. Connection limits, rate limits and sweeps belong to the
55
72
  * application's socket gate.
56
73
  */
57
- export class RelyingPartySocketBinding<TPrincipal extends object | undefined = undefined> {
74
+ export class RelyingPartySocketBinding<
75
+ TPrincipal extends object | undefined = undefined,
76
+ TConnection extends TRelyingPartySocketConnection = TRelyingPartySocketConnection,
77
+ > {
58
78
  readonly #sessions: RelyingPartySessions<TPrincipal>;
59
- readonly #transport: () => TRelyingPartySocketTransport | undefined;
79
+ readonly #transport: () => TRelyingPartySocketTransport<TConnection> | undefined;
60
80
  public readonly tagPrefix: string;
61
81
 
62
- constructor(optionsArg: IRelyingPartySocketBindingOptions<TPrincipal>) {
82
+ constructor(optionsArg: IRelyingPartySocketBindingOptions<TPrincipal, TConnection>) {
63
83
  this.#sessions = optionsArg.sessions;
64
84
  this.#transport = optionsArg.transport;
65
85
  this.tagPrefix = optionsArg.tagPrefix ?? 'idp_rp_session:';
@@ -112,7 +132,7 @@ export class RelyingPartySocketBinding<TPrincipal extends object | undefined = u
112
132
  * Tags a connection — its wrapper or its peer — with its session; throws
113
133
  * when the transport is not running or the connection is gone.
114
134
  */
115
- public tagConnection(targetArg: TRelyingPartySocketTarget, sessionIdArg: string): void {
135
+ public tagConnection(targetArg: TRelyingPartySocketTarget<TConnection>, sessionIdArg: string): void {
116
136
  const transport = this.#transport();
117
137
  if (!transport) {
118
138
  throw new Error('The socket transport is not running');
@@ -146,7 +166,7 @@ export class RelyingPartySocketBinding<TPrincipal extends object | undefined = u
146
166
  }
147
167
 
148
168
  /** The live connections of one session, for closing them when it ends. */
149
- public async connectionsOfSession(sessionIdArg: string): Promise<readonly TRelyingPartySocketConnection[]> {
169
+ public async connectionsOfSession(sessionIdArg: string): Promise<readonly TConnection[]> {
150
170
  const transport = this.#transport();
151
171
  if (!transport) {
152
172
  return [];
@@ -1,4 +1,4 @@
1
- import type { IOidcCompletion, TOidcCompletionFailureStage, TOidcTokenHint } from './classes.idpoidcprovider.js';
1
+ import type { IOidcCompletion, TOidcCompletionFailureStage, TOidcPrompt, TOidcTokenHint } from './classes.idpoidcprovider.js';
2
2
 
3
3
  /** A session as the application reads it; no token and no sealed value is part of it. */
4
4
  export interface IRelyingPartySession {
@@ -35,6 +35,27 @@ export interface IRelyingPartySessionOpening {
35
35
  sessionCookie: string;
36
36
  }
37
37
 
38
+ /**
39
+ * What `startLogin` carries besides the return path. `intent` is the
40
+ * application's own text — the invitation a login accepts, the account it
41
+ * links, the client it authorizes — sealed into the login attempt and handed
42
+ * to admission (`IRelyingPartyAdmission`) when exactly this login comes back;
43
+ * it never travels through the browser or the provider.
44
+ */
45
+ export interface IRelyingPartyLoginOptions {
46
+ /** at most `RELYING_PARTY_MAX_INTENT_LENGTH` characters, not empty */
47
+ intent?: string;
48
+ prompt?: TOidcPrompt;
49
+ }
50
+
51
+ /** What admission learns about the login besides the provider's completion. */
52
+ export interface IRelyingPartyAdmission {
53
+ /** the intent `startLogin` was given for this login, opened from its seal; absent when there was none */
54
+ intent?: string;
55
+ /** the login attempt the session will name (`IRelyingPartySession.loginAttemptId`) */
56
+ loginAttemptId: string;
57
+ }
58
+
38
59
  /** The redirect that starts a provider login, and the cookie that binds it to this browser. */
39
60
  export interface IRelyingPartyLoginStart {
40
61
  authorizationUrl: URL;
@@ -66,7 +87,7 @@ export interface IAuthorityTokenSource {
66
87
  * then handed back and the refusal reaches the caller unchanged.
67
88
  */
68
89
  export interface IRelyingPartyPrincipals {
69
- admit(completionArg: IOidcCompletion): Promise<{ principalId: string }>;
90
+ admit(completionArg: IOidcCompletion, admissionArg: IRelyingPartyAdmission): Promise<{ principalId: string }>;
70
91
  /** Whether a principal may act right now; a session of an inactive principal resolves to nothing. */
71
92
  isActive(principalIdArg: string): Promise<boolean>;
72
93
  }
@@ -78,7 +99,7 @@ export interface IRelyingPartyPrincipals {
78
99
  * `principal`. Give either `isActive` or `resolve`, never both.
79
100
  */
80
101
  export interface IRelyingPartyPrincipalResolver<TPrincipal extends object | undefined> {
81
- admit(completionArg: IOidcCompletion): Promise<{ principalId: string }>;
102
+ admit(completionArg: IOidcCompletion, admissionArg: IRelyingPartyAdmission): Promise<{ principalId: string }>;
82
103
  /** The principal object when it may act right now, `null` otherwise; any answer that is not an object (including `false`) resolves the session to nothing. */
83
104
  resolve(principalIdArg: string): Promise<TPrincipal | null>;
84
105
  }
@@ -4,9 +4,8 @@ import * as crypto from 'node:crypto';
4
4
  export { asyncHooks, crypto };
5
5
 
6
6
  import * as typedrequest from '@api.global/typedrequest';
7
- import * as typedsocket from '@api.global/typedsocket';
8
7
 
9
- export { typedrequest, typedsocket };
8
+ export { typedrequest };
10
9
 
11
10
  import * as nosqldb from '@lossless.org/client/nosqldb';
12
11
 
@@ -0,0 +1,306 @@
1
+ import * as plugins from './plugins.js';
2
+ import {
3
+ OidcCompletionError,
4
+ OidcRefreshRefusedError,
5
+ type IOidcAuthorizationOptions,
6
+ type IOidcCompletion,
7
+ type IOidcCompletionOptions,
8
+ type IOidcOrganizationClaim,
9
+ type IOidcOrganizationObservation,
10
+ type IOidcProvider,
11
+ type IOidcRefreshedTokens,
12
+ type TOidcTokenHint,
13
+ } from './classes.idpoidcprovider.js';
14
+
15
+ /**
16
+ * `@idp.global/sdk/relyingparty/testing`: an in-process idp.global for the
17
+ * tests of an application built on `RelyingPartySessions`. It is a test
18
+ * double and nothing else — no socket, no persistence, no check a real
19
+ * provider makes beyond the ones named here — and belongs in test code only.
20
+ */
21
+
22
+ /** A person at the double, with the organizations idp.global reports for them. */
23
+ export interface IProviderDoublePersona {
24
+ /** the stable `sub` */
25
+ subject: string;
26
+ displayName?: string;
27
+ email?: string;
28
+ /** `email_verified`; `true` when absent, as for an active idp.global account */
29
+ emailVerified?: boolean;
30
+ /** the `organizations` claim, and what introspection answers for each organization */
31
+ organizations?: IOidcOrganizationClaim[];
32
+ }
33
+
34
+ export interface IProviderDoubleOptions {
35
+ /** the issuer origin the authorization URLs point at; `https://idp.test` by default */
36
+ issuer?: string;
37
+ /** how long an access token lives, in seconds; 3600 by default, `0` names no lifetime */
38
+ accessTokenLifetimeSeconds?: number;
39
+ /** the clock every expiry reads; tests step it instead of sleeping */
40
+ now?: () => number;
41
+ requestTimeoutSeconds?: number;
42
+ }
43
+
44
+ interface IIssuedCode {
45
+ state: string;
46
+ nonce: string;
47
+ subject: string;
48
+ }
49
+
50
+ interface IIssuedToken {
51
+ subject: string;
52
+ family: string;
53
+ /** epoch milliseconds; `0` for no expiry */
54
+ expiresAt: number;
55
+ kind: 'access' | 'refresh';
56
+ revoked: boolean;
57
+ /** a refresh token that was rotated: using it again is reuse */
58
+ used: boolean;
59
+ }
60
+
61
+ const randomToken = (): string => plugins.crypto.randomBytes(18).toString('base64url');
62
+
63
+ /**
64
+ * The provider as `IOidcProvider` sees it. A test adds personas, names the one
65
+ * the next login signs in as (`signInNextAs`), follows the authorization URL
66
+ * back with `callbackUrlFor`, and changes what idp.global reports live
67
+ * (`setOrganizationRoles`, `endLoginsOf`). Codes are single use and bound to
68
+ * their login's state; access tokens introspect for their subject while they
69
+ * live; refresh tokens rotate, and a superseded one coming back revokes its
70
+ * whole family, as idp.global's reuse detection does. Every call is recorded,
71
+ * and each step can be made to fail.
72
+ */
73
+ export class RelyingPartyProviderDouble implements IOidcProvider {
74
+ public readonly requestTimeoutSeconds: number;
75
+ public readonly issuer: string;
76
+ public accessTokenLifetimeSeconds: number;
77
+
78
+ public readonly authorizations: IOidcAuthorizationOptions[] = [];
79
+ public readonly completions: Array<{ subject: string; state: string }> = [];
80
+ public readonly revocations: Array<{ token: string; hint: TOidcTokenHint }> = [];
81
+ public readonly introspections: Array<{ accessToken: string; subject: string; organizationId: string }> = [];
82
+ public readonly refreshes: string[] = [];
83
+
84
+ /** when set, the completion of a login fails with it (an `OidcCompletionError` names its stage) */
85
+ public completionFailure?: Error;
86
+ /** when set, every introspection fails with it (an `OidcAuthorityUnavailableError` for an unreachable provider) */
87
+ public introspectionFailure?: Error;
88
+ /** when set, every refresh fails with it */
89
+ public refreshFailure?: Error;
90
+ /** when set, every revocation fails with it, after it was recorded */
91
+ public revocationFailure?: Error;
92
+ /** runs inside an introspection before it answers: a test holds one open with it */
93
+ public onIntrospect?: (optionsArg: { accessToken: string; subject: string; organizationId: string }) => Promise<void>;
94
+ /** runs inside a refresh before it answers: a test races a logout with it */
95
+ public onRefresh?: (refreshTokenArg: string) => Promise<void>;
96
+
97
+ readonly #now: () => number;
98
+ readonly #personas = new Map<string, IProviderDoublePersona>();
99
+ readonly #codes = new Map<string, IIssuedCode>();
100
+ readonly #tokens = new Map<string, IIssuedToken>();
101
+ #next?: string;
102
+
103
+ constructor(optionsArg: IProviderDoubleOptions = {}) {
104
+ this.issuer = new URL(optionsArg.issuer ?? 'https://idp.test').origin;
105
+ this.accessTokenLifetimeSeconds = optionsArg.accessTokenLifetimeSeconds ?? 3_600;
106
+ this.requestTimeoutSeconds = optionsArg.requestTimeoutSeconds ?? 10;
107
+ this.#now = optionsArg.now ?? (() => Date.now());
108
+ }
109
+
110
+ // ---- personas ----
111
+
112
+ /** Adds a persona, or replaces the one with its subject. */
113
+ public addPersona(personaArg: IProviderDoublePersona): void {
114
+ this.#personas.set(personaArg.subject, {
115
+ ...personaArg,
116
+ organizations: (personaArg.organizations ?? []).map((entryArg) => ({ ...entryArg, roles: [...entryArg.roles] })),
117
+ });
118
+ }
119
+
120
+ public persona(subjectArg: string): IProviderDoublePersona | undefined {
121
+ return this.#personas.get(subjectArg);
122
+ }
123
+
124
+ /** The persona the next authorization signs in as; a persona given whole is added first. */
125
+ public signInNextAs(personaArg: string | IProviderDoublePersona): void {
126
+ if (typeof personaArg !== 'string') {
127
+ this.addPersona(personaArg);
128
+ }
129
+ const subject = typeof personaArg === 'string' ? personaArg : personaArg.subject;
130
+ if (!this.#personas.has(subject)) {
131
+ throw new Error(`RelyingPartyProviderDouble: no persona ${subject}; addPersona first`);
132
+ }
133
+ this.#next = subject;
134
+ }
135
+
136
+ /**
137
+ * What idp.global reports for the persona in an organization from now on:
138
+ * its roles, or `null` when the persona is no member any more. Introspection
139
+ * reads it at once; the claims of a login are those of its sign-in.
140
+ */
141
+ public setOrganizationRoles(subjectArg: string, organizationIdArg: string, rolesArg: string[] | null): void {
142
+ const persona = this.requirePersona(subjectArg);
143
+ const organizations = (persona.organizations ?? []).filter((entryArg) => entryArg.id !== organizationIdArg);
144
+ if (rolesArg) {
145
+ const known = persona.organizations?.find((entryArg) => entryArg.id === organizationIdArg);
146
+ organizations.push({
147
+ id: organizationIdArg,
148
+ name: known?.name ?? organizationIdArg,
149
+ slug: known?.slug ?? organizationIdArg,
150
+ roles: [...rolesArg],
151
+ });
152
+ }
153
+ persona.organizations = organizations;
154
+ }
155
+
156
+ /** Ends every login of the persona at the provider: its tokens neither introspect nor rotate any more. */
157
+ public endLoginsOf(subjectArg: string): void {
158
+ for (const token of this.#tokens.values()) {
159
+ if (token.subject === subjectArg) {
160
+ token.revoked = true;
161
+ }
162
+ }
163
+ }
164
+
165
+ /** Where the provider would send the browser back after the login this authorization URL started. */
166
+ public callbackUrlFor(authorizationUrlArg: URL, redirectUriArg: URL | string): URL {
167
+ const callback = new URL(redirectUriArg);
168
+ callback.searchParams.set('code', authorizationUrlArg.searchParams.get('code') ?? '');
169
+ callback.searchParams.set('state', authorizationUrlArg.searchParams.get('state') ?? '');
170
+ return callback;
171
+ }
172
+
173
+ // ---- IOidcProvider ----
174
+
175
+ public async init(): Promise<void> {}
176
+
177
+ public async buildAuthorizationUrl(optionsArg: IOidcAuthorizationOptions): Promise<URL> {
178
+ if (!this.#next) {
179
+ throw new Error('RelyingPartyProviderDouble: name the persona this login signs in as with signInNextAs');
180
+ }
181
+ this.authorizations.push({ ...optionsArg });
182
+ const code = 'code_' + randomToken();
183
+ this.#codes.set(code, { state: optionsArg.state, nonce: optionsArg.nonce, subject: this.#next });
184
+ const url = new URL('/oauth/authorize', this.issuer);
185
+ url.searchParams.set('response_type', 'code');
186
+ url.searchParams.set('state', optionsArg.state);
187
+ url.searchParams.set('nonce', optionsArg.nonce);
188
+ if (optionsArg.prompt) {
189
+ url.searchParams.set('prompt', optionsArg.prompt);
190
+ }
191
+ // what the double hands back at once; a real provider answers it after the login page
192
+ url.searchParams.set('code', code);
193
+ return url;
194
+ }
195
+
196
+ public async completeAuthorization(optionsArg: IOidcCompletionOptions): Promise<IOidcCompletion> {
197
+ const code = optionsArg.callbackUrl.searchParams.get('code') ?? '';
198
+ const issued = this.#codes.get(code);
199
+ this.#codes.delete(code);
200
+ if (!issued || issued.state !== optionsArg.state) {
201
+ throw new OidcCompletionError('token_exchange', new Error('unknown, used or foreign code'));
202
+ }
203
+ if (issued.nonce !== optionsArg.nonce) {
204
+ throw new OidcCompletionError('token_validation', new Error('nonce mismatch'));
205
+ }
206
+ this.completions.push({ subject: issued.subject, state: issued.state });
207
+ if (this.completionFailure) {
208
+ throw this.completionFailure;
209
+ }
210
+ const persona = this.requirePersona(issued.subject);
211
+ const family = randomToken();
212
+ const access = this.issue('access', issued.subject, family);
213
+ const refresh = this.issue('refresh', issued.subject, family);
214
+ return {
215
+ subject: persona.subject,
216
+ accessToken: access.token,
217
+ refreshToken: refresh.token,
218
+ accessTokenExpiresAt: access.expiresAt,
219
+ ...(persona.email !== undefined ? { email: persona.email } : {}),
220
+ emailVerified: persona.emailVerified ?? true,
221
+ ...(persona.displayName !== undefined ? { displayName: persona.displayName } : {}),
222
+ organizations: (persona.organizations ?? []).map((entryArg) => ({ ...entryArg, roles: [...entryArg.roles] })),
223
+ };
224
+ }
225
+
226
+ public async revokeToken(tokenArg: string, hintArg: TOidcTokenHint): Promise<void> {
227
+ this.revocations.push({ token: tokenArg, hint: hintArg });
228
+ if (this.revocationFailure) {
229
+ throw this.revocationFailure;
230
+ }
231
+ const token = this.#tokens.get(tokenArg);
232
+ if (token) {
233
+ token.revoked = true;
234
+ }
235
+ }
236
+
237
+ public async introspectOrganization(optionsArg: {
238
+ accessToken: string;
239
+ subject: string;
240
+ organizationId: string;
241
+ }): Promise<IOidcOrganizationObservation> {
242
+ this.introspections.push({ ...optionsArg });
243
+ await this.onIntrospect?.({ ...optionsArg });
244
+ if (this.introspectionFailure) {
245
+ throw this.introspectionFailure;
246
+ }
247
+ const token = this.#tokens.get(optionsArg.accessToken);
248
+ const inactive: IOidcOrganizationObservation = { active: false, roles: [], expiresAt: 0 };
249
+ if (!token || token.kind !== 'access' || !this.isLive(token) || token.subject !== optionsArg.subject) {
250
+ return inactive;
251
+ }
252
+ const membership = this.#personas.get(token.subject)?.organizations?.find((entryArg) => entryArg.id === optionsArg.organizationId);
253
+ if (!membership) {
254
+ return inactive;
255
+ }
256
+ return { active: true, roles: [...membership.roles].sort(), expiresAt: token.expiresAt };
257
+ }
258
+
259
+ public async refreshTokens(refreshTokenArg: string): Promise<IOidcRefreshedTokens> {
260
+ this.refreshes.push(refreshTokenArg);
261
+ await this.onRefresh?.(refreshTokenArg);
262
+ if (this.refreshFailure) {
263
+ throw this.refreshFailure;
264
+ }
265
+ const token = this.#tokens.get(refreshTokenArg);
266
+ if (!token || token.kind !== 'refresh' || token.revoked) {
267
+ throw new OidcRefreshRefusedError(new Error('invalid_grant'));
268
+ }
269
+ if (token.used) {
270
+ // reuse of a superseded refresh token: the family dies
271
+ for (const entry of this.#tokens.values()) {
272
+ if (entry.family === token.family) {
273
+ entry.revoked = true;
274
+ }
275
+ }
276
+ throw new OidcRefreshRefusedError(new Error('invalid_grant'));
277
+ }
278
+ token.used = true;
279
+ const access = this.issue('access', token.subject, token.family);
280
+ const refresh = this.issue('refresh', token.subject, token.family);
281
+ return { accessToken: access.token, refreshToken: refresh.token, accessTokenExpiresAt: access.expiresAt };
282
+ }
283
+
284
+ // ---- internals ----
285
+
286
+ private requirePersona(subjectArg: string): IProviderDoublePersona {
287
+ const persona = this.#personas.get(subjectArg);
288
+ if (!persona) {
289
+ throw new Error(`RelyingPartyProviderDouble: no persona ${subjectArg}`);
290
+ }
291
+ return persona;
292
+ }
293
+
294
+ private issue(kindArg: IIssuedToken['kind'], subjectArg: string, familyArg: string): { token: string; expiresAt: number } {
295
+ const token = (kindArg === 'access' ? 'at_' : 'rt_') + randomToken();
296
+ const expiresAt = kindArg === 'access' && this.accessTokenLifetimeSeconds > 0
297
+ ? this.#now() + this.accessTokenLifetimeSeconds * 1_000
298
+ : 0;
299
+ this.#tokens.set(token, { subject: subjectArg, family: familyArg, expiresAt, kind: kindArg, revoked: false, used: false });
300
+ return { token, expiresAt };
301
+ }
302
+
303
+ private isLive(tokenArg: IIssuedToken): boolean {
304
+ return !tokenArg.revoked && (tokenArg.expiresAt === 0 || tokenArg.expiresAt > this.#now());
305
+ }
306
+ }