@o3co/create-auth-provider 0.15.0 → 0.16.0

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