@hyper.authio/testing 0.7.0 → 0.8.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/README.md CHANGED
@@ -33,7 +33,8 @@ await authio.close();
33
33
  UserInfo (JSON, or a signed JWT with `userInfoAsJwt`), device authorization and end session.
34
34
  - Sign-in: `authorize(url)` follows an authorization URL as a browser would and returns the callback URL (or, for
35
35
  `response_mode=web_message`, the posted parameters). It signs in `signInAs`, the `login_hint` or the first user;
36
- honours PKCE (S256, plain), `prompt` (`none` answers `login_required` without a session, `login` signs in again),
36
+ honours PKCE (S256, plain; `code_challenge_method` is required), `prompt` (`none` answers `login_required` without a
37
+ session, `login` signs in again),
37
38
  `max_age`, `acr_values`, and sends RFC 9207 `iss` (`omitAuthorizationIssuer` leaves it out). ID tokens carry `nonce`,
38
39
  `sid`, `auth_time`, `acr`, `amr` and `at_hash`. `createAuthorizationCode` still mints a code directly.
39
40
  - Sign-out: the end-session endpoint checks `post_logout_redirect_uri`, echoes `state`, and posts a logout token to the
@@ -45,8 +46,23 @@ await authio.close();
45
46
  - JWE: a client with `encryption: { key, algorithm }` (RSA-OAEP or RSA-OAEP-256) gets its ID tokens, UserInfo, and
46
47
  optionally access and logout tokens encrypted; `issueAccessToken({ encryptTo })` mints one directly.
47
48
  - Personal access tokens: `personalAccessTokens: [{ token: "pat_…", sub, clientId? }]`.
48
- - Client authentication: `client_secret_basic`, `client_secret_post`, `private_key_jwt` (register `publicKey`) and
49
- `none`.
49
+ - Clients are configured as in Authio's console, and the fake enforces it as Authio does, refusing with an error that
50
+ names the setting and accepting what the client does not require:
51
+ - `tokenEndpointAuthMethod`: `client_secret_basic` (default with `clientSecret`), `client_secret_post`,
52
+ `private_key_jwt` (default with only `publicKey`) or `none` (default with neither), matched exactly
53
+ (`invalid_client`); a secret sent by a `none` client is ignored.
54
+ - `requirePkce` (default `true`, as Authio creates clients) refuses an authorization request without
55
+ `code_challenge`: declare `requirePkce: false` for a client with PKCE off. `pkceMethods` (default `["S256"]`)
56
+ refuses another method even when PKCE is not required; PKCE sent to a client that does not require it is checked
57
+ and accepted.
58
+ - `signingAlgorithm`: `RS256` (default) or `HS256`/`HS384`/`HS512` with the client secret, for the client's ID
59
+ tokens, UserInfo and logout tokens; `accessTokenSigningAlgorithm` the same for its access tokens, as Authio keeps
60
+ two settings. The secret needs at least 32, 48 or 64 bytes (RFC 7518 §3.2), or the client is refused.
61
+ - `issueIdTokenOnRefresh` adds a new ID token to every refresh.
62
+ - `frontchannelLogoutUri` (with `frontchannelLogoutSessionRequired` for `iss` and `sid`) is loaded when a session of
63
+ the client ends (`frontchannelDeliveries`); `frontchannelLogoutUrl` and `deliverFrontchannelLogout` do it by hand.
64
+ - `response_mode` other than `query` and `web_message` is refused.
65
+ - `clock` (such as the `ManualClock` the library uses) is the time tokens are issued and checked with.
50
66
  - `issueAccessToken` options: `expiresIn` (negative for an expired token), `type` (`ID` for a non-access token),
51
67
  `issuer`, `untrusted` (a signature that does not verify).
52
68
  - `failures.set("token", [{ status: 503 }])` answers the next calls to an endpoint with errors; `deviceOutcomes`
