@o3co/create-auth-provider 0.15.0 → 0.17.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 (92) hide show
  1. package/README.ja.md +139 -48
  2. package/README.md +138 -47
  3. package/dist/index.d.mts +33 -10
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +118 -59
  6. package/dist/internal/template-filter.d.mts +7 -10
  7. package/dist/internal/template-filter.d.mts.map +1 -1
  8. package/dist/internal/template-filter.mjs +7 -10
  9. package/package.json +6 -4
  10. package/templates/standalone/.dockerignore +15 -2
  11. package/templates/standalone/.env.example +69 -24
  12. package/templates/standalone/Dockerfile +7 -3
  13. package/templates/standalone/README.ja.md +677 -184
  14. package/templates/standalone/README.md +699 -238
  15. package/templates/standalone/config/application.conf +203 -325
  16. package/templates/standalone/config/clients.yaml +2 -0
  17. package/templates/standalone/config/clients.yaml.example +4 -0
  18. package/templates/standalone/config/development.conf +13 -4
  19. package/templates/standalone/config/mailpit.conf +8 -0
  20. package/templates/standalone/config/production.conf +5 -6
  21. package/templates/standalone/config/reference.conf +399 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +58 -30
  24. package/templates/standalone/docker-compose.yml +13 -3
  25. package/templates/standalone/gitignore +4 -2
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +65 -32
  28. package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1178 -0
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +256 -0
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1119 -0
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
  33. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  34. package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
  35. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  36. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
  37. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +672 -271
  38. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +480 -78
  39. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +102 -66
  40. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +135 -0
  41. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +49 -18
  42. package/templates/standalone/src/__tests__/google-session.fixture.mts +315 -0
  43. package/templates/standalone/src/__tests__/host-routes.test.mts +134 -0
  44. package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
  45. package/templates/standalone/src/__tests__/listen.test.mts +121 -0
  46. package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
  47. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  48. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
  49. package/templates/standalone/src/__tests__/metrics.test.mts +17 -7
  50. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  51. package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
  52. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
  53. package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
  54. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +183 -0
  55. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +135 -0
  56. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
  57. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  58. package/templates/standalone/src/__tests__/replica-safety.test.mts +166 -145
  59. package/templates/standalone/src/__tests__/router-error-answers.test.mts +146 -0
  60. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
  61. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  62. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
  63. package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
  64. package/templates/standalone/src/__tests__/shutdown.test.mts +319 -115
  65. package/templates/standalone/src/__tests__/smoke.test.mts +179 -233
  66. package/templates/standalone/src/__tests__/store-credential.test.mts +317 -0
  67. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  68. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
  69. package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
  70. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
  71. package/templates/standalone/src/adapters.mts +149 -0
  72. package/templates/standalone/src/app.mts +71 -115
  73. package/templates/standalone/src/buildModules.mts +229 -251
  74. package/templates/standalone/src/configPath.mts +422 -29
  75. package/templates/standalone/src/listen.mts +60 -0
  76. package/templates/standalone/src/logger.mts +31 -57
  77. package/templates/standalone/src/metrics.mts +24 -47
  78. package/templates/standalone/src/mfaSwitch.mts +262 -0
  79. package/templates/standalone/src/modules.mts +480 -464
  80. package/templates/standalone/src/rootRenames.mts +79 -0
  81. package/templates/standalone/src/routes.mts +89 -0
  82. package/templates/standalone/src/sections.mts +366 -0
  83. package/templates/standalone/src/shutdown.mts +54 -126
  84. package/templates/standalone/tests/index.test.js +2 -2
  85. package/templates/standalone/tests/package-lock.json +1615 -2223
  86. package/templates/standalone/tests/package.json +2 -2
  87. package/templates/standalone/tests/vitest.config.mjs +26 -0
  88. package/templates/standalone/vitest.config.mts +8 -7
  89. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  90. package/templates/versions.json +18 -15
  91. package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
  92. package/templates/standalone/src/terminalError.mts +0 -60
@@ -0,0 +1,183 @@
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, sessionFederationIndex } = 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.
160
+ expect(await sessionFederationIndex.listFederations(sid)).not.toContain("google");
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
+ });
@@ -0,0 +1,135 @@
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
+ });
@@ -15,83 +15,82 @@
15
15
  */
16
16
 
