@hyper.authio/testing 0.1.1 → 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/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createServer } from 'http';
2
- import { randomUUID } from 'crypto';
3
- import { SignJWT, decodeJwt, generateKeyPair, exportJWK, importJWK, jwtVerify } from 'jose';
2
+ import { randomUUID, createHash } from 'crypto';
3
+ import { SignJWT, decodeJwt, generateKeyPair, exportJWK, jwtVerify, importJWK, decodeProtectedHeader, CompactEncrypt } from 'jose';
4
4
  import { AuthioWebhookHeaders, computeWebhookSignatureHeader } from '@hyper.authio/client';
5
5
 
6
6
  // src/fake-authio.ts
@@ -9,23 +9,35 @@ var FakeAuthio = class _FakeAuthio {
9
9
  requests = [];
10
10
  /** Answers the next requests to an endpoint with these errors, in order, before answering normally. */
11
11
  failures = /* @__PURE__ */ new Map();
12
+ /** Every back-channel logout token posted, in order. */
13
+ backchannelDeliveries = [];
14
+ /** Every front-channel logout page loaded, in order. */
15
+ frontchannelDeliveries = [];
12
16
  /** What successive device-code polls answer; the last value repeats. Defaults to `["approved"]`. */
13
17
  deviceOutcomes = ["approved"];
14
18
  /** Overrides the discovery document's issuer, to test the mismatch check. */
15
19
  discoveryIssuer;
16
- /** Answers UserInfo as `application/jwt`, signed by the realm key. */
20
+ /** Answers UserInfo as `application/jwt`, signed by the realm key (and encrypted for a client with a key). */
17
21
  userInfoAsJwt = false;
22
+ /** Leaves `iss` out of authorization responses, to test the RFC 9207 check. */
23
+ omitAuthorizationIssuer = false;
18
24
  /** Answers connected-account exchanges. Defaults to granting a token for the provider. */
19
25
  connectionResponder;
26
+ /** The person the authorization endpoint signs in when the request names none with `login_hint`. Defaults to the first user. */
27
+ signInAs;
20
28
  clients = /* @__PURE__ */ new Map();
21
29
  users = /* @__PURE__ */ new Map();
30
+ personalAccessTokens = /* @__PURE__ */ new Map();
22
31
  codes = /* @__PURE__ */ new Map();
23
32
  refreshTokens = /* @__PURE__ */ new Map();
24
33
  issued = /* @__PURE__ */ new Map();
25
34
  devicePolls = /* @__PURE__ */ new Map();
26
35
  lifetime;
36
+ clock;
37
+ session;
27
38
  server;
28
39
  signingKey;
40
+ verificationKey;
29
41
  untrustedKey;
30
42
  publicJwk;
31
43
  baseUrl;
@@ -34,8 +46,10 @@ var FakeAuthio = class _FakeAuthio {
34
46
  constructor(options) {
35
47
  this.realm = options.realm ?? "fake";
36
48
  this.lifetime = options.accessTokenLifetime ?? 300;
37
- for (const client of options.clients ?? []) this.clients.set(client.clientId, client);
49
+ this.clock = options.clock ?? { now: () => Date.now() };
50
+ for (const client of options.clients ?? []) this.clients.set(client.clientId, checkClient(client));
38
51
  for (const user of options.users ?? []) this.users.set(user.username, user);
52
+ for (const pat of options.personalAccessTokens ?? []) this.personalAccessTokens.set(pat.token, pat);
39
53
  }
40
54
  /**
41
55
  * Starts the fake on a random loopback port.
@@ -57,14 +71,26 @@ var FakeAuthio = class _FakeAuthio {
57
71
  get jwks() {
58
72
  return { keys: [this.publicJwk] };
59
73
  }
74
+ /** The session the authorization endpoint keeps (one browser), or `undefined` when nobody is signed in. */
75
+ get currentSession() {
76
+ return this.session ? { sub: this.session.sub, sid: this.session.sid, authTime: this.session.authTime } : void 0;
77
+ }
60
78
  /** Registers a client. */
61
79
  addClient(client) {
62
- this.clients.set(client.clientId, client);
80
+ this.clients.set(client.clientId, checkClient(client));
63
81
  }
64
82
  /** Registers a person. */
65
83
  addUser(user) {
66
84
  this.users.set(user.username, user);
67
85
  }
86
+ /** Registers a personal access token. */
87
+ addPersonalAccessToken(token) {
88
+ this.personalAccessTokens.set(token.token, token);
89
+ }
90
+ /** Ends the signed-in session without notifying anyone, as if it expired. */
91
+ endSession() {
92
+ this.session = void 0;
93
+ }
68
94
  /**
69
95
  * Registers an authorization code for a person, as the authorization endpoint would after sign-in.
70
96
  */
@@ -77,15 +103,32 @@ var FakeAuthio = class _FakeAuthio {
77
103
  redirectUri: options.redirectUri,
78
104
  scope: options.scope,
79
105
  codeVerifier: options.codeChallengeVerifier,
80
- resource: options.resource
106
+ resource: options.resource,
107
+ nonce: options.nonce
81
108
  });
82
109
  return code;
83
110
  }
