@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,296 +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
- * The server `request(app)` starts listens on the address supertest dials,
19
- * in this project's own test run. Unpatched, supertest starts its server with
20
- * `app.listen(0)` — the dual-stack wildcard `[::]:P` — and sends the request
21
- * to `127.0.0.1:P`. On macOS the kernel can hand out a `P` another process
22
- * already holds as `127.0.0.1:P`; the request then reaches that process, and
23
- * if it never answers the test hangs until its timeout.
24
- * `vitest.supertest-loopback.mts` (a `setupFiles` entry in
25
- * `vitest.config.mts`) binds `127.0.0.1` instead; these tests fail if that
26
- * wiring is lost.
27
- */
28
-
29
- import http from "node:http";
30
- import net, { type AddressInfo } from "node:net";
31
- import express from "express";
32
- import request from "supertest";
33
- import { describe, expect, it } from "vitest";
34
-
35
- function helloApp() {
36
- const app = express();
37
- app.get("/hello", (_req, res) => {
38
- res.status(200).send("hello");
39
- });
40
- return app;
41
- }
42
-
43
- /** Resolves with the address `server` binds, whoever calls `listen`. */
44
- function boundAddress(server: net.Server): Promise<AddressInfo> {
45
- return new Promise((resolve) => {
46
- server.once("listening", () => resolve(server.address() as AddressInfo));
47
- });
48
- }
49
-
50
- /**
51
- * An app whose `GET /hold/:name` waits in its handler until the test releases
52
- * that name, so a test decides the order in which requests finish.
53
- * `inFlight` holds the names whose handlers have not answered yet.
54
- */
55
- function holdingApp() {
56
- const gates = new Map<
57
- string,
58
- { arrived: PromiseWithResolvers<void>; released: PromiseWithResolvers<void> }
59
- >();
60
- const gate = (name: string) => {
61
- let found = gates.get(name);
62
- if (!found) {
63
- found = { arrived: Promise.withResolvers(), released: Promise.withResolvers() };
64
- gates.set(name, found);
65
- }
66
- return found;
67
- };
68
- const inFlight = new Set<string>();
69
- const app = express();
70
- app.get("/hold/:name", async (req, res) => {
71
- const { name } = req.params;
72
- inFlight.add(name);
73
- gate(name).arrived.resolve();
74
- await gate(name).released.promise;
75
- inFlight.delete(name);
76
- res.status(200).send(name);
77
- });
78
- return {
79
- app,
80
- inFlight,
81
- arrived: (name: string) => gate(name).arrived.promise,
82
- release: (name: string) => gate(name).released.resolve(),
83
- releaseAll: () => {
84
- for (const { released } of gates.values()) released.resolve();
85
- },
86
- };
87
- }
88
-
89
- /**
90
- * Records, for every `close` of `server`, which held requests it was still
91
- * serving. A close that comes too early releases them all, so the test
92
- * finishes and reports it instead of waiting on a close that waits on them.
93
- */
94
- function recordCloses(server: http.Server, held: ReturnType<typeof holdingApp>): string[][] {
95
- const closes: string[][] = [];
96
- const close = server.close.bind(server);
97
- server.close = ((callback?: (err?: Error) => void) => {
98
- closes.push([...held.inFlight]);
99
- held.releaseAll();
100
- return close(callback);
101
- }) as typeof server.close;
102
- return closes;
103
- }
104
-
105
- describe("supertest's own server listens on the loopback address it dials", () => {
106
- it("binds 127.0.0.1, not the dual-stack wildcard", async () => {
107
- const server = http.createServer(helloApp());
108
- const bound = boundAddress(server);
109
-
110
- const res = await request(server).get("/hello");
111
-
112
- expect(res.status).toBe(200);
113
- expect(await bound).toMatchObject({ address: "127.0.0.1", family: "IPv4" });
114
- // supertest still closes the server it started.
115
- expect(server.listening).toBe(false);
116
- });
117
-
118
- it("rejects promptly with the request's own error when the request cannot be built", async () => {
119
- // Node refuses this header value (ERR_INVALID_CHAR) while supertest builds
120
- // the request. With the loopback bind that happens after the bind settles,
121
- // outside the promise the test awaits; the throw must still reject that
122
- // promise, as it does unpatched, and not become an unhandled rejection
123
- // that leaves the request hanging until the test timeout.
124
- const server = http.createServer(helloApp());
125
-
126
- await expect(request(server).get("/hello").set("x-test", "bad\nvalue")).rejects.toMatchObject({
127
- code: "ERR_INVALID_CHAR",
128
- });
129
- // The request never went out, so nothing would close the server it started.
130
- expect(server.listening).toBe(false);
131
- }, 5_000);
132
-
133
- it("hands the same error to an .end() callback", async () => {
134
- const server = http.createServer(helloApp());
135
-
136
- const err = await new Promise<unknown>((resolve) => {
137
- request(server)
138
- .get("/hello")
139
- .set("x-test", "bad\nvalue")
140
- .end((error) => resolve(error));
141
- });
142
-
143
- expect(err).toMatchObject({ code: "ERR_INVALID_CHAR" });
144
- }, 5_000);
145
-
146
- it("serves requests an agent sends together over its one server", async () => {
147
- const agent = request.agent(helloApp());
148
-
149
- const responses = await Promise.all([agent.get("/hello"), agent.get("/hello")]);
150
-
151
- expect(responses.map((res) => res.status)).toEqual([200, 200]);
152
- });
153
-
154
- it("closes an agent's one server after the last request sent over it, not the first", async () => {
155
- // The request built first is the one whose call bound the server. Closing
156
- // the server when that one finishes, as supertest 7.2 does, resets the
157
- // connection of a request still on its way: ECONNRESET on Node 26.
158
- const held = holdingApp();
159
- const server = http.createServer(held.app);
160
- const closes = recordCloses(server, held);
161
- const agent = request.agent(server);
162
-
163
- const first = agent.get("/hold/first");
164
- const second = agent.get("/hold/second");
165
- const both = Promise.all([first, second]);
166
- await Promise.all([held.arrived("first"), held.arrived("second")]);
167
- held.release("first");
168
- await first;
169
- held.release("second");
170
-
171
- expect((await both).map((res) => res.text)).toEqual(["first", "second"]);
172
- expect(closes).toEqual([[]]);
173
- expect(server.listening).toBe(false);
174
- });
175
-
176
- it("keeps it open for a request sent while the server is already serving another", async () => {
177
- const held = holdingApp();
178
- const server = http.createServer(held.app);
179
- const closes = recordCloses(server, held);
180
- const agent = request.agent(server);
181
-
182
- // A Test is sent when it is first awaited or `then`ed; the identity
183
- // `then` sends each one here, before the test waits for its arrival.
184
- const first = agent.get("/hold/first").then((res) => res);
185
- await held.arrived("first");
186
- const late = agent.get("/hold/late").then((res) => res);
187
- await held.arrived("late");
188
- held.release("first");
189
- await first;
190
- held.release("late");
191
-
192
- expect((await late).text).toBe("late");
193
- expect(closes).toEqual([[]]);
194
- expect(server.listening).toBe(false);
195
- });
196
-
197
- it("binds again for a request built before the server was closed, and sent after", async () => {
198
- const agent = request.agent(helloApp());
199
-
200
- const first = agent.get("/hello");
201
- const second = agent.get("/hello");
202
-
203
- expect((await first).status).toBe(200);
204
- expect((await second).status).toBe(200);
205
- });
206
-
207
- it("joins the bind another request made since, for a request built before the server was closed", async () => {
208
- // Built on the first bind, sent while a later request is being served
209
- // on the second: it joins that bind — one server, two requests in
210
- // flight — and the server closes once, after the last of them.
211
- const held = holdingApp();
212
- const server = http.createServer(held.app);
213
- const closes = recordCloses(server, held);
214
- const agent = request.agent(server);
215
-
216
- const early = agent.get("/hold/early");
217
- const first = agent.get("/hold/first").then((res) => res);
218
- await held.arrived("first");
219
- held.release("first");
220
- expect((await first).text).toBe("first");
221
- expect(server.listening).toBe(false);
222
-
223
- const second = agent.get("/hold/second").then((res) => res);
224
- await held.arrived("second");
225
- const earlySent = early.then((res) => res);
226
- await held.arrived("early");
227
- held.release("second");
228
- expect((await second).text).toBe("second");
229
- expect(server.listening).toBe(true);
230
- held.release("early");
231
-
232
- expect((await earlySent).text).toBe("early");
233
- expect(closes).toEqual([[], []]);
234
- expect(server.listening).toBe(false);
235
- });
236
-
237
- it("serves a request built on a bind that failed, once another request has bound the server again", async () => {
238
- // The first `listen` lands on a port another socket holds; the bind
239
- // fails, and the request built on it is not sent until a later request
240
- // has bound the server on a free port. It joins that bind rather than
241
- // failing with the error of the one it was built on.
242
- const held: net.Socket[] = [];
243
- const squatter = net.createServer((socket) => {
244
- held.push(socket);
245
- });
246
- await new Promise<void>((resolve) => squatter.listen(0, "127.0.0.1", resolve));
247
- const { port } = squatter.address() as AddressInfo;
248
-
249
- const server = http.createServer(helloApp());
250
- const listen = server.listen.bind(server) as (...args: unknown[]) => net.Server;
251
- let steered = false;
252
- (server as { listen: (...args: unknown[]) => net.Server }).listen = (requested, ...rest) => {
253
- if (steered) return listen(requested, ...rest);
254
- steered = true;
255
- return listen(port, ...rest);
256
- };
257
-
258
- try {
259
- const failed = new Promise<void>((resolve) => server.once("error", () => resolve()));
260
- const builtOnTheFailedBind = request(server).get("/hello");
261
- await failed;
262
- expect((await request(server).get("/hello")).status).toBe(200);
263
- expect((await builtOnTheFailedBind).status).toBe(200);
264
- expect(server.listening).toBe(false);
265
- } finally {
266
- for (const socket of held) socket.destroy();
267
- squatter.close();
268
- }
269
- }, 5_000);
270
-
271
- it("fails the request, instead of hanging on another socket, when 127.0.0.1:P is taken", async () => {
272
- // The collision the kernel produces by chance, produced on purpose: a
273
- // socket that accepts and never answers holds 127.0.0.1:P, and the
274
- // server supertest starts is steered onto P.
275
- const held: net.Socket[] = [];
276
- const squatter = net.createServer((socket) => {
277
- held.push(socket);
278
- });
279
- await new Promise<void>((resolve) => squatter.listen(0, "127.0.0.1", resolve));
280
- const { port } = squatter.address() as AddressInfo;
281
-
282
- const server = http.createServer(helloApp());
283
- const listen = server.listen.bind(server) as (...args: unknown[]) => net.Server;
284
- (server as { listen: (...args: unknown[]) => net.Server }).listen = (_port, ...rest) =>
285
- listen(port, ...rest);
286
-
287
- try {
288
- await expect(request(server).get("/hello")).rejects.toMatchObject({
289
- code: "EADDRINUSE",
290
- });
291
- } finally {
292
- for (const socket of held) socket.destroy();
293
- squatter.close();
294
- }
295
- }, 5_000);
296
- });
@@ -1,265 +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
- import { fileURLToPath } from "node:url";
18
- import type { AppConfig } from "@o3co/auth-provider-core";
19
- import { type OAuthSection, oauthEndpointsModule } from "@o3co/auth-provider-oauth";
20
- import { parseFile } from "@o3co/ts.hocon";
21
- import { describe, expect, it } from "vitest";
22
- import { readAdapters } from "../adapters.mjs";
23
- import { buildModules } from "../buildModules.mjs";
24
- import {
25
- readOwnLayers,
26
- readSwitches,
27
- resolveConfigPaths,
28
- resolveForBoot,
29
- type Switches,
30
- } from "../configPath.mjs";
31
- import { templateReference } from "../modules.mjs";
32
- import { configuredMfaMode } from "./configured-mfa-mode.fixture.mjs";
33
- import { type BothPhases, withSwitches } from "./library-references.fixture.mjs";
34
-
35
- // config/ is two levels above this test file:
36
- // src/__tests__/ → src/ → standalone/ → config/
37
- const configDir = fileURLToPath(new URL("../../config", import.meta.url));
38
-
39
- // Provide the secrets the template's files require. These are
40
- // test-only values — no real keys are embedded here. SESSION_STORE_SECRET
41
- // carries a 256-bit entropy floor, so these clear it (the '.' characters keep
42
- // them outside the base64 alphabet, so the UTF-8 length is what counts).
43
- const testEnv = {
44
- KEY_STORE_LOCAL_SECRET: "test-secret-three-tier.at-least-32-bytes.ok",
45
- OAUTH_JWT_ISSUER: "https://auth.test",
46
- SESSION_STORE_SECRET: "test-session-secret-three-tier.at-least-32-bytes.ok",
47
- };
48
-
49
- /**
50
- * The three tiers under `env`, as `app.mts` reads them: what phase one reads
51
- * of the template's own layers, laid over what phase two hands boot — the
52
- * own layers over the reference of every package the chosen modules come
53
- * from, resolved and unparsed.
54
- */
55
- function buildResolvedConfig(env: string, extraEnv: Record<string, string> = {}): BothPhases {
56
- const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, env);
57
- const own = readOwnLayers([envConfPath, applicationConfPath], {
58
- env: { ...testEnv, ...extraEnv },
59
- });
60
- const switches = readSwitches(own);
61
- return withSwitches(
62
- resolveForBoot(own, buildModules(switches), switches) as unknown as Record<string, unknown>,
63
- switches,
64
- );
65
- }
66
-
67
- /** `oauth.resourceIndicator` as the section's owner, the oauth module, parses it at boot. */
68
- function parsedResourceIndicator(config: AppConfig): { readonly enabled?: unknown } | undefined {
69
- const schema = oauthEndpointsModule.section?.schema;
70
- if (schema === undefined) throw new Error("the oauth module declares no section");
71
- return (schema.parse(config.oauth) as { resourceIndicator?: { enabled?: unknown } })
72
- .resourceIndicator;
73
- }
74
-
75
- // The oauth-authorization module's section as resolved, unparsed: the
76
- // runtime shape is a string from env substitution, a boolean from a literal.
77
- type GrantEntry = { enabled?: unknown };
78
- const grants = (config: AppConfig) =>
79
- ((config["oauth-authorization"] as { grants?: unknown } | undefined)?.grants as
80
- | Record<string, GrantEntry | undefined>
81
- | undefined) ?? {};
82
-
83
- /** `oauth {}` as the resolution leaves it, read as the shape the oauth module parses. */
84
- const oauthOf = (config: AppConfig): OAuthSection => config.oauth as OAuthSection;
85
-
86
- describe("three-tier HOCON resolution (env → application.conf → reference.conf)", () => {
87
- it("template application.conf wins over reference.conf for grant.enabled", () => {
88
- const config = buildResolvedConfig("development");
89
- // Template's application.conf sets authorizationCode.enabled = true,
90
- // so the resolved value is true whatever reference.conf says.
91
- expect(grants(config).authorizationCode?.enabled).toBe(true);
92
- });
93
-
94
- it("reference.conf default reaches resolved config when template omits the key", () => {
95
- const config = buildResolvedConfig("development");
96
- // nonce.maxLength is library-owned in both layers (template doesn't
97
- // override it). Verifies precedence falls through.
98
- expect(oauthOf(config).nonce?.maxLength).toBe(256);
99
- });
100
-
101
- it("env var at template layer can disable a template-enabled grant (precedence: env-override line must be repeated)", () => {
102
- // The env-override line is repeated at the template layer alongside
103
- // `enabled = true`; without it, the package reference's substitution is
104
- // shadowed by the template's literal `true`. The value stays a string
105
- // until boot: the module reads the switch with its own schema, `"false"`
106
- // as off.
107
- const config = buildResolvedConfig("development", {
108
- OAUTH_AUTHORIZATION_GRANTS_AUTHORIZATION_CODE_ENABLED: "false",
109
- });
110
- expect(grants(config).authorizationCode?.enabled).toBe("false");
111
- });
112
-
113
- it("reads the MFA switch, mfaMode, as the files write it where MFA_MODE is unset, and installs no MFA module under MFA_MODE=off", () => {
114
- // The template's reference.conf ships the switch required and binds
115
- // MFA_MODE; a project's application.conf may write it over that.
116
- const mfaNames = (config: Switches) =>
117
- buildModules(config)
118
- .map((m) => m.name)
119
- .filter((name) => /mfa/i.test(name));
120
- const shipped = buildResolvedConfig("development");
121
- const configured = configuredMfaMode("development");
122
- expect(shipped.mfaMode).toBe(configured);
123
- if (configured === "off") expect(mfaNames(shipped)).toEqual([]);
124
- else expect(mfaNames(shipped)).toContain("mfa");
125
- const off = buildResolvedConfig("development", { MFA_MODE: "off" });
126
- expect(off.mfaMode).toBe("off");
127
- expect(mfaNames(off)).toEqual([]);
128
- const optional = buildResolvedConfig("development", { MFA_MODE: "optional" });
129
- expect(optional.mfaMode).toBe("optional");
130
- expect(mfaNames(optional)).toContain("mfa");
131
- });
132
-
133
- it("core's reference.conf ships no rateLimit.failMode: it is the Redis limiter's own key", () => {
134
- const config = buildResolvedConfig("development");
135
- expect(config).not.toHaveProperty("rateLimit.failMode");
136
- });
137
-
138
- it("template application.conf ships clientCredentials.enabled off", () => {
139
- // The template writes every grant switch it reads before boot, so the
140
- // value phase one reads is the one boot parses.
141
- const config = buildResolvedConfig("development");
142
- expect(grants(config).clientCredentials?.enabled).toBe(false);
143
- });
144
-
145
- it("reference.conf default for oauth.resourceIndicator.enabled is false", () => {
146
- // reference.conf must ship the literal `false` anchor so that the
147
- // schema coercion (coerceBooleanFromEnv) can produce a boolean value.
148
- // Without the HOCON block the field resolves to undefined.
149
- const config = buildResolvedConfig("development");
150
- expect(oauthOf(config).resourceIndicator?.enabled).toBe(false);
151
- expect(parsedResourceIndicator(config)?.enabled).toBe(false);
152
- });
153
-
154
- it("env var OAUTH_RESOURCE_INDICATOR_ENABLED=true reaches resolved config and coerces to boolean true", () => {
155
- // HOCON env-substitution returns a string. The reference.conf anchor
156
- // (`enabled = ${?OAUTH_RESOURCE_INDICATOR_ENABLED}`) lets the env var
157
- // reach the resolved layer; the schema's coerceBooleanFromEnv turns
158
- // "true" → true so `=== true` guards in grant handlers work correctly.
159
- const config = buildResolvedConfig("development", {
160
- OAUTH_RESOURCE_INDICATOR_ENABLED: "true",
161
- });
162
- expect(oauthOf(config).resourceIndicator?.enabled).toBe("true");
163
- expect(parsedResourceIndicator(config)?.enabled).toBe(true);
164
- });
165
-
166
- // The selection is the composition root's own, `adapters.auditSink`,
167
- // which phase one reads over the template's own layers. These assertions
168
- // run the real merge, so they fail if a layer stops carrying the key.
169
- describe("the audit sink as the shipped artifact resolves it", () => {
170
- it("resolves adapters.auditSink to the template's logger sink with nothing set", () => {
171
- const config = buildResolvedConfig("production");
172
- expect(config.adapters.auditSink).toBe("logger");
173
- });
174
-
175
- it("wires exactly one auditSink provider for that resolved config", () => {
176
- const modules = buildModules(buildResolvedConfig("production"));
177
- const providers = modules.filter((m) => Object.keys(m.provides ?? {}).includes("auditSink"));
178
- expect(providers).toHaveLength(1);
179
- });
180
-
181
- it("lets an operator select core's built-in console sink by env var", () => {
182
- const config = buildResolvedConfig("production", { ADAPTERS_AUDIT_SINK: "console" });
183
- expect(config.adapters.auditSink).toBe("console");
184
- });
185
-
186
- it("the template's reference.conf alone selects a sink, not a drop", () => {
187
- // A composition resolved against the template's reference alone
188
- // still lands on a sink: an application.conf that forgets the
189
- // selection must not thereby lose its audit trail.
190
- const referenceOnly = parseFile(fileURLToPath(templateReference()), { env: {} });
191
- expect(readAdapters(referenceOnly.toObject() as Record<string, unknown>, {}).auditSink).toBe(
192
- "logger",
193
- );
194
- });
195
- });
196
-
197
- // The shape the umbrella E2E (o3co/auth) boots: the shipped
198
- // application.conf, `CORE_DEPLOYMENT_MODE=multi`, and one shared ioredis socket.
199
- // A memory denylist under `multi` is refused by the replica-safety guard,
200
- // and NO denylist by core's denylist boot guard, so the template has to
201
- // land on "redis" without the deployment naming it.
202
- describe("access-token revocation as the shipped artifact resolves it", () => {
203
- it("resolves adapters.accessTokenDenylist to redis with nothing set", () => {
204
- const config = buildResolvedConfig("production");
205
- expect(config.adapters.accessTokenDenylist).toBe("redis");
206
- });
207
-
208
- it("resolves oauth.revocation.accessToken to denylist with nothing set", () => {
209
- const config = buildResolvedConfig("production");
210
- expect(oauthOf(config).revocation?.accessToken).toBe("denylist");
211
- });
212
-
213
- it("picks the replica-safe denylist module for that resolved config", () => {
214
- const names = buildModules(buildResolvedConfig("production")).map((m) => m.name);
215
- expect(names).toContain("redis-access-token-denylist");
216
- expect(names).not.toContain("core-access-token-denylist-memory");
217
- });
218
-
219
- it("leaves nothing replica-unsafe under the umbrella E2E's environment", async () => {
220
- // The environment `o3co/auth`'s tests/docker-compose.yml sets. Under
221
- // `CORE_DEPLOYMENT_MODE=multi` the replica-safety guard fails boot naming
222
- // every in-memory shared store, so the denylist has to come out as
223
- // the Redis one from the template's own config: the compose file
224
- // names no denylist variable. Asked of each manifest, as the guard
225
- // does, so a rename cannot quietly invalidate it and the template's
226
- // own memory modules count too.
227
- const { replicaUnsafeReason } = await import("@o3co/auth-provider-core");
228
- const config = buildResolvedConfig("production", {
229
- ADAPTERS_USER_SESSION_STORES: "redis",
230
- ADAPTERS_RATE_LIMITER: "redis",
231
- ADAPTERS_CODE_REPOSITORY: "redis",
232
- // The federation token store defaults to memory, and the memory
233
- // module declares itself replica-unsafe.
234
- ADAPTERS_FEDERATION_TOKEN_STORE: "redis",
235
- REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
236
- // The umbrella sets MFA off.
237
- MFA_MODE: "off",
238
- });
239
- const modules = buildModules(config);
240
- expect(modules.map((m) => m.name)).toContain("redis-access-token-denylist");
241
- // Each module's section at its name: a declaration made from the
242
- // section is answered for it.
243
- const sections = config as unknown as Record<string, unknown>;
244
- for (const module of modules) {
245
- expect(replicaUnsafeReason(module, sections[module.name]), module.name).toBeUndefined();
246
- }
247
- });
248
-
249
- it("lets a single-instance deployment opt down to memory by env var", () => {
250
- const config = buildResolvedConfig("production", {
251
- ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
252
- });
253
- expect(config.adapters.accessTokenDenylist).toBe("memory");
254
- const names = buildModules(config).map((m) => m.name);
255
- expect(names).toContain("core-access-token-denylist-memory");
256
- });
257
-
258
- it("lets a deployment declare access-token revocation unsupported by env var", () => {
259
- const config = buildResolvedConfig("production", {
260
- OAUTH_REVOCATION_ACCESS_TOKEN: "unsupported",
261
- });
262
- expect(oauthOf(config).revocation?.accessToken).toBe("unsupported");
263
- });
264
- });
265
- });