@hyper.authio/testing 0.1.1 → 0.7.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
@@ -27,10 +27,24 @@ const token = await authio.issueAccessToken({
27
27
  await authio.close();
28
28
  ```
29
29
 
30
- - Endpoints: discovery, JWKS, token (client credentials, authorization code via `createAuthorizationCode`, refresh
31
- with rotation, token exchange including connected accounts, password, device code), introspection (answered
32
- according to who asks, like Authio), revocation, UserInfo (JSON, or a signed JWT with `userInfoAsJwt`) and device
33
- authorization.
30
+ - Endpoints: discovery (every endpoint it advertises is served), JWKS, authorization, token (client credentials,
31
+ authorization code with ID tokens, refresh with rotation, token exchange including connected accounts, password,
32
+ device code, personal access token), introspection (answered according to who asks, like Authio), revocation,
33
+ UserInfo (JSON, or a signed JWT with `userInfoAsJwt`), device authorization and end session.
34
+ - Sign-in: `authorize(url)` follows an authorization URL as a browser would and returns the callback URL (or, for
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),
37
+ `max_age`, `acr_values`, and sends RFC 9207 `iss` (`omitAuthorizationIssuer` leaves it out). ID tokens carry `nonce`,
38
+ `sid`, `auth_time`, `acr`, `amr` and `at_hash`. `createAuthorizationCode` still mints a code directly.
39
+ - Sign-out: the end-session endpoint checks `post_logout_redirect_uri`, echoes `state`, and posts a logout token to the
40
+ `backchannelLogoutUri` of every client of the session (`backchannelDeliveries`); `deliverBackchannelLogout` and
41
+ `createLogoutToken` do it by hand. Ending a session revokes its refresh tokens; `revokeRefreshTokens({ sub | sid })`
42
+ and `revokeToken(token)` revoke them by hand.
43
+ - Refresh tokens carry the session's `sid` through refreshes and are introspectable by the client they were issued to
44
+ (`token_type` `Refresh`), as a BFF's session check needs.
45
+ - JWE: a client with `encryption: { key, algorithm }` (RSA-OAEP or RSA-OAEP-256) gets its ID tokens, UserInfo, and
46
+ optionally access and logout tokens encrypted; `issueAccessToken({ encryptTo })` mints one directly.
47
+ - Personal access tokens: `personalAccessTokens: [{ token: "pat_…", sub, clientId? }]`.
34
48
  - Client authentication: `client_secret_basic`, `client_secret_post`, `private_key_jwt` (register `publicKey`) and
35
49
  `none`.
36
50
  - `issueAccessToken` options: `expiresIn` (negative for an expired token), `type` (`ID` for a non-access token),
package/dist/index.cjs CHANGED
@@ -11,23 +11,32 @@ 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 = [];
14
16
  /** What successive device-code polls answer; the last value repeats. Defaults to `["approved"]`. */
15
17
  deviceOutcomes = ["approved"];
16
18
  /** Overrides the discovery document's issuer, to test the mismatch check. */
17
19
  discoveryIssuer;
18
- /** 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). */
19
21
  userInfoAsJwt = false;
22
+ /** Leaves `iss` out of authorization responses, to test the RFC 9207 check. */
23
+ omitAuthorizationIssuer = false;
20
24
  /** Answers connected-account exchanges. Defaults to granting a token for the provider. */
21
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;
22
28
  clients = /* @__PURE__ */ new Map();
23
29
  users = /* @__PURE__ */ new Map();
30
+ personalAccessTokens = /* @__PURE__ */ new Map();
24
31
  codes = /* @__PURE__ */ new Map();
25
32
  refreshTokens = /* @__PURE__ */ new Map();
26
33
  issued = /* @__PURE__ */ new Map();
27
34
  devicePolls = /* @__PURE__ */ new Map();
28
35
  lifetime;
36
+ session;
29
37
  server;
30
38
  signingKey;
39
+ verificationKey;
31
40
  untrustedKey;
32
41
  publicJwk;
33
42
  baseUrl;
@@ -38,6 +47,7 @@ var FakeAuthio = class _FakeAuthio {
38
47
  this.lifetime = options.accessTokenLifetime ?? 300;
39
48
  for (const client of options.clients ?? []) this.clients.set(client.clientId, client);
40
49
  for (const user of options.users ?? []) this.users.set(user.username, user);
50
+ for (const pat of options.personalAccessTokens ?? []) this.personalAccessTokens.set(pat.token, pat);
41
51
  }
42
52
  /**
43
53
  * Starts the fake on a random loopback port.
@@ -59,6 +69,10 @@ var FakeAuthio = class _FakeAuthio {
59
69
  get jwks() {
60
70
  return { keys: [this.publicJwk] };
61
71
  }
72
+ /** The session the authorization endpoint keeps (one browser), or `undefined` when nobody is signed in. */
73
+ get currentSession() {
74
+ return this.session ? { sub: this.session.sub, sid: this.session.sid, authTime: this.session.authTime } : void 0;
75
+ }
62
76
  /** Registers a client. */
63
77
  addClient(client) {
64
78
  this.clients.set(client.clientId, client);
@@ -67,6 +81,14 @@ var FakeAuthio = class _FakeAuthio {
67
81
  addUser(user) {
68
82
  this.users.set(user.username, user);
69
83
  }
84
+ /** Registers a personal access token. */
85
+ addPersonalAccessToken(token) {
86
+ this.personalAccessTokens.set(token.token, token);
87
+ }
88
+ /** Ends the signed-in session without notifying anyone, as if it expired. */
89
+ endSession() {
90
+ this.session = void 0;
91
+ }
70
92
  /**
71
93
  * Registers an authorization code for a person, as the authorization endpoint would after sign-in.
72
94
  */
@@ -79,10 +101,27 @@ var FakeAuthio = class _FakeAuthio {
79
101
  redirectUri: options.redirectUri,
80
102
  scope: options.scope,
81
103
  codeVerifier: options.codeChallengeVerifier,
82
- resource: options.resource
104
+ resource: options.resource,
105
+ nonce: options.nonce
83
106
  });
84
107
  return code;
85
108
  }
109
+ /**
110
+ * Follows an authorization URL as a browser would, signing in `signInAs` (or the `login_hint`), and returns what
111
+ * Authio sent back: the callback URL for `response_mode=query`, the posted message's parameters for `web_message`.
112
+ */
113
+ async authorize(authorizationUrl) {
114
+ const response = await fetch(authorizationUrl, { redirect: "manual" });
115
+ const location = response.headers.get("location");
116
+ if (location) {
117
+ const callback = new URL(location);
118
+ return { parameters: Object.fromEntries(callback.searchParams), callbackUrl: location };
119
+ }
120
+ const html = await response.text();
121
+ const message = /data-authorization-response='([^']*)'/.exec(html)?.[1];
122
+ if (!message) throw new Error(`The fake answered the authorization with HTTP ${response.status} and no response.`);
123
+ return { parameters: JSON.parse(decodeURIComponent(message)), callbackUrl: void 0 };
124
+ }
86
125
  /**
87
126
  * Mints an access token signed by the realm key, recorded so introspection and revocation know it.
88
127
  */
@@ -98,14 +137,68 @@ var FakeAuthio = class _FakeAuthio {
98
137
  ...options.sub ? { sub: options.sub } : {},
99
138
  ...options.claims
100
139
  };
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 });
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);
141
+ const token = options.encryptTo ? await encryptTo(signed, options.encryptTo) : signed;
142
+ this.issued.set(token, { claims: jose.decodeJwt(signed), clientId, revoked: false });
103
143
  return token;
104
144
  }
105
- /** Marks a token revoked, as Authio would after a sign-out. */
145
+ /**
146
+ * Mints a back-channel logout token for a client, signed by the realm key. `overrides` replaces claims (`undefined`
147
+ * removes one) and `type` the header `typ`, to build invalid ones in tests.
148
+ */
149
+ async createLogoutToken(options) {
150
+ const now = Math.floor(Date.now() / 1e3);
151
+ const payload = {
152
+ iss: this.issuer,
153
+ aud: options.clientId,
154
+ iat: now,
155
+ exp: now + 120,
156
+ jti: crypto.randomUUID(),
157
+ ...options.sub ? { sub: options.sub } : {},
158
+ ...options.sid ? { sid: options.sid } : {},
159
+ events: { "http://schemas.openid.net/event/backchannel-logout": {} },
160
+ ...options.overrides
161
+ };
162
+ 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);
164
+ const encryption = this.clients.get(options.clientId)?.encryption;
165
+ return encryption?.logoutTokens ? encryptTo(signed, encryption) : signed;
166
+ }
167
+ /**
168
+ * Posts a back-channel logout token to the client's `backchannelLogoutUri`, as Authio does when a session ends.
169
+ *
170
+ * @returns The HTTP status the client answered, or 0 when it could not be reached.
171
+ */
172
+ async deliverBackchannelLogout(clientId, options = {}) {
173
+ const client = this.clients.get(clientId);
174
+ if (!client?.backchannelLogoutUri) throw new Error(`The fake client ${clientId} has no backchannelLogoutUri.`);
175
+ const logoutToken = await this.createLogoutToken({ clientId, sub: options.sub, sid: options.sid });
176
+ let status = 0;
177
+ try {
178
+ const response = await fetch(client.backchannelLogoutUri, {
179
+ method: "POST",
180
+ headers: { "content-type": "application/x-www-form-urlencoded" },
181
+ body: new URLSearchParams({ logout_token: logoutToken }).toString()
182
+ });
183
+ status = response.status;
184
+ await response.arrayBuffer();
185
+ } catch {
186
+ status = 0;
187
+ }
188
+ this.backchannelDeliveries.push({ clientId, uri: client.backchannelLogoutUri, logoutToken, status });
189
+ return status;
190
+ }
191
+ /** Marks a token revoked, as Authio would after a sign-out: an access token, or a refresh token, which is then forgotten. */
106
192
  revokeToken(token) {
107
193
  const issued = this.issued.get(token);
108
194
  if (issued) issued.revoked = true;
195
+ this.refreshTokens.delete(token);
196
+ }
197
+ /** Revokes every refresh token of a person, or of one Authio session (`sid`), as Authio does when a session ends. */
198
+ revokeRefreshTokens(options) {
199
+ for (const [token, refresh] of this.refreshTokens)
200
+ if (options.sid !== void 0 && refresh.sid === options.sid || options.sid === void 0 && options.sub !== void 0 && refresh.sub === options.sub)
201
+ this.refreshTokens.delete(token);
109
202
  }
110
203
  /** Forgets the recorded requests. */
111
204
  clearRequests() {
@@ -126,6 +219,7 @@ var FakeAuthio = class _FakeAuthio {
126
219
  const pair = await jose.generateKeyPair("RS256", { extractable: true });
127
220
  const untrusted = await jose.generateKeyPair("RS256");
128
221
  this.signingKey = pair.privateKey;
222
+ this.verificationKey = pair.publicKey;
129
223
  this.untrustedKey = untrusted.privateKey;
130
224
  this.publicJwk = { ...await jose.exportJWK(pair.publicKey), kid: this.keyId, alg: "RS256", use: "sig" };
131
225
  this.server = http.createServer((request, response) => {
@@ -141,7 +235,8 @@ var FakeAuthio = class _FakeAuthio {
141
235
  const body = await readBody(request);
142
236
  const form = Object.fromEntries(new URLSearchParams(request.headers["content-type"]?.includes("form") ? body : ""));
143
237
  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 };
238
+ const query = Object.fromEntries(url.searchParams);
239
+ const recorded = { method: request.method ?? "GET", path: url.pathname, headers, form, query };
145
240
  this.requests.push(recorded);
146
241
  const prefix = `/realms/${this.realm}`;
147
242
  const path = url.pathname.startsWith(prefix) ? url.pathname.slice(prefix.length) : url.pathname;
@@ -153,6 +248,8 @@ var FakeAuthio = class _FakeAuthio {
153
248
  return send(response, { status: 200, body: this.discovery() });
154
249
  case "/protocol/openid-connect/certs":
155
250
  return send(response, { status: 200, body: this.jwks });
251
+ case "/protocol/openid-connect/auth":
252
+ return this.authorizeEndpoint(query, response);
156
253
  case "/protocol/openid-connect/token":
157
254
  return send(response, await this.token(recorded, form));
158
255
  case "/protocol/openid-connect/token/introspect":
@@ -163,6 +260,8 @@ var FakeAuthio = class _FakeAuthio {
163
260
  return this.userInfo(headers, response);
164
261
  case "/protocol/openid-connect/auth/device":
165
262
  return send(response, await this.device(recorded, form));
263
+ case "/protocol/openid-connect/logout":
264
+ return this.endSessionEndpoint(query, response);
166
265
  default:
167
266
  return send(response, { status: 404, body: { error: "not_found" } });
168
267
  }
@@ -179,12 +278,129 @@ var FakeAuthio = class _FakeAuthio {
179
278
  end_session_endpoint: `${base}/logout`,
180
279
  device_authorization_endpoint: `${base}/auth/device`,
181
280
  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"],
281
+ scopes_supported: ["openid", "profile", "email", "phone", "roles", "offline_access"],
282
+ response_types_supported: ["code"],
283
+ response_modes_supported: ["query", "web_message"],
284
+ grant_types_supported: [
285
+ "authorization_code",
286
+ "refresh_token",
287
+ "client_credentials",
288
+ "password",
289
+ "urn:ietf:params:oauth:grant-type:device_code",
290
+ "urn:ietf:params:oauth:grant-type:token-exchange",
291
+ "personal_access_token"
292
+ ],
293
+ subject_types_supported: ["public"],
294
+ id_token_signing_alg_values_supported: ["RS256"],
295
+ id_token_encryption_alg_values_supported: ["RSA-OAEP", "RSA-OAEP-256"],
296
+ id_token_encryption_enc_values_supported: ["A128CBC-HS256", "A192CBC-HS384", "A256CBC-HS512"],
297
+ userinfo_signing_alg_values_supported: ["RS256"],
298
+ userinfo_encryption_alg_values_supported: ["RSA-OAEP", "RSA-OAEP-256"],
299
+ userinfo_encryption_enc_values_supported: ["A128CBC-HS256", "A192CBC-HS384", "A256CBC-HS512"],
183
300
  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
301
+ code_challenge_methods_supported: ["S256", "plain"],
302
+ prompt_values_supported: ["none", "login", "select_account", "consent"],
303
+ display_values_supported: ["page", "popup"],
304
+ acr_values_supported: ["1", "2", "3"],
305
+ backchannel_logout_supported: true,
306
+ backchannel_logout_session_supported: true,
307
+ authorization_response_iss_parameter_supported: !this.omitAuthorizationIssuer
186
308
  };
187
309
  }
310
+ authorizeEndpoint(query, response) {
311
+ const client = this.clients.get(query.client_id ?? "");
312
+ if (!client) return send(response, { status: 400, body: { error: "invalid_request", error_description: "Unknown client_id." } });
313
+ if (!query.redirect_uri || client.redirectUris && !client.redirectUris.includes(query.redirect_uri))
314
+ return send(response, { status: 400, body: { error: "invalid_request", error_description: "The redirect_uri is not registered." } });
315
+ const answer = (parameters) => {
316
+ const values = {};
317
+ for (const [name, value] of Object.entries({ ...parameters, state: query.state, iss: this.omitAuthorizationIssuer ? void 0 : this.issuer }))
318
+ if (value !== void 0) values[name] = value;
319
+ if (query.response_mode === "web_message") {
320
+ const message = encodeURIComponent(JSON.stringify(values));
321
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
322
+ response.end(
323
+ `<!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>`
324
+ );
325
+ return;
326
+ }
327
+ const location = new URL(query.redirect_uri);
328
+ for (const [name, value] of Object.entries(values)) location.searchParams.set(name, value);
329
+ response.writeHead(302, { location: location.toString() });
330
+ response.end();
331
+ };
332
+ 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." });
335
+ const prompts = (query.prompt ?? "").split(" ").filter(Boolean);
336
+ const now = Math.floor(Date.now() / 1e3);
337
+ const maxAge = query.max_age !== void 0 ? Number(query.max_age) : void 0;
338
+ const tooOld = this.session !== void 0 && maxAge !== void 0 && now - this.session.authTime > maxAge;
339
+ const requestedAcr = (query.acr_values ?? "").split(" ").filter(Boolean).sort().at(-1);
340
+ const weaker = this.session !== void 0 && requestedAcr !== void 0 && Number(this.session.acr) < Number(requestedAcr);
341
+ if (prompts.includes("none") && (!this.session || tooOld || weaker)) return answer({ error: "login_required", error_description: "Nobody is signed in." });
342
+ if (!this.session || prompts.includes("login") || tooOld || weaker || query.login_hint && this.users.get(query.login_hint)?.sub !== this.session.sub) {
343
+ const username = (query.login_hint && this.users.has(query.login_hint) ? query.login_hint : void 0) ?? this.signInAs ?? this.users.keys().next().value;
344
+ const user = username ? this.users.get(username) : void 0;
345
+ if (!user) return answer({ error: "access_denied", error_description: "The fake has no user to sign in." });
346
+ const acr = requestedAcr ?? user.acr ?? "1";
347
+ this.session = {
348
+ sub: user.sub,
349
+ sid: crypto.randomUUID().replace(/-/g, ""),
350
+ authTime: now,
351
+ acr,
352
+ amr: user.amr ?? (Number(acr) >= 2 ? ["pwd", "otp", "mfa"] : ["pwd"]),
353
+ clients: /* @__PURE__ */ new Set()
354
+ };
355
+ }
356
+ this.session.clients.add(client.clientId);
357
+ const code = crypto.randomUUID();
358
+ this.codes.set(code, {
359
+ clientId: client.clientId,
360
+ sub: this.session.sub,
361
+ redirectUri: query.redirect_uri,
362
+ scope: query.scope,
363
+ codeChallenge: query.code_challenge,
364
+ codeChallengeMethod: method,
365
+ resource: query.resource,
366
+ nonce: query.nonce,
367
+ sid: this.session.sid,
368
+ authTime: this.session.authTime,
369
+ acr: this.session.acr,
370
+ amr: this.session.amr
371
+ });
372
+ answer({ code });
373
+ }
374
+ async endSessionEndpoint(query, response) {
375
+ let clientId = query.client_id;
376
+ if (query.id_token_hint) {
377
+ try {
378
+ const { payload } = await jose.jwtVerify(query.id_token_hint, this.verificationKey, { issuer: this.issuer });
379
+ clientId ??= [payload.aud ?? []].flat()[0];
380
+ } catch {
381
+ return send(response, { status: 400, body: { error: "invalid_request", error_description: "The id_token_hint is not valid." } });
382
+ }
383
+ }
384
+ const client = clientId ? this.clients.get(clientId) : void 0;
385
+ const redirect = query.post_logout_redirect_uri;
386
+ if (redirect && (!client || client.postLogoutRedirectUris && !client.postLogoutRedirectUris.includes(redirect)))
387
+ return send(response, { status: 400, body: { error: "invalid_request", error_description: "The post_logout_redirect_uri is not registered." } });
388
+ const ended = this.session;
389
+ this.session = void 0;
390
+ 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
+ }
394
+ if (!redirect) {
395
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
396
+ response.end("<!doctype html><p>You are signed out.</p>");
397
+ return;
398
+ }
399
+ const location = new URL(redirect);
400
+ if (query.state) location.searchParams.set("state", query.state);
401
+ response.writeHead(302, { location: location.toString() });
402
+ response.end();
403
+ }
188
404
  async authenticate(recorded, form) {
189
405
  const invalid = (description) => ({ status: 401, body: { error: "invalid_client", error_description: description } });
190
406
  const authorization = recorded.headers.authorization;
@@ -215,10 +431,23 @@ var FakeAuthio = class _FakeAuthio {
215
431
  return client;
216
432
  }
217
433
  async token(recorded, form) {
434
+ const grant = (error, description) => ({ status: 400, body: { error, error_description: description } });
435
+ if (form.grant_type === "personal_access_token") {
436
+ const pat = this.personalAccessTokens.get(form.client_secret ?? "");
437
+ if (!pat || form.client_id && pat.clientId && form.client_id !== pat.clientId)
438
+ return { status: 401, body: { error: "invalid_client", error_description: "The personal access token is not valid." } };
439
+ const clientId = pat.clientId ?? "authio";
440
+ recorded.clientId = clientId;
441
+ return this.tokenAnswer(this.clients.get(clientId) ?? { clientId }, {
442
+ sub: pat.sub,
443
+ scope: form.scope,
444
+ audience: form.resource,
445
+ claims: { amr: "personal_access_token", actor_type: "personal_access_token", credential_id: pat.sub, ...pat.claims }
446
+ });
447
+ }
218
448
  const client = await this.authenticate(recorded, form);
219
449
  if ("status" in client) return client;
220
450
  const scope = form.scope ?? client.defaultScopes?.join(" ");
221
- const grant = (error, description) => ({ status: 400, body: { error, error_description: description } });
222
451
  switch (form.grant_type) {
223
452
  case "client_credentials":
224
453
  return this.tokenAnswer(client, { sub: client.clientId, scope, audience: form.resource, claims: client.claims });
@@ -231,12 +460,22 @@ var FakeAuthio = class _FakeAuthio {
231
460
  const code = this.codes.get(form.code ?? "");
232
461
  if (!code || code.clientId !== client.clientId || code.redirectUri !== form.redirect_uri) return grant("invalid_grant", "The code is not valid.");
233
462
  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"))
464
+ return grant("invalid_grant", "PKCE verification failed.");
234
465
  this.codes.delete(form.code);
235
466
  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"] };
236
468
  return this.tokenAnswer(
237
469
  client,
238
- { sub: user.sub, scope: code.scope, audience: form.resource ?? code.resource, claims: { ...client.claims, ...user.claims, preferred_username: user.username } },
239
- user
470
+ {
471
+ sub: user.sub,
472
+ scope: code.scope,
473
+ audience: form.resource ?? code.resource,
474
+ claims: { ...client.claims, ...user.claims, ...signIn, preferred_username: user.username }
475
+ },
476
+ user,
477
+ void 0,
478
+ { nonce: code.nonce, signIn }
240
479
  );
241
480
  }
242
481
  case "refresh_token": {
@@ -246,7 +485,7 @@ var FakeAuthio = class _FakeAuthio {
246
485
  const user = [...this.users.values()].find((candidate) => candidate.sub === refresh.sub);
247
486
  return this.tokenAnswer(
248
487
  client,
249
- { sub: refresh.sub, scope: form.scope ?? refresh.scope, claims: { ...client.claims, ...user?.claims } },
488
+ { sub: refresh.sub, scope: form.scope ?? refresh.scope, claims: { ...client.claims, ...user?.claims, ...refresh.sid ? { sid: refresh.sid } : {} } },
250
489
  user
251
490
  );
252
491
  }
@@ -263,12 +502,12 @@ var FakeAuthio = class _FakeAuthio {
263
502
  };
264
503
  return "status" in answer && typeof answer.status === "number" ? answer : { status: 200, body: answer };
265
504
  }
266
- const { iss: _issuer, aud: _audience, exp: _expiry, iat: _issuedAt, nbf: _notBefore, jti: _jti, ...claims } = subject.claims;
505
+ const { iss: _issuer, aud: _audience, exp: _expiry, iat: _issuedAt, nbf: _notBefore, jti: _jti, scope: _scope, ...claims } = subject.claims;
267
506
  return this.tokenAnswer(client, {
268
507
  sub: subject.claims.sub,
269
508
  scope: form.scope ?? subject.claims.scope,
270
509
  audience: form.resource,
271
- claims: { ...claims, act: { client_id: client.clientId, sub: client.clientId } }
510
+ claims: { ...claims, act: { client_id: client.clientId } }
272
511
  }, void 0, form.requested_token_type);
273
512
  }
274
513
  case "urn:ietf:params:oauth:grant-type:device_code": {
@@ -293,13 +532,15 @@ var FakeAuthio = class _FakeAuthio {
293
532
  return grant("unsupported_grant_type", `The grant ${form.grant_type} is not supported.`);
294
533
  }
295
534
  }
296
- async tokenAnswer(client, options, user, issuedTokenType) {
535
+ async tokenAnswer(client, options, user, issuedTokenType, idToken) {
536
+ const encryption = client.encryption;
297
537
  const accessToken = await this.issueAccessToken({
298
538
  clientId: client.clientId,
299
539
  sub: options.sub,
300
540
  scope: options.scope,
301
541
  audience: options.audience ?? client.clientId,
302
- claims: options.claims
542
+ claims: options.claims,
543
+ ...encryption?.accessTokens ? { encryptTo: encryption } : {}
303
544
  });
304
545
  const body = {
305
546
  access_token: accessToken,
@@ -310,9 +551,24 @@ var FakeAuthio = class _FakeAuthio {
310
551
  };
311
552
  if (user) {
312
553
  const refreshToken = crypto.randomUUID();
313
- this.refreshTokens.set(refreshToken, { clientId: client.clientId, sub: user.sub, scope: options.scope });
554
+ 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 });
314
556
  body.refresh_token = refreshToken;
315
557
  }
558
+ if (user && idToken && (options.scope ?? "").split(" ").includes("openid")) {
559
+ const now = Math.floor(Date.now() / 1e3);
560
+ 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);
570
+ body.id_token = encryption && encryption.idTokens !== false ? await encryptTo(signed, encryption) : signed;
571
+ }
316
572
  return { status: 200, body };
317
573
  }
318
574
  async introspect(recorded, form) {
@@ -320,6 +576,20 @@ var FakeAuthio = class _FakeAuthio {
320
576
  if ("status" in client) return client;
321
577
  const issued = this.issued.get(form.token ?? "");
322
578
  const now = Math.floor(Date.now() / 1e3);
579
+ const refresh = this.refreshTokens.get(form.token ?? "");
580
+ if (refresh)
581
+ return refresh.clientId === client.clientId ? {
582
+ status: 200,
583
+ body: {
584
+ active: true,
585
+ token_type: "Refresh",
586
+ client_id: refresh.clientId,
587
+ sub: refresh.sub,
588
+ exp: refresh.expiresAt,
589
+ ...refresh.scope ? { scope: refresh.scope } : {},
590
+ ...refresh.sid ? { sid: refresh.sid } : {}
591
+ }
592
+ } : { status: 200, body: { active: false } };
323
593
  if (!issued || issued.revoked || (issued.claims.exp ?? 0) <= now) return { status: 200, body: { active: false } };
324
594
  const audiences = [issued.claims.aud ?? []].flat();
325
595
  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,7 +632,10 @@ var FakeAuthio = class _FakeAuthio {
362
632
  const user = [...this.users.values()].find((candidate) => candidate.sub === issued.claims.sub);
363
633
  const claims = { sub: issued.claims.sub, ...user?.userInfo };
364
634
  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);
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);
637
+ const encryption = this.clients.get(issued.clientId)?.encryption;
638
+ const jwt = encryption && encryption.userInfo !== false ? await encryptTo(signed, encryption) : signed;
366
639
  response.writeHead(200, { "content-type": "application/jwt" });
367
640
  response.end(jwt);
368
641
  }
@@ -379,8 +652,22 @@ function endpointOf(path) {
379
652
  if (path.endsWith("/userinfo")) return "userinfo";
380
653
  if (path.endsWith("/auth/device")) return "device";
381
654
  if (path.endsWith("/openid-configuration")) return "discovery";
655
+ if (path.endsWith("/auth")) return "authorize";
656
+ if (path.endsWith("/logout")) return "logout";
382
657
  return void 0;
383
658
  }
659
+ async function encryptTo(signed, encryption) {
660
+ const algorithm = encryption.algorithm ?? "RSA-OAEP-256";
661
+ const key = await jose.importJWK({ ...encryption.key, alg: algorithm, use: "enc", key_ops: void 0 }, algorithm);
662
+ 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);
663
+ }
664
+ function verifiesChallenge(verifier, challenge, method) {
665
+ if (!verifier) return false;
666
+ return method === "S256" ? crypto.createHash("sha256").update(verifier, "ascii").digest("base64url") === challenge : verifier === challenge;
667
+ }
668
+ function halfHash(value) {
669
+ return crypto.createHash("sha256").update(value, "ascii").digest().subarray(0, 16).toString("base64url");
670
+ }
384
671
  function send(response, answer) {
385
672
  const headers = { ...answer.headers };
386
673
  if (answer.body !== void 0) headers["content-type"] ??= "application/json";