@o3co/create-auth-provider 0.16.0 → 0.17.0-rc.2

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 (100) hide show
  1. package/README.ja.md +66 -21
  2. package/README.md +76 -31
  3. package/dist/index.d.mts +33 -10
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +118 -59
  6. package/dist/internal/template-filter.d.mts +7 -10
  7. package/dist/internal/template-filter.d.mts.map +1 -1
  8. package/dist/internal/template-filter.mjs +7 -10
  9. package/package.json +6 -4
  10. package/templates/standalone/.dockerignore +15 -2
  11. package/templates/standalone/.env.example +59 -29
  12. package/templates/standalone/Dockerfile +6 -2
  13. package/templates/standalone/README.ja.md +324 -169
  14. package/templates/standalone/README.md +618 -208
  15. package/templates/standalone/config/application.conf +211 -352
  16. package/templates/standalone/config/clients.yaml +2 -0
  17. package/templates/standalone/config/clients.yaml.example +4 -0
  18. package/templates/standalone/config/development.conf +13 -4
  19. package/templates/standalone/config/mailpit.conf +8 -0
  20. package/templates/standalone/config/production.conf +5 -6
  21. package/templates/standalone/config/reference.conf +418 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +63 -32
  24. package/templates/standalone/docker-compose.yml +13 -3
  25. package/templates/standalone/gitignore +1 -0
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/adapters.test.mts +419 -0
  28. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +287 -198
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +71 -35
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +148 -94
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +84 -81
  33. package/templates/standalone/src/__tests__/config-notices.test.mts +154 -0
  34. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  35. package/templates/standalone/src/__tests__/core-federations.test.mts +249 -0
  36. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  37. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +10 -15
  38. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +970 -288
  39. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +421 -86
  40. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +87 -72
  41. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +16 -8
  42. package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
  43. package/templates/standalone/src/__tests__/google-session.fixture.mts +97 -93
  44. package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
  45. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
  46. package/templates/standalone/src/__tests__/library-references.fixture.mts +188 -0
  47. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
  48. package/templates/standalone/src/__tests__/listen.test.mts +6 -13
  49. package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
  50. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  51. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +326 -0
  52. package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
  53. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
  54. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  55. package/templates/standalone/src/__tests__/mfa-switch.test.mts +825 -0
  56. package/templates/standalone/src/__tests__/own-modules.test.mts +1486 -0
  57. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +10 -14
  58. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
  59. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +116 -81
  60. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  61. package/templates/standalone/src/__tests__/replica-safety.test.mts +265 -146
  62. package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
  63. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +278 -103
  64. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  65. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
  66. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +69 -61
  67. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
  68. package/templates/standalone/src/__tests__/session-requirements.test.mts +382 -0
  69. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +270 -0
  70. package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
  71. package/templates/standalone/src/__tests__/smoke.test.mts +176 -258
  72. package/templates/standalone/src/__tests__/store-credential.test.mts +88 -52
  73. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  74. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +131 -102
  75. package/templates/standalone/src/__tests__/two-phase-config.test.mts +604 -0
  76. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +382 -0
  77. package/templates/standalone/src/adapters.mts +162 -0
  78. package/templates/standalone/src/app.mts +65 -79
  79. package/templates/standalone/src/bootRefusal.mts +117 -0
  80. package/templates/standalone/src/buildModules.mts +220 -309
  81. package/templates/standalone/src/configPath.mts +449 -29
  82. package/templates/standalone/src/listen.mts +12 -18
  83. package/templates/standalone/src/logger.mts +31 -60
  84. package/templates/standalone/src/metrics.mts +24 -47
  85. package/templates/standalone/src/mfaSwitch.mts +285 -0
  86. package/templates/standalone/src/modules.mts +423 -629
  87. package/templates/standalone/src/rootRenames.mts +96 -0
  88. package/templates/standalone/src/routes.mts +8 -14
  89. package/templates/standalone/src/sections.mts +367 -0
  90. package/templates/standalone/src/shutdown.mts +45 -123
  91. package/templates/standalone/tests/index.test.js +6 -5
  92. package/templates/standalone/tests/package-lock.json +1615 -2223
  93. package/templates/standalone/tests/package.json +2 -2
  94. package/templates/standalone/tests/vitest.config.mjs +26 -0
  95. package/templates/standalone/vitest.config.mts +8 -7
  96. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  97. package/templates/versions.json +18 -15
  98. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -135
  99. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -199
  100. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -138
