@o3co/create-auth-provider 0.5.1 → 0.5.3

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.1",
3
+ "version": "0.5.3",
4
4
  "description": "Scaffold a new o3co/auth.provider project from the standalone template",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -24,6 +24,9 @@
24
24
  "dist",
25
25
  "templates"
26
26
  ],
27
+ "engines": {
28
+ "node": ">=18.19.0"
29
+ },
27
30
  "devDependencies": {
28
31
  "@types/node": "^25.1.0",
29
32
  "vitest": "^4.1.2"
@@ -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"]
@@ -2,6 +2,23 @@
2
2
 
3
3
  Deployable server template for auth.provider. This is the composition root — it reads config, loads modules, and starts the Express server. Generated by `@o3co/create-auth-provider`.
4
4
 
5
+ ## Requirements
6
+
7
+ - **Node.js** `>=18.19.0`
8
+ - **`bcrypt` native binary**: this template depends transitively on `bcrypt@6.x`, which ships **prebuilt N-API binaries** for `darwin-arm64`, `darwin-x64`, `linux-x64` (glibc and musl), `linux-arm64` (glibc and musl), `linux-arm`, `win32-x64`, and `win32-arm64`. On these platforms `pnpm install` succeeds without compiling from source, and no extra toolchain is required (the `darwin-*`, `node:*-alpine`, and `node:*-bookworm` images all match a shipped prebuild). On platforms or libc/arch combinations that have no matching prebuild, `pnpm install` falls back to compiling, which then requires a C++ compiler and Python (the standard Node.js native-addon toolchain): Debian/Ubuntu `apt-get install build-essential python3`, Alpine `apk add make g++ python3`, macOS `xcode-select --install`. The pnpm 10 `onlyBuiltDependencies` allowlist for `bcrypt` is set in this template's `package.json` so a fresh `pnpm install` does not silently skip the install hook on those platforms.
9
+ - **Redis 7.2 LTS or later** for any Redis-backed adapter (refresh token family store, code repository, federation token store, session store). The `pExpireGT` flag pair on which several adapters depend was introduced in Redis 7.0+; 7.2 LTS is the tested floor. Tested against AWS ElastiCache for Redis 7.2, Upstash Redis, Redis Cloud 7.2, and self-managed `redis:7.2-alpine`.
10
+ - **ioredis** `^5.4.1` (direct runtime dependency, used by the refresh-token-family client). Other Redis-backed adapters in this template still use the `redis` npm package; ioredis is added for the RT family store specifically.
11
+
12
+ ## Multi-replica deployments
13
+
14
+ When running multiple instances of the standalone server behind a load balancer, point `REFRESH_TOKEN_FAMILY_STORE_REDIS_URL` at a **shared** Redis 7.2+ instance. Without a shared Redis URL, every replica holds refresh-token families in its own connection (and any local-only Redis), so a refresh request that lands on a replica that did not issue the token returns `invalid_grant` on every other request in a round-robin LB.
15
+
16
+ Other multi-replica considerations covered by the default modules:
17
+
18
+ - The session store is wired via `redisSessionStoresModule`; set the corresponding session-store Redis URL (per `session.storage.redis.url`).
19
+ - The code repository defaults to Redis when `repositories.code.type = "redis"`; set `CLIENT_CODE_ENDPOINT_URI` to a shared Redis instance.
20
+ - The federation token store can be backed by Redis via `federationTokenStore.type = "redis"` (deferred to operator config; default is in-memory).
21
+
5
22
  ## Usage
6
23
 
7
24
  ```bash
@@ -77,6 +94,7 @@ fail fast rather than silently falling back to defaults.
77
94
  | Variable | Default | Description |
78
95
  |---|---|---|
79
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`. |
80
98
  | `SESSION_MAX_AGE` | `3600000` | Session cookie max age in milliseconds |
81
99
  | `SESSION_SECURE` | `true` | Set `Secure` flag on session cookie |
82
100
  | `SESSION_SAME_SITE` | `lax` | `SameSite` attribute (`lax`, `strict`, `none`) |
@@ -85,6 +103,11 @@ fail fast rather than silently falling back to defaults.
85
103
  | `SESSION_STORAGE_REDIS_URL` | `redis://localhost:6379` | Redis connection URL for session storage |
86
104
  | `SESSION_STORAGE_REDIS_PASSWORD` | — | Redis password for session storage |
87
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
+
88
111
  ### Google Federation
89
112
 
90
113
  | Variable | Default | Description |
@@ -119,6 +142,20 @@ fail fast rather than silently falling back to defaults.
119
142
  | `CLIENT_CODE_PASSWORD` | — | Redis password for code storage |
120
143
  | `CLIENT_CODE_DEFAULT_EXPIRES_IN` | `600` | Default authorization code lifetime in seconds |
121
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
+
122
159
  ### Endpoints
123
160
 
124
161
  | Variable | Default | Description |
@@ -168,6 +205,18 @@ make test
168
205
 
169
206
  The `docker-compose.yml` starts the auth server together with a Redis container. Configure environment variables in `.env`.
170
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
+
171
220
  ## Adding Custom Modules
172
221
 
173
222
  To add a custom module, import it in `src/buildModules.mts` and include it in the `modules` array passed to `createApp`:
@@ -8,6 +8,16 @@ 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
20
+ legacyTypAccept = ${?OAUTH_JWT_LEGACY_TYP_ACCEPT}
11
21
  signingKey {
12
22
  provider = "local"
13
23
  provider = ${?OAUTH_JWT_SIGNING_KEY_PROVIDER}
@@ -21,7 +31,18 @@ oauth {
21
31
  privateKeyPath = ${?OAUTH_JWT_PRIVATE_KEY_PATH}
22
32
  publicKey = ${?OAUTH_JWT_PUBLIC_KEY}
23
33
  publicKeyPath = ${?OAUTH_JWT_PUBLIC_KEY_PATH}
24
- previousKeys = []
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 = []
25
46
  }
26
47
  }
27
48
  }