111
+ /**
112
+ * Follows an authorization URL as a browser would, signing in `signInAs` (or the `login_hint`), and returns what
113
+ * Authio sent back: the callback URL for `response_mode=query`, the posted message's parameters for `web_message`.
114
+ */
115
+ async authorize(authorizationUrl) {
116
+ const response = await fetch(authorizationUrl, { redirect: "manual" });
117
+ const location = response.headers.get("location");
118
+ if (location) {
119
+ const callback = new URL(location);
120
+ return { parameters: Object.fromEntries(callback.searchParams), callbackUrl: location };
121
+ }
122
+ const html = await response.text();
123
+ const message = /data-authorization-response='([^']*)'/.exec(html)?.[1];
124
+ if (!message) throw new Error(`The fake answered the authorization with HTTP ${response.status} and no response.`);
125
+ return { parameters: JSON.parse(decodeURIComponent(message)), callbackUrl: void 0 };
126
+ }
84
127
  /**
85
128
  * Mints an access token signed by the realm key, recorded so introspection and revocation know it.
86
129
  */
87
130
  async issueAccessToken(options = {}) {
88
- const now = Math.floor(Date.now() / 1e3);
131
+ const now = this.nowSeconds();
89
132
  const clientId = options.clientId ?? "fake-client";
90
133
  const jti = randomUUID();
91
134
  const payload = {
@@ -96,14 +139,101 @@ var FakeAuthio = class _FakeAuthio {
96
139
  ...options.sub ? { sub: options.sub } : {},
97
140
  ...options.claims
98
141
  };
99
- const token = await new 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);
100
- this.issued.set(token, { claims: decodeJwt(token), clientId, revoked: false });
142
+ const builder = new SignJWT(payload).setIssuer(options.issuer ?? this.issuer).setAudience(options.audience ?? clientId).setIssuedAt(now).setNotBefore(now).setExpirationTime(now + (options.expiresIn ?? this.lifetime)).setJti(jti);
143
+ 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");
144
+ const token = options.encryptTo ? await encryptTo(signed, options.encryptTo) : signed;
145
+ this.issued.set(token, { claims: decodeJwt(signed), clientId, revoked: false });
101
146
  return token;
102
147
  }
103
- /** Marks a token revoked, as Authio would after a sign-out. */
148
+ /**
149
+ * Mints a back-channel logout token for a client, signed by the realm key. `overrides` replaces claims (`undefined`
150
+ * removes one) and `type` the header `typ`, to build invalid ones in tests.
151
+ */
152
+ async createLogoutToken(options) {
153
+ const now = this.nowSeconds();
154
+ const payload = {
155
+ iss: this.issuer,
156
+ aud: options.clientId,
157
+ iat: now,
158
+ exp: now + 120,
159
+ jti: randomUUID(),
160
+ ...options.sub ? { sub: options.sub } : {},
161
+ ...options.sid ? { sid: options.sid } : {},
162
+ events: { "http://schemas.openid.net/event/backchannel-logout": {} },
163
+ ...options.overrides
164
+ };
165
+ for (const key of Object.keys(payload)) if (payload[key] === void 0) delete payload[key];
166
+ const signed = await this.sign(new SignJWT(payload), this.clients.get(options.clientId), options.type ?? "logout+jwt");
167
+ const encryption = this.clients.get(options.clientId)?.encryption;
168
+ return encryption?.logoutTokens ? encryptTo(signed, encryption) : signed;
169
+ }
170
+ /**
171
+ * Posts a back-channel logout token to the client's `backchannelLogoutUri`, as Authio does when a session ends.
172
+ *
173
+ * @returns The HTTP status the client answered, or 0 when it could not be reached.
174
+ */
175
+ async deliverBackchannelLogout(clientId, options = {}) {
176
+ const client = this.clients.get(clientId);
177
+ if (!client?.backchannelLogoutUri) throw new Error(`The fake client ${clientId} has no backchannelLogoutUri.`);
178
+ const logoutToken = await this.createLogoutToken({ clientId, sub: options.sub, sid: options.sid });
179
+ let status = 0;
180
+ try {
181
+ const response = await fetch(client.backchannelLogoutUri, {
182
+ method: "POST",
183
+ headers: { "content-type": "application/x-www-form-urlencoded" },
184
+ body: new URLSearchParams({ logout_token: logoutToken }).toString()
185
+ });
186
+ status = response.status;
187
+ await response.arrayBuffer();
188
+ } catch {
189
+ status = 0;
190
+ }
191
+ this.backchannelDeliveries.push({ clientId, uri: client.backchannelLogoutUri, logoutToken, status });
192
+ return status;
193
+ }
194
+ /**
195
+ * The front-channel logout URL of a client for a session: its `frontchannelLogoutUri`, with `iss` and `sid` when the
196
+ * client requires the session.
197
+ */
198
+ frontchannelLogoutUrl(clientId, sid) {
199
+ const client = this.clients.get(clientId);
200
+ if (!client?.frontchannelLogoutUri) throw new Error(`The fake client ${clientId} has no frontchannelLogoutUri.`);
201
+ const url = new URL(client.frontchannelLogoutUri);
202
+ if (client.frontchannelLogoutSessionRequired) {
203
+ url.searchParams.set("iss", this.issuer);
204
+ url.searchParams.set("sid", sid);
205
+ }
206
+ return url.toString();
207
+ }
208
+ /**
209
+ * Loads the client's front-channel logout page, as the browser does in an iframe when Authio ends a session.
210
+ *
211
+ * @returns The HTTP status the client answered, or 0 when it could not be reached.
212
+ */
213
+ async deliverFrontchannelLogout(clientId, options) {
214
+ const url = this.frontchannelLogoutUrl(clientId, options.sid);
215
+ let status = 0;
216
+ try {
217
+ const response = await fetch(url, { headers: options.headers, redirect: "manual" });
218
+ status = response.status;
219
+ await response.arrayBuffer();
220
+ } catch {
221
+ status = 0;
222
+ }
223
+ this.frontchannelDeliveries.push({ clientId, url, status });
224
+ return status;
225
+ }
226
+ /** Marks a token revoked, as Authio would after a sign-out: an access token, or a refresh token, which is then forgotten. */
104
227
  revokeToken(token) {
105
228
  const issued = this.issued.get(token);
106
229
  if (issued) issued.revoked = true;
230
+ this.refreshTokens.delete(token);
231
+ }
232
+ /** Revokes every refresh token of a person, or of one Authio session (`sid`), as Authio does when a session ends. */
233
+ revokeRefreshTokens(options) {
234
+ for (const [token, refresh] of this.refreshTokens)
235
+ if (options.sid !== void 0 && refresh.sid === options.sid || options.sid === void 0 && options.sub !== void 0 && refresh.sub === options.sub)
236
+ this.refreshTokens.delete(token);
107
237
  }
