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

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 (114) hide show
  1. package/README.md +2 -198
  2. package/main.js +4 -0
  3. package/package.json +8 -30
  4. package/README.ja.md +0 -200
  5. package/dist/cli.d.mts +0 -3
  6. package/dist/cli.d.mts.map +0 -1
  7. package/dist/cli.mjs +0 -18
  8. package/dist/index.d.mts +0 -48
  9. package/dist/index.d.mts.map +0 -1
  10. package/dist/index.mjs +0 -325
  11. package/dist/internal/template-filter.d.mts +0 -14
  12. package/dist/internal/template-filter.d.mts.map +0 -1
  13. package/dist/internal/template-filter.mjs +0 -42
  14. package/templates/standalone/.dockerignore +0 -23
  15. package/templates/standalone/.env.example +0 -152
  16. package/templates/standalone/Dockerfile +0 -125
  17. package/templates/standalone/Makefile +0 -12
  18. package/templates/standalone/README.ja.md +0 -912
  19. package/templates/standalone/README.md +0 -1536
  20. package/templates/standalone/config/application.conf +0 -351
  21. package/templates/standalone/config/clients.yaml +0 -4
  22. package/templates/standalone/config/clients.yaml.example +0 -124
  23. package/templates/standalone/config/development.conf +0 -17
  24. package/templates/standalone/config/mailpit.conf +0 -13
  25. package/templates/standalone/config/production.conf +0 -15
  26. package/templates/standalone/config/reference.conf +0 -427
  27. package/templates/standalone/docker-compose.mailpit.yml +0 -39
  28. package/templates/standalone/docker-compose.production.yml +0 -229
  29. package/templates/standalone/docker-compose.test.yml +0 -16
  30. package/templates/standalone/docker-compose.yml +0 -67
  31. package/templates/standalone/gitignore +0 -23
  32. package/templates/standalone/package.json +0 -56
  33. package/templates/standalone/src/__tests__/adapters.test.mts +0 -425
  34. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +0 -87
  35. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +0 -1188
  36. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +0 -285
  37. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +0 -1154
  38. package/templates/standalone/src/__tests__/audit-sink.test.mts +0 -355
  39. package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +0 -64
  40. package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
  41. package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
  42. package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
  43. package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
  44. package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
  45. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
  46. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1291
  47. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -787
  48. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -464
  49. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
  50. package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
  51. package/templates/standalone/src/__tests__/frontchannel-logout-csp.test.mts +0 -168
  52. package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -318
  53. package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
  54. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
  55. package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
  56. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -217
  57. package/templates/standalone/src/__tests__/listen.test.mts +0 -121
  58. package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
  59. package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
  60. package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
  61. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
  62. package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
  63. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
  64. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
  65. package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
  66. package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1480
  67. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
  68. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -283
  69. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
  70. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
  71. package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +0 -98
  72. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
  73. package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -657
  74. package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
  75. package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
  76. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
  77. package/templates/standalone/src/__tests__/sections.test.mts +0 -516
  78. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -213
  79. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -305
  80. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
  81. package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
  82. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
  83. package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
  84. package/templates/standalone/src/__tests__/smoke.test.mts +0 -642
  85. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -164
  86. package/templates/standalone/src/__tests__/store-credential.test.mts +0 -314
  87. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
  88. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
  89. package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
  90. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -385
  91. package/templates/standalone/src/adapters.mts +0 -162
  92. package/templates/standalone/src/app.mts +0 -127
  93. package/templates/standalone/src/bootRefusal.mts +0 -117
  94. package/templates/standalone/src/buildModules.mts +0 -358
  95. package/templates/standalone/src/configPath.mts +0 -487
  96. package/templates/standalone/src/listen.mts +0 -60
  97. package/templates/standalone/src/logger.mts +0 -80
  98. package/templates/standalone/src/metrics.mts +0 -167
  99. package/templates/standalone/src/mfaSwitch.mts +0 -284
  100. package/templates/standalone/src/modules.mts +0 -705
  101. package/templates/standalone/src/rootRenames.mts +0 -95
  102. package/templates/standalone/src/routes.mts +0 -89
  103. package/templates/standalone/src/sections.mts +0 -378
  104. package/templates/standalone/src/securityHeaders.mts +0 -36
  105. package/templates/standalone/src/shutdown.mts +0 -213
  106. package/templates/standalone/tests/Makefile +0 -5
  107. package/templates/standalone/tests/index.test.js +0 -283
  108. package/templates/standalone/tests/package-lock.json +0 -1617
  109. package/templates/standalone/tests/package.json +0 -16
  110. package/templates/standalone/tests/vitest.config.mjs +0 -26
  111. package/templates/standalone/tsconfig.json +0 -31
  112. package/templates/standalone/vitest.config.mts +0 -31
  113. package/templates/standalone/vitest.supertest-loopback.mts +0 -278
  114. package/templates/versions.json +0 -20
