@o3co/create-auth-provider 0.5.1 → 0.5.2
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 +4 -1
- package/templates/standalone/README.md +17 -0
- package/templates/standalone/config/application.conf +141 -3
- package/templates/standalone/config/clients.yaml.example +17 -1
- package/templates/standalone/docker-compose.test.yml +1 -1
- package/templates/standalone/docker-compose.yml +1 -1
- package/templates/standalone/package.json +12 -3
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +180 -0
- package/templates/standalone/src/__tests__/smoke.test.mts +217 -8
- package/templates/standalone/src/app.mts +13 -40
- package/templates/standalone/src/buildModules.mts +120 -5
- package/templates/standalone/src/modules.mts +239 -24
- package/templates/versions.json +8 -8
- package/dist/__tests__/index.test.d.mts +0 -2
- package/dist/__tests__/index.test.d.mts.map +0 -1
- package/dist/__tests__/index.test.mjs +0 -281
- package/dist/__tests__/published-package.test.d.mts +0 -2
- package/dist/__tests__/published-package.test.d.mts.map +0 -1
- package/dist/__tests__/published-package.test.mjs +0 -73
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@o3co/create-auth-provider",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
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"
|
|
@@ -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
|
|
@@ -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
|
-
|
|
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,6 +91,17 @@ oauth {
|
|
|
45
91
|
}
|
|
46
92
|
refresh_token { enabled = true, enabled = ${?OAUTH_GRANTS_REFRESH_TOKEN_ENABLED} }
|
|
47
93
|
}
|
|
94
|
+
# OR-9: adapter switch for the OAuth authorization-code repository. When
|
|
95
|
+
# set to "redis", the standalone composition root wires
|
|
96
|
+
# `redisCodeRepositoryModule` against the shared ioredis socket
|
|
97
|
+
# (`standaloneRedisClientsModule`). Memory-only deployments lose codes on
|
|
98
|
+
# restart and across replicas — multi-replica production MUST use "redis".
|
|
99
|
+
# Supersedes the legacy `repositories.code.type` switch (kept below for
|
|
100
|
+
# one release cycle for backward compat).
|
|
101
|
+
code {
|
|
102
|
+
adapter = "redis"
|
|
103
|
+
adapter = ${?OAUTH_CODE_ADAPTER}
|
|
104
|
+
}
|
|
48
105
|
}
|
|
49
106
|
|
|
50
107
|
session {
|
|
@@ -68,8 +125,21 @@ session {
|
|
|
68
125
|
}
|
|
69
126
|
}
|
|
70
127
|
|
|
128
|
+
# Rate limiting for SESSION routes (e.g. /session/login bruteforce protection).
|
|
129
|
+
# Uses windowMs (milliseconds) — consumed by express-rate-limit in Session.mts.
|
|
130
|
+
# NOTE (IH-18): this section does NOT govern OAuth endpoint rate limiting.
|
|
131
|
+
# For OAuth rate limiting (/token, /authorize), use the `rateLimiter`
|
|
132
|
+
# component (memoryRateLimiterModule or redisRateLimiterModule) with
|
|
133
|
+
# its own `memoryRateLimiter.*` / `redisRateLimiter.*` config section
|
|
134
|
+
# (uses `windowSeconds` instead of `windowMs`). Switch via
|
|
135
|
+
# `rateLimiter.adapter = "memory" | "redis"` (see below).
|
|
71
136
|
rateLimit {
|
|
72
137
|
login { windowMs = 900000, limit = 20 }
|
|
138
|
+
# OR-5: fail-mode for the OAuth-endpoint rate limiter when the limiter
|
|
139
|
+
# backend errors. "open" = allow + `logger.error`. "closed" = HTTP 503
|
|
140
|
+
# + `logger.error` (recommended for security-sensitive deployments).
|
|
141
|
+
failMode = "open"
|
|
142
|
+
failMode = ${?RATE_LIMIT_FAIL_MODE}
|
|
73
143
|
}
|
|
74
144
|
|
|
75
145
|
federations {
|
|
@@ -126,10 +196,78 @@ endpoints {
|
|
|
126
196
|
url = "/login"
|
|
127
197
|
url = ${?ENDPOINTS_LOGIN_URL}
|
|
128
198
|
}
|
|
129
|
-
client { url
|
|
130
|
-
|
|
199
|
+
# IH-10: `client { url }` and `authCallback { url }` removed — no
|
|
200
|
+
# production consumer reads them.
|
|
131
201
|
}
|
|
132
202
|
|
|
133
203
|
cors {
|
|
134
204
|
allowedOrigins = []
|
|
135
205
|
}
|
|
206
|
+
|
|
207
|
+
# Wave 5d (IH-14 + OR-M1 + OR-4): adapter switches for the OAuth-endpoint
|
|
208
|
+
# rate limiter and the user-session stores (4-store split: userSessionStore
|
|
209
|
+
# / sessionRPRegistry / sessionFamilyIndex / sessionFederationIndex).
|
|
210
|
+
#
|
|
211
|
+
# Multi-replica production MUST set BOTH to `"redis"`. The defaults are
|
|
212
|
+
# `"memory"` for backward-compat with single-instance deployments and CI;
|
|
213
|
+
# in-memory rate-limit counters / session state are isolated per replica
|
|
214
|
+
# and inconsistent under load-balanced traffic.
|
|
215
|
+
#
|
|
216
|
+
# When `userSessionStores.adapter = "redis"` or `rateLimiter.adapter = "redis"`,
|
|
217
|
+
# the standalone composition root opens a single shared ioredis connection
|
|
218
|
+
# (via `standaloneRedisClientsModule`) consuming `refreshTokenFamilyStore.redis.url`
|
|
219
|
+
# (D-2 v2). One ioredis socket per replica backs ALL Redis-typed clients.
|
|
220
|
+
rateLimiter {
|
|
221
|
+
adapter = "memory"
|
|
222
|
+
adapter = ${?RATE_LIMITER_ADAPTER}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
userSessionStores {
|
|
226
|
+
adapter = "memory"
|
|
227
|
+
adapter = ${?USER_SESSION_STORES_ADAPTER}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
# D-2 v2 + Wave 5d: ioredis connection-config consumed by the shared
|
|
231
|
+
# `standaloneRedisClientsModule` in `templates/standalone/src/modules.mts`.
|
|
232
|
+
# One ioredis socket per replica backs ALL Redis-typed clients (RT family
|
|
233
|
+
# + 4 user-session stores + rate limiter). Multi-replica deployments MUST
|
|
234
|
+
# point this at a shared Redis 7.2+ instance — without it, each replica
|
|
235
|
+
# stores RT families in its own ioredis connection against a local-only
|
|
236
|
+
# Redis, defeating the cross-replica persistence purpose. Module-internal
|
|
237
|
+
# config (keyPrefix / casRetryLimit) lives separately under
|
|
238
|
+
# `redisRefreshTokenFamilyStore.*` (consumed by the store module's schema).
|
|
239
|
+
redisRefreshTokenFamilyStore {
|
|
240
|
+
keyPrefix = "rtfam:"
|
|
241
|
+
keyPrefix = ${?REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX}
|
|
242
|
+
casRetryLimit = 3
|
|
243
|
+
casRetryLimit = ${?REFRESH_TOKEN_FAMILY_STORE_CAS_RETRY_LIMIT}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
# OR-9 (Wave 5d): module-internal config for `redisCodeRepositoryModule`.
|
|
247
|
+
# Consumed when `oauth.code.adapter = "redis"`. The shared ioredis socket
|
|
248
|
+
# (`standaloneRedisClientsModule`) provides the `codeRepositoryClient`
|
|
249
|
+
# slot; this section governs only key namespacing and the default
|
|
250
|
+
# expiry-in-seconds the repository uses when /authorize doesn't supply one.
|
|
251
|
+
#
|
|
252
|
+
# Multi-tenant deployments override `keyPrefix` to isolate tenants in the
|
|
253
|
+
# same Redis instance — e.g. `keyPrefix = "tenant-a:code:"` keeps tenant A's
|
|
254
|
+
# codes from colliding with tenant B's under the default `oauth:code:`
|
|
255
|
+
# prefix (MIN-3 advisory).
|
|
256
|
+
redisCodeRepository {
|
|
257
|
+
# No literal default for keyPrefix — the RedisCodeRepository constructor
|
|
258
|
+
# falls back to `oauth:code:` when this field is absent. Operators that
|
|
259
|
+
# need multi-tenant isolation set CLIENT_CODE_KEY_PREFIX (e.g.
|
|
260
|
+
# `tenant-a:code:`); leaving the env var unset keeps the constructor
|
|
261
|
+
# default intact via HOCON's `${?VAR}` "absent if unset" semantics.
|
|
262
|
+
keyPrefix = ${?CLIENT_CODE_KEY_PREFIX}
|
|
263
|
+
defaultExpiresIn = 600
|
|
264
|
+
defaultExpiresIn = ${?CLIENT_CODE_DEFAULT_EXPIRES_IN}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
refreshTokenFamilyStore {
|
|
268
|
+
redis {
|
|
269
|
+
url = "redis://localhost:6379"
|
|
270
|
+
url = ${?REFRESH_TOKEN_FAMILY_STORE_REDIS_URL}
|
|
271
|
+
password = ${?REFRESH_TOKEN_FAMILY_STORE_REDIS_PASSWORD}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -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
|
-
#
|
|
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"
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@o3co/auth-provider-standalone",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
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.
|
|
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.
|
|
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
|
+
});
|