@idp.global/sdk 18.0.1 → 18.2.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 (50) hide show
  1. package/changelog.md +28 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts_relyingparty/classes.actorscope.d.ts +23 -0
  4. package/dist_ts_relyingparty/classes.actorscope.js +22 -0
  5. package/dist_ts_relyingparty/classes.authorityobserver.d.ts +81 -0
  6. package/dist_ts_relyingparty/classes.authorityobserver.js +185 -0
  7. package/dist_ts_relyingparty/classes.errors.d.ts +37 -0
  8. package/dist_ts_relyingparty/classes.errors.js +60 -0
  9. package/dist_ts_relyingparty/classes.idpoidcprovider.d.ts +142 -0
  10. package/dist_ts_relyingparty/classes.idpoidcprovider.js +255 -0
  11. package/dist_ts_relyingparty/classes.relyingpartyroutes.d.ts +102 -0
  12. package/dist_ts_relyingparty/classes.relyingpartyroutes.js +248 -0
  13. package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +223 -0
  14. package/dist_ts_relyingparty/classes.relyingpartysessions.js +825 -0
  15. package/dist_ts_relyingparty/classes.rpmodels.d.ts +87 -0
  16. package/dist_ts_relyingparty/classes.rpmodels.js +302 -0
  17. package/dist_ts_relyingparty/classes.secretbox.d.ts +14 -0
  18. package/dist_ts_relyingparty/classes.secretbox.js +45 -0
  19. package/dist_ts_relyingparty/classes.socketbinding.d.ts +84 -0
  20. package/dist_ts_relyingparty/classes.socketbinding.js +100 -0
  21. package/dist_ts_relyingparty/helpers.cookies.d.ts +11 -0
  22. package/dist_ts_relyingparty/helpers.cookies.js +62 -0
  23. package/dist_ts_relyingparty/helpers.crypto.d.ts +12 -0
  24. package/dist_ts_relyingparty/helpers.crypto.js +40 -0
  25. package/dist_ts_relyingparty/helpers.returnpath.d.ts +9 -0
  26. package/dist_ts_relyingparty/helpers.returnpath.js +39 -0
  27. package/dist_ts_relyingparty/index.d.ts +13 -0
  28. package/dist_ts_relyingparty/index.js +14 -0
  29. package/dist_ts_relyingparty/interfaces.d.ts +142 -0
  30. package/dist_ts_relyingparty/interfaces.js +2 -0
  31. package/dist_ts_relyingparty/plugins.d.ts +10 -0
  32. package/dist_ts_relyingparty/plugins.js +11 -0
  33. package/package.json +13 -10
  34. package/readme.md +215 -4
  35. package/ts/00_commitinfo_data.ts +1 -1
  36. package/ts_relyingparty/classes.actorscope.ts +39 -0
  37. package/ts_relyingparty/classes.authorityobserver.ts +253 -0
  38. package/ts_relyingparty/classes.errors.ts +77 -0
  39. package/ts_relyingparty/classes.idpoidcprovider.ts +386 -0
  40. package/ts_relyingparty/classes.relyingpartyroutes.ts +319 -0
  41. package/ts_relyingparty/classes.relyingpartysessions.ts +1017 -0
  42. package/ts_relyingparty/classes.rpmodels.ts +170 -0
  43. package/ts_relyingparty/classes.secretbox.ts +49 -0
  44. package/ts_relyingparty/classes.socketbinding.ts +156 -0
  45. package/ts_relyingparty/helpers.cookies.ts +79 -0
  46. package/ts_relyingparty/helpers.crypto.ts +49 -0
  47. package/ts_relyingparty/helpers.returnpath.ts +44 -0
  48. package/ts_relyingparty/index.ts +13 -0
  49. package/ts_relyingparty/interfaces.ts +165 -0
  50. package/ts_relyingparty/plugins.ts +17 -0