17
17
  /**
18
- * #495 / #496 — end to end, through this template's real resolution chain.
19
- *
20
- * `app.mts` resolves `{env}.conf → application.conf → reference.conf` and
21
- * validates the result with `AppConfigSchema` before `buildModules` runs.
22
- * The schema strips what it does not declare, so a section declared by a
23
- * module rather than by core used to vanish at exactly that step — and,
24
- * because `redisRateLimiterModule.configSchema` carries a
25
- * `.default({ limit: 60, windowSeconds: 60 })`, the deployment that
26
- * `docker-compose.production.yml` ships (`RATE_LIMITER_ADAPTER: redis`) then
27
- * ran on 60 requests / 60 s no matter what the operator wrote.
28
- *
29
- * This starts from the shipped configuration, adds the two overrides an
30
- * operator writes in their own `application.conf` layer, and follows them all
31
- * the way to the limiter the module builds.
18
+ * Sections declared by a module rather than by core (`redis-rate-limiter`,
19
+ * `mtls`) survive this template's real resolution chain, end to end.
20
+ * Starting from the shipped configuration plus an operator's own layer, the
21
+ * overrides are followed through both configuration phases and `createApp`
22
+ * to the limiter the module builds. Were `redis-rate-limiter` lost, the
23
+ * module's default of 60 requests / 60 s would apply to the deployment
24
+ * `docker-compose.production.yml` ships (`ADAPTERS_RATE_LIMITER: redis`)
25
+ * whatever the operator wrote.
32
26
  */
33
27
 
28
+ import { mkdtempSync, writeFileSync } from "node:fs";
29
+ import { tmpdir } from "node:os";
30
+ import { join } from "node:path";
34
31
  import { fileURLToPath } from "node:url";
35
- import { type AppConfig, AppConfigSchema, type RateLimiter } from "@o3co/auth-provider-core";
32
+ import {
33
+ type AppConfig,
34
+ createApp,
35
+ defineModule,
36
+ type RateLimiter,
37
+ } from "@o3co/auth-provider-core";
36
38
  import { redisRateLimiterModule } from "@o3co/auth-provider-redis";
37
- import { parseFile, parseString } from "@o3co/ts.hocon";
38
- import { validate } from "@o3co/ts.hocon/zod";
39
- import { describe, expect, it } from "vitest";
40
- import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
39
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
40
+ import { readOwnLayers, readSwitches, resolveConfigPaths, resolveForBoot } from "../configPath.mjs";
41
+ import { loggingModule } from "../modules.mjs";
41
42
 
42
43
  const configDir = fileURLToPath(new URL("../../config", import.meta.url));
43
44
 
44
45
  /**
45
46
  * The secrets `application.conf` substitutes, plus the one
46
47
  * `docker-compose.production.yml` sets to put the OAuth endpoints behind a
47
- * shared limiter. Test-only values; #282's entropy floor applies to the two
48
+ * shared limiter. Test-only values; the entropy floor applies to the two
48
49
  * secrets, and the `.` characters keep them out of the base64 alphabet so the
49
50
  * UTF-8 length is what counts.
50
51
  */
51
52
  const ENV = {
52
- OAUTH_JWT_SECRET: "test-secret-rate-limiter-e2e.at-least-32-bytes.ok",
53
+ KEY_STORE_LOCAL_SECRET: "test-secret-rate-limiter-e2e.at-least-32-bytes.ok",
53
54
  OAUTH_JWT_ISSUER: "https://auth.test",
54
- SESSION_SECRET: "test-session-secret-rate-limiter-e2e.at-least-32-bytes.ok",
55
- RATE_LIMITER_ADAPTER: "redis",
55
+ SESSION_STORE_SECRET: "test-session-secret-rate-limiter-e2e.at-least-32-bytes.ok",
56
+ ADAPTERS_RATE_LIMITER: "redis",
57
+ MFA_MODE: "off",
56
58
  };
57
59
 
58
60
  /**
59
61
  * What an operator adds to their own `application.conf` layer: per-endpoint
60
62
  * budgets for the Redis limiter, and the mTLS posture from
61
63
  * `@o3co/auth-provider-mtls`. Neither section appears in anything this
62
- * template ships, which is why neither was ever covered by a `.conf` diff.
64
+ * template ships.
63
65
  */
