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