@o3co/create-auth-provider 0.17.0-rc.1 → 0.17.0-rc.3

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.
Files changed (70) hide show
  1. package/README.ja.md +3 -1
  2. package/README.md +5 -2
  3. package/package.json +1 -1
  4. package/templates/standalone/.env.example +7 -5
  5. package/templates/standalone/README.ja.md +53 -25
  6. package/templates/standalone/README.md +140 -58
  7. package/templates/standalone/config/application.conf +41 -36
  8. package/templates/standalone/config/clients.yaml.example +8 -0
  9. package/templates/standalone/config/development.conf +2 -2
  10. package/templates/standalone/config/mailpit.conf +10 -5
  11. package/templates/standalone/config/reference.conf +30 -5
  12. package/templates/standalone/docker-compose.mailpit.yml +3 -0
  13. package/templates/standalone/docker-compose.production.yml +5 -2
  14. package/templates/standalone/package.json +1 -1
  15. package/templates/standalone/src/__tests__/adapters.test.mts +92 -25
  16. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
  17. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +112 -98
  18. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +40 -11
  19. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +86 -51
  20. package/templates/standalone/src/__tests__/audit-sink.test.mts +10 -9
  21. package/templates/standalone/src/__tests__/config-notices.test.mts +157 -0
  22. package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +48 -0
  23. package/templates/standalone/src/__tests__/core-federations.test.mts +57 -31
  24. package/templates/standalone/src/__tests__/core-sections.test.mts +7 -5
  25. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +1 -1
  26. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +434 -136
  27. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +19 -21
  28. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +9 -15
  29. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +10 -4
  30. package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
  31. package/templates/standalone/src/__tests__/google-session.fixture.mts +60 -67
  32. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
  33. package/templates/standalone/src/__tests__/library-references.fixture.mts +51 -46
  34. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
  35. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +81 -16
  36. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
  37. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +8 -4
  38. package/templates/standalone/src/__tests__/mfa-switch.test.mts +149 -46
  39. package/templates/standalone/src/__tests__/own-modules.test.mts +78 -23
  40. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +3 -3
  41. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +214 -0
  42. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +8 -4
  43. package/templates/standalone/src/__tests__/replica-safety.test.mts +131 -19
  44. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +25 -23
  45. package/templates/standalone/src/__tests__/sections.test.mts +24 -0
  46. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
  47. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +3 -7
  48. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
  49. package/templates/standalone/src/__tests__/session-requirements.test.mts +114 -31
  50. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +274 -0
  51. package/templates/standalone/src/__tests__/smoke.test.mts +19 -28
  52. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +148 -0
  53. package/templates/standalone/src/__tests__/store-credential.test.mts +8 -13
  54. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +58 -38
  55. package/templates/standalone/src/__tests__/two-phase-config.test.mts +243 -76
  56. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +23 -31
  57. package/templates/standalone/src/adapters.mts +31 -18
  58. package/templates/standalone/src/app.mts +6 -5
  59. package/templates/standalone/src/bootRefusal.mts +117 -0
  60. package/templates/standalone/src/buildModules.mts +46 -96
  61. package/templates/standalone/src/configPath.mts +180 -153
  62. package/templates/standalone/src/mfaSwitch.mts +49 -27
  63. package/templates/standalone/src/modules.mts +37 -219
  64. package/templates/standalone/src/rootRenames.mts +38 -22
  65. package/templates/standalone/src/sections.mts +13 -2
  66. package/templates/standalone/tests/index.test.js +4 -3
  67. package/templates/versions.json +18 -18
  68. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -134
  69. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -202
  70. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -144
@@ -1,6 +1,7 @@
1
1
  # Defaults of the standalone template's own modules (src/modules.mts), each in
2
2
  # the section named after its module, and of its own `adapters` and
3
- # `mfaMode`, each key with its variable, named after its path. Layered
3
+ # `mfaMode`, each key with its variable, named after its path, and the MFA
4
+ # routes' budget in each rate limiter's section (below `mfaMode`). Layered
4
5
  # beneath application.conf and {env}.conf: set a deployment's own values
