@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.
@@ -0,0 +1,651 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/testing/fake-oidc-provider.ts
21
+ var fake_oidc_provider_exports = {};
22
+ __export(fake_oidc_provider_exports, {
23
+ FAKE_BACKCHANNEL_LOGOUT_EVENT: () => FAKE_BACKCHANNEL_LOGOUT_EVENT,
24
+ FAKE_DEFAULT_LOCAL_CLIENT_ID: () => FAKE_DEFAULT_LOCAL_CLIENT_ID,
25
+ FAKE_DEFAULT_USER: () => FAKE_DEFAULT_USER,
26
+ FakeOidcProvider: () => FakeOidcProvider,
27
+ s256: () => s256
28
+ });
29
+ module.exports = __toCommonJS(fake_oidc_provider_exports);
30
+ var import_node_crypto = require("crypto");
31
+ var import_node_http = require("http");
32
+ var FAKE_BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
33
+ var FAKE_DEFAULT_LOCAL_CLIENT_ID = "ever-works-cli";
34
+ var FAKE_DEFAULT_USER = {
35
+ subject: "ever-id-subject-1",
36
+ email: "person@example.com",
37
+ emailVerified: true,
38
+ name: "A Person"
39
+ };
40
+ var FakeOidcProvider = class _FakeOidcProvider {
41
+ constructor(options) {
42
+ /** Every request, in order — method, path, query, form and headers. */
43
+ this.calls = [];
44
+ this.keys = [];
45
+ this.codes = /* @__PURE__ */ new Map();
46
+ this.devices = /* @__PURE__ */ new Map();
47
+ this.server = null;
48
+ this.issuerUrl = "";
49
+ this.keySequence = 0;
50
+ /** The person approved from now on, when a spec changed it after start (see {@link setUser}). */
51
+ this.userOverride = null;
52
+ /** Extra claims every ID token minted from now on carries (see {@link setIdTokenClaims}). */
53
+ this.extraIdTokenClaims = {};
54
+ /** The provider session id (`sid`) the next tokens carry (see {@link setSessionId}). */
55
+ this.sessionId = "ever-id-session-1";
56
+ this.options = options;
57
+ }
58
+ /** Bind a port and answer the discovery document a relying party will read. */
59
+ static async start(options = {}) {
60
+ const provider = new _FakeOidcProvider(options);
61
+ provider.rotateKeysSync({ keepPrevious: false });
62
+ await provider.listen();
63
+ return provider;
64
+ }
65
+ /**
66
+ * Bind an ephemeral loopback port and answer `/…/.well-known/openid-configuration`.
67
+ *
68
+ * Kept additive and public so a caller that wants a provider on a **fixed** port
69
+ * (a Playwright lane that must tell the API where to find it before the process
70
+ * starts) can build one; `start()` is the ordinary entry point.
71
+ */
72
+ async listen(port = 0) {
73
+ if (this.server !== null) throw new Error("FakeOidcProvider is already listening.");
74
+ const server = (0, import_node_http.createServer)((request, response) => {
75
+ void this.handle(request, response);
76
+ });
77
+ await new Promise((resolve, reject) => {
78
+ server.once("error", reject);
79
+ server.listen(port, "127.0.0.1", () => {
80
+ server.removeListener("error", reject);
81
+ resolve();
82
+ });
83
+ });
84
+ this.server = server;
85
+ const address = server.address();
86
+ this.issuerUrl = `http://localhost:${typeof address === "object" && address !== null ? address.port : port}`;
87
+ }
88
+ /** The issuer the discovery document advertises, and the settings' `issuerUrl`. */
89
+ get issuer() {
90
+ return this.issuerUrl;
91
+ }
92
+ /** The bound port. */
93
+ get port() {
94
+ const address = this.server?.address();
95
+ return typeof address === "object" && address !== null ? address.port : 0;
96
+ }
97
+ get discoveryUrl() {
98
+ return `${this.issuer}/.well-known/openid-configuration`;
99
+ }
100
+ get jwksUri() {
101
+ return `${this.issuer}/jwks`;
102
+ }
103
+ get authorizationEndpoint() {
104
+ return `${this.issuer}/authorize`;
105
+ }
106
+ get tokenEndpoint() {
107
+ return `${this.issuer}/token`;
108
+ }
109
+ get endSessionEndpoint() {
110
+ return `${this.issuer}/end_session`;
111
+ }
112
+ get deviceAuthorizationEndpoint() {
113
+ return `${this.issuer}/device_authorization`;
114
+ }
115
+ /** The relying party's client id the token endpoint expects. */
116
+ get clientId() {
117
+ return this.options.clientId ?? "ever-works-web";
118
+ }
119
+ /** The `client_secret_basic` secret the token endpoint expects, or `null` for "any". */
120
+ get clientSecret() {
121
+ return this.options.clientSecret === void 0 ? "fake-client-secret" : this.options.clientSecret;
122
+ }
123
+ /** The API audience a minted access token carries. */
124
+ get apiAudience() {
125
+ return this.options.apiAudience ?? "ever-works";
126
+ }
127
+ /** The `azp` a minted access token carries when the caller does not say. */
128
+ get defaultAuthorizedParty() {
129
+ return this.options.localClients?.[0]?.clientId ?? FAKE_DEFAULT_LOCAL_CLIENT_ID;
130
+ }
131
+ /** The document this provider publishes, with the fields `omit` removed. */
132
+ discoveryDocument() {
133
+ const document = {
134
+ issuer: this.issuer,
135
+ authorization_endpoint: this.authorizationEndpoint,
136
+ token_endpoint: this.tokenEndpoint,
137
+ jwks_uri: this.jwksUri,
138
+ end_session_endpoint: this.endSessionEndpoint,
139
+ device_authorization_endpoint: this.deviceAuthorizationEndpoint,
140
+ userinfo_endpoint: `${this.issuer}/userinfo`,
141
+ code_challenge_methods_supported: ["S256"],
142
+ id_token_signing_alg_values_supported: ["ES256"],
143
+ backchannel_logout_supported: true,
144
+ backchannel_logout_session_supported: true,
145
+ response_types_supported: ["code"],
146
+ grant_types_supported: ["authorization_code", "urn:ietf:params:oauth:grant-type:device_code"],
147
+ subject_types_supported: ["public"],
148
+ scopes_supported: ["openid", "email", "profile", this.delegatedScope, this.exchangeScope]
149
+ };
150
+ for (const field of this.options.omit ?? []) delete document[field];
151
+ return document;
152
+ }
153
+ /** FR-44's delegated scope this provider mints. */
154
+ get delegatedScope() {
155
+ return this.options.delegatedScope ?? "apps:read";
156
+ }
157
+ /** FR-39/FR-40's exchange scope this provider mints. */
158
+ get exchangeScope() {
159
+ return this.options.exchangeScope ?? "ever-works:session";
160
+ }
161
+ /** The current clock, in epoch milliseconds. */
162
+ now() {
163
+ return (this.options.now ?? Date.now)();
164
+ }
165
+ /** The current clock, in whole seconds — the unit every JWT time claim uses. */
166
+ nowSeconds() {
167
+ return Math.floor(this.now() / 1e3);
168
+ }
169
+ /**
170
+ * Replace the signing key, the way a provider rotates one (FR-13, ACC-12-08).
171
+ *
172
+ * `keepPrevious` publishes the old key alongside the new one, which is what a
173
+ * rollover looks like while tokens signed with it are still in flight; the
174
+ * default is the harder case, where the old key is **gone** and a token signed
175
+ * with it must stop validating.
176
+ */
177
+ async rotateKeys(options = {}) {
178
+ this.rotateKeysSync(options);
179
+ }
180
+ /** The current key id, so a spec can name what it is signing with. */
181
+ /**
182
+ * Change the person the provider approves from now on — so one running fake
183
+ * can sign in a connected identity, a new one and one whose e-mail an account
184
+ * already uses, in turn. Additive test seam; the constructor option still sets
185
+ * the first person.
186
+ */
187
+ setUser(user) {
188
+ this.userOverride = user;
189
+ }
190
+ /**
191
+ * Extra claims every ID token minted from now on carries (for example the
192
+ * optional `urn:ever:` hint claims). Pass `{}` to stop adding any.
193
+ */
194
+ setIdTokenClaims(claims) {
195
+ this.extraIdTokenClaims = { ...claims };
196
+ }
197
+ /** The provider session id (`sid`) the next ID tokens and logout tokens carry. */
198
+ setSessionId(sid) {
199
+ this.sessionId = sid;
200
+ }
201
+ /** The provider session id the next tokens carry. */
202
+ get currentSessionId() {
203
+ return this.sessionId;
204
+ }
205
+ get currentKeyId() {
206
+ return this.keys[this.keys.length - 1]?.kid ?? "";
207
+ }
208
+ /**
209
+ * Sign an ID token for the configured person — what the token endpoint answers
210
+ * with, and what a spec can use to drive `exchangeAuthorizationCode` directly.
211
+ */
212
+ async mintIdToken(overrides = {}) {
213
+ const user = this.user();
214
+ const issuedAt = this.nowSeconds();
215
+ return this.sign({
216
+ iss: this.issuer,
217
+ sub: user.subject,
218
+ aud: this.clientId,
219
+ exp: issuedAt + 300,
220
+ iat: issuedAt,
221
+ email: user.email,
222
+ email_verified: user.emailVerified ?? true,
223
+ name: user.name ?? "A Person",
224
+ auth_time: issuedAt,
225
+ sid: this.sessionId,
226
+ jti: randomId("id-token"),
227
+ ...this.extraIdTokenClaims,
228
+ ...overrides
229
+ });
230
+ }
231
+ /**
232
+ * Sign an access token — the artifact FR-40's exchange and FR-45's delegated read
233
+ * are handed.
234
+ *
235
+ * The defaults are a **valid** token for the configured installation: `aud` is
236
+ * the API audience, `azp` is the first configured local client,
237
+ * `scope` is the exchange scope, `iat` is now and `exp` is now plus
238
+ * {@link FakeOidcProviderOptions.accessTokenLifetimeSeconds}. Every case in a
239
+ * spec is therefore "everything is valid, except this", and the literal numbers
240
+ * a case wants are passed in rather than derived from the plugin.
241
+ */
242
+ async mintAccessToken(overrides = {}) {
243
+ const issuedAt = overrides.issuedAt ?? this.nowSeconds();
244
+ const claims = {
245
+ iss: this.issuer,
246
+ sub: overrides.subject ?? this.user().subject,
247
+ aud: overrides.audience ?? this.apiAudience,
248
+ scope: (overrides.scopes ?? [this.exchangeScope]).join(" "),
249
+ iat: issuedAt,
250
+ exp: overrides.expiresAt ?? issuedAt + (this.options.accessTokenLifetimeSeconds ?? 300),
251
+ jti: overrides.jti === void 0 ? randomId("jti") : overrides.jti,
252
+ ...overrides.claims
253
+ };
254
+ if (overrides.authorizedParty !== null) {
255
+ claims.azp = overrides.authorizedParty ?? this.defaultAuthorizedParty;
256
+ }
257
+ return this.sign(claims);
258
+ }
259
+ /**
260
+ * Sign a back-channel logout notice (FR-33).
261
+ *
262
+ * The default is a **valid** notice about the configured person's session:
263
+ * the event member, an `iat` of now, a fresh `jti`, `sid` and no `nonce`, `exp`
264
+ * or `sub`. `extraClaims` is how a spec adds the claims FR-33 forbids (or
265
+ * removes the ones it requires), so every case reads as one deliberate change.
266
+ */
267
+ async mintLogoutToken(overrides = {}) {
268
+ const issuedAt = overrides.issuedAt ?? this.nowSeconds();
269
+ const claims = {
270
+ iss: this.issuer,
271
+ aud: this.clientId,
272
+ iat: issuedAt,
273
+ jti: overrides.jti === void 0 ? randomId("logout") : overrides.jti,
274
+ events: overrides.events === void 0 ? { [FAKE_BACKCHANNEL_LOGOUT_EVENT]: {} } : overrides.events,
275
+ sid: overrides.sid === void 0 ? this.sessionId : overrides.sid,
276
+ ...overrides.extraClaims
277
+ };
278
+ if (overrides.subject !== void 0 && overrides.subject !== null) claims.sub = overrides.subject;
279
+ if (overrides.expiresAt !== void 0 && overrides.expiresAt !== null) claims.exp = overrides.expiresAt;
280
+ return this.sign(claims);
281
+ }
282
+ /**
283
+ * Post a signed notice to a relying party's back-channel endpoint, the way the
284
+ * provider does (FR-33's "one public endpoint", form-encoded).
285
+ *
286
+ * Answers the status and the body text rather than throwing, so a spec can assert
287
+ * FR-34's "an invalid notice answers 400" — and FR-34's "200 with
288
+ * `Cache-Control: no-store`" — on a real HTTP exchange. A token is minted first
289
+ * when the caller does not pass one.
290
+ */
291
+ async postBackchannelLogout(url, token, overrides = {}) {
292
+ const logoutToken = token ?? await this.mintLogoutToken(overrides);
293
+ const response = await fetch(url, {
294
+ method: "POST",
295
+ headers: { "content-type": "application/x-www-form-urlencoded" },
296
+ body: new URLSearchParams({ logout_token: logoutToken }).toString()
297
+ });
298
+ return {
299
+ status: response.status,
300
+ body: await response.text(),
301
+ cacheControl: response.headers.get("cache-control")
302
+ };
303
+ }
304
+ /**
305
+ * Approve a pending device grant — what the person does in the browser at
306
+ * `verification_uri` (FR-39).
307
+ *
308
+ * With no argument every pending grant is approved, which is what a spec that has
309
+ * just asked for one wants; the `userCode` form is there for the case that reads
310
+ * the code off the wire.
311
+ */
312
+ approveDeviceAuthorization(userCode) {
313
+ let approved = 0;
314
+ for (const grant of this.devices.values()) {
315
+ if (userCode !== void 0 && grant.userCode !== userCode) continue;
316
+ grant.approved = true;
317
+ approved += 1;
318
+ }
319
+ return approved;
320
+ }
321
+ /** Every device grant this provider has issued, approved or not. */
322
+ get deviceGrants() {
323
+ return [...this.devices.values()].map((grant) => ({
324
+ userCode: grant.userCode,
325
+ deviceCode: grant.deviceCode,
326
+ approved: grant.approved
327
+ }));
328
+ }
329
+ /** The last authorization request's query, or `null` when none has been made. */
330
+ lastAuthorizationRequest() {
331
+ for (let index = this.calls.length - 1; index >= 0; index -= 1) {
332
+ const call = this.calls[index];
333
+ if (call.path === "/authorize") return call.query;
334
+ }
335
+ return null;
336
+ }
337
+ /** Stop listening. Safe to call twice, and safe to call after a failed start. */
338
+ async stop() {
339
+ const server = this.server;
340
+ if (server === null) return;
341
+ this.server = null;
342
+ await new Promise((resolve, reject) => {
343
+ server.close((error) => error ? reject(error) : resolve());
344
+ server.closeAllConnections();
345
+ });
346
+ }
347
+ // ---------------------------------------------------------------- internals
348
+ user() {
349
+ return this.userOverride ?? this.options.user ?? FAKE_DEFAULT_USER;
350
+ }
351
+ /**
352
+ * Whether `clientId` is one of the PUBLIC local clients (CLI, node) — RFC 8628
353
+ * public clients authenticate with their `client_id` alone, no secret.
354
+ */
355
+ isLocalClient(clientId) {
356
+ if (clientId === void 0 || clientId === "") return false;
357
+ const configured = this.options.localClients ?? [
358
+ { kind: "cli", clientId: FAKE_DEFAULT_LOCAL_CLIENT_ID }
359
+ ];
360
+ return configured.some((client) => client.clientId === clientId);
361
+ }
362
+ rotateKeysSync(options) {
363
+ this.keySequence += 1;
364
+ const key = makeSigningKey(`fake-key-${this.keySequence}`);
365
+ if (options.keepPrevious !== true) this.keys.length = 0;
366
+ this.keys.push(key);
367
+ }
368
+ currentKey() {
369
+ const key = this.keys[this.keys.length - 1];
370
+ if (key === void 0) throw new Error("FakeOidcProvider has no signing key.");
371
+ return key;
372
+ }
373
+ sign(payload) {
374
+ return signCompactJws(this.currentKey(), payload);
375
+ }
376
+ async handle(request, response) {
377
+ const url = new URL(request.url ?? "/", this.issuer || "http://localhost");
378
+ const form = await readForm(request);
379
+ const call = {
380
+ method: request.method ?? "GET",
381
+ path: url.pathname,
382
+ query: Object.fromEntries(url.searchParams.entries()),
383
+ form,
384
+ headers: Object.fromEntries(
385
+ Object.entries(request.headers).flatMap(
386
+ ([name, value]) => value === void 0 ? [] : [[name.toLowerCase(), Array.isArray(value) ? value.join(", ") : value]]
387
+ )
388
+ )
389
+ };
390
+ this.calls.push(call);
391
+ try {
392
+ switch (url.pathname) {
393
+ case "/.well-known/openid-configuration":
394
+ return sendJson(response, 200, this.discoveryDocument());
395
+ case "/jwks":
396
+ return sendJson(response, 200, { keys: this.keys.map((key) => key.jwk) });
397
+ case "/authorize":
398
+ return this.handleAuthorize(call, response);
399
+ case "/token":
400
+ return this.handleToken(call, response);
401
+ case "/device_authorization":
402
+ return this.handleDeviceAuthorization(call, response);
403
+ case "/end_session":
404
+ return this.handleEndSession(call, response);
405
+ case "/userinfo":
406
+ return sendJson(response, 200, { sub: this.user().subject, email: this.user().email });
407
+ default:
408
+ return sendJson(response, 404, { error: "not_found" });
409
+ }
410
+ } catch (error) {
411
+ return sendJson(response, 500, { error: "fake_provider_error", error_description: String(error) });
412
+ }
413
+ }
414
+ /** §10.4's "authorize (auto-approve a configured user)", with S256 required. */
415
+ handleAuthorize(call, response) {
416
+ const { query } = call;
417
+ if (query.response_type !== "code") {
418
+ return sendJson(response, 400, { error: "unsupported_response_type" });
419
+ }
420
+ if (query.code_challenge_method !== "S256" || (query.code_challenge ?? "") === "") {
421
+ return sendJson(response, 400, { error: "invalid_request", error_description: "PKCE S256 is required" });
422
+ }
423
+ if (query.client_id !== this.clientId) {
424
+ return sendJson(response, 400, { error: "invalid_request", error_description: "unknown client" });
425
+ }
426
+ const redirectUri = query.redirect_uri ?? "";
427
+ if (redirectUri === "") {
428
+ return sendJson(response, 400, { error: "invalid_request", error_description: "redirect_uri is required" });
429
+ }
430
+ const code = randomId("code");
431
+ this.codes.set(code, {
432
+ code,
433
+ clientId: query.client_id,
434
+ redirectUri,
435
+ codeChallenge: query.code_challenge ?? "",
436
+ nonce: query.nonce ?? null,
437
+ scope: query.scope ?? "openid email profile",
438
+ expiresAtSeconds: this.nowSeconds() + (this.options.authorizationCodeLifetimeSeconds ?? 60)
439
+ });
440
+ const target = new URL(redirectUri);
441
+ target.searchParams.set("code", code);
442
+ if (query.state !== void 0) target.searchParams.set("state", query.state);
443
+ target.searchParams.set("iss", this.issuer);
444
+ response.writeHead(302, { location: target.toString(), "cache-control": "no-store" });
445
+ response.end();
446
+ }
447
+ /** RFC 6749 §4.1.3 and RFC 8628 §3.4–3.5: two grants, both checked. */
448
+ handleToken(call, response) {
449
+ const { form } = call;
450
+ const publicDeviceClient = form.grant_type === "urn:ietf:params:oauth:grant-type:device_code" && this.isLocalClient(form.client_id);
451
+ if (this.clientSecret !== null && !publicDeviceClient && !this.credentialsAccepted(call)) {
452
+ return sendJson(response, 401, { error: "invalid_client" });
453
+ }
454
+ if (form.grant_type === "authorization_code") return this.handleAuthorizationCodeGrant(form, response);
455
+ if (form.grant_type === "urn:ietf:params:oauth:grant-type:device_code") {
456
+ return this.handleDeviceCodeGrant(form, response);
457
+ }
458
+ return sendJson(response, 400, { error: "unsupported_grant_type" });
459
+ }
460
+ handleAuthorizationCodeGrant(form, response) {
461
+ const stored = this.codes.get(form.code ?? "");
462
+ if (stored === void 0) {
463
+ return sendJson(response, 400, { error: "invalid_grant", error_description: "unknown code" });
464
+ }
465
+ this.codes.delete(stored.code);
466
+ if (stored.expiresAtSeconds < this.nowSeconds()) {
467
+ return sendJson(response, 400, { error: "invalid_grant", error_description: "expired code" });
468
+ }
469
+ if (form.redirect_uri !== stored.redirectUri) {
470
+ return sendJson(response, 400, { error: "invalid_grant", error_description: "redirect_uri mismatch" });
471
+ }
472
+ if (s256(form.code_verifier ?? "") !== stored.codeChallenge) {
473
+ return sendJson(response, 400, { error: "invalid_grant", error_description: "PKCE verification failed" });
474
+ }
475
+ void this.tokensFor(stored.nonce, stored.scope).then(
476
+ (tokens) => sendJson(response, 200, tokens),
477
+ (error) => sendJson(response, 500, { error: "server_error", error_description: String(error) })
478
+ );
479
+ }
480
+ handleDeviceCodeGrant(form, response) {
481
+ const grant = this.devices.get(form.device_code ?? "");
482
+ if (grant === void 0) {
483
+ return sendJson(response, 400, { error: "invalid_grant", error_description: "unknown device code" });
484
+ }
485
+ const nowSeconds = this.nowSeconds();
486
+ if (grant.expiresAtSeconds < nowSeconds) {
487
+ this.devices.delete(grant.deviceCode);
488
+ return sendJson(response, 400, { error: "expired_token" });
489
+ }
490
+ if (grant.lastPolledAtSeconds !== null && nowSeconds - grant.lastPolledAtSeconds < grant.intervalSeconds) {
491
+ grant.lastPolledAtSeconds = nowSeconds;
492
+ return sendJson(response, 400, { error: "slow_down" });
493
+ }
494
+ grant.lastPolledAtSeconds = nowSeconds;
495
+ if (!grant.approved) return sendJson(response, 400, { error: "authorization_pending" });
496
+ this.devices.delete(grant.deviceCode);
497
+ const authorizedParty = this.isLocalClient(grant.clientId) ? grant.clientId : this.clientId;
498
+ void this.tokensFor(null, grant.scope, authorizedParty).then(
499
+ (tokens) => sendJson(response, 200, tokens),
500
+ (error) => sendJson(response, 500, { error: "server_error", error_description: String(error) })
501
+ );
502
+ }
503
+ /** RFC 8628 §3.2's device authorization response. */
504
+ handleDeviceAuthorization(call, response) {
505
+ if (this.clientSecret !== null && !this.isLocalClient(call.form.client_id) && !this.credentialsAccepted(call)) {
506
+ return sendJson(response, 401, { error: "invalid_client" });
507
+ }
508
+ const intervalSeconds = this.options.devicePollIntervalSeconds ?? 5;
509
+ const grant = {
510
+ deviceCode: randomId("device"),
511
+ userCode: randomUserCode(),
512
+ clientId: call.form.client_id ?? this.clientId,
513
+ scope: call.form.scope ?? "openid email profile",
514
+ expiresAtSeconds: this.nowSeconds() + (this.options.deviceCodeLifetimeSeconds ?? 900),
515
+ intervalSeconds,
516
+ approved: false,
517
+ lastPolledAtSeconds: null
518
+ };
519
+ this.devices.set(grant.deviceCode, grant);
520
+ return sendJson(response, 200, {
521
+ device_code: grant.deviceCode,
522
+ user_code: grant.userCode,
523
+ verification_uri: `${this.issuer}/device`,
524
+ verification_uri_complete: `${this.issuer}/device?user_code=${grant.userCode}`,
525
+ expires_in: this.options.deviceCodeLifetimeSeconds ?? 900,
526
+ interval: intervalSeconds
527
+ });
528
+ }
529
+ /** RP-Initiated Logout 1.0 §2: back to the relying party, with `state` preserved. */
530
+ handleEndSession(call, response) {
531
+ const redirectUri = call.query.post_logout_redirect_uri;
532
+ if (redirectUri === void 0 || redirectUri === "") {
533
+ return sendJson(response, 200, { signedOut: true });
534
+ }
535
+ const target = new URL(redirectUri);
536
+ if (call.query.state !== void 0) target.searchParams.set("state", call.query.state);
537
+ response.writeHead(302, { location: target.toString(), "cache-control": "no-store" });
538
+ response.end();
539
+ }
540
+ async tokensFor(nonce, scope, authorizedParty = this.clientId) {
541
+ const user = this.user();
542
+ const idToken = await this.mintIdToken({ nonce, sid: this.sessionId, sub: user.subject });
543
+ const accessToken = await this.mintAccessToken({
544
+ scopes: scope.split(/\s+/u).filter((entry) => entry.length > 0),
545
+ authorizedParty
546
+ });
547
+ return {
548
+ access_token: accessToken,
549
+ id_token: idToken,
550
+ token_type: "Bearer",
551
+ expires_in: this.options.accessTokenLifetimeSeconds ?? 300,
552
+ scope
553
+ };
554
+ }
555
+ /** `client_secret_basic` (RFC 6749 §2.3.1), which is what plan §4.2 registers. */
556
+ credentialsAccepted(call) {
557
+ const header = call.headers.authorization;
558
+ if (header === void 0 || !header.toLowerCase().startsWith("basic ")) return false;
559
+ const raw = header.slice("basic ".length);
560
+ const decoded = Buffer.from(raw, "base64").toString("utf8");
561
+ const separator = decoded.indexOf(":");
562
+ if (separator < 0) return false;
563
+ const username = decoded.slice(0, separator);
564
+ const decodedUsername = decodeURIComponent(username.replace(/\+/gu, " "));
565
+ const password = decodeURIComponent(decoded.slice(separator + 1).replace(/\+/gu, " "));
566
+ const usernameMatches = username === this.clientId || decodedUsername === this.clientId;
567
+ return usernameMatches && password === (this.clientSecret ?? "");
568
+ }
569
+ };
570
+ function s256(codeVerifier) {
571
+ return (0, import_node_crypto.createHash)("sha256").update(codeVerifier, "ascii").digest("base64url");
572
+ }
573
+ function makeSigningKey(kid) {
574
+ const { privateKey, publicKey } = (0, import_node_crypto.generateKeyPairSync)("ec", { namedCurve: "prime256v1" });
575
+ const jwk = publicKey.export({ format: "jwk" });
576
+ return { kid, jwk: { ...jwk, kid, alg: "ES256", use: "sig" }, privateKey };
577
+ }
578
+ function signCompactJws(key, payload) {
579
+ const header = { alg: "ES256", kid: key.kid, typ: "JWT" };
580
+ const signingInput = `${base64Url(JSON.stringify(header))}.${base64Url(JSON.stringify(payload))}`;
581
+ const signature = (0, import_node_crypto.createSign)("SHA256").update(signingInput).sign(key.privateKey);
582
+ return `${signingInput}.${base64Url(derToJoseSignature(signature))}`;
583
+ }
584
+ function derToJoseSignature(der, partLength = 32) {
585
+ if (der.length < 8 || der[0] !== 48) throw new Error("unexpected ECDSA signature encoding");
586
+ let offset = 1;
587
+ if (der[offset] !== void 0 && (der[offset] & 128) !== 0) {
588
+ offset += 1 + (der[offset] & 127);
589
+ } else {
590
+ offset += 1;
591
+ }
592
+ const readInteger = () => {
593
+ if (der[offset] !== 2) throw new Error("unexpected ECDSA signature encoding");
594
+ const length = der[offset + 1];
595
+ if (length === void 0) throw new Error("unexpected ECDSA signature encoding");
596
+ const value = der.subarray(offset + 2, offset + 2 + length);
597
+ offset += 2 + length;
598
+ return value;
599
+ };
600
+ const r = readInteger();
601
+ const s = readInteger();
602
+ return Buffer.concat([leftPadUnsigned(r, partLength), leftPadUnsigned(s, partLength)]);
603
+ }
604
+ function leftPadUnsigned(value, length) {
605
+ let start = 0;
606
+ while (start < value.length - 1 && value[start] === 0) start += 1;
607
+ const trimmed = value.subarray(start);
608
+ if (trimmed.length > length) throw new Error("ECDSA coordinate wider than the curve");
609
+ return Buffer.concat([Buffer.alloc(length - trimmed.length, 0), trimmed]);
610
+ }
611
+ function base64Url(value) {
612
+ return Buffer.from(value).toString("base64url");
613
+ }
614
+ function randomId(prefix) {
615
+ return `${prefix}-${(0, import_node_crypto.randomBytes)(12).toString("hex")}`;
616
+ }
617
+ function randomUserCode() {
618
+ const alphabet = "BCDFGHJKLMNPQRSTVWXZ";
619
+ const bytes = (0, import_node_crypto.randomBytes)(8);
620
+ let code = "";
621
+ for (let index = 0; index < 8; index += 1) {
622
+ if (index === 4) code += "-";
623
+ code += alphabet[bytes[index] % alphabet.length];
624
+ }
625
+ return code;
626
+ }
627
+ function sendJson(response, status, body) {
628
+ const payload = JSON.stringify(body);
629
+ response.writeHead(status, {
630
+ "content-type": "application/json",
631
+ "content-length": Buffer.byteLength(payload),
632
+ "cache-control": "no-store"
633
+ });
634
+ response.end(payload);
635
+ }
636
+ async function readForm(request) {
637
+ if (request.method !== "POST") return {};
638
+ const chunks = [];
639
+ for await (const chunk of request) chunks.push(Buffer.from(chunk));
640
+ const body = Buffer.concat(chunks).toString("utf8");
641
+ if (body === "") return {};
642
+ return Object.fromEntries(new URLSearchParams(body).entries());
643
+ }
644
+ // Annotate the CommonJS export names for ESM import in node:
645
+ 0 && (module.exports = {
646
+ FAKE_BACKCHANNEL_LOGOUT_EVENT,
647
+ FAKE_DEFAULT_LOCAL_CLIENT_ID,
648
+ FAKE_DEFAULT_USER,
649
+ FakeOidcProvider,
650
+ s256
651
+ });