@o3co/create-auth-provider 0.17.0 → 0.18.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +2 -198
  2. package/main.js +4 -0
  3. package/package.json +8 -30
  4. package/README.ja.md +0 -200
  5. package/dist/cli.d.mts +0 -3
  6. package/dist/cli.d.mts.map +0 -1
  7. package/dist/cli.mjs +0 -18
  8. package/dist/index.d.mts +0 -48
  9. package/dist/index.d.mts.map +0 -1
  10. package/dist/index.mjs +0 -325
  11. package/dist/internal/template-filter.d.mts +0 -14
  12. package/dist/internal/template-filter.d.mts.map +0 -1
  13. package/dist/internal/template-filter.mjs +0 -42
  14. package/templates/standalone/.dockerignore +0 -23
  15. package/templates/standalone/.env.example +0 -152
  16. package/templates/standalone/Dockerfile +0 -125
  17. package/templates/standalone/Makefile +0 -12
  18. package/templates/standalone/README.ja.md +0 -912
  19. package/templates/standalone/README.md +0 -1536
  20. package/templates/standalone/config/application.conf +0 -351
  21. package/templates/standalone/config/clients.yaml +0 -4
  22. package/templates/standalone/config/clients.yaml.example +0 -124
  23. package/templates/standalone/config/development.conf +0 -17
  24. package/templates/standalone/config/mailpit.conf +0 -13
  25. package/templates/standalone/config/production.conf +0 -15
  26. package/templates/standalone/config/reference.conf +0 -427
  27. package/templates/standalone/docker-compose.mailpit.yml +0 -39
  28. package/templates/standalone/docker-compose.production.yml +0 -229
  29. package/templates/standalone/docker-compose.test.yml +0 -16
  30. package/templates/standalone/docker-compose.yml +0 -67
  31. package/templates/standalone/gitignore +0 -23
  32. package/templates/standalone/package.json +0 -56
  33. package/templates/standalone/src/__tests__/adapters.test.mts +0 -425
  34. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +0 -87
  35. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +0 -1188
  36. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +0 -285
  37. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +0 -1154
  38. package/templates/standalone/src/__tests__/audit-sink.test.mts +0 -355
  39. package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +0 -64
  40. package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
  41. package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
  42. package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
  43. package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
  44. package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
  45. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
  46. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1291
  47. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -787
  48. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -464
  49. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
  50. package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
  51. package/templates/standalone/src/__tests__/frontchannel-logout-csp.test.mts +0 -168
  52. package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -318
  53. package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
  54. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
  55. package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
  56. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -217
  57. package/templates/standalone/src/__tests__/listen.test.mts +0 -121
  58. package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
  59. package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
  60. package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
  61. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
  62. package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
  63. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
  64. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
  65. package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
  66. package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1480
  67. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
  68. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -283
  69. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
  70. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
  71. package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +0 -98
  72. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
  73. package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -657
  74. package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
  75. package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
  76. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
  77. package/templates/standalone/src/__tests__/sections.test.mts +0 -516
  78. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -213
  79. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -305
  80. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
  81. package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
  82. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
  83. package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
  84. package/templates/standalone/src/__tests__/smoke.test.mts +0 -642
  85. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -164
  86. package/templates/standalone/src/__tests__/store-credential.test.mts +0 -314
  87. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
  88. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
  89. package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
  90. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -385
  91. package/templates/standalone/src/adapters.mts +0 -162
  92. package/templates/standalone/src/app.mts +0 -127
  93. package/templates/standalone/src/bootRefusal.mts +0 -117
  94. package/templates/standalone/src/buildModules.mts +0 -358
  95. package/templates/standalone/src/configPath.mts +0 -487
  96. package/templates/standalone/src/listen.mts +0 -60
  97. package/templates/standalone/src/logger.mts +0 -80
  98. package/templates/standalone/src/metrics.mts +0 -167
  99. package/templates/standalone/src/mfaSwitch.mts +0 -284
  100. package/templates/standalone/src/modules.mts +0 -705
  101. package/templates/standalone/src/rootRenames.mts +0 -95
  102. package/templates/standalone/src/routes.mts +0 -89
  103. package/templates/standalone/src/sections.mts +0 -378
  104. package/templates/standalone/src/securityHeaders.mts +0 -36
  105. package/templates/standalone/src/shutdown.mts +0 -213
  106. package/templates/standalone/tests/Makefile +0 -5
  107. package/templates/standalone/tests/index.test.js +0 -283
  108. package/templates/standalone/tests/package-lock.json +0 -1617
  109. package/templates/standalone/tests/package.json +0 -16
  110. package/templates/standalone/tests/vitest.config.mjs +0 -26
  111. package/templates/standalone/tsconfig.json +0 -31
  112. package/templates/standalone/vitest.config.mts +0 -31
  113. package/templates/standalone/vitest.supertest-loopback.mts +0 -278
  114. package/templates/versions.json +0 -20
