@o3co/create-auth-provider 0.17.0-rc.4 → 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.
Files changed (32) hide show
  1. package/package.json +1 -1
  2. package/templates/standalone/README.ja.md +2 -3
  3. package/templates/standalone/README.md +2 -3
  4. package/templates/standalone/config/clients.yaml.example +11 -5
  5. package/templates/standalone/config/reference.conf +5 -2
  6. package/templates/standalone/package.json +2 -1
  7. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +6 -10
  8. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +2 -2
  9. package/templates/standalone/src/__tests__/audit-sink.test.mts +13 -4
  10. package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +64 -0
  11. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +11 -10
  12. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +7 -4
  13. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +7 -4
  14. package/templates/standalone/src/__tests__/frontchannel-logout-csp.test.mts +168 -0
  15. package/templates/standalone/src/__tests__/google-session.fixture.mts +17 -7
  16. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +13 -12
  17. package/templates/standalone/src/__tests__/own-modules.test.mts +3 -35
  18. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +73 -4
  19. package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +98 -0
  20. package/templates/standalone/src/__tests__/replica-safety.test.mts +7 -4
  21. package/templates/standalone/src/__tests__/sections.test.mts +15 -3
  22. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +8 -10
  23. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +13 -4
  24. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +7 -7
  25. package/templates/standalone/src/__tests__/smoke.test.mts +13 -5
  26. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +24 -8
  27. package/templates/standalone/src/__tests__/store-credential.test.mts +7 -5
  28. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +7 -4
  29. package/templates/standalone/src/app.mts +2 -11
  30. package/templates/standalone/src/sections.mts +3 -2
  31. package/templates/standalone/src/securityHeaders.mts +36 -0
  32. package/templates/versions.json +18 -18
@@ -54,6 +54,7 @@ import { expect } from "vitest";
54
54
  import { buildModules } from "#/buildModules.mjs";
55
55
  import { resolveConfigPaths } from "#/configPath.mjs";
56
56
  import { templateReference } from "../modules.mjs";
57
+ import { keyStoreSectionSchema } from "../sections.mjs";
57
58
  import {
58
59
  type BothPhases,
59
60
  bothPhasesOf,
@@ -179,16 +180,18 @@ const testRepositoriesModule = defineModule({
179
180
  } as never,
180
181
  });
181
182
 
