@idp.global/sdk 19.0.0 → 19.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.
- package/changelog.md +32 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts_browser/classes.idpclient.js +1 -1
- package/dist_ts_browser/classes.idprequests.js +1 -1
- package/dist_ts_relyingparty/classes.authorityobserver.js +1 -1
- package/dist_ts_relyingparty/classes.errors.d.ts +9 -1
- package/dist_ts_relyingparty/classes.errors.js +11 -2
- package/dist_ts_relyingparty/classes.idpoidcprovider.d.ts +15 -0
- package/dist_ts_relyingparty/classes.idpoidcprovider.js +6 -2
- package/dist_ts_relyingparty/classes.relyingpartyroutes.d.ts +6 -0
- package/dist_ts_relyingparty/classes.relyingpartyroutes.js +19 -1
- package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +8 -3
- package/dist_ts_relyingparty/classes.relyingpartysessions.js +36 -13
- package/dist_ts_relyingparty/classes.rpmodels.d.ts +4 -2
- package/dist_ts_relyingparty/classes.rpmodels.js +9 -2
- package/dist_ts_relyingparty/helpers.cookies.d.ts +1 -1
- package/dist_ts_relyingparty/helpers.cookies.js +1 -1
- package/dist_ts_relyingparty/helpers.crypto.js +1 -1
- package/dist_ts_relyingparty/interfaces.d.ts +22 -3
- package/dist_ts_relyingparty/testing.d.ts +116 -0
- package/dist_ts_relyingparty/testing.js +266 -0
- package/dist_ts_server/classes.account-auth-service.js +1 -1
- package/dist_ts_server/classes.account-store.js +1 -1
- package/dist_ts_server/classes.idp-global-server-client.js +1 -1
- package/dist_ts_server/classes.oidc-introspection-client.js +1 -1
- package/dist_ts_server/classes.oidc-userinfo-client.js +1 -1
- package/package.json +7 -6
- package/readme.md +65 -4
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts_relyingparty/classes.errors.ts +11 -0
- package/ts_relyingparty/classes.idpoidcprovider.ts +21 -1
- package/ts_relyingparty/classes.relyingpartyroutes.ts +19 -0
- package/ts_relyingparty/classes.relyingpartysessions.ts +39 -13
- package/ts_relyingparty/classes.rpmodels.ts +4 -0
- package/ts_relyingparty/interfaces.ts +24 -3
- package/ts_relyingparty/testing.ts +352 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import {
|
|
3
|
+
OidcAuthorityUnavailableError,
|
|
4
|
+
OidcCompletionError,
|
|
5
|
+
OidcRefreshRefusedError,
|
|
6
|
+
type IOidcAuthorizationOptions,
|
|
7
|
+
type IOidcCompletion,
|
|
8
|
+
type IOidcCompletionOptions,
|
|
9
|
+
type IOidcOrganizationClaim,
|
|
10
|
+
type IOidcOrganizationObservation,
|
|
11
|
+
type IOidcProvider,
|
|
12
|
+
type IOidcRefreshedTokens,
|
|
13
|
+
type TOidcTokenHint,
|
|
14
|
+
} from './classes.idpoidcprovider.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `@idp.global/sdk/relyingparty/testing`: an in-process idp.global for the
|
|
18
|
+
* tests of an application built on `RelyingPartySessions`. It is a test
|
|
19
|
+
* double and nothing else — no socket, no persistence, no check a real
|
|
20
|
+
* provider makes beyond the ones named here — and belongs in test code only.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** A person at the double, with the organizations idp.global reports for them. */
|
|
24
|
+
export interface IProviderDoublePersona {
|
|
25
|
+
/** the stable `sub` */
|
|
26
|
+
subject: string;
|
|
27
|
+
displayName?: string;
|
|
28
|
+
email?: string;
|
|
29
|
+
/** `email_verified`; `true` when absent, as for an active idp.global account */
|
|
30
|
+
emailVerified?: boolean;
|
|
31
|
+
/** the `organizations` claim, and what introspection answers for each organization */
|
|
32
|
+
organizations?: IOidcOrganizationClaim[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface IProviderDoubleOptions {
|
|
36
|
+
/** the issuer origin the authorization URLs point at; `https://idp.test` by default */
|
|
37
|
+
issuer?: string;
|
|
38
|
+
/** how long an access token lives, in seconds; 3600 by default, `0` names no lifetime */
|
|
39
|
+
accessTokenLifetimeSeconds?: number;
|
|
40
|
+
/** the clock every expiry reads; tests step it instead of sleeping */
|
|
41
|
+
now?: () => number;
|
|
42
|
+
requestTimeoutSeconds?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface IIssuedCode {
|
|
46
|
+
state: string;
|
|
47
|
+
nonce: string;
|
|
48
|
+
subject: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface IIssuedToken {
|
|
52
|
+
subject: string;
|
|
53
|
+
family: string;
|
|
54
|
+
/** epoch milliseconds; `0` for no expiry */
|
|
55
|
+
expiresAt: number;
|
|
56
|
+
kind: 'access' | 'refresh';
|
|
57
|
+
revoked: boolean;
|
|
58
|
+
/** a refresh token that was rotated: using it again is reuse */
|
|
59
|
+
used: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const randomToken = (): string => plugins.crypto.randomBytes(18).toString('base64url');
|
|
63
|
+
|
|
64
|
+
/** What `IdpOidcProvider` fails with when a step needs the discovery that has not succeeded. */
|
|
65
|
+
const notDiscovered = (): Error => new Error('The identity provider is not initialized');
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The provider as `IOidcProvider` sees it. A test adds personas, names the one
|
|
69
|
+
* the next login signs in as (`signInNextAs`), follows the authorization URL
|
|
70
|
+
* back with `callbackUrlFor`, and changes what idp.global reports live
|
|
71
|
+
* (`setOrganizationRoles`, `endLoginsOf`). Codes are single use and bound to
|
|
72
|
+
* their login's state; access tokens introspect for their subject while they
|
|
73
|
+
* live; refresh tokens rotate, and a superseded one coming back revokes its
|
|
74
|
+
* whole family, as idp.global's reuse detection does. Every call is recorded,
|
|
75
|
+
* and each step can be made to fail. Discovery as well (`discoveryFailure`):
|
|
76
|
+
* until an `init` succeeds, the double then answers as `IdpOidcProvider` does
|
|
77
|
+
* before discovery.
|
|
78
|
+
*/
|
|
79
|
+
export class RelyingPartyProviderDouble implements IOidcProvider {
|
|
80
|
+
public readonly requestTimeoutSeconds: number;
|
|
81
|
+
public readonly issuer: string;
|
|
82
|
+
public accessTokenLifetimeSeconds: number;
|
|
83
|
+
|
|
84
|
+
public readonly authorizations: IOidcAuthorizationOptions[] = [];
|
|
85
|
+
public readonly completions: Array<{ subject: string; state: string }> = [];
|
|
86
|
+
public readonly revocations: Array<{ token: string; hint: TOidcTokenHint }> = [];
|
|
87
|
+
public readonly introspections: Array<{ accessToken: string; subject: string; organizationId: string }> = [];
|
|
88
|
+
public readonly refreshes: string[] = [];
|
|
89
|
+
/** how often `init` was called, failed or not */
|
|
90
|
+
public discoveries = 0;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* when set, an `init` before one has succeeded fails with it, and from then
|
|
94
|
+
* until an `init` succeeds no authorization URL is built, a completion fails
|
|
95
|
+
* at the token exchange and introspection and refresh fail as unavailable,
|
|
96
|
+
* as before discovery; such a step is not recorded, as no request of it
|
|
97
|
+
* reached the provider. Once an `init` has succeeded, discovery stays done,
|
|
98
|
+
* as with `IdpOidcProvider`
|
|
99
|
+
*/
|
|
100
|
+
public discoveryFailure?: Error;
|
|
101
|
+
/** when set, the completion of a login fails with it (an `OidcCompletionError` names its stage) */
|
|
102
|
+
public completionFailure?: Error;
|
|
103
|
+
/** when set, every introspection fails with it (an `OidcAuthorityUnavailableError` for an unreachable provider) */
|
|
104
|
+
public introspectionFailure?: Error;
|
|
105
|
+
/** when set, every refresh fails with it */
|
|
106
|
+
public refreshFailure?: Error;
|
|
107
|
+
/** when set, every revocation fails with it, after it was recorded */
|
|
108
|
+
public revocationFailure?: Error;
|
|
109
|
+
/** runs inside an introspection before it answers: a test holds one open with it */
|
|
110
|
+
public onIntrospect?: (optionsArg: { accessToken: string; subject: string; organizationId: string }) => Promise<void>;
|
|
111
|
+
/** runs inside a refresh before it answers: a test races a logout with it */
|
|
112
|
+
public onRefresh?: (refreshTokenArg: string) => Promise<void>;
|
|
113
|
+
|
|
114
|
+
readonly #now: () => number;
|
|
115
|
+
readonly #personas = new Map<string, IProviderDoublePersona>();
|
|
116
|
+
readonly #codes = new Map<string, IIssuedCode>();
|
|
117
|
+
readonly #tokens = new Map<string, IIssuedToken>();
|
|
118
|
+
#next?: string;
|
|
119
|
+
/** the double counts as discovered until an `init` fails, and for good once one has succeeded */
|
|
120
|
+
#discovered = true;
|
|
121
|
+
#initialized = false;
|
|
122
|
+
|
|
123
|
+
constructor(optionsArg: IProviderDoubleOptions = {}) {
|
|
124
|
+
this.issuer = new URL(optionsArg.issuer ?? 'https://idp.test').origin;
|
|
125
|
+
this.accessTokenLifetimeSeconds = optionsArg.accessTokenLifetimeSeconds ?? 3_600;
|
|
126
|
+
this.requestTimeoutSeconds = optionsArg.requestTimeoutSeconds ?? 10;
|
|
127
|
+
this.#now = optionsArg.now ?? (() => Date.now());
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---- personas ----
|
|
131
|
+
|
|
132
|
+
/** Adds a persona, or replaces the one with its subject. */
|
|
133
|
+
public addPersona(personaArg: IProviderDoublePersona): void {
|
|
134
|
+
this.#personas.set(personaArg.subject, {
|
|
135
|
+
...personaArg,
|
|
136
|
+
organizations: (personaArg.organizations ?? []).map((entryArg) => ({ ...entryArg, roles: [...entryArg.roles] })),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
public persona(subjectArg: string): IProviderDoublePersona | undefined {
|
|
141
|
+
return this.#personas.get(subjectArg);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The persona the next authorization signs in as; a persona given whole is added first. */
|
|
145
|
+
public signInNextAs(personaArg: string | IProviderDoublePersona): void {
|
|
146
|
+
if (typeof personaArg !== 'string') {
|
|
147
|
+
this.addPersona(personaArg);
|
|
148
|
+
}
|
|
149
|
+
const subject = typeof personaArg === 'string' ? personaArg : personaArg.subject;
|
|
150
|
+
if (!this.#personas.has(subject)) {
|
|
151
|
+
throw new Error(`RelyingPartyProviderDouble: no persona ${subject}; addPersona first`);
|
|
152
|
+
}
|
|
153
|
+
this.#next = subject;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* What idp.global reports for the persona in an organization from now on:
|
|
158
|
+
* its roles, or `null` when the persona is no member any more. Introspection
|
|
159
|
+
* reads it at once; the claims of a login are those of its sign-in.
|
|
160
|
+
*/
|
|
161
|
+
public setOrganizationRoles(subjectArg: string, organizationIdArg: string, rolesArg: string[] | null): void {
|
|
162
|
+
const persona = this.requirePersona(subjectArg);
|
|
163
|
+
const organizations = (persona.organizations ?? []).filter((entryArg) => entryArg.id !== organizationIdArg);
|
|
164
|
+
if (rolesArg) {
|
|
165
|
+
const known = persona.organizations?.find((entryArg) => entryArg.id === organizationIdArg);
|
|
166
|
+
organizations.push({
|
|
167
|
+
id: organizationIdArg,
|
|
168
|
+
name: known?.name ?? organizationIdArg,
|
|
169
|
+
slug: known?.slug ?? organizationIdArg,
|
|
170
|
+
roles: [...rolesArg],
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
persona.organizations = organizations;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Ends every login of the persona at the provider: its tokens neither introspect nor rotate any more. */
|
|
177
|
+
public endLoginsOf(subjectArg: string): void {
|
|
178
|
+
for (const token of this.#tokens.values()) {
|
|
179
|
+
if (token.subject === subjectArg) {
|
|
180
|
+
token.revoked = true;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Where the provider would send the browser back after the login this authorization URL started. */
|
|
186
|
+
public callbackUrlFor(authorizationUrlArg: URL, redirectUriArg: URL | string): URL {
|
|
187
|
+
const callback = new URL(redirectUriArg);
|
|
188
|
+
callback.searchParams.set('code', authorizationUrlArg.searchParams.get('code') ?? '');
|
|
189
|
+
callback.searchParams.set('state', authorizationUrlArg.searchParams.get('state') ?? '');
|
|
190
|
+
return callback;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---- IOidcProvider ----
|
|
194
|
+
|
|
195
|
+
public async init(): Promise<void> {
|
|
196
|
+
this.discoveries += 1;
|
|
197
|
+
if (this.#initialized) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (this.discoveryFailure) {
|
|
201
|
+
this.#discovered = false;
|
|
202
|
+
throw this.discoveryFailure;
|
|
203
|
+
}
|
|
204
|
+
this.#discovered = true;
|
|
205
|
+
this.#initialized = true;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
public async buildAuthorizationUrl(optionsArg: IOidcAuthorizationOptions): Promise<URL> {
|
|
209
|
+
if (!this.#discovered) {
|
|
210
|
+
throw notDiscovered();
|
|
211
|
+
}
|
|
212
|
+
if (!this.#next) {
|
|
213
|
+
throw new Error('RelyingPartyProviderDouble: name the persona this login signs in as with signInNextAs');
|
|
214
|
+
}
|
|
215
|
+
this.authorizations.push({ ...optionsArg });
|
|
216
|
+
const code = 'code_' + randomToken();
|
|
217
|
+
this.#codes.set(code, { state: optionsArg.state, nonce: optionsArg.nonce, subject: this.#next });
|
|
218
|
+
const url = new URL('/oauth/authorize', this.issuer);
|
|
219
|
+
url.searchParams.set('response_type', 'code');
|
|
220
|
+
url.searchParams.set('state', optionsArg.state);
|
|
221
|
+
url.searchParams.set('nonce', optionsArg.nonce);
|
|
222
|
+
if (optionsArg.prompt) {
|
|
223
|
+
url.searchParams.set('prompt', optionsArg.prompt);
|
|
224
|
+
}
|
|
225
|
+
// what the double hands back at once; a real provider answers it after the login page
|
|
226
|
+
url.searchParams.set('code', code);
|
|
227
|
+
return url;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
public async completeAuthorization(optionsArg: IOidcCompletionOptions): Promise<IOidcCompletion> {
|
|
231
|
+
if (!this.#discovered) {
|
|
232
|
+
throw new OidcCompletionError('token_exchange', notDiscovered());
|
|
233
|
+
}
|
|
234
|
+
const code = optionsArg.callbackUrl.searchParams.get('code') ?? '';
|
|
235
|
+
const issued = this.#codes.get(code);
|
|
236
|
+
this.#codes.delete(code);
|
|
237
|
+
if (!issued || issued.state !== optionsArg.state) {
|
|
238
|
+
throw new OidcCompletionError('token_exchange', new Error('unknown, used or foreign code'));
|
|
239
|
+
}
|
|
240
|
+
if (issued.nonce !== optionsArg.nonce) {
|
|
241
|
+
throw new OidcCompletionError('token_validation', new Error('nonce mismatch'));
|
|
242
|
+
}
|
|
243
|
+
this.completions.push({ subject: issued.subject, state: issued.state });
|
|
244
|
+
if (this.completionFailure) {
|
|
245
|
+
throw this.completionFailure;
|
|
246
|
+
}
|
|
247
|
+
const persona = this.requirePersona(issued.subject);
|
|
248
|
+
const family = randomToken();
|
|
249
|
+
const access = this.issue('access', issued.subject, family);
|
|
250
|
+
const refresh = this.issue('refresh', issued.subject, family);
|
|
251
|
+
return {
|
|
252
|
+
subject: persona.subject,
|
|
253
|
+
accessToken: access.token,
|
|
254
|
+
refreshToken: refresh.token,
|
|
255
|
+
accessTokenExpiresAt: access.expiresAt,
|
|
256
|
+
...(persona.email !== undefined ? { email: persona.email } : {}),
|
|
257
|
+
emailVerified: persona.emailVerified ?? true,
|
|
258
|
+
...(persona.displayName !== undefined ? { displayName: persona.displayName } : {}),
|
|
259
|
+
organizations: (persona.organizations ?? []).map((entryArg) => ({ ...entryArg, roles: [...entryArg.roles] })),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
public async revokeToken(tokenArg: string, hintArg: TOidcTokenHint): Promise<void> {
|
|
264
|
+
if (!this.#discovered) {
|
|
265
|
+
throw notDiscovered();
|
|
266
|
+
}
|
|
267
|
+
this.revocations.push({ token: tokenArg, hint: hintArg });
|
|
268
|
+
if (this.revocationFailure) {
|
|
269
|
+
throw this.revocationFailure;
|
|
270
|
+
}
|
|
271
|
+
const token = this.#tokens.get(tokenArg);
|
|
272
|
+
if (token) {
|
|
273
|
+
token.revoked = true;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
public async introspectOrganization(optionsArg: {
|
|
278
|
+
accessToken: string;
|
|
279
|
+
subject: string;
|
|
280
|
+
organizationId: string;
|
|
281
|
+
}): Promise<IOidcOrganizationObservation> {
|
|
282
|
+
if (!this.#discovered) {
|
|
283
|
+
throw new OidcAuthorityUnavailableError('unavailable', notDiscovered());
|
|
284
|
+
}
|
|
285
|
+
this.introspections.push({ ...optionsArg });
|
|
286
|
+
await this.onIntrospect?.({ ...optionsArg });
|
|
287
|
+
if (this.introspectionFailure) {
|
|
288
|
+
throw this.introspectionFailure;
|
|
289
|
+
}
|
|
290
|
+
const token = this.#tokens.get(optionsArg.accessToken);
|
|
291
|
+
const inactive: IOidcOrganizationObservation = { active: false, roles: [], expiresAt: 0 };
|
|
292
|
+
if (!token || token.kind !== 'access' || !this.isLive(token) || token.subject !== optionsArg.subject) {
|
|
293
|
+
return inactive;
|
|
294
|
+
}
|
|
295
|
+
const membership = this.#personas.get(token.subject)?.organizations?.find((entryArg) => entryArg.id === optionsArg.organizationId);
|
|
296
|
+
if (!membership) {
|
|
297
|
+
return inactive;
|
|
298
|
+
}
|
|
299
|
+
return { active: true, roles: [...membership.roles].sort(), expiresAt: token.expiresAt };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
public async refreshTokens(refreshTokenArg: string): Promise<IOidcRefreshedTokens> {
|
|
303
|
+
if (!this.#discovered) {
|
|
304
|
+
throw new OidcAuthorityUnavailableError('unavailable', notDiscovered());
|
|
305
|
+
}
|
|
306
|
+
this.refreshes.push(refreshTokenArg);
|
|
307
|
+
await this.onRefresh?.(refreshTokenArg);
|
|
308
|
+
if (this.refreshFailure) {
|
|
309
|
+
throw this.refreshFailure;
|
|
310
|
+
}
|
|
311
|
+
const token = this.#tokens.get(refreshTokenArg);
|
|
312
|
+
if (!token || token.kind !== 'refresh' || token.revoked) {
|
|
313
|
+
throw new OidcRefreshRefusedError(new Error('invalid_grant'));
|
|
314
|
+
}
|
|
315
|
+
if (token.used) {
|
|
316
|
+
// reuse of a superseded refresh token: the family dies
|
|
317
|
+
for (const entry of this.#tokens.values()) {
|
|
318
|
+
if (entry.family === token.family) {
|
|
319
|
+
entry.revoked = true;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
throw new OidcRefreshRefusedError(new Error('invalid_grant'));
|
|
323
|
+
}
|
|
324
|
+
token.used = true;
|
|
325
|
+
const access = this.issue('access', token.subject, token.family);
|
|
326
|
+
const refresh = this.issue('refresh', token.subject, token.family);
|
|
327
|
+
return { accessToken: access.token, refreshToken: refresh.token, accessTokenExpiresAt: access.expiresAt };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ---- internals ----
|
|
331
|
+
|
|
332
|
+
private requirePersona(subjectArg: string): IProviderDoublePersona {
|
|
333
|
+
const persona = this.#personas.get(subjectArg);
|
|
334
|
+
if (!persona) {
|
|
335
|
+
throw new Error(`RelyingPartyProviderDouble: no persona ${subjectArg}`);
|
|
336
|
+
}
|
|
337
|
+
return persona;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private issue(kindArg: IIssuedToken['kind'], subjectArg: string, familyArg: string): { token: string; expiresAt: number } {
|
|
341
|
+
const token = (kindArg === 'access' ? 'at_' : 'rt_') + randomToken();
|
|
342
|
+
const expiresAt = kindArg === 'access' && this.accessTokenLifetimeSeconds > 0
|
|
343
|
+
? this.#now() + this.accessTokenLifetimeSeconds * 1_000
|
|
344
|
+
: 0;
|
|
345
|
+
this.#tokens.set(token, { subject: subjectArg, family: familyArg, expiresAt, kind: kindArg, revoked: false, used: false });
|
|
346
|
+
return { token, expiresAt };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private isLive(tokenArg: IIssuedToken): boolean {
|
|
350
|
+
return !tokenArg.revoked && (tokenArg.expiresAt === 0 || tokenArg.expiresAt > this.#now());
|
|
351
|
+
}
|
|
352
|
+
}
|