@o3co/create-auth-provider 0.16.0 → 0.17.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +66 -21
- package/README.md +76 -31
- package/dist/index.d.mts +33 -10
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +118 -59
- package/dist/internal/template-filter.d.mts +7 -10
- package/dist/internal/template-filter.d.mts.map +1 -1
- package/dist/internal/template-filter.mjs +7 -10
- package/package.json +6 -4
- package/templates/standalone/.dockerignore +15 -2
- package/templates/standalone/.env.example +59 -26
- package/templates/standalone/Dockerfile +6 -2
- package/templates/standalone/README.ja.md +293 -162
- package/templates/standalone/README.md +533 -199
- package/templates/standalone/config/application.conf +196 -341
- package/templates/standalone/config/clients.yaml +2 -0
- package/templates/standalone/config/clients.yaml.example +4 -0
- package/templates/standalone/config/development.conf +13 -4
- package/templates/standalone/config/mailpit.conf +8 -0
- package/templates/standalone/config/production.conf +5 -6
- package/templates/standalone/config/reference.conf +399 -0
- package/templates/standalone/docker-compose.mailpit.yml +36 -0
- package/templates/standalone/docker-compose.production.yml +58 -30
- package/templates/standalone/docker-compose.yml +13 -3
- package/templates/standalone/gitignore +1 -0
- package/templates/standalone/package.json +9 -6
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +19 -20
- package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +216 -141
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +48 -33
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +77 -58
- package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
- package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
- package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
- package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +656 -269
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +425 -84
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +91 -70
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +7 -5
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +22 -19
- package/templates/standalone/src/__tests__/google-session.fixture.mts +53 -42
- package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
- package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
- package/templates/standalone/src/__tests__/listen.test.mts +6 -13
- package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
- package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
- package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
- package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +7 -11
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
- package/templates/standalone/src/__tests__/replica-safety.test.mts +153 -142
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
- package/templates/standalone/src/__tests__/sections.test.mts +480 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
- package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
- package/templates/standalone/src/__tests__/smoke.test.mts +179 -252
- package/templates/standalone/src/__tests__/store-credential.test.mts +91 -50
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
- package/templates/standalone/src/adapters.mts +149 -0
- package/templates/standalone/src/app.mts +64 -79
- package/templates/standalone/src/buildModules.mts +212 -251
- package/templates/standalone/src/configPath.mts +422 -29
- package/templates/standalone/src/listen.mts +12 -18
- package/templates/standalone/src/logger.mts +31 -60
- package/templates/standalone/src/metrics.mts +24 -47
- package/templates/standalone/src/mfaSwitch.mts +262 -0
- package/templates/standalone/src/modules.mts +458 -476
- package/templates/standalone/src/rootRenames.mts +79 -0
- package/templates/standalone/src/routes.mts +8 -14
- package/templates/standalone/src/sections.mts +366 -0
- package/templates/standalone/src/shutdown.mts +45 -123
- package/templates/standalone/tests/index.test.js +2 -2
- package/templates/standalone/tests/package-lock.json +1615 -2223
- package/templates/standalone/tests/package.json +2 -2
- package/templates/standalone/tests/vitest.config.mjs +26 -0
- package/templates/standalone/vitest.config.mts +8 -7
- package/templates/standalone/vitest.supertest-loopback.mts +97 -69
- package/templates/versions.json +18 -15
|
@@ -15,22 +15,19 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
19
|
-
* every security-relevant event the routes emit
|
|
20
|
-
* `authorize.rejected`, `rate_limit.unavailable`, …)
|
|
21
|
-
*
|
|
22
|
-
* slot is empty, so nothing failed and nothing warned: the deployment simply
|
|
23
|
-
* had no audit trail.
|
|
18
|
+
* The scaffold wires an audit sink. `emitAuditEvent` is a no-op when the slot
|
|
19
|
+
* is empty, so without one every security-relevant event the routes emit
|
|
20
|
+
* (`token.issued.failure`, `authorize.rejected`, `rate_limit.unavailable`, …)
|
|
21
|
+
* is dropped, and nothing fails or warns.
|
|
24
22
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
23
|
+
* Pinned from three sides: the sink implementation, the module that resolves
|
|
24
|
+
* it from config, and the manifest, which must contain exactly one provider
|
|
25
|
+
* for the slot.
|
|
28
26
|
*/
|
|
29
27
|
|
|
30
28
|
import { generateKeyPairSync } from "node:crypto";
|
|
31
29
|
import { readFileSync } from "node:fs";
|
|
32
30
|
import {
|
|
33
|
-
type AppConfig,
|
|
34
31
|
type AuditEvent,
|
|
35
32
|
type AuditSink,
|
|
36
33
|
createApp,
|
|
@@ -42,34 +39,56 @@ import {
|
|
|
42
39
|
memoryRefreshTokenFamilyStoreModule,
|
|
43
40
|
registerBuiltinKeyStores,
|
|
44
41
|
} from "@o3co/auth-provider-core";
|
|
42
|
+
import { coreConfigForTests } from "@o3co/auth-provider-core/testing";
|
|
45
43
|
import express from "express";
|
|
46
44
|
import request from "supertest";
|
|
47
45
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
48
46
|
import { buildModules } from "../buildModules.mjs";
|
|
47
|
+
import type { Switches } from "../configPath.mjs";
|
|
49
48
|
import { createAppLogger, createAuditLogger, createLoggerAuditSink } from "../logger.mjs";
|
|
50
|
-
import {
|
|
49
|
+
import { auditSinkModuleFor } from "../modules.mjs";
|
|
50
|
+
import { capturedRenames, shippedAdapters } from "./library-references.fixture.mjs";
|
|
51
51
|
|
|
52
52
|
const keyPair = generateKeyPairSync("ed25519", {
|
|
53
53
|
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
54
54
|
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
/** The template modules' sections sit beside core's, so the configuration is wider than `AppConfig`. */
|
|
58
|
+
const baseConfig: Switches & Record<string, unknown> = {
|
|
59
|
+
// The shipped selections, every store in this process.
|
|
60
|
+
adapters: {
|
|
61
|
+
...shippedAdapters(),
|
|
62
|
+
accessTokenDenylist: "memory",
|
|
63
|
+
replaySeenSet: "memory",
|
|
64
|
+
codeRepository: "memory",
|
|
65
|
+
userRepository: "yaml",
|
|
66
|
+
},
|
|
67
|
+
mfaMode: "off",
|
|
68
|
+
storeTransport: undefined,
|
|
69
|
+
// What a resolution under an environment that sets none captures of
|
|
70
|
+
// core's renamed variables.
|
|
71
|
+
...{
|
|
72
|
+
"renamed-variables": capturedRenames({}),
|
|
73
|
+
},
|
|
74
|
+
http: { port: 0, trustProxy: false, readinessTimeoutMs: 1000, cors: { allowedOrigins: [] } },
|
|
59
75
|
logging: { level: "silent" },
|
|
76
|
+
"key-store": {
|
|
77
|
+
provider: "local",
|
|
78
|
+
local: {
|
|
79
|
+
algorithm: "EdDSA",
|
|
80
|
+
kid: "v0",
|
|
81
|
+
privateKey: keyPair.privateKey,
|
|
82
|
+
publicKey: keyPair.publicKey,
|
|
83
|
+
previousKeys: [],
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
// The shipped `application.conf` expects no session requirement (ADR
|
|
87
|
+
// 2026-09-28-session-admission).
|
|
88
|
+
...coreConfigForTests({ federations: { google: { enabled: false } } }),
|
|
60
89
|
oauth: {
|
|
61
90
|
jwt: {
|
|
62
91
|
issuer: "https://auth.test",
|
|
63
|
-
signingKey: {
|
|
64
|
-
provider: "local",
|
|
65
|
-
local: {
|
|
66
|
-
algorithm: "EdDSA",
|
|
67
|
-
kid: "v0",
|
|
68
|
-
privateKey: keyPair.privateKey,
|
|
69
|
-
publicKey: keyPair.publicKey,
|
|
70
|
-
previousKeys: [],
|
|
71
|
-
},
|
|
72
|
-
},
|
|
73
92
|
},
|
|
74
93
|
accessToken: { expiresIn: 3600 },
|
|
75
94
|
refreshToken: {
|
|
@@ -79,9 +98,8 @@ const baseConfig: AppConfig = {
|
|
|
79
98
|
},
|
|
80
99
|
grants: {},
|
|
81
100
|
oidcMode: "oidc-required",
|
|
82
|
-
code: { adapter: "memory" as const },
|
|
83
101
|
},
|
|
84
|
-
session: {
|
|
102
|
+
"session-store": {
|
|
85
103
|
secret: "test-session-secret.at-least-32-bytes.ok",
|
|
86
104
|
name: "auth.sid",
|
|
87
105
|
maxAge: 3600000,
|
|
@@ -90,19 +108,12 @@ const baseConfig: AppConfig = {
|
|
|
90
108
|
domain: null,
|
|
91
109
|
storage: { type: "memory", redis: { url: "redis://localhost:6379" } },
|
|
92
110
|
},
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
},
|
|
97
|
-
federations: { google: { enabled: false } },
|
|
98
|
-
repositories: {
|
|
99
|
-
client: { type: "yaml", path: "./config/clients.yaml" },
|
|
100
|
-
user: { type: "yaml", path: "./config/users.yaml", timeout: 5000 },
|
|
101
|
-
code: { type: "memory", defaultExpiresIn: 600 },
|
|
111
|
+
session: {
|
|
112
|
+
loginPage: { url: "/login" },
|
|
113
|
+
rateLimit: { login: { windowMs: 60000, limit: 10 } },
|
|
102
114
|
},
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
audit: { sink: { type: "logger" } },
|
|
115
|
+
rateLimit: { failMode: "open" },
|
|
116
|
+
"standalone-in-memory-code-repository": { defaultExpiresIn: 600 },
|
|
106
117
|
};
|
|
107
118
|
|
|
108
119
|
const CLIENT_ID = "audit-client";
|
|
@@ -143,7 +154,7 @@ const testKeyStoreModule = defineModule({
|
|
|
143
154
|
registerBuiltinKeyStores(factory);
|
|
144
155
|
return factory.create({
|
|
145
156
|
type: "local",
|
|
146
|
-
...((c as
|
|
157
|
+
...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
|
|
147
158
|
});
|
|
148
159
|
},
|
|
149
160
|
},
|
|
@@ -163,14 +174,14 @@ function fakeLogger(): Logger & { info: ReturnType<typeof vi.fn> } {
|
|
|
163
174
|
return self as unknown as Logger & { info: ReturnType<typeof vi.fn> };
|
|
164
175
|
}
|
|
165
176
|
|
|
166
|
-
/** Resolve `auditSink` the way the boot planner does: call the module's provider. */
|
|
167
|
-
async function resolveSink(
|
|
168
|
-
const provider =
|
|
169
|
-
if (!provider) throw new Error("
|
|
170
|
-
return (await provider({
|
|
177
|
+
/** Resolve `auditSink` the way the boot planner does: call the module's provider with its section. */
|
|
178
|
+
async function resolveSink(sink: string, section?: Record<string, unknown>): Promise<AuditSink> {
|
|
179
|
+
const provider = auditSinkModuleFor(sink).provides?.auditSink;
|
|
180
|
+
if (!provider) throw new Error("the audit-sink module must provide the auditSink slot");
|
|
181
|
+
return (await provider({ section } as never)) as AuditSink;
|
|
171
182
|
}
|
|
172
183
|
|
|
173
|
-
describe("
|
|
184
|
+
describe("the template's audit sink", () => {
|
|
174
185
|
describe("createLoggerAuditSink — one event, one line, through the app's own JSON stream", () => {
|
|
175
186
|
it("records the event through the injected logger", async () => {
|
|
176
187
|
const logger = fakeLogger();
|
|
@@ -218,11 +229,10 @@ describe("#287: the template's audit sink", () => {
|
|
|
218
229
|
|
|
219
230
|
describe("the audit trail is not gated by logging.level", () => {
|
|
220
231
|
it("keeps the audit logger at info while the app logger is silenced", () => {
|
|
221
|
-
// An audit trail is evidence, not diagnostics. `
|
|
222
|
-
// ordinary production setting and `silent` is a legitimate one;
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
const appLogger = createAppLogger({ ...baseConfig, logging: { level: "silent" } });
|
|
232
|
+
// An audit trail is evidence, not diagnostics. `LOGGING_LEVEL=warn` is an
|
|
233
|
+
// ordinary production setting and `silent` is a legitimate one;
|
|
234
|
+
// neither may silently drop audit events.
|
|
235
|
+
const appLogger = createAppLogger({ level: "silent" });
|
|
226
236
|
const auditLogger = createAuditLogger();
|
|
227
237
|
expect((appLogger as unknown as { level: string }).level).toBe("silent");
|
|
228
238
|
expect((auditLogger as unknown as { level: string }).level).toBe("info");
|
|
@@ -236,31 +246,24 @@ describe("#287: the template's audit sink", () => {
|
|
|
236
246
|
});
|
|
237
247
|
});
|
|
238
248
|
|
|
239
|
-
describe("
|
|
240
|
-
it("
|
|
241
|
-
expect((await resolveSink(baseConfig)).kind).toBe("logger");
|
|
249
|
+
describe("the audit-sink module — builds the sink adapters.auditSink selects", () => {
|
|
250
|
+
it("builds the logger sink for the shipped selection", async () => {
|
|
251
|
+
expect((await resolveSink(baseConfig.adapters.auditSink)).kind).toBe("logger");
|
|
242
252
|
});
|
|
243
253
|
|
|
244
|
-
it("
|
|
245
|
-
|
|
246
|
-
expect(sink.kind).toBe("console");
|
|
254
|
+
it("builds core's built-in console sink when selected", async () => {
|
|
255
|
+
expect((await resolveSink("console")).kind).toBe("console");
|
|
247
256
|
});
|
|
248
257
|
|
|
249
|
-
it("
|
|
250
|
-
|
|
251
|
-
// nothing about auditing must land on a sink, not on `undefined`.
|
|
252
|
-
const { audit: _audit, ...withoutAudit } = baseConfig;
|
|
253
|
-
const sink = await resolveSink(withoutAudit as AppConfig);
|
|
254
|
-
expect(sink.kind).toBe("logger");
|
|
258
|
+
it("builds a sink with no audit-sink section at all", async () => {
|
|
259
|
+
expect((await resolveSink("logger", undefined)).kind).toBe("logger");
|
|
255
260
|
});
|
|
256
261
|
|
|
257
|
-
it("refuses
|
|
258
|
-
// There is no "none"
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
await expect(
|
|
262
|
-
resolveSink({ ...baseConfig, audit: { sink: { type: "none" } } }),
|
|
263
|
-
).rejects.toThrow(/none/);
|
|
262
|
+
it("refuses a sink no builder is registered under, naming it", async () => {
|
|
263
|
+
// There is no "none" sink. An operator who selects one gets a boot
|
|
264
|
+
// failure naming the sinks that exist, not a silent deployment with
|
|
265
|
+
// no audit trail.
|
|
266
|
+
await expect(resolveSink("none")).rejects.toThrow(/none/);
|
|
264
267
|
});
|
|
265
268
|
});
|
|
266
269
|
|
|
@@ -273,13 +276,13 @@ describe("#287: the template's audit sink", () => {
|
|
|
273
276
|
});
|
|
274
277
|
const providers = modules.filter((m) => Object.keys(m.provides ?? {}).includes("auditSink"));
|
|
275
278
|
expect(providers).toHaveLength(1);
|
|
276
|
-
expect(providers[0]?.name).toBe("
|
|
279
|
+
expect(providers[0]?.name).toBe("audit-sink");
|
|
277
280
|
});
|
|
278
281
|
|
|
279
|
-
it("the shipped
|
|
280
|
-
const conf = readFileSync(new URL("../../config/
|
|
281
|
-
expect(conf).toMatch(/
|
|
282
|
-
expect(conf).not.toMatch(/
|
|
282
|
+
it("the shipped reference.conf selects a sink, and never 'none'", () => {
|
|
283
|
+
const conf = readFileSync(new URL("../../config/reference.conf", import.meta.url), "utf8");
|
|
284
|
+
expect(conf).toMatch(/adapters\s*\{[\s\S]*?auditSink\s*=\s*"logger"/);
|
|
285
|
+
expect(conf).not.toMatch(/auditSink\s*=\s*"none"/);
|
|
283
286
|
});
|
|
284
287
|
});
|
|
285
288
|
|
|
@@ -292,11 +295,10 @@ describe("#287: the template's audit sink", () => {
|
|
|
292
295
|
});
|
|
293
296
|
|
|
294
297
|
it("records token.issued.failure for an unsupported grant_type", async () => {
|
|
295
|
-
//
|
|
296
|
-
// so `emitAuditEvent` returned without recording anything.
|
|
298
|
+
// `emitAuditEvent` records nothing when the slot is empty.
|
|
297
299
|
const logger = fakeLogger();
|
|
298
300
|
const spyAuditModule = defineModule({
|
|
299
|
-
name: "
|
|
301
|
+
name: "audit-sink",
|
|
300
302
|
provides: { auditSink: () => createLoggerAuditSink(logger) },
|
|
301
303
|
});
|
|
302
304
|
const handle = await createApp({
|
|
@@ -16,8 +16,10 @@
|
|
|
16
16
|
|
|
17
17
|
import { existsSync } from "node:fs";
|
|
18
18
|
import { isAbsolute } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { coreReference } from "@o3co/auth-provider-core";
|
|
19
21
|
import { describe, expect, it } from "vitest";
|
|
20
|
-
import { resolveConfigPaths
|
|
22
|
+
import { resolveConfigPaths } from "../configPath.mjs";
|
|
21
23
|
|
|
22
24
|
describe("resolveConfigPaths", () => {
|
|
23
25
|
it("accepts a configDirPath with a trailing slash (regression: fileURLToPath preserves trailing /)", () => {
|
|
@@ -51,17 +53,17 @@ describe("resolveConfigPaths", () => {
|
|
|
51
53
|
});
|
|
52
54
|
});
|
|
53
55
|
|
|
54
|
-
describe("
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
56
|
+
describe("core's reference.conf, as the template layers it", () => {
|
|
57
|
+
// `coreReference()` names the file `@o3co/auth-provider-core` exports as
|
|
58
|
+
// `./reference.conf`: the one a composition root that installed the
|
|
59
|
+
// package would resolve, and the bottom of every layering the template
|
|
60
|
+
// makes (`readSwitches`, `resolveForBoot`).
|
|
61
|
+
it("is the file the package exports as ./reference.conf, and it exists", () => {
|
|
62
|
+
const path = fileURLToPath(coreReference());
|
|
60
63
|
expect(isAbsolute(path)).toBe(true);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("points to a file that exists on disk", () => {
|
|
64
|
-
const path = resolveLibraryReferenceConfPath();
|
|
65
64
|
expect(existsSync(path)).toBe(true);
|
|
65
|
+
expect(path).toBe(
|
|
66
|
+
fileURLToPath(import.meta.resolve("@o3co/auth-provider-core/reference.conf")),
|
|
67
|
+
);
|
|
66
68
|
});
|
|
67
69
|
});
|
|
@@ -0,0 +1,226 @@
|
|
|
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 federations the template ships, in core's own section:
|
|
19
|
+
* `core.federations.google` and `core.federations.oidc`, each bound to the
|
|
20
|
+
* variables named after its paths (`CORE_FEDERATIONS_<NAME>_<KEY>`). Phase
|
|
21
|
+
* one chooses the federation modules from the map there. A variable the
|
|
22
|
+
* template bound before, `FEDERATIONS_<NAME>_<KEY>`, set alone or beside its
|
|
23
|
+
* new name at a different value is refused before any module is chosen,
|
|
24
|
+
* naming the new variable and path and never a value; the two at one value
|
|
25
|
+
* are accepted. The map written at the top level refuses boot, naming its
|
|
26
|
+
* paths under `core.federations`.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
30
|
+
import { tmpdir } from "node:os";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
import { fileURLToPath } from "node:url";
|
|
33
|
+
import { createApp } from "@o3co/auth-provider-core";
|
|
34
|
+
import { afterAll, describe, expect, it } from "vitest";
|
|
35
|
+
import { buildModules } from "#/buildModules.mjs";
|
|
36
|
+
import { readOwnLayers, readSwitches, resolveConfigPaths, resolveForBoot } from "#/configPath.mjs";
|
|
37
|
+
|
|
38
|
+
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
39
|
+
|
|
40
|
+
/** The directories the operator layers are written to, removed after the suite. */
|
|
41
|
+
const operatorDirs: string[] = [];
|
|
42
|
+
afterAll(() => {
|
|
43
|
+
for (const dir of operatorDirs) rmSync(dir, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** The template's own files for the production environment, under an operator's layer when given. */
|
|
47
|
+
function ownFiles(hocon?: string): string[] {
|
|
48
|
+
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
|
|
49
|
+
if (hocon === undefined) return [envConfPath, applicationConfPath];
|
|
50
|
+
const dir = mkdtempSync(join(tmpdir(), "core-federations-"));
|
|
51
|
+
operatorDirs.push(dir);
|
|
52
|
+
const file = join(dir, "operator.conf");
|
|
53
|
+
writeFileSync(file, hocon);
|
|
54
|
+
return [file, envConfPath, applicationConfPath];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** MFA, on by default, switched off: these are about the federations. */
|
|
58
|
+
const MFA_OFF: Readonly<Record<string, string>> = { MFA_MODE: "off" };
|
|
59
|
+
|
|
60
|
+
/** Phase one under `env` and an operator's `hocon`. */
|
|
61
|
+
const switchesFrom = (env: Record<string, string> = {}, hocon?: string) =>
|
|
62
|
+
readSwitches(readOwnLayers(ownFiles(hocon), { env: { ...MFA_OFF, ...env } }));
|
|
63
|
+
|
|
64
|
+
/** What phase one refuses under `env`. */
|
|
65
|
+
function refusal(env: Record<string, string>): Error {
|
|
66
|
+
try {
|
|
67
|
+
switchesFrom(env);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return err as Error;
|
|
70
|
+
}
|
|
71
|
+
throw new Error("phase one read the switches");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The federations phase one reads, by name. */
|
|
75
|
+
const federationsOf = (env: Record<string, string> = {}) =>
|
|
76
|
+
(switchesFrom(env).core as { federations?: Record<string, Record<string, unknown>> } | undefined)
|
|
77
|
+
?.federations ?? {};
|
|
78
|
+
|
|
79
|
+
/** The modules phase one chooses under `env`, by name. */
|
|
80
|
+
const moduleNames = (env: Record<string, string>) =>
|
|
81
|
+
buildModules(switchesFrom(env), { environment: "production" }).map((module) => module.name);
|
|
82
|
+
|
|
83
|
+
/** Each variable the template binds: its key under the federation, the old name and the new. */
|
|
84
|
+
const RENAMED = [
|
|
85
|
+
["google", "enabled", "FEDERATIONS_GOOGLE_ENABLED", "CORE_FEDERATIONS_GOOGLE_ENABLED", "true"],
|
|
86
|
+
[
|
|
87
|
+
"google",
|
|
88
|
+
"clientId",
|
|
89
|
+
"FEDERATIONS_GOOGLE_CLIENT_ID",
|
|
90
|
+
"CORE_FEDERATIONS_GOOGLE_CLIENT_ID",
|
|
91
|
+
"g-id",
|
|
92
|
+
],
|
|
93
|
+
[
|
|
94
|
+
"google",
|
|
95
|
+
"clientSecret",
|
|
96
|
+
"FEDERATIONS_GOOGLE_CLIENT_SECRET",
|
|
97
|
+
"CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET",
|
|
98
|
+
"g-secret",
|
|
99
|
+
],
|
|
100
|
+
[
|
|
101
|
+
"google",
|
|
102
|
+
"callbackURL",
|
|
103
|
+
"FEDERATIONS_GOOGLE_CALLBACK_URL",
|
|
104
|
+
"CORE_FEDERATIONS_GOOGLE_CALLBACK_URL",
|
|
105
|
+
"https://auth.test/session/oauth/federation/google/callback",
|
|
106
|
+
],
|
|
107
|
+
[
|
|
108
|
+
"google",
|
|
109
|
+
"accessType",
|
|
110
|
+
"FEDERATIONS_GOOGLE_ACCESS_TYPE",
|
|
111
|
+
"CORE_FEDERATIONS_GOOGLE_ACCESS_TYPE",
|
|
112
|
+
"online",
|
|
113
|
+
],
|
|
114
|
+
["oidc", "enabled", "FEDERATIONS_OIDC_ENABLED", "CORE_FEDERATIONS_OIDC_ENABLED", "true"],
|
|
115
|
+
["oidc", "issuer", "FEDERATIONS_OIDC_ISSUER", "CORE_FEDERATIONS_OIDC_ISSUER", "https://idp.test"],
|
|
116
|
+
["oidc", "clientId", "FEDERATIONS_OIDC_CLIENT_ID", "CORE_FEDERATIONS_OIDC_CLIENT_ID", "o-id"],
|
|
117
|
+
[
|
|
118
|
+
"oidc",
|
|
119
|
+
"clientSecret",
|
|
120
|
+
"FEDERATIONS_OIDC_CLIENT_SECRET",
|
|
121
|
+
"CORE_FEDERATIONS_OIDC_CLIENT_SECRET",
|
|
122
|
+
"o-secret",
|
|
123
|
+
],
|
|
124
|
+
[
|
|
125
|
+
"oidc",
|
|
126
|
+
"callbackURL",
|
|
127
|
+
"FEDERATIONS_OIDC_CALLBACK_URL",
|
|
128
|
+
"CORE_FEDERATIONS_OIDC_CALLBACK_URL",
|
|
129
|
+
"https://auth.test/session/oauth/federation/oidc/callback",
|
|
130
|
+
],
|
|
131
|
+
] as const;
|
|
132
|
+
|
|
133
|
+
describe("the federations the template ships, under core.federations", () => {
|
|
134
|
+
it("ships google and oidc disabled, oidc of type oidc", () => {
|
|
135
|
+
const federations = federationsOf();
|
|
136
|
+
expect(federations.google?.enabled).toBe(false);
|
|
137
|
+
expect(federations.oidc).toMatchObject({ enabled: false, type: "oidc" });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it.each(RENAMED)(
|
|
141
|
+
"reads core.federations.%s.%s from %s's new name",
|
|
142
|
+
(name, key, _old, variable, value) => {
|
|
143
|
+
expect(String(federationsOf({ [variable]: value })[name]?.[key])).toBe(value);
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
it("chooses the Google federation's modules when CORE_FEDERATIONS_GOOGLE_ENABLED is true, and none while it is not", () => {
|
|
148
|
+
expect(moduleNames({ CORE_FEDERATIONS_GOOGLE_ENABLED: "true" })).toContain("federation-google");
|
|
149
|
+
expect(moduleNames({})).not.toContain("federation-google");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("chooses an OIDC federation's module for an entry of type oidc written under core.federations", () => {
|
|
153
|
+
const names = buildModules(
|
|
154
|
+
switchesFrom(
|
|
155
|
+
{},
|
|
156
|
+
'core.federations.okta { enabled = true, type = "oidc", issuer = "https://okta.test", clientId = "c", clientSecret = "s", callbackURL = "https://auth.test/session/oauth/federation/okta/callback" }\n',
|
|
157
|
+
),
|
|
158
|
+
{ environment: "production" },
|
|
159
|
+
).map((module) => module.name);
|
|
160
|
+
expect(names).toContain("federation-oidc-okta");
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("a variable the template bound before, FEDERATIONS_<NAME>_<KEY>", () => {
|
|
165
|
+
it.each(RENAMED)(
|
|
166
|
+
"core.federations.%s.%s: %s set alone is refused before any module is chosen, naming %s",
|
|
167
|
+
(name, key, old, variable, value) => {
|
|
168
|
+
const err = refusal({ [old]: value });
|
|
169
|
+
expect(err).toBeInstanceOf(RangeError);
|
|
170
|
+
expect(err.message).toContain(`${old} was renamed ${variable}`);
|
|
171
|
+
expect(err.message).toContain(`core.federations.${name}.${key}`);
|
|
172
|
+
},
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
it.each(RENAMED)(
|
|
176
|
+
"core.federations.%s.%s: %s beside its new name at a different value is refused, quoting neither",
|
|
177
|
+
(_name, _key, old, variable) => {
|
|
178
|
+
const err = refusal({ [old]: "old-value-5e2d", [variable]: "new-value-c81a" });
|
|
179
|
+
expect(err.message).toContain(old);
|
|
180
|
+
expect(err.message).not.toContain("old-value-5e2d");
|
|
181
|
+
expect(err.message).not.toContain("new-value-c81a");
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
it.each(RENAMED)(
|
|
186
|
+
"core.federations.%s.%s: %s beside its new name at the same value is read",
|
|
187
|
+
(name, key, old, variable, value) => {
|
|
188
|
+
expect(String(federationsOf({ [old]: value, [variable]: value })[name]?.[key])).toBe(value);
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("the map written at the top level", () => {
|
|
194
|
+
it("refuses boot, naming its paths under core.federations", async () => {
|
|
195
|
+
const own = readOwnLayers(ownFiles("federations.okta.enabled = false\n"), {
|
|
196
|
+
env: {
|
|
197
|
+
...MFA_OFF,
|
|
198
|
+
KEY_STORE_LOCAL_SECRET: "core-federations-secret.at-least-32-bytes.ok",
|
|
199
|
+
OAUTH_JWT_ISSUER: "https://auth.test",
|
|
200
|
+
SESSION_STORE_SECRET: "core-federations-session.at-least-32-bytes.ok",
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
const switches = readSwitches(own);
|
|
204
|
+
const modules = buildModules(switches, { environment: "production" });
|
|
205
|
+
await expect(
|
|
206
|
+
createApp({
|
|
207
|
+
modules,
|
|
208
|
+
bootstrapComponents: {
|
|
209
|
+
config: resolveForBoot(own, modules, switches),
|
|
210
|
+
pathResolver: (s: string) => s,
|
|
211
|
+
} as never,
|
|
212
|
+
}),
|
|
213
|
+
).rejects.toMatchObject({
|
|
214
|
+
reason: "config-path-relocated",
|
|
215
|
+
details: {
|
|
216
|
+
relocated: [
|
|
217
|
+
expect.objectContaining({
|
|
218
|
+
from: "federations.okta.enabled",
|
|
219
|
+
to: "core.federations.okta.enabled",
|
|
220
|
+
environmentVariable: "CORE_FEDERATIONS_OKTA_ENABLED",
|
|
221
|
+
}),
|
|
222
|
+
],
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
});
|