@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.
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +11 -1
- package/package.json +2 -2
- package/templates/standalone/.env.example +19 -1
- package/templates/standalone/Dockerfile +9 -1
- package/templates/standalone/README.ja.md +24 -4
- package/templates/standalone/README.md +175 -21
- package/templates/standalone/config/application.conf +28 -0
- package/templates/standalone/config/clients.yaml.example +15 -2
- package/templates/standalone/config/production.conf +9 -3
- package/templates/standalone/docker-compose.production.yml +80 -5
- package/templates/standalone/docker-compose.yml +24 -0
- package/templates/standalone/gitignore +21 -0
- package/templates/standalone/package.json +5 -6
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +100 -0
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +71 -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 +246 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +288 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +324 -0
- package/templates/standalone/src/__tests__/smoke.test.mts +36 -0
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +12 -7
- package/templates/standalone/src/app.mts +16 -3
- package/templates/standalone/src/buildModules.mts +54 -16
- package/templates/standalone/src/modules.mts +128 -24
- package/templates/standalone/src/shutdown.mts +219 -0
- package/templates/versions.json +13 -11
package/dist/index.d.mts.map
CHANGED
|
@@ -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,
|
|
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.
|
|
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.
|
|
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
|
-
#
|
|
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
|
-
|
|
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` |
|
|
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
|
|
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
|
-
|
|
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** `^
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
# -
|
|
11
|
-
# -
|
|
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 (
|
|
6
|
-
# federation client secrets, etc.) should be supplied via environment
|
|
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).
|