@@ -1,1154 +0,0 @@
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
- * 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, 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"`.
28
- *
29
- * `knownDefect` marks a contract the composition breaks today; its comment
30
- * names the defect. In the monorepo it is `it.fails`: the fix that mends the
31
- * defect turns the case red, and turns it into a plain `it`. An outage case
32
- * pins only the part of the outage rule that is broken, and asserts the rest
33
- * (`describeOutages` in the fixture).
34
- */
35
-
36
- import { readdirSync, readFileSync } from "node:fs";
37
- import { type AppConfig, federationsOf } from "@o3co/auth-provider-core";
38
- import type express from "express";
39
- import request from "supertest";
40
- import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
41
- import {
42
- ALICE,
43
- AS_LISTED,
44
- authorize,
45
- basic,
46
- type Composition,
47
- codeFrom,
48
- compose,
49
- contributionNames,
50
- cookiesOf,
51
- DISCOVERY_PATHS,
52
- describeOutages,
53
- expectValidMetadata,
54
- FEDERATION_LANDING,
55
- FORM_TYPE,
56
- federatedCallback,
57
- ISSUER,
58
- inMonorepo,
59
- JSON_TYPE,
60
- KIB,
61
- knownDefect,
62
- lodgeGrant,
63
- login,
64
- M2M,
65
- type ModuleOrder,
66
- type Outage,
67
- type OutageCase,
68
- padForm,
69
- padJson,
70
- REVERSED,
71
- redeem,
72
- SINGLE_ENV,
73
- TEMPLATE_DEPENDENCIES,
74
- THIRD,
75
- TOO_LARGE,
76
- TRANSFERS,
77
- WEB,
78
- WORKER,
79
- webTokens,
80
- withLength,
81
- } from "./all-modules-composition.fixture.mjs";
82
-
83
- let current: Composition | undefined;
84
-
85
- afterEach(async () => {
86
- await current?.handle.dispose();
87
- current = undefined;
88
- });
89
-
90
- /** Boots and remembers the composition, so `afterEach` disposes it. */
91
- async function boot(options: Parameters<typeof compose>[0] = {}): Promise<Composition> {
92
- current = await compose(options);
93
- return current;
94
- }
95
-
96
- // ---------------------------------------------------------------------------
97
- // What is composed
98
- // ---------------------------------------------------------------------------
99
-
100
- /**
101
- * Every `@o3co/auth-provider-*` package the template depends on, and what the
102
- * all-on composition takes from it. A dependency added to `package.json`
103
- * without a line here fails the first case below, so a new module cannot be
104
- * shipped in the template without being booted here beside the rest.
105
- */
106
- const TEMPLATE_PACKAGES: Readonly<Record<string, string>> = {
107
- "@o3co/auth-provider-core":
108
- "jwksModule, the memory stores, the refresh-token family rotation and revocation",
109
- "@o3co/auth-provider-federation-google": "googleFederationTypeModule, the google type",
110
- "@o3co/auth-provider-federation-grants": "federationGrantsModules",
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)",
116
- "@o3co/auth-provider-oauth":
117
- "oauthEndpointsModule, oauthSessionGrantModule, oauthAuthorizationGrantsModule, subjectRevocationServiceModule",
118
- "@o3co/auth-provider-redis": "the Redis stores (all-modules-composition.multi.test.mts)",
119
- "@o3co/auth-provider-session": "sessionModule, sessionStoreModule",
120
- "@o3co/auth-provider-standard":
121
- "standardSmtpMailSenderModule; standardDevelopmentMailSenderModule in development",
122
- };
123
-
124
- /**
125
- * Workspace packages the template does not compose (the test kit is a
126
- * devDependency only), so nothing here can boot them. Each but the test kit, which has no module, is a module a
127
- * deployment adds to this manifest by hand —
128
- * `modules.mts` provides the Redis clients their stores need for that reason
129
- * — and `tools/composition` in the monorepo boots each beside every module
130
- * here, through this file's fixture.
131
- */
132
- const NOT_IN_TEMPLATE: Readonly<Record<string, string>> = {
133
- "@o3co/auth-provider-device-grant": "deviceAuthorizationGrantModule",
134
- "@o3co/auth-provider-dpop": "dpopModule",
135
- "@o3co/auth-provider-federation-apple": "appleFederationTypeModule",
136
- "@o3co/auth-provider-federation-github": "githubFederationTypeModule",
137
- "@o3co/auth-provider-mtls": "mtlsModule",
138
- "@o3co/auth-provider-oauth-token-exchange": "tokenExchangeModule",
139
- "@o3co/auth-provider-test-kit": "no module: contract suites and fakes, for tests",
140
- "@o3co/auth-provider-webauthn":
141
- "webauthnModule, webauthnSessionSubjectModule, webauthnMfaFactorModule",
142
- };
143
-
144
- describe("what the all-modules composition covers", () => {
145
- it("names every @o3co/auth-provider-* package the template depends on", () => {
146
- const siblings = [...TEMPLATE_DEPENDENCIES].sort();
147
- expect(siblings).toEqual(Object.keys(TEMPLATE_PACKAGES).sort());
148
- });
149
-
150
- describe.runIf(inMonorepo)("in the monorepo", () => {
151
- it("places every workspace package: composed here, or listed as not in the template", () => {
152
- const packagesDir = new URL("../../../../packages/", import.meta.url);
153
- const workspace = readdirSync(packagesDir, { withFileTypes: true })
154
- .filter((entry) => entry.isDirectory())
155
- .map(
156
- (entry) =>
157
- (
158
- JSON.parse(
159
- readFileSync(new URL(`${entry.name}/package.json`, packagesDir), "utf8"),
160
- ) as { name: string }
161
- ).name,
162
- )
163
- .sort();
164
- expect(workspace.length).toBeGreaterThan(0);
165
- expect(workspace).toEqual(
166
- [...Object.keys(TEMPLATE_PACKAGES), ...Object.keys(NOT_IN_TEMPLATE)].sort(),
167
- );
168
- });
169
- });
170
- });
171
-
172
- // ---------------------------------------------------------------------------
173
- // Boot
174
- // ---------------------------------------------------------------------------
175
-
176
- /** What `buildModules` lists with every switch on, one replica, memory stores. */
177
- const ALL_ON_MODULES = [
178
- "session-store",
179
- "federation-grant-background",
180
- "federation-grants",
181
- "oauth",
182
- "oauth-session",
183
- "oauth-authorization",
184
- "jwks",
185
- "session",
186
- "federation-google-type",
187
- "federation-oidc",
188
- "logging",
189
- "http",
190
- "key-store",
191
- "test:repositories",
192
- "audit-sink",
193
- "standard-smtp-mail-sender",
194
- "standalone-in-memory-federation-token-store",
195
- "core-federation-grant-store-memory",
196
- "core-federation-grant-intent-store-memory",
197
- "standalone-in-memory-session-stores",
198
- "core-session-lifecycle",
199
- "core-rate-limiter-memory",
200
- "standalone-in-memory-code-repository",
201
- "core-access-token-denylist-memory",
202
- "core-replay-seen-set-memory",
203
- "core-consent-store-memory",
204
- "core-refresh-token-family-store-memory",
205
- "core-default-refresh-token-family-rotation",
206
- "core-default-refresh-token-family-revocation",
207
- "subject-revocation-service",
208
- ];
209
-
210
- const ENABLED_GRANTS = ["authorization_code", "client_credentials", "refresh_token", "session"];
211
-
212
- describe("every module the template can turn on boots together", () => {
213
- it("lists every module, and boots with nothing refused", async () => {
214
- const { modules } = await boot();
215
- expect(modules.map((m) => m.name)).toEqual(ALL_ON_MODULES);
216
- });
217
-
218
- it("mounts every route a module contributes", async () => {
219
- const { modules, handle } = await boot();
220
- const contributing = modules.filter((m) => contributionNames(m, "routes").length > 0);
221
- expect(contributing.length).toBeGreaterThan(0);
222
- const mountedBy = new Set(handle.routes.map((r) => r.contributedBy));
223
- for (const module of contributing) expect(mountedBy, module.name).toContain(module.name);
224
- expect(handle.routes.map((r) => r.contribution.id).sort()).toEqual(
225
- [
226
- "core:oidc-discovery",
227
- "federation-grants",
228
- "federation-grants-browser",
229
- "federation-routes",
230
- "jwks",
231
- "oauth-endpoints",
232
- "session-middleware",
233
- "session-routes",
234
- ].sort(),
235
- );
236
- });
237
-
238
- it("registers a federation for every enabled entry of a type a module contributes, and every grant", async () => {
239
- const { modules, handle, app } = await boot();
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();
250
- expect(grants).toEqual(ENABLED_GRANTS);
251
- const discovery = await request(app).get("/.well-known/openid-configuration");
252
- expect([...discovery.body.grant_types_supported].sort()).toEqual(grants);
253
- });
254
-
255
- it("hands the deployment's logger to every module that answers a request", async () => {
256
- // `app.mts` fills the `logger` slot so that `LOGGING_LEVEL` and the JSON
257
- // envelope reach every module; boot hands a module only the slots its
258
- // manifest names, so a route or grant module without `logger` writes
259
- // its outage lines to nobody.
260
- const { modules } = await boot();
261
- const answering = modules.filter(
262
- (m) => contributionNames(m, "routes").length + contributionNames(m, "grants").length > 0,
263
- );
264
- expect(answering.length).toBeGreaterThan(5);
265
- for (const module of answering) {
266
- expect([...(module.requires ?? []), ...(module.optional ?? [])], module.name).toContain(
267
- "logger",
268
- );
269
- }
270
- });
271
-
272
- it("materializes every slot a module provides and another reads", async () => {
273
- // A provider boot dropped would leave its readers on their absence
274
- // branch; an unread one is never activated, so only read slots count.
275
- const { modules, handle } = await boot();
276
- const read = new Set(modules.flatMap((m) => [...(m.requires ?? []), ...(m.optional ?? [])]));
277
- const provided = modules.flatMap((m) => Object.keys(m.provides ?? {}));
278
- const expected = provided.filter((slot) => read.has(slot as never));
279
- expect(expected.length).toBeGreaterThan(10);
280
- const components = handle.components as Record<string, unknown>;
281
- for (const slot of expected) expect(components[slot], slot).toBeDefined();
282
- });
283
- });
284
-
285
- // ---------------------------------------------------------------------------
286
- // Discovery
287
- // ---------------------------------------------------------------------------
288
-
289
- describe("discovery", () => {
290
- it.each(DISCOVERY_PATHS)("%s is valid and advertises every enabled grant", async (path) => {
291
- const { app } = await boot();
292
- const res = await request(app).get(path);
293
- expect(res.status).toBe(200);
294
- expectValidMetadata(res.body);
295
- expect([...res.body.grant_types_supported].sort()).toEqual(ENABLED_GRANTS);
296
- expect(res.body.client_id_metadata_document_supported).toBe(true);
297
- for (const endpoint of [
298
- "userinfo_endpoint",
299
- "introspection_endpoint",
300
- "revocation_endpoint",
301
- "end_session_endpoint",
302
- ]) {
303
- expect(res.body[endpoint], endpoint).toBeDefined();
304
- }
305
- });
306
-
307
- it("serves the same document at both well-known paths", async () => {
308
- const { app } = await boot();
309
- const [oidc, oauth] = await Promise.all(DISCOVERY_PATHS.map((p) => request(app).get(p)));
310
- expect(oauth.body).toEqual(oidc.body);
311
- });
312
-
313
- it("advertises only endpoints the composed app answers", async () => {
314
- const { app } = await boot();
315
- const doc = (await request(app).get(DISCOVERY_PATHS[0])).body as Record<string, string>;
316
- const advertised = Object.entries(doc).filter(
317
- ([field]) => field.endsWith("_endpoint") || field === "jwks_uri",
318
- );
319
- expect(advertised.length).toBeGreaterThanOrEqual(7);
320
- for (const [field, url] of advertised) {
321
- const { pathname } = new URL(url);
322
- const get = await request(app).get(pathname);
323
- const post = await request(app).post(pathname).type("form").send({});
324
- // The module's own answer (a refusal is fine), never express's 404.
325
- expect(
326
- [get.status, post.status].some((status) => status !== 404),
327
- field,
328
- ).toBe(true);
329
- }
330
- });
331
-
332
- /**
333
- * Each switch the template exposes, turned off alone: what it contributed
334
- * is gone from the document and from the app, and nothing else moves.
335
- */
336
- const GRANT_SWITCHES: ReadonlyArray<readonly [grant: string, variable: string]> = [
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"],
341
- ];
342
-
343
- it.each(GRANT_SWITCHES)(
344
- "%s off: absent from grant_types_supported and refused at the token endpoint",
345
- async (grant, variable) => {
346
- const { app } = await boot({ env: { ...SINGLE_ENV, [variable]: "false" } });
347
- const doc = (await request(app).get(DISCOVERY_PATHS[0])).body;
348
- expectValidMetadata(doc, { authorizationCode: grant !== "authorization_code" });
349
- expect([...doc.grant_types_supported].sort()).toEqual(
350
- ENABLED_GRANTS.filter((g) => g !== grant),
351
- );
352
- const res = await request(app)
353
- .post("/oauth/token")
354
- .set("Authorization", basic(M2M))
355
- .type("form")
356
- .send({ grant_type: grant });
357
- expect(res.status).toBe(400);
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
- }
363
- },
364
- );
365
-
366
- it("Client ID Metadata Documents off: the flag is gone and nothing else moves", async () => {
367
- const { app } = await boot({
368
- env: { ...SINGLE_ENV, OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ENABLED: "false" },
369
- });
370
- const doc = (await request(app).get(DISCOVERY_PATHS[0])).body;
371
- expectValidMetadata(doc);
372
- expect(doc.client_id_metadata_document_supported).toBeUndefined();
373
- expect([...doc.grant_types_supported].sort()).toEqual(ENABLED_GRANTS);
374
- });
375
-
376
- it("no consent store: the flag is gone (it could not be completed) and /oauth/consent is not mounted", async () => {
377
- // On, the consent route answers for itself: no session, `401 login_required`.
378
- const on = await boot();
379
- const mounted = await request(on.app).get("/oauth/consent");
380
- expect(mounted.status).toBe(401);
381
- expect(mounted.body.error).toBe("login_required");
382
- await on.handle.dispose();
383
- current = undefined;
384
-
385
- const { app } = await boot({ env: { ...SINGLE_ENV, ADAPTERS_CONSENT_STORE: "none" } });
386
- const doc = (await request(app).get(DISCOVERY_PATHS[0])).body;
387
- expectValidMetadata(doc);
388
- expect(doc.client_id_metadata_document_supported).toBeUndefined();
389
- expect((await request(app).get("/oauth/consent")).status).toBe(404);
390
- });
391
-
392
- /** The grant connections dropped: they name the OIDC federation. */
393
- const withoutConnections = (config: AppConfig): AppConfig =>
394
- ({
395
- ...config,
396
- "federation-grants": {
397
- ...(config["federation-grants"] as Record<string, unknown>),
398
- connections: {},
399
- },
400
- }) as AppConfig;
401
-
402
- const FEATURE_SWITCHES: ReadonlyArray<
403
- readonly [
404
- feature: string,
405
- variable: string,
406
- path: string,
407
- config: ((config: AppConfig) => AppConfig) | undefined,
408
- ]
409
- > = [
410
- ["federation grants", "FEDERATION_GRANTS_ENABLED", "/oauth/federation-grants", undefined],
411
- [
412
- "the Google federation",
413
- "CORE_FEDERATIONS_GOOGLE_ENABLED",
414
- "/session/oauth/federation/google",
415
- undefined,
416
- ],
417
- [
418
- "the OIDC federation",
419
- "CORE_FEDERATIONS_OIDC_ENABLED",
420
- "/session/oauth/federation/oidc",
421
- withoutConnections,
422
- ],
423
- ];
424
-
425
- it.each(FEATURE_SWITCHES)(
426
- "%s off: its route answers 404 and the document does not change",
427
- async (_feature, variable, path, config) => {
428
- const on = await boot(config ? { config } : {});
429
- const before = (await request(on.app).get(DISCOVERY_PATHS[0])).body;
430
- await on.handle.dispose();
431
- current = undefined;
432
-
433
- const { app } = await boot({ env: { ...SINGLE_ENV, [variable]: "false" }, config });
434
- const after = (await request(app).get(DISCOVERY_PATHS[0])).body;
435
- expect(after).toEqual(before);
436
- const res = path.startsWith("/oauth/")
437
- ? await request(app).post(path).set("Authorization", basic(WORKER)).send({})
438
- : await request(app).get(path);
439
- expect(res.status).toBe(404);
440
- },
441
- );
442
-
443
- it("the OIDC federation off while a grant connection names it is refused at boot, naming both", async () => {
444
- await expect(
445
- compose({ env: { ...SINGLE_ENV, CORE_FEDERATIONS_OIDC_ENABLED: "false" } }),
446
- ).rejects.toSatisfy((err: unknown) => {
447
- const e = err as { name?: string; cause?: { message?: string } };
448
- return (
449
- e.name === "BootError" &&
450
- /connections\.calendar: federation "oidc" is configured but disabled/.test(
451
- e.cause?.message ?? "",
452
- )
453
- );
454
- });
455
- });
456
- });
457
-
458
- // ---------------------------------------------------------------------------
459
- // Routes
460
- // ---------------------------------------------------------------------------
461
-
462
- describe("every module's primary route answers in the one app", () => {
463
- it("authorization code with PKCE, end to end, then refresh", async () => {
464
- const { app } = await boot();
465
- const { res: loggedIn, cookies } = await login(app);
466
- expect(loggedIn.status).toBe(200);
467
- const authorized = await authorize(app, cookies);
468
- expect(authorized.status).toBe(302);
469
- expect(authorized.headers.location).toMatch(new RegExp(`^${WEB.redirectUri}\\?code=`));
470
- const tokens = await redeem(app, codeFrom(authorized));
471
- expect(tokens.status).toBe(200);
472
- expect(tokens.body).toMatchObject({ token_type: "Bearer" });
473
- for (const field of ["access_token", "id_token", "refresh_token"]) {
474
- expect(typeof tokens.body[field], field).toBe("string");
475
- }
476
-
477
- const refreshed = await request(app)
478
- .post("/oauth/token")
479
- .set("Authorization", basic(WEB))
480
- .type("form")
481
- .send({ grant_type: "refresh_token", refresh_token: tokens.body.refresh_token });
482
- expect(refreshed.status).toBe(200);
483
- expect(refreshed.body.refresh_token).not.toBe(tokens.body.refresh_token);
484
- });
485
-
486
- it("client_credentials", async () => {
487
- const { app } = await boot();
488
- const res = await request(app)
489
- .post("/oauth/token")
490
- .set("Authorization", basic(M2M))
491
- .type("form")
492
- .send({ grant_type: "client_credentials", scope: "api.read" });
493
- expect(res.status).toBe(200);
494
- expect(res.body).toMatchObject({ token_type: "Bearer", scope: "api.read" });
495
- });
496
-
497
- it("the session grant, from the browser session", async () => {
498
- const { app } = await boot();
499
- const { cookies } = await login(app);
500
- const res = await request(app)
501
- .post("/oauth/token")
502
- .set("Authorization", basic(WEB))
503
- .set("Cookie", cookies)
504
- .type("form")
505
- .send({ grant_type: "session", scope: "openid" });
506
- expect(res.status).toBe(200);
507
- expect(typeof res.body.access_token).toBe("string");
508
- });
509
-
510
- it("introspection, userinfo and revocation of one access token", async () => {
511
- const { app } = await boot();
512
- const { access_token } = await webTokens(app);
513
- const introspect = () =>
514
- request(app)
515
- .post("/oauth/introspect")
516
- .set("Authorization", basic(WEB))
517
- .type("form")
518
- .send({ token: access_token });
519
-
520
- const active = await introspect();
521
- expect(active.status).toBe(200);
522
- expect(active.body).toMatchObject({ active: true, sub: ALICE.sub, client_id: WEB.id });
523
-
524
- const userinfo = await request(app)
525
- .get("/oauth/userinfo")
526
- .set("Authorization", `Bearer ${access_token}`);
527
- expect(userinfo.status).toBe(200);
528
- expect(userinfo.body.sub).toBe(ALICE.sub);
529
-
530
- const revoked = await request(app)
531
- .post("/oauth/revoke")
532
- .set("Authorization", basic(WEB))
533
- .type("form")
534
- .send({ token: access_token });
535
- expect(revoked.status).toBe(200);
536
- expect((await introspect()).body).toEqual({ active: false });
537
- });
538
-
539
- it("JWKS publishes the key the tokens are signed under", async () => {
540
- const { app } = await boot();
541
- const { access_token } = await webTokens(app);
542
- const header = JSON.parse(
543
- Buffer.from(access_token.split(".")[0] as string, "base64url").toString(),
544
- ) as { kid: string; alg: string };
545
- const jwks = await request(app).get("/.well-known/jwks.json");
546
- expect(jwks.status).toBe(200);
547
- expect(jwks.body.keys).toEqual([
548
- expect.objectContaining({ kid: header.kid, alg: header.alg, use: "sig" }),
549
- ]);
550
- });
551
-
552
- it("the consent step sends a client that is not first-party to the consent page", async () => {
553
- const { app } = await boot();
554
- const { cookies } = await login(app);
555
- const res = await authorize(app, cookies, THIRD);
556
- expect(res.status).toBe(302);
557
- expect(res.headers.location).toMatch(/^\/consent\?challenge=/);
558
- });
559
-
560
- it.each([
561
- ["oidc", "the OIDC federation"],
562
- ["google", "the Google federation"],
563
- ] as const)(
564
- "a login through %s (%s) ends in a session /authorize accepts",
565
- async (name, _federation) => {
566
- const { app, upstreams } = await boot();
567
- const callback = await (await federatedCallback(app, name, upstreams[name]))();
568
- expect(callback.status).toBe(302);
569
- expect(callback.headers.location).toBe(FEDERATION_LANDING);
570
- const authorized = await authorize(app, cookiesOf(callback));
571
- expect(authorized.status).toBe(302);
572
- expect(authorized.headers.location).toMatch(/\?code=/);
573
- },
574
- );
575
-
576
- // KNOWN DEFECT (the template's federation config, with the session
577
- // package's redirect policy): a federation enabled the way the template
578
- // README documents — its `CORE_FEDERATIONS_OIDC_*` variables — boots, and every
579
- // login through it then ends at the callback in `500 misconfiguration`
580
- // "client URL not configured", after the user has signed in upstream.
581
- // `clientUrl` has no environment form (`application.conf` ships it
582
- // commented out) and nothing at boot asks for it. Either shape of fix
583
- // passes: boot refusing the federation by the missing key, or the login
584
- // completing.
585
- knownDefect(
586
- "a federation enabled from the documented variables alone either refuses to boot or completes a login",
587
- async () => {
588
- const withoutLanding = (config: AppConfig): AppConfig => {
589
- const federations = federationsOf(config) as Record<string, Record<string, unknown>>;
590
- const { clientUrl: _dropped, ...oidc } = federations.oidc ?? {};
591
- return {
592
- ...config,
593
- core: { ...config.core, federations: { ...federations, oidc } },
594
- } as unknown as AppConfig;
595
- };
596
- let composed: Composition;
597
- try {
598
- composed = await boot({ config: withoutLanding });
599
- } catch (err) {
600
- // A refusal: a BootError that names the key.
601
- const e = err as { name?: string; message?: string; cause?: { message?: string } };
602
- expect(e.name).toBe("BootError");
603
- expect(`${e.message ?? ""} ${e.cause?.message ?? ""}`).toMatch(/clientUrl/);
604
- return;
605
- }
606
- // Or a completed login: the callback lands the browser somewhere, and
607
- // the session it made is one /authorize accepts.
608
- const callback = await (
609
- await federatedCallback(composed.app, "oidc", composed.upstreams.oidc)
610
- )();
611
- expect(callback.status).toBe(302);
612
- const authorized = await authorize(composed.app, cookiesOf(callback));
613
- expect(authorized.status).toBe(302);
614
- expect(authorized.headers.location).toMatch(/\?code=/);
615
- },
616
- );
617
-
618
- it("a federation grant is lodged, beside oauthEndpointsModule under /oauth", async () => {
619
- const { app } = await boot();
620
- const res = await lodgeGrant(app);
621
- expect(res.status).toBe(201);
622
- expect(res.body).toMatchObject({ status: "pending" });
623
- expect(res.body.connect_uri).toMatch(
624
- new RegExp(`^${ISSUER}/session/federation-grants/connect\\?request=`),
625
- );
626
- });
627
- });
628
-
629
- // ---------------------------------------------------------------------------
630
- // Bodies and content types
631
- // ---------------------------------------------------------------------------
632
-
633
- describe.each([AS_LISTED, REVERSED] satisfies ModuleOrder[])(
634
- "bodies and content types, modules %s",
635
- (order) => {
636
- // One boot per order: nothing below changes what the next request meets.
637
- let composed: Composition;
638
- beforeAll(async () => {
639
- composed = await compose({ order });
640
- });
641
- afterAll(async () => {
642
- await composed.handle.dispose();
643
- });
644
-
645
- it("mounts in a different order from the other case, under /oauth and under /session", () => {
646
- const ids = composed.handle.routes.map((r) => r.contribution.id);
647
- for (const [neighbour, own] of [
648
- ["federation-grants", "oauth-endpoints"],
649
- ["federation-grants-browser", "session-routes"],
650
- ] as const) {
651
- expect(ids, own).toContain(own);
652
- expect(ids, neighbour).toContain(neighbour);
653
- expect(ids.indexOf(neighbour) < ids.indexOf(own), `${neighbour} / ${own}`).toBe(
654
- order === AS_LISTED,
655
- );
656
- }
657
- });
658
-
659
- it.each(TRANSFERS)(
660
- "federation grants keep their 16 KiB bound beneath oauthEndpointsModule's /oauth, a body sent %s",
661
- async (_transfer, send) => {
662
- for (const type of [JSON_TYPE, FORM_TYPE]) {
663
- const body =
664
- type === JSON_TYPE ? padJson(40 * KIB) : padForm(40 * KIB, "sub=local-subject");
665
- const res = await send(composed.app, "/oauth/federation-grants", type, body, {
666
- authorization: basic(WORKER),
667
- });
668
- expect(res.status, type).toBe(413);
669
- expect(res.body, type).toEqual({
670
- error: "invalid_request",
671
- error_description: "body_too_large",
672
- });
673
- }
674
- },
675
- );
676
-
677
- it.each(TRANSFERS)(
678
- "the grants consent route keeps its 16 KiB bound beneath sessionModule's /session, a body sent %s",
679
- async (_transfer, send) => {
680
- const res = await send(
681
- composed.app,
682
- "/session/federation-grants/consent",
683
- JSON_TYPE,
684
- padJson(40 * KIB),
685
- );
686
- expect(res.status).toBe(413);
687
- expect(res.body).toEqual({ error: "invalid_request", error_description: "body_too_large" });
688
- },
689
- );
690
-
691
- it.each(TRANSFERS)(
692
- "the token endpoint parses past a neighbour's 16 KiB and stops at its own 100 KiB, a body sent %s",
693
- async (_transfer, send) => {
694
- const headers = { authorization: basic(M2M) };
695
- const fields = "grant_type=client_credentials";
696
- const within = await send(
697
- composed.app,
698
- "/oauth/token",
699
- FORM_TYPE,
700
- padForm(50 * KIB, fields),
701
- headers,
702
- );
703
- // Parsed and handed to the grant, which refuses the missing scope.
704
- expect(within.status).toBe(400);
705
- expect(within.body.error).toBe("invalid_scope");
706
- const over = await send(
707
- composed.app,
708
- "/oauth/token",
709
- FORM_TYPE,
710
- padForm(150 * KIB, fields),
711
- headers,
712
- );
713
- expect(over.status).toBe(413);
714
- expect(over.body).toEqual(TOO_LARGE);
715
- },
716
- );
717
-
718
- it.each(TRANSFERS)(
719
- "the login route parses past a neighbour's 16 KiB and stops at its own 100 KiB, a body sent %s",
720
- async (_transfer, send) => {
721
- const fields = `username=${ALICE.username}&password=x`;
722
- const within = await send(
723
- composed.app,
724
- "/session/login",
725
- FORM_TYPE,
726
- padForm(50 * KIB, fields),
727
- );
728
- // Parsed and handed to the route, whose CSRF check refuses it.
729
- expect(within.status).toBe(403);
730
- expect(within.body.error).toBe("access_denied");
731
- const over = await send(
732
- composed.app,
733
- "/session/login",
734
- FORM_TYPE,
735
- padForm(150 * KIB, fields),
736
- );
737
- expect(over.status).toBe(413);
738
- expect(over.body).toEqual(TOO_LARGE);
739
- },
740
- );
741
-
742
- it("each module answers a body it cannot read with its own refusal", async () => {
743
- const { app } = composed;
744
- const grants = { authorization: basic(WORKER) };
745
- expect(
746
- await withLength(app, "/oauth/federation-grants", "text/plain", "hello", grants),
747
- ).toEqual({
748
- status: 415,
749
- body: { error: "invalid_request", error_description: "unsupported_content_type" },
750
- });
751
- expect(await withLength(app, "/oauth/federation-grants", JSON_TYPE, "{nope", grants)).toEqual(
752
- {
753
- status: 400,
754
- body: { error: "invalid_request", error_description: "malformed_body" },
755
- },
756
- );
757
- expect(
758
- await withLength(app, "/session/federation-grants/consent", JSON_TYPE, "{nope"),
759
- ).toEqual({
760
- status: 400,
761
- body: { error: "invalid_request", error_description: "malformed_body" },
762
- });
763
- const token = await withLength(app, "/oauth/token", JSON_TYPE, "{nope", {
764
- authorization: basic(M2M),
765
- });
766
- expect(token.status).toBe(400);
767
- expect(token.body.error).toBe("invalid_request");
768
- });
769
- },
770
- );
771
-
772
- describe("a request body the OAuth endpoints do not parse", () => {
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 —
777
- // leaves it undefined, the handler throws, and the terminal handler answers
778
- // `500 server_error` and logs `unhandled_request_error` for the client's
779
- // own mistake. `/oauth/revoke` checks, and answers 400.
780
- knownDefect.each([
781
- ["/oauth/token", "text/plain"],
782
- ["/oauth/token", "application/xml"],
783
- ["/oauth/introspect", "text/plain"],
784
- ])(
785
- "%s with %s is the client's mistake: 400 invalid_request, nothing logged",
786
- async (path, type) => {
787
- const { app, logger } = await boot();
788
- const res = await request(app)
789
- .post(path)
790
- .set("Authorization", basic(M2M))
791
- .set("Content-Type", type)
792
- .send("grant_type=client_credentials");
793
- expect(res.status).toBe(400);
794
- expect(res.body.error).toBe("invalid_request");
795
- expect(logger.lines.filter((line) => line.level === "error")).toEqual([]);
796
- },
797
- );
798
- });
799
-
800
- // ---------------------------------------------------------------------------
801
- // Outages
802
- // ---------------------------------------------------------------------------
803
-
804
- const tokenRequest = (app: express.Express, client: { id: string; secret: string }) =>
805
- request(app).post("/oauth/token").set("Authorization", basic(client)).type("form");
806
-
807
- /** The code exchange, with the store taken down just before the redeem. */
808
- const codeExchange = async (app: express.Express, outage: Outage) => {
809
- const { cookies } = await login(app);
810
- const code = codeFrom(await authorize(app, cookies));
811
- outage.down = true;
812
- return redeem(app, code);
813
- };
814
-
815
- /** The refresh grant, with the store taken down just before the refresh. */
816
- const refresh = async (app: express.Express, outage: Outage) => {
817
- const { refresh_token } = await webTokens(app);
818
- outage.down = true;
819
- return tokenRequest(app, WEB).send({ grant_type: "refresh_token", refresh_token });
820
- };
821
-
822
- /** A login through the OIDC federation, with the store taken down just before the callback. */
823
- const oidcCallback = async (app: express.Express, outage: Outage, c: Composition) => {
824
- const callback = await federatedCallback(app, "oidc", c.upstreams.oidc);
825
- outage.down = true;
826
- return callback();
827
- };
828
-
829
- const VERIFIER_WARN =
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)";
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
-
842
- const OUTAGES: readonly OutageCase[] = [
843
- {
844
- module: "oauth-authorization",
845
- slot: "codeRepository",
846
- surface: "the code exchange",
847
- run: codeExchange,
848
- answer: { status: 503, error: "temporarily_unavailable" },
849
- event: "authorization_grant_store_unavailable",
850
- },
851
- {
852
- module: "oauth",
853
- slot: "codeRepository",
854
- surface: "/oauth/authorize",
855
- run: async (app, outage) => {
856
- const { cookies } = await login(app);
857
- outage.down = true;
858
- return authorize(app, cookies);
859
- },
860
- answer: { redirect: WEB.redirectUri, error: "temporarily_unavailable" },
861
- event: "authorize_store_unavailable",
862
- },
863
- {
864
- module: "oauth-authorization",
865
- slot: "refreshTokenFamilyStore",
866
- surface: "the refresh grant",
867
- run: refresh,
868
- answer: { status: 503, error: "temporarily_unavailable" },
869
- event: "refresh_token_store_unavailable",
870
- unrelatedWarns: ["jwt_verify_aud_skipped"],
871
- },
872
- {
873
- module: "oauth-authorization",
874
- slot: "subjectRevocation",
875
- surface: "the refresh grant",
876
- run: refresh,
877
- answer: { status: 503, error: "temporarily_unavailable" },
878
- event: "token_verification_unavailable",
879
- unrelatedWarns: ["jwt_verify_aud_skipped"],
880
- defects: { "no-warn": VERIFIER_WARN },
881
- },
882
- {
883
- module: "oauth",
884
- slot: "accessTokenDenylist",
885
- surface: "/oauth/revoke",
886
- run: async (app, outage) => {
887
- const { access_token } = await webTokens(app);
888
- outage.down = true;
889
- return request(app)
890
- .post("/oauth/revoke")
891
- .set("Authorization", basic(WEB))
892
- .type("form")
893
- .send({ token: access_token });
894
- },
895
- answer: { status: 503, error: "temporarily_unavailable" },
896
- event: "revoke_store_unavailable",
897
- },
898
- {
899
- module: "oauth",
900
- slot: "accessTokenDenylist",
901
- surface: "/oauth/introspect",
902
- run: async (app, outage) => {
903
- const { access_token } = await webTokens(app);
904
- outage.down = true;
905
- return request(app)
906
- .post("/oauth/introspect")
907
- .set("Authorization", basic(WEB))
908
- .type("form")
909
- .send({ token: access_token });
910
- },
911
- answer: { status: 503, error: "temporarily_unavailable" },
912
- event: "token_verification_unavailable",
913
- defects: { "no-warn": VERIFIER_WARN },
914
- },
915
- {
916
- module: "oauth",
917
- slot: "userSessionStore",
918
- surface: "/oauth/userinfo",
919
- run: async (app, outage) => {
920
- const { access_token } = await webTokens(app);
921
- outage.down = true;
922
- return request(app).get("/oauth/userinfo").set("Authorization", `Bearer ${access_token}`);
923
- },
924
- answer: { status: 503, error: "temporarily_unavailable" },
925
- event: "userinfo_store_unavailable",
926
- unrelatedWarns: ["jwt_verify_aud_skipped"],
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
- },
969
- {
970
- module: "oauth-session",
971
- slot: "userSessionStore",
972
- surface: "the session grant",
973
- run: async (app, outage) => {
974
- const { cookies } = await login(app);
975
- outage.down = true;
976
- return tokenRequest(app, WEB)
977
- .set("Cookie", cookies)
978
- .send({ grant_type: "session", scope: "openid" });
979
- },
980
- answer: { status: 503, error: "temporarily_unavailable" },
981
- event: "session_admission_unavailable",
982
- },
983
- {
984
- module: "oauth (consent step)",
985
- slot: "consentStore",
986
- surface: "/oauth/authorize for a client that is not first-party",
987
- run: async (app, outage) => {
988
- const { cookies } = await login(app);
989
- outage.down = true;
990
- return authorize(app, cookies, THIRD);
991
- },
992
- answer: { redirect: THIRD.redirectUri, error: "temporarily_unavailable" },
993
- event: "authorize_consent_store_unavailable",
994
- defects: {
995
- "store-field":
996
- "packages/oauth `routes/authorizeConsent.mts`: `authorize_consent_store_unavailable` carries `clientId` and `err` but no `store` / `step` / `site` field",
997
- },
998
- },
999
- {
1000
- module: "oauth (consent step)",
1001
- slot: "pendingConsentStore",
1002
- surface: "/oauth/authorize for a client that is not first-party",
1003
- run: async (app, outage) => {
1004
- const { cookies } = await login(app);
1005
- outage.down = true;
1006
- return authorize(app, cookies, THIRD);
1007
- },
1008
- answer: { redirect: THIRD.redirectUri, error: "temporarily_unavailable" },
1009
- event: "authorize_pending_consent_store_unavailable",
1010
- defects: {
1011
- "store-field":
1012
- "packages/oauth `routes/authorizeConsent.mts`: `authorize_pending_consent_store_unavailable` carries `clientId` and `err` but no `store` / `step` / `site` field",
1013
- },
1014
- },
1015
- {
1016
- module: "session",
1017
- slot: "userSessionStore",
1018
- surface: "/session/login",
1019
- run: async (app, outage) => {
1020
- outage.down = true;
1021
- return (await login(app)).res;
1022
- },
1023
- answer: { status: 503, error: "temporarily_unavailable" },
1024
- event: "login_store_unavailable",
1025
- defects: { "no-warn": ROLLBACK_CLOSE_LOGS },
1026
- },
1027
- {
1028
- module: "session",
1029
- slot: "userRepository",
1030
- surface: "/session/login",
1031
- run: async (app, outage) => {
1032
- outage.down = true;
1033
- return (await login(app)).res;
1034
- },
1035
- answer: { status: 503, error: "temporarily_unavailable" },
1036
- event: "login_store_unavailable",
1037
- },
1038
- {
1039
- module: "oauth (client authentication)",
1040
- slot: "clientRepository",
1041
- surface: "/oauth/token",
1042
- run: async (app, outage) => {
1043
- outage.down = true;
1044
- return tokenRequest(app, M2M).send({ grant_type: "client_credentials", scope: "api.read" });
1045
- },
1046
- answer: { status: 503, error: "temporarily_unavailable" },
1047
- event: "client_repository_unavailable",
1048
- },
1049
- {
1050
- module: "oauth",
1051
- slot: "clientRepository",
1052
- surface: "/oauth/authorize",
1053
- run: async (app, outage) => {
1054
- const { cookies } = await login(app);
1055
- outage.down = true;
1056
- return authorize(app, cookies);
1057
- },
1058
- // JSON, not a redirect: no redirect target is trusted before the client is known.
1059
- answer: { status: 503, error: "temporarily_unavailable" },
1060
- event: "client_repository_unavailable",
1061
- },
1062
- {
1063
- module: "session",
1064
- slot: "subjectSessionIndex",
1065
- surface: "/session/login",
1066
- run: async (app, outage) => {
1067
- outage.down = true;
1068
- return (await login(app)).res;
1069
- },
1070
- // Intended: the login is not denied for a best-effort index write,
1071
- // and the missed write is logged at error because a token minted from
1072
- // this session keeps introspecting active after a credential change —
1073
- // the runbook pages on it.
1074
- answer: { status: 200 },
1075
- event: "subject_session_index_write_failed",
1076
- storeFieldNotRequired:
1077
- "the login succeeds; the line records a missed best-effort write, named by its event, with the subject and the session",
1078
- },
1079
- {
1080
- module: "session",
1081
- slot: "federationTokenStore",
1082
- surface: "the OIDC federation callback",
1083
- run: oidcCallback,
1084
- answer: { status: 503, error: "temporarily_unavailable" },
1085
- event: "federation_callback_store_unavailable",
1086
- defects: { "no-warn": ROLLBACK_CLOSE_LOGS },
1087
- },
1088
- {
1089
- module: "core (rate-limit guard)",
1090
- slot: "rateLimiter",
1091
- surface: "/oauth/token (redis-rate-limiter.failMode = closed)",
1092
- run: async (app, outage) => {
1093
- outage.down = true;
1094
- return tokenRequest(app, M2M).send({ grant_type: "client_credentials", scope: "api.read" });
1095
- },
1096
- answer: { status: 503, error: "service_unavailable" },
1097
- event: "rate_limiter_failed_closed",
1098
- defects: {
1099
- "store-field":
1100
- "core's rate-limit guard (`packages/core/src/ratelimit/guard.mts`): `rate_limiter_failed_closed` names the limiter by `tag`, not `store` / `step` / `site`",
1101
- projection:
1102
- "core's rate-limit guard (`packages/core/src/ratelimit/guard.mts`): the limiter's error is flattened to a string under `error` (the projection's `detail` or `name`), not the projection under `err`",
1103
- },
1104
- },
1105
- {
1106
- module: "federation-grants",
1107
- slot: "federationGrantStore",
1108
- surface: "POST /oauth/federation-grants/:grantId/status",
1109
- run: async (app, outage) => {
1110
- const lodged = await lodgeGrant(app);
1111
- outage.down = true;
1112
- return request(app)
1113
- .post(`/oauth/federation-grants/${lodged.body.grant_id as string}/status`)
1114
- .set("Authorization", basic(WORKER))
1115
- .send({ sub: ALICE.sub });
1116
- },
1117
- answer: { status: 503, error: "temporarily_unavailable" },
1118
- // store "federation_grant", step "inspect", the store error's projection.
1119
- event: "federation_grant_status_unavailable",
1120
- },
1121
- {
1122
- module: "federation-grants",
1123
- slot: "federationGrantIntentStore",
1124
- surface: "POST /oauth/federation-grants (lodging)",
1125
- run: async (app, outage) => {
1126
- outage.down = true;
1127
- return lodgeGrant(app);
1128
- },
1129
- answer: { status: 503, error: "temporarily_unavailable" },
1130
- // store "federation_grant_intent", step "put_intent", the store error's projection.
1131
- event: "federation_grant_lodge_unavailable",
1132
- },
1133
- {
1134
- module: "jwks",
1135
- slot: "keyStore",
1136
- surface: "/.well-known/jwks.json",
1137
- run: async (app, outage) => {
1138
- outage.down = true;
1139
- return request(app).get("/.well-known/jwks.json");
1140
- },
1141
- answer: { status: 503, error: "jwks_unavailable" },
1142
- event: "jwks_unavailable",
1143
- defects: {
1144
- "store-field":
1145
- "core's JWKS route (`packages/core/src/jwks/router.mts`): `jwks_unavailable` carries `algorithm` and `err` but no `store` / `step` / `site` field naming the key store",
1146
- },
1147
- },
1148
- ];
1149
-
1150
- describeOutages(
1151
- "a store or repository outage gets the answer its case pins, and is logged once, at error",
1152
- OUTAGES,
1153
- (outage) => compose({ outage }),
1154
- );