183
+ // The template's `key-store` section, read by its own schema, without the
184
+ // section's relocation bridge.
182
185
  const testKeyStoreModule = defineModule({
183
- name: "test:key-store",
184
- requires: ["config"] as const,
186
+ name: "key-store",
187
+ section: { schema: keyStoreSectionSchema },
185
188
  provides: {
186
- keyStore: async ({ config: c }) => {
189
+ keyStore: async ({ section }) => {
187
190
  const factory = createKeyStoreFactory();
188
191
  registerBuiltinKeyStores(factory);
189
192
  return factory.create({
190
193
  type: "local",
191
- ...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
194
+ ...(section.local ?? {}),
192
195
  });
193
196
  },
194
197
  },
@@ -216,11 +219,18 @@ export const idTokenHint = (sid: string): string => signed("JWT", { aud: CLIENT_
216
219
 
217
220
  /**
218
221
  * An access token for the session as the authorization-code grant issues it
219
- * to the client: `azp`, `sid` and a refresh-token `family_id`. The `session`
220
- * grant's token names no family, and the federation token route asks for one.
222
+ * to the client, for the client itself: `aud` and `azp`, `sid` and a
223
+ * refresh-token `family_id`. The `session` grant's token names no family, and
224
+ * the federation token route asks for one.
221
225
  */
222
226
  export const accessTokenWithFamily = (sid: string): string =>
223
- signed("at+jwt", { azp: CLIENT_ID, sid, family_id: "family-1", scope: "openid" });
227
+ signed("at+jwt", {
228
+ aud: CLIENT_ID,
229
+ azp: CLIENT_ID,
230
+ sid,
231
+ family_id: "family-1",
232
+ scope: "openid",
233
+ });
224
234
 
225
235
  const claimsOf = (jwt: string): Record<string, unknown> =>
226
236
  JSON.parse(Buffer.from(jwt.split(".")[1] ?? "", "base64url").toString("utf8")) as Record<
@@ -33,6 +33,7 @@ import {
33
33
  redeem,
34
34
  WEB,
35
35
  } from "./all-modules-composition.fixture.mjs";
36
+ import { backchannelPeer, withLoopbackRelyingParties } from "./backchannel-peer.fixture.mjs";
36
37
 
37
38
  afterEach(() => {
38
39
  vi.restoreAllMocks();
@@ -44,7 +45,9 @@ const claimsOf = (token: string): Record<string, unknown> =>
44
45
 
45
46
  describe("a session whose close is pending on a failed relying-party notice", () => {
46
47
  it("is refused by introspection, userinfo and the session grant, its user session still there", async () => {
48
+ const peer = await backchannelPeer(503);
47
49
  const { app, handle } = await compose({
50
+ config: withLoopbackRelyingParties,
48
51
  extraClients: {
49
52
  "rp-down": {
50
53
  tokenEndpointAuthMethod: "client_secret_basic",
@@ -52,7 +55,7 @@ describe("a session whose close is pending on a failed relying-party notice", ()
52
55
  allowedRedirectUris: ["https://rp-down.test/cb"],
53
56
  allowedScopes: ["openid"],
54
57
  allowedGrantTypes: ["authorization_code"],
55
- backchannelLogoutUri: "https://rp-down.test/logout",
58
+ backchannelLogoutUri: peer.uri,
56
59
  },
57
60
  },
58
61
  });
@@ -89,7 +92,7 @@ describe("a session whose close is pending on a failed relying-party notice", ()
89
92
  await lifecycle.join(sid, {
90
93
  rp: {
91
94
  clientId: "rp-down",
92
- backchannelLogoutUri: "https://rp-down.test/logout",
95
+ backchannelLogoutUri: peer.uri,
93
96
  backchannelLogoutSessionRequired: true,
94
97
  frontchannelLogoutUri: undefined,
95
98
  frontchannelLogoutSessionRequired: undefined,
@@ -98,11 +101,8 @@ describe("a session whose close is pending on a failed relying-party notice", ()
98
101
  familyId: "family-down",
99
102
  }),
100
103
  ).toEqual({ outcome: "joined" });
101
- const posted = vi
102
- .spyOn(globalThis, "fetch")
103
- .mockImplementation(async () => new Response(null, { status: 503 }));
104
104
  expect((await lifecycle.close(sid, "rp_logout")).outcome).toBe("pending");
105
- expect(posted).toHaveBeenCalled();
105
+ expect(peer.tokens.length).toBeGreaterThan(0);
106
106
  expect(await sessions.get(sid)).not.toBeNull();
107
107
 
108
108
  const afterIntrospect = await introspect();
@@ -121,11 +121,14 @@ describe("a session whose close is pending on a failed relying-party notice", ()
121
121
  });
122
122
  } finally {
123
123
  await handle.dispose();
124
+ await peer.close();
124
125
  }
125
126
  });
126
127
 
127
128
  it("an RP-initiated logout whose notice fails answers success, and its session is refused by introspection, userinfo and the session grant", async () => {
129
+ const peer = await backchannelPeer(503);
128
130
  const { app, handle } = await compose({
131
+ config: withLoopbackRelyingParties,
129
132
  extraClients: {
130
133
  "rp-down": {
131
134
  tokenEndpointAuthMethod: "client_secret_basic",
@@ -133,7 +136,7 @@ describe("a session whose close is pending on a failed relying-party notice", ()
133
136
  allowedRedirectUris: ["https://rp-down.test/cb"],
134
137
  allowedScopes: ["openid"],
135
138
  allowedGrantTypes: ["authorization_code"],
136
- backchannelLogoutUri: "https://rp-down.test/logout",
139
+ backchannelLogoutUri: peer.uri,
137
140
  },
138
141
  },
139
142
  });
@@ -166,7 +169,7 @@ describe("a session whose close is pending on a failed relying-party notice", ()
166
169
  await lifecycle.join(sid, {
167
170
  rp: {
168
171
  clientId: "rp-down",
169
- backchannelLogoutUri: "https://rp-down.test/logout",
172
+ backchannelLogoutUri: peer.uri,
170
173
  backchannelLogoutSessionRequired: true,
171
174
  frontchannelLogoutUri: undefined,
172
175
  frontchannelLogoutSessionRequired: undefined,
@@ -174,9 +177,6 @@ describe("a session whose close is pending on a failed relying-party notice", ()
174
177
  },
175
178
  }),
176
179
  ).toEqual({ outcome: "joined" });
177
- const posted = vi
178
- .spyOn(globalThis, "fetch")
179
- .mockImplementation(async () => new Response(null, { status: 503 }));
180
180
 
181
181
  const logout = await request(app)
182
182
  .post("/oauth/logout")
@@ -186,7 +186,7 @@ describe("a session whose close is pending on a failed relying-party notice", ()
186
186
  // The close committed with the notice pending: the logout succeeded.
187
187
  expect(logout.status).toBe(200);
188
188
  expect(logout.body).toEqual({ logged_out: true });
189
- expect(posted).toHaveBeenCalled();
189
+ expect(peer.tokens.length).toBeGreaterThan(0);
190
190
  expect(await sessions.get(sid)).not.toBeNull();
191
191
 
192
192
  const introspected = await request(app)
@@ -211,6 +211,7 @@ describe("a session whose close is pending on a failed relying-party notice", ()
211
211
  });
212
212
  } finally {
213
213
  await handle.dispose();
214
+ await peer.close();
214
215
  }
215
216
  });
216
217
  });
@@ -27,9 +27,7 @@
27
27
  */
28
28
 
29
29
  import { generateKeyPairSync } from "node:crypto";
30
- import { once } from "node:events";
31
30
  import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
32
- import { type AddressInfo, createServer, type Socket } from "node:net";
33
31
  import { tmpdir } from "node:os";
34
32
  import { join } from "node:path";
35
33
  import { fileURLToPath } from "node:url";
@@ -47,8 +45,9 @@ import {
47
45
  moduleReferences,
48
46
  supportsMfaEnrollmentWitness,
49
47
  } from "@o3co/auth-provider-core";
50
- import { httpSettingsContract, packageReferenceProblems } from "@o3co/auth-provider-core/testing";
48
+ import { packageReferenceProblems } from "@o3co/auth-provider-core/testing";
51
49
  import { HttpUserRepository } from "@o3co/auth-provider-foundation";
50
+ import { httpSettingsContract } from "@o3co/auth-provider-test-kit";
52
51
  import { parseFile } from "@o3co/ts.hocon";
53
52
  import express from "express";
54
53
  import request from "supertest";
@@ -74,6 +73,7 @@ import {
74
73
  standaloneRedisClientsModule,
75
74
  } from "../modules.mjs";
76
75
  import { repositoriesSectionSchema } from "../sections.mjs";
76
+ import { listeningRedis } from "./redis-stand-in.fixture.mjs";
77
77
 
78
78
  const configDir = fileURLToPath(new URL("../../config", import.meta.url));
79
79
  /** The template's own defaults: what its modules declare as their sections' reference. */
@@ -990,38 +990,6 @@ describe("key-store", () => {
990
990
  });
991
991
  });
992
992
 
993
- /**
994
- * A TCP server standing in for Redis: it records what each connection sends
995
- * and answers nothing, so a client dialled at it connects and writes its
996
- * handshake.
997
- */
998
- async function listeningRedis(): Promise<{
999
- readonly port: number;
1000
- readonly received: () => string;
1001
- readonly close: () => Promise<void>;
1002
- }> {
1003
- const sockets = new Set<Socket>();
1004
- let received = "";
1005
- const server = createServer((socket) => {
1006
- sockets.add(socket);
1007
- socket.on("close", () => sockets.delete(socket));
1008
- socket.on("data", (chunk) => {
1009
- received += chunk.toString("utf8");
1010
- });
1011
- });
1012
- server.listen(0, "127.0.0.1");
1013
- await once(server, "listening");
1014
- const { port } = server.address() as AddressInfo;
1015
- return {
1016
- port,
1017
- received: () => received,
1018
- close: async () => {
1019
- for (const socket of sockets) socket.destroy();
1020
- await new Promise<void>((resolve) => server.close(() => resolve()));
1021
- },
1022
- };
1023
- }
1024
-
1025
993
  describe("redis-clients", () => {
1026
994
  it("owns redis-clients, and reads it as its section rather than the configuration", () => {
1027
995
  expect(standaloneRedisClientsModule.name).toBe("redis-clients");
@@ -107,8 +107,11 @@ const MFA_ON_REDIS: Readonly<Record<string, string>> = {
107
107
  STANDARD_SMTP_MAIL_SENDER_FROM: "auth@auth.test",
108
108
  };
109
109
 
110
- const composing = (env: Readonly<Record<string, string>>): Promise<unknown> =>
111
- compose({ env, environment: "production", shippedRefreshTokenFamilyStore: true }).then(
110
+ const composing = (
111
+ env: Readonly<Record<string, string>>,
112
+ shippedRefreshTokenFamilyStore = true,
113
+ ): Promise<unknown> =>
114
+ compose({ env, environment: "production", shippedRefreshTokenFamilyStore }).then(
112
115
  (composition) => {
113
116
  current = composition;
114
117
  return undefined;
@@ -157,9 +160,75 @@ describe("a Redis that will not report its eviction policy", () => {
157
160
  });
158
161
  });
159
162
 
163
+ describe("the shipped refresh-token family store on a Redis that will not report its eviction policy", () => {
164
+ it.each([
165
+ ["unset", SINGLE_ENV],
166
+ ["false", { ...SINGLE_ENV, REDIS_CLIENTS_ASSUME_NO_EVICTION: "false" }],
167
+ ])(
168
+ "refuses the boot with REDIS_CLIENTS_ASSUME_NO_EVICTION %s, naming the store",
169
+ async (_label, env) => {
170
+ expect(await composing(env)).toMatchObject({
171
+ reason: "provides-factory-failed",
172
+ details: {
173
+ module: "redis-refresh-token-family-store",
174
+ componentKey: "refreshTokenFamilyStore",
175
+ },
176
+ cause: {
177
+ name: "RedisStoreEvictableError",
178
+ reason: "refresh-token-family-store-evictable",
179
+ maxmemoryPolicy: undefined,
180
+ },
181
+ });
182
+ },
183
+ );
184
+
185
+ it("boots with REDIS_CLIENTS_ASSUME_NO_EVICTION=true", async () => {
186
+ expect(
187
+ await composing({ ...SINGLE_ENV, REDIS_CLIENTS_ASSUME_NO_EVICTION: "true" }),
188
+ ).toBeUndefined();
189
+ expect(current?.handle.components.refreshTokenFamilyStore?.kind).toBe("redis");
190
+ });
191
+ });
192
+
193
+ describe("the replay seen-set on a Redis that will not report its eviction policy", () => {
194
+ /** Only the seen-set on Redis: a refusal can come from no other store. */
195
+ const REPLAY_ONLY = {
196
+ ...SINGLE_ENV,
197
+ ADAPTERS_REPLAY_SEEN_SET: "redis",
198
+ REDIS_CLIENTS_URL: "redis://redis.test:6379",
199
+ };
200
+ const composingReplayOnly = (env: Readonly<Record<string, string>>) => composing(env, false);
201
+
202
+ it.each([
203
+ ["unset", REPLAY_ONLY],
204
+ ["false", { ...REPLAY_ONLY, REDIS_CLIENTS_ASSUME_NO_EVICTION: "false" }],
205
+ ])(
206
+ "refuses the boot with REDIS_CLIENTS_ASSUME_NO_EVICTION %s, naming the store",
207
+ async (_label, env) => {
208
+ expect(await composingReplayOnly(env)).toMatchObject({
209
+ reason: "provides-factory-failed",
210
+ details: { module: "redis-replay-seen-set", componentKey: "replaySeenSet" },
211
+ cause: {
212
+ name: "RedisStoreEvictableError",
213
+ reason: "replay-seen-set-evictable",
214
+ maxmemoryPolicy: undefined,
215
+ },
216
+ });
217
+ },
218
+ );
219
+
220
+ it("boots with REDIS_CLIENTS_ASSUME_NO_EVICTION=true", async () => {
221
+ expect(
222
+ await composingReplayOnly({ ...REPLAY_ONLY, REDIS_CLIENTS_ASSUME_NO_EVICTION: "true" }),
223
+ ).toBeUndefined();
224
+ expect(current?.handle.components.replaySeenSet?.kind).toBe("redis");
225
+ });
226
+ });
227
+
160
228
  describe("the MFA stores on a Redis that will not report its eviction policy", () => {
161
229
  /** Only the MFA stores on Redis: a refusal can come from no other store. */
162
230
  const MFA_ONLY = { ...SINGLE_ENV, ...MFA_ON_REDIS };
231
+ const composingMfaOnly = (env: Readonly<Record<string, string>>) => composing(env, false);
163
232
 
164
233
  it.each([
165
234
  ["unset", MFA_ONLY],
@@ -167,7 +236,7 @@ describe("the MFA stores on a Redis that will not report its eviction policy", (
167
236
  ])(
168
237
  "refuses the boot with REDIS_CLIENTS_ASSUME_NO_EVICTION %s, naming an MFA store",
169
238
  async (_label, env) => {
170
- const err = await composing(env);
239
+ const err = await composingMfaOnly(env);
171
240
  expect(err).toBeInstanceOf(BootError);
172
241
  expect(err).toMatchObject({
173
242
  reason: "provides-factory-failed",
@@ -182,7 +251,7 @@ describe("the MFA stores on a Redis that will not report its eviction policy", (
182
251
 
183
252
  it("boots both MFA stores with REDIS_CLIENTS_ASSUME_NO_EVICTION=true", async () => {
184
253
  expect(
185
- await composing({ ...MFA_ONLY, REDIS_CLIENTS_ASSUME_NO_EVICTION: "true" }),
254
+ await composingMfaOnly({ ...MFA_ONLY, REDIS_CLIENTS_ASSUME_NO_EVICTION: "true" }),
186
255
  ).toBeUndefined();
187
256
  expect(current?.modules.map((module) => module.name)).toEqual(
188
257
  expect.arrayContaining(["redis-mfa-factor-store", "redis-mfa-transaction-store"]),
@@ -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: "test:key-store",
203
- requires: ["config"] as const,
205
+ name: "key-store",
206
+ section: { schema: keyStoreSectionSchema },
204
207
  provides: {
205
- keyStore: async ({ config: c }) => {
208
+ keyStore: async ({ section }) => {
206
209
  const factory = createKeyStoreFactory();
207
210
  registerBuiltinKeyStores(factory);
208
211
  return factory.create({
209
212
  type: "local",
210
- ...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
213
+ ...(section.local ?? {}),
211
214
  });
212
215
  },
213
216
  },
@@ -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(redisClientsSectionSchema.parse({ url: "redis://r:6379", password: "p" })).toEqual({
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(redisClientsSectionSchema.parse({ url: "redis://r:6379" })).toEqual({
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
- ["a key the section does not declare", { url: "redis://r:6379", db: 2 }],
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: "https://rp-bc.test/logout",
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: "https://rp-bc.test/logout",
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(posted).toHaveBeenCalledTimes(1);
116
- const [url, init] = posted.mock.calls[0] as [string, RequestInit];
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: "test:key-store",
160
- requires: ["config"] as const,
168
+ name: "key-store",
169
+ section: { schema: keyStoreSectionSchema },
161
170
  provides: {
162
- keyStore: async ({ config: c }) => {
171
+ keyStore: async ({ section }) => {
163
172
  const factory = createKeyStoreFactory();
164
173
  registerBuiltinKeyStores(factory);
165
174
  return factory.create({
166
175
  type: "local",
167
- ...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
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: "https://rp-bc.test/logout",
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: "https://rp-bc.test/logout",
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(posted.mock.calls.map(([url]) => String(url))).toEqual(["https://rp-bc.test/logout"]);
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