@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.
- package/README.md +2 -198
- package/main.js +4 -0
- package/package.json +8 -30
- package/README.ja.md +0 -200
- package/dist/cli.d.mts +0 -3
- package/dist/cli.d.mts.map +0 -1
- package/dist/cli.mjs +0 -18
- package/dist/index.d.mts +0 -48
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs +0 -325
- package/dist/internal/template-filter.d.mts +0 -14
- package/dist/internal/template-filter.d.mts.map +0 -1
- package/dist/internal/template-filter.mjs +0 -42
- package/templates/standalone/.dockerignore +0 -23
- package/templates/standalone/.env.example +0 -152
- package/templates/standalone/Dockerfile +0 -125
- package/templates/standalone/Makefile +0 -12
- package/templates/standalone/README.ja.md +0 -913
- package/templates/standalone/README.md +0 -1537
- package/templates/standalone/config/application.conf +0 -351
- package/templates/standalone/config/clients.yaml +0 -4
- package/templates/standalone/config/clients.yaml.example +0 -118
- package/templates/standalone/config/development.conf +0 -17
- package/templates/standalone/config/mailpit.conf +0 -13
- package/templates/standalone/config/production.conf +0 -15
- package/templates/standalone/config/reference.conf +0 -424
- package/templates/standalone/docker-compose.mailpit.yml +0 -39
- package/templates/standalone/docker-compose.production.yml +0 -229
- package/templates/standalone/docker-compose.test.yml +0 -16
- package/templates/standalone/docker-compose.yml +0 -67
- package/templates/standalone/gitignore +0 -23
- package/templates/standalone/package.json +0 -55
- package/templates/standalone/src/__tests__/adapters.test.mts +0 -425
- package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +0 -87
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +0 -1192
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +0 -285
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +0 -1154
- package/templates/standalone/src/__tests__/audit-sink.test.mts +0 -346
- package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
- package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
- package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
- package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
- package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1290
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -784
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -461
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
- package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
- package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -308
- package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
- package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
- package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
- package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -216
- package/templates/standalone/src/__tests__/listen.test.mts +0 -121
- package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
- package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
- package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
- package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
- package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
- package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1512
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
- package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -214
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
- package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -654
- package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
- package/templates/standalone/src/__tests__/sections.test.mts +0 -504
- package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -215
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -296
- package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
- package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
- package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
- package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
- package/templates/standalone/src/__tests__/smoke.test.mts +0 -634
- package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -148
- package/templates/standalone/src/__tests__/store-credential.test.mts +0 -312
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -382
- package/templates/standalone/src/adapters.mts +0 -162
- package/templates/standalone/src/app.mts +0 -136
- package/templates/standalone/src/bootRefusal.mts +0 -117
- package/templates/standalone/src/buildModules.mts +0 -358
- package/templates/standalone/src/configPath.mts +0 -487
- package/templates/standalone/src/listen.mts +0 -60
- package/templates/standalone/src/logger.mts +0 -80
- package/templates/standalone/src/metrics.mts +0 -167
- package/templates/standalone/src/mfaSwitch.mts +0 -284
- package/templates/standalone/src/modules.mts +0 -705
- package/templates/standalone/src/rootRenames.mts +0 -95
- package/templates/standalone/src/routes.mts +0 -89
- package/templates/standalone/src/sections.mts +0 -377
- package/templates/standalone/src/shutdown.mts +0 -213
- package/templates/standalone/tests/Makefile +0 -5
- package/templates/standalone/tests/index.test.js +0 -283
- package/templates/standalone/tests/package-lock.json +0 -1617
- package/templates/standalone/tests/package.json +0 -16
- package/templates/standalone/tests/vitest.config.mjs +0 -26
- package/templates/standalone/tsconfig.json +0 -31
- package/templates/standalone/vitest.config.mts +0 -31
- package/templates/standalone/vitest.supertest-loopback.mts +0 -278
- 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
|
-
});
|