@@ -32,6 +53,31 @@ oauth {
32
53
  refreshToken {
33
54
  expiresIn = 86400
34
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
35
81
  }
36
82
  grants {
37
83
  session { enabled = true, enabled = ${?OAUTH_GRANTS_SESSION_ENABLED} }
@@ -45,10 +91,38 @@ oauth {
45
91
  }
46
92
  refresh_token { enabled = true, enabled = ${?OAUTH_GRANTS_REFRESH_TOKEN_ENABLED} }
47
93
  }
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
+ # OR-9: adapter switch for the OAuth authorization-code repository. When
100
+ # set to "redis", the standalone composition root wires
101
+ # `redisCodeRepositoryModule` against the shared ioredis socket
102
+ # (`standaloneRedisClientsModule`). Memory-only deployments lose codes on
103
+ # restart and across replicas — multi-replica production MUST use "redis".
104
+ # Supersedes the legacy `repositories.code.type` switch (kept below for
105
+ # one release cycle for backward compat).
106
+ code {
107
+ adapter = "redis"
108
+ adapter = ${?OAUTH_CODE_ADAPTER}
109
+ }
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
+ }
48
117
  }
49
118
 
50
119
  session {
51
120
  secret = ${?SESSION_SECRET}
121
+ # MIN-2: __Host- prefix requires Secure, Path=/, and no Domain attribute.
122
+ # If SESSION_SECURE=false or SESSION_DOMAIN is set, also override
123
+ # SESSION_NAME to a non-__Host- value.
124
+ name = "__Host-auth.session"
125
+ name = ${?SESSION_NAME}
52
126
  maxAge = 3600000
53
127
  maxAge = ${?SESSION_MAX_AGE}
54
128
  secure = true
@@ -68,8 +142,21 @@ session {
68
142
  }
69
143
  }
70
144
 
145
+ # Rate limiting for SESSION routes (e.g. /session/login bruteforce protection).
146
+ # Uses windowMs (milliseconds) — consumed by express-rate-limit in Session.mts.
147
+ # NOTE (IH-18): this section does NOT govern OAuth endpoint rate limiting.
148
+ # For OAuth rate limiting (/token, /authorize), use the `rateLimiter`
149
+ # component (memoryRateLimiterModule or redisRateLimiterModule) with
150
+ # its own `memoryRateLimiter.*` / `redisRateLimiter.*` config section
151
+ # (uses `windowSeconds` instead of `windowMs`). Switch via
152
+ # `rateLimiter.adapter = "memory" | "redis"` (see below).
71
153
  rateLimit {
72
154
  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}