@@ -0,0 +1,386 @@
1
+ import * as plugins from './plugins.js';
2
+ import {
3
+ OidcIntrospectionClient,
4
+ OidcIntrospectionError,
5
+ } from '../ts_server/classes.oidc-introspection-client.js';
6
+
7
+ export interface IOidcAuthorizationOptions {
8
+ state: string;
9
+ nonce: string;
10
+ pkceVerifier: string;
11
+ }
12
+
13
+ export interface IOidcCompletionOptions extends IOidcAuthorizationOptions {
14
+ /** the callback as the application's own public origin saw it */
15
+ callbackUrl: URL;
16
+ }
17
+
18
+ /** One organization the provider reports for the subject; display data, not an authorization grant. */
19
+ export interface IOidcOrganizationClaim {
20
+ id: string;
21
+ name: string;
22
+ slug: string;
23
+ roles: string[];
24
+ }
25
+
26
+ export interface IOidcCompletion {
27
+ subject: string;
28
+ accessToken: string;
29
+ refreshToken?: string;
30
+ /** epoch milliseconds; `0` when the token response named no lifetime */
31
+ accessTokenExpiresAt: number;
32
+ email?: string;
33
+ emailVerified: boolean;
34
+ displayName?: string;
35
+ organizations: IOidcOrganizationClaim[];
36
+ }
37
+
38
+ /**
39
+ * One live observation of a subject's authority in one organization, as
40
+ * `POST /oauth/introspect` answers it. An inactive answer names no reason:
41
+ * revoked token, dead login session, removed membership and disconnected app
42
+ * are deliberately indistinguishable.
43
+ */
44
+ export interface IOidcOrganizationObservation {
45
+ active: boolean;
46
+ /** the sorted membership role keys; empty when inactive */
47
+ roles: readonly string[];
48
+ /** the observation's own `exp` in epoch milliseconds; `0` when inactive */
49
+ expiresAt: number;
50
+ }
51
+
52
+ export interface IOidcRefreshedTokens {
53
+ accessToken: string;
54
+ /** the rotated refresh token; absent when the provider returned none */
55
+ refreshToken?: string;
56
+ /** epoch milliseconds; `0` when the token response named no lifetime */
57
+ accessTokenExpiresAt: number;
58
+ }
59
+
60
+ /** Why an observation or a refresh could not be made; never why access was refused. */
61
+ export type TOidcAuthorityFailure = 'unavailable' | 'invalid_response';
62
+
63
+ /** The provider could not be asked, or did not answer in its own contract. The caller fails closed on it. */
64
+ export class OidcAuthorityUnavailableError extends Error {
65
+ constructor(
66
+ public readonly reason: TOidcAuthorityFailure,
67
+ causeArg?: unknown,
68
+ ) {
69
+ super('The identity provider could not be observed', { cause: causeArg });
70
+ this.name = 'OidcAuthorityUnavailableError';
71
+ }
72
+ }
73
+
74
+ /** The provider refused the refresh with `invalid_grant`: the refresh family is revoked, expired or was reused. */
75
+ export class OidcRefreshRefusedError extends Error {
76
+ constructor(causeArg?: unknown) {
77
+ super('The identity provider refused the refresh', { cause: causeArg });
78
+ this.name = 'OidcRefreshRefusedError';
79
+ }
80
+ }
81
+
82
+ export type TOidcTokenHint = 'access_token' | 'refresh_token';
83
+
84
+ export type TOidcCompletionFailureStage = 'token_exchange' | 'token_validation' | 'userinfo';
85
+
86
+ export class OidcCompletionError extends Error {
87
+ constructor(
88
+ public readonly stage: TOidcCompletionFailureStage,
89
+ causeArg: unknown,
90
+ ) {
91
+ super('OIDC completion failed', { cause: causeArg });
92
+ this.name = 'OidcCompletionError';
93
+ }
94
+ }
95
+
96
+ /**
97
+ * What the session layer needs from the provider. `IdpOidcProvider` is the
98
+ * implementation; tests bind a double to it, so no test opens a socket.
99
+ */
100
+ export interface IOidcProvider {
101
+ /**
102
+ * The deadline of every provider request, in seconds. The refresh lock that
103
+ * keeps two replicas from spending one refresh token outlives it.
104
+ */
105
+ readonly requestTimeoutSeconds: number;
106
+ init(): Promise<void>;
107
+ buildAuthorizationUrl(optionsArg: IOidcAuthorizationOptions): Promise<URL>;
108
+ completeAuthorization(optionsArg: IOidcCompletionOptions): Promise<IOidcCompletion>;
109
+ revokeToken(tokenArg: string, hintArg: TOidcTokenHint): Promise<void>;
110
+ /** Throws `OidcAuthorityUnavailableError` rather than guessing an answer. */
111
+ introspectOrganization(optionsArg: {
112
+ accessToken: string;
113
+ subject: string;
114
+ organizationId: string;
115
+ }): Promise<IOidcOrganizationObservation>;
116
+ /** Throws `OidcRefreshRefusedError` on `invalid_grant`, `OidcAuthorityUnavailableError` for everything else. */
117
+ refreshTokens(refreshTokenArg: string): Promise<IOidcRefreshedTokens>;
118
+ }
119
+
120
+ /**
121
+ * The scopes a relying party asks for. `organizations` and `roles` are what
122
+ * idp.global reports memberships under.
123
+ */
124
+ export const IDP_OIDC_DEFAULT_SCOPES = 'openid profile email organizations roles';
125
+
126
+ export const IDP_OIDC_DEFAULT_REQUEST_TIMEOUT_SECONDS = 10;
127
+
128
+ export interface IIdpOidcProviderOptions {
129
+ /** the issuer origin, `https://…`; the introspection client refuses anything else */
130
+ issuer: URL;
131
+ clientId: string;
132
+ clientSecret: string;
133
+ /** the registered callback, on the application's public origin */
134
+ redirectUri: URL;
135
+ scopes?: string;
136
+ /** deadline of discovery, token, UserInfo, refresh and revocation requests */
137
+ requestTimeoutSeconds?: number;
138
+ /** deadline of one introspection; the introspection client's default applies when absent */
139
+ introspectionTimeoutMs?: number;
140
+ }
141
+
142
+ const optionalString = (valueArg: unknown): string | undefined =>
143
+ typeof valueArg === 'string' && valueArg.trim().length > 0 ? valueArg.trim() : undefined;
144
+
145
+ /** When an access token dies, from the `expires_in` of a token response; `0` when it names none. */
146
+ const expiryOf = (expiresInArg: number | undefined): number =>
147
+ typeof expiresInArg === 'number' && Number.isFinite(expiresInArg) && expiresInArg > 0
148
+ ? Date.now() + Math.floor(expiresInArg) * 1_000
149
+ : 0;
150
+
151
+ /**
152
+ * Organization claims as idp.global documents them: `id`, `name`, `slug` and
153
+ * the member's roles. Anything that is not that exact shape is dropped rather
154
+ * than guessed at.
155
+ */
156
+ export const readOrganizationClaims = (valueArg: unknown): IOidcOrganizationClaim[] => {
157
+ if (!Array.isArray(valueArg)) {
158
+ return [];
159
+ }
160
+ const organizations: IOidcOrganizationClaim[] = [];
161
+ for (const entry of valueArg) {
162
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
163
+ continue;
164
+ }
165
+ const candidate = entry as Record<string, unknown>;
166
+ const id = optionalString(candidate.id);
167
+ const name = optionalString(candidate.name);
168
+ const slug = optionalString(candidate.slug);
169
+ if (!id || !name || !slug) {
170
+ continue;
171
+ }
172
+ const roles = Array.isArray(candidate.roles)
173
+ ? candidate.roles.filter((roleArg): roleArg is string => typeof roleArg === 'string')
174
+ : [];
175
+ organizations.push({ id, name, slug, roles });
176
+ }
177
+ return organizations;
178
+ };
179
+
180
+ /**
181
+ * The relying-party side of the authorization code flow with S256 PKCE on
182
+ * `openid-client`, in the profile idp.global supports: code responses, S256
183
+ * challenges, RS256 ID tokens and `client_secret_basic` for a confidential
184
+ * client. Live organization authority is observed with the sdk's
185
+ * `OidcIntrospectionClient`.
186
+ */
187
+ export class IdpOidcProvider implements IOidcProvider {
188
+ public readonly requestTimeoutSeconds: number;
189
+ readonly #options: IIdpOidcProviderOptions;
190
+ #configuration?: plugins.openidClient.Configuration;
191
+ #introspection?: OidcIntrospectionClient;
192
+
193
+ constructor(optionsArg: IIdpOidcProviderOptions) {
194
+ if (!(optionsArg?.issuer instanceof URL) || !(optionsArg.redirectUri instanceof URL)) {
195
+ throw new Error('IdpOidcProvider needs the issuer and the redirect URI as URLs');
196
+ }
197
+ if (!optionalString(optionsArg.clientId) || !optionalString(optionsArg.clientSecret)) {
198
+ throw new Error('IdpOidcProvider needs a client id and a client secret');
199
+ }
200
+ const timeout = optionsArg.requestTimeoutSeconds ?? IDP_OIDC_DEFAULT_REQUEST_TIMEOUT_SECONDS;
201
+ if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 60) {
202
+ throw new Error('IdpOidcProvider requestTimeoutSeconds must be an integer from 1 to 60');
203
+ }
204
+ this.requestTimeoutSeconds = timeout;
205
+ this.#options = optionsArg;
206
+ }
207
+
208
+ private get configuration(): plugins.openidClient.Configuration {
209
+ if (!this.#configuration) {
210
+ throw new Error('The identity provider is not initialized');
211
+ }
212
+ return this.#configuration;
213
+ }
214
+
215
+ public async init(): Promise<void> {
216
+ if (this.#configuration) {
217
+ return;
218
+ }
219
+ const options = this.#options;
220
+ // the timeout given to discovery bounds discovery itself and stays on the
221
+ // resolved configuration for every later request
222
+ const configuration = await plugins.openidClient.discovery(
223
+ options.issuer,
224
+ options.clientId,
225
+ {
226
+ client_secret: options.clientSecret,
227
+ token_endpoint_auth_method: 'client_secret_basic',
228
+ id_token_signed_response_alg: 'RS256',
229
+ },
230
+ plugins.openidClient.ClientSecretBasic(options.clientSecret),
231
+ { timeout: this.requestTimeoutSeconds },
232
+ );
233
+ const metadata = configuration.serverMetadata();
234
+ if (!metadata.authorization_endpoint || !metadata.token_endpoint || !metadata.jwks_uri) {
235
+ throw new Error('The identity provider is missing required OIDC endpoints');
236
+ }
237
+ if (!metadata.code_challenge_methods_supported?.includes('S256')) {
238
+ throw new Error('The identity provider must support PKCE S256');
239
+ }
240
+ if (
241
+ metadata.id_token_signing_alg_values_supported
242
+ && !metadata.id_token_signing_alg_values_supported.includes('RS256')
243
+ ) {
244
+ throw new Error('The identity provider must sign ID tokens with RS256');
245
+ }
246
+ if (metadata.grant_types_supported && !metadata.grant_types_supported.includes('authorization_code')) {
247
+ throw new Error('The identity provider must support the authorization_code grant');
248
+ }
249
+ if (metadata.grant_types_supported && !metadata.grant_types_supported.includes('refresh_token')) {
250
+ // without rotation the access token dies long before the session does
251
+ throw new Error('The identity provider must support the refresh_token grant');
252
+ }
253
+ this.#introspection = new OidcIntrospectionClient({
254
+ issuer: options.issuer.origin,
255
+ clientId: options.clientId,
256
+ clientSecret: options.clientSecret,
257
+ ...(options.introspectionTimeoutMs !== undefined ? { timeoutMs: options.introspectionTimeoutMs } : {}),
258
+ });
259
+ this.#configuration = configuration;
260
+ }
261
+
262
+ public async buildAuthorizationUrl(optionsArg: IOidcAuthorizationOptions): Promise<URL> {
263
+ const codeChallenge = await plugins.openidClient.calculatePKCECodeChallenge(optionsArg.pkceVerifier);
264
+ return plugins.openidClient.buildAuthorizationUrl(this.configuration, {
265
+ redirect_uri: this.#options.redirectUri.href,
266
+ response_type: 'code',
267
+ scope: this.#options.scopes ?? IDP_OIDC_DEFAULT_SCOPES,
268
+ code_challenge: codeChallenge,
269
+ code_challenge_method: 'S256',
270
+ state: optionsArg.state,
271
+ nonce: optionsArg.nonce,
272
+ });
273
+ }
274
+
275
+ public async completeAuthorization(optionsArg: IOidcCompletionOptions): Promise<IOidcCompletion> {
276
+ let tokens: Awaited<ReturnType<typeof plugins.openidClient.authorizationCodeGrant>>;
277
+ try {
278
+ tokens = await plugins.openidClient.authorizationCodeGrant(this.configuration, optionsArg.callbackUrl, {
279
+ pkceCodeVerifier: optionsArg.pkceVerifier,
280
+ expectedState: optionsArg.state,
281
+ expectedNonce: optionsArg.nonce,
282
+ idTokenExpected: true,
283
+ });
284
+ } catch (error) {
285
+ throw new OidcCompletionError('token_exchange', error);
286
+ }
287
+
288
+ let subject: string;
289
+ try {
290
+ const claims = tokens.claims();
291
+ if (!claims?.sub) {
292
+ throw new Error('The token response carries no subject');
293
+ }
294
+ subject = claims.sub;
295
+ } catch (error) {
296
+ throw new OidcCompletionError('token_validation', error);
297
+ }
298
+
299
+ // UserInfo is the current view of profile and organization claims; the ID
300
+ // token proves the login. Both are read, and the subjects must agree.
301
+ let userInfo: Awaited<ReturnType<typeof plugins.openidClient.fetchUserInfo>>;
302
+ try {
303
+ userInfo = await plugins.openidClient.fetchUserInfo(this.configuration, tokens.access_token, subject);
304
+ } catch (error) {
305
+ throw new OidcCompletionError('userinfo', error);
306
+ }
307
+ if (userInfo.sub !== subject) {
308
+ throw new OidcCompletionError('token_validation', new Error('The UserInfo subject does not match the ID token'));
309
+ }
310
+
311
+ return {
312
+ subject,
313
+ accessToken: tokens.access_token,
314
+ ...(tokens.refresh_token ? { refreshToken: tokens.refresh_token } : {}),
315
+ accessTokenExpiresAt: expiryOf(tokens.expires_in),
316
+ email: optionalString(userInfo.email),
317
+ emailVerified: userInfo.email_verified === true,
318
+ displayName: optionalString(userInfo.name) || optionalString(userInfo.preferred_username),
319
+ organizations: readOrganizationClaims(Reflect.get(userInfo, 'organizations')),
320
+ };
321
+ }
322
+
323
+ public async revokeToken(tokenArg: string, hintArg: TOidcTokenHint): Promise<void> {
324
+ await plugins.openidClient.tokenRevocation(this.configuration, tokenArg, { token_type_hint: hintArg });
325
+ }
326
+
327
+ /**
328
+ * One `POST /oauth/introspect` through the confidential introspection
329
+ * client, which validates the whole answer before this code sees it. A
330
+ * refusal (`active: false`) is an answer; anything else is a failure to
331
+ * observe.
332
+ */
333
+ public async introspectOrganization(optionsArg: {
334
+ accessToken: string;
335
+ subject: string;
336
+ organizationId: string;
337
+ }): Promise<IOidcOrganizationObservation> {
338
+ if (!this.#introspection) {
339
+ throw new Error('The identity provider is not initialized');
340
+ }
341
+ let response: Awaited<ReturnType<OidcIntrospectionClient['introspect']>>;
342
+ try {
343
+ response = await this.#introspection.introspect({
344
+ token: optionsArg.accessToken,
345
+ subject: optionsArg.subject,
346
+ organizationId: optionsArg.organizationId,
347
+ });
348
+ } catch (error) {
349
+ if (error instanceof OidcIntrospectionError) {
350
+ if (error.code === 'invalid_request') {
351
+ // a request the endpoint's own client refuses is a defect of the
352
+ // caller, never an answer about access
353
+ throw error;
354
+ }
355
+ throw new OidcAuthorityUnavailableError(error.code, error);
356
+ }
357
+ throw new OidcAuthorityUnavailableError('unavailable', error);
358
+ }
359
+ if (!response.active) {
360
+ return { active: false, roles: [], expiresAt: 0 };
361
+ }
362
+ return {
363
+ active: true,
364
+ roles: response.idp_global_authority.roles,
365
+ expiresAt: response.exp * 1_000,
366
+ };
367
+ }
368
+
369
+ /** The refresh grant with rotation; a superseded refresh token is never kept by the caller. */
370
+ public async refreshTokens(refreshTokenArg: string): Promise<IOidcRefreshedTokens> {
371
+ let tokens: Awaited<ReturnType<typeof plugins.openidClient.refreshTokenGrant>>;
372
+ try {
373
+ tokens = await plugins.openidClient.refreshTokenGrant(this.configuration, refreshTokenArg);
374
+ } catch (error) {
375
+ if (error instanceof plugins.openidClient.ResponseBodyError && error.error === 'invalid_grant') {
376
+ throw new OidcRefreshRefusedError(error);
377
+ }
378
+ throw new OidcAuthorityUnavailableError('unavailable', error);
379
+ }
380
+ return {
381
+ accessToken: tokens.access_token,
382
+ ...(tokens.refresh_token ? { refreshToken: tokens.refresh_token } : {}),
383
+ accessTokenExpiresAt: expiryOf(tokens.expires_in),
384
+ };
385
+ }
386
+ }