@o3co/create-auth-provider 0.5.3 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/create-auth-provider",
3
- "version": "0.5.3",
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.1.0",
32
- "vitest": "^4.1.2"
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,81 +14,22 @@ http {
6
14
  }
7
15
 
8
16
  oauth {
9
- jwt {
10
- issuer = ${?OAUTH_JWT_ISSUER}
11
- # SF-1 (v0.5.1): legacy typ acceptance for the central JWT verifier.
12
- # When true (default), tokens whose `typ` header is absent are
13
- # accepted with a `jwt_verify_legacy_typ` warning so v0.4.x tokens
14
- # keep verifying through the v0.5.x transition window. Flip to false
15
- # in v0.6+ to reject typ-less tokens. Active default per the v0.5.1
16
- # ADR: literal default lives in HOCON (zod has no `.default()`).
17
- # Operators set OAUTH_JWT_LEGACY_TYP_ACCEPT to override (set "false"
18
- # to enforce strict mode early).
19
- legacyTypAccept = true
20
- legacyTypAccept = ${?OAUTH_JWT_LEGACY_TYP_ACCEPT}
21
- signingKey {
22
- provider = "local"
23
- provider = ${?OAUTH_JWT_SIGNING_KEY_PROVIDER}
24
- local {
25
- algorithm = "HS256"
26
- algorithm = ${?OAUTH_JWT_ALGORITHM}
27
- kid = "v0"
28
- kid = ${?OAUTH_JWT_KID}
29
- secret = ${?OAUTH_JWT_SECRET}
30
- privateKey = ${?OAUTH_JWT_PRIVATE_KEY}
31
- privateKeyPath = ${?OAUTH_JWT_PRIVATE_KEY_PATH}
32
- publicKey = ${?OAUTH_JWT_PUBLIC_KEY}
33
- publicKeyPath = ${?OAUTH_JWT_PUBLIC_KEY_PATH}
34
- # IH-9: HS256 key rotation. To rotate without downtime, set the
35
- # new secret + kid here (e.g. "v1") and move the old pair into
36
- # `previousSecrets` with an `expiresAt` covering the access-token
37
- # TTL plus a buffer. `getVerificationKey(kid)` resolves old
38
- # tokens by kid until `expiresAt`. Operators on RS256/ES256/EdDSA
39
- # use the asymmetric-shaped `previousKeys` instead — the
40
- # discriminated-union schema rejects mixing the shapes.
41
- # Example:
42
- # previousSecrets = [
43
- # { kid = "v0", secret = "<old>", expiresAt = "2026-06-05T00:00:00Z" }
44
- # ]
45
- previousSecrets = []
46
- }
47
- }
48
- }
49
- accessToken {
50
- expiresIn = 3600
51
- expiresIn = ${?OAUTH_ACCESS_TOKEN_EXPIRES_IN}
52
- }
53
- refreshToken {
54
- expiresIn = 86400
55
- expiresIn = ${?OAUTH_REFRESH_TOKEN_EXPIRES_IN}
56
- # CC-2 (v0.5.1): handling for refresh tokens whose family_id is not
57
- # found in the rotation store. "reject" (default, inherited from core
58
- # application.conf) is safe-by-default. Use "accept" only during a
59
- # bounded migration window — e.g. moving from v0.4.x in-memory family
60
- # store to Redis with old tokens still in circulation. Operators set
61
- # OAUTH_REFRESH_TOKEN_UNKNOWN_FAMILY_POLICY for env-var override.
62
- # unknownFamilyPolicy = "reject"
63
- # SF-6 (v0.5.1): handling for refresh tokens lacking jti or family_id
64
- # claims when rotation is wired. "reject" (default) is safe. Use
65
- # "accept-with-warning" only during a migration window where v0.4.x
66
- # tokens (issued before claim standardization) are still in circulation;
67
- # that mode skips replay detection for the request and emits an audit
68
- # log. Pair with `unknownFamilyPolicy = "accept"` to avoid immediate
69
- # rejection on the next refresh — see Phase F migration guide.
70
- # legacyRtPolicy = "reject"
71
- # AS-12 (v0.5.1): v0.5.x default accepts v0.4.x refresh tokens that use
72
- # payload.type = "refresh" or claims.user.id as the subject. Set false
73
- # only after all pre-v0.5 refresh tokens have expired; remaining legacy
74
- # tokens will be rejected with invalid_grant. The strict marker-required
75
- # gate is preserved across both flag values: a typ-less JWT with no
76
- # payload.type is rejected at the refresh-grant gate even when
77
- # oauth.jwt.legacyTypAccept = true (AT-as-RT confusion stays defended).
78
- # Operators set OAUTH_REFRESH_TOKEN_LEGACY_TOKEN_COMPAT for env-var
79
- # override.
80
- # legacyTokenCompat = true
81
- }
82
17
  grants {
83
- session { enabled = true, enabled = ${?OAUTH_GRANTS_SESSION_ENABLED} }
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
+ }
84
33
  authorization_code {
85
34
  enabled = true
86
35
  enabled = ${?OAUTH_GRANTS_AUTHORIZATION_CODE_ENABLED}
@@ -89,13 +38,14 @@ oauth {
89
38
  requireS256 = ${?OAUTH_GRANTS_AUTHORIZATION_CODE_PKCE_REQUIRE_S256}
90
39
  }
91
40
  }
92
- refresh_token { enabled = true, enabled = ${?OAUTH_GRANTS_REFRESH_TOKEN_ENABLED} }
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.
93
48
  }
