@o3co/create-auth-provider 0.11.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.
@@ -142,6 +142,12 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
142
142
  REFRESH_TOKEN_FAMILY_STORE_CAS_RETRY_LIMIT: "3",
143
143
  REFRESH_TOKEN_FAMILY_STORE_REDIS_URL: "redis://redis:6379",
144
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=",
145
151
 
146
152
  // --- federation ---------------------------------------------------
147
153
  FEDERATIONS_GOOGLE_ENABLED: "true",
@@ -166,6 +172,12 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
166
172
 
167
173
  // --- endpoints ----------------------------------------------------
168
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",
169
181
  };
170
182
 
171
183
  /**
@@ -184,6 +196,14 @@ const DELIBERATELY_UNSET: Readonly<Record<string, string>> = {
184
196
  * so a parse failure here is a red umbrella build that this repository can see
185
197
  * first. `SESSION_SECURE=false` is the one it cannot run without: the suite
186
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.
187
207
  */
188
208
  const UMBRELLA_E2E_ENV: Readonly<Record<string, string>> = {
189
209
  OAUTH_JWT_ALGORITHM: "HS256",
@@ -198,6 +218,8 @@ const UMBRELLA_E2E_ENV: Readonly<Record<string, string>> = {
198
218
  USER_SESSION_STORES_ADAPTER: "redis",
199
219
  RATE_LIMITER_ADAPTER: "redis",
200
220
  OAUTH_CODE_ADAPTER: "redis",
221
+ FEDERATION_TOKEN_STORE_TYPE: "redis",
222
+ REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
201
223
  CLIENT_USER_TYPE: "yaml",
202
224
  OAUTH_RESOURCE_INDICATOR_ENABLED: "true",
203
225
  OAUTH_REQUIRE_EMAIL_VERIFIED: "true",
@@ -278,6 +300,45 @@ describe("#288: the shipped config boots with every documented override supplied
278
300
  expect(config.session.maxAge).toBe(3600000);
279
301
  expect(config.session.csrf?.ttlSeconds).toBe(7200);
280
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
+ });
281
342
  });
282
343
 
283
344
  it("parses the HS256 shape, whose strict union refuses asymmetric key fields", () => {
@@ -306,10 +367,12 @@ describe("#288: the shipped config boots with every documented override supplied
306
367
  it("wires nothing replica-unsafe for the umbrella E2E environment", async () => {
307
368
  // `DEPLOYMENT_MODE=multi` makes the provider audit its own store wiring
308
369
  // at boot, so a config that parses but wires a memory store still fails
309
- // there. Assert against the guard's own list rather than module names.
310
- const { REPLICA_UNSAFE_MODULES } = await import("@o3co/auth-provider-core");
311
- for (const name of buildModules(buildResolvedConfig(UMBRELLA_E2E_ENV)).map((m) => m.name)) {
312
- 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();
313
376
  }
314
377
  });
315
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
+ });