64
66
  const OPERATOR_OVERRIDES = `
65
- redisRateLimiter {
67
+ redis-rate-limiter {
66
68
  limits {
67
69
  token { limit = 120, windowSeconds = 60 }
68
70
  authorize { limit = 30, windowSeconds = 60 }
69
71
  }
70
72
  }
71
73
 
72
- oauth.mtls {
74
+ mtls {
73
75
  enabled = true
74
76
  mode = "full-pki"
75
- trusted-cas = ["-----BEGIN CERTIFICATE-----"]
76
- full-pki {
77
+ trustedCas = ["-----BEGIN CERTIFICATE-----"]
78
+ fullPki {
77
79
  revocation {
78
80
  mode = "crl"
79
- on-unavailable = "reject"
80
- allowed-hosts = ["crl.example.com"]
81
+ onUnavailable = "reject"
82
+ allowedHosts = ["crl.example.com"]
81
83
  }
82
84
  }
83
85
  }
84
86
  `;
85
87
 
86
- function resolveShippedConfig(): AppConfig {
88
+ /** The composition's own files: the operator's layer over the shipped production ones. */
89
+ function ownFiles(): string[] {
90
+ const operator = join(mkdtempSync(join(tmpdir(), "redis-rate-limiter-")), "operator.conf");
91
+ writeFileSync(operator, OPERATOR_OVERRIDES);
87
92
  const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
88
- return validate(
89
- parseString(OPERATOR_OVERRIDES, { env: ENV })
90
- .withFallback(parseFile(envConfPath, { env: ENV }))
91
- .withFallback(parseFile(applicationConfPath, { env: ENV }))
92
- .withFallback(parseFile(resolveLibraryReferenceConfPath(), { env: ENV })),
93
- AppConfigSchema,
94
- );
93
+ return [operator, envConfPath, applicationConfPath];
95
94
  }
96
95
 
97
96
  /** Counts one request per key, the way an untouched Redis counter would. */
@@ -99,79 +98,111 @@ function makeCountingClient(): { incrementWithTtl: (key: string, ttl: number) =>
99
98
  return { incrementWithTtl: async () => 1 };
100
99
  }
101
100
 
102
- function buildLimiter(config: AppConfig): RateLimiter {
103
- const provides = (
104
- redisRateLimiterModule as unknown as {
105
- provides: Record<string, (deps: Record<string, unknown>) => RateLimiter>;
106
- }
107
- ).provides;
108
- const provider = provides.rateLimiter;
109
- if (!provider) throw new Error("redisRateLimiterModule no longer provides rateLimiter");
110
- return provider({ config, rateLimiterClient: makeCountingClient() });
101
+ /** The limiter a consumer of the slot is handed, as a route module would be. */
102
+ let consumed: RateLimiter | undefined;
103
+ const consumerModule = defineModule({
104
+ name: "test:rate-limiter-consumer",
105
+ requires: ["rateLimiter"] as const,
106
+ contributes: {
107
+ grantMiddleware: [
108
+ ({ rateLimiter }) => {
109
+ consumed = rateLimiter;
110
+ return null;
111
+ },
112
+ ],
113
+ },
114
+ });
115
+
116
+ /** The shipped configuration through both phases, booted with the Redis limiter's module. */
117
+ async function bootShipped() {
118
+ const own = readOwnLayers(ownFiles(), { env: ENV });
119
+ const switches = readSwitches(own);
120
+ const modules = [redisRateLimiterModule, consumerModule];
121
+ return {
122
+ switches,
123
+ handle: await createApp({
124
+ modules,
125
+ bootstrapComponents: {
126
+ // The template's own reference too, which its application.conf is layered over.
127
+ config: resolveForBoot(own, [...modules, loggingModule], switches),
128
+ pathResolver: (s: string) => s,
129
+ rateLimiterClient: makeCountingClient() as never,
130
+ },
131
+ }),
132
+ };
111
133
  }
112
134
 
113
- describe("the shipped configuration reaches the Redis rate limiter (#495)", () => {
114
- const config = resolveShippedConfig();
135
+ let booted: Awaited<ReturnType<typeof bootShipped>>;
136
+ let config: AppConfig;
137
+ let limiter: RateLimiter;
138
+
139
+ beforeAll(async () => {
140
+ booted = await bootShipped();
141
+ const parsed = booted.handle.components.config;
142
+ const built = consumed;
143
+ if (parsed === undefined || built === undefined) {
144
+ throw new Error("the boot filled no config, or handed no consumer the limiter");
145
+ }
146
+ config = parsed;
147
+ limiter = built;
148
+ });
115
149
 
116
- it("selects the Redis adapter the production compose file pins", () => {
117
- expect(config.rateLimiter?.adapter).toBe("redis");
150
+ afterAll(async () => {
151
+ await booted?.handle.dispose();
152
+ });
153
+
154
+ describe("the shipped configuration reaches the Redis rate limiter", () => {
155
+ it("selects the Redis adapter the production compose file pins, in phase one", () => {
156
+ expect(booted.switches.adapters.rateLimiter).toBe("redis");
118
157
  });
119
158
 
120
- it("carries the operator's per-endpoint budgets through the pre-parse", () => {
121
- expect(config.redisRateLimiter?.limits).toEqual({
159
+ it("carries the operator's per-endpoint budgets through boot's parse", () => {
160
+ expect(
161
+ (config as unknown as Record<string, { limits?: unknown }>)["redis-rate-limiter"]?.limits,
162
+ ).toEqual({
122
163
  token: { limit: 120, windowSeconds: 60 },
123
164
  authorize: { limit: 30, windowSeconds: 60 },
124
165
  });
125
166
  });
126
167
 
127
168
  it("applies the declared budget at /token", async () => {
128
- const decision = await buildLimiter(config).check("token:ip:203.0.113.5", {
129
- ip: "203.0.113.5",
130
- });
169
+ const decision = await limiter.check("token:ip:203.0.113.5", { ip: "203.0.113.5" });
131
170
  expect(decision.limit).toBe(120);
132
171
  });
133
172
 
134
173
  it("applies the declared budget at /authorize", async () => {
135
- const decision = await buildLimiter(config).check("authorize:ip:203.0.113.5", {
136
- ip: "203.0.113.5",
137
- });
174
+ const decision = await limiter.check("authorize:ip:203.0.113.5", { ip: "203.0.113.5" });
138
175
  expect(decision.limit).toBe(30);
139
176
  });
140
177
 
141
178
  it("still falls back to the module's default where nothing is declared", async () => {
142
- // The point of the assertion above: 60 is what EVERY endpoint got while
143
- // the section was stripped, so a test that only saw 60 could not tell a
144
- // working configuration from a lost one.
145
- const decision = await buildLimiter(config).check("introspect:ip:203.0.113.5", {
146
- ip: "203.0.113.5",
147
- });
179
+ // 60 is what every endpoint would get were the section stripped, so the
180
+ // budgets above differ from it to tell a working configuration from a
181
+ // lost one.
182
+ const decision = await limiter.check("introspect:ip:203.0.113.5", { ip: "203.0.113.5" });
148
183
  expect(decision.limit).toBe(60);
149
184
  });
150
185
 
151
- it("keeps seeding /session/login from rateLimit.login", async () => {
152
- // `resolveSeededLimitSpecs` reads a declared section, so this worked
153
- // even while `redisRateLimiter` was being stripped — which is why the
154
- // defect showed up only at the endpoints below it.
155
- const decision = await buildLimiter(config).check("login:ip:203.0.113.5", {
156
- ip: "203.0.113.5",
157
- });
158
- expect(decision.limit).toBe(config.rateLimit?.login.limit);
186
+ it("applies no login budget without the session module: session.rateLimit.login is that module's to contribute", async () => {
187
+ // This boot loads the limiter alone. With the session module, the
188
+ // limiter applies the budget it contributes from session.rateLimit.login.
189
+ expect(config.session).toMatchObject({ rateLimit: { login: { limit: 20 } } });
190
+ const decision = await limiter.check("login:ip:203.0.113.5", { ip: "203.0.113.5" });
191
+ expect(decision.limit).toBe(60);
159
192
  });
160
193
  });
161
194
 
162
- describe("the shipped configuration carries an mTLS posture through (#496)", () => {
163
- const config = resolveShippedConfig();
164
-
195
+ describe("the shipped configuration carries an mTLS posture through", () => {
165
196
  it("keeps the operator's mTLS block instead of reporting mTLS off", () => {
166
- expect(config.oauth.mtls).toEqual({
197
+ expect((config as unknown as { mtls?: unknown }).mtls).toEqual({
167
198
  enabled: true,
168
199
  mode: "full-pki",
169
- "trusted-cas": ["-----BEGIN CERTIFICATE-----"],
170
- "full-pki": {
200
+ trustedCas: ["-----BEGIN CERTIFICATE-----"],
201
+ fullPki: {
171
202
  revocation: {
172
203
  mode: "crl",
173
- "on-unavailable": "reject",
174
- "allowed-hosts": ["crl.example.com"],
204
+ onUnavailable: "reject",
205
+ allowedHosts: ["crl.example.com"],
175
206
  },
176
207
  },
177
208
  });