94
- # IH-6: when oauth.jwt.issuer is configured, require `openid` in
95
- # /authorize scope by default. Set OAUTH_OIDC_MODE=dual only if this OP
96
- # intentionally also accepts OAuth-only authorization requests.
97
- oidcMode = "oidc-required"
98
- oidcMode = ${?OAUTH_OIDC_MODE}
99
49
  # OR-9: adapter switch for the OAuth authorization-code repository. When
100
50
  # set to "redis", the standalone composition root wires
101
51
  # `redisCodeRepositoryModule` against the shared ioredis socket
@@ -107,13 +57,6 @@ oauth {
107
57
  adapter = "redis"
108
58
  adapter = ${?OAUTH_CODE_ADAPTER}
109
59
  }
110
- # F10: maximum existing RFC 8693 actor chain depth accepted before adding
111
- # the current actor. Depth 3 allows one current actor plus two prior nested
112
- # actors; lower for stricter delegation environments.
113
- tokenExchange {
114
- maxActorChainDepth = 3
115
- maxActorChainDepth = ${?OAUTH_TOKEN_EXCHANGE_MAX_ACTOR_CHAIN_DEPTH}
116
- }
117
60
  }
118
61
 
119
62
  session {
@@ -149,17 +92,20 @@ session {
149
92
  # component (memoryRateLimiterModule or redisRateLimiterModule) with
150
93
  # its own `memoryRateLimiter.*` / `redisRateLimiter.*` config section
151
94
  # (uses `windowSeconds` instead of `windowMs`). Switch via
152
- # `rateLimiter.adapter = "memory" | "redis"` (see below).
95
+ # `rateLimiter.adapter = "memory" | "redis"` (see reference.conf).
153
96
  rateLimit {
97
+ # `login` is per-deployment tuning (bruteforce-window + limit), kept in
98
+ # this layer so operators can tweak without forking reference.conf.
154
99
  login { windowMs = 900000, limit = 20 }
155
- # OR-5: fail-mode for the OAuth-endpoint rate limiter when the limiter
156
- # backend errors. "open" = allow + `logger.error`. "closed" = HTTP 503
157
- # + `logger.error` (recommended for security-sensitive deployments).
158
- failMode = "open"
159
- 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}
160
104
  }
161
105
 
162
106
  federations {
107
+ # Template ships a Google federation declaration disabled-by-default.
108
+ # Operators enable + populate credentials via env vars.
163
109
  google {
164
110
  enabled = false
165
111
  enabled = ${?FEDERATIONS_GOOGLE_ENABLED}
@@ -221,83 +167,13 @@ cors {
221
167
  allowedOrigins = []
222
168
  }
223
169
 
224
- # Wave 5d (IH-14 + OR-M1 + OR-4): adapter switches for the OAuth-endpoint
225
- # rate limiter and the user-session stores (4-store split: userSessionStore
226
- # / sessionRPRegistry / sessionFamilyIndex / sessionFederationIndex).
227
- #
228
- # Multi-replica production MUST set BOTH to `"redis"`. The defaults are
229
- # `"memory"` for backward-compat with single-instance deployments and CI;
230
- # in-memory rate-limit counters / session state are isolated per replica
231
- # and inconsistent under load-balanced traffic.
232
- #
233
- # When `userSessionStores.adapter = "redis"` or `rateLimiter.adapter = "redis"`,
234
- # the standalone composition root opens a single shared ioredis connection
235
- # (via `standaloneRedisClientsModule`) consuming `refreshTokenFamilyStore.redis.url`
236
- # (D-2 v2). One ioredis socket per replica backs ALL Redis-typed clients.
237
- rateLimiter {
238
- adapter = "memory"
239
- adapter = ${?RATE_LIMITER_ADAPTER}
240
- }
241
-
242
- memoryRateLimiter {
243
- # SF-10: bound the in-memory OAuth endpoint rate-limiter bucket map.
244
- # Multi-replica production should use `rateLimiter.adapter = "redis"`;
245
- # this cap keeps the single-process fallback from growing without limit.
246
- maxBuckets = 10000
247
- maxBuckets = ${?MEMORY_RATE_LIMITER_MAX_BUCKETS}
248
- }
249
-
250
- userSessionStores {
251
- adapter = "memory"
252
- adapter = ${?USER_SESSION_STORES_ADAPTER}
253
- }
254
-
255
- # MIN-3: outer namespace for the four bundled Redis user-session stores.
256
- # The module appends fixed sub-prefixes (`us:`, `rp:`, `fi:`, `fed:`).
257
- # Multi-tenant Redis deployments should set a deployment-specific value,
258
- # e.g. `tenant-a:ss:`.
259
- redisSessionStores {
260
- keyPrefix = "ss:"
261
- keyPrefix = ${?REDIS_SESSION_STORES_KEY_PREFIX}
262
- }
263
-
264
170
  # D-2 v2 + Wave 5d: ioredis connection-config consumed by the shared
265
171
  # `standaloneRedisClientsModule` in `templates/standalone/src/modules.mts`.
266
172
  # One ioredis socket per replica backs ALL Redis-typed clients (RT family
267
173
  # + 4 user-session stores + rate limiter). Multi-replica deployments MUST
268
174
  # point this at a shared Redis 7.2+ instance — without it, each replica
269
175
  # stores RT families in its own ioredis connection against a local-only
270
- # Redis, defeating the cross-replica persistence purpose. Module-internal
271
- # config (keyPrefix / casRetryLimit) lives separately under
272
- # `redisRefreshTokenFamilyStore.*` (consumed by the store module's schema).
273
- redisRefreshTokenFamilyStore {
274
- keyPrefix = "rtfam:"
275
- keyPrefix = ${?REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX}
276
- casRetryLimit = 3
277
- casRetryLimit = ${?REFRESH_TOKEN_FAMILY_STORE_CAS_RETRY_LIMIT}
278
- }
279
-
280
- # OR-9 (Wave 5d): module-internal config for `redisCodeRepositoryModule`.
281
- # Consumed when `oauth.code.adapter = "redis"`. The shared ioredis socket
282
- # (`standaloneRedisClientsModule`) provides the `codeRepositoryClient`
283
- # slot; this section governs only key namespacing and the default
284
- # expiry-in-seconds the repository uses when /authorize doesn't supply one.
285
- #
286
- # Multi-tenant deployments override `keyPrefix` to isolate tenants in the
287
- # same Redis instance — e.g. `keyPrefix = "tenant-a:code:"` keeps tenant A's
288
- # codes from colliding with tenant B's under the default `oauth:code:`
289
- # prefix (MIN-3 advisory).
290
- redisCodeRepository {
291
- # No literal default for keyPrefix — the RedisCodeRepository constructor
292
- # falls back to `oauth:code:` when this field is absent. Operators that
293
- # need multi-tenant isolation set CLIENT_CODE_KEY_PREFIX (e.g.
294
- # `tenant-a:code:`); leaving the env var unset keeps the constructor
295
- # default intact via HOCON's `${?VAR}` "absent if unset" semantics.
296
- keyPrefix = ${?CLIENT_CODE_KEY_PREFIX}
297
- defaultExpiresIn = 600
298
- defaultExpiresIn = ${?CLIENT_CODE_DEFAULT_EXPIRES_IN}
299
- }
300
-
176
+ # Redis, defeating the cross-replica persistence purpose.
301
177
  refreshTokenFamilyStore {
302
178
  redis {
303
179
  url = "redis://localhost:6379"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/auth-provider-standalone",
3
- "version": "0.5.3",
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.1.0",
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.2"
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).withFallback(parseFile(applicationConfPath)),
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 for one release with a deprecation warn so existing
101
- // operators relying on `CLIENT_CODE_TYPE=redis` env-var overrides keep
102
- // working until they migrate. Removed in v0.6.
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 (will be removed in v0.6).',
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
@@ -1,10 +1,11 @@
1
1
  {
2
- "@o3co/auth-provider-core": "0.5.3",
3
- "@o3co/auth-provider-federation-google": "0.5.3",
4
- "@o3co/auth-provider-federation-github": "0.5.3",
5
- "@o3co/auth-provider-oauth": "0.5.3",
6
- "@o3co/auth-provider-oauth-token-exchange": "0.5.3",
7
- "@o3co/auth-provider-session": "0.5.3",
8
- "@o3co/auth-provider-foundation": "0.5.3",
9
- "@o3co/auth-provider-redis": "0.5.3"
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
  }