73
160
  }
74
161
 
75
162
  federations {
@@ -126,10 +213,95 @@ endpoints {
126
213
  url = "/login"
127
214
  url = ${?ENDPOINTS_LOGIN_URL}
128
215
  }
129
- client { url = ${?ENDPOINTS_CLIENT_URL} }
130
- authCallback { url = ${?ENDPOINTS_AUTH_CALLBACK_URL} }
216
+ # IH-10: `client { url }` and `authCallback { url }` removed — no
217
+ # production consumer reads them.
131
218
  }
132
219
 
133
220
  cors {
134
221
  allowedOrigins = []
135
222
  }
223
+
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
+ # D-2 v2 + Wave 5d: ioredis connection-config consumed by the shared
265
+ # `standaloneRedisClientsModule` in `templates/standalone/src/modules.mts`.
266
+ # One ioredis socket per replica backs ALL Redis-typed clients (RT family
267
+ # + 4 user-session stores + rate limiter). Multi-replica deployments MUST
268
+ # point this at a shared Redis 7.2+ instance — without it, each replica
269
+ # 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
+
301
+ refreshTokenFamilyStore {
302
+ redis {
303
+ url = "redis://localhost:6379"
304
+ url = ${?REFRESH_TOKEN_FAMILY_STORE_REDIS_URL}
305
+ password = ${?REFRESH_TOKEN_FAMILY_STORE_REDIS_PASSWORD}
306
+ }
307
+ }
@@ -1,14 +1,30 @@
1
1
  # Example client definitions loaded via loadYamlMap into InMemoryClientRepository.
2
2
  # Copy this file to clients.yaml and customize for your deployment.
3
3
  #
4
- # Secret format:
4
+ # `tokenEndpointAuthMethod` is REQUIRED (RFC 6749 §2.3 / RFC 7591 §2):
5
+ # - "client_secret_basic": HTTP Basic Authorization header — recommended for confidential clients
6
+ # - "client_secret_post": form-encoded body parameters
7
+ # - "none": public client — no clientSecret; PKCE/S256 mandatory at /authorize
8
+ #
9
+ # Secret format (only for confidential clients — basic / post):
5
10
  # - Plain text: "my-secret"
6
11
  # - Bcrypt hash: "$2b$10$..." (generate with: node -e "require('bcrypt').hash('secret',10).then(console.log)")
7
12
 
8
13
  my-app:
14
+ tokenEndpointAuthMethod: "client_secret_basic"
9
15
  clientSecret: "$2b$10$REPLACE_WITH_BCRYPT_HASH"
10
16
  allowedRedirectUris:
11
17
  - "http://localhost:3000/callback"
12
18
  allowedScopes:
13
19
  - "read"
14
20
  - "write"
21
+
22
+ # Public client example (e.g., a single-page app or mobile app):
23
+ # my-spa:
24
+ # tokenEndpointAuthMethod: "none"
25
+ # # clientSecret MUST be absent for "none" clients.
26
+ # allowedRedirectUris:
27
+ # - "https://app.example/callback"
28
+ # allowedScopes:
29
+ # - "openid"
30
+ # - "profile"
@@ -8,7 +8,7 @@ services:
8
8
  condition: service_healthy
9
9
 
10
10
  redis:
11
- image: redis:7-alpine
11
+ image: redis:7.2-alpine
12
12
  healthcheck:
13
13
  test: ["CMD", "redis-cli", "ping"]
14
14
  interval: 5s
@@ -16,7 +16,7 @@ services:
16
16
  required: false
17
17
 
18
18
  redis:
19
- image: redis:7-alpine
19
+ image: redis:7.2-alpine
20
20
  ports:
21
21
  - "127.0.0.1:6379:6379"
22
22
  healthcheck:
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@o3co/auth-provider-standalone",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
+ "engines": {
7
+ "node": ">=18.19.0"
8
+ },
6
9
  "packageManager": "pnpm@10.29.3",
7
10
  "type": "module",
