@o3co/create-auth-provider 0.17.0-rc.1 → 0.17.0-rc.2
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/package.json +1 -1
- package/templates/standalone/.env.example +0 -3
- package/templates/standalone/README.ja.md +42 -18
- package/templates/standalone/README.md +120 -44
- package/templates/standalone/config/application.conf +40 -36
- package/templates/standalone/config/reference.conf +21 -2
- package/templates/standalone/docker-compose.production.yml +5 -2
- package/templates/standalone/package.json +1 -1
- package/templates/standalone/src/__tests__/adapters.test.mts +80 -19
- 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 +27 -6
- 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 +154 -0
- package/templates/standalone/src/__tests__/core-federations.test.mts +48 -25
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +1 -1
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +349 -54
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +15 -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 +0 -2
- package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +70 -25
- package/templates/standalone/src/__tests__/own-modules.test.mts +40 -11
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +3 -3
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +8 -4
- package/templates/standalone/src/__tests__/replica-safety.test.mts +127 -19
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +15 -20
- 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 +270 -0
- package/templates/standalone/src/__tests__/smoke.test.mts +19 -28
- package/templates/standalone/src/__tests__/store-credential.test.mts +8 -13
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +50 -34
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +242 -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 +43 -20
- package/templates/standalone/src/modules.mts +25 -213
- package/templates/standalone/src/rootRenames.mts +35 -18
- package/templates/standalone/src/sections.mts +1 -0
- 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,
|
|
@@ -140,6 +138,7 @@ const ALL_REDIS_ENV: Readonly<Record<string, string>> = {
|
|
|
140
138
|
SESSION_STORE_STORAGE_REDIS_URL: "redis://redis.test:6379",
|
|
141
139
|
ADAPTERS_USER_SESSION_STORES: "redis",
|
|
142
140
|
ADAPTERS_RATE_LIMITER: "redis",
|
|
141
|
+
ADAPTERS_ATTEMPT_COUNTER: "redis",
|
|
143
142
|
ADAPTERS_CODE_REPOSITORY: "redis",
|
|
144
143
|
ADAPTERS_ACCESS_TOKEN_DENYLIST: "redis",
|
|
145
144
|
ADAPTERS_REPLAY_SEEN_SET: "redis",
|
|
@@ -156,29 +155,24 @@ const GRANTS_ON: Readonly<Record<string, string>> = {
|
|
|
156
155
|
FEDERATION_GRANTS_CONSENT_URL: "/consent/grants",
|
|
157
156
|
};
|
|
158
157
|
|
|
159
|
-
function resolveConfig(env: Record<string, string>):
|
|
158
|
+
function resolveConfig(env: Record<string, string>): BothPhases {
|
|
160
159
|
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
|
|
161
160
|
const layers = parseFile(envConfPath, { env })
|
|
162
161
|
.withFallback(parseFile(applicationConfPath, { env }))
|
|
163
162
|
.withFallback(parseFile(fileURLToPath(templateReference()), { env }))
|
|
164
163
|
.withFallback(libraryLayers(env));
|
|
165
|
-
const config =
|
|
166
|
-
...sectionsCoreDoesNotDeclare(layers),
|
|
167
|
-
...validate(layers, AppConfigSchema),
|
|
168
|
-
...rootSectionsOf(layers, env),
|
|
169
|
-
};
|
|
164
|
+
const config = bothPhasesOf(layers, env);
|
|
170
165
|
// The key ring has no environment form (a list of { id, key } is HOCON's);
|
|
171
166
|
// the Redis grant store refuses to construct without one under "required".
|
|
172
167
|
return {
|
|
173
168
|
...config,
|
|
174
|
-
// What the resolution captured of
|
|
175
|
-
// schema's parse drops.
|
|
169
|
+
// What the resolution captured of the renamed variables.
|
|
176
170
|
"renamed-variables": capturedRenames(env),
|
|
177
171
|
"redis-federation-grant-store": {
|
|
178
172
|
...(config["redis-federation-grant-store"] as object | undefined),
|
|
179
173
|
encryptionKeys: [{ id: "k-test", key: ENCRYPTION_KEY }],
|
|
180
174
|
},
|
|
181
|
-
} as
|
|
175
|
+
} as BothPhases;
|
|
182
176
|
}
|
|
183
177
|
|
|
184
178
|
const testRepositoriesModule = defineModule({
|
|
@@ -204,7 +198,7 @@ const testKeyStoreModule = defineModule({
|
|
|
204
198
|
},
|
|
205
199
|
});
|
|
206
200
|
|
|
207
|
-
const modulesFor = (config:
|
|
201
|
+
const modulesFor = (config: BothPhases, memoryOnly = false, environment?: string) =>
|
|
208
202
|
buildModules(config, {
|
|
209
203
|
keyStoreModule: testKeyStoreModule,
|
|
210
204
|
repositoriesModule: testRepositoriesModule,
|
|
@@ -212,13 +206,13 @@ const modulesFor = (config: Switches, memoryOnly = false, environment?: string)
|
|
|
212
206
|
...(environment === undefined ? {} : { environment }),
|
|
213
207
|
});
|
|
214
208
|
|
|
215
|
-
const boot = (config:
|
|
209
|
+
const boot = (config: BothPhases, memoryOnly = false, environment?: string) =>
|
|
216
210
|
createApp({
|
|
217
211
|
modules: modulesFor(config, memoryOnly, environment),
|
|
218
212
|
bootstrapComponents: { config, pathResolver: (s) => s },
|
|
219
213
|
});
|
|
220
214
|
|
|
221
|
-
const names = (config:
|
|
215
|
+
const names = (config: BothPhases, memoryOnly = false) =>
|
|
222
216
|
modulesFor(config, memoryOnly).map((m) => m.name);
|
|
223
217
|
|
|
224
218
|
/**
|
|
@@ -322,7 +316,7 @@ describe("the standalone composes federation grants from its config", () => {
|
|
|
322
316
|
);
|
|
323
317
|
expect(installed).not.toContain("redis-federation-grant-store");
|
|
324
318
|
expect(installed).not.toContain("redis-federation-grant-intent-store");
|
|
325
|
-
// Listed ahead of
|
|
319
|
+
// Listed ahead of oauthEndpointsModule, as the template writes them: the
|
|
326
320
|
// template's choice, since each module parses its own bodies and the
|
|
327
321
|
// order does not change that; the browser half sits after the session
|
|
328
322
|
// middleware by its own `after`.
|
|
@@ -399,7 +393,7 @@ describe("the standalone composes federation grants from its config", () => {
|
|
|
399
393
|
upstreamHardTimeoutMs: 60_000,
|
|
400
394
|
refreshLockTtlMs: 65_000,
|
|
401
395
|
},
|
|
402
|
-
} as
|
|
396
|
+
} as BothPhases;
|
|
403
397
|
handleRef = await boot(raised, true);
|
|
404
398
|
expect(handleRef.cleanupAllowanceMs).toBe(60_000 + 3_000 + 5_000 + 12_000);
|
|
405
399
|
|
|
@@ -619,7 +613,7 @@ describe("the browser consent route parses its own body, with sessionModule list
|
|
|
619
613
|
});
|
|
620
614
|
|
|
621
615
|
/** The standalone's modules, with `sessionModule` moved ahead of the grant modules. */
|
|
622
|
-
const sessionFirst = (config:
|
|
616
|
+
const sessionFirst = (config: BothPhases) => {
|
|
623
617
|
const modules = modulesFor(config, true);
|
|
624
618
|
const session = modules.find((m) => m.name === "session");
|
|
625
619
|
if (session === undefined) throw new Error("sessionModule is not in the standalone's list");
|
|
@@ -693,7 +687,7 @@ describe("the grants consent answer is held to the session module's CSRF guard",
|
|
|
693
687
|
const trusting = {
|
|
694
688
|
...config,
|
|
695
689
|
session: { ...session, csrf: { ...session.csrf, trustedOrigins: [SIBLING] } },
|
|
696
|
-
} as
|
|
690
|
+
} as BothPhases;
|
|
697
691
|
handleRef = await boot(trusting, true);
|
|
698
692
|
return express().set("trust proxy", "loopback").use(handleRef.router);
|
|
699
693
|
};
|
|
@@ -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
|
+
});
|