@idp.global/sdk 19.0.0 → 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.
@@ -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
  }
@@ -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
+ }