@@ -16,31 +16,25 @@
16
16
 
17
17
  /**
18
18
  * Every module this template can turn on, booted together on one replica
19
- * through the real composition — the shipped HOCON, `buildModules`, core's
20
- * `createApp`, mounted as `app.mts` mounts it — and held to the contracts that
21
- * exist only when the modules meet: one discovery document that tells the
22
- * truth about every grant and endpoint, every module's route answering in the
23
- * same app, each module's body rules surviving its neighbours in either mount
24
- * order, and a store outage answered 503 and logged once.
25
- *
26
- * Each package's own suite boots that package alone or against stand-ins, and
27
- * the defects this file exists for were invisible there: a discovery document
28
- * made invalid by a neighbour's contribution, a disabled grant still
29
- * advertised, one module's prefix parser setting another's body limit, a
30
- * content-type rule that held in one mount order only. See
31
- * `all-modules-composition.fixture.mts` for what is real and what is
32
- * substituted, and `all-modules-composition.multi.test.mts` for the same
33
- * composition on Redis under `deployment.mode = "multi"`.
19
+ * through the real composition (the shipped HOCON, `buildModules`, core's
20
+ * `createApp`, mounted as `app.mts` mounts it), and held to the contracts that
21
+ * exist only when the modules meet, which no package's own suite can see: one
22
+ * discovery document that tells the truth about every grant and endpoint,
23
+ * every module's route answering in the same app, each module's body rules
24
+ * surviving its neighbours in either mount order, and a store outage answered
25
+ * 503 and logged once. The fixture file says what is real and what is
26
+ * substituted; `all-modules-composition.multi.test.mts` boots the same
27
+ * composition on Redis under `core.deployment.mode = "multi"`.
34
28
  *
35
29
  * `knownDefect` marks a contract the composition breaks today; its comment
36
30
  * names the defect. In the monorepo it is `it.fails`: the fix that mends the
37
31
  * defect turns the case red, and turns it into a plain `it`. An outage case
38
- * pins only the part of the #685 rule that is broken, and asserts the rest
32
+ * pins only the part of the outage rule that is broken, and asserts the rest
39
33
  * (`describeOutages` in the fixture).
40
34
  */
41
35
 
42
36
  import { readdirSync, readFileSync } from "node:fs";
43
- import type { AppConfig } from "@o3co/auth-provider-core";
37
+ import { type AppConfig, federationsOf } from "@o3co/auth-provider-core";
44
38
  import type express from "express";
45
39
  import request from "supertest";
46
40
  import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
@@ -112,31 +106,39 @@ async function boot(options: Parameters<typeof compose>[0] = {}): Promise<Compos
112
106
  const TEMPLATE_PACKAGES: Readonly<Record<string, string>> = {
113
107
  "@o3co/auth-provider-core":
114
108
  "jwksModule, the memory stores, the refresh-token family rotation and revocation",
115
- "@o3co/auth-provider-federation-google": "googleFederationModule",
109
+ "@o3co/auth-provider-federation-google": "googleFederationTypeModule, the google type",
116
110
  "@o3co/auth-provider-federation-grants": "federationGrantsModules",
117
- "@o3co/auth-provider-federation-oidc": "oidcFederationModule, one per `oidc` section",
118
- "@o3co/auth-provider-foundation": "the HTTP user repository adapter (no module)",
111
+ "@o3co/auth-provider-federation-oidc": "oidcFederationTypeModule, the oidc type",
112
+ "@o3co/auth-provider-foundation":
113
+ "the HTTP user repository adapter; foundationMfaFactorStoreModule under MFA_MODE with adapters.mfaFactorStore = store (tools/composition boots it)",
114
+ "@o3co/auth-provider-mfa":
115
+ "mfaModules (mfaTotpFactorModule, mfaRecoveryCodeFactorModule, mfaEmailFactorModule, mfaModule) and mfaResetModule, under MFA_MODE (mfa-switch.test.mts)",
119
116
  "@o3co/auth-provider-oauth":
120
- "oauthModule, oauthSessionModule, oauthAuthorizationModule, subjectRevocationServiceModule",
117
+ "oauthEndpointsModule, oauthSessionGrantModule, oauthAuthorizationGrantsModule, subjectRevocationServiceModule",
121
118
  "@o3co/auth-provider-redis": "the Redis stores (all-modules-composition.multi.test.mts)",
122
- "@o3co/auth-provider-session": "sessionModule, sessionStoreModuleFor",
119
+ "@o3co/auth-provider-session": "sessionModule, sessionStoreModule",
120
+ "@o3co/auth-provider-standard":
121
+ "standardSmtpMailSenderModule; standardDevelopmentMailSenderModule in development",
123
122
  };
