@ever-works/oidc-identity 1.0.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/dist/index.cjs +3303 -0
- package/dist/index.d.cts +1468 -0
- package/dist/index.d.ts +1468 -0
- package/dist/index.js +3244 -0
- package/dist/testing/fake-oidc-provider.cjs +651 -0
- package/dist/testing/fake-oidc-provider.d.cts +321 -0
- package/dist/testing/fake-oidc-provider.d.ts +321 -0
- package/dist/testing/fake-oidc-provider.js +622 -0
- package/package.json +88 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* APW-12 T8 — a **fake OpenID Connect provider**, published only through this
|
|
3
|
+
* package's `./testing` subpath (plan §10.4).
|
|
4
|
+
*
|
|
5
|
+
* The API integration spec (`apps/api/src/auth/ever-id.flow.integration.spec.ts`,
|
|
6
|
+
* T20/T25) and the Playwright lanes both need a provider that answers the way Ever
|
|
7
|
+
* ID will: a discovery document, a key set, an authorization endpoint that
|
|
8
|
+
* approves a configured person, a token endpoint that **checks PKCE**, device
|
|
9
|
+
* authorization with FR-41's polling rules, an end-session endpoint, and helpers
|
|
10
|
+
* that mint the access and logout tokens the verifiers are given. One fake, in one
|
|
11
|
+
* place, because a second one would be a second set of provider behaviours to keep
|
|
12
|
+
* in step.
|
|
13
|
+
*
|
|
14
|
+
* ## Why the subpath, and why that is load-bearing
|
|
15
|
+
*
|
|
16
|
+
* Plan §10.4: "published only through a `./testing` subpath export, never from the
|
|
17
|
+
* main entry". The main entry (`src/index.ts`) is what the plugin loader imports in
|
|
18
|
+
* production, and a fake provider that reached it would be dead weight in every
|
|
19
|
+
* API process — and, worse, an HTTP server behind a class the platform already
|
|
20
|
+
* instantiates. `package.json`'s `exports["./testing"]` and `tsup`'s second entry
|
|
21
|
+
* are what make that separation real; `src/__tests__/fake-oidc-provider.spec.ts`
|
|
22
|
+
* asserts it from both the source side and (when a build exists) the built side.
|
|
23
|
+
*
|
|
24
|
+
* ## What it is not
|
|
25
|
+
*
|
|
26
|
+
* It is not a security reference. It signs with ES256 keys it generates
|
|
27
|
+
* (`node:crypto`, plan §10.4 — not `jose`, so a bug in the library under test
|
|
28
|
+
* cannot hide a bug in the fake), it keeps device grants and authorization codes
|
|
29
|
+
* in memory, and it makes no attempt to be a *correct* provider in the corners the
|
|
30
|
+
* epic does not read. Where it deliberately *is* strict — PKCE S256, single-use
|
|
31
|
+
* codes, `client_secret_basic`, FR-41's poll interval — the strictness is the
|
|
32
|
+
* point: those are the behaviours the relying party has to get right, and a fake
|
|
33
|
+
* that accepted anything would prove nothing about them.
|
|
34
|
+
*
|
|
35
|
+
* ## Time
|
|
36
|
+
*
|
|
37
|
+
* Every clock read goes through the injected `now` (epoch **milliseconds**,
|
|
38
|
+
* defaulting to `Date.now`), and every minted claim is stamped from it. A spec
|
|
39
|
+
* therefore drives token lifetimes, the device-poll interval and `iat`/`exp` with
|
|
40
|
+
* literal values and an injected clock rather than by sleeping — the rule this
|
|
41
|
+
* package learned the hard way in T6.
|
|
42
|
+
*/
|
|
43
|
+
/**
|
|
44
|
+
* The `events` member of a back-channel logout notice — OpenID Connect
|
|
45
|
+
* Back-Channel Logout 1.0 §2.4's event identifier.
|
|
46
|
+
*
|
|
47
|
+
* Spelled here rather than imported from the plugin on purpose: this module stands
|
|
48
|
+
* in for an **external** provider, and a provider does not read the relying
|
|
49
|
+
* party's constants. `fake-oidc-provider.spec.ts` asserts that this literal and
|
|
50
|
+
* `OIDC_BACKCHANNEL_LOGOUT_EVENT` are the same string, so the two cannot drift
|
|
51
|
+
* without a red test.
|
|
52
|
+
*/
|
|
53
|
+
declare const FAKE_BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
|
|
54
|
+
/** The public client an exchange token names by default. */
|
|
55
|
+
declare const FAKE_DEFAULT_LOCAL_CLIENT_ID = "ever-works-cli";
|
|
56
|
+
/** The person the provider approves when a spec configures nobody (§10.4). */
|
|
57
|
+
declare const FAKE_DEFAULT_USER: {
|
|
58
|
+
readonly subject: "ever-id-subject-1";
|
|
59
|
+
readonly email: "person@example.com";
|
|
60
|
+
readonly emailVerified: true;
|
|
61
|
+
readonly name: "A Person";
|
|
62
|
+
};
|
|
63
|
+
/** The document fields a spec may ask the fake to leave out (FR-3's rows, FR-36's `null`). */
|
|
64
|
+
type FakeOidcOmittableField = 'authorization_endpoint' | 'token_endpoint' | 'jwks_uri' | 'end_session_endpoint' | 'device_authorization_endpoint' | 'code_challenge_methods_supported' | 'backchannel_logout_supported';
|
|
65
|
+
/** The person this provider signs in. */
|
|
66
|
+
interface FakeOidcUser {
|
|
67
|
+
readonly subject: string;
|
|
68
|
+
readonly email: string;
|
|
69
|
+
readonly emailVerified?: boolean;
|
|
70
|
+
readonly name?: string;
|
|
71
|
+
}
|
|
72
|
+
/** Construction options for {@link FakeOidcProvider.start}. */
|
|
73
|
+
interface FakeOidcProviderOptions {
|
|
74
|
+
/** The person auto-approved at `/authorize` and at device authorization. */
|
|
75
|
+
readonly user?: FakeOidcUser;
|
|
76
|
+
/** The relying party's client id. Default `ever-works-web`. */
|
|
77
|
+
readonly clientId?: string;
|
|
78
|
+
/**
|
|
79
|
+
* The `client_secret_basic` secret the token endpoint requires, or `null` to
|
|
80
|
+
* accept any (a spec that is not exercising client authentication).
|
|
81
|
+
*/
|
|
82
|
+
readonly clientSecret?: string | null;
|
|
83
|
+
/** The audience a minted access token carries. Default `ever-works`. */
|
|
84
|
+
readonly apiAudience?: string;
|
|
85
|
+
/** The public clients an exchange token may name in `azp`. */
|
|
86
|
+
readonly localClients?: readonly {
|
|
87
|
+
readonly kind: 'cli' | 'node';
|
|
88
|
+
readonly clientId: string;
|
|
89
|
+
}[];
|
|
90
|
+
/** FR-44's scope a delegated access token carries. Default `apps:read`. */
|
|
91
|
+
readonly delegatedScope?: string;
|
|
92
|
+
/** FR-39/FR-40's scope an exchange access token carries. Default `ever-works:session`. */
|
|
93
|
+
readonly exchangeScope?: string;
|
|
94
|
+
/** How long a minted access token lives when the caller does not say. Default 300. */
|
|
95
|
+
readonly accessTokenLifetimeSeconds?: number;
|
|
96
|
+
/** FR-41's poll interval the device endpoint returns, in seconds. Default 5. */
|
|
97
|
+
readonly devicePollIntervalSeconds?: number;
|
|
98
|
+
/** How long a device code lives. Default 900 (FR-41). */
|
|
99
|
+
readonly deviceCodeLifetimeSeconds?: number;
|
|
100
|
+
/** How long an authorization code lives. Default 60. */
|
|
101
|
+
readonly authorizationCodeLifetimeSeconds?: number;
|
|
102
|
+
/** Document fields to leave out, so a spec can drive the rows and the `null` answers. */
|
|
103
|
+
readonly omit?: readonly FakeOidcOmittableField[];
|
|
104
|
+
/** The clock, in epoch milliseconds. Defaults to `Date.now`. */
|
|
105
|
+
readonly now?: () => number;
|
|
106
|
+
}
|
|
107
|
+
/** One request the provider received, so a spec can assert on what actually went out. */
|
|
108
|
+
interface FakeOidcCall {
|
|
109
|
+
readonly method: string;
|
|
110
|
+
readonly path: string;
|
|
111
|
+
/** The query string, as a plain object (a repeated key keeps its first value). */
|
|
112
|
+
readonly query: Record<string, string>;
|
|
113
|
+
/** The `application/x-www-form-urlencoded` body, as a plain object. */
|
|
114
|
+
readonly form: Record<string, string>;
|
|
115
|
+
/** The request headers, lower-cased. */
|
|
116
|
+
readonly headers: Record<string, string>;
|
|
117
|
+
}
|
|
118
|
+
/** What a device-code poll answered. */
|
|
119
|
+
interface FakeDevicePoll {
|
|
120
|
+
readonly status: number;
|
|
121
|
+
readonly body: Record<string, unknown>;
|
|
122
|
+
}
|
|
123
|
+
/** What {@link FakeOidcProvider.start} resolves to. */
|
|
124
|
+
type FakeOidcProviderStarted = FakeOidcProvider;
|
|
125
|
+
/**
|
|
126
|
+
* A local OpenID Connect provider, for specs.
|
|
127
|
+
*
|
|
128
|
+
* `start()` binds an ephemeral port on the loopback interface and answers on
|
|
129
|
+
* `http://localhost:<port>` — the one non-TLS issuer `settings.schema.ts` accepts
|
|
130
|
+
* and the one the plugin's configuration tells `openid-client` about
|
|
131
|
+
* (`isInsecureIssuer`). The issuer therefore depends on the port and is only known
|
|
132
|
+
* after the server is listening, which is why construction goes through `start()`.
|
|
133
|
+
*/
|
|
134
|
+
declare class FakeOidcProvider {
|
|
135
|
+
/** Every request, in order — method, path, query, form and headers. */
|
|
136
|
+
readonly calls: FakeOidcCall[];
|
|
137
|
+
private readonly options;
|
|
138
|
+
private readonly keys;
|
|
139
|
+
private readonly codes;
|
|
140
|
+
private readonly devices;
|
|
141
|
+
private server;
|
|
142
|
+
private issuerUrl;
|
|
143
|
+
private keySequence;
|
|
144
|
+
/** The person approved from now on, when a spec changed it after start (see {@link setUser}). */
|
|
145
|
+
private userOverride;
|
|
146
|
+
/** Extra claims every ID token minted from now on carries (see {@link setIdTokenClaims}). */
|
|
147
|
+
private extraIdTokenClaims;
|
|
148
|
+
/** The provider session id (`sid`) the next tokens carry (see {@link setSessionId}). */
|
|
149
|
+
private sessionId;
|
|
150
|
+
private constructor();
|
|
151
|
+
/** Bind a port and answer the discovery document a relying party will read. */
|
|
152
|
+
static start(options?: FakeOidcProviderOptions): Promise<FakeOidcProvider>;
|
|
153
|
+
/**
|
|
154
|
+
* Bind an ephemeral loopback port and answer `/…/.well-known/openid-configuration`.
|
|
155
|
+
*
|
|
156
|
+
* Kept additive and public so a caller that wants a provider on a **fixed** port
|
|
157
|
+
* (a Playwright lane that must tell the API where to find it before the process
|
|
158
|
+
* starts) can build one; `start()` is the ordinary entry point.
|
|
159
|
+
*/
|
|
160
|
+
listen(port?: number): Promise<void>;
|
|
161
|
+
/** The issuer the discovery document advertises, and the settings' `issuerUrl`. */
|
|
162
|
+
get issuer(): string;
|
|
163
|
+
/** The bound port. */
|
|
164
|
+
get port(): number;
|
|
165
|
+
get discoveryUrl(): string;
|
|
166
|
+
get jwksUri(): string;
|
|
167
|
+
get authorizationEndpoint(): string;
|
|
168
|
+
get tokenEndpoint(): string;
|
|
169
|
+
get endSessionEndpoint(): string;
|
|
170
|
+
get deviceAuthorizationEndpoint(): string;
|
|
171
|
+
/** The relying party's client id the token endpoint expects. */
|
|
172
|
+
get clientId(): string;
|
|
173
|
+
/** The `client_secret_basic` secret the token endpoint expects, or `null` for "any". */
|
|
174
|
+
get clientSecret(): string | null;
|
|
175
|
+
/** The API audience a minted access token carries. */
|
|
176
|
+
get apiAudience(): string;
|
|
177
|
+
/** The `azp` a minted access token carries when the caller does not say. */
|
|
178
|
+
get defaultAuthorizedParty(): string;
|
|
179
|
+
/** The document this provider publishes, with the fields `omit` removed. */
|
|
180
|
+
discoveryDocument(): Record<string, unknown>;
|
|
181
|
+
/** FR-44's delegated scope this provider mints. */
|
|
182
|
+
get delegatedScope(): string;
|
|
183
|
+
/** FR-39/FR-40's exchange scope this provider mints. */
|
|
184
|
+
get exchangeScope(): string;
|
|
185
|
+
/** The current clock, in epoch milliseconds. */
|
|
186
|
+
now(): number;
|
|
187
|
+
/** The current clock, in whole seconds — the unit every JWT time claim uses. */
|
|
188
|
+
nowSeconds(): number;
|
|
189
|
+
/**
|
|
190
|
+
* Replace the signing key, the way a provider rotates one (FR-13, ACC-12-08).
|
|
191
|
+
*
|
|
192
|
+
* `keepPrevious` publishes the old key alongside the new one, which is what a
|
|
193
|
+
* rollover looks like while tokens signed with it are still in flight; the
|
|
194
|
+
* default is the harder case, where the old key is **gone** and a token signed
|
|
195
|
+
* with it must stop validating.
|
|
196
|
+
*/
|
|
197
|
+
rotateKeys(options?: {
|
|
198
|
+
keepPrevious?: boolean;
|
|
199
|
+
}): Promise<void>;
|
|
200
|
+
/** The current key id, so a spec can name what it is signing with. */
|
|
201
|
+
/**
|
|
202
|
+
* Change the person the provider approves from now on — so one running fake
|
|
203
|
+
* can sign in a connected identity, a new one and one whose e-mail an account
|
|
204
|
+
* already uses, in turn. Additive test seam; the constructor option still sets
|
|
205
|
+
* the first person.
|
|
206
|
+
*/
|
|
207
|
+
setUser(user: FakeOidcUser): void;
|
|
208
|
+
/**
|
|
209
|
+
* Extra claims every ID token minted from now on carries (for example the
|
|
210
|
+
* optional `urn:ever:` hint claims). Pass `{}` to stop adding any.
|
|
211
|
+
*/
|
|
212
|
+
setIdTokenClaims(claims: Record<string, unknown>): void;
|
|
213
|
+
/** The provider session id (`sid`) the next ID tokens and logout tokens carry. */
|
|
214
|
+
setSessionId(sid: string): void;
|
|
215
|
+
/** The provider session id the next tokens carry. */
|
|
216
|
+
get currentSessionId(): string;
|
|
217
|
+
get currentKeyId(): string;
|
|
218
|
+
/**
|
|
219
|
+
* Sign an ID token for the configured person — what the token endpoint answers
|
|
220
|
+
* with, and what a spec can use to drive `exchangeAuthorizationCode` directly.
|
|
221
|
+
*/
|
|
222
|
+
mintIdToken(overrides?: Record<string, unknown>): Promise<string>;
|
|
223
|
+
/**
|
|
224
|
+
* Sign an access token — the artifact FR-40's exchange and FR-45's delegated read
|
|
225
|
+
* are handed.
|
|
226
|
+
*
|
|
227
|
+
* The defaults are a **valid** token for the configured installation: `aud` is
|
|
228
|
+
* the API audience, `azp` is the first configured local client,
|
|
229
|
+
* `scope` is the exchange scope, `iat` is now and `exp` is now plus
|
|
230
|
+
* {@link FakeOidcProviderOptions.accessTokenLifetimeSeconds}. Every case in a
|
|
231
|
+
* spec is therefore "everything is valid, except this", and the literal numbers
|
|
232
|
+
* a case wants are passed in rather than derived from the plugin.
|
|
233
|
+
*/
|
|
234
|
+
mintAccessToken(overrides?: {
|
|
235
|
+
readonly scopes?: readonly string[];
|
|
236
|
+
readonly authorizedParty?: string | null;
|
|
237
|
+
readonly audience?: string | readonly string[];
|
|
238
|
+
readonly subject?: string;
|
|
239
|
+
readonly issuedAt?: number;
|
|
240
|
+
readonly expiresAt?: number;
|
|
241
|
+
readonly jti?: string | null;
|
|
242
|
+
readonly claims?: Record<string, unknown>;
|
|
243
|
+
}): Promise<string>;
|
|
244
|
+
/**
|
|
245
|
+
* Sign a back-channel logout notice (FR-33).
|
|
246
|
+
*
|
|
247
|
+
* The default is a **valid** notice about the configured person's session:
|
|
248
|
+
* the event member, an `iat` of now, a fresh `jti`, `sid` and no `nonce`, `exp`
|
|
249
|
+
* or `sub`. `extraClaims` is how a spec adds the claims FR-33 forbids (or
|
|
250
|
+
* removes the ones it requires), so every case reads as one deliberate change.
|
|
251
|
+
*/
|
|
252
|
+
mintLogoutToken(overrides?: {
|
|
253
|
+
readonly subject?: string | null;
|
|
254
|
+
readonly sid?: string | null;
|
|
255
|
+
readonly jti?: string | null;
|
|
256
|
+
readonly issuedAt?: number;
|
|
257
|
+
readonly expiresAt?: number | null;
|
|
258
|
+
readonly events?: Record<string, unknown> | null;
|
|
259
|
+
readonly extraClaims?: Record<string, unknown>;
|
|
260
|
+
}): Promise<string>;
|
|
261
|
+
/**
|
|
262
|
+
* Post a signed notice to a relying party's back-channel endpoint, the way the
|
|
263
|
+
* provider does (FR-33's "one public endpoint", form-encoded).
|
|
264
|
+
*
|
|
265
|
+
* Answers the status and the body text rather than throwing, so a spec can assert
|
|
266
|
+
* FR-34's "an invalid notice answers 400" — and FR-34's "200 with
|
|
267
|
+
* `Cache-Control: no-store`" — on a real HTTP exchange. A token is minted first
|
|
268
|
+
* when the caller does not pass one.
|
|
269
|
+
*/
|
|
270
|
+
postBackchannelLogout(url: string, token?: string, overrides?: Parameters<FakeOidcProvider['mintLogoutToken']>[0]): Promise<{
|
|
271
|
+
readonly status: number;
|
|
272
|
+
readonly body: string;
|
|
273
|
+
readonly cacheControl: string | null;
|
|
274
|
+
}>;
|
|
275
|
+
/**
|
|
276
|
+
* Approve a pending device grant — what the person does in the browser at
|
|
277
|
+
* `verification_uri` (FR-39).
|
|
278
|
+
*
|
|
279
|
+
* With no argument every pending grant is approved, which is what a spec that has
|
|
280
|
+
* just asked for one wants; the `userCode` form is there for the case that reads
|
|
281
|
+
* the code off the wire.
|
|
282
|
+
*/
|
|
283
|
+
approveDeviceAuthorization(userCode?: string): number;
|
|
284
|
+
/** Every device grant this provider has issued, approved or not. */
|
|
285
|
+
get deviceGrants(): readonly {
|
|
286
|
+
readonly userCode: string;
|
|
287
|
+
readonly deviceCode: string;
|
|
288
|
+
readonly approved: boolean;
|
|
289
|
+
}[];
|
|
290
|
+
/** The last authorization request's query, or `null` when none has been made. */
|
|
291
|
+
lastAuthorizationRequest(): Record<string, string> | null;
|
|
292
|
+
/** Stop listening. Safe to call twice, and safe to call after a failed start. */
|
|
293
|
+
stop(): Promise<void>;
|
|
294
|
+
private user;
|
|
295
|
+
/**
|
|
296
|
+
* Whether `clientId` is one of the PUBLIC local clients (CLI, node) — RFC 8628
|
|
297
|
+
* public clients authenticate with their `client_id` alone, no secret.
|
|
298
|
+
*/
|
|
299
|
+
private isLocalClient;
|
|
300
|
+
private rotateKeysSync;
|
|
301
|
+
private currentKey;
|
|
302
|
+
private sign;
|
|
303
|
+
private handle;
|
|
304
|
+
/** §10.4's "authorize (auto-approve a configured user)", with S256 required. */
|
|
305
|
+
private handleAuthorize;
|
|
306
|
+
/** RFC 6749 §4.1.3 and RFC 8628 §3.4–3.5: two grants, both checked. */
|
|
307
|
+
private handleToken;
|
|
308
|
+
private handleAuthorizationCodeGrant;
|
|
309
|
+
private handleDeviceCodeGrant;
|
|
310
|
+
/** RFC 8628 §3.2's device authorization response. */
|
|
311
|
+
private handleDeviceAuthorization;
|
|
312
|
+
/** RP-Initiated Logout 1.0 §2: back to the relying party, with `state` preserved. */
|
|
313
|
+
private handleEndSession;
|
|
314
|
+
private tokensFor;
|
|
315
|
+
/** `client_secret_basic` (RFC 6749 §2.3.1), which is what plan §4.2 registers. */
|
|
316
|
+
private credentialsAccepted;
|
|
317
|
+
}
|
|
318
|
+
/** S256: `BASE64URL(SHA256(ASCII(code_verifier)))` (RFC 7636 §4.6). */
|
|
319
|
+
declare function s256(codeVerifier: string): string;
|
|
320
|
+
|
|
321
|
+
export { FAKE_BACKCHANNEL_LOGOUT_EVENT, FAKE_DEFAULT_LOCAL_CLIENT_ID, FAKE_DEFAULT_USER, type FakeDevicePoll, type FakeOidcCall, type FakeOidcOmittableField, FakeOidcProvider, type FakeOidcProviderOptions, type FakeOidcProviderStarted, type FakeOidcUser, s256 };
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* APW-12 T8 — a **fake OpenID Connect provider**, published only through this
|
|
3
|
+
* package's `./testing` subpath (plan §10.4).
|
|
4
|
+
*
|
|
5
|
+
* The API integration spec (`apps/api/src/auth/ever-id.flow.integration.spec.ts`,
|
|
6
|
+
* T20/T25) and the Playwright lanes both need a provider that answers the way Ever
|
|
7
|
+
* ID will: a discovery document, a key set, an authorization endpoint that
|
|
8
|
+
* approves a configured person, a token endpoint that **checks PKCE**, device
|
|
9
|
+
* authorization with FR-41's polling rules, an end-session endpoint, and helpers
|
|
10
|
+
* that mint the access and logout tokens the verifiers are given. One fake, in one
|
|
11
|
+
* place, because a second one would be a second set of provider behaviours to keep
|
|
12
|
+
* in step.
|
|
13
|
+
*
|
|
14
|
+
* ## Why the subpath, and why that is load-bearing
|
|
15
|
+
*
|
|
16
|
+
* Plan §10.4: "published only through a `./testing` subpath export, never from the
|
|
17
|
+
* main entry". The main entry (`src/index.ts`) is what the plugin loader imports in
|
|
18
|
+
* production, and a fake provider that reached it would be dead weight in every
|
|
19
|
+
* API process — and, worse, an HTTP server behind a class the platform already
|
|
20
|
+
* instantiates. `package.json`'s `exports["./testing"]` and `tsup`'s second entry
|
|
21
|
+
* are what make that separation real; `src/__tests__/fake-oidc-provider.spec.ts`
|
|
22
|
+
* asserts it from both the source side and (when a build exists) the built side.
|
|
23
|
+
*
|
|
24
|
+
* ## What it is not
|
|
25
|
+
*
|
|
26
|
+
* It is not a security reference. It signs with ES256 keys it generates
|
|
27
|
+
* (`node:crypto`, plan §10.4 — not `jose`, so a bug in the library under test
|
|
28
|
+
* cannot hide a bug in the fake), it keeps device grants and authorization codes
|
|
29
|
+
* in memory, and it makes no attempt to be a *correct* provider in the corners the
|
|
30
|
+
* epic does not read. Where it deliberately *is* strict — PKCE S256, single-use
|
|
31
|
+
* codes, `client_secret_basic`, FR-41's poll interval — the strictness is the
|
|
32
|
+
* point: those are the behaviours the relying party has to get right, and a fake
|
|
33
|
+
* that accepted anything would prove nothing about them.
|
|
34
|
+
*
|
|
35
|
+
* ## Time
|
|
36
|
+
*
|
|
37
|
+
* Every clock read goes through the injected `now` (epoch **milliseconds**,
|
|
38
|
+
* defaulting to `Date.now`), and every minted claim is stamped from it. A spec
|
|
39
|
+
* therefore drives token lifetimes, the device-poll interval and `iat`/`exp` with
|
|
40
|
+
* literal values and an injected clock rather than by sleeping — the rule this
|
|
41
|
+
* package learned the hard way in T6.
|
|
42
|
+
*/
|
|
43
|
+
/**
|
|
44
|
+
* The `events` member of a back-channel logout notice — OpenID Connect
|
|
45
|
+
* Back-Channel Logout 1.0 §2.4's event identifier.
|
|
46
|
+
*
|
|
47
|
+
* Spelled here rather than imported from the plugin on purpose: this module stands
|
|
48
|
+
* in for an **external** provider, and a provider does not read the relying
|
|
49
|
+
* party's constants. `fake-oidc-provider.spec.ts` asserts that this literal and
|
|
50
|
+
* `OIDC_BACKCHANNEL_LOGOUT_EVENT` are the same string, so the two cannot drift
|
|
51
|
+
* without a red test.
|
|
52
|
+
*/
|
|
53
|
+
declare const FAKE_BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
|
|
54
|
+
/** The public client an exchange token names by default. */
|
|
55
|
+
declare const FAKE_DEFAULT_LOCAL_CLIENT_ID = "ever-works-cli";
|
|
56
|
+
/** The person the provider approves when a spec configures nobody (§10.4). */
|
|
57
|
+
declare const FAKE_DEFAULT_USER: {
|
|
58
|
+
readonly subject: "ever-id-subject-1";
|
|
59
|
+
readonly email: "person@example.com";
|
|
60
|
+
readonly emailVerified: true;
|
|
61
|
+
readonly name: "A Person";
|
|
62
|
+
};
|
|
63
|
+
/** The document fields a spec may ask the fake to leave out (FR-3's rows, FR-36's `null`). */
|
|
64
|
+
type FakeOidcOmittableField = 'authorization_endpoint' | 'token_endpoint' | 'jwks_uri' | 'end_session_endpoint' | 'device_authorization_endpoint' | 'code_challenge_methods_supported' | 'backchannel_logout_supported';
|
|
65
|
+
/** The person this provider signs in. */
|
|
66
|
+
interface FakeOidcUser {
|
|
67
|
+
readonly subject: string;
|
|
68
|
+
readonly email: string;
|
|
69
|
+
readonly emailVerified?: boolean;
|
|
70
|
+
readonly name?: string;
|
|
71
|
+
}
|
|
72
|
+
/** Construction options for {@link FakeOidcProvider.start}. */
|
|
73
|
+
interface FakeOidcProviderOptions {
|
|
74
|
+
/** The person auto-approved at `/authorize` and at device authorization. */
|
|
75
|
+
readonly user?: FakeOidcUser;
|
|
76
|
+
/** The relying party's client id. Default `ever-works-web`. */
|
|
77
|
+
readonly clientId?: string;
|
|
78
|
+
/**
|
|
79
|
+
* The `client_secret_basic` secret the token endpoint requires, or `null` to
|
|
80
|
+
* accept any (a spec that is not exercising client authentication).
|
|
81
|
+
*/
|
|
82
|
+
readonly clientSecret?: string | null;
|
|
83
|
+
/** The audience a minted access token carries. Default `ever-works`. */
|
|
84
|
+
readonly apiAudience?: string;
|
|
85
|
+
/** The public clients an exchange token may name in `azp`. */
|
|
86
|
+
readonly localClients?: readonly {
|
|
87
|
+
readonly kind: 'cli' | 'node';
|
|
88
|
+
readonly clientId: string;
|
|
89
|
+
}[];
|
|
90
|
+
/** FR-44's scope a delegated access token carries. Default `apps:read`. */
|
|
91
|
+
readonly delegatedScope?: string;
|
|
92
|
+
/** FR-39/FR-40's scope an exchange access token carries. Default `ever-works:session`. */
|
|
93
|
+
readonly exchangeScope?: string;
|
|
94
|
+
/** How long a minted access token lives when the caller does not say. Default 300. */
|
|
95
|
+
readonly accessTokenLifetimeSeconds?: number;
|
|
96
|
+
/** FR-41's poll interval the device endpoint returns, in seconds. Default 5. */
|
|
97
|
+
readonly devicePollIntervalSeconds?: number;
|
|
98
|
+
/** How long a device code lives. Default 900 (FR-41). */
|
|
99
|
+
readonly deviceCodeLifetimeSeconds?: number;
|
|
100
|
+
/** How long an authorization code lives. Default 60. */
|
|
101
|
+
readonly authorizationCodeLifetimeSeconds?: number;
|
|
102
|
+
/** Document fields to leave out, so a spec can drive the rows and the `null` answers. */
|
|
103
|
+
readonly omit?: readonly FakeOidcOmittableField[];
|
|
104
|
+
/** The clock, in epoch milliseconds. Defaults to `Date.now`. */
|
|
105
|
+
readonly now?: () => number;
|
|
106
|
+
}
|
|
107
|
+
/** One request the provider received, so a spec can assert on what actually went out. */
|
|
108
|
+
interface FakeOidcCall {
|
|
109
|
+
readonly method: string;
|
|
110
|
+
readonly path: string;
|
|
111
|
+
/** The query string, as a plain object (a repeated key keeps its first value). */
|
|
112
|
+
readonly query: Record<string, string>;
|
|
113
|
+
/** The `application/x-www-form-urlencoded` body, as a plain object. */
|
|
114
|
+
readonly form: Record<string, string>;
|
|
115
|
+
/** The request headers, lower-cased. */
|
|
116
|
+
readonly headers: Record<string, string>;
|
|
117
|
+
}
|
|
118
|
+
/** What a device-code poll answered. */
|
|
119
|
+
interface FakeDevicePoll {
|
|
120
|
+
readonly status: number;
|
|
121
|
+
readonly body: Record<string, unknown>;
|
|
122
|
+
}
|
|
123
|
+
/** What {@link FakeOidcProvider.start} resolves to. */
|
|
124
|
+
type FakeOidcProviderStarted = FakeOidcProvider;
|
|
125
|
+
/**
|
|
126
|
+
* A local OpenID Connect provider, for specs.
|
|
127
|
+
*
|
|
128
|
+
* `start()` binds an ephemeral port on the loopback interface and answers on
|
|
129
|
+
* `http://localhost:<port>` — the one non-TLS issuer `settings.schema.ts` accepts
|
|
130
|
+
* and the one the plugin's configuration tells `openid-client` about
|
|
131
|
+
* (`isInsecureIssuer`). The issuer therefore depends on the port and is only known
|
|
132
|
+
* after the server is listening, which is why construction goes through `start()`.
|
|
133
|
+
*/
|
|
134
|
+
declare class FakeOidcProvider {
|
|
135
|
+
/** Every request, in order — method, path, query, form and headers. */
|
|
136
|
+
readonly calls: FakeOidcCall[];
|
|
137
|
+
private readonly options;
|
|
138
|
+
private readonly keys;
|
|
139
|
+
private readonly codes;
|
|
140
|
+
private readonly devices;
|
|
141
|
+
private server;
|
|
142
|
+
private issuerUrl;
|
|
143
|
+
private keySequence;
|
|
144
|
+
/** The person approved from now on, when a spec changed it after start (see {@link setUser}). */
|
|
145
|
+
private userOverride;
|
|
146
|
+
/** Extra claims every ID token minted from now on carries (see {@link setIdTokenClaims}). */
|
|
147
|
+
private extraIdTokenClaims;
|
|
148
|
+
/** The provider session id (`sid`) the next tokens carry (see {@link setSessionId}). */
|
|
149
|
+
private sessionId;
|
|
150
|
+
private constructor();
|
|
151
|
+
/** Bind a port and answer the discovery document a relying party will read. */
|
|
152
|
+
static start(options?: FakeOidcProviderOptions): Promise<FakeOidcProvider>;
|
|
153
|
+
/**
|
|
154
|
+
* Bind an ephemeral loopback port and answer `/…/.well-known/openid-configuration`.
|
|
155
|
+
*
|
|
156
|
+
* Kept additive and public so a caller that wants a provider on a **fixed** port
|
|
157
|
+
* (a Playwright lane that must tell the API where to find it before the process
|
|
158
|
+
* starts) can build one; `start()` is the ordinary entry point.
|
|
159
|
+
*/
|
|
160
|
+
listen(port?: number): Promise<void>;
|
|
161
|
+
/** The issuer the discovery document advertises, and the settings' `issuerUrl`. */
|
|
162
|
+
get issuer(): string;
|
|
163
|
+
/** The bound port. */
|
|
164
|
+
get port(): number;
|
|
165
|
+
get discoveryUrl(): string;
|
|
166
|
+
get jwksUri(): string;
|
|
167
|
+
get authorizationEndpoint(): string;
|
|
168
|
+
get tokenEndpoint(): string;
|
|
169
|
+
get endSessionEndpoint(): string;
|
|
170
|
+
get deviceAuthorizationEndpoint(): string;
|
|
171
|
+
/** The relying party's client id the token endpoint expects. */
|
|
172
|
+
get clientId(): string;
|
|
173
|
+
/** The `client_secret_basic` secret the token endpoint expects, or `null` for "any". */
|
|
174
|
+
get clientSecret(): string | null;
|
|
175
|
+
/** The API audience a minted access token carries. */
|
|
176
|
+
get apiAudience(): string;
|
|
177
|
+
/** The `azp` a minted access token carries when the caller does not say. */
|
|
178
|
+
get defaultAuthorizedParty(): string;
|
|
179
|
+
/** The document this provider publishes, with the fields `omit` removed. */
|
|
180
|
+
discoveryDocument(): Record<string, unknown>;
|
|
181
|
+
/** FR-44's delegated scope this provider mints. */
|
|
182
|
+
get delegatedScope(): string;
|
|
183
|
+
/** FR-39/FR-40's exchange scope this provider mints. */
|
|
184
|
+
get exchangeScope(): string;
|
|
185
|
+
/** The current clock, in epoch milliseconds. */
|
|
186
|
+
now(): number;
|
|
187
|
+
/** The current clock, in whole seconds — the unit every JWT time claim uses. */
|
|
188
|
+
nowSeconds(): number;
|
|
189
|
+
/**
|
|
190
|
+
* Replace the signing key, the way a provider rotates one (FR-13, ACC-12-08).
|
|
191
|
+
*
|
|
192
|
+
* `keepPrevious` publishes the old key alongside the new one, which is what a
|
|
193
|
+
* rollover looks like while tokens signed with it are still in flight; the
|
|
194
|
+
* default is the harder case, where the old key is **gone** and a token signed
|
|
195
|
+
* with it must stop validating.
|
|
196
|
+
*/
|
|
197
|
+
rotateKeys(options?: {
|
|
198
|
+
keepPrevious?: boolean;
|
|
199
|
+
}): Promise<void>;
|
|
200
|
+
/** The current key id, so a spec can name what it is signing with. */
|
|
201
|
+
/**
|
|
202
|
+
* Change the person the provider approves from now on — so one running fake
|
|
203
|
+
* can sign in a connected identity, a new one and one whose e-mail an account
|
|
204
|
+
* already uses, in turn. Additive test seam; the constructor option still sets
|
|
205
|
+
* the first person.
|
|
206
|
+
*/
|
|
207
|
+
setUser(user: FakeOidcUser): void;
|
|
208
|
+
/**
|
|
209
|
+
* Extra claims every ID token minted from now on carries (for example the
|
|
210
|
+
* optional `urn:ever:` hint claims). Pass `{}` to stop adding any.
|
|
211
|
+
*/
|
|
212
|
+
setIdTokenClaims(claims: Record<string, unknown>): void;
|
|
213
|
+
/** The provider session id (`sid`) the next ID tokens and logout tokens carry. */
|
|
214
|
+
setSessionId(sid: string): void;
|
|
215
|
+
/** The provider session id the next tokens carry. */
|
|
216
|
+
get currentSessionId(): string;
|
|
217
|
+
get currentKeyId(): string;
|
|
218
|
+
/**
|
|
219
|
+
* Sign an ID token for the configured person — what the token endpoint answers
|
|
220
|
+
* with, and what a spec can use to drive `exchangeAuthorizationCode` directly.
|
|
221
|
+
*/
|
|
222
|
+
mintIdToken(overrides?: Record<string, unknown>): Promise<string>;
|
|
223
|
+
/**
|
|
224
|
+
* Sign an access token — the artifact FR-40's exchange and FR-45's delegated read
|
|
225
|
+
* are handed.
|
|
226
|
+
*
|
|
227
|
+
* The defaults are a **valid** token for the configured installation: `aud` is
|
|
228
|
+
* the API audience, `azp` is the first configured local client,
|
|
229
|
+
* `scope` is the exchange scope, `iat` is now and `exp` is now plus
|
|
230
|
+
* {@link FakeOidcProviderOptions.accessTokenLifetimeSeconds}. Every case in a
|
|
231
|
+
* spec is therefore "everything is valid, except this", and the literal numbers
|
|
232
|
+
* a case wants are passed in rather than derived from the plugin.
|
|
233
|
+
*/
|
|
234
|
+
mintAccessToken(overrides?: {
|
|
235
|
+
readonly scopes?: readonly string[];
|
|
236
|
+
readonly authorizedParty?: string | null;
|
|
237
|
+
readonly audience?: string | readonly string[];
|
|
238
|
+
readonly subject?: string;
|
|
239
|
+
readonly issuedAt?: number;
|
|
240
|
+
readonly expiresAt?: number;
|
|
241
|
+
readonly jti?: string | null;
|
|
242
|
+
readonly claims?: Record<string, unknown>;
|
|
243
|
+
}): Promise<string>;
|
|
244
|
+
/**
|
|
245
|
+
* Sign a back-channel logout notice (FR-33).
|
|
246
|
+
*
|
|
247
|
+
* The default is a **valid** notice about the configured person's session:
|
|
248
|
+
* the event member, an `iat` of now, a fresh `jti`, `sid` and no `nonce`, `exp`
|
|
249
|
+
* or `sub`. `extraClaims` is how a spec adds the claims FR-33 forbids (or
|
|
250
|
+
* removes the ones it requires), so every case reads as one deliberate change.
|
|
251
|
+
*/
|
|
252
|
+
mintLogoutToken(overrides?: {
|
|
253
|
+
readonly subject?: string | null;
|
|
254
|
+
readonly sid?: string | null;
|
|
255
|
+
readonly jti?: string | null;
|
|
256
|
+
readonly issuedAt?: number;
|
|
257
|
+
readonly expiresAt?: number | null;
|
|
258
|
+
readonly events?: Record<string, unknown> | null;
|
|
259
|
+
readonly extraClaims?: Record<string, unknown>;
|
|
260
|
+
}): Promise<string>;
|
|
261
|
+
/**
|
|
262
|
+
* Post a signed notice to a relying party's back-channel endpoint, the way the
|
|
263
|
+
* provider does (FR-33's "one public endpoint", form-encoded).
|
|
264
|
+
*
|
|
265
|
+
* Answers the status and the body text rather than throwing, so a spec can assert
|
|
266
|
+
* FR-34's "an invalid notice answers 400" — and FR-34's "200 with
|
|
267
|
+
* `Cache-Control: no-store`" — on a real HTTP exchange. A token is minted first
|
|
268
|
+
* when the caller does not pass one.
|
|
269
|
+
*/
|
|
270
|
+
postBackchannelLogout(url: string, token?: string, overrides?: Parameters<FakeOidcProvider['mintLogoutToken']>[0]): Promise<{
|
|
271
|
+
readonly status: number;
|
|
272
|
+
readonly body: string;
|
|
273
|
+
readonly cacheControl: string | null;
|
|
274
|
+
}>;
|
|
275
|
+
/**
|
|
276
|
+
* Approve a pending device grant — what the person does in the browser at
|
|
277
|
+
* `verification_uri` (FR-39).
|
|
278
|
+
*
|
|
279
|
+
* With no argument every pending grant is approved, which is what a spec that has
|
|
280
|
+
* just asked for one wants; the `userCode` form is there for the case that reads
|
|
281
|
+
* the code off the wire.
|
|
282
|
+
*/
|
|
283
|
+
approveDeviceAuthorization(userCode?: string): number;
|
|
284
|
+
/** Every device grant this provider has issued, approved or not. */
|
|
285
|
+
get deviceGrants(): readonly {
|
|
286
|
+
readonly userCode: string;
|
|
287
|
+
readonly deviceCode: string;
|
|
288
|
+
readonly approved: boolean;
|
|
289
|
+
}[];
|
|
290
|
+
/** The last authorization request's query, or `null` when none has been made. */
|
|
291
|
+
lastAuthorizationRequest(): Record<string, string> | null;
|
|
292
|
+
/** Stop listening. Safe to call twice, and safe to call after a failed start. */
|
|
293
|
+
stop(): Promise<void>;
|
|
294
|
+
private user;
|
|
295
|
+
/**
|
|
296
|
+
* Whether `clientId` is one of the PUBLIC local clients (CLI, node) — RFC 8628
|
|
297
|
+
* public clients authenticate with their `client_id` alone, no secret.
|
|
298
|
+
*/
|
|
299
|
+
private isLocalClient;
|
|
300
|
+
private rotateKeysSync;
|
|
301
|
+
private currentKey;
|
|
302
|
+
private sign;
|
|
303
|
+
private handle;
|
|
304
|
+
/** §10.4's "authorize (auto-approve a configured user)", with S256 required. */
|
|
305
|
+
private handleAuthorize;
|
|
306
|
+
/** RFC 6749 §4.1.3 and RFC 8628 §3.4–3.5: two grants, both checked. */
|
|
307
|
+
private handleToken;
|
|
308
|
+
private handleAuthorizationCodeGrant;
|
|
309
|
+
private handleDeviceCodeGrant;
|
|
310
|
+
/** RFC 8628 §3.2's device authorization response. */
|
|
311
|
+
private handleDeviceAuthorization;
|
|
312
|
+
/** RP-Initiated Logout 1.0 §2: back to the relying party, with `state` preserved. */
|
|
313
|
+
private handleEndSession;
|
|
314
|
+
private tokensFor;
|
|
315
|
+
/** `client_secret_basic` (RFC 6749 §2.3.1), which is what plan §4.2 registers. */
|
|
316
|
+
private credentialsAccepted;
|
|
317
|
+
}
|
|
318
|
+
/** S256: `BASE64URL(SHA256(ASCII(code_verifier)))` (RFC 7636 §4.6). */
|
|
319
|
+
declare function s256(codeVerifier: string): string;
|
|
320
|
+
|
|
321
|
+
export { FAKE_BACKCHANNEL_LOGOUT_EVENT, FAKE_DEFAULT_LOCAL_CLIENT_ID, FAKE_DEFAULT_USER, type FakeDevicePoll, type FakeOidcCall, type FakeOidcOmittableField, FakeOidcProvider, type FakeOidcProviderOptions, type FakeOidcProviderStarted, type FakeOidcUser, s256 };
|