@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/create-auth-provider",
3
- "version": "0.6.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.1.0",
32
- "vitest": "^4.1.4"
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
- 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
+ }
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 { 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.
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 below).
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
- # OR-5: fail-mode for the OAuth-endpoint rate limiter when the limiter
145
- # backend errors. "open" = allow + `logger.error`. "closed" = HTTP 503
146
- # + `logger.error` (recommended for security-sensitive deployments).
147
- failMode = "open"
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. Module-internal
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.6.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.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.4"
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.6.0",
3
- "@o3co/auth-provider-federation-google": "0.6.0",
4
- "@o3co/auth-provider-federation-github": "0.6.0",
5
- "@o3co/auth-provider-oauth": "0.6.0",
6
- "@o3co/auth-provider-oauth-token-exchange": "0.6.0",
7
- "@o3co/auth-provider-session": "0.6.0",
8
- "@o3co/auth-provider-foundation": "0.6.0",
9
- "@o3co/auth-provider-redis": "0.6.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
  }