124
123
 
125
124
  /**
126
125
  * Workspace packages the template does not depend on, so nothing here can
127
- * boot them. Each is a module a deployment adds to this manifest by hand —
126
+ * boot them. Each but the test kit, which has no module, is a module a
127
+ * deployment adds to this manifest by hand —
128
128
  * `modules.mts` provides the Redis clients their stores need for that reason
129
129
  * — and `tools/composition` in the monorepo boots each beside every module
130
130
  * here, through this file's fixture.
131
131
  */
132
132
  const NOT_IN_TEMPLATE: Readonly<Record<string, string>> = {
133
- "@o3co/auth-provider-device-grant": "deviceGrantModule",
133
+ "@o3co/auth-provider-device-grant": "deviceAuthorizationGrantModule",
134
134
  "@o3co/auth-provider-dpop": "dpopModule",
135
- "@o3co/auth-provider-federation-apple": "appleFederationModule",
136
- "@o3co/auth-provider-federation-github": "githubFederationModule",
135
+ "@o3co/auth-provider-federation-apple": "appleFederationTypeModule",
136
+ "@o3co/auth-provider-federation-github": "githubFederationTypeModule",
137
137
  "@o3co/auth-provider-mtls": "mtlsModule",
138
138
  "@o3co/auth-provider-oauth-token-exchange": "tokenExchangeModule",
139
- "@o3co/auth-provider-webauthn": "webauthnModule",
139
+ "@o3co/auth-provider-test-kit": "no module: contract suites and fakes, for tests",
140
+ "@o3co/auth-provider-webauthn":
141
+ "webauthnModule, webauthnSessionSubjectModule, webauthnMfaFactorModule",
140
142
  };
141
143
 
