@o3co/create-auth-provider 0.11.0 → 0.12.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 +1 -1
- package/templates/standalone/.env.example +13 -0
- package/templates/standalone/README.ja.md +9 -1
- package/templates/standalone/README.md +162 -20
- package/templates/standalone/config/application.conf +17 -0
- package/templates/standalone/docker-compose.production.yml +63 -4
- package/templates/standalone/docker-compose.yml +17 -0
- package/templates/standalone/package.json +1 -1
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +100 -0
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +67 -4
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +179 -0
- package/templates/standalone/src/__tests__/replica-safety.test.mts +456 -0
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +129 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +288 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +81 -1
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +12 -7
- package/templates/standalone/src/app.mts +5 -1
- package/templates/standalone/src/buildModules.mts +54 -16
- package/templates/standalone/src/modules.mts +88 -22
- package/templates/standalone/src/shutdown.mts +64 -7
- package/templates/versions.json +13 -12
package/package.json
CHANGED
|
@@ -40,6 +40,19 @@ SESSION_SECRET=
|
|
|
40
40
|
CLIENT_USER_AUTHENTICATE_URL=
|
|
41
41
|
CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL=
|
|
42
42
|
|
|
43
|
+
# --- Reverse proxy (required in production) -----------------------------------
|
|
44
|
+
# Express `trust proxy`. Set this to the address or CIDR range of whatever
|
|
45
|
+
# terminates TLS in front of this service — `10.0.0.0/8`, or `loopback` for a
|
|
46
|
+
# sidecar on the same host. Comma-separate several.
|
|
47
|
+
#
|
|
48
|
+
# Prefer naming the hop over `true`: `true` believes a forwarded client address
|
|
49
|
+
# from anyone who can reach this process, and with it any rate-limit identity.
|
|
50
|
+
# Left EMPTY here because it cannot be guessed; `docker-compose.production.yml`
|
|
51
|
+
# refuses to start until it is set, since without it the Secure cookie is never
|
|
52
|
+
# set, the CSRF origin check 403s every browser POST, and every IP-keyed rate
|
|
53
|
+
# limit shares one bucket. A plain-HTTP local run needs nothing here.
|
|
54
|
+
HTTP_TRUST_PROXY=
|
|
55
|
+
|
|
43
56
|
# Redis (code repository + session storage + the shared client socket)
|
|
44
57
|
# Use redis://redis:6379 when running via docker compose
|
|
45
58
|
CLIENT_CODE_ENDPOINT_URI=redis://redis:6379
|
|
@@ -115,7 +115,15 @@ openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
|
|
|
115
115
|
設定上の注意が 2 点:
|
|
116
116
|
|
|
117
117
|
- TLS 終端プロキシの背後では `HTTP_TRUST_PROXY` にそのプロキシのアドレス/CIDR を設定する (例: `HTTP_TRUST_PROXY=10.0.0.0/8`)。設定しないと `req.protocol` は `http` のままでブラウザは `Origin: https://…` を送るため、origin 側の判定が全リクエストを拒否する。`true` は「プロキシ以外が絶対にプロセスへ到達できない」と言い切れる場合にのみ正しい (#292) — 英語版 README も同じ指針。
|
|
118
|
-
- ログイン UI をプロバイダーと**別 origin** で配信している場合は、その origin を HOCON の `session.csrf.trustedOrigins` に列挙する。`cors.allowedOrigins` は CSRF
|
|
118
|
+
- ログイン UI をプロバイダーと**別 origin** で配信している場合は、その origin を HOCON の `session.csrf.trustedOrigins` に列挙する。`cors.allowedOrigins` は CSRF 信頼を与えない。
|
|
119
|
+
|
|
120
|
+
### CORS(#500)
|
|
121
|
+
|
|
122
|
+
ブラウザーアプリをプロバイダーと別 origin で配信している場合、その origin を `cors.allowedOrigins`(環境変数は `CORS_ALLOWED_ORIGINS`、カンマ区切り)に列挙しないと、SPA は token エンドポイントを呼べない。有効になるのは `/oauth/token`、`/oauth/userinfo`、`/oauth/revoke`、discovery、JWKS の 5 つだけで、`/oauth/introspect`(サーバー間通信)と `/oauth/authorize`(トップレベル遷移)は意図的に対象外。
|
|
123
|
+
|
|
124
|
+
`Origin` ヘッダーとの**完全一致**なので、末尾スラッシュ・明示的な `:443`・パス・大文字ホスト・ワイルドカードはいずれも起動時に失敗する(一致しない許可リストが黙って居座るのを防ぐため)。ループバックホストを除き https が必須。
|
|
125
|
+
|
|
126
|
+
`Access-Control-Allow-Credentials` は**決して送らない** — ここでのクロスオリジン SPA は PKCE を使う public client であり cookie は不要で、cookie を渡せば `session` grant に届いてしまう。空リスト(既定)なら CORS は無効で、middleware 自体がマウントされない。詳細は英語版 README の [CORS](README.md#cors) を参照。
|
|
119
127
|
|
|
120
128
|
### Google フェデレーション
|
|
121
129
|
|
|
@@ -7,7 +7,7 @@ Deployable server template for auth.provider. This is the composition root — i
|
|
|
7
7
|
- **Node.js** `>=22.0.0`
|
|
8
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` lives in `pnpm-workspace.yaml` — pnpm ≥10.29 reads it only from there, in single-package projects too. `create-auth-provider` writes that file into the project it scaffolds (inside this monorepo, the allowlist sits in the workspace root's `pnpm-workspace.yaml`); without it a fresh `pnpm install` silently skips the install hook on those platforms.
|
|
9
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** `^
|
|
10
|
+
- **ioredis** `^6.0.0` (direct runtime dependency). Every `@o3co/auth-provider-redis` adapter this template wires — refresh-token families, authorization codes, rate-limit counters, the access-token denylist, the user-session stores, the federation-token store — runs off the one ioredis connection `standaloneRedisClientsModule` opens per replica (`makeIoredisClients`). The `redis` npm package (`^6.2.1`) stays a dependency for one thing: `connect-redis` takes a node-redis client, so the `express-session` store behind `SESSION_STORAGE_TYPE=redis` is a second, separate connection (the `session-store` probe in `/readyz`).
|
|
11
11
|
|
|
12
12
|
## First-party clients
|
|
13
13
|
|
|
@@ -31,6 +31,8 @@ my-app:
|
|
|
31
31
|
|
|
32
32
|
## Multi-replica deployments
|
|
33
33
|
|
|
34
|
+
The [operator runbook](../../docs/operator-runbook.md) covers the operational side end to end — boot refusals, probes, dependency failures, alerts, Redis sizing, key rotation. This section is the multi-replica checklist.
|
|
35
|
+
|
|
34
36
|
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.
|
|
35
37
|
|
|
36
38
|
**Set `HTTP_TRUST_PROXY` to your proxy's address behind a load balancer.** It defaults to `false`, which makes `req.ip` the *load balancer's* address rather than the client's. Every rate limit keyed by IP — the OAuth endpoint limiters and the `POST /session/login` brute-force guard — then shares **one bucket across all users**: the first 20 login attempts from anyone exhaust the window and every subsequent user gets `429`. The failure reads like an attack rather than a misconfiguration, which is what makes it expensive to diagnose at 3am. Set it whenever anything terminates TLS or proxies in front of this service, and make sure that hop is the one setting `X-Forwarded-For`.
|
|
@@ -50,7 +52,50 @@ An allowlist is a network control, not a cryptographic one. The edge must also *
|
|
|
50
52
|
|
|
51
53
|
**Point `rateLimiter.adapter` at Redis.** It defaults to `"memory"`, which is per-process: with N replicas every configured limit is effectively N times larger and resets on every deploy. The memory adapter is also **evadable under bucket exhaustion**: it caps itself at 10,000 buckets and, at the cap, admitting a new key evicts the bucket closest to reset — so an attacker who can present many source IPs (and `req.ip` is client-influenced whenever `HTTP_TRUST_PROXY` is broader than your actual hops) can churn the table until a target's counter is evicted and starts over. That is acceptable for one dev process; it is not a production rate limit. Since #270 the login guard runs on this same shared component, so one setting covers both the OAuth endpoints and `/session/login`. The login window and limit stay configured at `rateLimit.login`; both adapters seed their own `limits.login` from it, so there is nothing to restate.
|
|
52
54
|
|
|
53
|
-
**
|
|
55
|
+
**Behind a BFF, raise `limits.token` before you need to.** The OAuth-endpoint
|
|
56
|
+
rate limits key on `req.ip` (`packages/core/src/ratelimit/guard.mts` builds the
|
|
57
|
+
bucket key as `<endpoint>:ip:<req.ip>`), which is the right identity when the
|
|
58
|
+
client is the browser or a native app talking to this provider directly. It is
|
|
59
|
+
the wrong one in a backend-for-frontend topology, where a server-side app holds
|
|
60
|
+
the session and exchanges codes and refreshes on its users' behalf: every
|
|
61
|
+
`/oauth/token` and `/oauth/introspect` call then arrives from the BFF's single
|
|
62
|
+
address and the whole deployment shares one bucket. At the default 60 requests
|
|
63
|
+
per 60 s that caps you at roughly **60 session-grant exchanges a minute across
|
|
64
|
+
all users** — and it does not surface as a rate limit. The BFF gets a `429` it
|
|
65
|
+
was not written to expect, turns it into a `502` for its own caller, and the
|
|
66
|
+
symptom your users report is "sign-in is broken sometimes", at the traffic
|
|
67
|
+
level where it starts happening and not before.
|
|
68
|
+
|
|
69
|
+
`HTTP_TRUST_PROXY` does not fix this and is not meant to. It teaches `req.ip`
|
|
70
|
+
to see through a *proxy* — a hop that forwards someone else's request and says
|
|
71
|
+
so in `X-Forwarded-For`. A BFF is not forwarding anyone; it is the client, and
|
|
72
|
+
its address is the honest answer to "who called". There is no per-user identity
|
|
73
|
+
in these requests to key on.
|
|
74
|
+
|
|
75
|
+
The knob is the per-endpoint budget. Both limiter adapters take
|
|
76
|
+
`limits { <prefix> { limit, windowSeconds } }`, keyed by the endpoint prefix:
|
|
77
|
+
|
|
78
|
+
```hocon
|
|
79
|
+
redisRateLimiter {
|
|
80
|
+
limits {
|
|
81
|
+
token { limit = 600, windowSeconds = 60 }
|
|
82
|
+
introspect { limit = 600, windowSeconds = 60 }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
(`memoryRateLimiter.limits` for the memory adapter, same shape.) Size it from
|
|
88
|
+
your BFF's peak sign-ins and refreshes per minute with headroom, and remember
|
|
89
|
+
that under `rateLimiter.adapter = "redis"` the budget is shared across replicas
|
|
90
|
+
— which is what you want here, and what makes the number mean something.
|
|
91
|
+
|
|
92
|
+
The defaults are deliberately left alone: 60/60 s per source IP is a sensible
|
|
93
|
+
brute-force bound for a deployment whose clients really are distinct IPs, and
|
|
94
|
+
raising it globally to accommodate one topology would weaken it for the other.
|
|
95
|
+
Put the throttling that protects the BFF's *own* users in front of the BFF,
|
|
96
|
+
where per-user identity still exists.
|
|
97
|
+
|
|
98
|
+
**Set `DEPLOYMENT_MODE=multi` once you run more than one replica.** Boot then *fails* if any in-memory store that has to be shared is still wired, naming every offender and what it costs — user sessions forking (back-channel logout reaches one replica, a logged-out session stays valid on the others), rate-limit counters multiplying, access-token revocation not propagating, DPoP proof-replay detection forking. The check reads the declaration each installed module carries on its own manifest rather than a list of library module names, so this template's own in-memory modules — the user-session stores (`USER_SESSION_STORES_ADAPTER=memory`), the authorization-code repository (`OAUTH_CODE_ADAPTER=memory`) and the federation token store (`FEDERATION_TOKEN_STORE_TYPE=memory`, the default) — are refused by name too; before #455 they booted under `multi`. Since #474 that includes express-session's own store under `SESSION_STORAGE_TYPE=memory` and the login / WebAuthn-options rate limiters when no shared `rateLimiter` is wired; with the mode unset those three warn instead (see the operator runbook). With the mode unset you get one `replica_unsafe_adapters` warning at boot instead; with `DEPLOYMENT_MODE=single` the check is silent, because you have said there is one replica.
|
|
54
99
|
|
|
55
100
|
Be aware of what this check *cannot* do: if you scale to N replicas without ever setting `DEPLOYMENT_MODE`, nothing fails. A process holding all its state in its own memory has no shared medium through which to notice peers — the condition is undetectable from inside exactly when it is true. Set the variable as part of scaling, not after something breaks.
|
|
56
101
|
|
|
@@ -58,9 +103,10 @@ Be aware of what this check *cannot* do: if you scale to N replicas without ever
|
|
|
58
103
|
|
|
59
104
|
Other multi-replica considerations covered by the default modules:
|
|
60
105
|
|
|
61
|
-
- The session store is
|
|
62
|
-
- The
|
|
63
|
-
- The
|
|
106
|
+
- The `express-session` store (`sessionStoreModule`) is its own connection: `SESSION_STORAGE_TYPE=redis` with `SESSION_STORAGE_REDIS_URL` (`session.storage.redis.url`) pointing at the shared instance.
|
|
107
|
+
- The user-session stores switch on `userSessionStores.adapter = "redis"` (`USER_SESSION_STORES_ADAPTER`), which wires `redisSessionStoresModule` off the shared ioredis connection — the one `REFRESH_TOKEN_FAMILY_STORE_REDIS_URL` configures.
|
|
108
|
+
- The authorization-code repository switches on `oauth.code.adapter` (`OAUTH_CODE_ADAPTER`); the template ships `"redis"`, on that same connection. The older `repositories.code.type = "redis"` (`CLIENT_CODE_TYPE`) switch is still honoured with a deprecation warning at boot. `CLIENT_CODE_ENDPOINT_URI` (`repositories.code.redis.endpointUri`) is still bound by `config/application.conf` but nothing reads it: the Redis code repository has run on the shared connection since the `{ endpointUri }` builder shape was retired, so there is one Redis URL for every adapter and that variable is legacy.
|
|
109
|
+
- The federation token store defaults to memory. Set `FEDERATION_TOKEN_STORE_TYPE=redis` (`federationTokenStore.type = "redis"`) and supply `REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY` — 32 bytes, base64-encoded (`openssl rand -base64 32`); the store encrypts the upstream refresh tokens it holds. It shares the ioredis socket configured by `REFRESH_TOKEN_FAMILY_STORE_REDIS_URL`. See [Federation Token Store](#federation-token-store).
|
|
64
110
|
|
|
65
111
|
## Usage
|
|
66
112
|
|
|
@@ -171,7 +217,7 @@ tokens that have already expired.
|
|
|
171
217
|
| `SESSION_SAME_SITE` | `lax` | `SameSite` attribute (`lax`, `strict`, `none`). `none` **requires** `SESSION_SECURE=true` — browsers drop a `SameSite=None` cookie that is not `Secure`, so boot refuses the combination rather than letting every login fail silently in the client. |
|
|
172
218
|
| `SESSION_DOMAIN` | — | Cookie domain (unset by default) |
|
|
173
219
|
| `SESSION_CSRF_TTL_SECONDS` | `7200` | Lifetime of an issued CSRF token, in seconds. Integer, 1–86400; boot fails otherwise (an *empty* value coerces to `0` and would silently disable the token arm). |
|
|
174
|
-
| `SESSION_STORAGE_TYPE` | `redis` | Session store backend: `redis` or `memory` |
|
|
220
|
+
| `SESSION_STORAGE_TYPE` | `redis` | Session store backend: `redis` or `memory`. `memory` is per process and is refused under `DEPLOYMENT_MODE=multi` like the other in-memory stores (#474) |
|
|
175
221
|
| `SESSION_STORAGE_REDIS_URL` | `redis://localhost:6379` | Redis connection URL for session storage |
|
|
176
222
|
| `SESSION_STORAGE_REDIS_PASSWORD` | — | Redis password for session storage |
|
|
177
223
|
|
|
@@ -197,7 +243,64 @@ Two configuration notes:
|
|
|
197
243
|
request.
|
|
198
244
|
- If your login UI is served from a **different origin** than the provider,
|
|
199
245
|
list that origin under `session.csrf.trustedOrigins` in your HOCON config.
|
|
200
|
-
`cors.allowedOrigins`
|
|
246
|
+
`cors.allowedOrigins` does not confer CSRF trust — see
|
|
247
|
+
[CORS](#cors) for what it does confer.
|
|
248
|
+
|
|
249
|
+
### CORS
|
|
250
|
+
|
|
251
|
+
| Variable | Default | Description |
|
|
252
|
+
|---|---|---|
|
|
253
|
+
| `CORS_ALLOWED_ORIGINS` | *(empty — CORS off)* | Comma-separated list of browser origins allowed to read the token, userinfo, revocation and discovery/JWKS responses |
|
|
254
|
+
|
|
255
|
+
A browser app served from a different origin than this provider — an SPA on
|
|
256
|
+
`https://app.example.com` against a provider on `https://auth.example.com` —
|
|
257
|
+
cannot call the token endpoint at all unless that origin is on
|
|
258
|
+
`cors.allowedOrigins`. Set it and the provider answers preflights and stamps
|
|
259
|
+
`Access-Control-Allow-Origin` on the five endpoints a browser has a legitimate
|
|
260
|
+
reason to call cross-origin:
|
|
261
|
+
|
|
262
|
+
| Endpoint | Methods | Why |
|
|
263
|
+
|---|---|---|
|
|
264
|
+
| `/oauth/token` | `POST` | The PKCE code exchange and refresh — the call an SPA cannot avoid |
|
|
265
|
+
| `/oauth/userinfo` | `GET`, `POST` | OIDC Core §5.3 defines both |
|
|
266
|
+
| `/oauth/revoke` | `POST` | RFC 7009 §2.1 — a public client revoking its own tokens on sign-out |
|
|
267
|
+
| `/.well-known/openid-configuration` | `GET` | Discovery, fetched by browser client libraries |
|
|
268
|
+
| `/.well-known/jwks.json` | `GET` | Same; follows `oauth.jwt.jwksPath` when you override it |
|
|
269
|
+
|
|
270
|
+
`/oauth/introspect` and `/oauth/authorize` are deliberately **not** included.
|
|
271
|
+
Introspection is server-to-server and already refuses public clients, so no
|
|
272
|
+
browser could use it; `/authorize` is a top-level navigation rather than a
|
|
273
|
+
`fetch`, and CORS has no say over where a browser may navigate.
|
|
274
|
+
|
|
275
|
+
```hocon
|
|
276
|
+
cors {
|
|
277
|
+
allowedOrigins = ["https://app.example.com", "http://localhost:5173"]
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
or `CORS_ALLOWED_ORIGINS=https://app.example.com,http://localhost:5173`.
|
|
282
|
+
|
|
283
|
+
**Matching is exact string equality against the `Origin` header**, and every
|
|
284
|
+
shape that could not match fails at boot naming its index rather than sitting
|
|
285
|
+
in the config admitting nobody. So: no trailing slash
|
|
286
|
+
(`https://app.example.com/`), no explicit default port (`:443`), no path, no
|
|
287
|
+
uppercase host, and **no wildcard** — there is no subdomain matching and there
|
|
288
|
+
is not going to be. `https` is required except for a loopback host
|
|
289
|
+
(`localhost`, `127.0.0.0/8`, `[::1]`), which is the carve-out that lets a
|
|
290
|
+
front-end dev server work without a certificate.
|
|
291
|
+
|
|
292
|
+
**Credentials are never allowed.** The provider sends no
|
|
293
|
+
`Access-Control-Allow-Credentials`, so an allowlisted origin can read these
|
|
294
|
+
responses and cannot use the browser's session cookie. That is deliberate: a
|
|
295
|
+
cross-origin SPA here is a public client using PKCE and needs no cookie, while
|
|
296
|
+
the cookie it would otherwise gain reaches the `session` grant — which
|
|
297
|
+
exchanges an authenticated browser session for tokens. If your app needs that
|
|
298
|
+
grant, serve it same-origin or put a BFF in front.
|
|
299
|
+
|
|
300
|
+
Every response from these five routes carries `Vary: Origin`, including the
|
|
301
|
+
ones with no CORS headers, so a shared cache cannot hand one origin's response
|
|
302
|
+
to another. An empty list (the default) means the middleware is not mounted at
|
|
303
|
+
all — no headers, no `Vary`, nothing changed.
|
|
201
304
|
|
|
202
305
|
### Google Federation
|
|
203
306
|
|
|
@@ -238,11 +341,29 @@ or the response cap is unusable, rather than at the first login attempt.
|
|
|
238
341
|
|
|
239
342
|
| Variable | Default | Description |
|
|
240
343
|
|---|---|---|
|
|
241
|
-
| `CLIENT_CODE_TYPE` | `redis` |
|
|
242
|
-
| `CLIENT_CODE_ENDPOINT_URI` | — |
|
|
344
|
+
| `CLIENT_CODE_TYPE` | `redis` | Deprecated alias of `OAUTH_CODE_ADAPTER` (`repositories.code.type`); honoured with a boot warning |
|
|
345
|
+
| `CLIENT_CODE_ENDPOINT_URI` | — | Legacy; bound to `repositories.code.redis.endpointUri` but not read — codes use the shared `REFRESH_TOKEN_FAMILY_STORE_REDIS_URL` connection |
|
|
243
346
|
| `CLIENT_CODE_PASSWORD` | — | Redis password for code storage |
|
|
244
347
|
| `CLIENT_CODE_DEFAULT_EXPIRES_IN` | `600` | Default authorization code lifetime in seconds |
|
|
245
348
|
|
|
349
|
+
### Federation Token Store
|
|
350
|
+
|
|
351
|
+
The upstream IdP tokens (a Google refresh token, say) held on behalf of a
|
|
352
|
+
session. Memory by default, which forks per replica and is refused under
|
|
353
|
+
`DEPLOYMENT_MODE=multi`; the Redis store shares the socket configured by
|
|
354
|
+
`REFRESH_TOKEN_FAMILY_STORE_REDIS_URL` and encrypts records at rest, so it
|
|
355
|
+
needs a key.
|
|
356
|
+
|
|
357
|
+
| Variable | Default | Description |
|
|
358
|
+
|---|---|---|
|
|
359
|
+
| `FEDERATION_TOKEN_STORE_TYPE` | `memory` | Federation token store backend: `memory` or `redis` |
|
|
360
|
+
| `REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY` | — | AES-256-GCM key for records at rest: 32 bytes, base64-encoded (`openssl rand -base64 32`). **Required** with `redis` unless the mode below is `allow-plaintext` |
|
|
361
|
+
| `REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_MODE` | `required` | `required` or `allow-plaintext`. Plaintext is refused when the config was selected by a production/staging environment (`CONFIG_ENV` or `NODE_ENV`) and under `DEPLOYMENT_MODE=multi` in any environment, unless `FEDERATION_TOKENS_ALLOW_INSECURE=1` is also set — development only |
|
|
362
|
+
|
|
363
|
+
`ttl` (seconds; keep it above the upstream refresh-token lifetime) and the #291
|
|
364
|
+
`scanFallback` migration flag live under `redisFederationTokenStore` in a
|
|
365
|
+
config layer rather than behind an environment variable.
|
|
366
|
+
|
|
246
367
|
### Redis Namespacing
|
|
247
368
|
|
|
248
369
|
For multi-tenant Redis clusters, set deployment-specific key prefixes so two
|
|
@@ -253,9 +374,10 @@ auth.provider instances cannot collide in the same database:
|
|
|
253
374
|
| `REDIS_SESSION_STORES_KEY_PREFIX` | `ss:` | Outer prefix for user sessions, RP registry, session-family index, and session-federation index. |
|
|
254
375
|
| `REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX` | `rtfam:` | Prefix for refresh-token family records. |
|
|
255
376
|
| `CLIENT_CODE_KEY_PREFIX` | `oauth:code:` | Prefix for OAuth authorization codes. |
|
|
377
|
+
| `REDIS_FEDERATION_TOKEN_STORE_KEY_PREFIX` | `ft:` | Prefix for federation token records, their per-session index and their lock keys. |
|
|
256
378
|
|
|
257
379
|
Use values that include the deployment name, for example `tenant-a:ss:`,
|
|
258
|
-
`tenant-a:rtfam:`, and `tenant-a:
|
|
380
|
+
`tenant-a:rtfam:`, `tenant-a:code:`, and `tenant-a:ft:`.
|
|
259
381
|
|
|
260
382
|
### Endpoints
|
|
261
383
|
|
|
@@ -270,21 +392,36 @@ per-provider (`FEDERATIONS_GOOGLE_CALLBACK_URL`).
|
|
|
270
392
|
|
|
271
393
|
## Module Composition Order
|
|
272
394
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
1. **`
|
|
276
|
-
2. **`
|
|
277
|
-
3. **`oauthSessionModule`** —
|
|
278
|
-
4. **`oauthAuthorizationModule`** —
|
|
395
|
+
`src/buildModules.mts` is the single source of truth for the manifest; this is its order. The boot planner resolves `requires` / `provides` topologically, so list position matters only where a module says so — `sessionStoreModule` must stay first, because it wires the `express-session` middleware every session-consuming route needs. Entries marked *adapter switch* are one of a memory/Redis pair, chosen by config; both provide the same slot, so exactly one is wired.
|
|
396
|
+
|
|
397
|
+
1. **`sessionStoreModule`** (`@o3co/auth-provider-session`) — `express-session` middleware and its store (`session.storage.type`)
|
|
398
|
+
2. **`oauthModule`** — `POST /oauth/token`, `POST /oauth/introspect`, `POST /oauth/revoke`, `GET /oauth/authorize`, and OIDC discovery when an issuer is configured
|
|
399
|
+
3. **`oauthSessionModule`** — session grant (exchanges an authenticated session for a token)
|
|
400
|
+
4. **`oauthAuthorizationModule`** — authorization-code and refresh-token grants
|
|
401
|
+
5. **`jwksModule`** (`@o3co/auth-provider-core`) — `GET /.well-known/jwks.json` (or `oauth.jwt.jwksPath`); a signing provider publishes its verification keys whether or not an issuer is set
|
|
402
|
+
6. **`sessionModule`** — `POST /session/login`, `POST /session/logout`, `GET/POST /session/oauth/federation/*`. `/session/logout` deletes the `UserSession` record (so `/oauth/introspect` and `/oauth/userinfo` stop honouring tokens minted from that session), the subject index and the federation entries — but it does **not** revoke refresh-token families; `POST /oauth/logout` is the endpoint that runs the full cascade. See [Which logout endpoint invalidates what](../../docs/operator-runbook.md#which-logout-endpoint-invalidates-what).
|
|
403
|
+
7. **`googleFederationModule`** + **`googleFederationConfigModule`** — only when `federations.google.enabled = true`
|
|
404
|
+
8. **`keyStoreModule`** — the signing key store from `oauth.jwt.signingKey`
|
|
405
|
+
9. **`repositoriesModule`** — client and user repositories from `repositories.client` / `repositories.user`
|
|
406
|
+
10. **`auditSinkModule`** — always wired; `audit.sink.type` picks `logger` (default) or `console`
|
|
407
|
+
11. **`standaloneRedisClientsModule`** — the one shared ioredis connection; present whenever any switch below is `"redis"`
|
|
408
|
+
12. **`redisFederationTokenStoreModule`** / **`inMemoryFederationTokenStoreModule`** — *adapter switch*: upstream federation tokens per session (`federationTokenStore.type`; see [Multi-replica deployments](#multi-replica-deployments))
|
|
409
|
+
13. **`redisSessionStoresModule`** / **`inMemorySessionStoresModule`** — *adapter switch*: user-session stores (`userSessionStores.adapter`)
|
|
410
|
+
14. **`redisRateLimiterModule`** / **`memoryRateLimiterModule`** — *adapter switch*: the rate limiter behind the OAuth endpoints and `POST /session/login` (`rateLimiter.adapter`)
|
|
411
|
+
15. **`redisCodeRepositoryModule`** / **`inMemoryCodeRepositoryModule`** — *adapter switch*: authorization codes (`oauth.code.adapter`)
|
|
412
|
+
16. **`redisAccessTokenDenylistModule`** / **`memoryAccessTokenDenylistModule`** — *adapter switch*: RFC 7009 access-token denylist (`accessTokenDenylist.adapter`)
|
|
413
|
+
17. **`redisRefreshTokenFamilyStoreModule`** — refresh-token families; Redis unless a test override swaps in the memory store
|
|
414
|
+
18. **`defaultRefreshTokenFamilyRotationModule`**, **`defaultRefreshTokenFamilyRevocationModule`** — rotation and revocation policies over the family store
|
|
279
415
|
|
|
280
416
|
## Built-in Routes
|
|
281
417
|
|
|
282
|
-
|
|
418
|
+
`src/app.mts` mounts these on the host app ahead of the composed auth router, so they keep answering while the auth pipeline is degraded — which is when an operator needs them. The JWKS route is not one of them: `jwksModule` contributes it (see above).
|
|
283
419
|
|
|
284
420
|
| Method | Path | Description |
|
|
285
421
|
|---|---|---|
|
|
286
|
-
| `GET` | `/
|
|
287
|
-
| `GET` |
|
|
422
|
+
| `GET` | `/_healthcheck` | Liveness — `200 {"status":"ok"}` whenever the process is up, whatever Redis is doing. See [Health endpoints](#health-endpoints) |
|
|
423
|
+
| `GET` | `/readyz` | Readiness — runs every registered dependency probe (`redis`, `session-store`, …) under `HTTP_READINESS_TIMEOUT_MS`; `200 {"status":"ready"}` or `503 {"status":"unready"}`, never cached |
|
|
424
|
+
| `GET` | `/metrics` | Prometheus text exposition; dependency gauges are re-probed on every scrape. Keep it off the public listener — see [Metrics](#metrics) |
|
|
288
425
|
|
|
289
426
|
## Docker
|
|
290
427
|
|
|
@@ -310,7 +447,11 @@ make test
|
|
|
310
447
|
The `docker-compose.yml` starts the auth server together with a Redis container. Configure environment variables in `.env`.
|
|
311
448
|
|
|
312
449
|
`docker-compose.yml` is a **development** file: it builds the `develop` target and bind-mounts `./src` and `./config`, so deploying it ships a hot-reload server running your working tree. The deployable shape is
|
|
313
|
-
[`docker-compose.production.yml`](docker-compose.production.yml) — `runtime` target, no source mounts, restart policies, a network-internal persistent Redis, and a **required** `.env` (a boot without a real `SESSION_SECRET` must fail loudly). Its header comments state what it deliberately leaves to you: TLS termination in front
|
|
450
|
+
[`docker-compose.production.yml`](docker-compose.production.yml) — `runtime` target, no source mounts, restart policies, a network-internal persistent Redis, and a **required** `.env` (a boot without a real `SESSION_SECRET` must fail loudly). Its header comments state what it deliberately leaves to you: TLS termination in front, and the [multi-replica](#multi-replica-deployments) steps before any `--scale`.
|
|
451
|
+
|
|
452
|
+
`HTTP_TRUST_PROXY` is an explicit `${HTTP_TRUST_PROXY:?…}` entry there, so `docker compose up` **refuses to start** until you name the hop in `.env`. That is deliberate: there is no address the file could default to that would not silently trust a hop you never chose, and without the variable the Secure cookie the file pins is never set, the CSRF origin check 403s every browser POST, and every IP-keyed rate limit shares one bucket.
|
|
453
|
+
|
|
454
|
+
Every store whose records must outlive one process is named in that file's `environment:` block rather than inherited — including `USER_SESSION_STORES_ADAPTER=redis`, which has to travel with `SESSION_STORAGE_TYPE=redis`. `DEPLOYMENT_MODE=single` will not tell you when it does not: the replica guard answers "can these stores be shared", not "do these two stores have the same lifetime". Split them and a restart leaves every browser holding a surviving express-session that still reads `isAuthenticated` with no `UserSession` behind it — `/authorize` bounces to login, the cookie bounces it back, and the loop clears only when the user deletes the cookie.
|
|
314
455
|
|
|
315
456
|
```bash
|
|
316
457
|
# The signing keys are a required input: EdDSA is the default and there is no
|
|
@@ -486,6 +627,7 @@ installGracefulShutdown(server, { logger, cleanup: () => handle.dispose() });
|
|
|
486
627
|
|
|
487
628
|
## See Also
|
|
488
629
|
|
|
630
|
+
- [Operator runbook](../../docs/operator-runbook.md) — deployment shapes and boot refusals, probes, what each dependency failure looks like, events to alert on, Redis key families and failure timing, key rotation, upgrading and rollback
|
|
489
631
|
- [`@o3co/auth-provider-core`](../../packages/core) — Application factory and config schema
|
|
490
632
|
- [`@o3co/auth-provider-oauth`](../../packages/oauth) — OAuth module
|
|
491
633
|
- [`@o3co/auth-provider-session`](../../packages/session) — Session module
|
|
@@ -291,8 +291,25 @@ endpoints {
|
|
|
291
291
|
# production consumer reads them.
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
+
# #500: browser origins allowed to read the token, userinfo, revocation and
|
|
295
|
+
# discovery/JWKS responses. Empty (the default) means CORS is off -- leave it
|
|
296
|
+
# alone unless a browser app is served from a different origin than this
|
|
297
|
+
# provider. See the library reference.conf for the full rules; the short
|
|
298
|
+
# version is that matching is EXACT, so each entry is a bare origin with no
|
|
299
|
+
# trailing slash, no path and no wildcard, and https is required off loopback.
|
|
300
|
+
#
|
|
301
|
+
# allowedOrigins = ["https://app.example.com", "http://localhost:5173"]
|
|
302
|
+
#
|
|
303
|
+
# The env-override line is repeated at this layer for the HOCON precedence
|
|
304
|
+
# reason the oauth.grants block above explains: application.conf wins over
|
|
305
|
+
# reference.conf, so without it CORS_ALLOWED_ORIGINS could not take effect.
|
|
306
|
+
# It takes a comma-separated list.
|
|
307
|
+
#
|
|
308
|
+
# Not to be confused with `session.csrf.trustedOrigins`, which answers the
|
|
309
|
+
# other question (#272).
|
|
294
310
|
cors {
|
|
295
311
|
allowedOrigins = []
|
|
312
|
+
allowedOrigins = ${?CORS_ALLOWED_ORIGINS}
|
|
296
313
|
}
|
|
297
314
|
|
|
298
315
|
# D-2 v2 + Wave 5d: ioredis connection-config consumed by the shared
|
|
@@ -19,10 +19,11 @@
|
|
|
19
19
|
#
|
|
20
20
|
# What this file deliberately does NOT do:
|
|
21
21
|
#
|
|
22
|
-
# - **TLS.** Terminate it in front (reverse proxy / load balancer)
|
|
23
|
-
# HTTP_TRUST_PROXY
|
|
24
|
-
# rate limit — means the client, not the proxy
|
|
25
|
-
# "Multi-replica
|
|
22
|
+
# - **TLS.** Terminate it in front (reverse proxy / load balancer). The
|
|
23
|
+
# HTTP_TRUST_PROXY entry below then needs that hop's address so `req.ip` —
|
|
24
|
+
# and every IP-keyed rate limit — means the client, not the proxy; compose
|
|
25
|
+
# refuses to start until you supply it. See README "Multi-replica
|
|
26
|
+
# deployments".
|
|
26
27
|
# - **Scale out.** This is one replica (DEPLOYMENT_MODE=single). Before
|
|
27
28
|
# `--scale app=N`, set DEPLOYMENT_MODE=multi and read that same README
|
|
28
29
|
# section — boot will then refuse any in-memory store that must be shared.
|
|
@@ -74,7 +75,31 @@ services:
|
|
|
74
75
|
- jwt_public_key
|
|
75
76
|
environment:
|
|
76
77
|
NODE_ENV: production
|
|
78
|
+
# One replica. Under `single` the replica-safety guard is SILENT by
|
|
79
|
+
# design — you have told it there are no peers — so it will not tell you
|
|
80
|
+
# that a store below is on memory while its sibling is on Redis. That is
|
|
81
|
+
# why the adapter block further down states every one of them explicitly
|
|
82
|
+
# instead of leaving them to inherit. Before `--scale app=N`, set `multi`
|
|
83
|
+
# and read the README's "Multi-replica deployments".
|
|
77
84
|
DEPLOYMENT_MODE: single
|
|
85
|
+
# Behind the TLS terminator this file assumes, `req.ip` is the proxy's
|
|
86
|
+
# address and `req.protocol` reads `http` until this names the hop. The
|
|
87
|
+
# README calls it required for exactly that reason, and the cost of
|
|
88
|
+
# leaving it unset is three failures at once: the Secure cookie pinned
|
|
89
|
+
# below is never set, the CSRF origin check 403s every browser POST
|
|
90
|
+
# (the browser sends `Origin: https://…`, the process believes it is
|
|
91
|
+
# serving http), and every IP-keyed rate limit — the OAuth endpoint
|
|
92
|
+
# limiters and the /session/login brute-force guard — shares ONE bucket
|
|
93
|
+
# across all users.
|
|
94
|
+
#
|
|
95
|
+
# It cannot be guessed and it is not defaulted: only you know what sits
|
|
96
|
+
# in front, and a default here would silently trust a hop you never
|
|
97
|
+
# chose. Put your edge's address or CIDR range in `.env` — `10.0.0.0/8`,
|
|
98
|
+
# or `loopback` for a sidecar on this host. Prefer that over `true`,
|
|
99
|
+
# which believes a forwarded client address from anyone able to reach
|
|
100
|
+
# this process. The `:?` form makes compose refuse to start until it is
|
|
101
|
+
# set, rather than starting wrong.
|
|
102
|
+
HTTP_TRUST_PROXY: ${HTTP_TRUST_PROXY:?set to your TLS terminator's address or CIDR range (e.g. 10.0.0.0/8, or `loopback` for a same-host sidecar) — never `true` behind an open network}
|
|
78
103
|
# Point the process at the mounted secrets rather than the working-copy
|
|
79
104
|
# paths `.env` carries for local runs.
|
|
80
105
|
OAUTH_JWT_PRIVATE_KEY_PATH: /run/secrets/jwt_private_key
|
|
@@ -89,12 +114,46 @@ services:
|
|
|
89
114
|
SESSION_SECURE: "true"
|
|
90
115
|
SESSION_NAME: __Host-auth.session
|
|
91
116
|
# Every Redis-backed store shares the compose-network Redis below.
|
|
117
|
+
#
|
|
118
|
+
# These are stated in full rather than inherited from
|
|
119
|
+
# `config/application.conf`, so that the deployable file names its own
|
|
120
|
+
# topology: an operator reading only this one can see which stores
|
|
121
|
+
# survive a restart. `OAUTH_CODE_ADAPTER` and
|
|
122
|
+
# `ACCESS_TOKEN_DENYLIST_ADAPTER` already ship as `redis` in that config
|
|
123
|
+
# layer and are left there on purpose — restating a value that agrees is
|
|
124
|
+
# a second place to forget to change.
|
|
92
125
|
REFRESH_TOKEN_FAMILY_STORE_REDIS_URL: redis://redis:6379
|
|
93
126
|
SESSION_STORAGE_TYPE: redis
|
|
94
127
|
SESSION_STORAGE_REDIS_URL: redis://redis:6379
|
|
128
|
+
# The UserSession stores: the provider's own record of who is signed in,
|
|
129
|
+
# which RPs a session has touched, and the family / federation indexes
|
|
130
|
+
# hanging off it. They MUST be on Redis whenever the express-session
|
|
131
|
+
# store above is, and this line is the fix for what happens when they
|
|
132
|
+
# are not.
|
|
133
|
+
#
|
|
134
|
+
# express-session on Redis survives a restart; a memory UserSession store
|
|
135
|
+
# does not. So every browser comes back holding a session cookie whose
|
|
136
|
+
# express-session record still says `isAuthenticated: true`, while the
|
|
137
|
+
# `UserSession` that record points at is gone — /authorize sees an
|
|
138
|
+
# authenticated session, finds no user session behind it, and sends the
|
|
139
|
+
# browser to log in again, where the still-valid cookie puts it straight
|
|
140
|
+
# back. A login loop that clears only when the user deletes the cookie.
|
|
141
|
+
# `DEPLOYMENT_MODE: single` says nothing about it: the replica guard
|
|
142
|
+
# answers "can these stores be shared", not "do these two stores have the
|
|
143
|
+
# same lifetime".
|
|
144
|
+
USER_SESSION_STORES_ADAPTER: redis
|
|
95
145
|
# The memory rate limiter is per-process and evadable under bucket
|
|
96
146
|
# exhaustion (see README) — production points it at Redis.
|
|
97
147
|
RATE_LIMITER_ADAPTER: redis
|
|
148
|
+
# The federation token store is deliberately left at its `memory`
|
|
149
|
+
# default, and that is coherent here rather than an oversight: this
|
|
150
|
+
# template ships every federation disabled, so nothing writes to the
|
|
151
|
+
# store, and switching it on would need a 32-byte AES key
|
|
152
|
+
# (REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY) that this file must not
|
|
153
|
+
# invent — the records are long-lived upstream refresh tokens. Enabling
|
|
154
|
+
# a federation means setting BOTH, together:
|
|
155
|
+
# FEDERATION_TOKEN_STORE_TYPE: redis
|
|
156
|
+
# REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: <openssl rand -base64 32, from .env>
|
|
98
157
|
|
|
99
158
|
redis:
|
|
100
159
|
# Tag-pinned, not digest-pinned. The Dockerfile argues for digests (#289)
|
|
@@ -14,6 +14,23 @@ services:
|
|
|
14
14
|
env_file:
|
|
15
15
|
- path: .env
|
|
16
16
|
required: false
|
|
17
|
+
environment:
|
|
18
|
+
# The same coherence the production file states, for the same reason,
|
|
19
|
+
# and it matters MORE here. `config/application.conf` already puts
|
|
20
|
+
# express-session, the authorization codes and the access-token denylist
|
|
21
|
+
# on the Redis this compose runs; only the UserSession stores were left
|
|
22
|
+
# on their `memory` default. That combination survives a restart on one
|
|
23
|
+
# side and not the other, so every browser comes back holding an
|
|
24
|
+
# express-session that still says `isAuthenticated` with no `UserSession`
|
|
25
|
+
# behind it and /authorize loops.
|
|
26
|
+
#
|
|
27
|
+
# A dev container restarts on every save (`target: develop` is hot
|
|
28
|
+
# reload), so the developer meets that loop constantly — and meets it
|
|
29
|
+
# while changing something else, which is the worst possible time to
|
|
30
|
+
# debug it. Redis is already running and already required by three other
|
|
31
|
+
# stores, so this costs nothing: the shared ioredis socket is open
|
|
32
|
+
# either way.
|
|
33
|
+
USER_SESSION_STORES_ADAPTER: redis
|
|
17
34
|
|
|
18
35
|
redis:
|
|
19
36
|
# Tag-pinned, not digest-pinned. The Dockerfile argues for digests (#289)
|
|
@@ -0,0 +1,100 @@
|
|
|
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
|
+
/**
|
|
18
|
+
* `standaloneRedisClientsModule` provides `deviceCodeStoreClient` (#433).
|
|
19
|
+
*
|
|
20
|
+
* The template does not mount the device grant, so nothing in `buildModules`
|
|
21
|
+
* selects `redisDeviceCodeStoreModule` today and the smoke test's client-slot
|
|
22
|
+
* invariant cannot see it. This pins the slot directly: a deployment that adds
|
|
23
|
+
* `deviceGrantModule` to this manifest and picks the Redis store must not hit
|
|
24
|
+
* the `missing-required-component` boot failure that #439 already paid for
|
|
25
|
+
* once with the subject-level slots — a Redis-branch module whose required
|
|
26
|
+
* client slot the shared clients module did not provide, caught only by the
|
|
27
|
+
* umbrella e2e.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { LifecycleRegistrar } from "@o3co/auth-provider-core";
|
|
31
|
+
import { redisDeviceCodeStoreModule } from "@o3co/auth-provider-redis";
|
|
32
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
33
|
+
|
|
34
|
+
const redisCtorCalls: Array<{ url: string }> = [];
|
|
35
|
+
|
|
36
|
+
vi.mock("ioredis", () => {
|
|
37
|
+
class MockRedis {
|
|
38
|
+
on = vi.fn();
|
|
39
|
+
quit = vi.fn(async () => "OK" as const);
|
|
40
|
+
ping = vi.fn(async () => "PONG" as const);
|
|
41
|
+
eval = vi.fn();
|
|
42
|
+
evalsha = vi.fn();
|
|
43
|
+
duplicate(): MockRedis {
|
|
44
|
+
return new MockRedis("redis://duplicate.local");
|
|
45
|
+
}
|
|
46
|
+
constructor(url: string) {
|
|
47
|
+
redisCtorCalls.push({ url });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { Redis: MockRedis, default: MockRedis };
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const importModule = async () => await import("../modules.mjs");
|
|
54
|
+
|
|
55
|
+
const baseConfig = {
|
|
56
|
+
refreshTokenFamilyStore: {
|
|
57
|
+
redis: { url: "redis://example.com:6379" },
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
describe("#433 / standaloneRedisClientsModule.deviceCodeStoreClient", () => {
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
redisCtorCalls.length = 0;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
vi.restoreAllMocks();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("provides every *Client slot redisDeviceCodeStoreModule requires", async () => {
|
|
71
|
+
const { standaloneRedisClientsModule } = await importModule();
|
|
72
|
+
const provided = new Set(Object.keys(standaloneRedisClientsModule.provides ?? {}));
|
|
73
|
+
const required = [...(redisDeviceCodeStoreModule.requires ?? [])].filter((key) =>
|
|
74
|
+
key.endsWith("Client"),
|
|
75
|
+
);
|
|
76
|
+
expect(required).toEqual(["deviceCodeStoreClient"]);
|
|
77
|
+
expect(required.filter((key) => !provided.has(key))).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("resolves the client off the one shared socket, not a second connection", async () => {
|
|
81
|
+
const { standaloneRedisClientsModule } = await importModule();
|
|
82
|
+
const provides = (
|
|
83
|
+
standaloneRedisClientsModule as unknown as {
|
|
84
|
+
provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
|
|
85
|
+
}
|
|
86
|
+
).provides;
|
|
87
|
+
const lifecycleRegistrar: LifecycleRegistrar = { register: () => {} };
|
|
88
|
+
|
|
89
|
+
const client = (await provides.deviceCodeStoreClient?.({
|
|
90
|
+
config: { ...baseConfig },
|
|
91
|
+
lifecycleRegistrar,
|
|
92
|
+
})) as Record<string, unknown>;
|
|
93
|
+
await provides.rateLimiterClient?.({ config: { ...baseConfig }, lifecycleRegistrar });
|
|
94
|
+
|
|
95
|
+
expect(redisCtorCalls).toHaveLength(1);
|
|
96
|
+
for (const method of ["create", "findPending", "decide", "poll", "remove"]) {
|
|
97
|
+
expect(typeof client[method], `deviceCodeStoreClient.${method}`).toBe("function");
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
});
|