@o3co/create-auth-provider 0.5.2 → 0.6.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.2",
3
+ "version": "0.6.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",
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/node": "^25.1.0",
32
- "vitest": "^4.1.2"
32
+ "vitest": "^4.1.4"
33
33
  },
34
34
  "scripts": {
35
35
  "build": "tsc",
@@ -13,11 +13,17 @@ FROM node-base AS deps
13
13
 
14
14
  COPY package.json ./
15
15
 
16
+ USER node
17
+
16
18
  RUN pnpm install
17
19
 
18
20
  #############################################
19
21
  FROM deps AS builder
20
22
 
23
+ # USER node inherited from deps stage; repeated here to make the builder
24
+ # hardening invariant obvious in Dockerfile reviews.
25
+ USER node
26
+
21
27
  COPY tsconfig.json ./
22
28
  COPY config/ ./config/
23
29
  COPY src/ ./src/
@@ -31,13 +37,22 @@ ENV NODE_ENV=production
31
37
 
32
38
  COPY --from=deps /home/node/package.json /home/node/pnpm-lock.yaml ./
33
39
 
40
+ USER node
41
+
34
42
  RUN pnpm install --prod \
35
43
  && pnpm store prune
36
44
 
37
45
  COPY --from=builder /home/node/dist/ ./dist/
38
46
  COPY --from=builder /home/node/config/ ./config/
39
47
 
40
- USER node
48
+ ENV HTTP_PORT=3000
49
+ EXPOSE ${HTTP_PORT}
50
+
51
+ # Use the shell form so ${HTTP_PORT} expands at probe time. The HOCON
52
+ # config reads `${?HTTP_PORT}` for `http.port`, so app.listen() and the
53
+ # healthcheck stay in sync regardless of the operator's port choice.
54
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
55
+ CMD wget -q -O /dev/null "http://localhost:${HTTP_PORT}/_healthcheck" || exit 1
41
56
 
42
57
  ENTRYPOINT ["tini", "--"]
43
58
  CMD ["node", "dist/app.mjs"]
@@ -53,6 +68,4 @@ CMD ["pnpm", "run", "test", "--", "--passWithNoTests"]
53
68
  #############################################
54
69
  FROM builder AS develop
55
70
 
56
- USER node
57
-
58
71
  CMD ["pnpm", "run", "debug"]
@@ -94,6 +94,7 @@ fail fast rather than silently falling back to defaults.
94
94
  | Variable | Default | Description |
95
95
  |---|---|---|
96
96
  | `SESSION_SECRET` | — | **Required.** Session signing secret |
97
+ | `SESSION_NAME` | `__Host-auth.session` | Session cookie name. The default uses the `__Host-` prefix and therefore requires `SESSION_SECURE=true` and no `SESSION_DOMAIN`. |
97
98
  | `SESSION_MAX_AGE` | `3600000` | Session cookie max age in milliseconds |
98
99
  | `SESSION_SECURE` | `true` | Set `Secure` flag on session cookie |
99
100
  | `SESSION_SAME_SITE` | `lax` | `SameSite` attribute (`lax`, `strict`, `none`) |
@@ -102,6 +103,11 @@ fail fast rather than silently falling back to defaults.
102
103
  | `SESSION_STORAGE_REDIS_URL` | `redis://localhost:6379` | Redis connection URL for session storage |
103
104
  | `SESSION_STORAGE_REDIS_PASSWORD` | — | Redis password for session storage |
104
105
 
106
+ If you set `SESSION_SECURE=false` for local HTTP development or set `SESSION_DOMAIN`
107
+ for shared-domain cookies, also set `SESSION_NAME` to a non-`__Host-` value such as
108
+ `auth.sid`. The server fails fast when a `__Host-` cookie name is combined with
109
+ attributes that browsers reject for that prefix.
110
+
105
111
  ### Google Federation
106
112
 
107
113
  | Variable | Default | Description |
@@ -136,6 +142,20 @@ fail fast rather than silently falling back to defaults.
136
142
  | `CLIENT_CODE_PASSWORD` | — | Redis password for code storage |
137
143
  | `CLIENT_CODE_DEFAULT_EXPIRES_IN` | `600` | Default authorization code lifetime in seconds |
138
144
 
145
+ ### Redis Namespacing
146
+
147
+ For multi-tenant Redis clusters, set deployment-specific key prefixes so two
148
+ auth.provider instances cannot collide in the same database:
149
+
150
+ | Variable | Default | Description |
151
+ |---|---|---|
152
+ | `REDIS_SESSION_STORES_KEY_PREFIX` | `ss:` | Outer prefix for user sessions, RP registry, session-family index, and session-federation index. |
153
+ | `REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX` | `rtfam:` | Prefix for refresh-token family records. |
154
+ | `CLIENT_CODE_KEY_PREFIX` | `oauth:code:` | Prefix for OAuth authorization codes. |
155
+
156
+ Use values that include the deployment name, for example `tenant-a:ss:`,
157
+ `tenant-a:rtfam:`, and `tenant-a:code:`.
158
+
139
159
  ### Endpoints
140
160
 
141
161
  | Variable | Default | Description |
@@ -185,6 +205,18 @@ make test
185
205
 
186
206
  The `docker-compose.yml` starts the auth server together with a Redis container. Configure environment variables in `.env`.
187
207
 
208
+ The production image sets `ENV HTTP_PORT=3000` and uses it for both `EXPOSE`
209
+ and the Docker-native healthcheck against `/_healthcheck`. The HOCON config
210
+ also reads `${?HTTP_PORT}` for `http.port`, so `app.listen()` and the
211
+ healthcheck cannot drift apart. To run on a different port:
212
+
213
+ ```bash
214
+ docker run -e HTTP_PORT=8080 -p 8080:8080 my-auth-provider
215
+ ```
216
+
217
+ `EXPOSE` is image metadata and does not publish ports by itself, so the
218
+ explicit `-p` mapping is still required.
219
+
188
220
  ## Adding Custom Modules
189
221
 
190
222
  To add a custom module, import it in `src/buildModules.mts` and include it in the `modules` array passed to `createApp`:
@@ -8,15 +8,17 @@ http {
8
8
  oauth {
9
9
  jwt {
10
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
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
20
22
  legacyTypAccept = ${?OAUTH_JWT_LEGACY_TYP_ACCEPT}
21
23
  signingKey {
22
24
  provider = "local"
@@ -60,24 +62,11 @@ oauth {
60
62
  # store to Redis with old tokens still in circulation. Operators set
61
63
  # OAUTH_REFRESH_TOKEN_UNKNOWN_FAMILY_POLICY for env-var override.
62
64
  # 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.
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.
70
69
  # 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
70
  }
82
71
  grants {
83
72
  session { enabled = true, enabled = ${?OAUTH_GRANTS_SESSION_ENABLED} }
@@ -91,6 +80,11 @@ oauth {
91
80
  }
92
81
  refresh_token { enabled = true, enabled = ${?OAUTH_GRANTS_REFRESH_TOKEN_ENABLED} }
93
82
  }
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}
94
88
  # OR-9: adapter switch for the OAuth authorization-code repository. When
95
89
  # set to "redis", the standalone composition root wires
96
90
  # `redisCodeRepositoryModule` against the shared ioredis socket
@@ -102,10 +96,22 @@ oauth {
102
96
  adapter = "redis"
103
97
  adapter = ${?OAUTH_CODE_ADAPTER}
104
98
  }
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
+ }
105
106
  }
106
107
 
107
108
  session {
108
109
  secret = ${?SESSION_SECRET}
110
+ # MIN-2: __Host- prefix requires Secure, Path=/, and no Domain attribute.
111
+ # If SESSION_SECURE=false or SESSION_DOMAIN is set, also override
112
+ # SESSION_NAME to a non-__Host- value.
113
+ name = "__Host-auth.session"
114
+ name = ${?SESSION_NAME}
109
115
  maxAge = 3600000
110
116
  maxAge = ${?SESSION_MAX_AGE}
111
117
  secure = true
@@ -222,11 +228,28 @@ rateLimiter {
222
228
  adapter = ${?RATE_LIMITER_ADAPTER}
223
229
  }
224
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
+
225
239
  userSessionStores {
226
240
  adapter = "memory"
227
241
  adapter = ${?USER_SESSION_STORES_ADAPTER}
228
242
  }
229
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
+
230
253
  # D-2 v2 + Wave 5d: ioredis connection-config consumed by the shared
231
254
  # `standaloneRedisClientsModule` in `templates/standalone/src/modules.mts`.
232
255
  # One ioredis socket per replica backs ALL Redis-typed clients (RT family
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/auth-provider-standalone",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -45,7 +45,7 @@
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.4"
49
49
  },
50
50
  "pnpm": {
51
51
  "onlyBuiltDependencies": [
@@ -14,6 +14,7 @@
14
14
  * limitations under the License.
15
15
  */
16
16
 
17
+ import { readFileSync } from "node:fs";
17
18
  import {
18
19
  type AppConfig,
19
20
  createApp,
@@ -30,6 +31,8 @@ import request from "supertest";
30
31
  import { afterEach, describe, expect, it, vi } from "vitest";
31
32
  import { buildModules } from "../buildModules.mjs";
32
33
 
34
+ const dockerfile = readFileSync(new URL("../../Dockerfile", import.meta.url), "utf8");
35
+
33
36
  const config: AppConfig = {
34
37
  http: { port: 0, trustProxy: false },
35
38
  oauth: {
@@ -51,6 +54,7 @@ const config: AppConfig = {
51
54
  legacyRtPolicy: "reject" as const,
52
55
  },
53
56
  grants: {},
57
+ oidcMode: "oidc-required",
54
58
  // OR-9: explicit memory adapter for the smoke test. The legacy
55
59
  // `repositories.code.type` fallback would also pick memory here,
56
60
  // but the explicit setting documents the intent and avoids
@@ -59,6 +63,7 @@ const config: AppConfig = {
59
63
  },
60
64
  session: {
61
65
  secret: "test-session-secret",
66
+ name: "auth.sid",
62
67
  maxAge: 3600000,
63
68
  secure: false,
64
69
  sameSite: "lax",
@@ -180,6 +185,36 @@ describe("standalone smoke test", () => {
180
185
  handleRef = undefined;
181
186
  });
182
187
 
188
+ it("Dockerfile declares runtime metadata and runs install/build steps as node", () => {
189
+ // Default port is 3000 but the HEALTHCHECK and EXPOSE must read it
190
+ // from ${HTTP_PORT} so an operator overriding the env var keeps the
191
+ // app, EXPOSE metadata, and healthcheck in sync. Assert the ENV /
192
+ // EXPOSE / HEALTHCHECK structure rather than the literal port so the
193
+ // test is not brittle when operators or downstream forks change the
194
+ // default.
195
+ // The literal `${HTTP_PORT}` token from the Dockerfile is split across
196
+ // concatenations to sidestep biome's `noTemplateCurlyInString` rule
197
+ // (it cannot statically tell that this string is intentionally NOT
198
+ // a JS template placeholder — it is a Dockerfile env-substitution).
199
+ const HTTP_PORT_VAR = `$${"{"}HTTP_PORT}`;
200
+ expect(dockerfile).toContain("ENV HTTP_PORT=3000");
201
+ expect(dockerfile).toContain(`EXPOSE ${HTTP_PORT_VAR}`);
202
+ expect(dockerfile).toMatch(
203
+ /HEALTHCHECK\s+--interval=30s\s+--timeout=3s\s+--start-period=10s\s+--retries=3/,
204
+ );
205
+ // Match the literal `${HTTP_PORT}` token inside the HEALTHCHECK CMD.
206
+ // `[$]` (character class) sidesteps biome's `noTemplateCurlyInString`
207
+ // false-positive without changing what we actually accept.
208
+ expect(dockerfile).toMatch(
209
+ /CMD\s+wget\s+-q\s+-O\s+\/dev\/null\s+"http:\/\/localhost:[$]\{HTTP_PORT\}\/_healthcheck"\s+\|\|\s+exit\s+1/,
210
+ );
211
+ expect(dockerfile).toMatch(/FROM node-base AS deps[\s\S]*USER node[\s\S]*RUN pnpm install/);
212
+ expect(dockerfile).toMatch(/FROM deps AS builder[\s\S]*USER node[\s\S]*RUN pnpm run build/);
213
+ expect(dockerfile).toMatch(
214
+ /FROM node-base AS runtime[\s\S]*USER node[\s\S]*RUN pnpm install --prod/,
215
+ );
216
+ });
217
+
183
218
  it("GET /_healthcheck returns 200", async () => {
184
219
  const { app, handle } = await buildApp();
185
220
  handleRef = handle;
@@ -1,10 +1,10 @@
1
1
  {
2
- "@o3co/auth-provider-core": "0.5.2",
3
- "@o3co/auth-provider-federation-google": "0.5.2",
4
- "@o3co/auth-provider-federation-github": "0.5.2",
5
- "@o3co/auth-provider-oauth": "0.5.2",
6
- "@o3co/auth-provider-oauth-token-exchange": "0.5.2",
7
- "@o3co/auth-provider-session": "0.5.2",
8
- "@o3co/auth-provider-foundation": "0.5.2",
9
- "@o3co/auth-provider-redis": "0.5.2"
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"
10
10
  }