108
238
  /** Forgets the recorded requests. */
109
239
  clearRequests() {
@@ -124,6 +254,7 @@ var FakeAuthio = class _FakeAuthio {
124
254
  const pair = await generateKeyPair("RS256", { extractable: true });
125
255
  const untrusted = await generateKeyPair("RS256");
126
256
  this.signingKey = pair.privateKey;
257
+ this.verificationKey = pair.publicKey;
127
258
  this.untrustedKey = untrusted.privateKey;
128
259
  this.publicJwk = { ...await exportJWK(pair.publicKey), kid: this.keyId, alg: "RS256", use: "sig" };
129
260
  this.server = createServer((request, response) => {
@@ -139,7 +270,8 @@ var FakeAuthio = class _FakeAuthio {
139
270
  const body = await readBody(request);
140
271
  const form = Object.fromEntries(new URLSearchParams(request.headers["content-type"]?.includes("form") ? body : ""));
141
272
  const headers = Object.fromEntries(Object.entries(request.headers).map(([name, value]) => [name, Array.isArray(value) ? value.join(", ") : value ?? ""]));
142
- const recorded = { method: request.method ?? "GET", path: url.pathname, headers, form };
273
+ const query = Object.fromEntries(url.searchParams);
274
+ const recorded = { method: request.method ?? "GET", path: url.pathname, headers, form, query };
143
275
  this.requests.push(recorded);
144
276
  const prefix = `/realms/${this.realm}`;
145
277
  const path = url.pathname.startsWith(prefix) ? url.pathname.slice(prefix.length) : url.pathname;
@@ -151,6 +283,8 @@ var FakeAuthio = class _FakeAuthio {
151
283
  return send(response, { status: 200, body: this.discovery() });
152
284
  case "/protocol/openid-connect/certs":
153
285
  return send(response, { status: 200, body: this.jwks });
286
+ case "/protocol/openid-connect/auth":
287
+ return this.authorizeEndpoint(query, response);
154
288
  case "/protocol/openid-connect/token":
155
289
  return send(response, await this.token(recorded, form));
156
290
  case "/protocol/openid-connect/token/introspect":
@@ -161,6 +295,8 @@ var FakeAuthio = class _FakeAuthio {
161
295
  return this.userInfo(headers, response);
162
296
  case "/protocol/openid-connect/auth/device":
163
297
  return send(response, await this.device(recorded, form));
298
+ case "/protocol/openid-connect/logout":
299
+ return this.endSessionEndpoint(query, response);
164
300
  default:
165
301
  return send(response, { status: 404, body: { error: "not_found" } });
166
302
  }
@@ -177,46 +313,211 @@ var FakeAuthio = class _FakeAuthio {
177
313
  end_session_endpoint: `${base}/logout`,
178
314
  device_authorization_endpoint: `${base}/auth/device`,
179
315
  jwks_uri: `${base}/certs`,
180
- grant_types_supported: ["authorization_code", "refresh_token", "client_credentials", "password", "urn:ietf:params:oauth:grant-type:device_code", "urn:ietf:params:oauth:grant-type:token-exchange"],
316
+ scopes_supported: ["openid", "profile", "email", "phone", "roles", "offline_access"],
317
+ response_types_supported: ["code"],
318
+ response_modes_supported: ["query", "web_message"],
319
+ grant_types_supported: [
320
+ "authorization_code",
321
+ "refresh_token",
322
+ "client_credentials",
323
+ "password",
324
+ "urn:ietf:params:oauth:grant-type:device_code",
325
+ "urn:ietf:params:oauth:grant-type:token-exchange",
326
+ "personal_access_token"
327
+ ],
328
+ subject_types_supported: ["public"],
329
+ id_token_signing_alg_values_supported: ["RS256"],
330
+ id_token_encryption_alg_values_supported: ["RSA-OAEP", "RSA-OAEP-256"],
331
+ id_token_encryption_enc_values_supported: ["A128CBC-HS256", "A192CBC-HS384", "A256CBC-HS512"],
332
+ userinfo_signing_alg_values_supported: ["RS256"],
333
+ userinfo_encryption_alg_values_supported: ["RSA-OAEP", "RSA-OAEP-256"],
334
+ userinfo_encryption_enc_values_supported: ["A128CBC-HS256", "A192CBC-HS384", "A256CBC-HS512"],
181
335
  token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "private_key_jwt", "none"],
182
- code_challenge_methods_supported: ["S256"],
183
- authorization_response_iss_parameter_supported: true
336
+ code_challenge_methods_supported: ["S256", "plain"],
337
+ prompt_values_supported: ["none", "login", "select_account", "consent"],
338
+ display_values_supported: ["page", "popup"],
339
+ acr_values_supported: ["1", "2", "3"],
340
+ backchannel_logout_supported: true,
341
+ backchannel_logout_session_supported: true,
342
+ frontchannel_logout_supported: true,
343
+ frontchannel_logout_session_supported: true,
344
+ authorization_response_iss_parameter_supported: !this.omitAuthorizationIssuer
345
+ };
346
+ }
347
+ authorizeEndpoint(query, response) {
348
+ const client = this.clients.get(query.client_id ?? "");
349
+ if (!client) return send(response, { status: 400, body: { error: "invalid_request", error_description: "Unknown client_id." } });
350
+ if (!query.redirect_uri || client.redirectUris && !client.redirectUris.includes(query.redirect_uri))
351
+ return send(response, { status: 400, body: { error: "invalid_request", error_description: "The redirect_uri is not registered." } });
352
+ const answer = (parameters) => {
353
+ const values = {};
354
+ for (const [name, value] of Object.entries({ ...parameters, state: query.state, iss: this.omitAuthorizationIssuer ? void 0 : this.issuer }))
355
+ if (value !== void 0) values[name] = value;
356
+ if (query.response_mode === "web_message") {
357
+ const message = encodeURIComponent(JSON.stringify(values));
358
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
359
+ response.end(
360
+ `<!doctype html><html><body data-authorization-response='${message}'><script>window.opener&&window.opener.postMessage({type:"authorization_response",response:JSON.parse(decodeURIComponent(document.body.dataset.authorizationResponse))},${JSON.stringify(new URL(query.redirect_uri).origin)});</script></body></html>`
361
+ );
362
+ return;
363
+ }
364
+ const location = new URL(query.redirect_uri);
365
+ for (const [name, value] of Object.entries(values)) location.searchParams.set(name, value);
366
+ response.writeHead(302, { location: location.toString() });
367
+ response.end();
184
368
  };
369
+ if (query.response_mode !== void 0 && query.response_mode !== "query" && query.response_mode !== "web_message")
370
+ return answer({ error: "invalid_request", error_description: `The response_mode '${query.response_mode}' is not supported: Authio answers with query or web_message.` });
371
+ if (query.response_type !== "code") return answer({ error: "unsupported_response_type", error_description: "Only code is supported." });
372
+ const method = query.code_challenge ? query.code_challenge_method : void 0;
373
+ const allowedMethods = client.pkceMethods ?? ["S256"];
374
+ if (query.code_challenge) {
375
+ if (!method)
376
+ return answer({ error: "invalid_request", error_description: "code_challenge_method is required with code_challenge: Authio does not assume plain." });
377
+ if (method !== "S256" && method !== "plain") return answer({ error: "invalid_request", error_description: `The PKCE method '${method}' is not supported.` });
378
+ if (!allowedMethods.includes(method))
379
+ return answer({
380
+ error: "invalid_request",
381
+ error_description: `The PKCE method '${method}' is not allowed for the client ${client.clientId}, which allows ${allowedMethods.join(", ")} (codeChallengeMethod).`
382
+ });
383
+ } else if (client.requirePkce ?? true) {
384
+ return answer({
385
+ error: "invalid_request",
386
+ error_description: `The client ${client.clientId} requires PKCE: send code_challenge and code_challenge_method (checks: ["pkce"]).`
387
+ });
388
+ }
389
+ const prompts = (query.prompt ?? "").split(" ").filter(Boolean);
390
+ const now = this.nowSeconds();
391
+ const maxAge = query.max_age !== void 0 ? Number(query.max_age) : void 0;
392
+ const tooOld = this.session !== void 0 && maxAge !== void 0 && now - this.session.authTime > maxAge;
393
+ const requestedAcr = (query.acr_values ?? "").split(" ").filter(Boolean).sort().at(-1);
394
+ const weaker = this.session !== void 0 && requestedAcr !== void 0 && Number(this.session.acr) < Number(requestedAcr);
395
+ if (prompts.includes("none") && (!this.session || tooOld || weaker)) return answer({ error: "login_required", error_description: "Nobody is signed in." });
396
+ if (!this.session || prompts.includes("login") || tooOld || weaker || query.login_hint && this.users.get(query.login_hint)?.sub !== this.session.sub) {
397
+ const username = (query.login_hint && this.users.has(query.login_hint) ? query.login_hint : void 0) ?? this.signInAs ?? this.users.keys().next().value;
398
+ const user = username ? this.users.get(username) : void 0;
399
+ if (!user) return answer({ error: "access_denied", error_description: "The fake has no user to sign in." });
400
+ const acr = requestedAcr ?? user.acr ?? "1";
401
+ this.session = {
402
+ sub: user.sub,
403
+ sid: randomUUID().replace(/-/g, ""),
404
+ authTime: now,
405
+ acr,
406
+ amr: user.amr ?? (Number(acr) >= 2 ? ["pwd", "otp", "mfa"] : ["pwd"]),
407
+ clients: /* @__PURE__ */ new Set()
408
+ };
409
+ }
410
+ this.session.clients.add(client.clientId);
411
+ const code = randomUUID();
412
+ this.codes.set(code, {
413
+ clientId: client.clientId,
414
+ sub: this.session.sub,
415
+ redirectUri: query.redirect_uri,
416
+ scope: query.scope,
417
+ codeChallenge: query.code_challenge,
418
+ codeChallengeMethod: method,
419
+ resource: query.resource,
420
+ nonce: query.nonce,
421
+ sid: this.session.sid,
422
+ authTime: this.session.authTime,
423
+ acr: this.session.acr,
424
+ amr: this.session.amr
425
+ });
426
+ answer({ code });
427
+ }
428
+ async endSessionEndpoint(query, response) {
429
+ let clientId = query.client_id;
430
+ if (query.id_token_hint) {
431
+ try {
432
+ const audience = [decodeJwt(query.id_token_hint).aud ?? []].flat()[0];
433
+ const { payload } = await jwtVerify(query.id_token_hint, this.verificationKeyFor(audience ? this.clients.get(audience) : void 0, query.id_token_hint), {
434
+ issuer: this.issuer,
435
+ currentDate: new Date(this.clock.now())
436
+ });
437
+ clientId ??= [payload.aud ?? []].flat()[0];
438
+ } catch {
439
+ return send(response, { status: 400, body: { error: "invalid_request", error_description: "The id_token_hint is not valid." } });
440
+ }
441
+ }
442
+ const client = clientId ? this.clients.get(clientId) : void 0;
443
+ const redirect = query.post_logout_redirect_uri;
444
+ if (redirect && (!client || client.postLogoutRedirectUris && !client.postLogoutRedirectUris.includes(redirect)))
445
+ return send(response, { status: 400, body: { error: "invalid_request", error_description: "The post_logout_redirect_uri is not registered." } });
446
+ const ended = this.session;
447
+ this.session = void 0;
448
+ if (ended) this.revokeRefreshTokens({ sid: ended.sid });
449
+ if (ended)
450
+ for (const id of ended.clients) {
451
+ if (this.clients.get(id)?.backchannelLogoutUri) await this.deliverBackchannelLogout(id, { sub: ended.sub, sid: ended.sid });
452
+ if (this.clients.get(id)?.frontchannelLogoutUri) await this.deliverFrontchannelLogout(id, { sid: ended.sid });
453
+ }
454
+ if (!redirect) {
455
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
456
+ response.end("<!doctype html><p>You are signed out.</p>");
457
+ return;
458
+ }
459
+ const location = new URL(redirect);
460
+ if (query.state) location.searchParams.set("state", query.state);
461
+ response.writeHead(302, { location: location.toString() });
462
+ response.end();
185
463
  }
186
464
  async authenticate(recorded, form) {
187
465
  const invalid = (description) => ({ status: 401, body: { error: "invalid_client", error_description: description } });
188
466
  const authorization = recorded.headers.authorization;
189
- let client;
190
- if (authorization?.startsWith("Basic ")) {
191
- const [id, secret] = Buffer.from(authorization.slice(6), "base64").toString("utf8").split(":").map(decodeURIComponent);
192
- client = this.clients.get(id ?? "");
193
- if (!client || !client.clientSecret || client.clientSecret !== secret) return invalid("The client credentials are wrong.");
194
- } else if (form.client_assertion) {
195
- client = this.clients.get(form.client_id ?? "");
196
- if (!client?.publicKey) return invalid("The client has no registered key.");
197
- if (form.client_assertion_type !== "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") return invalid("Wrong assertion type.");
198
- try {
199
- const key = await importJWK(client.publicKey, client.publicKey.alg);
200
- const { payload } = await jwtVerify(form.client_assertion, key, { issuer: client.clientId, subject: client.clientId, audience: this.issuer });
201
- if (!payload.jti) return invalid("The assertion has no jti.");
202
- } catch (error) {
203
- return invalid(`The assertion does not verify: ${error.message}`);
467
+ const basic = authorization?.startsWith("Basic ") ? Buffer.from(authorization.slice(6), "base64").toString("utf8").split(":").map(decodeURIComponent) : void 0;
468
+ const presented = basic ? "client_secret_basic" : form.client_assertion ? "private_key_jwt" : form.client_secret !== void 0 ? "client_secret_post" : "none";
469
+ const client = this.clients.get((basic ? basic[0] : form.client_id) ?? "");
470
+ if (!client) return invalid("Unknown client.");
471
+ if (form.client_assertion && form.client_secret !== void 0) return invalid("A client assertion and a client secret cannot be sent together.");
472
+ const registered = registeredMethodOf(client);
473
+ if (presented !== registered && !(registered === "none" && (presented === "client_secret_basic" || presented === "client_secret_post")))
474
+ return invalid(`The client ${client.clientId} authenticates with ${registered} (tokenEndpointAuthMethod), not ${presented}.`);
475
+ switch (registered) {
476
+ case "client_secret_basic":
477
+ if (!client.clientSecret || client.clientSecret !== basic?.[1]) return invalid("The client credentials are wrong.");
478
+ break;
479
+ case "client_secret_post":
480
+ if (!client.clientSecret || client.clientSecret !== form.client_secret) return invalid("The client credentials are wrong.");
481
+ break;
482
+ case "private_key_jwt": {
483
+ if (!client.publicKey) return invalid("The client has no registered key.");
484
+ if (form.client_assertion_type !== "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") return invalid("Wrong assertion type.");
485
+ try {
486
+ const key = await importJWK(client.publicKey, client.publicKey.alg);
487
+ const { payload } = await jwtVerify(form.client_assertion, key, {
488
+ issuer: client.clientId,
489
+ subject: client.clientId,
490
+ audience: this.issuer,
491
+ currentDate: new Date(this.clock.now())
492
+ });
493
+ if (!payload.jti) return invalid("The assertion has no jti.");
494
+ } catch (error) {
495
+ return invalid(`The assertion does not verify: ${error.message}`);
496
+ }
497
+ break;
204
498
  }
205
- } else if (form.client_secret !== void 0) {
206
- client = this.clients.get(form.client_id ?? "");
207
- if (!client || client.clientSecret !== form.client_secret) return invalid("The client credentials are wrong.");
208
- } else {
209
- client = this.clients.get(form.client_id ?? "");
210
- if (!client || client.clientSecret || client.publicKey) return invalid("The client must authenticate.");
211
499
  }
212
500
  recorded.clientId = client.clientId;
213
501
  return client;
214
502
  }
215
503
  async token(recorded, form) {
504
+ const grant = (error, description) => ({ status: 400, body: { error, error_description: description } });
505
+ if (form.grant_type === "personal_access_token") {
506
+ const pat = this.personalAccessTokens.get(form.client_secret ?? "");
507
+ if (!pat || form.client_id && pat.clientId && form.client_id !== pat.clientId)
508
+ return { status: 401, body: { error: "invalid_client", error_description: "The personal access token is not valid." } };
509
+ const clientId = pat.clientId ?? "authio";
510
+ recorded.clientId = clientId;
511
+ return this.tokenAnswer(this.clients.get(clientId) ?? { clientId }, {
512
+ sub: pat.sub,
513
+ scope: form.scope,
514
+ audience: form.resource,
515
+ claims: { amr: "personal_access_token", actor_type: "personal_access_token", credential_id: pat.sub, ...pat.claims }
516
+ });
517
+ }
216
518
  const client = await this.authenticate(recorded, form);
217
519
  if ("status" in client) return client;
218
520
  const scope = form.scope ?? client.defaultScopes?.join(" ");
219
- const grant = (error, description) => ({ status: 400, body: { error, error_description: description } });
220
521
  switch (form.grant_type) {
221
522
  case "client_credentials":
222
523
  return this.tokenAnswer(client, { sub: client.clientId, scope, audience: form.resource, claims: client.claims });
@@ -229,12 +530,23 @@ var FakeAuthio = class _FakeAuthio {
229
530
  const code = this.codes.get(form.code ?? "");
230
531
  if (!code || code.clientId !== client.clientId || code.redirectUri !== form.redirect_uri) return grant("invalid_grant", "The code is not valid.");
231
532
  if (code.codeVerifier && code.codeVerifier !== form.code_verifier) return grant("invalid_grant", "PKCE verification failed.");
533
+ if (code.codeChallenge && !form.code_verifier) return grant("invalid_grant", "code_verifier is required: the code was issued with a code_challenge.");
534
+ if (code.codeChallenge && !verifiesChallenge(form.code_verifier, code.codeChallenge, code.codeChallengeMethod ?? "S256"))
535
+ return grant("invalid_grant", "PKCE verification failed.");
232
536
  this.codes.delete(form.code);
233
537
  const user = [...this.users.values()].find((candidate) => candidate.sub === code.sub);
538
+ 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"] };
234
539
  return this.tokenAnswer(
235
540
  client,
236
- { sub: user.sub, scope: code.scope, audience: form.resource ?? code.resource, claims: { ...client.claims, ...user.claims, preferred_username: user.username } },
237
- user
541
+ {
542
+ sub: user.sub,
543
+ scope: code.scope,
544
+ audience: form.resource ?? code.resource,
545
+ claims: { ...client.claims, ...user.claims, ...signIn, preferred_username: user.username }
546
+ },
547
+ user,
548
+ void 0,
549
+ { nonce: code.nonce, signIn }
238
550
  );
239
551
  }
240
552
  case "refresh_token": {
@@ -242,10 +554,13 @@ var FakeAuthio = class _FakeAuthio {
242
554
  if (!refresh || refresh.clientId !== client.clientId) return grant("invalid_grant", "The refresh token is not valid.");
243
555
  this.refreshTokens.delete(form.refresh_token);
244
556
  const user = [...this.users.values()].find((candidate) => candidate.sub === refresh.sub);
557
+ const signIn = { ...refresh.sid ? { sid: refresh.sid } : {}, ...refresh.authTime ? { auth_time: refresh.authTime } : {} };
245
558
  return this.tokenAnswer(
246
559
  client,
247
- { sub: refresh.sub, scope: form.scope ?? refresh.scope, claims: { ...client.claims, ...user?.claims } },
248
- user
560
+ { sub: refresh.sub, scope: form.scope ?? refresh.scope, claims: { ...client.claims, ...user?.claims, ...signIn } },
561
+ user,
562
+ void 0,
563
+ client.issueIdTokenOnRefresh ? { signIn } : void 0
249
564
  );
250
565
  }
251
566
  case "urn:ietf:params:oauth:grant-type:token-exchange": {
@@ -261,12 +576,12 @@ var FakeAuthio = class _FakeAuthio {
261
576
  };
262
577
  return "status" in answer && typeof answer.status === "number" ? answer : { status: 200, body: answer };
263
578
  }
264
- const { iss: _issuer, aud: _audience, exp: _expiry, iat: _issuedAt, nbf: _notBefore, jti: _jti, ...claims } = subject.claims;
579
+ const { iss: _issuer, aud: _audience, exp: _expiry, iat: _issuedAt, nbf: _notBefore, jti: _jti, scope: _scope, ...claims } = subject.claims;
265
580
  return this.tokenAnswer(client, {
266
581
  sub: subject.claims.sub,
267
582
  scope: form.scope ?? subject.claims.scope,
268
583
  audience: form.resource,
269
- claims: { ...claims, act: { client_id: client.clientId, sub: client.clientId } }
584
+ claims: { ...claims, act: { client_id: client.clientId } }
270
585
  }, void 0, form.requested_token_type);
271
586
  }
272
587
  case "urn:ietf:params:oauth:grant-type:device_code": {
@@ -291,13 +606,15 @@ var FakeAuthio = class _FakeAuthio {
291
606
  return grant("unsupported_grant_type", `The grant ${form.grant_type} is not supported.`);
292
607
  }
293
608
  }
294
- async tokenAnswer(client, options, user, issuedTokenType) {
609
+ async tokenAnswer(client, options, user, issuedTokenType, idToken) {
610
+ const encryption = client.encryption;
295
611
  const accessToken = await this.issueAccessToken({
296
612
  clientId: client.clientId,
297
613
  sub: options.sub,
298
614
  scope: options.scope,
299
615
  audience: options.audience ?? client.clientId,
300
- claims: options.claims
616
+ claims: options.claims,
617
+ ...encryption?.accessTokens ? { encryptTo: encryption } : {}
301
618
  });
302
619
  const body = {
303
620
  access_token: accessToken,
@@ -308,16 +625,57 @@ var FakeAuthio = class _FakeAuthio {
308
625
  };
309
626
  if (user) {
310
627
  const refreshToken = randomUUID();
311
- this.refreshTokens.set(refreshToken, { clientId: client.clientId, sub: user.sub, scope: options.scope });
628
+ const sid = typeof options.claims?.sid === "string" ? options.claims.sid : void 0;
629
+ const authTime = typeof options.claims?.auth_time === "number" ? options.claims.auth_time : void 0;
630
+ this.refreshTokens.set(refreshToken, {
631
+ clientId: client.clientId,
632
+ sub: user.sub,
633
+ scope: options.scope,
634
+ sid,
635
+ authTime,
636
+ expiresAt: this.nowSeconds() + 30 * 24 * 3600
637
+ });
312
638
  body.refresh_token = refreshToken;
313
639
  }
640
+ if (user && idToken && (options.scope ?? "").split(" ").includes("openid")) {
641
+ const now = this.nowSeconds();
642
+ const signIn = Object.fromEntries(Object.entries(idToken.signIn).filter(([, value]) => value !== void 0));
643
+ const signed = await this.sign(
644
+ new SignJWT({
645
+ typ: "ID",
646
+ azp: client.clientId,
647
+ ...signIn,
648
+ ...idToken.nonce ? { nonce: idToken.nonce } : {},
649
+ at_hash: halfHash(accessToken, client.signingAlgorithm ?? "RS256"),
650
+ preferred_username: user.username,
651
+ ...user.userInfo
652
+ }).setIssuer(this.issuer).setAudience(client.clientId).setSubject(user.sub).setIssuedAt(now).setExpirationTime(now + this.lifetime),
653
+ client,
654
+ "JWT"
655
+ );
656
+ body.id_token = encryption && encryption.idTokens !== false ? await encryptTo(signed, encryption) : signed;
657
+ }
314
658
  return { status: 200, body };
315
659
  }
316
660
  async introspect(recorded, form) {
317
661
  const client = await this.authenticate(recorded, form);
318
662
  if ("status" in client) return client;
319
663
  const issued = this.issued.get(form.token ?? "");
320
- const now = Math.floor(Date.now() / 1e3);
664
+ const now = this.nowSeconds();
665
+ const refresh = this.refreshTokens.get(form.token ?? "");
666
+ if (refresh)
667
+ return refresh.clientId === client.clientId ? {
668
+ status: 200,
669
+ body: {
670
+ active: true,
671
+ token_type: "Refresh",
672
+ client_id: refresh.clientId,
673
+ sub: refresh.sub,
674
+ exp: refresh.expiresAt,
675
+ ...refresh.scope ? { scope: refresh.scope } : {},
676
+ ...refresh.sid ? { sid: refresh.sid } : {}
677
+ }
678
+ } : { status: 200, body: { active: false } };
321
679
  if (!issued || issued.revoked || (issued.claims.exp ?? 0) <= now) return { status: 200, body: { active: false } };
322
680
  const audiences = [issued.claims.aud ?? []].flat();
323
681
  const allowed = issued.clientId === client.clientId && audiences.includes(client.clientId) ? true : audiences.some((audience) => this.clients.get(audience)?.introspectionClients?.includes(client.clientId) || audience === client.clientId);
@@ -360,10 +718,29 @@ var FakeAuthio = class _FakeAuthio {
360
718
  const user = [...this.users.values()].find((candidate) => candidate.sub === issued.claims.sub);
361
719
  const claims = { sub: issued.claims.sub, ...user?.userInfo };
362
720
  if (!this.userInfoAsJwt) return send(response, { status: 200, body: claims });
363
- const jwt = await new SignJWT(claims).setProtectedHeader({ alg: "RS256", kid: this.keyId }).setIssuer(this.issuer).setAudience(issued.clientId).setIssuedAt().sign(this.signingKey);
721
+ const now = this.nowSeconds();
722
+ const signed = await this.sign(
723
+ new SignJWT(claims).setIssuer(this.issuer).setAudience(issued.clientId).setIssuedAt(now).setExpirationTime(now + this.lifetime),
724
+ this.clients.get(issued.clientId),
725
+ void 0
726
+ );
727
+ const encryption = this.clients.get(issued.clientId)?.encryption;
728
+ const jwt = encryption && encryption.userInfo !== false ? await encryptTo(signed, encryption) : signed;
364
729
  response.writeHead(200, { "content-type": "application/jwt" });
365
730
  response.end(jwt);
366
731
  }
732
+ nowSeconds() {
733
+ return Math.floor(this.clock.now() / 1e3);
734
+ }
735
+ sign(builder, client, type, algorithm = client?.signingAlgorithm ?? "RS256") {
736
+ if (algorithm === "RS256") return builder.setProtectedHeader({ alg: "RS256", kid: this.keyId, ...type ? { typ: type } : {} }).sign(this.signingKey);
737
+ if (!client?.clientSecret) throw new Error(`The fake client ${client?.clientId} signs with ${algorithm} and has no clientSecret.`);
738
+ return builder.setProtectedHeader({ alg: algorithm, ...type ? { typ: type } : {} }).sign(new TextEncoder().encode(client.clientSecret));
739
+ }
740
+ verificationKeyFor(client, token) {
741
+ const algorithm = String(decodeProtectedHeader(token).alg ?? "");
742
+ return algorithm.startsWith("HS") && client?.clientSecret ? new TextEncoder().encode(client.clientSecret) : this.verificationKey;
743
+ }
367
744
  requireUser(username) {
368
745
  const user = this.users.get(username);
369
746
  if (!user) throw new Error(`The fake has no user ${username}.`);
@@ -377,8 +754,35 @@ function endpointOf(path) {
377
754
  if (path.endsWith("/userinfo")) return "userinfo";
378
755
  if (path.endsWith("/auth/device")) return "device";
379
756
  if (path.endsWith("/openid-configuration")) return "discovery";
757
+ if (path.endsWith("/auth")) return "authorize";
758
+ if (path.endsWith("/logout")) return "logout";
380
759
  return void 0;
381
760
  }
761
+ async function encryptTo(signed, encryption) {
762
+ const algorithm = encryption.algorithm ?? "RSA-OAEP-256";
763
+ const key = await importJWK({ ...encryption.key, alg: algorithm, use: "enc", key_ops: void 0 }, algorithm);
764
+ return new CompactEncrypt(new TextEncoder().encode(signed)).setProtectedHeader({ alg: algorithm, enc: encryption.encryptionMethod ?? "A256CBC-HS512", typ: "JWE", cty: "JWT", ...encryption.key.kid ? { kid: encryption.key.kid } : {} }).encrypt(key);
765
+ }
766
+ function verifiesChallenge(verifier, challenge, method) {
767
+ if (!verifier) return false;
768
+ return method === "S256" ? createHash("sha256").update(verifier, "ascii").digest("base64url") === challenge : verifier === challenge;
769
+ }
770
+ function halfHash(value, algorithm) {
771
+ const size = algorithm.endsWith("384") ? 384 : algorithm.endsWith("512") ? 512 : 256;
772
+ const digest = createHash(`sha${size}`).update(value, "ascii").digest();
773
+ return digest.subarray(0, digest.length / 2).toString("base64url");
774
+ }
775
+ function checkClient(client) {
776
+ for (const algorithm of [client.signingAlgorithm ?? "RS256", client.accessTokenSigningAlgorithm ?? "RS256"]) {
777
+ const minimum = { HS256: 32, HS384: 48, HS512: 64 }[algorithm];
778
+ if (minimum !== void 0 && Buffer.byteLength(client.clientSecret ?? "", "utf8") < minimum)
779
+ throw new Error(`The fake client ${client.clientId} signs with ${algorithm}, which needs a clientSecret of at least ${minimum} bytes (RFC 7518 \xA73.2).`);
780
+ }
781
+ return client;
782
+ }
783
+ function registeredMethodOf(client) {
784
+ return client.tokenEndpointAuthMethod ?? (client.clientSecret ? "client_secret_basic" : client.publicKey ? "private_key_jwt" : "none");
785
+ }
382
786
  function send(response, answer) {
383
787
  const headers = { ...answer.headers };
384
788
  if (answer.body !== void 0) headers["content-type"] ??= "application/json";