@@ -1,183 +0,0 @@
1
- /*
2
- * Copyright 2026 1o1 Co. Ltd.
3
- *
4
- * Licensed under the Apache License, Version 2.0 (the "License");
5
- * you may not use this file except in compliance with the License.
6
- * You may obtain a copy of the License at
7
- *
8
- * http://www.apache.org/licenses/LICENSE-2.0
9
- *
10
- * Unless required by applicable law or agreed to in writing, software
11
- * distributed under the License is distributed on an "AS IS" BASIS,
12
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
- * See the License for the specific language governing permissions and
14
- * limitations under the License.
15
- */
16
-
17
- /**
18
- * A logout's `post_logout_redirect_uri` reaches the upstream federation's
19
- * end-session call only once it has matched one of the client's registered
20
- * `postLogoutRedirectUris` — at `/oauth/logout` and at
21
- * `POST /oauth/federation/:name/logout` alike.
22
- *
23
- * Google publishes no end-session endpoint, and without one configured its
24
- * adapter sends the browser straight to the `postLogoutRedirectUri` it is
25
- * handed: unchecked, a caller's own fresh id_token would make this provider's
26
- * origin answer `303` to any site. With an end-session endpoint configured,
27
- * an unchecked value would be forwarded to the upstream instead. The real
28
- * Google adapter is composed (`google-session.fixture.mts`), so the redirect
29
- * asserted is the one a browser would follow.
30
- */
31
-
32
- import type express from "express";
33
- import request from "supertest";
34
- import { afterEach, describe, expect, it } from "vitest";
35
- import {
36
- type GoogleSession,
37
- type GoogleWiring,
38
- idTokenHint,
39
- REGISTERED_POST_LOGOUT_REDIRECT_URI as REGISTERED,
40
- signInLinkedToGoogle,
41
- } from "./google-session.fixture.mjs";
42
-
43
- const UNREGISTERED = "https://evil.example/landing";
44
- const GOOGLE_LOGOUT = "https://accounts.google.com/Logout";
45
- const UPSTREAM_END_SESSION = "https://idp-logout.example/end-session";
46
- const UPSTREAM_ID_TOKEN = "upstream-id-token";
47
-
48
- /** Google's end-session endpoint, where a deployment names one. */
49
- const WITH_END_SESSION: GoogleWiring = { endSessionEndpoint: UPSTREAM_END_SESSION };
50
-
51
- describe("a logout's post_logout_redirect_uri reaches the upstream only once it is registered", () => {
52
- let session: GoogleSession | undefined;
53
-
54
- afterEach(async () => {
55
- await session?.dispose();
56
- session = undefined;
57
- });
58
-
59
- /** Alice signed in, with Google linked and its id_token held for the end-session call. */
60
- async function signedInWithGoogle(google: GoogleWiring): Promise<GoogleSession> {
61
- session = await signInLinkedToGoogle({
62
- google,
63
- tokens: {
64
- accessToken: "upstream-access-token",
65
- expiresAt: null,
66
- refreshToken: undefined,
67
- idToken: UPSTREAM_ID_TOKEN,
68
- tokenType: "Bearer",
69
- scope: undefined,
70
- grantedScope: undefined,
71
- obtainedAt: undefined,
72
- },
73
- });
74
- return session;
75
- }
76
-
77
- const rpLogout = (app: express.Express, sid: string, postLogoutRedirectUri: string) =>
78
- request(app)
79
- .get("/oauth/logout")
80
- .query({
81
- id_token_hint: idTokenHint(sid),
82
- post_logout_redirect_uri: postLogoutRedirectUri,
83
- state: "s-1",
84
- });
85
-
86
- const federationLogout = (
87
- app: express.Express,
88
- accessToken: string,
89
- postLogoutRedirectUri: string,
90
- ) =>
91
- request(app)
92
- .post("/oauth/federation/google/logout")
93
- .set("Authorization", `Bearer ${accessToken}`)
94
- .type("form")
95
- .send({ post_logout_redirect_uri: postLogoutRedirectUri, state: "s-1" });
96
-
97
- describe("GET /oauth/logout, Google with no end-session endpoint", () => {
98
- it("does not send the browser to an unregistered post_logout_redirect_uri", async () => {
99
- const { app, sid, userSessionStore } = await signedInWithGoogle("shipped");
100
-
101
- const res = await rpLogout(app, sid, UNREGISTERED);
102
-
103
- expect(res.status).toBe(303);
104
- expect(new URL(res.headers.location as string).origin).not.toBe("https://evil.example");
105
- // Google's own logout page: the adapter's answer when it is handed no
106
- // redirect target, which is what an unregistered one becomes.
107
- expect(res.headers.location).toBe(`${GOOGLE_LOGOUT}?state=s-1`);
108
- // The logout itself still happened.
109
- expect(await userSessionStore.get(sid)).toBeNull();
110
- });
111
-
112
- it("still returns the browser to a registered post_logout_redirect_uri, with state", async () => {
113
- const { app, sid, userSessionStore } = await signedInWithGoogle("shipped");
114
-
115
- const res = await rpLogout(app, sid, REGISTERED);
116
-
117
- expect(res.status).toBe(303);
118
- expect(res.headers.location).toBe(`${REGISTERED}?state=s-1`);
119
- expect(await userSessionStore.get(sid)).toBeNull();
120
- });
121
- });
122
-
123
- describe("GET /oauth/logout, Google with a configured end-session endpoint", () => {
124
- it("does not forward an unregistered post_logout_redirect_uri to the upstream", async () => {
125
- const { app, sid } = await signedInWithGoogle(WITH_END_SESSION);
126
-
127
- const res = await rpLogout(app, sid, UNREGISTERED);
128
-
129
- expect(res.status).toBe(303);
130
- const location = new URL(res.headers.location as string);
131
- expect(`${location.origin}${location.pathname}`).toBe(UPSTREAM_END_SESSION);
132
- expect(location.searchParams.get("id_token_hint")).toBe(UPSTREAM_ID_TOKEN);
133
- expect(location.searchParams.has("post_logout_redirect_uri")).toBe(false);
134
- expect(res.headers.location).not.toContain("evil.example");
135
- });
136
-
137
- it("forwards a registered one", async () => {
138
- const { app, sid } = await signedInWithGoogle(WITH_END_SESSION);
139
-
140
- const res = await rpLogout(app, sid, REGISTERED);
141
-
142
- expect(res.status).toBe(303);
143
- const location = new URL(res.headers.location as string);
144
- expect(`${location.origin}${location.pathname}`).toBe(UPSTREAM_END_SESSION);
145
- expect(location.searchParams.get("post_logout_redirect_uri")).toBe(REGISTERED);
146
- expect(location.searchParams.get("state")).toBe("s-1");
147
- });
148
- });
149
-
150
- describe("POST /oauth/federation/google/logout", () => {
151
- it("does not send the browser to an unregistered post_logout_redirect_uri", async () => {
152
- const { app, sid, accessToken, federationTokenStore } = await signedInWithGoogle("shipped");
153
-
154
- const res = await federationLogout(app, accessToken, UNREGISTERED);
155
-
156
- expect(res.status).toBe(303);
157
- expect(new URL(res.headers.location as string).origin).not.toBe("https://evil.example");
158
- expect(res.headers.location).toBe(`${GOOGLE_LOGOUT}?state=s-1`);
159
- // The federation was disconnected all the same: its tokens are gone.
160
- expect(await federationTokenStore.get(sid, "google")).toBeNull();
161
- });
162
-
163
- it("still returns the browser to one registered for the token's client", async () => {
164
- const { app, accessToken } = await signedInWithGoogle("shipped");
165
-
166
- const res = await federationLogout(app, accessToken, REGISTERED);
167
-
168
- expect(res.status).toBe(303);
169
- expect(res.headers.location).toBe(`${REGISTERED}?state=s-1`);
170
- });
171
-
172
- it("does not forward an unregistered one to a configured end-session endpoint", async () => {
173
- const { app, accessToken } = await signedInWithGoogle(WITH_END_SESSION);
174
-
175
- const res = await federationLogout(app, accessToken, UNREGISTERED);
176
-
177
- expect(res.status).toBe(303);
178
- const location = new URL(res.headers.location as string);
179
- expect(`${location.origin}${location.pathname}`).toBe(UPSTREAM_END_SESSION);
180
- expect(location.searchParams.has("post_logout_redirect_uri")).toBe(false);
181
- });
182
- });
183
- });
@@ -1,283 +0,0 @@
1
- /*
2
- * Copyright 2026 1o1 Co. Ltd.
3
- *
4
- * Licensed under the Apache License, Version 2.0 (the "License");
5
- * you may not use this file except in compliance with the License.
6
- * You may obtain a copy of the License at
7
- *
8
- * http://www.apache.org/licenses/LICENSE-2.0
9
- *
10
- * Unless required by applicable law or agreed to in writing, software
11
- * distributed under the License is distributed on an "AS IS" BASIS,
12
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
- * See the License for the specific language governing permissions and
14
- * limitations under the License.
15
- */
16
-
17
- /**
18
- * `redis-clients.assumeNoEviction` (`REDIS_CLIENTS_ASSUME_NO_EVICTION`), on a
19
- * Redis that will not report its eviction policy: every store on Redis, as a
20
- * multi-replica deployment selects them. ioredis is a stand-in that refuses
21
- * `INFO` and `CONFIG` as an ACL-restricted user's connection does; every
22
- * other command resolves to nothing. node-redis and connect-redis, behind
23
- * express-session's store, are stand-ins too, as in
24
- * `all-modules-composition.multi.test.mts`.
25
- */
26
-
27
- import { BootError } from "@o3co/auth-provider-core";
28
- import { afterEach, describe, expect, it, vi } from "vitest";
29
- import {
30
- type Composition,
31
- compose,
32
- MULTI_ENV,
33
- SINGLE_ENV,
34
- } from "./all-modules-composition.fixture.mjs";
35
-
36
- vi.mock("redis", () => ({
37
- createClient: vi.fn(() => ({
38
- connect: vi.fn().mockResolvedValue(undefined),
39
- quit: vi.fn().mockResolvedValue(undefined),
40
- ping: vi.fn().mockResolvedValue("PONG"),
41
- on: vi.fn(),
42
- })),
43
- }));
44
-
45
- vi.mock("connect-redis", async () => {
46
- const { EventEmitter } = await import("node:events");
47
- // express-session subscribes to store events, so the stand-in is an emitter.
48
- return {
49
- RedisStore: class MockRedisStore extends EventEmitter {
50
- get(): unknown {
51
- return undefined;
52
- }
53
- set(): void {}
54
- destroy(): void {}
55
- },
56
- };
57
- });
58
-
59
- vi.mock("ioredis", () => {
60
- const refused = async (): Promise<never> => {
61
- throw Object.assign(new Error("NOPERM this user has no permissions to run this command"), {
62
- name: "ReplyError",
63
- });
64
- };
65
- const explicit: Record<string, unknown> = {
66
- on: () => undefined,
67
- quit: async () => "OK",
68
- disconnect: () => undefined,
69
- ping: async () => "PONG",
70
- info: refused,
71
- config: refused,
72
- };
73
- const makeMockRedis = (): object =>
74
- new Proxy(
75
- {},
76
- {
77
- get(_target, prop) {
78
- // A function-valued `then` would make the instance a thenable.
79
- if (typeof prop !== "string" || prop === "then") return undefined;
80
- if (prop === "duplicate") return makeMockRedis;
81
- if (prop in explicit) return explicit[prop];
82
- return async () => null;
83
- },
84
- },
85
- );
86
- function MockRedis(): object {
87
- return makeMockRedis();
88
- }
89
- return { Redis: MockRedis, default: MockRedis };
90
- });
91
-
92
- let current: Composition | undefined;
93
-
94
- afterEach(async () => {
95
- await current?.handle.dispose();
96
- current = undefined;
97
- });
98
-
99
- /** MFA required, both MFA stores on Redis, a key of the deployment's own and an SMTP relay. */
100
- const MFA_ON_REDIS: Readonly<Record<string, string>> = {
101
- MFA_MODE: "required",
102
- ADAPTERS_MFA_FACTOR_STORE: "redis",
103
- ADAPTERS_MFA_TRANSACTION_STORE: "redis",
104
- REDIS_CLIENTS_URL: "redis://redis.test:6379",
105
- MFA_ENCRYPTION_KEY: Buffer.alloc(32, 9).toString("base64"),
106
- STANDARD_SMTP_MAIL_SENDER_HOST: "smtp.auth.test",
107
- STANDARD_SMTP_MAIL_SENDER_FROM: "auth@auth.test",
108
- };
109
-
110
- const composing = (
111
- env: Readonly<Record<string, string>>,
112
- shippedRefreshTokenFamilyStore = true,
113
- ): Promise<unknown> =>
114
- compose({ env, environment: "production", shippedRefreshTokenFamilyStore }).then(
115
- (composition) => {
116
- current = composition;
117
- return undefined;
118
- },
119
- (caught: unknown) => caught,
120
- );
121
-
122
- describe("a Redis that will not report its eviction policy", () => {
123
- it.each([
124
- ["unset", MULTI_ENV],
125
- ["false", { ...MULTI_ENV, REDIS_CLIENTS_ASSUME_NO_EVICTION: "false" }],
126
- ])(
127
- "refuses the boot with REDIS_CLIENTS_ASSUME_NO_EVICTION %s, naming the store",
128
- async (_label, env) => {
129
- const err = await composing(env);
130
- expect(err).toBeInstanceOf(BootError);
131
- expect(err).toMatchObject({
132
- reason: "provides-factory-failed",
133
- cause: { name: "RedisStoreEvictableError", maxmemoryPolicy: undefined },
134
- });
135
- },
136
- );
137
-
138
- it("boots every store with REDIS_CLIENTS_ASSUME_NO_EVICTION=true", async () => {
139
- expect(
140
- await composing({ ...MULTI_ENV, REDIS_CLIENTS_ASSUME_NO_EVICTION: "true" }),
141
- ).toBeUndefined();
142
- expect(current?.modules.map((module) => module.name)).toEqual(
143
- expect.arrayContaining([
144
- "redis-attempt-counter",
145
- "redis-session-stores",
146
- "redis-federation-token-store",
147
- ]),
148
- );
149
- });
150
-
151
- it("boots with redis-clients.assumeNoEviction = true written in HOCON", async () => {
152
- const composition = await compose({
153
- env: MULTI_ENV,
154
- environment: "production",
155
- shippedRefreshTokenFamilyStore: true,
156
- operatorHocon: "redis-clients.assumeNoEviction = true\n",
157
- });
158
- current = composition;
159
- expect(composition.handle).toBeDefined();
160
- });
161
- });
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
-
228
- describe("the MFA stores on a Redis that will not report its eviction policy", () => {
229
- /** Only the MFA stores on Redis: a refusal can come from no other store. */
230
- const MFA_ONLY = { ...SINGLE_ENV, ...MFA_ON_REDIS };
231
- const composingMfaOnly = (env: Readonly<Record<string, string>>) => composing(env, false);
232
-
233
- it.each([
234
- ["unset", MFA_ONLY],
235
- ["false", { ...MFA_ONLY, REDIS_CLIENTS_ASSUME_NO_EVICTION: "false" }],
236
- ])(
237
- "refuses the boot with REDIS_CLIENTS_ASSUME_NO_EVICTION %s, naming an MFA store",
238
- async (_label, env) => {
239
- const err = await composingMfaOnly(env);
240
- expect(err).toBeInstanceOf(BootError);
241
- expect(err).toMatchObject({
242
- reason: "provides-factory-failed",
243
- cause: {
244
- name: "RedisStoreEvictableError",
245
- reason: expect.stringMatching(/^mfa-(factor|transaction)-store-evictable$/),
246
- maxmemoryPolicy: undefined,
247
- },
248
- });
249
- },
250
- );
251
-
252
- it("boots both MFA stores with REDIS_CLIENTS_ASSUME_NO_EVICTION=true", async () => {
253
- expect(
254
- await composingMfaOnly({ ...MFA_ONLY, REDIS_CLIENTS_ASSUME_NO_EVICTION: "true" }),
255
- ).toBeUndefined();
256
- expect(current?.modules.map((module) => module.name)).toEqual(
257
- expect.arrayContaining(["redis-mfa-factor-store", "redis-mfa-transaction-store"]),
258
- );
259
- expect(current?.handle.components.mfaFactorStore?.kind).toBe("redis");
260
- expect(current?.handle.components.mfaTransactionStore?.kind).toBe("redis");
261
- });
262
-
263
- it.each([
264
- ["unset", { ...MULTI_ENV, ...MFA_ON_REDIS }],
265
- ["false", { ...MULTI_ENV, ...MFA_ON_REDIS, REDIS_CLIENTS_ASSUME_NO_EVICTION: "false" }],
266
- ])(
267
- "refuses every store on Redis with MFA on, REDIS_CLIENTS_ASSUME_NO_EVICTION %s",
268
- async (_label, env) => {
269
- expect(await composing(env)).toMatchObject({
270
- reason: "provides-factory-failed",
271
- cause: { name: "RedisStoreEvictableError", maxmemoryPolicy: undefined },
272
- });
273
- },
274
- );
275
-
276
- it("boots every store on Redis with MFA on and REDIS_CLIENTS_ASSUME_NO_EVICTION=true", async () => {
277
- expect(
278
- await composing({ ...MULTI_ENV, ...MFA_ON_REDIS, REDIS_CLIENTS_ASSUME_NO_EVICTION: "true" }),
279
- ).toBeUndefined();
280
- expect(current?.handle.components.mfaFactorStore?.kind).toBe("redis");
281
- expect(current?.handle.components.mfaTransactionStore?.kind).toBe("redis");
282
- });
283
- });
@@ -1,135 +0,0 @@
1
- /*
2
- * Copyright 2026 1o1 Co. Ltd.
3
- *
4
- * Licensed under the Apache License, Version 2.0 (the "License");
5
- * you may not use this file except in compliance with the License.
6
- * You may obtain a copy of the License at
7
- *
8
- * http://www.apache.org/licenses/LICENSE-2.0
9
- *
10
- * Unless required by applicable law or agreed to in writing, software
11
- * distributed under the License is distributed on an "AS IS" BASIS,
12
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
- * See the License for the specific language governing permissions and
14
- * limitations under the License.
15
- */
16
-
17
- /**
18
- * What the template's shared Redis connection logs when the server refuses
19
- * it: the real ioredis the template ships, against a TCP server that answers
20
- * the way Redis answers a password it no longer accepts. ioredis puts the
21
- * handshake it sent — `AUTH` and the password — on the error it emits, so
22
- * the `error` listener must log core's `loggableError` projection, never
23
- * the error. The logger here serialises every own property of what it is
24
- * handed, as a deployment's logger may.
25
- */
26
-
27
- import { once } from "node:events";
28
- import { type AddressInfo, createServer, type Socket } from "node:net";
29
- import type { LifecycleRegistrar, Logger } from "@o3co/auth-provider-core";
30
- import { describe, expect, it, vi } from "vitest";
31
- import { standaloneRedisClientsModule } from "#/modules.mjs";
32
-
33
- /** Redis's reply to a handshake whose password it does not accept. */
34
- const WRONGPASS = "-WRONGPASS invalid username-password pair or user is disabled.\r\n";
35
-
36
- /** The password the deployment is configured with, and the server no longer takes. */
37
- const STALE_PASSWORD = "stale-s3cret-redis-password";
38
-
39
- /** A TCP server that answers everything sent to it as Redis answers a refused password. */
40
- async function refusingRedis(): Promise<{ port: number; close: () => Promise<void> }> {
41
- const sockets = new Set<Socket>();
42
- const server = createServer((socket) => {
43
- sockets.add(socket);
44
- socket.on("close", () => sockets.delete(socket));
45
- socket.on("data", () => {
46
- socket.write(WRONGPASS);
47
- });
48
- });
49
- server.listen(0, "127.0.0.1");
50
- await once(server, "listening");
51
- const { port } = server.address() as AddressInfo;
52
- return {
53
- port,
54
- close: async () => {
55
- for (const socket of sockets) socket.destroy();
56
- await new Promise<void>((resolve) => server.close(() => resolve()));
57
- },
58
- };
59
- }
60
-
61
- /**
62
- * A logger that serialises every own property of what it is handed, `cause`
63
- * and non-enumerable fields included. `lines` is what it wrote; its levels
64
- * are spies.
65
- */
66
- const serialiseEverythingLogger = () => {
67
- const lines: string[] = [];
68
- const walk = (value: unknown, seen = new WeakSet<object>()): unknown => {
69
- if (typeof value !== "object" || value === null) return value;
70
- if (seen.has(value)) return "[circular]";
71
- seen.add(value);
72
- const out: Record<string, unknown> = {};
73
- for (const key of Object.getOwnPropertyNames(value)) {
74
- out[key] = walk((value as Record<string, unknown>)[key], seen);
75
- }
76
- return out;
77
- };
78
- const record = (level: string) =>
79
- vi.fn((...args: unknown[]): void => {
80
- lines.push(JSON.stringify({ level, args: walk(args) }));
81
- });
82
- const logger = {
83
- trace: record("trace"),
84
- debug: record("debug"),
85
- info: record("info"),
86
- warn: record("warn"),
87
- error: record("error"),
88
- fatal: record("fatal"),
89
- child: () => logger,
90
- };
91
- return { logger: logger as typeof logger & Logger, lines };
92
- };
93
-
94
- describe("standaloneRedisClientsModule: the shared connection refused by the server", () => {
95
- it("logs the connection's error as loggableError's projection, never the AUTH arguments ioredis puts on it", async () => {
96
- const redis = await refusingRedis();
97
- const cleanups: Array<() => Promise<void>> = [];
98
- const lifecycleRegistrar: LifecycleRegistrar = {
99
- register: (cleanup) => {
100
- cleanups.push(cleanup);
101
- },
102
- };
103
- const { logger, lines } = serialiseEverythingLogger();
104
- const provides = (
105
- standaloneRedisClientsModule as unknown as {
106
- provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
107
- }
108
- ).provides;
109
- try {
110
- await provides.refreshTokenFamilyClient({
111
- section: { url: `redis://127.0.0.1:${redis.port}`, password: STALE_PASSWORD },
112
- lifecycleRegistrar,
113
- logger,
114
- });
115
- await vi.waitFor(() => expect(logger.error).toHaveBeenCalled(), { timeout: 10_000 });
116
- } finally {
117
- // `io.quit()`, as `handle.dispose()` runs it.
118
- await Promise.all(cleanups.map((cleanup) => cleanup().catch(() => {})));
119
- await redis.close();
120
- }
121
-
122
- expect(logger.error).toHaveBeenCalledWith(
123
- {
124
- err: expect.objectContaining({
125
- name: "ReplyError",
126
- detail: expect.stringMatching(/^WRONGPASS /),
127
- // Which command the server refused, and nothing it carried.
128
- command: { name: "hello" },
129
- }),
130
- },
131
- "standalone_redis_clients_error",
132
- );
133
- for (const line of lines) expect(line).not.toContain(STALE_PASSWORD);
134
- });
135
- });