8
11
  "scripts": {
@@ -24,14 +27,15 @@
24
27
  "@o3co/auth-provider-session": "workspace:*",
25
28
  "@o3co/auth-provider-foundation": "workspace:*",
26
29
  "@o3co/auth-provider-redis": "workspace:*",
27
- "@o3co/auth.utils": "^0.0.3",
30
+ "@o3co/auth.utils": "^0.0.4",
28
31
  "@o3co/ts.hocon": "^1.1.0",
29
32
  "connect-redis": "^9.0.0",
30
33
  "express": "^5.2.1",
31
34
  "express-session": "^1.19.0",
32
35
  "helmet": "^8.1.0",
36
+ "ioredis": "^5.4.1",
33
37
  "pino": "^10.3.1",
34
- "redis": "^5.10.0"
38
+ "redis": "^5.12.1"
35
39
  },
36
40
  "devDependencies": {
37
41
  "@types/express": "^5.0.6",
@@ -42,5 +46,10 @@
42
46
  "tsx": "^4.21.0",
43
47
  "typescript": "^5.9.3",
44
48
  "vitest": "^4.1.2"
49
+ },
50
+ "pnpm": {
51
+ "onlyBuiltDependencies": [
52
+ "bcrypt"
53
+ ]
45
54
  }
46
55
  }
