@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.
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +11 -1
- package/package.json +2 -2
- package/templates/standalone/.env.example +19 -1
- package/templates/standalone/Dockerfile +9 -1
- package/templates/standalone/README.ja.md +24 -4
- package/templates/standalone/README.md +175 -21
- package/templates/standalone/config/application.conf +28 -0
- package/templates/standalone/config/clients.yaml.example +15 -2
- package/templates/standalone/config/production.conf +9 -3
- package/templates/standalone/docker-compose.production.yml +80 -5
- package/templates/standalone/docker-compose.yml +24 -0
- package/templates/standalone/gitignore +21 -0
- package/templates/standalone/package.json +5 -6
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +100 -0
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +71 -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 +246 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +288 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +324 -0
- package/templates/standalone/src/__tests__/smoke.test.mts +36 -0
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +12 -7
- package/templates/standalone/src/app.mts +16 -3
- package/templates/standalone/src/buildModules.mts +54 -16
- package/templates/standalone/src/modules.mts +128 -24
- package/templates/standalone/src/shutdown.mts +219 -0
- 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)
|
|
23
|
-
# HTTP_TRUST_PROXY
|
|
24
|
-
# rate limit — means the client, not the proxy
|
|
25
|
-
# "Multi-replica
|
|
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
|
-
-
|
|
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.
|
|
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/
|
|
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.
|
|
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": "^
|
|
49
|
-
"vitest": "^4.1.
|
|
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.
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
+
});
|