@o3co/create-auth-provider 0.17.0-rc.4 → 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 (110) 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 -913
  19. package/templates/standalone/README.md +0 -1537
  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 -118
  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 -424
  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 -55
  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 -1192
  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 -346
  39. package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
  40. package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
  41. package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
  42. package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
  43. package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
  44. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
  45. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1290
  46. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -784
  47. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -461
  48. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
  49. package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
  50. package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -308
  51. package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
  52. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
  53. package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
  54. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -216
  55. package/templates/standalone/src/__tests__/listen.test.mts +0 -121
  56. package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
  57. package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
  58. package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
  59. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
  60. package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
  61. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
  62. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
  63. package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
  64. package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1512
  65. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
  66. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -214
  67. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
  68. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
  69. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
  70. package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -654
  71. package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
  72. package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
  73. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
  74. package/templates/standalone/src/__tests__/sections.test.mts +0 -504
  75. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -215
  76. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -296
  77. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
  78. package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
  79. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
  80. package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
  81. package/templates/standalone/src/__tests__/smoke.test.mts +0 -634
  82. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -148
  83. package/templates/standalone/src/__tests__/store-credential.test.mts +0 -312
  84. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
  85. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
  86. package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
  87. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -382
  88. package/templates/standalone/src/adapters.mts +0 -162
  89. package/templates/standalone/src/app.mts +0 -136
  90. package/templates/standalone/src/bootRefusal.mts +0 -117
  91. package/templates/standalone/src/buildModules.mts +0 -358
  92. package/templates/standalone/src/configPath.mts +0 -487
  93. package/templates/standalone/src/listen.mts +0 -60
  94. package/templates/standalone/src/logger.mts +0 -80
  95. package/templates/standalone/src/metrics.mts +0 -167
  96. package/templates/standalone/src/mfaSwitch.mts +0 -284
  97. package/templates/standalone/src/modules.mts +0 -705
  98. package/templates/standalone/src/rootRenames.mts +0 -95
  99. package/templates/standalone/src/routes.mts +0 -89
  100. package/templates/standalone/src/sections.mts +0 -377
  101. package/templates/standalone/src/shutdown.mts +0 -213
  102. package/templates/standalone/tests/Makefile +0 -5
  103. package/templates/standalone/tests/index.test.js +0 -283
  104. package/templates/standalone/tests/package-lock.json +0 -1617
  105. package/templates/standalone/tests/package.json +0 -16
  106. package/templates/standalone/tests/vitest.config.mjs +0 -26
  107. package/templates/standalone/tsconfig.json +0 -31
  108. package/templates/standalone/vitest.config.mts +0 -31
  109. package/templates/standalone/vitest.supertest-loopback.mts +0 -278
  110. 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
- });