@o3co/create-auth-provider 0.17.0 → 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 -912
- package/templates/standalone/README.md +0 -1536
- package/templates/standalone/config/application.conf +0 -351
- package/templates/standalone/config/clients.yaml +0 -4
- package/templates/standalone/config/clients.yaml.example +0 -124
- 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 -427
- 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 -56
- 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 -1188
- 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 -355
- package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +0 -64
- 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 -1291
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -787
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -464
- 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__/frontchannel-logout-csp.test.mts +0 -168
- package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -318
- 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 -217
- 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 -1480
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
- package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -283
- 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__/redis-stand-in.fixture.mts +0 -98
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
- package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -657
- 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 -516
- package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -213
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -305
- 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 -642
- package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -164
- package/templates/standalone/src/__tests__/store-credential.test.mts +0 -314
- 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 -385
- package/templates/standalone/src/adapters.mts +0 -162
- package/templates/standalone/src/app.mts +0 -127
- 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 -378
- package/templates/standalone/src/securityHeaders.mts +0 -36
- 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,239 +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 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
|
-
});
|
|
@@ -1,168 +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 front-channel logout page, served through the template behind its
|
|
19
|
-
* security headers, works under the one policy the response carries: its
|
|
20
|
-
* relying party's frame and its redirect are allowed, and nothing else is.
|
|
21
|
-
* Every other answer keeps the template's policy.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import { createHash } from "node:crypto";
|
|
25
|
-
import request from "supertest";
|
|
26
|
-
import { describe, expect, it } from "vitest";
|
|
27
|
-
import {
|
|
28
|
-
authorize,
|
|
29
|
-
basic,
|
|
30
|
-
codeFrom,
|
|
31
|
-
compose,
|
|
32
|
-
login,
|
|
33
|
-
PKCE,
|
|
34
|
-
} from "./all-modules-composition.fixture.mjs";
|
|
35
|
-
|
|
36
|
-
const RP = {
|
|
37
|
-
id: "rp-fc",
|
|
38
|
-
secret: "rp-fc-secret-long-enough",
|
|
39
|
-
redirectUri: "https://rp-fc.test/cb",
|
|
40
|
-
frontchannelLogoutUri: "https://rp-fc.test:8443/frontchannel-logout",
|
|
41
|
-
postLogoutRedirectUri: "https://rp-fc.test/signed-out",
|
|
42
|
-
} as const;
|
|
43
|
-
|
|
44
|
-
/** A serialized policy as directive name → sources; a directive named twice fails. */
|
|
45
|
-
const parsePolicy = (policy: string): Map<string, string[]> => {
|
|
46
|
-
const directives = new Map<string, string[]>();
|
|
47
|
-
for (const part of policy.split(";")) {
|
|
48
|
-
const [name, ...sources] = part.trim().split(/\s+/);
|
|
49
|
-
if (name === undefined || name === "") continue;
|
|
50
|
-
if (directives.has(name)) throw new Error(`directive ${name} appears twice`);
|
|
51
|
-
directives.set(name, sources);
|
|
52
|
-
}
|
|
53
|
-
return directives;
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
/** The sources a fetch directive resolves to: its own, else `default-src`'s. */
|
|
57
|
-
const effective = (policy: Map<string, string[]>, directive: string): string[] =>
|
|
58
|
-
policy.get(directive) ?? policy.get("default-src") ?? [];
|
|
59
|
-
|
|
60
|
-
const decode = (value: string): string =>
|
|
61
|
-
value
|
|
62
|
-
.replace(/"/g, '"')
|
|
63
|
-
.replace(/'/g, "'")
|
|
64
|
-
.replace(/</g, "<")
|
|
65
|
-
.replace(/>/g, ">")
|
|
66
|
-
.replace(/&/g, "&");
|
|
67
|
-
|
|
68
|
-
/** How many `Content-Security-Policy` header lines the response carried, as sent. */
|
|
69
|
-
const policyHeaderCount = (response: object): number => {
|
|
70
|
-
const raw = (response as { res?: { rawHeaders?: unknown } }).res?.rawHeaders;
|
|
71
|
-
if (!Array.isArray(raw)) throw new Error("the response carries no raw headers");
|
|
72
|
-
return raw.filter(
|
|
73
|
-
(name, i) => i % 2 === 0 && String(name).toLowerCase() === "content-security-policy",
|
|
74
|
-
).length;
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
describe("the front-channel logout page under the template's security headers", () => {
|
|
78
|
-
it("is allowed its relying party's frame and its redirect by the one policy it is served with", async () => {
|
|
79
|
-
const { app, handle } = await compose({
|
|
80
|
-
extraClients: {
|
|
81
|
-
[RP.id]: {
|
|
82
|
-
tokenEndpointAuthMethod: "client_secret_basic",
|
|
83
|
-
clientSecret: RP.secret,
|
|
84
|
-
allowedRedirectUris: [RP.redirectUri],
|
|
85
|
-
allowedScopes: ["openid"],
|
|
86
|
-
allowedGrantTypes: ["authorization_code"],
|
|
87
|
-
frontchannelLogoutUri: RP.frontchannelLogoutUri,
|
|
88
|
-
postLogoutRedirectUris: [RP.postLogoutRedirectUri],
|
|
89
|
-
firstParty: true,
|
|
90
|
-
},
|
|
91
|
-
},
|
|
92
|
-
});
|
|
93
|
-
try {
|
|
94
|
-
// A session the relying party joined by exchanging a code.
|
|
95
|
-
const { cookies } = await login(app);
|
|
96
|
-
const exchanged = await request(app)
|
|
97
|
-
.post("/oauth/token")
|
|
98
|
-
.set("Authorization", basic(RP))
|
|
99
|
-
.type("form")
|
|
100
|
-
.send({
|
|
101
|
-
grant_type: "authorization_code",
|
|
102
|
-
code: codeFrom(await authorize(app, cookies, RP)),
|
|
103
|
-
redirect_uri: RP.redirectUri,
|
|
104
|
-
code_verifier: PKCE.verifier,
|
|
105
|
-
});
|
|
106
|
-
expect(exchanged.status).toBe(200);
|
|
107
|
-
|
|
108
|
-
const res = await request(app)
|
|
109
|
-
.get("/oauth/logout")
|
|
110
|
-
.set("Accept", "text/html")
|
|
111
|
-
.set("Cookie", cookies)
|
|
112
|
-
.query({
|
|
113
|
-
id_token_hint: exchanged.body.id_token as string,
|
|
114
|
-
post_logout_redirect_uri: RP.postLogoutRedirectUri,
|
|
115
|
-
state: "s-1",
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
expect(res.status).toBe(200);
|
|
119
|
-
expect(res.headers["content-type"]).toBe("text/html; charset=utf-8");
|
|
120
|
-
expect(policyHeaderCount(res)).toBe(1);
|
|
121
|
-
const policy = parsePolicy(res.headers["content-security-policy"] as string);
|
|
122
|
-
|
|
123
|
-
// The frame: the relying party's, from the one origin the policy allows.
|
|
124
|
-
const srcs = [...res.text.matchAll(/<iframe src="([^"]*)"/g)].map((m) => decode(m[1] ?? ""));
|
|
125
|
-
expect(srcs).toHaveLength(1);
|
|
126
|
-
expect(new URL(srcs[0] ?? "").origin).toBe("https://rp-fc.test:8443");
|
|
127
|
-
expect(effective(policy, "frame-src")).toEqual(["https://rp-fc.test:8443"]);
|
|
128
|
-
|
|
129
|
-
// The redirect: one inline script, allowed by its hash, sending the
|
|
130
|
-
// browser to the registered URI with the state.
|
|
131
|
-
const scripts = [
|
|
132
|
-
...res.text.matchAll(/<script data-target="([^"]*)" data-delay="(\d+)">([^<]*)<\/script>/g),
|
|
133
|
-
];
|
|
134
|
-
expect(scripts).toHaveLength(1);
|
|
135
|
-
expect(res.text.match(/<script/g)).toHaveLength(1);
|
|
136
|
-
const [, target, , text] = scripts[0] ?? [];
|
|
137
|
-
expect(decode(target ?? "")).toBe(`${RP.postLogoutRedirectUri}?state=s-1`);
|
|
138
|
-
const hash = createHash("sha256")
|
|
139
|
-
.update(text ?? "", "utf8")
|
|
140
|
-
.digest("base64");
|
|
141
|
-
expect(effective(policy, "script-src")).toEqual([`'sha256-${hash}'`]);
|
|
142
|
-
|
|
143
|
-
// Nothing else.
|
|
144
|
-
expect(policy.get("default-src")).toEqual(["'none'"]);
|
|
145
|
-
expect(effective(policy, "style-src")).toEqual(["'none'"]);
|
|
146
|
-
expect(effective(policy, "connect-src")).toEqual(["'none'"]);
|
|
147
|
-
expect(policy.get("frame-ancestors")).toEqual(["'none'"]);
|
|
148
|
-
expect(res.headers["cache-control"]).toBe("no-store");
|
|
149
|
-
} finally {
|
|
150
|
-
await handle.dispose();
|
|
151
|
-
}
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it("leaves the template's policy on every other answer", async () => {
|
|
155
|
-
const { app, handle } = await compose();
|
|
156
|
-
try {
|
|
157
|
-
const res = await request(app).get("/.well-known/openid-configuration");
|
|
158
|
-
|
|
159
|
-
expect(policyHeaderCount(res)).toBe(1);
|
|
160
|
-
const policy = parsePolicy(res.headers["content-security-policy"] as string);
|
|
161
|
-
expect(policy.get("default-src")).toEqual(["'none'"]);
|
|
162
|
-
expect(policy.get("frame-ancestors")).toEqual(["'none'"]);
|
|
163
|
-
expect(effective(policy, "frame-src")).toEqual(["'none'"]);
|
|
164
|
-
} finally {
|
|
165
|
-
await handle.dispose();
|
|
166
|
-
}
|
|
167
|
-
});
|
|
168
|
-
});
|