@@ -0,0 +1,180 @@
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
+ import type { LifecycleRegistrar } from "@o3co/auth-provider-core";
17
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
18
+
19
+ // D-2 v2: ioredis Redis constructor capture. The module under test imports
20
+ // `Redis` from "ioredis"; vi.mock replaces that import so the test never
21
+ // opens a real socket. Captured arguments verify that the validated config
22
+ // reaches the constructor (BLOCKER 1: schema strip closure).
23
+ const redisCtorCalls: Array<{ url: string; options: Record<string, unknown> | undefined }> = [];
24
+ const quitSpies: Array<ReturnType<typeof vi.fn>> = [];
25
+ const onSpies: Array<ReturnType<typeof vi.fn>> = [];
26
+
27
+ vi.mock("ioredis", () => {
28
+ class MockRedis {
29
+ duplicate(): MockRedis {
30
+ return new MockRedis("redis://duplicate.local");
31
+ }
32
+ on = vi.fn();
33
+ quit = vi.fn(async () => "OK" as const);
34
+ // Minimal command surface — `makeIoredisClients` only invokes these
35
+ // during construction in some paths; tests never exercise the wrapped
36
+ // client itself, so most stubs are unused.
37
+ set = vi.fn();
38
+ get = vi.fn();
39
+ pttl = vi.fn();
40
+ watch = vi.fn();
41
+ unwatch = vi.fn();
42
+ multi = vi.fn(() => ({
43
+ set: vi.fn().mockReturnThis(),
44
+ exec: vi.fn(async () => []),
45
+ }));
46
+
47
+ constructor(url: string, options?: Record<string, unknown>) {
48
+ redisCtorCalls.push({ url, options });
49
+ onSpies.push(this.on);
50
+ quitSpies.push(this.quit);
51
+ }
52
+ }
53
+ return { Redis: MockRedis, default: MockRedis };
54
+ });
55
+
56
+ // Imported AFTER vi.mock so the mocked ioredis is in scope.
57
+ const importModule = async () =>
58
+ (await import("../modules.mjs")) as typeof import("../modules.mjs") & {
59
+ standaloneRedisClientsModule: import("@o3co/auth-provider-core").Module;
60
+ };
61
+
62
+ // D-1 / D-5 are already merged on develop, so importing `standaloneRedisClientsModule`
63
+ // from `../modules.mjs` is the natural integration point. Pre-fix the export does not
64
+ // exist — TS error → RED.
65
+
66
+ const baseConfig = {
67
+ refreshTokenFamilyStore: {
68
+ redis: { url: "redis://example.com:6379", password: "test-pw" },
69
+ },
70
+ };
71
+
72
+ describe("D-2 / standaloneRedisClientsModule", () => {
73
+ beforeEach(() => {
74
+ redisCtorCalls.length = 0;
75
+ quitSpies.length = 0;
76
+ onSpies.length = 0;
77
+ });
78
+
79
+ afterEach(() => {
80
+ vi.restoreAllMocks();
81
+ });
82
+
83
+ it("passes operator-supplied Redis URL + password from config to the ioredis constructor (BLOCKER 1 closure)", async () => {
84
+ const { standaloneRedisClientsModule } = await importModule();
85
+ const provides = (
86
+ standaloneRedisClientsModule as unknown as {
87
+ provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
88
+ }
89
+ ).provides;
90
+ await provides.refreshTokenFamilyClient({ config: { ...baseConfig } });
91
+
92
+ expect(redisCtorCalls).toHaveLength(1);
93
+ expect(redisCtorCalls[0]?.url).toBe("redis://example.com:6379");
94
+ expect(redisCtorCalls[0]?.options?.password).toBe("test-pw");
95
+ });
96
+
97
+ it("registers io.quit() with lifecycleRegistrar when provided; drain calls quit exactly once", async () => {
98
+ const registered: Array<() => Promise<void>> = [];
99
+ const lifecycleRegistrar: LifecycleRegistrar = {
100
+ register: (cleanup) => {
101
+ registered.push(cleanup);
102
+ },
103
+ };
104
+
105
+ const { standaloneRedisClientsModule } = await importModule();
106
+ const provides = (
107
+ standaloneRedisClientsModule as unknown as {
108
+ provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
109
+ }
110
+ ).provides;
111
+ await provides.refreshTokenFamilyClient({ config: { ...baseConfig }, lifecycleRegistrar });
112
+
113
+ expect(registered).toHaveLength(1);
114
+ // Pre-drain: quit not yet called.
115
+ expect(quitSpies[0]).not.toHaveBeenCalled();
116
+
117
+ // Drain (simulate handle.dispose()).
118
+ await registered[0]?.();
119
+ expect(quitSpies[0]).toHaveBeenCalledTimes(1);
120
+ });
121
+
122
+ it("does not crash when lifecycleRegistrar is absent (graceful no-op, no quit registered)", async () => {
123
+ const { standaloneRedisClientsModule } = await importModule();
124
+ const provides = (
125
+ standaloneRedisClientsModule as unknown as {
126
+ provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
127
+ }
128
+ ).provides;
129
+
130
+ // No lifecycleRegistrar key in deps — must not throw and must NOT
131
+ // register a cleanup (verified by the absence of any quit invocation
132
+ // after the factory resolves; the registrar branch is the only place
133
+ // `quit()` would be wired up in the no-real-shutdown unit-test path).
134
+ await expect(
135
+ provides.refreshTokenFamilyClient({ config: { ...baseConfig } }),
136
+ ).resolves.toBeDefined();
137
+ expect(quitSpies[0]).not.toHaveBeenCalled();
138
+ });
139
+
140
+ it("fails fast when refreshTokenFamilyStore.redis.url is missing (no silent localhost fallback)", async () => {
141
+ const { standaloneRedisClientsModule } = await importModule();
142
+ const provides = (
143
+ standaloneRedisClientsModule as unknown as {
144
+ provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
145
+ }
146
+ ).provides;
147
+
148
+ // Operator deliberately removed the section — must throw instead of
149
+ // silently falling back to redis://localhost:6379 (which would re-
150
+ // introduce the OR-1 multi-replica failure mode in production).
151
+ await expect(provides.refreshTokenFamilyClient({ config: {} })).rejects.toThrow(
152
+ /refreshTokenFamilyStore\.redis\.url/,
153
+ );
154
+ // And no ioredis instance was constructed because we threw before
155
+ // `new Redis(...)`.
156
+ expect(redisCtorCalls).toHaveLength(0);
157
+ });
158
+
159
+ it("attaches an error event handler to the ioredis client (prevents unhandled-error crash)", async () => {
160
+ const { standaloneRedisClientsModule } = await importModule();
161
+ const provides = (
162
+ standaloneRedisClientsModule as unknown as {
163
+ provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
164
+ }
165
+ ).provides;
166
+ await provides.refreshTokenFamilyClient({ config: { ...baseConfig } });
167
+
168
+ expect(onSpies[0]).toHaveBeenCalledWith("error", expect.any(Function));
169
+ });
170
+
171
+ it("declares 'config' as required and 'lifecycleRegistrar' as optional", async () => {
172
+ const { standaloneRedisClientsModule } = await importModule();
173
+ const m = standaloneRedisClientsModule as {
174
+ requires?: readonly string[];
175
+ optional?: readonly string[];
176
+ };
177
+ expect(m.requires).toContain("config");
178
+ expect(m.optional).toContain("lifecycleRegistrar");
179
+ });
180
+ });