@o3co/create-auth-provider 0.11.0 → 0.12.1

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.11.0",
3
+ "version": "0.12.1",
4
4
  "description": "Scaffold a new o3co/auth.provider project from the standalone template",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -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** `^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.
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
- **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. 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.
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 wired via `redisSessionStoresModule`; set the corresponding session-store Redis URL (per `session.storage.redis.url`).
62
- - The code repository defaults to Redis when `repositories.code.type = "redis"`; set `CLIENT_CODE_ENDPOINT_URI` to a shared Redis instance.
63
- - The federation token store can be backed by Redis via `federationTokenStore.type = "redis"` (deferred to operator config; default is in-memory).
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` no longer confers CSRF trust.
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` | Authorization code store backend: `redis` |
242
- | `CLIENT_CODE_ENDPOINT_URI` | — | Redis connection URI for code storage |
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:code:`.
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
- Modules are registered in the following order. Each module registers its own routes and grant types.
274
-
275
- 1. **`oauthModule`** — Core OAuth 2.0 endpoints: `POST /oauth/token`, `POST /oauth/introspect`, `GET /oauth/authorize`
276
- 2. **`sessionModule`** — Session-based login/logout and federation: `POST /session/login`, `POST /session/logout`, `GET/POST /session/oauth/federation/*`
277
- 3. **`oauthSessionModule`** — Session grant type (exchanges a valid session for a token)
278
- 4. **`oauthAuthorizationModule`** — Authorization code and refresh token grant types
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
- The following routes are registered outside of the module system:
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` | `/health` | Health check endpoint |
287
- | `GET` | `/.well-known/jwks.json` | Public key set (JWKS) for token verification |
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 plus `HTTP_TRUST_PROXY`, and the [multi-replica](#multi-replica-deployments) steps before any `--scale`.
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), and set
23
- # HTTP_TRUST_PROXY to that hop's address so `req.ip` — and every IP-keyed
24
- # rate limit — means the client, not the proxy. See README
25
- # "Multi-replica deployments".
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)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/auth-provider-standalone",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -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
+ });