@o3co/create-auth-provider 0.6.0 → 0.7.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 +3 -3
- package/templates/standalone/config/application.conf +40 -153
- package/templates/standalone/package.json +3 -3
- package/templates/standalone/src/__tests__/configPath.test.mts +18 -1
- package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +29 -0
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +117 -0
- package/templates/standalone/src/app.mts +10 -2
- package/templates/standalone/src/buildModules.mts +4 -4
- package/templates/standalone/src/configPath.mts +30 -0
- package/templates/versions.json +9 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@o3co/create-auth-provider",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Scaffold a new o3co/auth.provider project from the standalone template",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"node": ">=18.19.0"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
|
-
"@types/node": "^25.
|
|
32
|
-
"vitest": "^4.1.
|
|
31
|
+
"@types/node": "^25.7.0",
|
|
32
|
+
"vitest": "^4.1.6"
|
|
33
33
|
},
|
|
34
34
|
"scripts": {
|
|
35
35
|
"build": "tsc",
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
# Application-level overrides for the standalone template.
|
|
2
|
+
#
|
|
3
|
+
# The library ships its declarative defaults in
|
|
4
|
+
# @o3co/auth-provider-core/reference.conf
|
|
5
|
+
# (resolved at boot via standalone/src/app.mts withFallback chain).
|
|
6
|
+
# Values omitted here inherit from that baseline; add overrides only
|
|
7
|
+
# for keys this deployment intentionally diverges on.
|
|
8
|
+
|
|
1
9
|
http {
|
|
2
10
|
port = 3000
|
|
3
11
|
port = ${?HTTP_PORT}
|
|
@@ -6,70 +14,22 @@ http {
|
|
|
6
14
|
}
|
|
7
15
|
|
|
8
16
|
oauth {
|
|
9
|
-
jwt {
|
|
10
|
-
issuer = ${?OAUTH_JWT_ISSUER}
|
|
11
|
-
# SF-1 / Phase G / S2: legacy typ acceptance for the
|
|
12
|
-
# central JWT verifier. The the default is `false`: tokens whose
|
|
13
|
-
# `typ` header is absent are rejected. Operators with v0.4.x tokens
|
|
14
|
-
# still in circulation can opt back to legacy acceptance with
|
|
15
|
-
# `OAUTH_JWT_LEGACY_TYP_ACCEPT=true` for their own bounded migration
|
|
16
|
-
# window — when set, typ-less tokens verify with a
|
|
17
|
-
# `jwt_verify_legacy_typ` warning. Per the v0.5.1 ADR the literal
|
|
18
|
-
# default lives in HOCON, not the Zod schema — this keeps a single
|
|
19
|
-
# source of truth so the env-var override path and the file default
|
|
20
|
-
# don't drift.
|
|
21
|
-
legacyTypAccept = false
|
|
22
|
-
legacyTypAccept = ${?OAUTH_JWT_LEGACY_TYP_ACCEPT}
|
|
23
|
-
signingKey {
|
|
24
|
-
provider = "local"
|
|
25
|
-
provider = ${?OAUTH_JWT_SIGNING_KEY_PROVIDER}
|
|
26
|
-
local {
|
|
27
|
-
algorithm = "HS256"
|
|
28
|
-
algorithm = ${?OAUTH_JWT_ALGORITHM}
|
|
29
|
-
kid = "v0"
|
|
30
|
-
kid = ${?OAUTH_JWT_KID}
|
|
31
|
-
secret = ${?OAUTH_JWT_SECRET}
|
|
32
|
-
privateKey = ${?OAUTH_JWT_PRIVATE_KEY}
|
|
33
|
-
privateKeyPath = ${?OAUTH_JWT_PRIVATE_KEY_PATH}
|
|
34
|
-
publicKey = ${?OAUTH_JWT_PUBLIC_KEY}
|
|
35
|
-
publicKeyPath = ${?OAUTH_JWT_PUBLIC_KEY_PATH}
|
|
36
|
-
# IH-9: HS256 key rotation. To rotate without downtime, set the
|
|
37
|
-
# new secret + kid here (e.g. "v1") and move the old pair into
|
|
38
|
-
# `previousSecrets` with an `expiresAt` covering the access-token
|
|
39
|
-
# TTL plus a buffer. `getVerificationKey(kid)` resolves old
|
|
40
|
-
# tokens by kid until `expiresAt`. Operators on RS256/ES256/EdDSA
|
|
41
|
-
# use the asymmetric-shaped `previousKeys` instead — the
|
|
42
|
-
# discriminated-union schema rejects mixing the shapes.
|
|
43
|
-
# Example:
|
|
44
|
-
# previousSecrets = [
|
|
45
|
-
# { kid = "v0", secret = "<old>", expiresAt = "2026-06-05T00:00:00Z" }
|
|
46
|
-
# ]
|
|
47
|
-
previousSecrets = []
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
accessToken {
|
|
52
|
-
expiresIn = 3600
|
|
53
|
-
expiresIn = ${?OAUTH_ACCESS_TOKEN_EXPIRES_IN}
|
|
54
|
-
}
|
|
55
|
-
refreshToken {
|
|
56
|
-
expiresIn = 86400
|
|
57
|
-
expiresIn = ${?OAUTH_REFRESH_TOKEN_EXPIRES_IN}
|
|
58
|
-
# CC-2 (v0.5.1): handling for refresh tokens whose family_id is not
|
|
59
|
-
# found in the rotation store. "reject" (default, inherited from core
|
|
60
|
-
# application.conf) is safe-by-default. Use "accept" only during a
|
|
61
|
-
# bounded migration window — e.g. moving from v0.4.x in-memory family
|
|
62
|
-
# store to Redis with old tokens still in circulation. Operators set
|
|
63
|
-
# OAUTH_REFRESH_TOKEN_UNKNOWN_FAMILY_POLICY for env-var override.
|
|
64
|
-
# unknownFamilyPolicy = "reject"
|
|
65
|
-
# SF-6 / Phase G / M6: refresh tokens lacking jti or
|
|
66
|
-
# family_id claims when rotation is wired are always rejected.
|
|
67
|
-
# The only valid value is "reject"; the `accept-with-warning`
|
|
68
|
-
# migration-window opt-in was removed.
|
|
69
|
-
# legacyRtPolicy = "reject"
|
|
70
|
-
}
|
|
71
17
|
grants {
|
|
72
|
-
|
|
18
|
+
# Template is a typical web-app OP — enable the universal user-auth path.
|
|
19
|
+
# client_credentials remains off (reference.conf default); deployments that
|
|
20
|
+
# need M2M flows set OAUTH_GRANTS_CLIENT_CREDENTIALS_ENABLED=true or add
|
|
21
|
+
# `client_credentials { enabled = true }` in their own application.conf.
|
|
22
|
+
#
|
|
23
|
+
# The env-override line (`enabled = ${?OAUTH_GRANTS_*_ENABLED}`) is repeated
|
|
24
|
+
# at this layer because HOCON precedence: application.conf wins over
|
|
25
|
+
# reference.conf, so without the env-override line at the template layer,
|
|
26
|
+
# an operator setting OAUTH_GRANTS_SESSION_ENABLED=false (etc.) cannot
|
|
27
|
+
# disable the grant — the env var only takes effect at the layer where
|
|
28
|
+
# the substitution is written.
|
|
29
|
+
session {
|
|
30
|
+
enabled = true
|
|
31
|
+
enabled = ${?OAUTH_GRANTS_SESSION_ENABLED}
|
|
32
|
+
}
|
|
73
33
|
authorization_code {
|
|
74
34
|
enabled = true
|
|
75
35
|
enabled = ${?OAUTH_GRANTS_AUTHORIZATION_CODE_ENABLED}
|
|
@@ -78,13 +38,14 @@ oauth {
|
|
|
78
38
|
requireS256 = ${?OAUTH_GRANTS_AUTHORIZATION_CODE_PKCE_REQUIRE_S256}
|
|
79
39
|
}
|
|
80
40
|
}
|
|
81
|
-
refresh_token {
|
|
41
|
+
refresh_token {
|
|
42
|
+
enabled = true
|
|
43
|
+
enabled = ${?OAUTH_GRANTS_REFRESH_TOKEN_ENABLED}
|
|
44
|
+
}
|
|
45
|
+
# client_credentials: omitted -> inherits reference.conf default (enabled = false).
|
|
46
|
+
# Operators that need M2M flows set OAUTH_GRANTS_CLIENT_CREDENTIALS_ENABLED=true
|
|
47
|
+
# or add `client_credentials { enabled = true }` here.
|
|
82
48
|
}
|
|
83
|
-
# IH-6: when oauth.jwt.issuer is configured, require `openid` in
|
|
84
|
-
# /authorize scope by default. Set OAUTH_OIDC_MODE=dual only if this OP
|
|
85
|
-
# intentionally also accepts OAuth-only authorization requests.
|
|
86
|
-
oidcMode = "oidc-required"
|
|
87
|
-
oidcMode = ${?OAUTH_OIDC_MODE}
|
|
88
49
|
# OR-9: adapter switch for the OAuth authorization-code repository. When
|
|
89
50
|
# set to "redis", the standalone composition root wires
|
|
90
51
|
# `redisCodeRepositoryModule` against the shared ioredis socket
|
|
@@ -96,13 +57,6 @@ oauth {
|
|
|
96
57
|
adapter = "redis"
|
|
97
58
|
adapter = ${?OAUTH_CODE_ADAPTER}
|
|
98
59
|
}
|
|
99
|
-
# F10: maximum existing RFC 8693 actor chain depth accepted before adding
|
|
100
|
-
# the current actor. Depth 3 allows one current actor plus two prior nested
|
|
101
|
-
# actors; lower for stricter delegation environments.
|
|
102
|
-
tokenExchange {
|
|
103
|
-
maxActorChainDepth = 3
|
|
104
|
-
maxActorChainDepth = ${?OAUTH_TOKEN_EXCHANGE_MAX_ACTOR_CHAIN_DEPTH}
|
|
105
|
-
}
|
|
106
60
|
}
|
|
107
61
|
|
|
108
62
|
session {
|
|
@@ -138,17 +92,20 @@ session {
|
|
|
138
92
|
# component (memoryRateLimiterModule or redisRateLimiterModule) with
|
|
139
93
|
# its own `memoryRateLimiter.*` / `redisRateLimiter.*` config section
|
|
140
94
|
# (uses `windowSeconds` instead of `windowMs`). Switch via
|
|
141
|
-
# `rateLimiter.adapter = "memory" | "redis"` (see
|
|
95
|
+
# `rateLimiter.adapter = "memory" | "redis"` (see reference.conf).
|
|
142
96
|
rateLimit {
|
|
97
|
+
# `login` is per-deployment tuning (bruteforce-window + limit), kept in
|
|
98
|
+
# this layer so operators can tweak without forking reference.conf.
|
|
143
99
|
login { windowMs = 900000, limit = 20 }
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
#
|
|
147
|
-
failMode =
|
|
148
|
-
failMode = ${?RATE_LIMIT_FAIL_MODE}
|
|
100
|
+
# rateLimit.failMode: inherits reference.conf default ("closed").
|
|
101
|
+
# Deployments that prefer fail-open override here:
|
|
102
|
+
# failMode = "open"
|
|
103
|
+
# failMode = ${?RATE_LIMIT_FAIL_MODE}
|
|
149
104
|
}
|
|
150
105
|
|
|
151
106
|
federations {
|
|
107
|
+
# Template ships a Google federation declaration disabled-by-default.
|
|
108
|
+
# Operators enable + populate credentials via env vars.
|
|
152
109
|
google {
|
|
153
110
|
enabled = false
|
|
154
111
|
enabled = ${?FEDERATIONS_GOOGLE_ENABLED}
|
|
@@ -210,83 +167,13 @@ cors {
|
|
|
210
167
|
allowedOrigins = []
|
|
211
168
|
}
|
|
212
169
|
|
|
213
|
-
# Wave 5d (IH-14 + OR-M1 + OR-4): adapter switches for the OAuth-endpoint
|
|
214
|
-
# rate limiter and the user-session stores (4-store split: userSessionStore
|
|
215
|
-
# / sessionRPRegistry / sessionFamilyIndex / sessionFederationIndex).
|
|
216
|
-
#
|
|
217
|
-
# Multi-replica production MUST set BOTH to `"redis"`. The defaults are
|
|
218
|
-
# `"memory"` for backward-compat with single-instance deployments and CI;
|
|
219
|
-
# in-memory rate-limit counters / session state are isolated per replica
|
|
220
|
-
# and inconsistent under load-balanced traffic.
|
|
221
|
-
#
|
|
222
|
-
# When `userSessionStores.adapter = "redis"` or `rateLimiter.adapter = "redis"`,
|
|
223
|
-
# the standalone composition root opens a single shared ioredis connection
|
|
224
|
-
# (via `standaloneRedisClientsModule`) consuming `refreshTokenFamilyStore.redis.url`
|
|
225
|
-
# (D-2 v2). One ioredis socket per replica backs ALL Redis-typed clients.
|
|
226
|
-
rateLimiter {
|
|
227
|
-
adapter = "memory"
|
|
228
|
-
adapter = ${?RATE_LIMITER_ADAPTER}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
memoryRateLimiter {
|
|
232
|
-
# SF-10: bound the in-memory OAuth endpoint rate-limiter bucket map.
|
|
233
|
-
# Multi-replica production should use `rateLimiter.adapter = "redis"`;
|
|
234
|
-
# this cap keeps the single-process fallback from growing without limit.
|
|
235
|
-
maxBuckets = 10000
|
|
236
|
-
maxBuckets = ${?MEMORY_RATE_LIMITER_MAX_BUCKETS}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
userSessionStores {
|
|
240
|
-
adapter = "memory"
|
|
241
|
-
adapter = ${?USER_SESSION_STORES_ADAPTER}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
# MIN-3: outer namespace for the four bundled Redis user-session stores.
|
|
245
|
-
# The module appends fixed sub-prefixes (`us:`, `rp:`, `fi:`, `fed:`).
|
|
246
|
-
# Multi-tenant Redis deployments should set a deployment-specific value,
|
|
247
|
-
# e.g. `tenant-a:ss:`.
|
|
248
|
-
redisSessionStores {
|
|
249
|
-
keyPrefix = "ss:"
|
|
250
|
-
keyPrefix = ${?REDIS_SESSION_STORES_KEY_PREFIX}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
170
|
# D-2 v2 + Wave 5d: ioredis connection-config consumed by the shared
|
|
254
171
|
# `standaloneRedisClientsModule` in `templates/standalone/src/modules.mts`.
|
|
255
172
|
# One ioredis socket per replica backs ALL Redis-typed clients (RT family
|
|
256
173
|
# + 4 user-session stores + rate limiter). Multi-replica deployments MUST
|
|
257
174
|
# point this at a shared Redis 7.2+ instance — without it, each replica
|
|
258
175
|
# stores RT families in its own ioredis connection against a local-only
|
|
259
|
-
# Redis, defeating the cross-replica persistence purpose.
|
|
260
|
-
# config (keyPrefix / casRetryLimit) lives separately under
|
|
261
|
-
# `redisRefreshTokenFamilyStore.*` (consumed by the store module's schema).
|
|
262
|
-
redisRefreshTokenFamilyStore {
|
|
263
|
-
keyPrefix = "rtfam:"
|
|
264
|
-
keyPrefix = ${?REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX}
|
|
265
|
-
casRetryLimit = 3
|
|
266
|
-
casRetryLimit = ${?REFRESH_TOKEN_FAMILY_STORE_CAS_RETRY_LIMIT}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
# OR-9 (Wave 5d): module-internal config for `redisCodeRepositoryModule`.
|
|
270
|
-
# Consumed when `oauth.code.adapter = "redis"`. The shared ioredis socket
|
|
271
|
-
# (`standaloneRedisClientsModule`) provides the `codeRepositoryClient`
|
|
272
|
-
# slot; this section governs only key namespacing and the default
|
|
273
|
-
# expiry-in-seconds the repository uses when /authorize doesn't supply one.
|
|
274
|
-
#
|
|
275
|
-
# Multi-tenant deployments override `keyPrefix` to isolate tenants in the
|
|
276
|
-
# same Redis instance — e.g. `keyPrefix = "tenant-a:code:"` keeps tenant A's
|
|
277
|
-
# codes from colliding with tenant B's under the default `oauth:code:`
|
|
278
|
-
# prefix (MIN-3 advisory).
|
|
279
|
-
redisCodeRepository {
|
|
280
|
-
# No literal default for keyPrefix — the RedisCodeRepository constructor
|
|
281
|
-
# falls back to `oauth:code:` when this field is absent. Operators that
|
|
282
|
-
# need multi-tenant isolation set CLIENT_CODE_KEY_PREFIX (e.g.
|
|
283
|
-
# `tenant-a:code:`); leaving the env var unset keeps the constructor
|
|
284
|
-
# default intact via HOCON's `${?VAR}` "absent if unset" semantics.
|
|
285
|
-
keyPrefix = ${?CLIENT_CODE_KEY_PREFIX}
|
|
286
|
-
defaultExpiresIn = 600
|
|
287
|
-
defaultExpiresIn = ${?CLIENT_CODE_DEFAULT_EXPIRES_IN}
|
|
288
|
-
}
|
|
289
|
-
|
|
176
|
+
# Redis, defeating the cross-replica persistence purpose.
|
|
290
177
|
refreshTokenFamilyStore {
|
|
291
178
|
redis {
|
|
292
179
|
url = "redis://localhost:6379"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@o3co/auth-provider-standalone",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"engines": {
|
|
@@ -40,12 +40,12 @@
|
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@types/express": "^5.0.6",
|
|
42
42
|
"@types/express-session": "^1",
|
|
43
|
-
"@types/node": "^25.
|
|
43
|
+
"@types/node": "^25.7.0",
|
|
44
44
|
"@types/supertest": "^7.2.0",
|
|
45
45
|
"supertest": "^7.1.0",
|
|
46
46
|
"tsx": "^4.21.0",
|
|
47
47
|
"typescript": "^5.9.3",
|
|
48
|
-
"vitest": "^4.1.
|
|
48
|
+
"vitest": "^4.1.6"
|
|
49
49
|
},
|
|
50
50
|
"pnpm": {
|
|
51
51
|
"onlyBuiltDependencies": [
|
|
@@ -14,8 +14,10 @@
|
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { existsSync } from "node:fs";
|
|
18
|
+
import { isAbsolute } from "node:path";
|
|
17
19
|
import { describe, expect, it } from "vitest";
|
|
18
|
-
import { resolveConfigPaths } from "../configPath.mjs";
|
|
20
|
+
import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
|
|
19
21
|
|
|
20
22
|
describe("resolveConfigPaths", () => {
|
|
21
23
|
it("accepts a configDirPath with a trailing slash (regression: fileURLToPath preserves trailing /)", () => {
|
|
@@ -48,3 +50,18 @@ describe("resolveConfigPaths", () => {
|
|
|
48
50
|
).toThrow(/resolves outside/);
|
|
49
51
|
});
|
|
50
52
|
});
|
|
53
|
+
|
|
54
|
+
describe("resolveLibraryReferenceConfPath", () => {
|
|
55
|
+
it("returns an absolute path ending in reference.conf", () => {
|
|
56
|
+
const path = resolveLibraryReferenceConfPath();
|
|
57
|
+
expect(path.endsWith("reference.conf")).toBe(true);
|
|
58
|
+
// Use `isAbsolute` rather than `startsWith("/")` so the assertion
|
|
59
|
+
// holds on Windows (e.g. `C:\...`) as well as POSIX paths.
|
|
60
|
+
expect(isAbsolute(path)).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("points to a file that exists on disk", () => {
|
|
64
|
+
const path = resolveLibraryReferenceConfPath();
|
|
65
|
+
expect(existsSync(path)).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { describe, expect, it } from "vitest";
|
|
20
|
+
|
|
21
|
+
describe("library reference.conf subpath resolution", () => {
|
|
22
|
+
it("resolves @o3co/auth-provider-core/reference.conf to a readable file", () => {
|
|
23
|
+
const url = import.meta.resolve("@o3co/auth-provider-core/reference.conf");
|
|
24
|
+
const path = fileURLToPath(url);
|
|
25
|
+
expect(existsSync(path)).toBe(true);
|
|
26
|
+
const content = readFileSync(path, "utf-8");
|
|
27
|
+
expect(content).toMatch(/oauth\s*\{/); // sanity: HOCON content
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
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
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { type AppConfig, AppConfigSchema } from "@o3co/auth-provider-core";
|
|
19
|
+
import { parseFile } from "@o3co/ts.hocon";
|
|
20
|
+
import { validate } from "@o3co/ts.hocon/zod";
|
|
21
|
+
import { describe, expect, it } from "vitest";
|
|
22
|
+
import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
|
|
23
|
+
|
|
24
|
+
// config/ is two levels above this test file:
|
|
25
|
+
// src/__tests__/ → src/ → standalone/ → config/
|
|
26
|
+
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
27
|
+
|
|
28
|
+
// Provide required secrets so AppConfigSchema parse succeeds. These are
|
|
29
|
+
// test-only values — no real keys are embedded here.
|
|
30
|
+
const testEnv = {
|
|
31
|
+
OAUTH_JWT_SECRET: "test-secret-three-tier",
|
|
32
|
+
SESSION_SECRET: "test-session-secret-three-tier",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function buildResolvedConfig(env: string, extraEnv: Record<string, string> = {}): AppConfig {
|
|
36
|
+
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, env);
|
|
37
|
+
const libraryReferencePath = resolveLibraryReferenceConfPath();
|
|
38
|
+
const resolvedEnv = { ...testEnv, ...extraEnv };
|
|
39
|
+
return validate(
|
|
40
|
+
parseFile(envConfPath, { env: resolvedEnv })
|
|
41
|
+
.withFallback(parseFile(applicationConfPath, { env: resolvedEnv }))
|
|
42
|
+
.withFallback(parseFile(libraryReferencePath, { env: resolvedEnv })),
|
|
43
|
+
AppConfigSchema,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// `oauth.grants` is declared as `z.object({}).passthrough()` in the schema,
|
|
48
|
+
// so the inferred TS type is `{}` per entry and accessing `.enabled` doesn't
|
|
49
|
+
// compile under `tsc --strict`. Tests assert against the resolved runtime
|
|
50
|
+
// shape (string from env substitution, boolean from literal), so the cast
|
|
51
|
+
// is intentional and well-bounded.
|
|
52
|
+
type GrantEntry = { enabled?: unknown };
|
|
53
|
+
const grants = (config: AppConfig) =>
|
|
54
|
+
(config.oauth.grants as unknown as Record<string, GrantEntry | undefined>) ?? {};
|
|
55
|
+
|
|
56
|
+
describe("three-tier HOCON resolution (env → application.conf → reference.conf)", () => {
|
|
57
|
+
it("template application.conf wins over reference.conf for grant.enabled", () => {
|
|
58
|
+
const config = buildResolvedConfig("development");
|
|
59
|
+
// Template's application.conf sets authorization_code.enabled = true.
|
|
60
|
+
// This test asserts the resolved value — it remains true whether
|
|
61
|
+
// reference.conf says false (current secure-default) or true (legacy).
|
|
62
|
+
expect(grants(config).authorization_code?.enabled).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("reference.conf default reaches resolved config when template omits the key", () => {
|
|
66
|
+
const config = buildResolvedConfig("development");
|
|
67
|
+
// tokenExchange.maxActorChainDepth is library-owned in both layers
|
|
68
|
+
// (template doesn't override it). Verifies precedence falls through.
|
|
69
|
+
expect(config.oauth.tokenExchange?.maxActorChainDepth).toBe(3);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("env var at template layer can disable a template-enabled grant (precedence: env-override line must be repeated)", () => {
|
|
73
|
+
// Template enables authorization_code via application.conf. The env-override
|
|
74
|
+
// line is repeated at the template layer alongside `enabled = true`,
|
|
75
|
+
// so OAUTH_GRANTS_AUTHORIZATION_CODE_ENABLED=false reaches the resolved value.
|
|
76
|
+
// Without the repeated env line, the substitution at reference.conf is
|
|
77
|
+
// shadowed by the template's literal `true`.
|
|
78
|
+
// Note: ts.hocon substitutes env vars as strings; the schema (passthrough)
|
|
79
|
+
// preserves the string. The assertion checks the string form so that
|
|
80
|
+
// the precedence invariant is testable now; a future schema-coercion PR
|
|
81
|
+
// (separate from this one) will make this resolve to boolean false.
|
|
82
|
+
const config = buildResolvedConfig("development", {
|
|
83
|
+
OAUTH_GRANTS_AUTHORIZATION_CODE_ENABLED: "false",
|
|
84
|
+
});
|
|
85
|
+
expect(grants(config).authorization_code?.enabled).toBe("false");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("reference.conf default for rateLimit.failMode is 'closed'", () => {
|
|
89
|
+
const config = buildResolvedConfig("development");
|
|
90
|
+
expect(config.rateLimit?.failMode).toBe("closed");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("reference.conf default for client_credentials.enabled is false", () => {
|
|
94
|
+
// Template doesn't enable client_credentials. Reference default propagates.
|
|
95
|
+
const config = buildResolvedConfig("development");
|
|
96
|
+
expect(grants(config).client_credentials?.enabled).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("reference.conf default for oauth.resourceIndicator.enabled is false", () => {
|
|
100
|
+
// reference.conf must ship the literal `false` anchor so that the
|
|
101
|
+
// schema coercion (coerceBooleanFromEnv) can produce a boolean value.
|
|
102
|
+
// Without the HOCON block the field resolves to undefined.
|
|
103
|
+
const config = buildResolvedConfig("development");
|
|
104
|
+
expect(config.oauth.resourceIndicator?.enabled).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("env var OAUTH_RESOURCE_INDICATOR_ENABLED=true reaches resolved config and coerces to boolean true", () => {
|
|
108
|
+
// HOCON env-substitution returns a string. The reference.conf anchor
|
|
109
|
+
// (`enabled = ${?OAUTH_RESOURCE_INDICATOR_ENABLED}`) lets the env var
|
|
110
|
+
// reach the resolved layer; the schema's coerceBooleanFromEnv turns
|
|
111
|
+
// "true" → true so `=== true` guards in grant handlers work correctly.
|
|
112
|
+
const config = buildResolvedConfig("development", {
|
|
113
|
+
OAUTH_RESOURCE_INDICATOR_ENABLED: "true",
|
|
114
|
+
});
|
|
115
|
+
expect(config.oauth.resourceIndicator?.enabled).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -23,16 +23,24 @@ import helmet from "helmet";
|
|
|
23
23
|
|
|
24
24
|
import logger from "#/logger.mjs";
|
|
25
25
|
import { buildModules } from "./buildModules.mjs";
|
|
26
|
-
import { resolveConfigPaths } from "./configPath.mjs";
|
|
26
|
+
import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "./configPath.mjs";
|
|
27
27
|
|
|
28
28
|
// Step 1: Load and validate application config (HOCON → Zod schema).
|
|
29
29
|
// ENV = CONFIG_ENV || NODE_ENV || "development"; missing {ENV}.conf is fatal.
|
|
30
|
+
//
|
|
31
|
+
// 3-tier HOCON precedence (highest → lowest):
|
|
32
|
+
// 1. {env}.conf — environment-specific overrides (e.g. production.conf)
|
|
33
|
+
// 2. application.conf — template-level consumer delta
|
|
34
|
+
// 3. reference.conf — library defaults shipped in @o3co/auth-provider-core
|
|
30
35
|
const env = process.env.CONFIG_ENV || process.env.NODE_ENV || "development";
|
|
31
36
|
const configDir = new URL("../config/", import.meta.url);
|
|
32
37
|
const configDirPath = fileURLToPath(configDir);
|
|
33
38
|
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDirPath, env);
|
|
39
|
+
const libraryReferencePath = resolveLibraryReferenceConfPath();
|
|
34
40
|
const config: AppConfig = validate(
|
|
35
|
-
parseFile(envConfPath)
|
|
41
|
+
parseFile(envConfPath)
|
|
42
|
+
.withFallback(parseFile(applicationConfPath))
|
|
43
|
+
.withFallback(parseFile(libraryReferencePath)),
|
|
36
44
|
AppConfigSchema,
|
|
37
45
|
);
|
|
38
46
|
|
|
@@ -97,9 +97,9 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
97
97
|
|
|
98
98
|
// OR-9: effective code-repo adapter. `oauth.code.adapter` is the
|
|
99
99
|
// authoritative switch; the legacy `repositories.code.type = "redis"`
|
|
100
|
-
// path is honored
|
|
101
|
-
//
|
|
102
|
-
//
|
|
100
|
+
// path is honored with a deprecation warn so existing operators
|
|
101
|
+
// relying on `CLIENT_CODE_TYPE=redis` env-var overrides keep working
|
|
102
|
+
// until they migrate. See CHANGELOG for the removal version.
|
|
103
103
|
const oauthCodeAdapter = config.oauth?.code?.adapter;
|
|
104
104
|
const legacyCodeType = (config.repositories?.code as { type?: string } | undefined)?.type;
|
|
105
105
|
let codeRepositoryAdapter: "memory" | "redis";
|
|
@@ -108,7 +108,7 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
108
108
|
} else if (legacyCodeType === "redis") {
|
|
109
109
|
console.warn(
|
|
110
110
|
'[buildModules] `repositories.code.type = "redis"` is deprecated; use ' +
|
|
111
|
-
'`oauth.code.adapter = "redis"` instead
|
|
111
|
+
'`oauth.code.adapter = "redis"` instead — see CHANGELOG for the removal version.',
|
|
112
112
|
);
|
|
113
113
|
codeRepositoryAdapter = "redis";
|
|
114
114
|
} else {
|
|
@@ -14,13 +14,43 @@
|
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { createRequire } from "node:module";
|
|
17
18
|
import path from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
18
20
|
|
|
19
21
|
export interface ResolvedConfigPaths {
|
|
20
22
|
readonly applicationConfPath: string;
|
|
21
23
|
readonly envConfPath: string;
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
const REFERENCE_CONF_SUBPATH = "@o3co/auth-provider-core/reference.conf";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Returns the absolute path to the shipped `reference.conf` inside
|
|
30
|
+
* `@o3co/auth-provider-core`. This file is the bottom layer of the
|
|
31
|
+
* 3-tier HOCON precedence chain (reference.conf → application.conf → {env}.conf).
|
|
32
|
+
*
|
|
33
|
+
* Primary path: `import.meta.resolve` (Node.js Stability 1.2 RC, unflagged
|
|
34
|
+
* since Node 18.19.0 / 20.6.0 — the engine floor of every package in this
|
|
35
|
+
* scope). Fallback: `createRequire(import.meta.url).resolve(...)`. The
|
|
36
|
+
* fallback covers two edge cases:
|
|
37
|
+
*
|
|
38
|
+
* 1. A future Node release deprecates or alters the sync form of
|
|
39
|
+
* `import.meta.resolve` (still labelled Stability 1.2 RC per Node docs).
|
|
40
|
+
* 2. An exotic runtime / loader where `import.meta.resolve` is not
|
|
41
|
+
* available but CommonJS-style resolution still is.
|
|
42
|
+
*
|
|
43
|
+
* Both APIs read the same `exports` map in `@o3co/auth-provider-core`'s
|
|
44
|
+
* `package.json`, so the resolved path is identical.
|
|
45
|
+
*/
|
|
46
|
+
export function resolveLibraryReferenceConfPath(): string {
|
|
47
|
+
try {
|
|
48
|
+
return fileURLToPath(import.meta.resolve(REFERENCE_CONF_SUBPATH));
|
|
49
|
+
} catch {
|
|
50
|
+
return createRequire(import.meta.url).resolve(REFERENCE_CONF_SUBPATH);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
24
54
|
export function resolveConfigPaths(configDirPath: string, env: string): ResolvedConfigPaths {
|
|
25
55
|
// path.resolve strips trailing slashes that fileURLToPath may preserve, so
|
|
26
56
|
// the containment check below compares equal shapes (path.dirname never
|
package/templates/versions.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
-
"@o3co/auth-provider-core": "0.
|
|
3
|
-
"@o3co/auth-provider-federation-google": "0.
|
|
4
|
-
"@o3co/auth-provider-federation-github": "0.
|
|
5
|
-
"@o3co/auth-provider-oauth": "0.
|
|
6
|
-
"@o3co/auth-provider-oauth-token-exchange": "0.
|
|
7
|
-
"@o3co/auth-provider-session": "0.
|
|
8
|
-
"@o3co/auth-provider-foundation": "0.
|
|
9
|
-
"@o3co/auth-provider-redis": "0.
|
|
2
|
+
"@o3co/auth-provider-core": "0.7.0",
|
|
3
|
+
"@o3co/auth-provider-federation-google": "0.7.0",
|
|
4
|
+
"@o3co/auth-provider-federation-github": "0.7.0",
|
|
5
|
+
"@o3co/auth-provider-oauth": "0.7.0",
|
|
6
|
+
"@o3co/auth-provider-oauth-token-exchange": "0.7.0",
|
|
7
|
+
"@o3co/auth-provider-session": "0.7.0",
|
|
8
|
+
"@o3co/auth-provider-foundation": "0.7.0",
|
|
9
|
+
"@o3co/auth-provider-redis": "0.7.0",
|
|
10
|
+
"@o3co/auth-provider-webauthn": "0.7.0"
|
|
10
11
|
}
|