5
6
  # there, not here. A value either file sets wins over
6
7
  # the variable bound here, except for HTTP_PORT, HTTP_TRUST_PROXY,
@@ -129,11 +130,17 @@ key-store {
129
130
 
130
131
  # `redis-clients`: the one ioredis socket per replica every Redis-backed store
131
132
  # shares. More than one replica needs a shared Redis 7.2+ here; localhost
132
- # inside a container is the container itself.
133
+ # inside a container is the container itself. The Redis stores that keep
134
+ # durable keys refuse to boot unless the server reports maxmemory-policy
135
+ # `noeviction`. `assumeNoEviction = true` asserts it for a server that will not
136
+ # report it (INFO and CONFIG refused to the connection's user); a policy the
137
+ # server does report always decides.
133
138
  redis-clients {
134
139
  url = "redis://localhost:6379"
135
140
  url = ${?REDIS_CLIENTS_URL}
136
141
  password = ${?REDIS_CLIENTS_PASSWORD}
142
+ assumeNoEviction = false
143
+ assumeNoEviction = ${?REDIS_CLIENTS_ASSUME_NO_EVICTION}
137
144
  }
138
145
 
139
146
  # `repositories`: the client registry, and the user repository's settings for
@@ -217,9 +224,16 @@ audit-sink {}
217
224
  # replica, and `core.deployment.mode = "multi"` refuses each memory store by
218
225
  # name. Each Redis store shares the one socket `redis-clients` opens.
219
226
  adapters {
220
- # The OAuth endpoints' and /session/login's rate-limit counters.
227
+ # The rate-limit counters: the OAuth endpoints' and the MFA routes'.
221
228
  rateLimiter = "memory"
222
229
  rateLimiter = ${?ADAPTERS_RATE_LIMITER}
230
+ # The counter behind /session/login's own attempt limit
231
+ # (session.rateLimit.login), and device verification's
232
+ # (device-grant.rateLimit) when the device grant is added. `memory` wires
233
+ # none: each counts per process, which `core.deployment.mode = "multi"`
234
+ # refuses. `redis` needs a server whose maxmemory-policy is `noeviction`.
235
+ attemptCounter = "memory"
236
+ attemptCounter = ${?ADAPTERS_ATTEMPT_COUNTER}
223
237
  # The four user-session stores and the subject-level revocation pair. They
224
238
  # must be on Redis whenever express-session's store is, or a restart leaves
225
239
  # every browser a session whose user session is gone.
@@ -294,14 +308,25 @@ adapters {
294
308
  mfaMode = "required"
295
309
  mfaMode = ${?MFA_MODE}
296
310
 
311
+ # The MFA routes' budget: the one setting this file writes in sections of
312
+ # modules other than the template's own. Every /session/mfa POST is limited
313
+ # per client address under the prefix `mfa` (`mfa:ip:<ip>`) by the wired
314
+ # limiter alone: the MFA package ships no budget of its own. Both limiters
315
+ # carry it, so switching `adapters.rateLimiter` keeps it; written here, beside
316
+ # each package's own defaults, the limiter not wired has it as a default
317
+ # rather than as a setting nothing reads. Without an entry the limiter's
318
+ # `defaultLimit` (60 per 60 s) applies. The MFA lock is separate and unchanged.
319
+ core-rate-limiter-memory.limits.mfa { limit = 60, windowSeconds = 300 }
320
+ redis-rate-limiter.limits.mfa { limit = 60, windowSeconds = 300 }
321
+
297
322
  # The old paths of these sections refuse boot naming the new ones: `cors`,
298
323
  # `oauth.jwt.signingKey`, `refreshTokenFamilyStore.redis`,
299
324
  # `repositories.code` and `audit.sink`; the old adapter switches
300
325
  # (`rateLimiter.adapter`, `oauth.code.adapter`, ...) are refused before the
301
326
  # modules are chosen, and so are the variables renamed with them. What the
302
327
  # resolution saw of each variable they renamed: each name null, then its value
303
- # when set. Boot refuses an old name set unless its new name is set to the
304
- # same value, and removes this section before it parses the configuration.
328
+ # when set. Boot refuses an old name set, whether or not its new name is set,
329
+ # and removes this section before it parses the configuration.
305
330
  # Never write it in another layer.
306
331
  renamed-variables {
307
332
  LOG_LEVEL = null
@@ -4,6 +4,9 @@
4
4
  #
5
5
  # docker compose -f docker-compose.yml -f docker-compose.mailpit.yml up --build
6
6
  #
7
+ # Write MFA_ENCRYPTION_KEY into .env first (openssl rand -base64 32): this run
8
+ # reads its MFA key from it, not the development sample key.
9
+ #
7
10
  # Mailpit keeps every mail the provider sends and delivers none; read them at
8
11
  # http://localhost:8025. Never use it in production, where
9
12
  # STANDARD_SMTP_MAIL_SENDER_* name a real relay. Leave every
@@ -142,8 +142,8 @@ services:
142
142
  SESSION_STORE_STORAGE_TYPE: redis
143
143
  SESSION_STORE_STORAGE_REDIS_URL: redis://redis:6379
144
144
  # The UserSession stores: the provider's own record of who is signed in,
145
- # which RPs a session has touched, and the family / federation indexes
146
- # hanging off it. They MUST be on Redis whenever the express-session
145
+ # the subject index and revocation boundaries beside it, and the session
146
+ # lifecycle records. They MUST be on Redis whenever the express-session
147
147
  # store above is, and this line is the fix for what happens when they
148
148
  # are not.
149
149
  #
@@ -161,6 +161,9 @@ services:
161
161
  # The memory rate limiter is per-process and evadable under bucket
162
162
  # exhaustion (see README) — production points it at Redis.
163
163
  ADAPTERS_RATE_LIMITER: redis
164
+ # The counter behind the login's own attempt limit: on Redis, a restart
165
+ # does not reset it. Redis must keep `maxmemory-policy noeviction`.
166
+ ADAPTERS_ATTEMPT_COUNTER: redis
164
167
  # The federation token store is deliberately left at its `memory`
165
168
  # default, and that is coherent here rather than an oversight: this
166
169
  # template ships every federation disabled, so nothing writes to the
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/auth-provider-standalone",
3
- "version": "0.17.0-rc.1",
3
+ "version": "0.17.0-rc.3",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -28,7 +28,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
28
28
  import { tmpdir } from "node:os";
29
29
  import { join } from "node:path";
30
30
  import { fileURLToPath } from "node:url";
31
- import { defineModule } from "@o3co/auth-provider-core";
31
+ import { BootError, defineModule } from "@o3co/auth-provider-core";
32
32
  import { afterAll, describe, expect, it } from "vitest";
33
33
  import { z } from "zod";
34
34
  import { buildModules } from "#/buildModules.mjs";
@@ -60,12 +60,13 @@ const MFA_OFF: Readonly<Record<string, string>> = { MFA_MODE: "off" };
60
60
  const adaptersFrom = (env: Record<string, string> = {}, hocon?: string) =>
61
61
  readSwitches(readOwnLayers(ownFiles(hocon), { env })).adapters;
62
62
 
63
- /** What phase one refuses under `env` and an operator's `hocon`. */
64
- function refusal(env: Record<string, string> = {}, hocon?: string): Error {
63
+ /** What phase one refuses under `env` and an operator's `hocon`: a `BootError`. */
64
+ function refusal(env: Record<string, string> = {}, hocon?: string): BootError {
65
65
  try {
66
66
  adaptersFrom(env, hocon);
67
67
  } catch (err) {
68
- return err as Error;
68
+ expect(err).toBeInstanceOf(BootError);
69
+ return err as BootError;
69
70
  }
70
71
  throw new Error("phase one read the adapters");
71
72
  }
@@ -141,6 +142,7 @@ describe("the shipped selections", () => {
141
142
  it("reads every selection from the template's reference.conf, at the values the template ships", () => {
142
143
  expect(adaptersFrom()).toEqual({
143
144
  rateLimiter: "memory",
145
+ attemptCounter: "memory",
144
146
  userSessionStores: "memory",
145
147
  accessTokenDenylist: "redis",
146
148
  replaySeenSet: "redis",
@@ -164,14 +166,25 @@ describe("the shipped selections", () => {
164
166
  },
165
167
  );
166
168
 
169
+ it("reads adapters.attemptCounter from ADAPTERS_ATTEMPT_COUNTER", () => {
170
+ expect(adaptersFrom({ ADAPTERS_ATTEMPT_COUNTER: "redis" }).attemptCounter).toBe("redis");
171
+ expect(refusal({ ADAPTERS_ATTEMPT_COUNTER: "memcached" }).message).toMatch(
172
+ /adapters\.attemptCounter/,
173
+ );
174
+ });
175
+
167
176
  it("reads a selection an operator writes in HOCON", () => {
168
177
  expect(adaptersFrom({}, 'adapters.rateLimiter = "redis"\n').rateLimiter).toBe("redis");
169
178
  });
170
179
 
171
180
  it("refuses a value its schema does not know, naming the key", () => {
172
- expect(refusal({ ADAPTERS_RATE_LIMITER: "memcached" }).message).toMatch(
173
- /adapters\.rateLimiter/,
174
- );
181
+ const err = refusal({ ADAPTERS_RATE_LIMITER: "memcached" });
182
+ expect(err.message).toMatch(/adapters\.rateLimiter/);
183
+ expect(err.reason).toBe("config-validation-failed");
184
+ expect(err.details).toMatchObject({
185
+ issues: [{ path: ["adapters", "rateLimiter"] }],
186
+ modules: [{ module: "adapters", schemaPath: "adapters" }],
187
+ });
175
188
  });
176
189
 
177
190
  it("refuses the Store for the MFA transaction store, which holds verification state, and accepts it for the factor store", () => {
@@ -198,9 +211,22 @@ describe("a path a selection moved from, written in the operator's own layer", (
198
211
  "adapters.%s: refused where it was, %s's old path, naming the new path and ADAPTERS_%s",
199
212
  (key, name, oldPath, _oldName, value) => {
200
213
  const err = refusal({}, `${oldPath} = "${value}"\n`);
201
- expect(err).toBeInstanceOf(RangeError);
202
- expect(err.message).toContain(`${oldPath} has moved to adapters.${key}`);
214
+ expect(err.message).toContain(
215
+ `${oldPath} has moved to adapters.${key}; see the upgrade guide (docs/upgrading-from-v0.16.0.md).`,
216
+ );
203
217
  expect(err.message).toContain(`ADAPTERS_${name}`);
218
+ expect(err.reason).toBe("config-path-relocated");
219
+ expect(err.details).toEqual({
220
+ reason: "config-path-relocated",
221
+ relocated: [
222
+ {
223
+ module: "adapters",
224
+ from: oldPath,
225
+ to: `adapters.${key}`,
226
+ environmentVariable: `ADAPTERS_${name}`,
227
+ },
228
+ ],
229
+ });
204
230
  },
205
231
  );
206
232
 
@@ -212,6 +238,13 @@ describe("a path a selection moved from, written in the operator's own layer", (
212
238
  for (const path of ["rateLimiter.adapter", "consentStore.adapter", "audit.sink.type"]) {
213
239
  expect(err.message).toContain(path);
214
240
  }
241
+ expect(err.details).toMatchObject({
242
+ relocated: [
243
+ { from: "rateLimiter.adapter" },
244
+ { from: "consentStore.adapter" },
245
+ { from: "audit.sink.type" },
246
+ ],
247
+ });
215
248
  });
216
249
  });
217
250
 
@@ -220,9 +253,22 @@ describe("a variable renamed with a selection", () => {
220
253
  "adapters.%s: %s's old variable set alone is refused, naming ADAPTERS_%s",
221
254
  (key, name, _oldPath, oldName, value) => {
222
255
  const err = refusal({ [oldName]: value });
223
- expect(err).toBeInstanceOf(RangeError);
224
- expect(err.message).toContain(`${oldName} was renamed ADAPTERS_${name}`);
225
- expect(err.message).toContain(`adapters.${key}`);
256
+ expect(err.message).toContain(
257
+ `${oldName} was renamed ADAPTERS_${name}, the variable adapters.${key} is bound to; see the upgrade guide (docs/upgrading-from-v0.16.0.md).`,
258
+ );
259
+ expect(err.reason).toBe("environment-variable-renamed");
260
+ expect(err.details).toEqual({
261
+ reason: "environment-variable-renamed",
262
+ renamed: [
263
+ {
264
+ module: "adapters",
265
+ from: oldName,
266
+ to: `ADAPTERS_${name}`,
267
+ path: `adapters.${key}`,
268
+ state: "unset",
269
+ },
270
+ ],
271
+ });
226
272
  },
227
273
  );
228
274
 
@@ -233,13 +279,18 @@ describe("a variable renamed with a selection", () => {
233
279
  expect(err.message).toContain(oldName);
234
280
  expect(err.message).not.toContain("old-value-5e2d");
235
281
  expect(err.message).not.toContain("new-value-c81a");
282
+ expect(err.details).toMatchObject({ renamed: [{ from: oldName, state: "different" }] });
283
+ expect(JSON.stringify(err.details)).not.toMatch(/old-value-5e2d|new-value-c81a/);
236
284
  },
237
285
  );
238
286
 
239
287
  it.each(SELECTIONS)(
240
- "adapters.%s: the old variable beside ADAPTERS_%s at the same value is read",
241
- (key, name, _oldPath, oldName, value) => {
242
- expect(adaptersFrom({ [oldName]: value, [`ADAPTERS_${name}`]: value })[key]).toBe(value);
288
+ "adapters.%s: the old variable beside ADAPTERS_%s at the same value is refused all the same",
289
+ (_key, name, _oldPath, oldName, value) => {
290
+ const err = refusal({ [oldName]: value, [`ADAPTERS_${name}`]: value });
291
+ expect(err.reason).toBe("environment-variable-renamed");
292
+ expect(err.message).toContain(`unset ${oldName}`);
293
+ expect(err.details).toMatchObject({ renamed: [{ from: oldName, state: "different" }] });
243
294
  },
244
295
  );
245
296
  });
@@ -277,6 +328,13 @@ describe("the modules phase one chooses by the selections", () => {
277
328
  expect(modules).toContain(installed);
278
329
  expect(modules).not.toContain(absent);
279
330
  });
331
+
332
+ it("ADAPTERS_ATTEMPT_COUNTER=redis installs redis-attempt-counter over the shared Redis socket; memory installs no counter", () => {
333
+ const onRedis = names({ ADAPTERS_ATTEMPT_COUNTER: "redis" });
334
+ expect(onRedis).toContain("redis-attempt-counter");
335
+ expect(onRedis).toContain("redis-clients");
336
+ expect(names({ ADAPTERS_ATTEMPT_COUNTER: "memory" })).not.toContain("redis-attempt-counter");
337
+ });
280
338
  });
281
339
 
282
340
  describe("the federation-grant stores", () => {
@@ -335,24 +393,33 @@ describe("boot and the section", () => {
335
393
  });
336
394
 
337
395
  it.each([
338
- ["named adapters", "adapters", undefined],
339
- ["with its section at adapters.custom", "custom-thing", "adapters.custom"],
340
- ] as const)("refuses a module %s: its section would never reach boot", (_label, name, at) => {
396
+ ["named adapters", "adapters"],
397
+ ["named mfaMode", "mfaMode"],
398
+ ] as const)("refuses a module %s: its section would never reach boot", (_label, name) => {
341
399
  const own = readOwnLayers(ownFiles(), { env: MFA_OFF });
342
400
  const switches = readSwitches(own);
343
401
  const mine = defineModule({
344
402
  name,
345
- section: {
346
- schema: z.object({}).passthrough().optional(),
347
- ...(at === undefined ? {} : { at }),
348
- },
403
+ section: { schema: z.object({}).passthrough().optional() },
349
404
  });
350
- expect(() =>
405
+ const run = () =>
351
406
  resolveForBoot(
352
407
  own,
353
408
  [...buildModules(switches, { environment: "production" }), mine],
354
409
  switches,
355
- ),
356
- ).toThrow(new RegExp(`"${name}".*adapters`));
410
+ );
411
+ expect(run).toThrow(new RegExp(`"${name}".*composition root's own section`));
412
+ const err = (() => {
413
+ try {
414
+ run();
415
+ } catch (thrown) {
416
+ return thrown;
417
+ }
418
+ })();
419
+ expect(err).toBeInstanceOf(BootError);
420
+ expect(err).toMatchObject({
421
+ reason: "module-section-path-invalid",
422
+ details: { module: name, at: name },
423
+ });
357
424
  });
358
425
  });
@@ -0,0 +1,87 @@
1
+ /*
2
+ * Copyright 2026 1o1 Co. Ltd.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /**
18
+ * The oauth package's consumers of session admission read the session
19
+ * lifecycle's record the template's session stores provide: a session whose
20
+ * record is closing is refused at `/authorize`, the code exchange and the
21
+ * refresh grant, while its user session is still there.
22
+ */
23
+
24
+ import type { SessionLifecycleStore, UserSessionStore } from "@o3co/auth-provider-core";
25
+ import request from "supertest";
26
+ import { describe, expect, it } from "vitest";
27
+ import {
28
+ authorize,
29
+ basic,
30
+ codeFrom,
31
+ compose,
32
+ login,
33
+ redeem,
34
+ WEB,
35
+ } from "./all-modules-composition.fixture.mjs";
36
+
37
+ /** A JWT's claims, unverified. */
38
+ const claimsOf = (token: string): Record<string, unknown> =>
39
+ JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
40
+
41
+ describe("a session whose lifecycle record is closing", () => {
42
+ it("is refused at /authorize, the code exchange and the refresh grant", async () => {
43
+ const { app, handle } = await compose();
44
+ try {
45
+ const components = handle.components as Record<string, unknown>;
46
+ const store = components.sessionLifecycleStore as SessionLifecycleStore;
47
+ const sessions = components.userSessionStore as UserSessionStore;
48
+ const { cookies } = await login(app);
49
+ const tokens = (await redeem(app, codeFrom(await authorize(app, cookies)))).body as Record<
50
+ string,
51
+ string
52
+ >;
53
+ const sid = String(claimsOf(tokens.id_token ?? "").sid);
54
+ const pendingCode = codeFrom(await authorize(app, cookies));
55
+
56
+ // The session's close commits, its work pending: the user session stays.
57
+ const session = await sessions.get(sid);
58
+ if (session === null) throw new Error("the login wrote no session");
59
+ expect((await store.open(sid, session.sub, session.expiresAt)).outcome).toBe("opened");
60
+ const closing = await store.beginClose(sid, {
61
+ cause: "rp_logout",
62
+ steps: ["held_open"],
63
+ perParticipant: [],
64
+ retainMs: 0,
65
+ });
66
+ expect(closing.outcome).toBe("closing");
67
+ expect(await sessions.get(sid)).not.toBeNull();
68
+
69
+ const again = await authorize(app, cookies);
70
+ // Sent to log in again, with no code.
71
+ expect(again.status).toBe(302);
72
+ expect(String(again.headers.location)).not.toMatch(/[?&]code=/);
73
+
74
+ expect((await redeem(app, pendingCode)).body).toMatchObject({ error: "invalid_grant" });
75
+
76
+ const refreshed = await request(app)
77
+ .post("/oauth/token")
78
+ .set("Authorization", basic(WEB))
79
+ .type("form")
80
+ .send({ grant_type: "refresh_token", refresh_token: tokens.refresh_token });
81
+ expect(refreshed.status).toBe(400);
82
+ expect(refreshed.body).toMatchObject({ error: "invalid_grant" });
83
+ } finally {
84
+ await handle.dispose();
85
+ }
86
+ });
87
+ });