@o3co/create-auth-provider 0.15.0 → 0.17.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 (92) hide show
  1. package/README.ja.md +139 -48
  2. package/README.md +138 -47
  3. package/dist/index.d.mts +33 -10
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +118 -59
  6. package/dist/internal/template-filter.d.mts +7 -10
  7. package/dist/internal/template-filter.d.mts.map +1 -1
  8. package/dist/internal/template-filter.mjs +7 -10
  9. package/package.json +6 -4
  10. package/templates/standalone/.dockerignore +15 -2
  11. package/templates/standalone/.env.example +69 -24
  12. package/templates/standalone/Dockerfile +7 -3
  13. package/templates/standalone/README.ja.md +677 -184
  14. package/templates/standalone/README.md +699 -238
  15. package/templates/standalone/config/application.conf +203 -325
  16. package/templates/standalone/config/clients.yaml +2 -0
  17. package/templates/standalone/config/clients.yaml.example +4 -0
  18. package/templates/standalone/config/development.conf +13 -4
  19. package/templates/standalone/config/mailpit.conf +8 -0
  20. package/templates/standalone/config/production.conf +5 -6
  21. package/templates/standalone/config/reference.conf +399 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +58 -30
  24. package/templates/standalone/docker-compose.yml +13 -3
  25. package/templates/standalone/gitignore +4 -2
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +65 -32
  28. package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1178 -0
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +256 -0
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1119 -0
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
  33. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  34. package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
  35. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  36. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
  37. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +672 -271
  38. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +480 -78
  39. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +102 -66
  40. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +135 -0
  41. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +49 -18
  42. package/templates/standalone/src/__tests__/google-session.fixture.mts +315 -0
  43. package/templates/standalone/src/__tests__/host-routes.test.mts +134 -0
  44. package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
  45. package/templates/standalone/src/__tests__/listen.test.mts +121 -0
  46. package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
  47. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  48. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
  49. package/templates/standalone/src/__tests__/metrics.test.mts +17 -7
  50. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  51. package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
  52. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
  53. package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
  54. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +183 -0
  55. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +135 -0
  56. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
  57. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  58. package/templates/standalone/src/__tests__/replica-safety.test.mts +166 -145
  59. package/templates/standalone/src/__tests__/router-error-answers.test.mts +146 -0
  60. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
  61. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  62. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
  63. package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
  64. package/templates/standalone/src/__tests__/shutdown.test.mts +319 -115
  65. package/templates/standalone/src/__tests__/smoke.test.mts +179 -233
  66. package/templates/standalone/src/__tests__/store-credential.test.mts +317 -0
  67. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  68. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
  69. package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
  70. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
  71. package/templates/standalone/src/adapters.mts +149 -0
  72. package/templates/standalone/src/app.mts +71 -115
  73. package/templates/standalone/src/buildModules.mts +229 -251
  74. package/templates/standalone/src/configPath.mts +422 -29
  75. package/templates/standalone/src/listen.mts +60 -0
  76. package/templates/standalone/src/logger.mts +31 -57
  77. package/templates/standalone/src/metrics.mts +24 -47
  78. package/templates/standalone/src/mfaSwitch.mts +262 -0
  79. package/templates/standalone/src/modules.mts +480 -464
  80. package/templates/standalone/src/rootRenames.mts +79 -0
  81. package/templates/standalone/src/routes.mts +89 -0
  82. package/templates/standalone/src/sections.mts +366 -0
  83. package/templates/standalone/src/shutdown.mts +54 -126
  84. package/templates/standalone/tests/index.test.js +2 -2
  85. package/templates/standalone/tests/package-lock.json +1615 -2223
  86. package/templates/standalone/tests/package.json +2 -2
  87. package/templates/standalone/tests/vitest.config.mjs +26 -0
  88. package/templates/standalone/vitest.config.mts +8 -7
  89. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  90. package/templates/versions.json +18 -15
  91. package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
  92. package/templates/standalone/src/terminalError.mts +0 -60
