@o3co/create-auth-provider 0.17.0-rc.1 → 0.17.0-rc.3
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 +3 -1
- package/README.md +5 -2
- package/package.json +1 -1
- package/templates/standalone/.env.example +7 -5
- package/templates/standalone/README.ja.md +53 -25
- package/templates/standalone/README.md +140 -58
- package/templates/standalone/config/application.conf +41 -36
- package/templates/standalone/config/clients.yaml.example +8 -0
- package/templates/standalone/config/development.conf +2 -2
- package/templates/standalone/config/mailpit.conf +10 -5
- package/templates/standalone/config/reference.conf +30 -5
- package/templates/standalone/docker-compose.mailpit.yml +3 -0
- package/templates/standalone/docker-compose.production.yml +5 -2
- package/templates/standalone/package.json +1 -1
- package/templates/standalone/src/__tests__/adapters.test.mts +92 -25
- package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +112 -98
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +40 -11
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +86 -51
- package/templates/standalone/src/__tests__/audit-sink.test.mts +10 -9
- package/templates/standalone/src/__tests__/config-notices.test.mts +157 -0
- package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +48 -0
- package/templates/standalone/src/__tests__/core-federations.test.mts +57 -31
- package/templates/standalone/src/__tests__/core-sections.test.mts +7 -5
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +1 -1
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +434 -136
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +19 -21
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +9 -15
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +10 -4
- package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
- package/templates/standalone/src/__tests__/google-session.fixture.mts +60 -67
- package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
- package/templates/standalone/src/__tests__/library-references.fixture.mts +51 -46
- package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +81 -16
- package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +8 -4
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +149 -46
- package/templates/standalone/src/__tests__/own-modules.test.mts +78 -23
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +3 -3
- package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +214 -0
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +8 -4
- package/templates/standalone/src/__tests__/replica-safety.test.mts +131 -19
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +25 -23
- package/templates/standalone/src/__tests__/sections.test.mts +24 -0
- package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +3 -7
- package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
- package/templates/standalone/src/__tests__/session-requirements.test.mts +114 -31
- package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +274 -0
- package/templates/standalone/src/__tests__/smoke.test.mts +19 -28
- package/templates/standalone/src/__tests__/startup-log-lines.test.mts +148 -0
- package/templates/standalone/src/__tests__/store-credential.test.mts +8 -13
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +58 -38
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +243 -76
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +23 -31
- package/templates/standalone/src/adapters.mts +31 -18
- package/templates/standalone/src/app.mts +6 -5
- package/templates/standalone/src/bootRefusal.mts +117 -0
- package/templates/standalone/src/buildModules.mts +46 -96
- package/templates/standalone/src/configPath.mts +180 -153
- package/templates/standalone/src/mfaSwitch.mts +49 -27
- package/templates/standalone/src/modules.mts +37 -219
- package/templates/standalone/src/rootRenames.mts +38 -22
- package/templates/standalone/src/sections.mts +13 -2
- package/templates/standalone/tests/index.test.js +4 -3
- package/templates/versions.json +18 -18
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -134
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -202
- package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -144
|
@@ -28,7 +28,6 @@
|
|
|
28
28
|
|
|
29
29
|
import { fileURLToPath } from "node:url";
|
|
30
30
|
import {
|
|
31
|
-
AppConfigSchema,
|
|
32
31
|
createApp,
|
|
33
32
|
createKeyStoreFactory,
|
|
34
33
|
defineModule,
|
|
@@ -39,19 +38,18 @@ import {
|
|
|
39
38
|
} from "@o3co/auth-provider-core";
|
|
40
39
|
import { standardSmtpMailSenderConfigForTests } from "@o3co/auth-provider-standard/testing";
|
|
41
40
|
import { parseFile } from "@o3co/ts.hocon";
|
|
42
|
-
import { validate } from "@o3co/ts.hocon/zod";
|
|
43
41
|
import express from "express";
|
|
44
42
|
import request from "supertest";
|
|
45
43
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
46
44
|
import { buildModules } from "../buildModules.mjs";
|
|
47
|
-
import { resolveConfigPaths
|
|
45
|
+
import { resolveConfigPaths } from "../configPath.mjs";
|
|
48
46
|
import { templateReference } from "../modules.mjs";
|
|
49
47
|
import { installGracefulShutdown } from "../shutdown.mjs";
|
|
50
48
|
import {
|
|
49
|
+
type BothPhases,
|
|
50
|
+
bothPhasesOf,
|
|
51
51
|
capturedRenames,
|
|
52
52
|
libraryLayers,
|
|
53
|
-
rootSectionsOf,
|
|
54
|
-
sectionsCoreDoesNotDeclare,
|
|
55
53
|
} from "./library-references.fixture.mjs";
|
|
56
54
|
|
|
57
55
|
// The same stand-ins `replica-safety.test.mts` boots under: no socket opens,
|
|
@@ -87,6 +85,10 @@ vi.mock("ioredis", () => {
|
|
|
87
85
|
quit: async () => "OK",
|
|
88
86
|
disconnect: () => undefined,
|
|
89
87
|
ping: async () => "PONG",
|
|
88
|
+
// The Redis stores' eviction gate reads the policy from INFO memory: a
|
|
89
|
+
// default server's.
|
|
90
|
+
info: async (section: string) =>
|
|
91
|
+
section === "memory" ? "# Memory\r\nmaxmemory_policy:noeviction\r\n" : null,
|
|
90
92
|
};
|
|
91
93
|
const makeMockRedis = (): object =>
|
|
92
94
|
new Proxy(
|
|
@@ -140,6 +142,7 @@ const ALL_REDIS_ENV: Readonly<Record<string, string>> = {
|
|
|
140
142
|
SESSION_STORE_STORAGE_REDIS_URL: "redis://redis.test:6379",
|
|
141
143
|
ADAPTERS_USER_SESSION_STORES: "redis",
|
|
142
144
|
ADAPTERS_RATE_LIMITER: "redis",
|
|
145
|
+
ADAPTERS_ATTEMPT_COUNTER: "redis",
|
|
143
146
|
ADAPTERS_CODE_REPOSITORY: "redis",
|
|
144
147
|
ADAPTERS_ACCESS_TOKEN_DENYLIST: "redis",
|
|
145
148
|
ADAPTERS_REPLAY_SEEN_SET: "redis",
|
|
@@ -156,29 +159,24 @@ const GRANTS_ON: Readonly<Record<string, string>> = {
|
|
|
156
159
|
FEDERATION_GRANTS_CONSENT_URL: "/consent/grants",
|
|
157
160
|
};
|
|
158
161
|
|
|
159
|
-
function resolveConfig(env: Record<string, string>):
|
|
162
|
+
function resolveConfig(env: Record<string, string>): BothPhases {
|
|
160
163
|
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
|
|
161
164
|
const layers = parseFile(envConfPath, { env })
|
|
162
165
|
.withFallback(parseFile(applicationConfPath, { env }))
|
|
163
166
|
.withFallback(parseFile(fileURLToPath(templateReference()), { env }))
|
|
164
167
|
.withFallback(libraryLayers(env));
|
|
165
|
-
const config =
|
|
166
|
-
...sectionsCoreDoesNotDeclare(layers),
|
|
167
|
-
...validate(layers, AppConfigSchema),
|
|
168
|
-
...rootSectionsOf(layers, env),
|
|
169
|
-
};
|
|
168
|
+
const config = bothPhasesOf(layers, env);
|
|
170
169
|
// The key ring has no environment form (a list of { id, key } is HOCON's);
|
|
171
170
|
// the Redis grant store refuses to construct without one under "required".
|
|
172
171
|
return {
|
|
173
172
|
...config,
|
|
174
|
-
// What the resolution captured of
|
|
175
|
-
// schema's parse drops.
|
|
173
|
+
// What the resolution captured of the renamed variables.
|
|
176
174
|
"renamed-variables": capturedRenames(env),
|
|
177
175
|
"redis-federation-grant-store": {
|
|
178
176
|
...(config["redis-federation-grant-store"] as object | undefined),
|
|
179
177
|
encryptionKeys: [{ id: "k-test", key: ENCRYPTION_KEY }],
|
|
180
178
|
},
|
|
181
|
-
} as
|
|
179
|
+
} as BothPhases;
|
|
182
180
|
}
|
|
183
181
|
|
|
184
182
|
const testRepositoriesModule = defineModule({
|
|
@@ -204,7 +202,7 @@ const testKeyStoreModule = defineModule({
|
|
|
204
202
|
},
|
|
205
203
|
});
|
|
206
204
|
|
|
207
|
-
const modulesFor = (config:
|
|
205
|
+
const modulesFor = (config: BothPhases, memoryOnly = false, environment?: string) =>
|
|
208
206
|
buildModules(config, {
|
|
209
207
|
keyStoreModule: testKeyStoreModule,
|
|
210
208
|
repositoriesModule: testRepositoriesModule,
|
|
@@ -212,13 +210,13 @@ const modulesFor = (config: Switches, memoryOnly = false, environment?: string)
|
|
|
212
210
|
...(environment === undefined ? {} : { environment }),
|
|
213
211
|
});
|
|
214
212
|
|
|
215
|
-
const boot = (config:
|
|
213
|
+
const boot = (config: BothPhases, memoryOnly = false, environment?: string) =>
|
|
216
214
|
createApp({
|
|
217
215
|
modules: modulesFor(config, memoryOnly, environment),
|
|
218
216
|
bootstrapComponents: { config, pathResolver: (s) => s },
|
|
219
217
|
});
|
|
220
218
|
|
|
221
|
-
const names = (config:
|
|
219
|
+
const names = (config: BothPhases, memoryOnly = false) =>
|
|
222
220
|
modulesFor(config, memoryOnly).map((m) => m.name);
|
|
223
221
|
|
|
224
222
|
/**
|
|
@@ -322,7 +320,7 @@ describe("the standalone composes federation grants from its config", () => {
|
|
|
322
320
|
);
|
|
323
321
|
expect(installed).not.toContain("redis-federation-grant-store");
|
|
324
322
|
expect(installed).not.toContain("redis-federation-grant-intent-store");
|
|
325
|
-
// Listed ahead of
|
|
323
|
+
// Listed ahead of oauthEndpointsModule, as the template writes them: the
|
|
326
324
|
// template's choice, since each module parses its own bodies and the
|
|
327
325
|
// order does not change that; the browser half sits after the session
|
|
328
326
|
// middleware by its own `after`.
|
|
@@ -399,7 +397,7 @@ describe("the standalone composes federation grants from its config", () => {
|
|
|
399
397
|
upstreamHardTimeoutMs: 60_000,
|
|
400
398
|
refreshLockTtlMs: 65_000,
|
|
401
399
|
},
|
|
402
|
-
} as
|
|
400
|
+
} as BothPhases;
|
|
403
401
|
handleRef = await boot(raised, true);
|
|
404
402
|
expect(handleRef.cleanupAllowanceMs).toBe(60_000 + 3_000 + 5_000 + 12_000);
|
|
405
403
|
|
|
@@ -619,7 +617,7 @@ describe("the browser consent route parses its own body, with sessionModule list
|
|
|
619
617
|
});
|
|
620
618
|
|
|
621
619
|
/** The standalone's modules, with `sessionModule` moved ahead of the grant modules. */
|
|
622
|
-
const sessionFirst = (config:
|
|
620
|
+
const sessionFirst = (config: BothPhases) => {
|
|
623
621
|
const modules = modulesFor(config, true);
|
|
624
622
|
const session = modules.find((m) => m.name === "session");
|
|
625
623
|
if (session === undefined) throw new Error("sessionModule is not in the standalone's list");
|
|
@@ -693,7 +691,7 @@ describe("the grants consent answer is held to the session module's CSRF guard",
|
|
|
693
691
|
const trusting = {
|
|
694
692
|
...config,
|
|
695
693
|
session: { ...session, csrf: { ...session.csrf, trustedOrigins: [SIBLING] } },
|
|
696
|
-
} as
|
|
694
|
+
} as BothPhases;
|
|
697
695
|
handleRef = await boot(trusting, true);
|
|
698
696
|
return express().set("trust proxy", "loopback").use(handleRef.router);
|
|
699
697
|
};
|
|
@@ -17,9 +17,8 @@
|
|
|
17
17
|
/**
|
|
18
18
|
* A federation grant outlives the session it was agreed through, at both HTTP
|
|
19
19
|
* logout endpoints (ADR 2026-09-17-federation-grants-offline-delegation).
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* by construction. A proof of the helper is not a proof of the endpoints, so
|
|
20
|
+
* Both close the session through core's session lifecycle, whose close work
|
|
21
|
+
* reaches the grant store on no path, by construction. A proof of the helper is not a proof of the endpoints, so
|
|
23
22
|
* this drives both, on the standalone, as a deployment composes them: a grant
|
|
24
23
|
* seeded beside a live browser session, the session's records gone afterwards,
|
|
25
24
|
* the grant and its credential exactly as they were, and `/token` still
|
|
@@ -35,7 +34,6 @@ import { createHmac } from "node:crypto";
|
|
|
35
34
|
import { fileURLToPath } from "node:url";
|
|
36
35
|
import {
|
|
37
36
|
type AppConfig,
|
|
38
|
-
AppConfigSchema,
|
|
39
37
|
createApp,
|
|
40
38
|
createKeyStoreFactory,
|
|
41
39
|
defineModule,
|
|
@@ -51,18 +49,17 @@ import {
|
|
|
51
49
|
type UserSessionStore,
|
|
52
50
|
} from "@o3co/auth-provider-core";
|
|
53
51
|
import { parseFile } from "@o3co/ts.hocon";
|
|
54
|
-
import { validate } from "@o3co/ts.hocon/zod";
|
|
55
52
|
import express from "express";
|
|
56
53
|
import request from "supertest";
|
|
57
54
|
import { afterEach, describe, expect, it } from "vitest";
|
|
58
55
|
import { buildModules } from "../buildModules.mjs";
|
|
59
|
-
import { resolveConfigPaths
|
|
56
|
+
import { resolveConfigPaths } from "../configPath.mjs";
|
|
60
57
|
import { templateReference } from "../modules.mjs";
|
|
61
58
|
import {
|
|
59
|
+
type BothPhases,
|
|
60
|
+
bothPhasesOf,
|
|
62
61
|
capturedRenames,
|
|
63
62
|
libraryLayers,
|
|
64
|
-
rootSectionsOf,
|
|
65
|
-
sectionsCoreDoesNotDeclare,
|
|
66
63
|
} from "./library-references.fixture.mjs";
|
|
67
64
|
|
|
68
65
|
const DAY = 86_400_000;
|
|
@@ -122,19 +119,16 @@ const ENV: Readonly<Record<string, string>> = {
|
|
|
122
119
|
* that have no environment form written over it: the
|
|
123
120
|
* upstream federation the connection names, and the connection.
|
|
124
121
|
*/
|
|
125
|
-
function resolveConfig():
|
|
122
|
+
function resolveConfig(): BothPhases {
|
|
126
123
|
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
|
|
127
124
|
const layers = parseFile(envConfPath, { env: ENV })
|
|
128
125
|
.withFallback(parseFile(applicationConfPath, { env: ENV }))
|
|
129
126
|
.withFallback(parseFile(fileURLToPath(templateReference()), { env: ENV }))
|
|
130
127
|
.withFallback(libraryLayers(ENV));
|
|
131
|
-
const config =
|
|
128
|
+
const config = bothPhasesOf(layers, ENV);
|
|
132
129
|
return {
|
|
133
|
-
...sectionsCoreDoesNotDeclare(layers),
|
|
134
|
-
...rootSectionsOf(layers, ENV),
|
|
135
130
|
...config,
|
|
136
|
-
// What the resolution captured of
|
|
137
|
-
// schema's parse drops.
|
|
131
|
+
// What the resolution captured of the renamed variables.
|
|
138
132
|
"renamed-variables": capturedRenames(ENV),
|
|
139
133
|
core: {
|
|
140
134
|
...config.core,
|
|
@@ -174,7 +168,7 @@ function resolveConfig(): Switches {
|
|
|
174
168
|
},
|
|
175
169
|
},
|
|
176
170
|
},
|
|
177
|
-
} as
|
|
171
|
+
} as BothPhases;
|
|
178
172
|
}
|
|
179
173
|
|
|
180
174
|
const testRepositoriesModule = defineModule({
|
|
@@ -94,7 +94,7 @@ describe("POST /oauth/federation/google/token: an upstream outage never ends the
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
it("keeps them and answers 503 when Google answers 503 with a body naming invalid_grant", async () => {
|
|
97
|
-
const { res, sid, federationTokenStore,
|
|
97
|
+
const { res, sid, federationTokenStore, sessionLifecycle } = await refreshAnswered(503, {
|
|
98
98
|
error: "invalid_grant",
|
|
99
99
|
error_description: "Backend unavailable",
|
|
100
100
|
});
|
|
@@ -107,7 +107,10 @@ describe("POST /oauth/federation/google/token: an upstream outage never ends the
|
|
|
107
107
|
expect((await federationTokenStore.get(sid, "google"))?.refreshToken).toBe(
|
|
108
108
|
STORED_REFRESH_TOKEN,
|
|
109
109
|
);
|
|
110
|
-
expect(await
|
|
110
|
+
expect(await sessionLifecycle.federations(sid)).toEqual({
|
|
111
|
+
outcome: "listed",
|
|
112
|
+
federations: ["google"],
|
|
113
|
+
});
|
|
111
114
|
});
|
|
112
115
|
|
|
113
116
|
it("keeps them and answers 503 when Google answers 502 with no OAuth body at all", async () => {
|
|
@@ -120,7 +123,7 @@ describe("POST /oauth/federation/google/token: an upstream outage never ends the
|
|
|
120
123
|
});
|
|
121
124
|
|
|
122
125
|
it("ends them on Google's structured 400 invalid_grant", async () => {
|
|
123
|
-
const { res, sid, federationTokenStore,
|
|
126
|
+
const { res, sid, federationTokenStore, sessionLifecycle } = await refreshAnswered(400, {
|
|
124
127
|
error: "invalid_grant",
|
|
125
128
|
error_description: "Token has been expired or revoked.",
|
|
126
129
|
});
|
|
@@ -130,6 +133,9 @@ describe("POST /oauth/federation/google/token: an upstream outage never ends the
|
|
|
130
133
|
expect(await federationTokenStore.get(sid, "google")).toBeNull();
|
|
131
134
|
// The route never writes the session's index: the link, now holding no
|
|
132
135
|
// credential, stays listed until federation logout or the session's end.
|
|
133
|
-
expect(await
|
|
136
|
+
expect(await sessionLifecycle.federations(sid)).toEqual({
|
|
137
|
+
outcome: "listed",
|
|
138
|
+
federations: ["google"],
|
|
139
|
+
});
|
|
134
140
|
});
|
|
135
141
|
});
|
|
@@ -0,0 +1,239 @@
|
|
|
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 are handled by the modules of their
|
|
19
|
+
* types. The template loads the federation types it bundles, `google` and
|
|
20
|
+
* `oidc`, whatever the configuration enables, and core dispatches each
|
|
21
|
+
* enabled `core.federations` entry to the module of its `type`, under the
|
|
22
|
+
* entry's name: two entries of one type are two federations. The template
|
|
23
|
+
* reads no entry before boot, so a malformed one is core's to refuse, at its
|
|
24
|
+
* path.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { mkdtempSync, readdirSync, writeFileSync } from "node:fs";
|
|
28
|
+
import { tmpdir } from "node:os";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
import request from "supertest";
|
|
32
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
33
|
+
import { buildModules } from "#/buildModules.mjs";
|
|
34
|
+
import { readOwnLayers, readSwitches, resolveConfigPaths, resolveLayers } from "#/configPath.mjs";
|
|
35
|
+
import {
|
|
36
|
+
type Composition,
|
|
37
|
+
compose,
|
|
38
|
+
ISSUER,
|
|
39
|
+
ownFiles,
|
|
40
|
+
resolveConfig,
|
|
41
|
+
SINGLE_ENV,
|
|
42
|
+
} from "./all-modules-composition.fixture.mjs";
|
|
43
|
+
|
|
44
|
+
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
45
|
+
|
|
46
|
+
/** Google's authorization endpoint, where a Google federation's start sends the browser. */
|
|
47
|
+
const GOOGLE_AUTHORIZE = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
48
|
+
|
|
49
|
+
let current: Composition | undefined;
|
|
50
|
+
|
|
51
|
+
afterEach(async () => {
|
|
52
|
+
await current?.handle.dispose();
|
|
53
|
+
current = undefined;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/** Boots and remembers the composition, so `afterEach` disposes it. */
|
|
57
|
+
async function boot(options: Parameters<typeof compose>[0] = {}): Promise<Composition> {
|
|
58
|
+
current = await compose(options);
|
|
59
|
+
return current;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** What `compose` refuses under `options`. */
|
|
63
|
+
async function refusal(options: Parameters<typeof compose>[0]): Promise<{
|
|
64
|
+
readonly reason?: string;
|
|
65
|
+
readonly message: string;
|
|
66
|
+
}> {
|
|
67
|
+
try {
|
|
68
|
+
current = await compose(options);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
return err as { reason?: string; message: string };
|
|
71
|
+
}
|
|
72
|
+
throw new Error("the composition booted");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `text` in a file of its own, for a layer above the composition's files. */
|
|
76
|
+
function hoconFile(text: string): string {
|
|
77
|
+
const file = join(mkdtempSync(join(tmpdir(), "federation-types-")), "operator.conf");
|
|
78
|
+
writeFileSync(file, text);
|
|
79
|
+
return file;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The module names `buildModules` lists for `env`, federation ones only. */
|
|
83
|
+
const federationModules = (env: Readonly<Record<string, string>>): string[] =>
|
|
84
|
+
buildModules(resolveConfig(env))
|
|
85
|
+
.map((module) => module.name)
|
|
86
|
+
.filter((name) => name.startsWith("federation-") && !name.startsWith("federation-grant"));
|
|
87
|
+
|
|
88
|
+
/** Where the start of federation `name` sends the browser. */
|
|
89
|
+
async function startOf(composed: Composition, name: string): Promise<URL> {
|
|
90
|
+
const res = await request(composed.app).get(`/session/oauth/federation/${name}`);
|
|
91
|
+
expect(res.status).toBe(302);
|
|
92
|
+
return new URL(res.headers.location as string);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
describe("the federation types the template bundles", () => {
|
|
96
|
+
it("are listed whatever the configuration enables", () => {
|
|
97
|
+
const off = {
|
|
98
|
+
...SINGLE_ENV,
|
|
99
|
+
CORE_FEDERATIONS_GOOGLE_ENABLED: "false",
|
|
100
|
+
CORE_FEDERATIONS_OIDC_ENABLED: "false",
|
|
101
|
+
};
|
|
102
|
+
expect(federationModules(off)).toEqual(["federation-google-type", "federation-oidc"]);
|
|
103
|
+
expect(federationModules(SINGLE_ENV)).toEqual(["federation-google-type", "federation-oidc"]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("handle every entry the template ships, each of which names its type", () => {
|
|
107
|
+
const { applicationConfPath } = resolveConfigPaths(configDir, "production");
|
|
108
|
+
const resolved = resolveLayers(readOwnLayers([applicationConfPath], { env: {} }), []) as {
|
|
109
|
+
core?: { federations?: Record<string, { type?: unknown }> };
|
|
110
|
+
};
|
|
111
|
+
const types = Object.fromEntries(
|
|
112
|
+
Object.entries(resolved.core?.federations ?? {}).map(([name, entry]) => [name, entry.type]),
|
|
113
|
+
);
|
|
114
|
+
expect(types).toEqual({ google: "google", oidc: "oidc" });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("boot the shipped Google federation, enabled, as the provider named google that its type built", async () => {
|
|
118
|
+
const composed = await boot();
|
|
119
|
+
const names = composed.modules.map((module) => module.name);
|
|
120
|
+
expect(names).toContain("federation-google-type");
|
|
121
|
+
expect(names).not.toContain("federation-google");
|
|
122
|
+
expect(composed.handle.components.federationProviders?.get("google")?.name).toBe("google");
|
|
123
|
+
|
|
124
|
+
const start = await startOf(composed, "google");
|
|
125
|
+
expect(`${start.origin}${start.pathname}`).toBe(GOOGLE_AUTHORIZE);
|
|
126
|
+
expect(start.searchParams.get("client_id")).toBe(SINGLE_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_ID);
|
|
127
|
+
expect(start.searchParams.get("redirect_uri")).toBe(
|
|
128
|
+
"http://localhost:3000/session/oauth/federation/google/callback",
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("boot a second entry of type google, under its own name, beside the shipped one", async () => {
|
|
133
|
+
const callbackURL = `${ISSUER}/session/oauth/federation/google-work/callback`;
|
|
134
|
+
const composed = await boot({
|
|
135
|
+
operatorHocon: `core.federations.google-work {
|
|
136
|
+
enabled = true
|
|
137
|
+
type = "google"
|
|
138
|
+
clientId = "google-work-client"
|
|
139
|
+
clientSecret = "google-work-secret"
|
|
140
|
+
callbackURL = ${JSON.stringify(callbackURL)}
|
|
141
|
+
}
|
|
142
|
+
`,
|
|
143
|
+
});
|
|
144
|
+
const providers = composed.handle.components.federationProviders;
|
|
145
|
+
expect([...(providers?.keys() ?? [])].sort()).toEqual(["google", "google-work", "oidc"]);
|
|
146
|
+
expect(providers?.get("google-work")?.name).toBe("google-work");
|
|
147
|
+
|
|
148
|
+
const work = await startOf(composed, "google-work");
|
|
149
|
+
expect(`${work.origin}${work.pathname}`).toBe(GOOGLE_AUTHORIZE);
|
|
150
|
+
expect(work.searchParams.get("client_id")).toBe("google-work-client");
|
|
151
|
+
expect(work.searchParams.get("redirect_uri")).toBe(callbackURL);
|
|
152
|
+
const shipped = await startOf(composed, "google");
|
|
153
|
+
expect(shipped.searchParams.get("client_id")).toBe(
|
|
154
|
+
SINGLE_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_ID,
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("hand an entry to the type it names, not the type its name reads as", async () => {
|
|
159
|
+
// `google` of type `oidc` is a generic OIDC federation named google.
|
|
160
|
+
const composed = await boot({
|
|
161
|
+
operatorHocon: `core.federations.google { type = "oidc", issuer = ${JSON.stringify(SINGLE_ENV.CORE_FEDERATIONS_OIDC_ISSUER)} }\n`,
|
|
162
|
+
});
|
|
163
|
+
const start = await startOf(composed, "google");
|
|
164
|
+
expect(start.origin).toBe(SINGLE_ENV.CORE_FEDERATIONS_OIDC_ISSUER);
|
|
165
|
+
expect(start.searchParams.get("client_id")).toBe(SINGLE_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_ID);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("register the type of every entry each shipped configuration file writes", () => {
|
|
169
|
+
const registered = new Set(
|
|
170
|
+
buildModules(resolveConfig(SINGLE_ENV)).flatMap((module) =>
|
|
171
|
+
Object.keys(module.contributes?.federationTypes ?? {}),
|
|
172
|
+
),
|
|
173
|
+
);
|
|
174
|
+
const written = readdirSync(configDir)
|
|
175
|
+
.filter((file) => file.endsWith(".conf"))
|
|
176
|
+
.flatMap((file) => {
|
|
177
|
+
const resolved = resolveLayers(readOwnLayers([join(configDir, file)], { env: {} }), []) as {
|
|
178
|
+
core?: { federations?: Record<string, { type?: unknown }> };
|
|
179
|
+
};
|
|
180
|
+
return Object.entries(resolved.core?.federations ?? {}).map(([name, entry]) => ({
|
|
181
|
+
file,
|
|
182
|
+
name,
|
|
183
|
+
type: entry.type,
|
|
184
|
+
}));
|
|
185
|
+
});
|
|
186
|
+
expect(written).not.toEqual([]);
|
|
187
|
+
expect(written.filter(({ type }) => typeof type !== "string" || !registered.has(type))).toEqual(
|
|
188
|
+
[],
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("the template reads no federation entry before boot", () => {
|
|
194
|
+
it("passes a malformed entry through phase one, and boot refuses it at its path", async () => {
|
|
195
|
+
const operatorHocon = 'core.federations.okta { enabled = "maybe", type = "oidc" }\n';
|
|
196
|
+
expect(() =>
|
|
197
|
+
readSwitches(readOwnLayers([hoconFile(operatorHocon), ...ownFiles()], { env: SINGLE_ENV })),
|
|
198
|
+
).not.toThrow();
|
|
199
|
+
const err = await refusal({ operatorHocon });
|
|
200
|
+
expect(err.reason).toBe("config-validation-failed");
|
|
201
|
+
expect(err.message).toContain("core.federations.okta.enabled");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it.each([
|
|
205
|
+
["accessType", '"sometimes"'],
|
|
206
|
+
// A bare string where a list belongs: the natural typo.
|
|
207
|
+
["redirectAllowlist", '"https://app.test/home"'],
|
|
208
|
+
["sessionDomain", "42"],
|
|
209
|
+
["requireAuthorizationResponseIss", '"no"'],
|
|
210
|
+
// Only an environment variable's string is read as a flag's spelling.
|
|
211
|
+
["requireAuthorizationResponseIss", "1"],
|
|
212
|
+
["requireAuthorizationResponseIss", "[true]"],
|
|
213
|
+
])(
|
|
214
|
+
"leaves %s, a key of the entry's type, to the type's schema, which core refuses it by, at its path",
|
|
215
|
+
async (key, value) => {
|
|
216
|
+
const err = await refusal({ operatorHocon: `core.federations.google.${key} = ${value}\n` });
|
|
217
|
+
expect(err.reason).toBe("config-validation-failed");
|
|
218
|
+
expect(err.message).toContain(`core.federations.google.${key}`);
|
|
219
|
+
},
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe("an enabled Google federation's credentials", () => {
|
|
224
|
+
it.each([
|
|
225
|
+
["unset", undefined],
|
|
226
|
+
["exported empty", ""],
|
|
227
|
+
])(
|
|
228
|
+
"refuse the boot when the client secret is %s, at its path, quoting no value of the entry",
|
|
229
|
+
async (_case, secret) => {
|
|
230
|
+
const { CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET: _shipped, ...env } = SINGLE_ENV;
|
|
231
|
+
const err = await refusal({
|
|
232
|
+
env: secret === undefined ? env : { ...env, CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET: secret },
|
|
233
|
+
});
|
|
234
|
+
expect(err.reason).toBe("config-validation-failed");
|
|
235
|
+
expect(err.message).toContain("core.federations.google.clientSecret");
|
|
236
|
+
expect(err.message).not.toContain(SINGLE_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_ID as string);
|
|
237
|
+
},
|
|
238
|
+
);
|
|
239
|
+
});
|