@o3co/create-auth-provider 0.10.0 → 0.12.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 (28) hide show
  1. package/dist/index.d.mts.map +1 -1
  2. package/dist/index.mjs +11 -1
  3. package/package.json +2 -2
  4. package/templates/standalone/.env.example +19 -1
  5. package/templates/standalone/Dockerfile +9 -1
  6. package/templates/standalone/README.ja.md +24 -4
  7. package/templates/standalone/README.md +175 -21
  8. package/templates/standalone/config/application.conf +28 -0
  9. package/templates/standalone/config/clients.yaml.example +15 -2
  10. package/templates/standalone/config/production.conf +9 -3
  11. package/templates/standalone/docker-compose.production.yml +80 -5
  12. package/templates/standalone/docker-compose.yml +24 -0
  13. package/templates/standalone/gitignore +21 -0
  14. package/templates/standalone/package.json +5 -6
  15. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +100 -0
  16. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +71 -4
  17. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +179 -0
  18. package/templates/standalone/src/__tests__/replica-safety.test.mts +456 -0
  19. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +246 -0
  20. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +288 -0
  21. package/templates/standalone/src/__tests__/shutdown.test.mts +324 -0
  22. package/templates/standalone/src/__tests__/smoke.test.mts +36 -0
  23. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +12 -7
  24. package/templates/standalone/src/app.mts +16 -3
  25. package/templates/standalone/src/buildModules.mts +54 -16
  26. package/templates/standalone/src/modules.mts +128 -24
  27. package/templates/standalone/src/shutdown.mts +219 -0
  28. package/templates/versions.json +13 -11
@@ -19,10 +19,11 @@
19
19
  #
20
20
  # What this file deliberately does NOT do:
21
21
  #
22
- # - **TLS.** Terminate it in front (reverse proxy / load balancer), and set
23
- # HTTP_TRUST_PROXY to that hop's address so `req.ip` — and every IP-keyed
24
- # rate limit — means the client, not the proxy. See README
25
- # "Multi-replica deployments".
22
+ # - **TLS.** Terminate it in front (reverse proxy / load balancer). The
23
+ # HTTP_TRUST_PROXY entry below then needs that hop's address so `req.ip` —
24
+ # and every IP-keyed rate limit — means the client, not the proxy; compose
25
+ # refuses to start until you supply it. See README "Multi-replica
26
+ # deployments".
26
27
  # - **Scale out.** This is one replica (DEPLOYMENT_MODE=single). Before
27
28
  # `--scale app=N`, set DEPLOYMENT_MODE=multi and read that same README
28
29
  # section — boot will then refuse any in-memory store that must be shared.
@@ -38,7 +39,16 @@ services:
38
39
  target: runtime
39
40
  restart: unless-stopped
40
41
  ports:
41
- - "3000:3000"
42
+ # Loopback-bound on purpose (#407). This file assumes TLS is terminated
43
+ # in front, and the README says to keep `/metrics` off the public
44
+ # listener — a bare "3000:3000" publishes on every interface and
45
+ # contradicts both. A reverse proxy on the same host reaches it here.
46
+ #
47
+ # If your load balancer is on another host, publish it instead
48
+ # (`- "3000:3000"`) and put the access control somewhere real: a
49
+ # security group, a firewall rule, or the proxy's own network. Do not
50
+ # widen this line and call it done.
51
+ - "127.0.0.1:3000:3000"
42
52
  depends_on:
43
53
  redis:
44
54
  condition: service_healthy
@@ -65,7 +75,31 @@ services:
65
75
  - jwt_public_key
66
76
  environment:
67
77
  NODE_ENV: production
78
+ # One replica. Under `single` the replica-safety guard is SILENT by
79
+ # design — you have told it there are no peers — so it will not tell you
80
+ # that a store below is on memory while its sibling is on Redis. That is
81
+ # why the adapter block further down states every one of them explicitly
82
+ # instead of leaving them to inherit. Before `--scale app=N`, set `multi`
83
+ # and read the README's "Multi-replica deployments".
68
84
  DEPLOYMENT_MODE: single
