@o3co/create-auth-provider 0.10.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.
Files changed (28) hide show
  1. package/dist/index.d.mts.map +1 -1
  2. package/dist/index.mjs +11 -1
  3. package/package.json +2 -2
  4. package/templates/standalone/.env.example +19 -1
  5. package/templates/standalone/Dockerfile +9 -1
  6. package/templates/standalone/README.ja.md +24 -4
  7. package/templates/standalone/README.md +175 -21
  8. package/templates/standalone/config/application.conf +28 -0
  9. package/templates/standalone/config/clients.yaml.example +15 -2
  10. package/templates/standalone/config/production.conf +9 -3
  11. package/templates/standalone/docker-compose.production.yml +80 -5
  12. package/templates/standalone/docker-compose.yml +24 -0
  13. package/templates/standalone/gitignore +21 -0
  14. package/templates/standalone/package.json +5 -6
  15. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +100 -0
  16. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +71 -4
  17. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +179 -0
  18. package/templates/standalone/src/__tests__/replica-safety.test.mts +456 -0
  19. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +246 -0
  20. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +288 -0
  21. package/templates/standalone/src/__tests__/shutdown.test.mts +324 -0
  22. package/templates/standalone/src/__tests__/smoke.test.mts +36 -0
  23. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +12 -7
  24. package/templates/standalone/src/app.mts +16 -3
  25. package/templates/standalone/src/buildModules.mts +54 -16
  26. package/templates/standalone/src/modules.mts +128 -24
  27. package/templates/standalone/src/shutdown.mts +219 -0
  28. package/templates/versions.json +13 -11
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../src/index.mts"],"names":[],"mappings":"AAoCA,eAAO,MAAM,kBAAkB,GAAI,MAAM,MAAM,KAAG,OAIjD,CAAC;AAEF,eAAO,MAAM,cAAc,GAAI,MAAM,MAAM,KAAG,OAI7C,CAAC;AAEF,eAAO,MAAM,QAAQ,GAAI,WAAW,MAAM,EAAE,aAAa,MAAM,KAAG,IA8DjE,CAAC;AAEF,wDAAwD;AACxD,MAAM,MAAM,cAAc,GACvB;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAC/C;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAqBnD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,gBAAgB,GAAI,WAAW,MAAM,KAAG,cA6BpD,CAAC;AA0DF,eAAO,MAAM,IAAI,QAAO,IAmEvB,CAAC"}
1
+ {"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../src/index.mts"],"names":[],"mappings":"AAoCA,eAAO,MAAM,kBAAkB,GAAI,MAAM,MAAM,KAAG,OAIjD,CAAC;AAEF,eAAO,MAAM,cAAc,GAAI,MAAM,MAAM,KAAG,OAI7C,CAAC;AAEF,eAAO,MAAM,QAAQ,GAAI,WAAW,MAAM,EAAE,aAAa,MAAM,KAAG,IAyEjE,CAAC;AAEF,wDAAwD;AACxD,MAAM,MAAM,cAAc,GACvB;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAC/C;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAqBnD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,gBAAgB,GAAI,WAAW,MAAM,KAAG,cA6BpD,CAAC;AA0DF,eAAO,MAAM,IAAI,QAAO,IAmEvB,CAAC"}
package/dist/index.mjs CHANGED
@@ -14,7 +14,7 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  import { spawnSync } from "node:child_process";
17
- import { cpSync, existsSync, readFileSync, writeFileSync } from "node:fs";
17
+ import { cpSync, existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
18
18
  import { dirname, resolve } from "node:path";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { shouldCopyTemplateEntry } from "./internal/template-filter.mjs";
@@ -53,6 +53,16 @@ export const scaffold = (targetDir, projectName) => {
53
53
  recursive: true,
54
54
  filter: (source) => shouldCopyTemplateEntry(source, TEMPLATES_DIR),
55
55
  });
56
+ // #407: npm drops a file literally named `.gitignore` from a published
57
+ // package, so `copy-templates.mjs` stages it dot-less and it is restored
58
+ // here. Without it the first `git add .` in a scaffolded project commits
59
+ // the `.env` and the signing key the README's own setup steps say to
60
+ // create — and it would have shipped that way silently, because the file
61
+ // is present in the repository and only missing from the tarball.
62
+ const stagedGitignore = resolve(targetDir, "gitignore");
63
+ if (existsSync(stagedGitignore)) {
64
+ renameSync(stagedGitignore, resolve(targetDir, ".gitignore"));
65
+ }
56
66
  // Rewrite package.json
