@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.
- package/package.json +1 -1
- package/templates/standalone/.env.example +13 -0
- package/templates/standalone/README.ja.md +9 -1
- package/templates/standalone/README.md +162 -20
- package/templates/standalone/config/application.conf +17 -0
- package/templates/standalone/docker-compose.production.yml +63 -4
- package/templates/standalone/docker-compose.yml +17 -0
- package/templates/standalone/package.json +1 -1
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +100 -0
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +67 -4
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +179 -0
- package/templates/standalone/src/__tests__/replica-safety.test.mts +456 -0
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +129 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +288 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +81 -1
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +12 -7
- package/templates/standalone/src/app.mts +5 -1
- package/templates/standalone/src/buildModules.mts +54 -16
- package/templates/standalone/src/modules.mts +88 -22
- package/templates/standalone/src/shutdown.mts +64 -7
- package/templates/versions.json +13 -12
|
@@ -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.
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
+
});
|