85
+ # Behind the TLS terminator this file assumes, `req.ip` is the proxy's
86
+ # address and `req.protocol` reads `http` until this names the hop. The
87
+ # README calls it required for exactly that reason, and the cost of
88
+ # leaving it unset is three failures at once: the Secure cookie pinned
89
+ # below is never set, the CSRF origin check 403s every browser POST
90
+ # (the browser sends `Origin: https://…`, the process believes it is
91
+ # serving http), and every IP-keyed rate limit — the OAuth endpoint
92
+ # limiters and the /session/login brute-force guard — shares ONE bucket
93
+ # across all users.
94
+ #
95
+ # It cannot be guessed and it is not defaulted: only you know what sits
96
+ # in front, and a default here would silently trust a hop you never
97
+ # chose. Put your edge's address or CIDR range in `.env` — `10.0.0.0/8`,
98
+ # or `loopback` for a sidecar on this host. Prefer that over `true`,
99
+ # which believes a forwarded client address from anyone able to reach
100
+ # this process. The `:?` form makes compose refuse to start until it is
101
+ # set, rather than starting wrong.
102
+ HTTP_TRUST_PROXY: ${HTTP_TRUST_PROXY:?set to your TLS terminator's address or CIDR range (e.g. 10.0.0.0/8, or `loopback` for a same-host sidecar) — never `true` behind an open network}
69
103
  # Point the process at the mounted secrets rather than the working-copy
70
104
  # paths `.env` carries for local runs.
71
105
  OAUTH_JWT_PRIVATE_KEY_PATH: /run/secrets/jwt_private_key
@@ -80,14 +114,55 @@ services:
80
114
  SESSION_SECURE: "true"
81
115
  SESSION_NAME: __Host-auth.session
82
116
  # Every Redis-backed store shares the compose-network Redis below.
117
+ #
118
+ # These are stated in full rather than inherited from
119
+ # `config/application.conf`, so that the deployable file names its own
120
+ # topology: an operator reading only this one can see which stores
121
+ # survive a restart. `OAUTH_CODE_ADAPTER` and
122
+ # `ACCESS_TOKEN_DENYLIST_ADAPTER` already ship as `redis` in that config
123
+ # layer and are left there on purpose — restating a value that agrees is
124
+ # a second place to forget to change.
83
125
  REFRESH_TOKEN_FAMILY_STORE_REDIS_URL: redis://redis:6379
84
126
  SESSION_STORAGE_TYPE: redis
85
127
  SESSION_STORAGE_REDIS_URL: redis://redis:6379
128
+ # The UserSession stores: the provider's own record of who is signed in,
129
+ # which RPs a session has touched, and the family / federation indexes
130
+ # hanging off it. They MUST be on Redis whenever the express-session
131
+ # store above is, and this line is the fix for what happens when they
132
+ # are not.
133
+ #
134
+ # express-session on Redis survives a restart; a memory UserSession store
135
+ # does not. So every browser comes back holding a session cookie whose
136
+ # express-session record still says `isAuthenticated: true`, while the
137
+ # `UserSession` that record points at is gone — /authorize sees an
138
+ # authenticated session, finds no user session behind it, and sends the
139
+ # browser to log in again, where the still-valid cookie puts it straight
140
+ # back. A login loop that clears only when the user deletes the cookie.
141
+ # `DEPLOYMENT_MODE: single` says nothing about it: the replica guard
142
+ # answers "can these stores be shared", not "do these two stores have the
143
+ # same lifetime".
144
+ USER_SESSION_STORES_ADAPTER: redis
86
145
  # The memory rate limiter is per-process and evadable under bucket
87
146
  # exhaustion (see README) — production points it at Redis.
88
147
  RATE_LIMITER_ADAPTER: redis
148
+ # The federation token store is deliberately left at its `memory`
149
+ # default, and that is coherent here rather than an oversight: this
150
+ # template ships every federation disabled, so nothing writes to the
151
+ # store, and switching it on would need a 32-byte AES key
152
+ # (REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY) that this file must not
153
+ # invent — the records are long-lived upstream refresh tokens. Enabling
154
+ # a federation means setting BOTH, together:
155
+ # FEDERATION_TOKEN_STORE_TYPE: redis
156
+ # REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: <openssl rand -base64 32, from .env>
89
157
 
90
158
  redis:
159
+ # Tag-pinned, not digest-pinned. The Dockerfile argues for digests (#289)
160
+ # and that argument holds here too — a tag is mutable, so two `up`s a month
161
+ # apart can run different Redis builds. Pin the digest for a production
162
+ # deployment you need to reproduce:
163
+ # image: redis:7.2-alpine@sha256:<digest>
164
+ # Left as a tag in the template because a digest goes stale the week it is
165
+ # written and a scaffold that ships a stale one teaches nothing (#407).
91
166
  image: redis:7.2-alpine
92
167
  restart: unless-stopped
93
168
  # No `ports:`, deliberately — the app reaches Redis over the compose
@@ -14,8 +14,32 @@ services:
14
14
  env_file:
15
15
  - path: .env
16
16
  required: false
17
+ environment:
18
+ # The same coherence the production file states, for the same reason,
19
+ # and it matters MORE here. `config/application.conf` already puts
20
+ # express-session, the authorization codes and the access-token denylist
21
+ # on the Redis this compose runs; only the UserSession stores were left
22
+ # on their `memory` default. That combination survives a restart on one
23
+ # side and not the other, so every browser comes back holding an
24
+ # express-session that still says `isAuthenticated` with no `UserSession`
25
+ # behind it and /authorize loops.
26
+ #
27
+ # A dev container restarts on every save (`target: develop` is hot
28
+ # reload), so the developer meets that loop constantly — and meets it
29
+ # while changing something else, which is the worst possible time to
30
+ # debug it. Redis is already running and already required by three other
31
+ # stores, so this costs nothing: the shared ioredis socket is open
32
+ # either way.
33
+ USER_SESSION_STORES_ADAPTER: redis
17
34
 
18
35
  redis:
36
+ # Tag-pinned, not digest-pinned. The Dockerfile argues for digests (#289)
37
+ # and that argument holds here too — a tag is mutable, so two `up`s a month
38
+ # apart can run different Redis builds. Pin the digest for a production
39
+ # deployment you need to reproduce:
40
+ # image: redis:7.2-alpine@sha256:<digest>
41
+ # Left as a tag in the template because a digest goes stale the week it is
42
+ # written and a scaffold that ships a stale one teaches nothing (#407).
19
43
  image: redis:7.2-alpine
20
44
  ports:
21
45
  - "127.0.0.1:6379:6379"
@@ -0,0 +1,21 @@
1
+ # #407: without this file, the first `git add .` in a scaffolded project
2
+ # commits the two things the README's own setup steps tell you to create right
3
+ # here — the `.env` holding SESSION_SECRET, and the signing key pair.
4
+
5
+ # Secrets and per-machine configuration
6
+ .env
7
+ .env.*
8
+ !.env.example
9
+ *.pem
10
+ *.key
11
+ config/clients.yaml
12
+ config/*.local.conf
13
+
14
+ # Build output and dependencies
15
+ node_modules/
16
+ dist/
17
+ coverage/
18
+
19
+ # Editor / OS noise
20
+ .DS_Store
21
+ *.log
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/auth-provider-standalone",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -27,8 +27,7 @@
27
27
  "@o3co/auth-provider-oauth": "workspace:*",
28
28
  "@o3co/auth-provider-redis": "workspace:*",
29
29
  "@o3co/auth-provider-session": "workspace:*",
30
- "@o3co/auth.utils": "^0.0.4",
31
- "@o3co/ts.hocon": "^1.12.0",
30
+ "@o3co/ts.hocon": "^1.13.0",
32
31
  "connect-redis": "^10.0.0",
33
32
  "express": "^5.2.1",
34
33
  "express-session": "^1.19.0",
@@ -36,7 +35,7 @@
36
35
  "ioredis": "^6.0.0",
37
36
  "pino": "^10.3.1",
38
37
  "prom-client": "^15.1.3",
39
- "redis": "^6.2.0"
38
+ "redis": "^6.2.1"
40
39
  },
41
40
  "devDependencies": {
42
41
  "@types/express": "^5.0.6",
@@ -45,7 +44,7 @@
45
44
  "@types/supertest": "^7.2.1",
46
45
  "supertest": "^7.1.0",
47
46
  "tsx": "^4.23.10",
48
- "typescript": "^5.9.3",
49
- "vitest": "^4.1.10"
47
+ "typescript": "^6.0.3",
48
+ "vitest": "^4.1.11"
50
49
  }
51
50
  }
@@ -0,0 +1,100 @@
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
+ * `standaloneRedisClientsModule` provides `deviceCodeStoreClient` (#433).
19
+ *
20
+ * The template does not mount the device grant, so nothing in `buildModules`
21
+ * selects `redisDeviceCodeStoreModule` today and the smoke test's client-slot
22
+ * invariant cannot see it. This pins the slot directly: a deployment that adds
23
+ * `deviceGrantModule` to this manifest and picks the Redis store must not hit
24
+ * the `missing-required-component` boot failure that #439 already paid for
25
+ * once with the subject-level slots — a Redis-branch module whose required
26
+ * client slot the shared clients module did not provide, caught only by the
27
+ * umbrella e2e.
28
+ */
29
+
30
+ import type { LifecycleRegistrar } from "@o3co/auth-provider-core";
31
+ import { redisDeviceCodeStoreModule } from "@o3co/auth-provider-redis";
32
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
33
+
34
+ const redisCtorCalls: Array<{ url: string }> = [];
35
+
36
+ vi.mock("ioredis", () => {
37
+ class MockRedis {
38
+ on = vi.fn();
39
+ quit = vi.fn(async () => "OK" as const);
40
+ ping = vi.fn(async () => "PONG" as const);
41
+ eval = vi.fn();
42
+ evalsha = vi.fn();
43
+ duplicate(): MockRedis {
44
+ return new MockRedis("redis://duplicate.local");
45
+ }
46
+ constructor(url: string) {
47
+ redisCtorCalls.push({ url });
48
+ }
49
+ }
50
+ return { Redis: MockRedis, default: MockRedis };
51
+ });
52
+
53
+ const importModule = async () => await import("../modules.mjs");
54
+
55
+ const baseConfig = {
56
+ refreshTokenFamilyStore: {
57
+ redis: { url: "redis://example.com:6379" },
58
+ },
59
+ };
60
+
61
+ describe("#433 / standaloneRedisClientsModule.deviceCodeStoreClient", () => {
62
+ beforeEach(() => {
63
+ redisCtorCalls.length = 0;
64
+ });
65
+
66
+ afterEach(() => {
67
+ vi.restoreAllMocks();
68
+ });
69
+
70
+ it("provides every *Client slot redisDeviceCodeStoreModule requires", async () => {
71
+ const { standaloneRedisClientsModule } = await importModule();
72
+ const provided = new Set(Object.keys(standaloneRedisClientsModule.provides ?? {}));
73
+ const required = [...(redisDeviceCodeStoreModule.requires ?? [])].filter((key) =>
74
+ key.endsWith("Client"),
75
+ );
76
+ expect(required).toEqual(["deviceCodeStoreClient"]);
77
+ expect(required.filter((key) => !provided.has(key))).toEqual([]);
78
+ });
79
+
80
+ it("resolves the client off the one shared socket, not a second connection", async () => {
81
+ const { standaloneRedisClientsModule } = await importModule();
82
+ const provides = (
83
+ standaloneRedisClientsModule as unknown as {
84
+ provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
85
+ }
86
+ ).provides;
87
+ const lifecycleRegistrar: LifecycleRegistrar = { register: () => {} };
88
+
89
+ const client = (await provides.deviceCodeStoreClient?.({
90
+ config: { ...baseConfig },
91
+ lifecycleRegistrar,
92
+ })) as Record<string, unknown>;
93
+ await provides.rateLimiterClient?.({ config: { ...baseConfig }, lifecycleRegistrar });
94
+
95
+ expect(redisCtorCalls).toHaveLength(1);
96
+ for (const method of ["create", "findPending", "decide", "poll", "remove"]) {
97
+ expect(typeof client[method], `deviceCodeStoreClient.${method}`).toBe("function");
98
+ }
99
+ });
100
+ });
@@ -88,11 +88,15 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
88
88
  OAUTH_OIDC_MODE: "dual",
89
89
  OAUTH_CODE_ADAPTER: "redis",
90
90
  OAUTH_REVOCATION_ACCESS_TOKEN: "denylist",
91
+ OAUTH_REVOCATION_SUBJECT: "unsupported",
91
92
  OAUTH_REQUIRE_EMAIL_VERIFIED: "true",
93
+ OAUTH_REQUIRE_GRANT_TYPE_ALLOWLIST: "true",
94
+ OAUTH_GRANTS_JWT_BEARER_ENABLED: "false",
92
95
  OAUTH_NONCE_MAX_LENGTH: "256",
93
96
  OAUTH_TOKEN_EXCHANGE_MAX_ACTOR_CHAIN_DEPTH: "3",
94
97
  OAUTH_RESOURCE_INDICATOR_ENABLED: "true",
95
98
  OAUTH_TOKEN_BINDING_DISPATCH_POLICY: "intent-explicit",
99
+ OAUTH_TOKEN_BINDING_BIND_CONFIDENTIAL_CLIENT_REFRESH_TOKENS: "true",
96
100
 
97
101
  // --- oauth.grants -------------------------------------------------
98
102
  OAUTH_GRANTS_SESSION_ENABLED: "false",
@@ -138,6 +142,12 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
138
142
  REFRESH_TOKEN_FAMILY_STORE_CAS_RETRY_LIMIT: "3",
139
143
  REFRESH_TOKEN_FAMILY_STORE_REDIS_URL: "redis://redis:6379",
140
144
  REFRESH_TOKEN_FAMILY_STORE_REDIS_PASSWORD: "rt-family-password",
145
+ // #456: the federation token store's Redis branch, which the README
146
+ // documented and which could not boot.
147
+ FEDERATION_TOKEN_STORE_TYPE: "redis",
148
+ REDIS_FEDERATION_TOKEN_STORE_KEY_PREFIX: "ft:",
149
+ REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_MODE: "required",
150
+ REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
141
151
 
142
152
  // --- federation ---------------------------------------------------
143
153
  FEDERATIONS_GOOGLE_ENABLED: "true",
@@ -162,6 +172,12 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
162
172
 
163
173
  // --- endpoints ----------------------------------------------------
164
174
  ENDPOINTS_LOGIN_URL: "/login",
175
+
176
+ // --- cors ---------------------------------------------------------
177
+ // #500: a list, in the only shape an environment variable can carry one.
178
+ // The schema splits and validates it; the `turns every non-boolean
179
+ // override into its declared type` case below pins the result.
180
+ CORS_ALLOWED_ORIGINS: "https://app.example.com,http://localhost:5173",
165
181
  };
