@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.
Files changed (37) hide show
  1. package/package.json +3 -3
  2. package/templates/standalone/README.ja.md +5 -4
  3. package/templates/standalone/README.md +23 -4
  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 +7 -6
  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 +14 -12
  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__/logout-session-cookie.test.mts +113 -0
  18. package/templates/standalone/src/__tests__/own-modules.test.mts +3 -35
  19. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +73 -4
  20. package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +98 -0
  21. package/templates/standalone/src/__tests__/replica-safety.test.mts +7 -4
  22. package/templates/standalone/src/__tests__/repositories-logger.test.mts +119 -0
  23. package/templates/standalone/src/__tests__/sections.test.mts +15 -3
  24. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +8 -10
  25. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +13 -4
  26. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +7 -7
  27. package/templates/standalone/src/__tests__/smoke.test.mts +13 -5
  28. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +24 -8
  29. package/templates/standalone/src/__tests__/store-credential.test.mts +7 -5
  30. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +7 -4
  31. package/templates/standalone/src/app.mts +2 -11
  32. package/templates/standalone/src/modules.mts +9 -3
  33. package/templates/standalone/src/sections.mts +3 -2
  34. package/templates/standalone/src/securityHeaders.mts +36 -0
  35. package/templates/standalone/tests/package-lock.json +95 -95
  36. package/templates/standalone/tests/package.json +1 -1
  37. package/templates/versions.json +18 -18
@@ -0,0 +1,168 @@
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(/&quot;/g, '"')
63
+ .replace(/&#39;/g, "'")
64
+ .replace(/&lt;/g, "<")
65
+ .replace(/&gt;/g, ">")
66
+ .replace(/&amp;/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
+ });
@@ -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
  });
@@ -0,0 +1,113 @@
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 template's two logout endpoints and the session cookie: the session
19
+ * store module expires the cookie of a session destroyed during the request,
20
+ * whichever route destroyed it, so `POST /oauth/logout` — whose package knows
21
+ * no cookie name — and `POST /session/logout` both answer with the cookie
22
+ * expired, with the attributes the login set it with.
23
+ */
24
+
25
+ import type { SessionCookiePolicy } from "@o3co/auth-provider-core";
26
+ import request from "supertest";
27
+ import { describe, expect, it } from "vitest";
28
+ import { authorize, codeFrom, compose, login, redeem } from "./all-modules-composition.fixture.mjs";
29
+
30
+ /** The `Set-Cookie` lines among `lines` for the cookie `name`. */
31
+ const linesNamed = (lines: readonly string[], name: string): string[] =>
32
+ lines.filter((line) => line.startsWith(`${name}=`));
33
+
34
+ /** A response's `Set-Cookie` lines. */
35
+ const setCookies = (res: request.Response): string[] =>
36
+ ([] as string[]).concat(res.headers["set-cookie"] ?? []);
37
+
38
+ /** A `Set-Cookie` line's attributes, lower-cased, with its value and expiry left out. */
39
+ const attributesOf = (line: string): string[] =>
40
+ line
41
+ .split(";")
42
+ .slice(1)
43
+ .map((part) => part.trim().toLowerCase())
44
+ .filter((part) => !part.startsWith("expires=") && !part.startsWith("max-age="))
45
+ .sort();
46
+
47
+ /** The CSRF token a login reissued, read off its double-submit cookie. */
48
+ const csrfTokenOf = (cookies: readonly string[]): string => {
49
+ const cookie = cookies.find((c) => /^[^=]*\.csrf=/.test(c));
50
+ if (cookie === undefined) throw new Error("the login reissued no CSRF cookie");
51
+ return decodeURIComponent(cookie.slice(cookie.indexOf("=") + 1).split(";")[0] ?? "");
52
+ };
53
+
54
+ /**
55
+ * The one `Set-Cookie` line of `res` for the session cookie, checked to
56
+ * expire it with the attributes of `setLine`, the line the login set it with.
57
+ */
58
+ function expectExpired(res: request.Response, name: string, setLine: string): void {
59
+ const lines = linesNamed(setCookies(res), name);
60
+ expect(lines).toHaveLength(1);
61
+ const [line] = lines as [string];
62
+ expect(line.startsWith(`${name}=;`)).toBe(true);
63
+ expect(line).toContain("Expires=Thu, 01 Jan 1970 00:00:00 GMT");
64
+ expect(attributesOf(line)).toEqual(attributesOf(setLine));
65
+ }
66
+
67
+ describe("the session cookie at the template's logout endpoints", () => {
68
+ it("POST /oauth/logout expires the session cookie, with the attributes the login set it with", async () => {
69
+ const { app, handle } = await compose();
70
+ try {
71
+ const policy = handle.components.sessionCookiePolicy as SessionCookiePolicy;
72
+ const { res: signedIn, cookies } = await login(app);
73
+ expect(signedIn.status).toBe(200);
74
+ const [setLine] = linesNamed(cookies, policy.name);
75
+ expect(setLine).toBeDefined();
76
+ const tokens = await redeem(app, codeFrom(await authorize(app, cookies)));
77
+ expect(tokens.status).toBe(200);
78
+
79
+ const res = await request(app)
80
+ .post("/oauth/logout")
81
+ .set("Cookie", cookies)
82
+ .type("form")
83
+ .send({ id_token_hint: tokens.body.id_token as string });
84
+
85
+ expect(res.status).toBe(200);
86
+ expect(res.body).toEqual({ logged_out: true });
87
+ expectExpired(res, policy.name, setLine as string);
88
+ } finally {
89
+ await handle.dispose();
90
+ }
91
+ });
92
+
93
+ it("POST /session/logout expires the session cookie, with the attributes the login set it with", async () => {
94
+ const { app, handle } = await compose();
95
+ try {
96
+ const policy = handle.components.sessionCookiePolicy as SessionCookiePolicy;
97
+ const { res: signedIn, cookies } = await login(app);
98
+ expect(signedIn.status).toBe(200);
99
+ const [setLine] = linesNamed(cookies, policy.name);
100
+ expect(setLine).toBeDefined();
101
+
102
+ const res = await request(app)
103
+ .post("/session/logout")
104
+ .set("Cookie", cookies)
105
+ .set("x-csrf-token", csrfTokenOf(cookies));
106
+
107
+ expect(res.status).toBe(200);
108
+ expectExpired(res, policy.name, setLine as string);
109
+ } finally {
110
+ await handle.dispose();
111
+ }
112
+ });
113
+ });
@@ -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"]),