57
67
  const pkgPath = resolve(targetDir, "package.json");
58
68
  const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/create-auth-provider",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Scaffold a new o3co/auth.provider project from the standalone template",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/node": "^26.2.0",
32
- "vitest": "^4.1.10"
32
+ "vitest": "^4.1.11"
33
33
  },
34
34
  "scripts": {
35
35
  "build": "tsc",
@@ -40,10 +40,28 @@ SESSION_SECRET=
40
40
  CLIENT_USER_AUTHENTICATE_URL=
41
41
  CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL=
42
42
 
43
- # Redis (code repository + session storage)
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
+
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
46
59
  SESSION_STORAGE_REDIS_URL=redis://redis:6379
60
+ # #407: this one backs the SHARED ioredis socket -- the refresh-token family
61
+ # store, the access-token denylist and the rate limiter all ride on it. It
62
+ # defaults to redis://localhost:6379, which inside the container is the
63
+ # container, so leaving it unset makes the compose boot dial nothing.
64
+ REFRESH_TOKEN_FAMILY_STORE_REDIS_URL=redis://redis:6379
47
65
 
48
66
  # Development (override for local HTTP).
49
67
  # The default cookie name carries the `__Host-` prefix, which browsers only
@@ -24,7 +24,15 @@ FROM node-base AS deps
24
24
  # it at scaffold time and the project commits it. Without it this COPY fails,
25
25
  # which is the intended outcome — a build that silently re-resolves every
26
26
  # dependency range is the thing this file stopped doing (#289).
27
- COPY --chown=node:node package.json pnpm-lock.yaml ./
27
+ #
28
+ # pnpm-workspace.yaml is required for the same reason and was missing (#407):
29
+ # the scaffolder generates it to carry bcrypt's `onlyBuiltDependencies`
30
+ # allowlist, and pnpm >= 10.29 reads that setting ONLY from this file, in
31
+ # single-package projects too (#360). Installing without it runs
32
+ # allowlist-less, so pnpm silently skips bcrypt's build hook — which costs
33
+ # nothing while an alpine prebuild exists, and re-creates #360 the day one
34
+ # is missing and the native addon has to compile.
35
+ COPY --chown=node:node package.json pnpm-lock.yaml pnpm-workspace.yaml ./
28
36
 
29
37
  USER node
30
38
 
@@ -42,7 +42,7 @@ overlay の値は `application.conf` を上書きします。scaffold には
42
42
  | 変数 | デフォルト | 説明 |
43
43
  |---|---|---|
44
44
  | `HTTP_PORT` | `3000` | サーバーがリッスンするポート |
45
- | `HTTP_TRUST_PROXY` | `false` | リバースプロキシの背後で動作する場合は `true` に設定。**ロードバランサ配下では必須** — 未設定だと `req.ip` が LB のアドレスになり、IP をキーとする全ての rate limit が全ユーザーで 1 バケットを共有する |
45
+ | `HTTP_TRUST_PROXY` | `false` | Express の `trust proxy`。`false` / アドレス・CIDR のリスト (`10.0.0.0/8,loopback`) / ホップ数 (`1`) / `true` のいずれか。**ロードバランサ配下では必須** — 未設定だと `req.ip` が LB のアドレスになり、IP をキーとする全ての rate limit が全ユーザーで 1 バケットを共有する。`true` ではなく**実際のプロキシを名指しする**こと (#292): `true` は「接続してきた相手が誰であれ、その X-Forwarded-For の先頭を信じる」という意味で、プロセスに直接到達できる相手が自分で `req.ip` を選べてしまう。詳細は [Multi-replica deployments](#multi-replica-deployments) |
46
46
 
47
47
  ### OAuth JWT
48
48
 
@@ -114,8 +114,16 @@ openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
114
114
 
115
115
  設定上の注意が 2 点:
116
116
 
117
- - TLS 終端プロキシの背後では `HTTP_TRUST_PROXY=true` を設定する。設定しないと `req.protocol` は `http` のままでブラウザは `Origin: https://…` を送るため、origin 側の判定が全リクエストを拒否する。
118
- - ログイン UI をプロバイダーと**別 origin** で配信している場合は、その origin を HOCON の `session.csrf.trustedOrigins` に列挙する。`cors.allowedOrigins` は CSRF 信頼を与えなくなった。
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 信頼を与えない。
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
 
@@ -271,9 +279,21 @@ const handle = await createApp({
271
279
 
272
280
  app.use(handle.router);
273
281
  const server = app.listen(config.http.port);
274
- gracefulShutdown(server, () => handle.dispose());
282
+ installGracefulShutdown(server, { logger, cleanup: () => handle.dispose() });
275
283
  ```
276
284
 
285
+ ### シャットダウンの保証
286
+
287
+ `src/shutdown.mts` は依存パッケージではなく scaffold 側に置いてある (#290)。全ユーザーセッションを終端するコンポーネントについて「SIGTERM は in-flight リクエストを待つのか、待つなら何秒か」は、デプロイするコード自体から答えられる必要があるため。
288
+
289
+ 1. **`SIGTERM` / `SIGINT`** のどちらでも開始する。2 回目のシグナルは無視する(1 回目の dispose に重ねて 2 回目を走らせない)。
290
+ 2. **新規接続は即座に停止**し、idle な keep-alive ソケットは解放する。リクエストを持たないまま server を開いたままにするソケットなので、解放しないと閑散時に deadline を丸ごと待つことになる。
291
+ 3. **in-flight リクエストには `drainTimeoutMs`**(既定 **10 秒**)を与える。
292
+ 4. **deadline を超えたら残接続を切り、プロセスは非ゼロ終了する。** 常に `0` しか見えない orchestrator では、正常な drain と時間切れの強制切断を区別できない。
293
+ 5. **`cleanup` は drain 後・exit 前**に走る(`handle.dispose()` = 逆トポロジカルなコンポーネント cleanup + Redis/タイマーの drain)。失敗はこのサービス自身の logger(他の行と同じ NDJSON)に出し、exit code にも反映する。dispose が throw してもプロセスは終了する。
294
+
295
+ **`drainTimeoutMs` は orchestrator の kill grace period より短く設定すること** — Kubernetes の `terminationGracePeriodSeconds`、compose の `stop_grace_period` はいずれも既定 30 秒。他人の都合の `SIGKILL` が来る前に、自分の都合で閉じるのが目的。
296
+
277
297
  ## npm スクリプト
278
298
 
279
299
  | スクリプト | 説明 |
@@ -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
@@ -460,9 +601,21 @@ const handle = await createApp({
460
601
 
461
602
  app.use(handle.router);
462
603
  const server = app.listen(config.http.port);
463
- gracefulShutdown(server, () => handle.dispose());
604
+ installGracefulShutdown(server, { logger, cleanup: () => handle.dispose() });
464
605
  ```
465
606
 
607
+ ### Shutdown guarantees
608
+
609
+ `src/shutdown.mts` is deliberately part of the scaffold rather than a dependency (#290): for the component that terminates every user session, *"does SIGTERM wait for in-flight requests, and for how long?"* has to be answerable from the code you deploy.
610
+
611
+ 1. **`SIGTERM` and `SIGINT`** both start it. A second signal is ignored rather than starting a second dispose over the first one's stores.
612
+ 2. **New connections stop immediately**, and idle keep-alive sockets are released — they hold the server open with no request behind them, so a quiet server would otherwise wait out the whole deadline for nothing.
613
+ 3. **In-flight requests get `drainTimeoutMs`** (default **10s**) to finish.
614
+ 4. **Past the deadline the remaining connections are cut and the process exits non-zero.** An orchestrator that only ever sees `0` cannot tell a clean drain from one that ran out of time.
615
+ 5. **`cleanup` runs after draining, before exit** — `handle.dispose()`, i.e. reverse-topological component cleanup plus the Redis/timer drain. A failure there is logged through this service's own logger (NDJSON, like every other line) and reflected in the exit code. A dispose that throws still exits; it never wedges the process.
616
+
617
+ **Size `drainTimeoutMs` below your orchestrator's kill grace period** — Kubernetes `terminationGracePeriodSeconds` and compose `stop_grace_period` are both 30s by default. The point is to close on your terms before `SIGKILL` arrives on someone else's.
618
+
466
619
  ## npm Scripts
467
620
 
468
621
  | Script | Description |
@@ -474,6 +627,7 @@ gracefulShutdown(server, () => handle.dispose());
474
627
 
475
628
  ## See Also
476
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
477
631
  - [`@o3co/auth-provider-core`](../../packages/core) — Application factory and config schema
478
632
  - [`@o3co/auth-provider-oauth`](../../packages/oauth) — OAuth module
479
633
  - [`@o3co/auth-provider-session`](../../packages/session) — Session module
@@ -149,6 +149,17 @@ session {
149
149
  sameSite = ${?SESSION_SAME_SITE}
150
150
  domain = null
151
151
  domain = ${?SESSION_DOMAIN}
152
+ # #405: every URL `POST /session/login` may accept as `redirect_to`, matched
153
+ # EXACTLY -- scheme, host, port, path and query all significant. Empty (the
154
+ # default) refuses every `redirect_to`, so leave it alone unless your login
155
+ # page actually posts one.
156
+ #
157
+ # redirectAllowlist = ["https://app.example.com/welcome"]
158
+ #
159
+ # `http://` is accepted for loopback hosts only. With `domain` set above, an
160
+ # entry outside it fails boot rather than being silently ignored. The
161
+ # federation flow has a separate list per provider (see `federations` below).
162
+ redirectAllowlist = []
152
163
  storage {
153
164
  type = "redis"
154
165
  type = ${?SESSION_STORAGE_TYPE}
@@ -280,8 +291,25 @@ endpoints {
280
291
  # production consumer reads them.
281
292
  }
282
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).
283
310
  cors {
284
311
  allowedOrigins = []
312
+ allowedOrigins = ${?CORS_ALLOWED_ORIGINS}
285
313
  }
286
314
 
287
315
  # D-2 v2 + Wave 5d: ioredis connection-config consumed by the shared
@@ -7,8 +7,21 @@
7
7
  # - "none": public client — no clientSecret; PKCE/S256 mandatory at /authorize
8
8
  #
9
9
  # Secret format (only for confidential clients — basic / post):
10
- # - Plain text: "my-secret"
11
- # - Bcrypt hash: "$2b$10$..." (generate with: node -e "require('bcrypt').hash('secret',10).then(console.log)")
10
+ # - Bcrypt hash: "$2b$10$..." <-- use this
11
+ # - Plain text: "my-secret" <-- accepted, for local development only
12
+ #
13
+ # Generate a real one:
14
+ # SECRET=$(openssl rand -base64 32)
15
+ # node -e "require('bcrypt').hash(process.argv[1],10).then(console.log)" "$SECRET"
16
+ # Give $SECRET to the client; put the hash in this file.
17
+ #
18
+ # #407: the schema accepts any non-empty string here, unlike `session.secret`
19
+ # and the JWT signing secret, which have carried a 256-bit floor since #282.
20
+ # That is deliberate — this field also holds a bcrypt hash, whose shape those
21
+ # floors would reject — but it means NOTHING stops you from shipping
22
+ # `clientSecret: "changeme"`. A client secret authenticates a confidential
23
+ # client at the token endpoint; treat it as you would any other credential
24
+ # and give it 256 bits of randomness.
12
25
  #
13
26
  # `firstParty` (#267): REQUIRED to be `true` for a client to use `/authorize`.
14
27
  # `/authorize` mints a code as soon as the session is authenticated, with no
@@ -2,9 +2,15 @@
2
2
  # Loaded when CONFIG_ENV (or NODE_ENV) is "production".
3
3
  # Values here override application.conf.
4
4
  #
5
- # Most production-sensitive values (OAUTH_JWT_SECRET, SESSION_SECRET, REDIS URLs,
6
- # federation client secrets, etc.) should be supplied via environment variables —
7
- # see the ${?VAR} substitutions in application.conf.
5
+ # Most production-sensitive values (SESSION_SECRET, the JWT signing key, REDIS
6
+ # URLs, federation client secrets, etc.) should be supplied via environment
7
+ # variables — see the ${?VAR} substitutions in application.conf.
8
+ #
9
+ # #407: this used to name OAUTH_JWT_SECRET as the typical example. That is the
10
+ # HMAC secret, and it stopped being the default when EdDSA became one (#282) —
11
+ # the shipped path is now a key pair delivered as files
12
+ # (OAUTH_JWT_PRIVATE_KEY_PATH / OAUTH_JWT_PUBLIC_KEY_PATH), which
13
+ # docker-compose.production.yml mounts as compose secrets.
8
14
  #
9
15
  # Override entries here only when a value must be fixed per-environment rather
10
16
  # than per-deployment (e.g. feature flags turned on only in production).