@@ -0,0 +1,480 @@
1
+ /*
2
+ * Copyright 2026 1o1 Co. Ltd.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /**
18
+ * The schemas of the template's own modules' sections, each strict: what
19
+ * `logging`, `http` (with its CORS list), `key-store` and `redis-clients`
20
+ * accept, and how each refuses what it cannot use, before any module reads it.
21
+ */
22
+
23
+ import { MAX_KID_LENGTH } from "@o3co/auth-provider-core";
24
+ import { describe, expect, it } from "vitest";
25
+ import {
26
+ httpSectionSchema,
27
+ inMemoryCodeRepositorySectionSchema,
28
+ keyStoreSectionSchema,
29
+ loggingSectionSchema,
30
+ redisClientsSectionSchema,
31
+ } from "#/sections.mjs";
32
+
33
+ /** The shipped `http` section, with `overrides` laid over it. */
34
+ const http = (overrides: Record<string, unknown> = {}) => ({
35
+ port: 3000,
36
+ trustProxy: false,
37
+ readinessTimeoutMs: 1000,
38
+ cors: { allowedOrigins: [] },
39
+ ...overrides,
40
+ });
41
+
42
+ const parsedTrustProxy = (trustProxy: unknown) => httpSectionSchema.safeParse(http({ trustProxy }));
43
+
44
+ const issuePaths = (result: { success: boolean; error?: { issues: { path: PropertyKey[] }[] } }) =>
45
+ result.success ? [] : (result.error?.issues ?? []).map((issue) => issue.path.join("."));
46
+
47
+ describe("logging", () => {
48
+ it.each(["trace", "debug", "info", "warn", "error", "fatal", "silent"])(
49
+ "accepts the level %s",
50
+ (level) => {
51
+ expect(loggingSectionSchema.parse({ level })).toEqual({ level });
52
+ },
53
+ );
54
+
55
+ it("refuses a level it does not know", () => {
56
+ expect(issuePaths(loggingSectionSchema.safeParse({ level: "verbose" }))).toEqual(["level"]);
57
+ });
58
+
59
+ it("refuses a key it does not declare", () => {
60
+ expect(loggingSectionSchema.safeParse({ level: "info", format: "json" }).success).toBe(false);
61
+ });
62
+ });
63
+
64
+ describe("http.trustProxy — boolean", () => {
65
+ it.each([false, true])("accepts the boolean %s unchanged", (value) => {
66
+ const result = parsedTrustProxy(value);
67
+ expect(result.success).toBe(true);
68
+ if (result.success) expect(result.data.trustProxy).toBe(value);
69
+ });
70
+
71
+ it.each([
72
+ ["true", true],
73
+ ["false", false],
74
+ ])("reads the variable's string %s as a boolean", (raw, expected) => {
75
+ const result = parsedTrustProxy(raw);
76
+ expect(result.success).toBe(true);
77
+ if (result.success) expect(result.data.trustProxy).toBe(expected);
78
+ });
79
+
80
+ it("reads an exported-but-empty variable as `false`, trusting nothing", () => {
81
+ const result = parsedTrustProxy("");
82
+ expect(result.success).toBe(true);
83
+ if (result.success) expect(result.data.trustProxy).toBe(false);
84
+ });
85
+ });
86
+
87
+ describe("http.trustProxy — hop count", () => {
88
+ it.each([0, 1, 3])("accepts the hop count %s", (value) => {
89
+ const result = parsedTrustProxy(value);
90
+ expect(result.success).toBe(true);
91
+ if (result.success) expect(result.data.trustProxy).toBe(value);
92
+ });
93
+
94
+ it("reads a numeric string as a hop count, not as a one-entry address list", () => {
95
+ const result = parsedTrustProxy("2");
96
+ expect(result.success).toBe(true);
97
+ if (result.success) expect(result.data.trustProxy).toBe(2);
98
+ });
99
+
100
+ it.each([
101
+ ["a negative hop count", -1],
102
+ ["a fractional hop count", 1.5],
103
+ ["a hop count above the ceiling", 100_000],
104
+ ])("refuses %s", (_what, value) => {
105
+ expect(parsedTrustProxy(value).success).toBe(false);
106
+ });
107
+ });
108
+
109
+ describe("http.trustProxy — address list", () => {
110
+ it.each([
111
+ [["10.0.0.7", "2001:db8::1"]],
112
+ [["10.0.0.0/8", "fc00::/7"]],
113
+ [["loopback"]],
114
+ [["linklocal"]],
115
+ [["uniquelocal"]],
116
+ ])("accepts %j", (list) => {
117
+ const result = parsedTrustProxy(list);
118
+ expect(result.success).toBe(true);
119
+ if (result.success) expect(result.data.trustProxy).toEqual(list);
120
+ });
121
+
122
+ it("splits a comma-separated string into entries, trimming each", () => {
123
+ const result = parsedTrustProxy("10.0.0.0/8, 192.168.0.0/16");
124
+ expect(result.success).toBe(true);
125
+ if (result.success) expect(result.data.trustProxy).toEqual(["10.0.0.0/8", "192.168.0.0/16"]);
126
+ });
127
+
128
+ it("reads a single entry as a one-entry list", () => {
129
+ const result = parsedTrustProxy("loopback");
130
+ expect(result.success).toBe(true);
131
+ if (result.success) expect(result.data.trustProxy).toEqual(["loopback"]);
132
+ });
133
+
134
+ it("refuses an empty list", () => {
135
+ expect(parsedTrustProxy([]).success).toBe(false);
136
+ });
137
+
138
+ it("refuses a hostname, naming its index", () => {
139
+ expect(issuePaths(parsedTrustProxy(["proxy.internal"]))).toContain("trustProxy.0");
140
+ });
141
+
142
+ it("names the offending index of a longer list", () => {
143
+ expect(issuePaths(parsedTrustProxy(["loopback", "10.0.0.0/8", "10.0.0.0/33"]))).toContain(
144
+ "trustProxy.2",
145
+ );
146
+ });
147
+
148
+ it("refuses dotted-netmask notation, naming the prefix-length form", () => {
149
+ const result = parsedTrustProxy(["10.0.0.0/255.0.0.0"]);
150
+ expect(result.success).toBe(false);
151
+ if (!result.success) {
152
+ expect(result.error.issues.some((issue) => /prefix length/i.test(issue.message))).toBe(true);
153
+ }
154
+ });
155
+
156
+ it.each([[[42]], [null], [{}]])("refuses %j", (value) => {
157
+ expect(parsedTrustProxy(value).success).toBe(false);
158
+ });
159
+
160
+ it("is required", () => {
161
+ const { trustProxy: _dropped, ...rest } = http();
162
+ expect(issuePaths(httpSectionSchema.safeParse(rest))).toContain("trustProxy");
163
+ });
164
+ });
165
+
166
+ describe("http.cors.allowedOrigins", () => {
167
+ const parsed = (allowedOrigins: unknown) =>
168
+ httpSectionSchema.safeParse(http({ cors: { allowedOrigins } }));
169
+ const messagesOf = (result: ReturnType<typeof parsed>): string =>
170
+ result.success ? "" : result.error.issues.map((issue) => issue.message).join("\n");
171
+
172
+ it("accepts the empty list, CORS off", () => {
173
+ const result = parsed([]);
174
+ expect(result.success).toBe(true);
175
+ if (result.success) expect(result.data.cors.allowedOrigins).toEqual([]);
176
+ });
177
+
178
+ it.each([
179
+ "https://app.example.com",
180
+ "https://app.example.com:8443",
181
+ "http://localhost:5173",
182
+ "http://127.0.0.1:5173",
183
+ "http://[::1]:5173",
184
+ ])("accepts the serialized origin %s", (origin) => {
185
+ expect(parsed([origin]).success).toBe(true);
186
+ });
187
+
188
+ it.each([
189
+ ["a trailing slash", "https://app.example.com/"],
190
+ ["an explicit default port", "https://app.example.com:443"],
191
+ ["an uppercase host", "https://APP.example.com"],
192
+ ["a path", "https://app.example.com/callback"],
193
+ ["a query", "https://app.example.com?x=1"],
194
+ ["a fragment", "https://app.example.com#f"],
195
+ ["userinfo", "https://user:pass@app.example.com"],
196
+ ["a wildcard", "https://*.example.com"],
197
+ ["a bare wildcard", "*"],
198
+ ["plaintext off loopback", "http://app.example.com"],
199
+ ["something unparsable", "not-an-origin"],
200
+ ["the literal null origin", "null"],
201
+ ["a custom app scheme with no tuple origin", "com.example.app://x"],
202
+ ])("refuses %s", (_label, origin) => {
203
+ expect(parsed([origin]).success).toBe(false);
204
+ });
205
+
206
+ it("names the key and the index of an entry that could never match", () => {
207
+ const result = parsed(["https://ok.example.com", "https://app.example.com/"]);
208
+ expect(messagesOf(result)).toContain("http.cors.allowedOrigins[1]");
209
+ expect(issuePaths(result)).toEqual(["cors.allowedOrigins.1"]);
210
+ });
211
+
212
+ it("suggests the serialized form an entry was probably meant to be", () => {
213
+ expect(messagesOf(parsed(["https://app.example.com/"]))).toContain('"https://app.example.com"');
214
+ });
215
+
216
+ it("says a wildcard matches nothing, rather than only refusing it", () => {
217
+ expect(messagesOf(parsed(["https://*.example.com"]))).toMatch(/exact string equality/);
218
+ });
219
+
220
+ it("splits the variable's comma-separated string, trimming each entry and dropping a trailing comma", () => {
221
+ const result = parsed(" https://app.example.com , http://localhost:5173 ,");
222
+ expect(result.success).toBe(true);
223
+ if (result.success) {
224
+ expect(result.data.cors.allowedOrigins).toEqual([
225
+ "https://app.example.com",
226
+ "http://localhost:5173",
227
+ ]);
228
+ }
229
+ });
230
+
231
+ it("reads an exported-but-empty variable, and null, as no origins", () => {
232
+ for (const value of ["", null]) {
233
+ const result = parsed(value);
234
+ expect(result.success).toBe(true);
235
+ if (result.success) expect(result.data.cors.allowedOrigins).toEqual([]);
236
+ }
237
+ });
238
+
239
+ it("checks every entry the string carried", () => {
240
+ expect(parsed("https://app.example.com,https://*.example.com").success).toBe(false);
241
+ });
242
+
243
+ it.each([
244
+ ["a number", 42],
245
+ ["an object", { origin: "https://app.example.com" }],
246
+ ["a boolean", true],
247
+ ])("refuses %s, naming the key, both spellings and the new variable", (_label, value) => {
248
+ const result = parsed(value);
249
+ expect(issuePaths(result)).toEqual(["cors.allowedOrigins"]);
250
+ expect(messagesOf(result)).toMatch(/list of origins/);
251
+ expect(messagesOf(result)).toMatch(/comma-separated string/);
252
+ expect(messagesOf(result)).toContain("HTTP_CORS_ALLOWED_ORIGINS");
253
+ });
254
+ });
255
+
256
+ describe("http — the rest of the section", () => {
257
+ it("reads the port and the readiness deadline from the variables' strings", () => {
258
+ expect(
259
+ httpSectionSchema.parse(http({ port: "8080", readinessTimeoutMs: "1500" })),
260
+ ).toMatchObject({ port: 8080, readinessTimeoutMs: 1500 });
261
+ });
262
+
263
+ it.each([
264
+ ["a blank variable", ""],
265
+ ["zero", 0],
266
+ ["a negative deadline", -1],
267
+ ["a fractional deadline", 1.5],
268
+ ["a deadline beyond Node's timer range", 2_147_483_648],
269
+ ])("refuses %s as the readiness deadline", (_what, readinessTimeoutMs) => {
270
+ expect(issuePaths(httpSectionSchema.safeParse(http({ readinessTimeoutMs })))).toEqual([
271
+ "readinessTimeoutMs",
272
+ ]);
273
+ });
274
+
275
+ describe("the readiness deadline is read in decimal digits", () => {
276
+ const MESSAGE = "must be a whole number from 1 to 2147483647, in decimal digits";
277
+ const messagesAt = (readinessTimeoutMs: unknown) =>
278
+ (httpSectionSchema.safeParse(http({ readinessTimeoutMs })).error?.issues ?? [])
279
+ .filter((issue) => issue.path.join(".") === "readinessTimeoutMs")
280
+ .map((issue) => issue.message);
281
+
282
+ it.each([
283
+ ["0x10"],
284
+ ["1e3"],
285
+ ["5.0"],
286
+ ["+5"],
287
+ [true],
288
+ [""],
289
+ [" "],
290
+ ["Infinity"],
291
+ [0],
292
+ [2_147_483_648],
293
+ ])("refuses %j, naming the range", (readinessTimeoutMs) => {
294
+ expect(messagesAt(readinessTimeoutMs)).toEqual([MESSAGE]);
295
+ });
296
+
297
+ it.each([[60], ["60"], [" 60 "]])("reads %j as 60", (readinessTimeoutMs) => {
298
+ expect(httpSectionSchema.parse(http({ readinessTimeoutMs })).readinessTimeoutMs).toBe(60);
299
+ });
300
+ });
301
+
302
+ it.each([
303
+ [8080, 8080],
304
+ [" 8080 ", 8080],
305
+ [65_535, 65_535],
306
+ ])("reads the port %j as %j", (port, read) => {
307
+ expect(httpSectionSchema.parse(http({ port })).port).toBe(read);
308
+ });
309
+
310
+ it.each([0, "0"])("keeps an explicit %j as the port, the OS choosing a free one", (port) => {
311
+ expect(httpSectionSchema.parse(http({ port })).port).toBe(0);
312
+ });
313
+
314
+ it.each([
315
+ ["the empty string an exported-but-empty HTTP_PORT carries", ""],
316
+ ["a blank variable", " "],
317
+ ["hexadecimal", "0x50"],
318
+ ["an exponent", "8e3"],
319
+ ["a sign", "+80"],
320
+ ["a negative number", -1],
321
+ ["a fraction", 80.5],
322
+ ["a number above 65535", 65_536],
323
+ ["a number above 65535, as the variable's string", "65536"],
324
+ ["null", null],
325
+ ["true", true],
326
+ ])("refuses %s as the port, naming http.port and HTTP_PORT", (_what, port) => {
327
+ const result = httpSectionSchema.safeParse(http({ port }));
328
+ expect(issuePaths(result)).toEqual(["port"]);
329
+ const message = result.error?.issues.map((issue) => issue.message).join("\n");
330
+ expect(message).toContain("http.port");
331
+ expect(message).toContain("HTTP_PORT");
332
+ });
333
+
334
+ it.each([
335
+ ["a key the section does not declare", http({ host: "0.0.0.0" })],
336
+ ["a key cors does not declare", http({ cors: { allowedOrigins: [], credentials: true } })],
337
+ ])("refuses %s", (_what, section) => {
338
+ expect(httpSectionSchema.safeParse(section).success).toBe(false);
339
+ });
340
+ });
341
+
342
+ /** A `local` block with every field an HS256 one takes, `overrides` laid over it. */
343
+ const hs256 = (overrides: Record<string, unknown> = {}) => ({
344
+ algorithm: "HS256",
345
+ kid: "v0",
346
+ secret: "s3cret",
347
+ previousSecrets: [],
348
+ ...overrides,
349
+ });
350
+
351
+ describe("key-store", () => {
352
+ it("accepts provider local with its local block", () => {
353
+ expect(keyStoreSectionSchema.parse({ provider: "local", local: hs256() })).toEqual({
354
+ provider: "local",
355
+ local: hs256(),
356
+ });
357
+ });
358
+
359
+ it("requires provider", () => {
360
+ expect(issuePaths(keyStoreSectionSchema.safeParse({ local: hs256() }))).toContain("provider");
361
+ });
362
+
363
+ it("refuses a block the section does not declare", () => {
364
+ expect(
365
+ keyStoreSectionSchema.safeParse({ provider: "kms", kms: { keyArn: "arn:aws:..." } }).success,
366
+ ).toBe(false);
367
+ });
368
+
369
+ it("leaves the key material to the key store: HS256 without a secret parses", () => {
370
+ expect(
371
+ keyStoreSectionSchema.safeParse({
372
+ provider: "local",
373
+ local: { algorithm: "HS256", kid: "v0", previousSecrets: [] },
374
+ }).success,
375
+ ).toBe(true);
376
+ });
377
+
378
+ it("accepts HS256 rotation through previousSecrets, and refuses previousKeys under HS256", () => {
379
+ const previous = { kid: "v-old", expiresAt: "2099-12-31T00:00:00Z" };
380
+ expect(
381
+ keyStoreSectionSchema.safeParse({
382
+ provider: "local",
383
+ local: hs256({ previousSecrets: [{ ...previous, secret: "old-secret" }] }),
384
+ }).success,
385
+ ).toBe(true);
386
+ expect(
387
+ keyStoreSectionSchema.safeParse({
388
+ provider: "local",
389
+ local: hs256({ previousKeys: [{ ...previous, publicKey: "...pem..." }] }),
390
+ }).success,
391
+ ).toBe(false);
392
+ });
393
+
394
+ it("accepts an asymmetric block carrying the secret the reference binds for every algorithm", () => {
395
+ expect(
396
+ keyStoreSectionSchema.safeParse({
397
+ provider: "local",
398
+ local: { algorithm: "EdDSA", kid: "v0", privateKey: "p", publicKey: "q", secret: "s" },
399
+ }).success,
400
+ ).toBe(true);
401
+ });
402
+
403
+ it("refuses a key an asymmetric block does not declare", () => {
404
+ expect(
405
+ keyStoreSectionSchema.safeParse({
406
+ provider: "local",
407
+ local: { algorithm: "EdDSA", kid: "v0", privateKeyPth: "/keys/p.pem" },
408
+ }).success,
409
+ ).toBe(false);
410
+ });
411
+
412
+ it("refuses an algorithm it does not know", () => {
413
+ expect(
414
+ keyStoreSectionSchema.safeParse({ provider: "local", local: hs256({ algorithm: "none" }) })
415
+ .success,
416
+ ).toBe(false);
417
+ });
418
+
419
+ describe.each([
420
+ ["longer than MAX_KID_LENGTH", "k".repeat(MAX_KID_LENGTH + 1)],
421
+ ["empty", ""],
422
+ ["carrying a line feed", "v1\nv2"],
423
+ ["not a string", 7],
424
+ ])("a kid %s", (_label, kid) => {
425
+ const parse = (local: Record<string, unknown>) =>
426
+ keyStoreSectionSchema.safeParse({ provider: "local", local }).success;
427
+ const ed25519 = { algorithm: "EdDSA", kid: "v0", privateKey: "p", publicKey: "q" };
428
+ const at = "2030-01-01T00:00:00Z";
429
+
430
+ it("is refused as the current kid, and in previousSecrets and previousKeys", () => {
431
+ expect(parse(hs256({ kid }))).toBe(false);
432
+ expect(parse(hs256({ previousSecrets: [{ kid, secret: "s", expiresAt: at }] }))).toBe(false);
433
+ expect(parse({ ...ed25519, kid })).toBe(false);
434
+ expect(parse({ ...ed25519, previousKeys: [{ kid, publicKey: "q", expiresAt: at }] })).toBe(
435
+ false,
436
+ );
437
+ });
438
+ });
439
+ });
440
+
441
+ describe("redis-clients", () => {
442
+ it("accepts a URL, and a password beside it", () => {
443
+ expect(redisClientsSectionSchema.parse({ url: "redis://r:6379", password: "p" })).toEqual({
444
+ url: "redis://r:6379",
445
+ password: "p",
446
+ });
447
+ expect(redisClientsSectionSchema.parse({ url: "redis://r:6379" })).toEqual({
448
+ url: "redis://r:6379",
449
+ });
450
+ });
451
+
452
+ it.each([
453
+ ["no URL", {}],
454
+ ["a null URL", { url: null }],
455
+ ["a key the section does not declare", { url: "redis://r:6379", db: 2 }],
456
+ ])("refuses %s", (_what, section) => {
457
+ expect(redisClientsSectionSchema.safeParse(section).success).toBe(false);
458
+ });
459
+ });
460
+
461
+ describe("the in-process code repository", () => {
462
+ const MESSAGE = "must be a whole number of at least 1, in decimal digits";
463
+ const messagesAt = (defaultExpiresIn: unknown) =>
464
+ (inMemoryCodeRepositorySectionSchema.safeParse({ defaultExpiresIn }).error?.issues ?? [])
465
+ .filter((issue) => issue.path.join(".") === "defaultExpiresIn")
466
+ .map((issue) => issue.message);
467
+
468
+ it.each([["0x10"], ["1e3"], ["5.0"], ["+5"], [true], [""], [" "], ["Infinity"], [0], ["0"]])(
469
+ "refuses %j as defaultExpiresIn, naming the range",
470
+ (defaultExpiresIn) => {
471
+ expect(messagesAt(defaultExpiresIn)).toEqual([MESSAGE]);
472
+ },
473
+ );
474
+
475
+ it.each([[60], ["60"], [" 60 "]])("reads %j as defaultExpiresIn 60", (defaultExpiresIn) => {
476
+ expect(inMemoryCodeRepositorySectionSchema.parse({ defaultExpiresIn }).defaultExpiresIn).toBe(
477
+ 60,
478
+ );
479
+ });
480
+ });
@@ -18,22 +18,16 @@
18
18
  * `POST /session/logout` must invalidate the token the `session` grant minted