package/dist/index.cjs CHANGED
@@ -13,6 +13,8 @@ var FakeAuthio = class _FakeAuthio {
13
13
  failures = /* @__PURE__ */ new Map();
14
14
  /** Every back-channel logout token posted, in order. */
15
15
  backchannelDeliveries = [];
16
+ /** Every front-channel logout page loaded, in order. */
17
+ frontchannelDeliveries = [];
16
18
  /** What successive device-code polls answer; the last value repeats. Defaults to `["approved"]`. */
17
19
  deviceOutcomes = ["approved"];
18
20
  /** Overrides the discovery document's issuer, to test the mismatch check. */
@@ -33,6 +35,7 @@ var FakeAuthio = class _FakeAuthio {
33
35
  issued = /* @__PURE__ */ new Map();
34
36
  devicePolls = /* @__PURE__ */ new Map();
35
37
  lifetime;
38
+ clock;
36
39
  session;
37
40
  server;
38
41
  signingKey;
@@ -45,7 +48,8 @@ var FakeAuthio = class _FakeAuthio {
45
48
  constructor(options) {
46
49
  this.realm = options.realm ?? "fake";
47
50
  this.lifetime = options.accessTokenLifetime ?? 300;
48
- for (const client of options.clients ?? []) this.clients.set(client.clientId, client);
51
+ this.clock = options.clock ?? { now: () => Date.now() };
52
+ for (const client of options.clients ?? []) this.clients.set(client.clientId, checkClient(client));
49
53
  for (const user of options.users ?? []) this.users.set(user.username, user);
50
54
  for (const pat of options.personalAccessTokens ?? []) this.personalAccessTokens.set(pat.token, pat);
51
55
  }
@@ -75,7 +79,7 @@ var FakeAuthio = class _FakeAuthio {
75
79
  }
76
80
  /** Registers a client. */
77
81
  addClient(client) {
78
- this.clients.set(client.clientId, client);
82
+ this.clients.set(client.clientId, checkClient(client));
79
83
  }
80
84
  /** Registers a person. */
81
85
  addUser(user) {
@@ -126,7 +130,7 @@ var FakeAuthio = class _FakeAuthio {
126
130
  * Mints an access token signed by the realm key, recorded so introspection and revocation know it.
127
131
  */
128
132
  async issueAccessToken(options = {}) {
129
- const now = Math.floor(Date.now() / 1e3);
133
+ const now = this.nowSeconds();
130
134
  const clientId = options.clientId ?? "fake-client";
131
135
  const jti = crypto.randomUUID();
132
136
  const payload = {
@@ -137,7 +141,8 @@ var FakeAuthio = class _FakeAuthio {
137
141
  ...options.sub ? { sub: options.sub } : {},
138
142
  ...options.claims
139
143
  };
140
- const signed = await new jose.SignJWT(payload).setProtectedHeader({ alg: "RS256", kid: this.keyId, typ: "JWT" }).setIssuer(options.issuer ?? this.issuer).setAudience(options.audience ?? clientId).setIssuedAt(now).setNotBefore(now).setExpirationTime(now + (options.expiresIn ?? this.lifetime)).setJti(jti).sign(options.untrusted ? this.untrustedKey : this.signingKey);
144
+ const builder = new jose.SignJWT(payload).setIssuer(options.issuer ?? this.issuer).setAudience(options.audience ?? clientId).setIssuedAt(now).setNotBefore(now).setExpirationTime(now + (options.expiresIn ?? this.lifetime)).setJti(jti);
145
+ const signed = options.untrusted ? await builder.setProtectedHeader({ alg: "RS256", kid: this.keyId, typ: "JWT" }).sign(this.untrustedKey) : await this.sign(builder, this.clients.get(clientId), "JWT", this.clients.get(clientId)?.accessTokenSigningAlgorithm ?? "RS256");
141
146
  const token = options.encryptTo ? await encryptTo(signed, options.encryptTo) : signed;
142
147
  this.issued.set(token, { claims: jose.decodeJwt(signed), clientId, revoked: false });
143
148
  return token;
@@ -147,7 +152,7 @@ var FakeAuthio = class _FakeAuthio {
147
152
  * removes one) and `type` the header `typ`, to build invalid ones in tests.
148
153
  */
149
154
  async createLogoutToken(options) {
150
- const now = Math.floor(Date.now() / 1e3);
155
+ const now = this.nowSeconds();
151
156
  const payload = {
152
157
  iss: this.issuer,
153
158
  aud: options.clientId,
@@ -160,7 +165,7 @@ var FakeAuthio = class _FakeAuthio {
160
165
  ...options.overrides
161
166
  };
162
167
  for (const key of Object.keys(payload)) if (payload[key] === void 0) delete payload[key];
163
- const signed = await new jose.SignJWT(payload).setProtectedHeader({ alg: "RS256", kid: this.keyId, typ: options.type ?? "logout+jwt" }).sign(this.signingKey);
168
+ const signed = await this.sign(new jose.SignJWT(payload), this.clients.get(options.clientId), options.type ?? "logout+jwt");
164
169
  const encryption = this.clients.get(options.clientId)?.encryption;
165
170
  return encryption?.logoutTokens ? encryptTo(signed, encryption) : signed;
166
171
  }
@@ -188,6 +193,38 @@ var FakeAuthio = class _FakeAuthio {
188
193
  this.backchannelDeliveries.push({ clientId, uri: client.backchannelLogoutUri, logoutToken, status });
189
194
  return status;
190
195
  }
196
+ /**
197
+ * The front-channel logout URL of a client for a session: its `frontchannelLogoutUri`, with `iss` and `sid` when the
198
+ * client requires the session.
199
+ */
200
+ frontchannelLogoutUrl(clientId, sid) {
201
+ const client = this.clients.get(clientId);
202
+ if (!client?.frontchannelLogoutUri) throw new Error(`The fake client ${clientId} has no frontchannelLogoutUri.`);
203
+ const url = new URL(client.frontchannelLogoutUri);
204
+ if (client.frontchannelLogoutSessionRequired) {
205
+ url.searchParams.set("iss", this.issuer);
206
+ url.searchParams.set("sid", sid);
207
+ }
208
+ return url.toString();
209
+ }
210
+ /**
211
+ * Loads the client's front-channel logout page, as the browser does in an iframe when Authio ends a session.
212
+ *
213
+ * @returns The HTTP status the client answered, or 0 when it could not be reached.
214
+ */
215
+ async deliverFrontchannelLogout(clientId, options) {
216
+ const url = this.frontchannelLogoutUrl(clientId, options.sid);
217
+ let status = 0;
218
+ try {
219
+ const response = await fetch(url, { headers: options.headers, redirect: "manual" });
220
+ status = response.status;
221
+ await response.arrayBuffer();
222
+ } catch {
223
+ status = 0;
224
+ }
225
+ this.frontchannelDeliveries.push({ clientId, url, status });
226
+ return status;
227
+ }
191
228
  /** Marks a token revoked, as Authio would after a sign-out: an access token, or a refresh token, which is then forgotten. */
192
229
  revokeToken(token) {
193
230
  const issued = this.issued.get(token);
@@ -304,6 +341,8 @@ var FakeAuthio = class _FakeAuthio {
304
341
  acr_values_supported: ["1", "2", "3"],
305
342
  backchannel_logout_supported: true,
306
343
  backchannel_logout_session_supported: true,
344
+ frontchannel_logout_supported: true,
345
+ frontchannel_logout_session_supported: true,
307
346
  authorization_response_iss_parameter_supported: !this.omitAuthorizationIssuer
308
347
  };
309
348
  }
@@ -329,11 +368,28 @@ var FakeAuthio = class _FakeAuthio {
329
368
  response.writeHead(302, { location: location.toString() });
330
369
  response.end();
331
370
  };
371
+ if (query.response_mode !== void 0 && query.response_mode !== "query" && query.response_mode !== "web_message")
372
+ return answer({ error: "invalid_request", error_description: `The response_mode '${query.response_mode}' is not supported: Authio answers with query or web_message.` });
332
373
  if (query.response_type !== "code") return answer({ error: "unsupported_response_type", error_description: "Only code is supported." });
333
- const method = query.code_challenge_method ?? (query.code_challenge ? "plain" : void 0);
334
- if (method && method !== "S256" && method !== "plain") return answer({ error: "invalid_request", error_description: "Unsupported PKCE method." });
374
+ const method = query.code_challenge ? query.code_challenge_method : void 0;
375
+ const allowedMethods = client.pkceMethods ?? ["S256"];
376
+ if (query.code_challenge) {
377
+ if (!method)
378
+ return answer({ error: "invalid_request", error_description: "code_challenge_method is required with code_challenge: Authio does not assume plain." });
379
+ if (method !== "S256" && method !== "plain") return answer({ error: "invalid_request", error_description: `The PKCE method '${method}' is not supported.` });
380
+ if (!allowedMethods.includes(method))
381
+ return answer({
382
+ error: "invalid_request",
383
+ error_description: `The PKCE method '${method}' is not allowed for the client ${client.clientId}, which allows ${allowedMethods.join(", ")} (codeChallengeMethod).`
384
+ });
385
+ } else if (client.requirePkce ?? true) {
386
+ return answer({
387
+ error: "invalid_request",
388
+ error_description: `The client ${client.clientId} requires PKCE: send code_challenge and code_challenge_method (checks: ["pkce"]).`
389
+ });
390
+ }
335
391
  const prompts = (query.prompt ?? "").split(" ").filter(Boolean);
336
- const now = Math.floor(Date.now() / 1e3);
392
+ const now = this.nowSeconds();
337
393
  const maxAge = query.max_age !== void 0 ? Number(query.max_age) : void 0;
338
394
  const tooOld = this.session !== void 0 && maxAge !== void 0 && now - this.session.authTime > maxAge;
339
395
  const requestedAcr = (query.acr_values ?? "").split(" ").filter(Boolean).sort().at(-1);
@@ -375,7 +431,11 @@ var FakeAuthio = class _FakeAuthio {
375
431
  let clientId = query.client_id;
376
432
  if (query.id_token_hint) {
377
433
  try {
378
- const { payload } = await jose.jwtVerify(query.id_token_hint, this.verificationKey, { issuer: this.issuer });
434
+ const audience = [jose.decodeJwt(query.id_token_hint).aud ?? []].flat()[0];
435
+ const { payload } = await jose.jwtVerify(query.id_token_hint, this.verificationKeyFor(audience ? this.clients.get(audience) : void 0, query.id_token_hint), {
436
+ issuer: this.issuer,
437
+ currentDate: new Date(this.clock.now())
438
+ });
379
439
  clientId ??= [payload.aud ?? []].flat()[0];
380
440
  } catch {
381
441
  return send(response, { status: 400, body: { error: "invalid_request", error_description: "The id_token_hint is not valid." } });
@@ -388,9 +448,11 @@ var FakeAuthio = class _FakeAuthio {
388
448
  const ended = this.session;
389
449
  this.session = void 0;
390
450
  if (ended) this.revokeRefreshTokens({ sid: ended.sid });
391
- if (ended) {
392
- for (const id of ended.clients) if (this.clients.get(id)?.backchannelLogoutUri) await this.deliverBackchannelLogout(id, { sub: ended.sub, sid: ended.sid });
393
- }
451
+ if (ended)
452
+ for (const id of ended.clients) {
453
+ if (this.clients.get(id)?.backchannelLogoutUri) await this.deliverBackchannelLogout(id, { sub: ended.sub, sid: ended.sid });
454
+ if (this.clients.get(id)?.frontchannelLogoutUri) await this.deliverFrontchannelLogout(id, { sid: ended.sid });
455
+ }
394
456
  if (!redirect) {
395
457
  response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
396
458
  response.end("<!doctype html><p>You are signed out.</p>");
@@ -404,28 +466,38 @@ var FakeAuthio = class _FakeAuthio {
404
466
  async authenticate(recorded, form) {
405
467
  const invalid = (description) => ({ status: 401, body: { error: "invalid_client", error_description: description } });
406
468
  const authorization = recorded.headers.authorization;
407
- let client;
408
- if (authorization?.startsWith("Basic ")) {
409
- const [id, secret] = Buffer.from(authorization.slice(6), "base64").toString("utf8").split(":").map(decodeURIComponent);
410
- client = this.clients.get(id ?? "");
411
- if (!client || !client.clientSecret || client.clientSecret !== secret) return invalid("The client credentials are wrong.");
412
- } else if (form.client_assertion) {
413
- client = this.clients.get(form.client_id ?? "");
414
- if (!client?.publicKey) return invalid("The client has no registered key.");
415
- if (form.client_assertion_type !== "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") return invalid("Wrong assertion type.");
416
- try {
417
- const key = await jose.importJWK(client.publicKey, client.publicKey.alg);
418
- const { payload } = await jose.jwtVerify(form.client_assertion, key, { issuer: client.clientId, subject: client.clientId, audience: this.issuer });
419
- if (!payload.jti) return invalid("The assertion has no jti.");
420
- } catch (error) {
421
- return invalid(`The assertion does not verify: ${error.message}`);
469
+ const basic = authorization?.startsWith("Basic ") ? Buffer.from(authorization.slice(6), "base64").toString("utf8").split(":").map(decodeURIComponent) : void 0;
470
+ const presented = basic ? "client_secret_basic" : form.client_assertion ? "private_key_jwt" : form.client_secret !== void 0 ? "client_secret_post" : "none";
471
+ const client = this.clients.get((basic ? basic[0] : form.client_id) ?? "");
472
+ if (!client) return invalid("Unknown client.");
473
+ if (form.client_assertion && form.client_secret !== void 0) return invalid("A client assertion and a client secret cannot be sent together.");
474
+ const registered = registeredMethodOf(client);
475
+ if (presented !== registered && !(registered === "none" && (presented === "client_secret_basic" || presented === "client_secret_post")))
476
+ return invalid(`The client ${client.clientId} authenticates with ${registered} (tokenEndpointAuthMethod), not ${presented}.`);
477
+ switch (registered) {
478
+ case "client_secret_basic":
479
+ if (!client.clientSecret || client.clientSecret !== basic?.[1]) return invalid("The client credentials are wrong.");
480
+ break;
481
+ case "client_secret_post":
482
+ if (!client.clientSecret || client.clientSecret !== form.client_secret) return invalid("The client credentials are wrong.");
483
+ break;
484
+ case "private_key_jwt": {
485
+ if (!client.publicKey) return invalid("The client has no registered key.");
486
+ if (form.client_assertion_type !== "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") return invalid("Wrong assertion type.");
487
+ try {
488
+ const key = await jose.importJWK(client.publicKey, client.publicKey.alg);
489
+ const { payload } = await jose.jwtVerify(form.client_assertion, key, {
490
+ issuer: client.clientId,
491
+ subject: client.clientId,
492
+ audience: this.issuer,
493
+ currentDate: new Date(this.clock.now())
494
+ });
495
+ if (!payload.jti) return invalid("The assertion has no jti.");
496
+ } catch (error) {
497
+ return invalid(`The assertion does not verify: ${error.message}`);
498
+ }
499
+ break;
422
500
  }
423
- } else if (form.client_secret !== void 0) {
424
- client = this.clients.get(form.client_id ?? "");
425
- if (!client || client.clientSecret !== form.client_secret) return invalid("The client credentials are wrong.");
426
- } else {
427
- client = this.clients.get(form.client_id ?? "");
428
- if (!client || client.clientSecret || client.publicKey) return invalid("The client must authenticate.");
429
501
  }
430
502
  recorded.clientId = client.clientId;
431
503
  return client;
@@ -460,11 +532,12 @@ var FakeAuthio = class _FakeAuthio {
460
532
  const code = this.codes.get(form.code ?? "");
461
533
  if (!code || code.clientId !== client.clientId || code.redirectUri !== form.redirect_uri) return grant("invalid_grant", "The code is not valid.");
462
534
  if (code.codeVerifier && code.codeVerifier !== form.code_verifier) return grant("invalid_grant", "PKCE verification failed.");
463
- if (code.codeChallenge && !verifiesChallenge(form.code_verifier, code.codeChallenge, code.codeChallengeMethod ?? "plain"))
535
+ if (code.codeChallenge && !form.code_verifier) return grant("invalid_grant", "code_verifier is required: the code was issued with a code_challenge.");
536
+ if (code.codeChallenge && !verifiesChallenge(form.code_verifier, code.codeChallenge, code.codeChallengeMethod ?? "S256"))
464
537
  return grant("invalid_grant", "PKCE verification failed.");
465
538
  this.codes.delete(form.code);
466
539
  const user = [...this.users.values()].find((candidate) => candidate.sub === code.sub);
467
- const signIn = code.sid ? { sid: code.sid, auth_time: code.authTime, acr: code.acr, amr: code.amr } : { auth_time: Math.floor(Date.now() / 1e3), acr: user.acr ?? "1", amr: user.amr ?? ["pwd"] };
540
+ const signIn = code.sid ? { sid: code.sid, auth_time: code.authTime, acr: code.acr, amr: code.amr } : { auth_time: this.nowSeconds(), acr: user.acr ?? "1", amr: user.amr ?? ["pwd"] };
468
541
  return this.tokenAnswer(
469
542
  client,
470
543
  {
@@ -483,10 +556,13 @@ var FakeAuthio = class _FakeAuthio {
483
556
  if (!refresh || refresh.clientId !== client.clientId) return grant("invalid_grant", "The refresh token is not valid.");
484
557
  this.refreshTokens.delete(form.refresh_token);
485
558
  const user = [...this.users.values()].find((candidate) => candidate.sub === refresh.sub);
559
+ const signIn = { ...refresh.sid ? { sid: refresh.sid } : {}, ...refresh.authTime ? { auth_time: refresh.authTime } : {} };
486
560
  return this.tokenAnswer(
487
561
  client,
488
- { sub: refresh.sub, scope: form.scope ?? refresh.scope, claims: { ...client.claims, ...user?.claims, ...refresh.sid ? { sid: refresh.sid } : {} } },
489
- user
562
+ { sub: refresh.sub, scope: form.scope ?? refresh.scope, claims: { ...client.claims, ...user?.claims, ...signIn } },
563
+ user,
564
+ void 0,
565
+ client.issueIdTokenOnRefresh ? { signIn } : void 0
490
566
  );
491
567
  }
492
568
  case "urn:ietf:params:oauth:grant-type:token-exchange": {
@@ -552,21 +628,33 @@ var FakeAuthio = class _FakeAuthio {
552
628
  if (user) {
553
629
  const refreshToken = crypto.randomUUID();
554
630
  const sid = typeof options.claims?.sid === "string" ? options.claims.sid : void 0;
555
- this.refreshTokens.set(refreshToken, { clientId: client.clientId, sub: user.sub, scope: options.scope, sid, expiresAt: Math.floor(Date.now() / 1e3) + 30 * 24 * 3600 });
631
+ const authTime = typeof options.claims?.auth_time === "number" ? options.claims.auth_time : void 0;
632
+ this.refreshTokens.set(refreshToken, {
633
+ clientId: client.clientId,
634
+ sub: user.sub,
635
+ scope: options.scope,
636
+ sid,
637
+ authTime,
638
+ expiresAt: this.nowSeconds() + 30 * 24 * 3600
639
+ });
556
640
  body.refresh_token = refreshToken;
557
641
  }
558
642
  if (user && idToken && (options.scope ?? "").split(" ").includes("openid")) {
559
- const now = Math.floor(Date.now() / 1e3);
643
+ const now = this.nowSeconds();
560
644
  const signIn = Object.fromEntries(Object.entries(idToken.signIn).filter(([, value]) => value !== void 0));
561
- const signed = await new jose.SignJWT({
562
- typ: "ID",
563
- azp: client.clientId,
564
- ...signIn,
565
- ...idToken.nonce ? { nonce: idToken.nonce } : {},
566
- at_hash: halfHash(accessToken),
567
- preferred_username: user.username,
568
- ...user.userInfo
569
- }).setProtectedHeader({ alg: "RS256", kid: this.keyId, typ: "JWT" }).setIssuer(this.issuer).setAudience(client.clientId).setSubject(user.sub).setIssuedAt(now).setExpirationTime(now + this.lifetime).sign(this.signingKey);
645
+ const signed = await this.sign(
646
+ new jose.SignJWT({
647
+ typ: "ID",
648
+ azp: client.clientId,
649
+ ...signIn,
650
+ ...idToken.nonce ? { nonce: idToken.nonce } : {},
651
+ at_hash: halfHash(accessToken, client.signingAlgorithm ?? "RS256"),
652
+ preferred_username: user.username,
653
+ ...user.userInfo
654
+ }).setIssuer(this.issuer).setAudience(client.clientId).setSubject(user.sub).setIssuedAt(now).setExpirationTime(now + this.lifetime),
655
+ client,
656
+ "JWT"
657
+ );
570
658
  body.id_token = encryption && encryption.idTokens !== false ? await encryptTo(signed, encryption) : signed;
571
659
  }
572
660
  return { status: 200, body };
@@ -575,7 +663,7 @@ var FakeAuthio = class _FakeAuthio {
575
663
  const client = await this.authenticate(recorded, form);
576
664
  if ("status" in client) return client;
577
665
  const issued = this.issued.get(form.token ?? "");
578
- const now = Math.floor(Date.now() / 1e3);
666
+ const now = this.nowSeconds();
579
667
  const refresh = this.refreshTokens.get(form.token ?? "");
580
668
  if (refresh)
581
669
  return refresh.clientId === client.clientId ? {
@@ -632,13 +720,29 @@ var FakeAuthio = class _FakeAuthio {
632
720
  const user = [...this.users.values()].find((candidate) => candidate.sub === issued.claims.sub);
633
721
  const claims = { sub: issued.claims.sub, ...user?.userInfo };
634
722
  if (!this.userInfoAsJwt) return send(response, { status: 200, body: claims });
635
- const now = Math.floor(Date.now() / 1e3);
636
- const signed = await new jose.SignJWT(claims).setProtectedHeader({ alg: "RS256", kid: this.keyId }).setIssuer(this.issuer).setAudience(issued.clientId).setIssuedAt(now).setExpirationTime(now + this.lifetime).sign(this.signingKey);
723
+ const now = this.nowSeconds();
724
+ const signed = await this.sign(
725
+ new jose.SignJWT(claims).setIssuer(this.issuer).setAudience(issued.clientId).setIssuedAt(now).setExpirationTime(now + this.lifetime),
726
+ this.clients.get(issued.clientId),
727
+ void 0
728
+ );
637
729
  const encryption = this.clients.get(issued.clientId)?.encryption;
638
730
  const jwt = encryption && encryption.userInfo !== false ? await encryptTo(signed, encryption) : signed;
639
731
  response.writeHead(200, { "content-type": "application/jwt" });
640
732
  response.end(jwt);
641
733
  }
734
+ nowSeconds() {
735
+ return Math.floor(this.clock.now() / 1e3);
736
+ }
737
+ sign(builder, client, type, algorithm = client?.signingAlgorithm ?? "RS256") {
738
+ if (algorithm === "RS256") return builder.setProtectedHeader({ alg: "RS256", kid: this.keyId, ...type ? { typ: type } : {} }).sign(this.signingKey);
739
+ if (!client?.clientSecret) throw new Error(`The fake client ${client?.clientId} signs with ${algorithm} and has no clientSecret.`);
740
+ return builder.setProtectedHeader({ alg: algorithm, ...type ? { typ: type } : {} }).sign(new TextEncoder().encode(client.clientSecret));
741
+ }
742
+ verificationKeyFor(client, token) {
743
+ const algorithm = String(jose.decodeProtectedHeader(token).alg ?? "");
744
+ return algorithm.startsWith("HS") && client?.clientSecret ? new TextEncoder().encode(client.clientSecret) : this.verificationKey;
745
+ }
642
746
  requireUser(username) {
643
747
  const user = this.users.get(username);
644
748
  if (!user) throw new Error(`The fake has no user ${username}.`);
@@ -665,8 +769,21 @@ function verifiesChallenge(verifier, challenge, method) {
665
769
  if (!verifier) return false;
666
770
  return method === "S256" ? crypto.createHash("sha256").update(verifier, "ascii").digest("base64url") === challenge : verifier === challenge;
667
771
  }
668
- function halfHash(value) {
669
- return crypto.createHash("sha256").update(value, "ascii").digest().subarray(0, 16).toString("base64url");
772
+ function halfHash(value, algorithm) {
773
+ const size = algorithm.endsWith("384") ? 384 : algorithm.endsWith("512") ? 512 : 256;
774
+ const digest = crypto.createHash(`sha${size}`).update(value, "ascii").digest();
775
+ return digest.subarray(0, digest.length / 2).toString("base64url");
776
+ }
777
+ function checkClient(client) {
778
+ for (const algorithm of [client.signingAlgorithm ?? "RS256", client.accessTokenSigningAlgorithm ?? "RS256"]) {
779
+ const minimum = { HS256: 32, HS384: 48, HS512: 64 }[algorithm];
780
+ if (minimum !== void 0 && Buffer.byteLength(client.clientSecret ?? "", "utf8") < minimum)
781
+ throw new Error(`The fake client ${client.clientId} signs with ${algorithm}, which needs a clientSecret of at least ${minimum} bytes (RFC 7518 \xA73.2).`);
782
+ }
783
+ return client;
784
+ }
785
+ function registeredMethodOf(client) {
786
+ return client.tokenEndpointAuthMethod ?? (client.clientSecret ? "client_secret_basic" : client.publicKey ? "private_key_jwt" : "none");
670
787
  }
671
788
  function send(response, answer) {
672
789
  const headers = { ...answer.headers };