@o3co/create-auth-provider 0.17.0-rc.3 → 0.17.0
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 +3 -3
- package/templates/standalone/README.ja.md +5 -4
- package/templates/standalone/README.md +23 -4
- package/templates/standalone/config/clients.yaml.example +11 -5
- package/templates/standalone/config/reference.conf +5 -2
- package/templates/standalone/package.json +7 -6
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +6 -10
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +2 -2
- package/templates/standalone/src/__tests__/audit-sink.test.mts +13 -4
- package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +64 -0
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +14 -12
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +7 -4
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +7 -4
- package/templates/standalone/src/__tests__/frontchannel-logout-csp.test.mts +168 -0
- package/templates/standalone/src/__tests__/google-session.fixture.mts +17 -7
- package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +13 -12
- package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +113 -0
- package/templates/standalone/src/__tests__/own-modules.test.mts +3 -35
- package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +73 -4
- package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +98 -0
- package/templates/standalone/src/__tests__/replica-safety.test.mts +7 -4
- package/templates/standalone/src/__tests__/repositories-logger.test.mts +119 -0
- package/templates/standalone/src/__tests__/sections.test.mts +15 -3
- package/templates/standalone/src/__tests__/session-lifecycle.test.mts +8 -10
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +13 -4
- package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +7 -7
- package/templates/standalone/src/__tests__/smoke.test.mts +13 -5
- package/templates/standalone/src/__tests__/startup-log-lines.test.mts +24 -8
- package/templates/standalone/src/__tests__/store-credential.test.mts +7 -5
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +7 -4
- package/templates/standalone/src/app.mts +2 -11
- package/templates/standalone/src/modules.mts +9 -3
- package/templates/standalone/src/sections.mts +3 -2
- package/templates/standalone/src/securityHeaders.mts +36 -0
- package/templates/standalone/tests/package-lock.json +95 -95
- package/templates/standalone/tests/package.json +1 -1
- package/templates/versions.json +18 -18
|
@@ -0,0 +1,98 @@
|
|
|
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
|
+
* A TCP stand-in for a Redis server, for the tests that boot the template
|
|
19
|
+
* against a socket and need no real server: it records what each connection
|
|
20
|
+
* sends and answers only what boot needs — the handshake (`HELLO` refused, so
|
|
21
|
+
* the client speaks RESP2), `INFO` reporting `maxmemory-policy noeviction`,
|
|
22
|
+
* and `+OK` to anything else. It stores nothing and is not a Redis for data:
|
|
23
|
+
* a test that reads or writes a store needs a real server.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { once } from "node:events";
|
|
27
|
+
import { type AddressInfo, createServer, type Socket } from "node:net";
|
|
28
|
+
|
|
29
|
+
/** The first command `buffer` holds in full, as RESP sends it, and its length; `undefined` until it does. */
|
|
30
|
+
function nextCommand(
|
|
31
|
+
buffer: string,
|
|
32
|
+
): { readonly name: string; readonly length: number } | undefined {
|
|
33
|
+
const header = /^\*(\d+)\r\n/.exec(buffer);
|
|
34
|
+
if (header === null) return undefined;
|
|
35
|
+
let at = header[0].length;
|
|
36
|
+
const args: string[] = [];
|
|
37
|
+
for (let i = 0; i < Number(header[1]); i++) {
|
|
38
|
+
const bulk = /^\$(\d+)\r\n/.exec(buffer.slice(at));
|
|
39
|
+
if (bulk === null) return undefined;
|
|
40
|
+
const start = at + bulk[0].length;
|
|
41
|
+
const end = start + Number(bulk[1]);
|
|
42
|
+
if (buffer.length < end + 2) return undefined;
|
|
43
|
+
args.push(buffer.slice(start, end));
|
|
44
|
+
at = end + 2;
|
|
45
|
+
}
|
|
46
|
+
return { name: (args[0] ?? "").toLowerCase(), length: at };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What a server that runs `noeviction` and speaks RESP2 answers `name`. */
|
|
50
|
+
function replyTo(name: string): string {
|
|
51
|
+
if (name === "hello") return "-NOPROTO unsupported protocol version\r\n";
|
|
52
|
+
if (name === "info") {
|
|
53
|
+
const info =
|
|
54
|
+
"# Memory\r\nmaxmemory_policy:noeviction\r\n# Persistence\r\naof_enabled:1\r\nloading:0\r\n";
|
|
55
|
+
return `$${Buffer.byteLength(info)}\r\n${info}\r\n`;
|
|
56
|
+
}
|
|
57
|
+
return "+OK\r\n";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A TCP server standing in for Redis: it records what each connection sends,
|
|
62
|
+
* and answers as a server running `noeviction` does — `HELLO` refused, so the
|
|
63
|
+
* client speaks RESP2, `INFO` with the policy, anything else `OK` — so a
|
|
64
|
+
* client dialled at it writes its handshake and the stores whose factories
|
|
65
|
+
* read the policy build.
|
|
66
|
+
*/
|
|
67
|
+
export async function listeningRedis(): Promise<{
|
|
68
|
+
readonly port: number;
|
|
69
|
+
readonly received: () => string;
|
|
70
|
+
readonly close: () => Promise<void>;
|
|
71
|
+
}> {
|
|
72
|
+
const sockets = new Set<Socket>();
|
|
73
|
+
let received = "";
|
|
74
|
+
const server = createServer((socket) => {
|
|
75
|
+
sockets.add(socket);
|
|
76
|
+
let pending = "";
|
|
77
|
+
socket.on("close", () => sockets.delete(socket));
|
|
78
|
+
socket.on("data", (chunk) => {
|
|
79
|
+
received += chunk.toString("utf8");
|
|
80
|
+
pending += chunk.toString("utf8");
|
|
81
|
+
for (let command = nextCommand(pending); command; command = nextCommand(pending)) {
|
|
82
|
+
pending = pending.slice(command.length);
|
|
83
|
+
socket.write(replyTo(command.name));
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
server.listen(0, "127.0.0.1");
|
|
88
|
+
await once(server, "listening");
|
|
89
|
+
const { port } = server.address() as AddressInfo;
|
|
90
|
+
return {
|
|
91
|
+
port,
|
|
92
|
+
received: () => received,
|
|
93
|
+
close: async () => {
|
|
94
|
+
for (const socket of sockets) socket.destroy();
|
|
95
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -56,6 +56,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
56
56
|
import { buildModules } from "../buildModules.mjs";
|
|
57
57
|
import { resolveConfigPaths } from "../configPath.mjs";
|
|
58
58
|
import { templateReference } from "../modules.mjs";
|
|
59
|
+
import { keyStoreSectionSchema } from "../sections.mjs";
|
|
59
60
|
import {
|
|
60
61
|
type BothPhases,
|
|
61
62
|
bothPhasesOf,
|
|
@@ -198,16 +199,18 @@ const testRepositoriesModule = defineModule({
|
|
|
198
199
|
},
|
|
199
200
|
});
|
|
200
201
|
|
|
202
|
+
// The template's `key-store` section, read by its own schema, without the
|
|
203
|
+
// section's relocation bridge.
|
|
201
204
|
const testKeyStoreModule = defineModule({
|
|
202
|
-
name: "
|
|
203
|
-
|
|
205
|
+
name: "key-store",
|
|
206
|
+
section: { schema: keyStoreSectionSchema },
|
|
204
207
|
provides: {
|
|
205
|
-
keyStore: async ({
|
|
208
|
+
keyStore: async ({ section }) => {
|
|
206
209
|
const factory = createKeyStoreFactory();
|
|
207
210
|
registerBuiltinKeyStores(factory);
|
|
208
211
|
return factory.create({
|
|
209
212
|
type: "local",
|
|
210
|
-
...(
|
|
213
|
+
...(section.local ?? {}),
|
|
211
214
|
});
|
|
212
215
|
},
|
|
213
216
|
},
|
|
@@ -0,0 +1,119 @@
|
|
|
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 repositories module builds its user repository with the composition's
|
|
19
|
+
* logger: the yaml user builder's `user_repository_in_memory` line reaches the
|
|
20
|
+
* `logger` bootstrap component (the template's pino logger, at its level),
|
|
21
|
+
* not the console.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import os from "node:os";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import {
|
|
28
|
+
type BootstrapMap,
|
|
29
|
+
createApp,
|
|
30
|
+
defineModule,
|
|
31
|
+
type Logger,
|
|
32
|
+
type UserRepository,
|
|
33
|
+
} from "@o3co/auth-provider-core";
|
|
34
|
+
import {
|
|
35
|
+
CORE_RELOCATIONS,
|
|
36
|
+
makeValidCoreConfig,
|
|
37
|
+
renamedVariableCaptures,
|
|
38
|
+
} from "@o3co/auth-provider-core/testing";
|
|
39
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
40
|
+
import { repositoriesModuleFor } from "../modules.mjs";
|
|
41
|
+
|
|
42
|
+
const spyLogger = () => {
|
|
43
|
+
const logger = {
|
|
44
|
+
trace: vi.fn(),
|
|
45
|
+
debug: vi.fn(),
|
|
46
|
+
info: vi.fn(),
|
|
47
|
+
warn: vi.fn(),
|
|
48
|
+
error: vi.fn(),
|
|
49
|
+
fatal: vi.fn(),
|
|
50
|
+
child: vi.fn(),
|
|
51
|
+
};
|
|
52
|
+
logger.child.mockReturnValue(logger);
|
|
53
|
+
return logger;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
describe("the repositories module's user repository", () => {
|
|
57
|
+
let dir: string;
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), "repositories-logger-"));
|
|
60
|
+
});
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
63
|
+
vi.restoreAllMocks();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("warns user_repository_in_memory on the composition's logger, built through createApp, and not on the console", async () => {
|
|
67
|
+
const usersPath = path.join(dir, "users.yaml");
|
|
68
|
+
fs.writeFileSync(usersPath, 'alice:\n password: "plainpass"\n');
|
|
69
|
+
const clientsPath = path.join(dir, "clients.yaml");
|
|
70
|
+
fs.writeFileSync(clientsPath, "");
|
|
71
|
+
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
72
|
+
const logger = spyLogger();
|
|
73
|
+
let built: UserRepository | undefined;
|
|
74
|
+
const reader = defineModule({
|
|
75
|
+
name: "test:user-repository-reader",
|
|
76
|
+
requires: ["userRepository"] as const,
|
|
77
|
+
provides: {
|
|
78
|
+
userRepositoryRead: ({ userRepository }: { userRepository: UserRepository }) => {
|
|
79
|
+
built = userRepository;
|
|
80
|
+
return true;
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
lifecycle: { userRepositoryRead: { eager: true } },
|
|
84
|
+
} as never);
|
|
85
|
+
|
|
86
|
+
const repositories = repositoriesModuleFor({ client: "yaml", user: "yaml" });
|
|
87
|
+
const handle = await createApp({
|
|
88
|
+
modules: [repositories, reader],
|
|
89
|
+
bootstrapComponents: {
|
|
90
|
+
config: {
|
|
91
|
+
...makeValidCoreConfig(),
|
|
92
|
+
repositories: {
|
|
93
|
+
client: { yaml: { path: clientsPath } },
|
|
94
|
+
user: { yaml: { path: usersPath }, http: {} },
|
|
95
|
+
},
|
|
96
|
+
// What a resolution with none of the renamed variables set captures.
|
|
97
|
+
"renamed-variables": renamedVariableCaptures({
|
|
98
|
+
modules: [repositories],
|
|
99
|
+
core: CORE_RELOCATIONS,
|
|
100
|
+
env: {},
|
|
101
|
+
}),
|
|
102
|
+
},
|
|
103
|
+
pathResolver: (p: string) => p,
|
|
104
|
+
logger: logger as unknown as Logger,
|
|
105
|
+
} as unknown as BootstrapMap,
|
|
106
|
+
});
|
|
107
|
+
try {
|
|
108
|
+
expect((await built?.authenticate("alice", "plainpass"))?.username).toBe("alice");
|
|
109
|
+
expect(
|
|
110
|
+
logger.warn.mock.calls.filter(([, event]) => event === "user_repository_in_memory"),
|
|
111
|
+
).toEqual([[{ store: "userRepository", adapter: "yaml" }, "user_repository_in_memory"]]);
|
|
112
|
+
expect(
|
|
113
|
+
consoleWarn.mock.calls.filter((args) => args.includes("user_repository_in_memory")),
|
|
114
|
+
).toEqual([]);
|
|
115
|
+
} finally {
|
|
116
|
+
await handle.dispose();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -440,12 +440,20 @@ describe("key-store", () => {
|
|
|
440
440
|
|
|
441
441
|
describe("redis-clients", () => {
|
|
442
442
|
it("accepts a URL, and a password beside it", () => {
|
|
443
|
-
expect(
|
|
443
|
+
expect(
|
|
444
|
+
redisClientsSectionSchema.parse({
|
|
445
|
+
url: "redis://r:6379",
|
|
446
|
+
password: "p",
|
|
447
|
+
assumeNoEviction: false,
|
|
448
|
+
}),
|
|
449
|
+
).toEqual({
|
|
444
450
|
url: "redis://r:6379",
|
|
445
451
|
password: "p",
|
|
446
452
|
assumeNoEviction: false,
|
|
447
453
|
});
|
|
448
|
-
expect(
|
|
454
|
+
expect(
|
|
455
|
+
redisClientsSectionSchema.parse({ url: "redis://r:6379", assumeNoEviction: false }),
|
|
456
|
+
).toEqual({
|
|
449
457
|
url: "redis://r:6379",
|
|
450
458
|
assumeNoEviction: false,
|
|
451
459
|
});
|
|
@@ -476,7 +484,11 @@ describe("redis-clients", () => {
|
|
|
476
484
|
it.each([
|
|
477
485
|
["no URL", {}],
|
|
478
486
|
["a null URL", { url: null }],
|
|
479
|
-
[
|
|
487
|
+
[
|
|
488
|
+
"a key the section does not declare",
|
|
489
|
+
{ url: "redis://r:6379", db: 2, assumeNoEviction: false },
|
|
490
|
+
],
|
|
491
|
+
["no assumeNoEviction, which the template's reference.conf ships", { url: "redis://r:6379" }],
|
|
480
492
|
])("refuses %s", (_what, section) => {
|
|
481
493
|
expect(redisClientsSectionSchema.safeParse(section).success).toBe(false);
|
|
482
494
|
});
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
WEB,
|
|
42
42
|
webTokens,
|
|
43
43
|
} from "./all-modules-composition.fixture.mjs";
|
|
44
|
+
import { backchannelPeer, withLoopbackRelyingParties } from "./backchannel-peer.fixture.mjs";
|
|
44
45
|
|
|
45
46
|
afterEach(() => {
|
|
46
47
|
vi.restoreAllMocks();
|
|
@@ -68,7 +69,9 @@ describe("the template, which composes the session lifecycle module", () => {
|
|
|
68
69
|
});
|
|
69
70
|
|
|
70
71
|
it("tells a relying party that joined, through the oauth module's notifier, when the session closes", async () => {
|
|
72
|
+
const peer = await backchannelPeer();
|
|
71
73
|
const { handle } = await compose({
|
|
74
|
+
config: withLoopbackRelyingParties,
|
|
72
75
|
extraClients: {
|
|
73
76
|
"rp-bc": {
|
|
74
77
|
tokenEndpointAuthMethod: "client_secret_basic",
|
|
@@ -76,7 +79,7 @@ describe("the template, which composes the session lifecycle module", () => {
|
|
|
76
79
|
allowedRedirectUris: ["https://rp-bc.test/cb"],
|
|
77
80
|
allowedScopes: ["openid"],
|
|
78
81
|
allowedGrantTypes: ["authorization_code"],
|
|
79
|
-
backchannelLogoutUri:
|
|
82
|
+
backchannelLogoutUri: peer.uri,
|
|
80
83
|
},
|
|
81
84
|
},
|
|
82
85
|
});
|
|
@@ -99,7 +102,7 @@ describe("the template, which composes the session lifecycle module", () => {
|
|
|
99
102
|
});
|
|
100
103
|
const rp = {
|
|
101
104
|
clientId: "rp-bc",
|
|
102
|
-
backchannelLogoutUri:
|
|
105
|
+
backchannelLogoutUri: peer.uri,
|
|
103
106
|
backchannelLogoutSessionRequired: true,
|
|
104
107
|
frontchannelLogoutUri: undefined,
|
|
105
108
|
frontchannelLogoutSessionRequired: undefined,
|
|
@@ -108,15 +111,9 @@ describe("the template, which composes the session lifecycle module", () => {
|
|
|
108
111
|
expect(await lifecycle.join("sid-bc", { rp, familyId: "family-bc" })).toEqual({
|
|
109
112
|
outcome: "joined",
|
|
110
113
|
});
|
|
111
|
-
const posted = vi
|
|
112
|
-
.spyOn(globalThis, "fetch")
|
|
113
|
-
.mockImplementation(async () => new Response(null, { status: 200 }));
|
|
114
114
|
expect((await lifecycle.close("sid-bc", "rp_logout")).outcome).toBe("done");
|
|
115
|
-
expect(
|
|
116
|
-
|
|
117
|
-
expect(url).toBe("https://rp-bc.test/logout");
|
|
118
|
-
const token = new URLSearchParams(String(init.body)).get("logout_token");
|
|
119
|
-
expect(claimsOf(token ?? "")).toMatchObject({
|
|
115
|
+
expect(peer.tokens).toHaveLength(1);
|
|
116
|
+
expect(claimsOf(peer.tokens[0] ?? "")).toMatchObject({
|
|
120
117
|
iss: ISSUER,
|
|
121
118
|
aud: "rp-bc",
|
|
122
119
|
sub: "u-bc",
|
|
@@ -124,6 +121,7 @@ describe("the template, which composes the session lifecycle module", () => {
|
|
|
124
121
|
});
|
|
125
122
|
} finally {
|
|
126
123
|
await handle.dispose();
|
|
124
|
+
await peer.close();
|
|
127
125
|
}
|
|
128
126
|
});
|
|
129
127
|
|
|
@@ -48,6 +48,7 @@ import request from "supertest";
|
|
|
48
48
|
import { afterEach, describe, expect, it } from "vitest";
|
|
49
49
|
import { buildModules } from "../buildModules.mjs";
|
|
50
50
|
import type { Switches } from "../configPath.mjs";
|
|
51
|
+
import { keyStoreSectionSchema } from "../sections.mjs";
|
|
51
52
|
import { capturedRenames, inProcessAdapters } from "./library-references.fixture.mjs";
|
|
52
53
|
|
|
53
54
|
const keyPair = generateKeyPairSync("ed25519", {
|
|
@@ -117,6 +118,12 @@ const config: AppConfig & Record<string, unknown> = {
|
|
|
117
118
|
},
|
|
118
119
|
rateLimit: { failMode: "open" },
|
|
119
120
|
"standalone-in-memory-code-repository": { defaultExpiresIn: 600 },
|
|
121
|
+
// The in-process limiter's section as core's `reference.conf` ships it.
|
|
122
|
+
"core-rate-limiter-memory": {
|
|
123
|
+
maxBuckets: 10000,
|
|
124
|
+
defaultLimit: { limit: 60, windowSeconds: 60 },
|
|
125
|
+
limits: {},
|
|
126
|
+
},
|
|
120
127
|
} as unknown as AppConfig;
|
|
121
128
|
|
|
122
129
|
/** What phase one hands `buildModules`: the configuration, and every store in process. */
|
|
@@ -155,16 +162,18 @@ const testRepositoriesModule = defineModule({
|
|
|
155
162
|
} as never,
|
|
156
163
|
});
|
|
157
164
|
|
|
165
|
+
// The template's `key-store` section, read by its own schema, without the
|
|
166
|
+
// section's relocation bridge.
|
|
158
167
|
const testKeyStoreModule = defineModule({
|
|
159
|
-
name: "
|
|
160
|
-
|
|
168
|
+
name: "key-store",
|
|
169
|
+
section: { schema: keyStoreSectionSchema },
|
|
161
170
|
provides: {
|
|
162
|
-
keyStore: async ({
|
|
171
|
+
keyStore: async ({ section }) => {
|
|
163
172
|
const factory = createKeyStoreFactory();
|
|
164
173
|
registerBuiltinKeyStores(factory);
|
|
165
174
|
return factory.create({
|
|
166
175
|
type: "local",
|
|
167
|
-
...(
|
|
176
|
+
...(section.local ?? {}),
|
|
168
177
|
});
|
|
169
178
|
},
|
|
170
179
|
},
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
import request from "supertest";
|
|
32
32
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
33
33
|
import { authorize, codeFrom, compose, login, redeem } from "./all-modules-composition.fixture.mjs";
|
|
34
|
+
import { backchannelPeer, withLoopbackRelyingParties } from "./backchannel-peer.fixture.mjs";
|
|
34
35
|
|
|
35
36
|
afterEach(() => {
|
|
36
37
|
vi.restoreAllMocks();
|
|
@@ -49,7 +50,9 @@ const csrfTokenOf = (cookies: readonly string[]): string => {
|
|
|
49
50
|
|
|
50
51
|
describe("POST /session/logout with the session lifecycle module", () => {
|
|
51
52
|
it("revokes the session's families, tells its relying parties and closes its record", async () => {
|
|
53
|
+
const peer = await backchannelPeer();
|
|
52
54
|
const { app, handle } = await compose({
|
|
55
|
+
config: withLoopbackRelyingParties,
|
|
53
56
|
extraClients: {
|
|
54
57
|
"rp-bc": {
|
|
55
58
|
tokenEndpointAuthMethod: "client_secret_basic",
|
|
@@ -57,7 +60,7 @@ describe("POST /session/logout with the session lifecycle module", () => {
|
|
|
57
60
|
allowedRedirectUris: ["https://rp-bc.test/cb"],
|
|
58
61
|
allowedScopes: ["openid"],
|
|
59
62
|
allowedGrantTypes: ["authorization_code"],
|
|
60
|
-
backchannelLogoutUri:
|
|
63
|
+
backchannelLogoutUri: peer.uri,
|
|
61
64
|
},
|
|
62
65
|
},
|
|
63
66
|
});
|
|
@@ -76,7 +79,7 @@ describe("POST /session/logout with the session lifecycle module", () => {
|
|
|
76
79
|
await lifecycle.join(sid, {
|
|
77
80
|
rp: {
|
|
78
81
|
clientId: "rp-bc",
|
|
79
|
-
backchannelLogoutUri:
|
|
82
|
+
backchannelLogoutUri: peer.uri,
|
|
80
83
|
backchannelLogoutSessionRequired: true,
|
|
81
84
|
frontchannelLogoutUri: undefined,
|
|
82
85
|
frontchannelLogoutSessionRequired: undefined,
|
|
@@ -84,10 +87,6 @@ describe("POST /session/logout with the session lifecycle module", () => {
|
|
|
84
87
|
},
|
|
85
88
|
}),
|
|
86
89
|
).toEqual({ outcome: "joined" });
|
|
87
|
-
const posted = vi
|
|
88
|
-
.spyOn(globalThis, "fetch")
|
|
89
|
-
.mockImplementation(async () => new Response(null, { status: 200 }));
|
|
90
|
-
|
|
91
90
|
const csrfToken = csrfTokenOf(cookies);
|
|
92
91
|
const res = await request(app)
|
|
93
92
|
.post("/session/logout")
|
|
@@ -96,12 +95,13 @@ describe("POST /session/logout with the session lifecycle module", () => {
|
|
|
96
95
|
|
|
97
96
|
expect(res.status).toBe(200);
|
|
98
97
|
expect(await families.isFamilyRevoked(familyId)).toBe(true);
|
|
99
|
-
expect(
|
|
98
|
+
expect(peer.tokens).toHaveLength(1);
|
|
100
99
|
expect(await sessions.get(sid)).toBeNull();
|
|
101
100
|
const store = components.sessionLifecycleStore as SessionLifecycleStore;
|
|
102
101
|
expect(readVersionedSessionLifecycle(await store.read(sid))?.value.state).toBe("closed");
|
|
103
102
|
} finally {
|
|
104
103
|
await handle.dispose();
|
|
104
|
+
await peer.close();
|
|
105
105
|
}
|
|
106
106
|
});
|
|
107
107
|
|
|
@@ -36,7 +36,7 @@ import request from "supertest";
|
|
|
36
36
|
import { afterEach, describe, expect, it } from "vitest";
|
|
37
37
|
import { buildModules } from "../buildModules.mjs";
|
|
38
38
|
import type { Switches } from "../configPath.mjs";
|
|
39
|
-
import type
|
|
39
|
+
import { type Adapters, keyStoreSectionSchema } from "../sections.mjs";
|
|
40
40
|
import {
|
|
41
41
|
capturedRenames,
|
|
42
42
|
inProcessAdapters,
|
|
@@ -110,6 +110,12 @@ const config: AppConfig & Record<string, unknown> = {
|
|
|
110
110
|
},
|
|
111
111
|
rateLimit: { failMode: "open" },
|
|
112
112
|
"standalone-in-memory-code-repository": { defaultExpiresIn: 600 },
|
|
113
|
+
// The in-process limiter's section as core's `reference.conf` ships it.
|
|
114
|
+
"core-rate-limiter-memory": {
|
|
115
|
+
maxBuckets: 10000,
|
|
116
|
+
defaultLimit: { limit: 60, windowSeconds: 60 },
|
|
117
|
+
limits: {},
|
|
118
|
+
},
|
|
113
119
|
};
|
|
114
120
|
|
|
115
121
|
/** What phase one hands `buildModules`: the configuration, with `changes` over every store in process. */
|
|
@@ -165,16 +171,18 @@ const testRepositoriesModule = defineModule({
|
|
|
165
171
|
},
|
|
166
172
|
});
|
|
167
173
|
|
|
174
|
+
// The template's `key-store` section, read by its own schema, without the
|
|
175
|
+
// section's relocation bridge.
|
|
168
176
|
const testKeyStoreModule = defineModule({
|
|
169
|
-
name: "
|
|
170
|
-
|
|
177
|
+
name: "key-store",
|
|
178
|
+
section: { schema: keyStoreSectionSchema },
|
|
171
179
|
provides: {
|
|
172
|
-
keyStore: async ({
|
|
180
|
+
keyStore: async ({ section }) => {
|
|
173
181
|
const factory = createKeyStoreFactory();
|
|
174
182
|
registerBuiltinKeyStores(factory);
|
|
175
183
|
return factory.create({
|
|
176
184
|
type: "local",
|
|
177
|
-
...(
|
|
185
|
+
...(section.local ?? {}),
|
|
178
186
|
});
|
|
179
187
|
},
|
|
180
188
|
},
|
|
@@ -28,7 +28,8 @@ import { createRequire } from "node:module";
|
|
|
28
28
|
import { tmpdir } from "node:os";
|
|
29
29
|
import { join } from "node:path";
|
|
30
30
|
import { fileURLToPath } from "node:url";
|
|
31
|
-
import { describe, expect, it } from "vitest";
|
|
31
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
32
|
+
import { listeningRedis } from "./redis-stand-in.fixture.mjs";
|
|
32
33
|
|
|
33
34
|
const templateRoot = fileURLToPath(new URL("../..", import.meta.url));
|
|
34
35
|
const tsxCli = createRequire(import.meta.url).resolve("tsx/cli");
|
|
@@ -45,14 +46,29 @@ const clientsFile = (() => {
|
|
|
45
46
|
return file;
|
|
46
47
|
})();
|
|
47
48
|
|
|
49
|
+
/**
|
|
50
|
+
* The Redis the refresh-token family store's shared socket dials: a stand-in
|
|
51
|
+
* that reports `noeviction`, so the store passes its eviction gate and the run
|
|
52
|
+
* reaches no real server.
|
|
53
|
+
*/
|
|
54
|
+
let redis: Awaited<ReturnType<typeof listeningRedis>>;
|
|
55
|
+
|
|
56
|
+
beforeAll(async () => {
|
|
57
|
+
redis = await listeningRedis();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterAll(async () => {
|
|
61
|
+
await redis?.close();
|
|
62
|
+
});
|
|
63
|
+
|
|
48
64
|
/**
|
|
49
65
|
* Development, with every store the `adapters` section selects in memory.
|
|
50
66
|
* The refresh-token family store has no memory choice in the template; its
|
|
51
|
-
* shared Redis socket points at
|
|
52
|
-
*
|
|
53
|
-
*
|
|
67
|
+
* shared Redis socket points at the stand-in above. Nothing is inherited from
|
|
68
|
+
* the test runner's environment but PATH, so its NODE_ENV and LOGGING_LEVEL do
|
|
69
|
+
* not reach the child.
|
|
54
70
|
*/
|
|
55
|
-
const
|
|
71
|
+
const env = (): Readonly<Record<string, string>> => ({
|
|
56
72
|
PATH: process.env.PATH ?? "",
|
|
57
73
|
CONFIG_ENV: "development",
|
|
58
74
|
NODE_ENV: "development",
|
|
@@ -74,11 +90,11 @@ const ENV: Readonly<Record<string, string>> = {
|
|
|
74
90
|
ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
|
|
75
91
|
ADAPTERS_MFA_FACTOR_STORE: "memory",
|
|
76
92
|
ADAPTERS_MFA_TRANSACTION_STORE: "memory",
|
|
77
|
-
REDIS_CLIENTS_URL:
|
|
93
|
+
REDIS_CLIENTS_URL: `redis://127.0.0.1:${redis.port}`,
|
|
78
94
|
REPOSITORIES_CLIENT_YAML_PATH: clientsFile,
|
|
79
95
|
REPOSITORIES_USER_HTTP_AUTHENTICATE_URL: "http://127.0.0.1:9/authenticate",
|
|
80
96
|
REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL: "http://127.0.0.1:9/authenticate-by-token",
|
|
81
|
-
};
|
|
97
|
+
});
|
|
82
98
|
|
|
83
99
|
interface Run {
|
|
84
100
|
readonly stdout: string;
|
|
@@ -91,7 +107,7 @@ function startAndStop(timeoutMs: number): Promise<Run> {
|
|
|
91
107
|
return new Promise((resolve, reject) => {
|
|
92
108
|
const child: ChildProcess = spawn(process.execPath, [tsxCli, "src/app.mts"], {
|
|
93
109
|
cwd: templateRoot,
|
|
94
|
-
env:
|
|
110
|
+
env: env(),
|
|
95
111
|
stdio: ["ignore", "pipe", "pipe"],
|
|
96
112
|
});
|
|
97
113
|
let stdout = "";
|
|
@@ -48,7 +48,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
|
48
48
|
import { buildModules } from "../buildModules.mjs";
|
|
49
49
|
import { resolveConfigPaths } from "../configPath.mjs";
|
|
50
50
|
import { repositoriesModuleFor, templateReference } from "../modules.mjs";
|
|
51
|
-
import { repositoriesSectionSchema } from "../sections.mjs";
|
|
51
|
+
import { keyStoreSectionSchema, repositoriesSectionSchema } from "../sections.mjs";
|
|
52
52
|
import {
|
|
53
53
|
type BothPhases,
|
|
54
54
|
bothPhasesOf,
|
|
@@ -186,16 +186,18 @@ describe("a token the Store refuses, seen from outside the booted app", () => {
|
|
|
186
186
|
ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "memory",
|
|
187
187
|
};
|
|
188
188
|
|
|
189
|
+
// The template's `key-store` section, read by its own schema, without the
|
|
190
|
+
// section's relocation bridge.
|
|
189
191
|
const testKeyStoreModule = defineModule({
|
|
190
|
-
name: "
|
|
191
|
-
|
|
192
|
+
name: "key-store",
|
|
193
|
+
section: { schema: keyStoreSectionSchema },
|
|
192
194
|
provides: {
|
|
193
|
-
keyStore: async ({
|
|
195
|
+
keyStore: async ({ section }) => {
|
|
194
196
|
const factory = createKeyStoreFactory();
|
|
195
197
|
registerBuiltinKeyStores(factory);
|
|
196
198
|
return factory.create({
|
|
197
199
|
type: "local",
|
|
198
|
-
...(
|
|
200
|
+
...(section.local ?? {}),
|
|
199
201
|
});
|
|
200
202
|
},
|
|
201
203
|
},
|
|
@@ -50,6 +50,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
|
|
50
50
|
import { buildModules } from "#/buildModules.mjs";
|
|
51
51
|
import { resolveConfigPaths } from "#/configPath.mjs";
|
|
52
52
|
import { templateReference } from "../modules.mjs";
|
|
53
|
+
import { keyStoreSectionSchema } from "../sections.mjs";
|
|
53
54
|
import {
|
|
54
55
|
type BothPhases,
|
|
55
56
|
bothPhasesOf,
|
|
@@ -181,16 +182,18 @@ const testRepositoriesModule = defineModule({
|
|
|
181
182
|
} as never,
|
|
182
183
|
});
|
|
183
184
|
|
|
185
|
+
// The template's `key-store` section, read by its own schema, without the
|
|
186
|
+
// section's relocation bridge.
|
|
184
187
|
const testKeyStoreModule = defineModule({
|
|
185
|
-
name: "
|
|
186
|
-
|
|
188
|
+
name: "key-store",
|
|
189
|
+
section: { schema: keyStoreSectionSchema },
|
|
187
190
|
provides: {
|
|
188
|
-
keyStore: async ({
|
|
191
|
+
keyStore: async ({ section }) => {
|
|
189
192
|
const factory = createKeyStoreFactory();
|
|
190
193
|
registerBuiltinKeyStores(factory);
|
|
191
194
|
return factory.create({
|
|
192
195
|
type: "local",
|
|
193
|
-
...(
|
|
196
|
+
...(section.local ?? {}),
|
|
194
197
|
});
|
|
195
198
|
},
|
|
196
199
|
},
|
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
17
|
import { createApp } from "@o3co/auth-provider-core";
|
|
18
18
|
import express from "express";
|
|
19
|
-
import helmet from "helmet";
|
|
20
19
|
import { buildModules } from "./buildModules.mjs";
|
|
21
20
|
import {
|
|
22
21
|
configDefaultsFor,
|
|
@@ -31,6 +30,7 @@ import { listen } from "./listen.mjs";
|
|
|
31
30
|
import { createAppLogger } from "./logger.mjs";
|
|
32
31
|
import { createMetrics } from "./metrics.mjs";
|
|
33
32
|
import { mountRoutes } from "./routes.mjs";
|
|
33
|
+
import { securityHeaders } from "./securityHeaders.mjs";
|
|
34
34
|
import { installGracefulShutdown } from "./shutdown.mjs";
|
|
35
35
|
|
|
36
36
|
// Step 1: the configuration, in two phases (`configPath.mts`; template
|
|
@@ -57,16 +57,7 @@ await (async (): Promise<void> => {
|
|
|
57
57
|
// `trust proxy` is set from the `http` module's `httpSettings` once boot
|
|
58
58
|
// has it (step 3), before a request can arrive.
|
|
59
59
|
const app = express();
|
|
60
|
-
app.use(
|
|
61
|
-
helmet({
|
|
62
|
-
contentSecurityPolicy: {
|
|
63
|
-
directives: {
|
|
64
|
-
defaultSrc: ["'none'"],
|
|
65
|
-
frameAncestors: ["'none'"],
|
|
66
|
-
},
|
|
67
|
-
},
|
|
68
|
-
}),
|
|
69
|
-
);
|
|
60
|
+
app.use(securityHeaders());
|
|
70
61
|
|
|
71
62
|
// Timing middleware goes ahead of everything so the histogram covers the
|
|
72
63
|
// whole stack — including responses produced by middleware that short-circuits
|