19
19
  * from the same browser session.
20
20
  *
21
- * This lives here rather than in `packages/session` or `packages/oauth`
22
- * because it is the one assertion neither package can make alone: the logout
23
- * endpoint is in `@o3co/auth-provider-session`, the introspection and userinfo
24
- * endpoints are in `@o3co/auth-provider-oauth`, and the two are siblings that
25
- * may not import each other. The standalone template is the composition root
26
- * where both are mounted on one app, so it is where the contract between them
27
- * is testable.
21
+ * Neither package can assert this alone: the logout endpoint is in
22
+ * `@o3co/auth-provider-session`, introspection and userinfo are in
23
+ * `@o3co/auth-provider-oauth`, and the two are siblings that may not import
24
+ * each other. This template is the composition root that mounts both.
28
25
  *
29
- * What it pins: #506 stamped `sid` on the `session` grant's access token and
30
- * gave `/oauth/introspect` the session-liveness check `/oauth/userinfo`
31
- * already ran. Both resolve the `UserSession` record — which `/session/logout`
32
- * used to leave alive, because it destroyed the cookie and nothing else. So in
33
- * the BFF / `auth.proxy` injection topology, whose logout IS this endpoint, an
34
- * access token minted from the session kept introspecting `active: true` and
35
- * kept answering at `/userinfo` for its full lifetime after the user logged
36
- * out. Only `/oauth/logout` with an `id_token_hint` was ever closed by #506.
26
+ * The `session` grant stamps `sid` on its access token, and `/oauth/introspect`
27
+ * and `/oauth/userinfo` both check that the `UserSession` it names is alive.
28
+ * In the BFF / `auth.proxy` injection topology, whose logout IS this endpoint,
29
+ * a logout that destroyed only the cookie would leave the token live at both
30
+ * for its full lifetime.
37
31
  */