142
144
  describe("what the all-modules composition covers", () => {
@@ -173,7 +175,7 @@ describe("what the all-modules composition covers", () => {
173
175
 
174
176
  /** What `buildModules` lists with every switch on, one replica, memory stores. */
175
177
  const ALL_ON_MODULES = [
176
- "sessionStoreModule",
178
+ "session-store",
177
179
  "federation-grant-background",
178
180
  "federation-grants",
179
181
  "oauth",
@@ -181,19 +183,21 @@ const ALL_ON_MODULES = [
181
183
  "oauth-authorization",
182
184
  "jwks",
183
185
  "session",
184
- "federation:google",
185
- "standalone:google-federation-config",
186
- "standalone:oidc-federation-config",
187
- "federation:oidc:oidc",
188
- "standalone:key-store",
186
+ "federation-google-type",
187
+ "federation-oidc",
188
+ "logging",
189
+ "http",
190
+ "key-store",
189
191
  "test:repositories",
190
- "standalone:audit-sink",
191
- "standalone:in-memory-federation-token-store",
192
+ "audit-sink",
193
+ "standard-smtp-mail-sender",
194
+ "standalone-in-memory-federation-token-store",
192
195
  "core-federation-grant-store-memory",
193
196
  "core-federation-grant-intent-store-memory",
194
- "standalone:in-memory-session-stores",
197
+ "standalone-in-memory-session-stores",
198
+ "core-session-lifecycle",
195
199
  "core-rate-limiter-memory",
196
- "standalone:in-memory-code-repository",
200
+ "standalone-in-memory-code-repository",
197
201
  "core-access-token-denylist-memory",
198
202
  "core-replay-seen-set-memory",
199
203
  "core-consent-store-memory",
@@ -231,23 +235,28 @@ describe("every module the template can turn on boots together", () => {
231
235
  );
232
236
  });
233
237
 
234
- it("registers every federation and every grant a module contributes", async () => {
238
+ it("registers a federation for every enabled entry of a type a module contributes, and every grant", async () => {
235
239
  const { modules, handle, app } = await boot();
236
- const federations = modules.flatMap((m) => contributionNames(m, "federations")).sort();
237
- expect(federations).toEqual(["google", "oidc"]);
238
- expect([...(handle.components.federationProviders?.keys() ?? [])].sort()).toEqual(federations);
239
-
240
- const grants = modules.flatMap((m) => contributionNames(m, "grants")).sort();
240
+ const types = modules.flatMap((m) => contributionNames(m, "federationTypes")).sort();
241
+ expect(types).toEqual(["google", "oidc"]);
242
+ // The shipped entries are named after their types.
243
+ expect([...(handle.components.federationProviders?.keys() ?? [])].sort()).toEqual(types);
244
+
245
+ // What boot registered: a module's grant switched off by its section
246
+ // answers `null` and registers nothing.
247
+ const grants = [...(handle.components.grantHandlerResolver?.entries() ?? [])]
248
+ .map(([grantType]) => grantType)
249
+ .sort();
241
250
  expect(grants).toEqual(ENABLED_GRANTS);
242
251
  const discovery = await request(app).get("/.well-known/openid-configuration");
243
252
  expect([...discovery.body.grant_types_supported].sort()).toEqual(grants);
244
253
  });
245
254
 
246
255
  it("hands the deployment's logger to every module that answers a request", async () => {
247
- // `app.mts` fills the `logger` slot so that `LOG_LEVEL` and the JSON
256
+ // `app.mts` fills the `logger` slot so that `LOGGING_LEVEL` and the JSON
248
257
  // envelope reach every module; boot hands a module only the slots its
249
258
  // manifest names, so a route or grant module without `logger` writes
250
- // its outage lines to nobody (the session grant did, answering 503).
259
+ // its outage lines to nobody.
251
260
  const { modules } = await boot();
252
261
  const answering = modules.filter(
253
262
  (m) => contributionNames(m, "routes").length + contributionNames(m, "grants").length > 0,
@@ -325,10 +334,10 @@ describe("discovery", () => {
325
334
  * is gone from the document and from the app, and nothing else moves.
326
335
  */
327
336
  const GRANT_SWITCHES: ReadonlyArray<readonly [grant: string, variable: string]> = [
328
- ["session", "OAUTH_GRANTS_SESSION_ENABLED"],
329
- ["authorization_code", "OAUTH_GRANTS_AUTHORIZATION_CODE_ENABLED"],
330
- ["refresh_token", "OAUTH_GRANTS_REFRESH_TOKEN_ENABLED"],
331
- ["client_credentials", "OAUTH_GRANTS_CLIENT_CREDENTIALS_ENABLED"],
337
+ ["session", "OAUTH_SESSION_ENABLED"],
338
+ ["authorization_code", "OAUTH_AUTHORIZATION_GRANTS_AUTHORIZATION_CODE_ENABLED"],
339
+ ["refresh_token", "OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_ENABLED"],
340
+ ["client_credentials", "OAUTH_AUTHORIZATION_GRANTS_CLIENT_CREDENTIALS_ENABLED"],
332
341
  ];
333
342
 
334
343
  it.each(GRANT_SWITCHES)(
@@ -336,7 +345,7 @@ describe("discovery", () => {
336
345
  async (grant, variable) => {
337
346
  const { app } = await boot({ env: { ...SINGLE_ENV, [variable]: "false" } });
338
347
  const doc = (await request(app).get(DISCOVERY_PATHS[0])).body;
339
- expectValidMetadata(doc);
348
+ expectValidMetadata(doc, { authorizationCode: grant !== "authorization_code" });
340
349
  expect([...doc.grant_types_supported].sort()).toEqual(
341
350
  ENABLED_GRANTS.filter((g) => g !== grant),
342
351
  );
@@ -347,11 +356,17 @@ describe("discovery", () => {
347
356
  .send({ grant_type: grant });
348
357
  expect(res.status).toBe(400);
349
358
  expect(res.body.error).toBe("unsupported_grant_type");
359
+ if (grant === "authorization_code") {
360
+ // No grant redeems a code, so none is issued: none is served.
361
+ expect((await request(app).get("/oauth/authorize")).status).toBe(404);
362
+ }
350
363
  },
351
364
  );
352
365
 
353
366
  it("Client ID Metadata Documents off: the flag is gone and nothing else moves", async () => {
354
- const { app } = await boot({ env: { ...SINGLE_ENV, OAUTH_CIMD_ENABLED: "false" } });
367
+ const { app } = await boot({
368
+ env: { ...SINGLE_ENV, OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ENABLED: "false" },
369
+ });
355
370
  const doc = (await request(app).get(DISCOVERY_PATHS[0])).body;
356
371
  expectValidMetadata(doc);
357
372
  expect(doc.client_id_metadata_document_supported).toBeUndefined();
@@ -367,7 +382,7 @@ describe("discovery", () => {
367
382
  await on.handle.dispose();
368
383
  current = undefined;
369
384
 
370
- const { app } = await boot({ env: { ...SINGLE_ENV, CONSENT_STORE_ADAPTER: "none" } });
385
+ const { app } = await boot({ env: { ...SINGLE_ENV, ADAPTERS_CONSENT_STORE: "none" } });
371
386
  const doc = (await request(app).get(DISCOVERY_PATHS[0])).body;
372
387
  expectValidMetadata(doc);
373
388
  expect(doc.client_id_metadata_document_supported).toBeUndefined();
@@ -376,7 +391,13 @@ describe("discovery", () => {
376
391
 
377
392
  /** The grant connections dropped: they name the OIDC federation. */
378
393
  const withoutConnections = (config: AppConfig): AppConfig =>
379
- ({ ...config, federationGrants: { ...config.federationGrants, connections: {} } }) as AppConfig;
394
+ ({
395
+ ...config,
396
+ "federation-grants": {
397
+ ...(config["federation-grants"] as Record<string, unknown>),
398
+ connections: {},
399
+ },
400
+ }) as AppConfig;
380
401
 
381
402
  const FEATURE_SWITCHES: ReadonlyArray<
382
403
  readonly [
@@ -389,13 +410,13 @@ describe("discovery", () => {
389
410
  ["federation grants", "FEDERATION_GRANTS_ENABLED", "/oauth/federation-grants", undefined],
390
411
  [
391
412
  "the Google federation",
392
- "FEDERATIONS_GOOGLE_ENABLED",
413
+ "CORE_FEDERATIONS_GOOGLE_ENABLED",
393
414
  "/session/oauth/federation/google",
394
415
  undefined,
395
416
  ],
396
417
  [
397
418
  "the OIDC federation",
398
- "FEDERATIONS_OIDC_ENABLED",
419
+ "CORE_FEDERATIONS_OIDC_ENABLED",
399
420
  "/session/oauth/federation/oidc",
400
421
  withoutConnections,
401
422
  ],
@@ -421,7 +442,7 @@ describe("discovery", () => {
421
442
 
422
443
  it("the OIDC federation off while a grant connection names it is refused at boot, naming both", async () => {
423
444
  await expect(
424
- compose({ env: { ...SINGLE_ENV, FEDERATIONS_OIDC_ENABLED: "false" } }),
445
+ compose({ env: { ...SINGLE_ENV, CORE_FEDERATIONS_OIDC_ENABLED: "false" } }),
425
446
  ).rejects.toSatisfy((err: unknown) => {
426
447
  const e = err as { name?: string; cause?: { message?: string } };
427
448
  return (
@@ -554,7 +575,7 @@ describe("every module's primary route answers in the one app", () => {
554
575
 
555
576
  // KNOWN DEFECT (the template's federation config, with the session
556
577
  // package's redirect policy): a federation enabled the way the template
557
- // README documents — its `FEDERATIONS_OIDC_*` variables — boots, and every
578
+ // README documents — its `CORE_FEDERATIONS_OIDC_*` variables — boots, and every
558
579
  // login through it then ends at the callback in `500 misconfiguration`
559
580
  // "client URL not configured", after the user has signed in upstream.
560
581
  // `clientUrl` has no environment form (`application.conf` ships it
@@ -565,9 +586,12 @@ describe("every module's primary route answers in the one app", () => {
565
586
  "a federation enabled from the documented variables alone either refuses to boot or completes a login",
566
587
  async () => {
567
588
  const withoutLanding = (config: AppConfig): AppConfig => {
568
- const federations = config.federations as Record<string, Record<string, unknown>>;
589
+ const federations = federationsOf(config) as Record<string, Record<string, unknown>>;
569
590
  const { clientUrl: _dropped, ...oidc } = federations.oidc ?? {};
570
- return { ...config, federations: { ...federations, oidc } } as unknown as AppConfig;
591
+ return {
592
+ ...config,
593
+ core: { ...config.core, federations: { ...federations, oidc } },
594
+ } as unknown as AppConfig;
571
595
  };
572
596
  let composed: Composition;
573
597
  try {
@@ -591,7 +615,7 @@ describe("every module's primary route answers in the one app", () => {
591
615
  },
592
616
  );
593
617
 
594
- it("a federation grant is lodged, beside oauthModule under /oauth", async () => {
618
+ it("a federation grant is lodged, beside oauthEndpointsModule under /oauth", async () => {
595
619
  const { app } = await boot();
596
620
  const res = await lodgeGrant(app);
597
621
  expect(res.status).toBe(201);
@@ -633,7 +657,7 @@ describe.each([AS_LISTED, REVERSED] satisfies ModuleOrder[])(
633
657
  });
634
658
 
635
659
  it.each(TRANSFERS)(
636
- "federation grants keep their 16 KiB bound beneath oauthModule's /oauth, a body sent %s",
660
+ "federation grants keep their 16 KiB bound beneath oauthEndpointsModule's /oauth, a body sent %s",
637
661
  async (_transfer, send) => {
638
662
  for (const type of [JSON_TYPE, FORM_TYPE]) {
639
663
  const body =
@@ -746,9 +770,10 @@ describe.each([AS_LISTED, REVERSED] satisfies ModuleOrder[])(
746
770
  );
747
771
 
748
772
  describe("a request body the OAuth endpoints do not parse", () => {
749
- // KNOWN DEFECT (packages/oauth, `createOAuthRouter` in routes.mts): the
750
- // token and introspection handlers read `req.body` without checking that a
751
- // parser ran. A body that is neither a form nor JSON — or none at all —
773
+ // KNOWN DEFECT (packages/oauth, `routes/token.mts`,
774
+ // `routes/introspectCaller.mts` and `routes.mts`): the token and
775
+ // introspection handlers read `req.body` without checking that a parser
776
+ // ran. A body that is neither a form nor JSON — or none at all —
752
777
  // leaves it undefined, the handler throws, and the terminal handler answers
753
778
  // `500 server_error` and logs `unhandled_request_error` for the client's
754
779
  // own mistake. `/oauth/revoke` checks, and answers 400.
@@ -804,6 +829,16 @@ const oidcCallback = async (app: express.Express, outage: Outage, c: Composition
804
829
  const VERIFIER_WARN =
805
830
  "core's verifier (`verifyJwt`, `packages/core/src/jwt/verify.mts`) writes its own `jwt_verify_rejected` warn (`reason: \"revocation_unavailable\"`) beside the route's error line: two lines for one outage (the runbook's outage table documents both)";
806
831
 
832
+ /** The CSRF token the session middleware set in `cookies`, for a request that mutates. */
833
+ const csrfTokenOf = (cookies: readonly string[]): string => {
834
+ const cookie = cookies.find((c) => /^[^=]*\.csrf=/.test(c));
835
+ if (cookie === undefined) throw new Error("the login reissued no CSRF cookie");
836
+ return decodeURIComponent(cookie.slice(cookie.indexOf("=") + 1).split(";")[0] ?? "");
837
+ };
838
+
839
+ const ROLLBACK_CLOSE_LOGS =
840
+ "a login that fails after its record's open closes that record (`packages/session/src/establish-session.mts`), and the close's own work meets the same outage: core's session lifecycle (`packages/core/src/session-lifecycle/service.mts`) warns `session_close_item_failed` for the item and keeps the close pending for its sweep, a second line for one outage";
841
+
807
842
  const OUTAGES: readonly OutageCase[] = [
808
843
  {
809
844
  module: "oauth-authorization",
@@ -813,22 +848,6 @@ const OUTAGES: readonly OutageCase[] = [
813
848
  answer: { status: 503, error: "temporarily_unavailable" },
814
849
  event: "authorization_grant_store_unavailable",
815
850
  },
816
- {
817
- module: "oauth-authorization",
818
- slot: "sessionFamilyIndex",
819
- surface: "the code exchange",
820
- run: codeExchange,
821
- answer: { status: 503, error: "temporarily_unavailable" },
822
- event: "authorization_grant_store_unavailable",
823
- },
824
- {
825
- module: "oauth-authorization",
826
- slot: "sessionRPRegistry",
827
- surface: "the code exchange",
828
- run: codeExchange,
829
- answer: { status: 503, error: "temporarily_unavailable" },
830
- event: "authorization_grant_store_unavailable",
831
- },
832
851
  {
833
852
  module: "oauth",
834
853
  slot: "codeRepository",
@@ -906,6 +925,47 @@ const OUTAGES: readonly OutageCase[] = [
906
925
  event: "userinfo_store_unavailable",
907
926
  unrelatedWarns: ["jwt_verify_aud_skipped"],
908
927
  },
928
+ {
929
+ module: "oauth",
930
+ slot: "sessionLifecycleStore",
931
+ surface: "/oauth/userinfo",
932
+ run: async (app, outage) => {
933
+ const { access_token } = await webTokens(app);
934
+ outage.down = true;
935
+ return request(app).get("/oauth/userinfo").set("Authorization", `Bearer ${access_token}`);
936
+ },
937
+ answer: { status: 503, error: "temporarily_unavailable" },
938
+ event: "userinfo_store_unavailable",
939
+ unrelatedWarns: ["jwt_verify_aud_skipped"],
940
+ },
941
+ {
942
+ module: "oauth",
943
+ slot: "sessionLifecycleStore",
944
+ surface: "POST /oauth/logout",
945
+ run: async (app, outage) => {
946
+ const { id_token } = await webTokens(app);
947
+ outage.down = true;
948
+ return request(app).post("/oauth/logout").type("form").send({ id_token_hint: id_token });
949
+ },
950
+ answer: { status: 503, error: "temporarily_unavailable" },
951
+ event: "logout_store_unavailable",
952
+ unrelatedWarns: ["jwt_verify_aud_skipped"],
953
+ },
954
+ {
955
+ module: "session",
956
+ slot: "sessionLifecycleStore",
957
+ surface: "POST /session/logout",
958
+ run: async (app, outage) => {
959
+ const { cookies } = await login(app);
960
+ outage.down = true;
961
+ return request(app)
962
+ .post("/session/logout")
963
+ .set("Cookie", cookies)
964
+ .set("x-csrf-token", csrfTokenOf(cookies));
965
+ },
966
+ answer: { status: 503, error: "temporarily_unavailable" },
967
+ event: "session_logout_store_unavailable",
968
+ },
909
969
  {
910
970
  module: "oauth-session",
911
971
  slot: "userSessionStore",
@@ -918,7 +978,7 @@ const OUTAGES: readonly OutageCase[] = [
918
978
  .send({ grant_type: "session", scope: "openid" });
919
979
  },
920
980
  answer: { status: 503, error: "temporarily_unavailable" },
921
- event: "session_grant_store_unavailable",
981
+ event: "session_admission_unavailable",
922
982
  },
923
983
  {
924
984
  module: "oauth (consent step)",
@@ -933,7 +993,7 @@ const OUTAGES: readonly OutageCase[] = [
933
993
  event: "authorize_consent_store_unavailable",
934
994
  defects: {
935
995
  "store-field":
936
- "packages/oauth `routes/authorize.mts`: `authorize_consent_store_unavailable` carries `clientId` and `err` but no `store` / `step` / `site` field",
996
+ "packages/oauth `routes/authorizeConsent.mts`: `authorize_consent_store_unavailable` carries `clientId` and `err` but no `store` / `step` / `site` field",
937
997
  },
938
998
  },
939
999
  {
@@ -949,7 +1009,7 @@ const OUTAGES: readonly OutageCase[] = [
949
1009
  event: "authorize_pending_consent_store_unavailable",
950
1010
  defects: {
951
1011
  "store-field":
952
- "packages/oauth `routes/authorize.mts`: `authorize_pending_consent_store_unavailable` carries `clientId` and `err` but no `store` / `step` / `site` field",
1012
+ "packages/oauth `routes/authorizeConsent.mts`: `authorize_pending_consent_store_unavailable` carries `clientId` and `err` but no `store` / `step` / `site` field",
953
1013
  },
954
1014
  },
955
1015
  {
@@ -962,6 +1022,7 @@ const OUTAGES: readonly OutageCase[] = [
962
1022
  },
963
1023
  answer: { status: 503, error: "temporarily_unavailable" },
964
1024
  event: "login_store_unavailable",
1025
+ defects: { "no-warn": ROLLBACK_CLOSE_LOGS },
965
1026
  },
966
1027
  {
967
1028
  module: "session",
@@ -1006,7 +1067,7 @@ const OUTAGES: readonly OutageCase[] = [
1006
1067
  outage.down = true;
1007
1068
  return (await login(app)).res;
1008
1069
  },
1009
- // Intended (#296): the login is not denied for a best-effort index write,
1070
+ // Intended: the login is not denied for a best-effort index write,
1010
1071
  // and the missed write is logged at error because a token minted from
1011
1072
  // this session keeps introspecting active after a credential change —
1012
1073
  // the runbook pages on it.
@@ -1022,19 +1083,12 @@ const OUTAGES: readonly OutageCase[] = [
1022
1083
  run: oidcCallback,
1023
1084
  answer: { status: 503, error: "temporarily_unavailable" },
1024
1085
  event: "federation_callback_store_unavailable",
1025
- },
1026
- {
1027
- module: "session",
1028
- slot: "sessionFederationIndex",
1029
- surface: "the OIDC federation callback",
1030
- run: oidcCallback,
1031
- answer: { status: 503, error: "temporarily_unavailable" },
1032
- event: "federation_callback_store_unavailable",
1086
+ defects: { "no-warn": ROLLBACK_CLOSE_LOGS },
1033
1087
  },
1034
1088
  {
1035
1089
  module: "core (rate-limit guard)",
1036
1090
  slot: "rateLimiter",
1037
- surface: "/oauth/token (rateLimit.failMode = closed)",
1091
+ surface: "/oauth/token (redis-rate-limiter.failMode = closed)",
1038
1092
  run: async (app, outage) => {
1039
1093
  outage.down = true;
1040
1094
  return tokenRequest(app, M2M).send({ grant_type: "client_credentials", scope: "api.read" });
@@ -1094,7 +1148,7 @@ const OUTAGES: readonly OutageCase[] = [
1094
1148
  ];
1095
1149
 
1096
1150
  describeOutages(
1097
- "a store or repository outage answers 503 and is logged once, at error (#685)",
1151
+ "a store or repository outage gets the answer its case pins, and is logged once, at error",
1098
1152
  OUTAGES,
1099
1153
  (outage) => compose({ outage }),
1100
1154
  );