166
182
 
167
183
  /**
@@ -180,6 +196,14 @@ const DELIBERATELY_UNSET: Readonly<Record<string, string>> = {
180
196
  * so a parse failure here is a red umbrella build that this repository can see
181
197
  * first. `SESSION_SECURE=false` is the one it cannot run without: the suite
182
198
  * speaks plain HTTP.
199
+ *
200
+ * The two `FEDERATION_TOKEN_STORE` lines are what #455 / #456 **require the
201
+ * umbrella compose to add**: the federation token store defaults to memory,
202
+ * and the standalone's memory module now declares itself replica-unsafe, so
203
+ * the umbrella's `DEPLOYMENT_MODE=multi` refuses it by name until the compose
204
+ * selects the Redis store and supplies its encryption key. Until that lands
205
+ * in `o3co/auth`, this transcription is ahead of the file it transcribes on
206
+ * exactly those two lines.
183
207
  */
184
208
  const UMBRELLA_E2E_ENV: Readonly<Record<string, string>> = {
185
209
  OAUTH_JWT_ALGORITHM: "HS256",
@@ -194,6 +218,8 @@ const UMBRELLA_E2E_ENV: Readonly<Record<string, string>> = {
194
218
  USER_SESSION_STORES_ADAPTER: "redis",
195
219
  RATE_LIMITER_ADAPTER: "redis",
196
220
  OAUTH_CODE_ADAPTER: "redis",
221
+ FEDERATION_TOKEN_STORE_TYPE: "redis",
222
+ REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
197
223
  CLIENT_USER_TYPE: "yaml",
198
224
  OAUTH_RESOURCE_INDICATOR_ENABLED: "true",
199
225
  OAUTH_REQUIRE_EMAIL_VERIFIED: "true",
@@ -274,6 +300,45 @@ describe("#288: the shipped config boots with every documented override supplied
274
300
  expect(config.session.maxAge).toBe(3600000);
275
301
  expect(config.session.csrf?.ttlSeconds).toBe(7200);
276
302
  expect(config.oauth.nonce?.maxLength).toBe(256);
303
+ // #500: a comma-separated string becomes a list of origins, trimmed.
304
+ expect(config.cors?.allowedOrigins).toEqual([
305
+ "https://app.example.com",
306
+ "http://localhost:5173",
307
+ ]);
308
+ });
309
+
310
+ describe("CORS_ALLOWED_ORIGINS (#500)", () => {
311
+ it("reads an exported-but-empty variable as no origins, not as an error", () => {
312
+ // The .env / compose / ConfigMap shape. "CORS off" is what both the
313
+ // unset key and the empty string mean, so they must agree.
314
+ const config = buildResolvedConfig({ ...DOCUMENTED_ENV, CORS_ALLOWED_ORIGINS: "" });
315
+ expect(config.cors?.allowedOrigins).toEqual([]);
316
+ });
317
+
318
+ it("fails boot on an origin that could never match, naming the key", () => {
319
+ // Every one of these parses as a URL and is a real typo: matching is
320
+ // exact string equality against the Origin header, so each would be
321
+ // an allowlist that admits nobody with nothing to say so.
322
+ for (const bad of [
323
+ "https://app.example.com/", // trailing slash
324
+ "https://app.example.com:443", // explicit default port
325
+ "https://*.example.com", // wildcard
326
+ "http://app.example.com", // plaintext off loopback
327
+ "https://app.example.com/callback", // a URL, not an origin
328
+ ]) {
329
+ expect(() => buildResolvedConfig({ ...DOCUMENTED_ENV, CORS_ALLOWED_ORIGINS: bad })).toThrow(
330
+ /cors\.allowedOrigins/,
331
+ );
332
+ }
333
+ });
334
+
335
+ it("accepts the loopback http carve-out a dev front-end needs", () => {
336
+ const config = buildResolvedConfig({
337
+ ...DOCUMENTED_ENV,
338
+ CORS_ALLOWED_ORIGINS: "http://localhost:5173,http://127.0.0.1:5173,https://app.example.com",
339
+ });
340
+ expect(config.cors?.allowedOrigins).toHaveLength(3);
341
+ });
277
342
  });
278
343
 
279
344
  it("parses the HS256 shape, whose strict union refuses asymmetric key fields", () => {
@@ -302,10 +367,12 @@ describe("#288: the shipped config boots with every documented override supplied
302
367
  it("wires nothing replica-unsafe for the umbrella E2E environment", async () => {
303
368
  // `DEPLOYMENT_MODE=multi` makes the provider audit its own store wiring
304
369
  // at boot, so a config that parses but wires a memory store still fails
305
- // there. Assert against the guard's own list rather than module names.
306
- const { REPLICA_UNSAFE_MODULES } = await import("@o3co/auth-provider-core");
307
- for (const name of buildModules(buildResolvedConfig(UMBRELLA_E2E_ENV)).map((m) => m.name)) {
308
- expect(REPLICA_UNSAFE_MODULES).not.toContain(name);
370
+ // there. Ask each manifest, the way the guard does (#455): the exported
371
+ // name list covers core's modules only, and the template's own memory
372
+ // modules are precisely the ones it could not see.
373
+ const { replicaUnsafeReason } = await import("@o3co/auth-provider-core");
374
+ for (const module of buildModules(buildResolvedConfig(UMBRELLA_E2E_ENV))) {
375
+ expect(replicaUnsafeReason(module), module.name).toBeUndefined();
309
376
  }
310
377
  });
311
378
 
@@ -0,0 +1,179 @@
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
+ * #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.
32
+ */
33
+
34
+ import { fileURLToPath } from "node:url";
35
+ import { type AppConfig, AppConfigSchema, type RateLimiter } from "@o3co/auth-provider-core";
36
+ 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";
41
+
42
+ const configDir = fileURLToPath(new URL("../../config", import.meta.url));
43
+
44
+ /**
45
+ * The secrets `application.conf` substitutes, plus the one
46
+ * `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
+ * secrets, and the `.` characters keep them out of the base64 alphabet so the
49
+ * UTF-8 length is what counts.
50
+ */
51
+ const ENV = {
52
+ OAUTH_JWT_SECRET: "test-secret-rate-limiter-e2e.at-least-32-bytes.ok",
53
+ 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",
56
+ };
57
+
58
+ /**
59
+ * What an operator adds to their own `application.conf` layer: per-endpoint
60
+ * budgets for the Redis limiter, and the mTLS posture from
61
+ * `@o3co/auth-provider-mtls`. Neither section appears in anything this
62
+ * template ships, which is why neither was ever covered by a `.conf` diff.
63
+ */
64
+ const OPERATOR_OVERRIDES = `
65
+ redisRateLimiter {
66
+ limits {
67
+ token { limit = 120, windowSeconds = 60 }
68
+ authorize { limit = 30, windowSeconds = 60 }
69
+ }
70
+ }
71
+
72
+ oauth.mtls {
73
+ enabled = true
74
+ mode = "full-pki"
75
+ trusted-cas = ["-----BEGIN CERTIFICATE-----"]
76
+ full-pki {
77
+ revocation {
78
+ mode = "crl"
79
+ on-unavailable = "reject"
80
+ allowed-hosts = ["crl.example.com"]
81
+ }
82
+ }
83
+ }
84
+ `;
85
+
86
+ function resolveShippedConfig(): AppConfig {
87
+ 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
+ );
95
+ }
96
+
97
+ /** Counts one request per key, the way an untouched Redis counter would. */
98
+ function makeCountingClient(): { incrementWithTtl: (key: string, ttl: number) => Promise<number> } {
99
+ return { incrementWithTtl: async () => 1 };
100
+ }
101
+
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() });
111
+ }
112
+
113
+ describe("the shipped configuration reaches the Redis rate limiter (#495)", () => {
114
+ const config = resolveShippedConfig();
115
+
116
+ it("selects the Redis adapter the production compose file pins", () => {
117
+ expect(config.rateLimiter?.adapter).toBe("redis");
118
+ });
119
+
120
+ it("carries the operator's per-endpoint budgets through the pre-parse", () => {
121
+ expect(config.redisRateLimiter?.limits).toEqual({
122
+ token: { limit: 120, windowSeconds: 60 },
123
+ authorize: { limit: 30, windowSeconds: 60 },
124
+ });
125
+ });
126
+
127
+ 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
+ });
131
+ expect(decision.limit).toBe(120);
132
+ });
133
+
134
+ 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
+ });
138
+ expect(decision.limit).toBe(30);
139
+ });
140
+
141
+ 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
+ });
148
+ expect(decision.limit).toBe(60);
149
+ });
150
+
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);
159
+ });
160
+ });
161
+
162
+ describe("the shipped configuration carries an mTLS posture through (#496)", () => {
163
+ const config = resolveShippedConfig();
164
+
165
+ it("keeps the operator's mTLS block instead of reporting mTLS off", () => {
166
+ expect(config.oauth.mtls).toEqual({
167
+ enabled: true,
168
+ mode: "full-pki",
169
+ "trusted-cas": ["-----BEGIN CERTIFICATE-----"],
170
+ "full-pki": {
171
+ revocation: {
172
+ mode: "crl",
173
+ "on-unavailable": "reject",
174
+ "allowed-hosts": ["crl.example.com"],
175
+ },
176
+ },
177
+ });
178
+ });
179
+ });