38
32
 
39
33
  import { generateKeyPairSync } from "node:crypto";
@@ -48,10 +42,13 @@ import {
48
42
  memoryRefreshTokenFamilyStoreModule,
49
43
  registerBuiltinKeyStores,
50
44
  } from "@o3co/auth-provider-core";
45
+ import { coreConfigForTests } from "@o3co/auth-provider-core/testing";
51
46
  import express from "express";
52
47
  import request from "supertest";
53
48
  import { afterEach, describe, expect, it } from "vitest";
54
49
  import { buildModules } from "../buildModules.mjs";
50
+ import type { Switches } from "../configPath.mjs";
51
+ import { capturedRenames, inProcessAdapters } from "./library-references.fixture.mjs";
55
52
 
56
53
  const keyPair = generateKeyPairSync("ed25519", {
57
54
  publicKeyEncoding: { type: "spki", format: "pem" },
@@ -64,22 +61,39 @@ const BASIC = `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("ba
64
61
  const USERNAME = "alice";
65
62
  const PASSWORD = "correct-horse-battery-staple";
66
63
 
67
- const config: AppConfig = {
68
- http: { port: 0, trustProxy: false, readinessTimeoutMs: 1000 },
64
+ /** The `http` module's section, as the hand-built configuration below carries it. */
65
+ const HTTP = {
66
+ port: 0,
67
+ trustProxy: false,
68
+ readinessTimeoutMs: 1000,
69
+ cors: { allowedOrigins: [] as string[] },
70
+ };
71
+
72
+ /** The template modules' sections sit beside core's, so the configuration is wider than `AppConfig`. */
73
+ const config: AppConfig & Record<string, unknown> = {
74
+ // What a resolution under an environment that sets none captures of
75
+ // core's renamed variables.
76
+ ...{
77
+ "renamed-variables": capturedRenames({}),
78
+ },
79
+ http: HTTP,
69
80
  logging: { level: "silent" },
81
+ "key-store": {
82
+ provider: "local",
83
+ local: {
84
+ algorithm: "EdDSA",
85
+ kid: "v0",
86
+ privateKey: keyPair.privateKey,
87
+ publicKey: keyPair.publicKey,
88
+ previousKeys: [],
89
+ },
90
+ },
91
+ // The session requirements this composition expects, as the shipped
92
+ // `application.conf` does (ADR 2026-09-28-session-admission): none.
93
+ ...coreConfigForTests({ federations: { google: { enabled: false } } }),
70
94
  oauth: {
71
95
  jwt: {
72
96
  issuer: "https://auth.test",
73
- signingKey: {
74
- provider: "local",
75
- local: {
76
- algorithm: "EdDSA",
77
- kid: "v0",
78
- privateKey: keyPair.privateKey,
79
- publicKey: keyPair.publicKey,
80
- previousKeys: [],
81
- },
82
- },
83
97
  },
84
98
  accessToken: { expiresIn: 3600 },
85
99
  refreshToken: {
@@ -87,13 +101,12 @@ const config: AppConfig = {
87
101
  unknownFamilyPolicy: "reject" as const,
88
102
  legacyRtPolicy: "reject" as const,
89
103
  },
90
- // The grant this whole test is about: it mints straight from an
91
- // authenticated browser session, which is the BFF topology.
92
- grants: { session: { enabled: true } },
93
104
  oidcMode: "oidc-required",
94
- code: { adapter: "memory" as const },
95
105
  },
96
- session: {
106
+ // The grant this whole test is about: it mints straight from an
107
+ // authenticated browser session, which is the BFF topology.
108
+ "oauth-session": { enabled: true },
109
+ "session-store": {
97
110
  secret: "test-session-secret.at-least-32-bytes.ok",
98
111
  name: "auth.sid",
99
112
  maxAge: 3600000,
@@ -102,20 +115,21 @@ const config: AppConfig = {
102
115
  domain: null,
103
116
  storage: { type: "memory", redis: { url: "redis://localhost:6379" } },
104
117
  },
105
- rateLimit: {
106
- login: { windowMs: 60000, limit: 100 },
107
- failMode: "open",
108
- },
109
- federations: { google: { enabled: false } },
110
- repositories: {
111
- client: { type: "yaml", path: "./config/clients.yaml" },
112
- user: { type: "yaml", path: "./config/users.yaml", timeout: 5000 },
113
- code: { type: "memory", defaultExpiresIn: 600 },
118
+ session: {
119
+ loginPage: { url: "/login" },
120
+ rateLimit: { login: { windowMs: 60000, limit: 100 } },
114
121
  },
115
- endpoints: { login: { url: "/login" } },
116
- cors: { allowedOrigins: [] },
122
+ rateLimit: { failMode: "open" },
123
+ "standalone-in-memory-code-repository": { defaultExpiresIn: 600 },
117
124
  } as unknown as AppConfig;
118
125
 
126
+ /** What phase one hands `buildModules`: the configuration, and every store in process. */
127
+ const switches = {
128
+ ...config,
129
+ adapters: inProcessAdapters(),
130
+ mfaMode: "off",
131
+ } as unknown as Switches;
132
+
119
133
  const testRepositoriesModule = defineModule({
120
134
  name: "test:repositories",
121
135
  provides: {
@@ -154,7 +168,7 @@ const testKeyStoreModule = defineModule({
154
168
  registerBuiltinKeyStores(factory);
155
169
  return factory.create({
156
170
  type: "local",
157
- ...((c as AppConfig).oauth.jwt.signingKey.local ?? {}),
171
+ ...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
158
172
  });
159
173
  },
160
174
  },
@@ -170,7 +184,7 @@ describe("POST /session/logout invalidates the session grant's access token", ()
170
184
 
171
185
  async function buildApp() {
172
186
  const handle = await createApp({
173
- modules: buildModules(config, {
187
+ modules: buildModules(switches, {
174
188
  keyStoreModule: testKeyStoreModule,
175
189
  repositoriesModule: testRepositoriesModule,
176
190
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
@@ -183,7 +197,7 @@ describe("POST /session/logout invalidates the session grant's access token", ()
183
197
  app.use(
184
198
  createReadinessRouter(express, {
185
199
  probes: handle.readinessProbes,
186
- timeoutMs: config.http.readinessTimeoutMs,
200
+ timeoutMs: HTTP.readinessTimeoutMs,
187
201
  }),
188
202
  );
189
203
  app.use(handle.router);
@@ -212,12 +226,10 @@ describe("POST /session/logout invalidates the session grant's access token", ()
212
226
  expect(loginRes.status).toBe(200);
213
227
 
214
228
  const loginCookies = loginRes.headers["set-cookie"] as unknown as string[];
215
- // The session cookie comes from the login response, and so does a FRESH
216
- // CSRF cookie — the login handler reissues one on the regenerated
217
- // session so the follow-up logout needs no second round trip. The
218
- // mechanism is a signed double-submit, so the cookie's value IS the
219
- // token the header has to echo; the pre-login token no longer matches.
220
- const csrfCookiePrefix = `${config.session.name}.csrf=`;
229
+ // The mechanism is a signed double-submit, so the reissued cookie's
230
+ // value IS the token the header has to echo; the pre-login token no
231
+ // longer matches.
232
+ const csrfCookiePrefix = `${(config["session-store"] as { name: string }).name}.csrf=`;
221
233
  const reissued = loginCookies
222
234
  .find((c) => c.startsWith(csrfCookiePrefix))
223
235
  ?.slice(csrfCookiePrefix.length)
@@ -268,9 +280,9 @@ describe("POST /session/logout invalidates the session grant's access token", ()
268
280
  expect(logoutRes.status).toBe(200);
269
281
  expect(logoutRes.body).toMatchObject({ message: "Logged out successfully" });
270
282
 
271
- // After it, the same token no longer authorizes anything. This is the
272
- // assertion that failed before the fix: the `UserSession` record the
273
- // liveness checks resolve outlived the logout.
283
+ // After it, the same token no longer authorizes anything: the
284
+ // `UserSession` record the liveness checks resolve does not outlive
285
+ // the logout.
274
286
  const afterIntrospect = await request(app)
275
287
  .post("/oauth/introspect")
276
288
  .set("Authorization", BASIC)