@o3co/create-auth-provider 0.10.0 → 0.11.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 +6 -1
- package/templates/standalone/Dockerfile +9 -1
- package/templates/standalone/README.ja.md +15 -3
- package/templates/standalone/README.md +13 -1
- package/templates/standalone/config/application.conf +11 -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 +17 -1
- package/templates/standalone/docker-compose.yml +7 -0
- package/templates/standalone/gitignore +21 -0
- package/templates/standalone/package.json +5 -6
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +4 -0
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +117 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +244 -0
- package/templates/standalone/src/__tests__/smoke.test.mts +36 -0
- package/templates/standalone/src/app.mts +11 -2
- package/templates/standalone/src/modules.mts +40 -2
- package/templates/standalone/src/shutdown.mts +162 -0
- package/templates/versions.json +12 -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.11.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,15 @@ 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
|
+
# Redis (code repository + session storage + the shared client socket)
|
|
44
44
|
# Use redis://redis:6379 when running via docker compose
|
|
45
45
|
CLIENT_CODE_ENDPOINT_URI=redis://redis:6379
|
|
46
46
|
SESSION_STORAGE_REDIS_URL=redis://redis:6379
|
|
47
|
+
# #407: this one backs the SHARED ioredis socket -- the refresh-token family
|
|
48
|
+
# store, the access-token denylist and the rate limiter all ride on it. It
|
|
49
|
+
# defaults to redis://localhost:6379, which inside the container is the
|
|
50
|
+
# container, so leaving it unset makes the compose boot dial nothing.
|
|
51
|
+
REFRESH_TOKEN_FAMILY_STORE_REDIS_URL=redis://redis:6379
|
|
47
52
|
|
|
48
53
|
# Development (override for local HTTP).
|
|
49
54
|
# 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,7 +114,7 @@ openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
|
|
|
114
114
|
|
|
115
115
|
設定上の注意が 2 点:
|
|
116
116
|
|
|
117
|
-
- TLS 終端プロキシの背後では `HTTP_TRUST_PROXY
|
|
117
|
+
- TLS 終端プロキシの背後では `HTTP_TRUST_PROXY` にそのプロキシのアドレス/CIDR を設定する (例: `HTTP_TRUST_PROXY=10.0.0.0/8`)。設定しないと `req.protocol` は `http` のままでブラウザは `Origin: https://…` を送るため、origin 側の判定が全リクエストを拒否する。`true` は「プロキシ以外が絶対にプロセスへ到達できない」と言い切れる場合にのみ正しい (#292) — 英語版 README も同じ指針。
|
|
118
118
|
- ログイン UI をプロバイダーと**別 origin** で配信している場合は、その origin を HOCON の `session.csrf.trustedOrigins` に列挙する。`cors.allowedOrigins` は CSRF 信頼を与えなくなった。
|
|
119
119
|
|
|
120
120
|
### Google フェデレーション
|
|
@@ -271,9 +271,21 @@ const handle = await createApp({
|
|
|
271
271
|
|
|
272
272
|
app.use(handle.router);
|
|
273
273
|
const server = app.listen(config.http.port);
|
|
274
|
-
|
|
274
|
+
installGracefulShutdown(server, { logger, cleanup: () => handle.dispose() });
|
|
275
275
|
```
|
|
276
276
|
|
|
277
|
+
### シャットダウンの保証
|
|
278
|
+
|
|
279
|
+
`src/shutdown.mts` は依存パッケージではなく scaffold 側に置いてある (#290)。全ユーザーセッションを終端するコンポーネントについて「SIGTERM は in-flight リクエストを待つのか、待つなら何秒か」は、デプロイするコード自体から答えられる必要があるため。
|
|
280
|
+
|
|
281
|
+
1. **`SIGTERM` / `SIGINT`** のどちらでも開始する。2 回目のシグナルは無視する(1 回目の dispose に重ねて 2 回目を走らせない)。
|
|
282
|
+
2. **新規接続は即座に停止**し、idle な keep-alive ソケットは解放する。リクエストを持たないまま server を開いたままにするソケットなので、解放しないと閑散時に deadline を丸ごと待つことになる。
|
|
283
|
+
3. **in-flight リクエストには `drainTimeoutMs`**(既定 **10 秒**)を与える。
|
|
284
|
+
4. **deadline を超えたら残接続を切り、プロセスは非ゼロ終了する。** 常に `0` しか見えない orchestrator では、正常な drain と時間切れの強制切断を区別できない。
|
|
285
|
+
5. **`cleanup` は drain 後・exit 前**に走る(`handle.dispose()` = 逆トポロジカルなコンポーネント cleanup + Redis/タイマーの drain)。失敗はこのサービス自身の logger(他の行と同じ NDJSON)に出し、exit code にも反映する。dispose が throw してもプロセスは終了する。
|
|
286
|
+
|
|
287
|
+
**`drainTimeoutMs` は orchestrator の kill grace period より短く設定すること** — Kubernetes の `terminationGracePeriodSeconds`、compose の `stop_grace_period` はいずれも既定 30 秒。他人の都合の `SIGKILL` が来る前に、自分の都合で閉じるのが目的。
|
|
288
|
+
|
|
277
289
|
## npm スクリプト
|
|
278
290
|
|
|
279
291
|
| スクリプト | 説明 |
|
|
@@ -460,9 +460,21 @@ const handle = await createApp({
|
|
|
460
460
|
|
|
461
461
|
app.use(handle.router);
|
|
462
462
|
const server = app.listen(config.http.port);
|
|
463
|
-
|
|
463
|
+
installGracefulShutdown(server, { logger, cleanup: () => handle.dispose() });
|
|
464
464
|
```
|
|
465
465
|
|
|
466
|
+
### Shutdown guarantees
|
|
467
|
+
|
|
468
|
+
`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.
|
|
469
|
+
|
|
470
|
+
1. **`SIGTERM` and `SIGINT`** both start it. A second signal is ignored rather than starting a second dispose over the first one's stores.
|
|
471
|
+
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.
|
|
472
|
+
3. **In-flight requests get `drainTimeoutMs`** (default **10s**) to finish.
|
|
473
|
+
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.
|
|
474
|
+
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.
|
|
475
|
+
|
|
476
|
+
**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.
|
|
477
|
+
|
|
466
478
|
## npm Scripts
|
|
467
479
|
|
|
468
480
|
| Script | Description |
|
|
@@ -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}
|
|
@@ -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).
|
|
@@ -38,7 +38,16 @@ services:
|
|
|
38
38
|
target: runtime
|
|
39
39
|
restart: unless-stopped
|
|
40
40
|
ports:
|
|
41
|
-
-
|
|
41
|
+
# Loopback-bound on purpose (#407). This file assumes TLS is terminated
|
|
42
|
+
# in front, and the README says to keep `/metrics` off the public
|
|
43
|
+
# listener — a bare "3000:3000" publishes on every interface and
|
|
44
|
+
# contradicts both. A reverse proxy on the same host reaches it here.
|
|
45
|
+
#
|
|
46
|
+
# If your load balancer is on another host, publish it instead
|
|
47
|
+
# (`- "3000:3000"`) and put the access control somewhere real: a
|
|
48
|
+
# security group, a firewall rule, or the proxy's own network. Do not
|
|
49
|
+
# widen this line and call it done.
|
|
50
|
+
- "127.0.0.1:3000:3000"
|
|
42
51
|
depends_on:
|
|
43
52
|
redis:
|
|
44
53
|
condition: service_healthy
|
|
@@ -88,6 +97,13 @@ services:
|
|
|
88
97
|
RATE_LIMITER_ADAPTER: redis
|
|
89
98
|
|
|
90
99
|
redis:
|
|
100
|
+
# Tag-pinned, not digest-pinned. The Dockerfile argues for digests (#289)
|
|
101
|
+
# and that argument holds here too — a tag is mutable, so two `up`s a month
|
|
102
|
+
# apart can run different Redis builds. Pin the digest for a production
|
|
103
|
+
# deployment you need to reproduce:
|
|
104
|
+
# image: redis:7.2-alpine@sha256:<digest>
|
|
105
|
+
# Left as a tag in the template because a digest goes stale the week it is
|
|
106
|
+
# written and a scaffold that ships a stale one teaches nothing (#407).
|
|
91
107
|
image: redis:7.2-alpine
|
|
92
108
|
restart: unless-stopped
|
|
93
109
|
# No `ports:`, deliberately — the app reaches Redis over the compose
|
|
@@ -16,6 +16,13 @@ services:
|
|
|
16
16
|
required: false
|
|
17
17
|
|
|
18
18
|
redis:
|
|
19
|
+
# Tag-pinned, not digest-pinned. The Dockerfile argues for digests (#289)
|
|
20
|
+
# and that argument holds here too — a tag is mutable, so two `up`s a month
|
|
21
|
+
# apart can run different Redis builds. Pin the digest for a production
|
|
22
|
+
# deployment you need to reproduce:
|
|
23
|
+
# image: redis:7.2-alpine@sha256:<digest>
|
|
24
|
+
# Left as a tag in the template because a digest goes stale the week it is
|
|
25
|
+
# written and a scaffold that ships a stale one teaches nothing (#407).
|
|
19
26
|
image: redis:7.2-alpine
|
|
20
27
|
ports:
|
|
21
28
|
- "127.0.0.1:6379:6379"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# #407: without this file, the first `git add .` in a scaffolded project
|
|
2
|
+
# commits the two things the README's own setup steps tell you to create right
|
|
3
|
+
# here — the `.env` holding SESSION_SECRET, and the signing key pair.
|
|
4
|
+
|
|
5
|
+
# Secrets and per-machine configuration
|
|
6
|
+
.env
|
|
7
|
+
.env.*
|
|
8
|
+
!.env.example
|
|
9
|
+
*.pem
|
|
10
|
+
*.key
|
|
11
|
+
config/clients.yaml
|
|
12
|
+
config/*.local.conf
|
|
13
|
+
|
|
14
|
+
# Build output and dependencies
|
|
15
|
+
node_modules/
|
|
16
|
+
dist/
|
|
17
|
+
coverage/
|
|
18
|
+
|
|
19
|
+
# Editor / OS noise
|
|
20
|
+
.DS_Store
|
|
21
|
+
*.log
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@o3co/auth-provider-standalone",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"engines": {
|
|
@@ -27,8 +27,7 @@
|
|
|
27
27
|
"@o3co/auth-provider-oauth": "workspace:*",
|
|
28
28
|
"@o3co/auth-provider-redis": "workspace:*",
|
|
29
29
|
"@o3co/auth-provider-session": "workspace:*",
|
|
30
|
-
"@o3co/
|
|
31
|
-
"@o3co/ts.hocon": "^1.12.0",
|
|
30
|
+
"@o3co/ts.hocon": "^1.13.0",
|
|
32
31
|
"connect-redis": "^10.0.0",
|
|
33
32
|
"express": "^5.2.1",
|
|
34
33
|
"express-session": "^1.19.0",
|
|
@@ -36,7 +35,7 @@
|
|
|
36
35
|
"ioredis": "^6.0.0",
|
|
37
36
|
"pino": "^10.3.1",
|
|
38
37
|
"prom-client": "^15.1.3",
|
|
39
|
-
"redis": "^6.2.
|
|
38
|
+
"redis": "^6.2.1"
|
|
40
39
|
},
|
|
41
40
|
"devDependencies": {
|
|
42
41
|
"@types/express": "^5.0.6",
|
|
@@ -45,7 +44,7 @@
|
|
|
45
44
|
"@types/supertest": "^7.2.1",
|
|
46
45
|
"supertest": "^7.1.0",
|
|
47
46
|
"tsx": "^4.23.10",
|
|
48
|
-
"typescript": "^
|
|
49
|
-
"vitest": "^4.1.
|
|
47
|
+
"typescript": "^6.0.3",
|
|
48
|
+
"vitest": "^4.1.11"
|
|
50
49
|
}
|
|
51
50
|
}
|
|
@@ -88,11 +88,15 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
|
|
|
88
88
|
OAUTH_OIDC_MODE: "dual",
|
|
89
89
|
OAUTH_CODE_ADAPTER: "redis",
|
|
90
90
|
OAUTH_REVOCATION_ACCESS_TOKEN: "denylist",
|
|
91
|
+
OAUTH_REVOCATION_SUBJECT: "unsupported",
|
|
91
92
|
OAUTH_REQUIRE_EMAIL_VERIFIED: "true",
|
|
93
|
+
OAUTH_REQUIRE_GRANT_TYPE_ALLOWLIST: "true",
|
|
94
|
+
OAUTH_GRANTS_JWT_BEARER_ENABLED: "false",
|
|
92
95
|
OAUTH_NONCE_MAX_LENGTH: "256",
|
|
93
96
|
OAUTH_TOKEN_EXCHANGE_MAX_ACTOR_CHAIN_DEPTH: "3",
|
|
94
97
|
OAUTH_RESOURCE_INDICATOR_ENABLED: "true",
|
|
95
98
|
OAUTH_TOKEN_BINDING_DISPATCH_POLICY: "intent-explicit",
|
|
99
|
+
OAUTH_TOKEN_BINDING_BIND_CONFIDENTIAL_CLIENT_REFRESH_TOKENS: "true",
|
|
96
100
|
|
|
97
101
|
// --- oauth.grants -------------------------------------------------
|
|
98
102
|
OAUTH_GRANTS_SESSION_ENABLED: "false",
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 1o1 Co. Ltd.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Issue #407 — scaffold hardening from the v0.10.0 release-cut audit.
|
|
19
|
+
*
|
|
20
|
+
* These pin the parts of the scaffold that are checkable from inside the
|
|
21
|
+
* repository. The scaffold is the artifact an operator actually deploys, and
|
|
22
|
+
* every one of these was invisible until someone read the file: the container
|
|
23
|
+
* install losing an allowlist, the dev compose dialling a Redis that is not
|
|
24
|
+
* there, and one `git add .` committing the signing key.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
import { describe, expect, it } from "vitest";
|
|
30
|
+
|
|
31
|
+
const standaloneDir = fileURLToPath(new URL("../..", import.meta.url));
|
|
32
|
+
const read = (rel: string): string => readFileSync(`${standaloneDir}${rel}`, "utf8");
|
|
33
|
+
|
|
34
|
+
describe("#407 — the Dockerfile installs with everything pnpm needs", () => {
|
|
35
|
+
it("copies pnpm-workspace.yaml into the deps stage", () => {
|
|
36
|
+
// `create-auth-provider` generates it to carry the bcrypt
|
|
37
|
+
// `onlyBuiltDependencies` allowlist, because pnpm >= 10.29 reads that
|
|
38
|
+
// setting ONLY from this file — in single-package projects too (#360).
|
|
39
|
+
// A deps stage that copies `package.json` and the lockfile but not this
|
|
40
|
+
// runs allowlist-less, which re-creates #360 the day an alpine prebuild
|
|
41
|
+
// is missing and bcrypt has to compile.
|
|
42
|
+
const dockerfile = read("/Dockerfile");
|
|
43
|
+
const depsStage = dockerfile.slice(
|
|
44
|
+
dockerfile.indexOf("FROM node-base AS deps"),
|
|
45
|
+
dockerfile.indexOf("FROM", dockerfile.indexOf("FROM node-base AS deps") + 1),
|
|
46
|
+
);
|
|
47
|
+
// Matched as a COPY instruction, not as a substring of the stage: the
|
|
48
|
+
// comment above that COPY names the file too, so a substring check
|
|
49
|
+
// would keep passing if the instruction regressed and the comment
|
|
50
|
+
// stayed — which is the failure this test exists to catch.
|
|
51
|
+
const copyLines = depsStage
|
|
52
|
+
.split("\n")
|
|
53
|
+
.filter((line) => /^COPY\b/.test(line.trim()) && !line.trim().startsWith("#"));
|
|
54
|
+
expect(copyLines.some((line) => /\bpnpm-workspace\.yaml\b/.test(line))).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("#407 — the dev compose can reach every Redis it configures", () => {
|
|
59
|
+
it("sets every *_REDIS_URL the template config reads", () => {
|
|
60
|
+
// The template's application.conf substitutes several Redis URLs, each
|
|
61
|
+
// defaulting to `redis://localhost:6379` — which inside the container is
|
|
62
|
+
// the container itself. `.env.example` is what the compose file loads,
|
|
63
|
+
// so a URL missing from it is a boot that dials nothing.
|
|
64
|
+
const conf = read("/config/application.conf");
|
|
65
|
+
const declared = [...conf.matchAll(/\$\{\?([A-Z0-9_]*REDIS_URL)\}/g)].map((m) => m[1]);
|
|
66
|
+
expect(declared.length).toBeGreaterThan(0);
|
|
67
|
+
|
|
68
|
+
const env = read("/.env.example");
|
|
69
|
+
const missing = [...new Set(declared)].filter(
|
|
70
|
+
(name) => !new RegExp(`^${name}=`, "m").test(env),
|
|
71
|
+
);
|
|
72
|
+
expect(missing).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("#407 — the scaffold does not invite committing its own secrets", () => {
|
|
77
|
+
it("ships a .gitignore", () => {
|
|
78
|
+
// Without one, the first `git add .` in a scaffolded project commits
|
|
79
|
+
// `.env` and `jwt-private.pem` — both of which the README tells the
|
|
80
|
+
// operator to create right there.
|
|
81
|
+
expect(existsSync(`${standaloneDir}/.gitignore`)).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("ignores the files the scaffold's own setup steps create", () => {
|
|
85
|
+
const ignored = read("/.gitignore");
|
|
86
|
+
for (const pattern of [".env", "*.pem", "node_modules", "dist"]) {
|
|
87
|
+
expect(ignored).toContain(pattern);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("keeps .env.example tracked — it is the documentation", () => {
|
|
92
|
+
// A bare `.env*` would take the example with it, which is the mistake
|
|
93
|
+
// this pins against.
|
|
94
|
+
expect(read("/.gitignore")).toMatch(/^!\.env\.example$/m);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("#407 — the production compose matches the topology it documents", () => {
|
|
99
|
+
it("does not publish the app port on every interface", () => {
|
|
100
|
+
// The README says to keep `/metrics` off the public listener and the
|
|
101
|
+
// file assumes TLS is terminated in front, so a bare "3000:3000"
|
|
102
|
+
// contradicts both — it binds 0.0.0.0 on the host.
|
|
103
|
+
const compose = read("/docker-compose.production.yml");
|
|
104
|
+
expect(compose).not.toMatch(/^\s*-\s*"3000:3000"\s*$/m);
|
|
105
|
+
expect(compose).toMatch(/127\.0\.0\.1:3000:3000/);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("#407 — the two READMEs agree on security advice", () => {
|
|
110
|
+
it("does not recommend HTTP_TRUST_PROXY=true in the Japanese README", () => {
|
|
111
|
+
// #292 made `trust proxy` a CIDR/hop policy precisely because `true`
|
|
112
|
+
// means "believe the leftmost forwarded entry from whoever opened the
|
|
113
|
+
// connection". The English README warns against it; the Japanese one
|
|
114
|
+
// still recommended it, so the two disagreed on which is safe.
|
|
115
|
+
expect(read("/README.ja.md")).not.toMatch(/HTTP_TRUST_PROXY=true/);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 1o1 Co. Ltd.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Issue #290 — shutdown was delegated to `@o3co/auth.utils@0.0.4`, whose
|
|
19
|
+
* guarantees were not pinned by any contract this repository could check.
|
|
20
|
+
*
|
|
21
|
+
* Reading the 22 lines answered the question the issue asked, and the answer
|
|
22
|
+
* was worth knowing: **there was no force-close deadline**. `server.close()`
|
|
23
|
+
* waits for in-flight requests indefinitely, so one stuck request meant the
|
|
24
|
+
* process never exited on its own and the orchestrator's SIGKILL took it down
|
|
25
|
+
* mid-flight — the opposite of a graceful shutdown, and invisible until it
|
|
26
|
+
* happened. The cleanup-failure path also wrote to `console.error`, a bare
|
|
27
|
+
* line in a service whose every other line is NDJSON.
|
|
28
|
+
*
|
|
29
|
+
* So the behaviour lives here now, with a deadline and the app's own logger,
|
|
30
|
+
* and these tests are the contract the issue said was missing.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { Server } from "node:http";
|
|
34
|
+
import type { Logger } from "@o3co/auth-provider-core";
|
|
35
|
+
import { describe, expect, it, vi } from "vitest";
|
|
36
|
+
import { installGracefulShutdown } from "../shutdown.mjs";
|
|
37
|
+
|
|
38
|
+
/** A `Server` double whose `close` callback fires only when we say so. */
|
|
39
|
+
function makeServer() {
|
|
40
|
+
let closeCallback: ((err?: Error) => void) | undefined;
|
|
41
|
+
const server = {
|
|
42
|
+
close: vi.fn((cb?: (err?: Error) => void) => {
|
|
43
|
+
closeCallback = cb;
|
|
44
|
+
return server;
|
|
45
|
+
}),
|
|
46
|
+
closeIdleConnections: vi.fn(),
|
|
47
|
+
closeAllConnections: vi.fn(),
|
|
48
|
+
};
|
|
49
|
+
return {
|
|
50
|
+
server: server as unknown as Server,
|
|
51
|
+
spies: server,
|
|
52
|
+
/** Simulate the last in-flight request finishing. */
|
|
53
|
+
finishDraining: () => closeCallback?.(),
|
|
54
|
+
/** Simulate `close` reporting a failure through its callback. */
|
|
55
|
+
failClose: (err: Error) => closeCallback?.(err),
|
|
56
|
+
get drained() {
|
|
57
|
+
return closeCallback !== undefined;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A `Logger`-shaped spy. Typed rather than cast: `as never` would hide a real
|
|
64
|
+
* mismatch the day the port gains a method, which is the whole reason the
|
|
65
|
+
* shutdown path logs through the app logger instead of `console`.
|
|
66
|
+
*/
|
|
67
|
+
const makeLogger = () => {
|
|
68
|
+
const spy = {
|
|
69
|
+
trace: vi.fn(),
|
|
70
|
+
debug: vi.fn(),
|
|
71
|
+
info: vi.fn(),
|
|
72
|
+
warn: vi.fn(),
|
|
73
|
+
error: vi.fn(),
|
|
74
|
+
fatal: vi.fn(),
|
|
75
|
+
// `child` returns a Logger; returning this one keeps a child's calls
|
|
76
|
+
// visible on the same spies, which is what a test wants.
|
|
77
|
+
child: vi.fn(() => spy as unknown as Logger),
|
|
78
|
+
};
|
|
79
|
+
return spy satisfies Logger;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Drive one shutdown without touching the real `process` or exiting. */
|
|
83
|
+
function install(opts: { cleanup?: () => void | Promise<void>; drainTimeoutMs?: number } = {}) {
|
|
84
|
+
const { server, spies, finishDraining, failClose } = makeServer();
|
|
85
|
+
const logger = makeLogger();
|
|
86
|
+
const exit = vi.fn();
|
|
87
|
+
const signals = new Map<string, () => void>();
|
|
88
|
+
|
|
89
|
+
installGracefulShutdown(server, {
|
|
90
|
+
logger,
|
|
91
|
+
cleanup: opts.cleanup ?? (() => {}),
|
|
92
|
+
...(opts.drainTimeoutMs === undefined ? {} : { drainTimeoutMs: opts.drainTimeoutMs }),
|
|
93
|
+
exit,
|
|
94
|
+
onSignal: (name, handler) => signals.set(name, handler),
|
|
95
|
+
offSignal: (name) => signals.delete(name),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return { spies, logger, exit, signals, finishDraining, failClose };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe("installGracefulShutdown (#290)", () => {
|
|
102
|
+
it("listens for both SIGTERM and SIGINT", async () => {
|
|
103
|
+
const { signals } = install();
|
|
104
|
+
expect([...signals.keys()].sort()).toEqual(["SIGINT", "SIGTERM"]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("stops accepting connections and releases idle keep-alive sockets", async () => {
|
|
108
|
+
const { spies, signals } = install();
|
|
109
|
+
signals.get("SIGTERM")?.();
|
|
110
|
+
expect(spies.close).toHaveBeenCalled();
|
|
111
|
+
// Idle keep-alive sockets hold the server open with no request behind
|
|
112
|
+
// them; releasing them is what lets a quiet server exit promptly.
|
|
113
|
+
expect(spies.closeIdleConnections).toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("runs cleanup once draining completes, then exits zero", async () => {
|
|
117
|
+
const cleanup = vi.fn();
|
|
118
|
+
const { exit, signals, finishDraining } = install({ cleanup });
|
|
119
|
+
signals.get("SIGTERM")?.();
|
|
120
|
+
expect(cleanup).not.toHaveBeenCalled();
|
|
121
|
+
finishDraining();
|
|
122
|
+
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(0));
|
|
123
|
+
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("ignores a second signal instead of running cleanup twice", async () => {
|
|
127
|
+
// An operator pressing ^C twice, or a SIGINT arriving after SIGTERM,
|
|
128
|
+
// must not start a second dispose over the first one's stores.
|
|
129
|
+
const cleanup = vi.fn();
|
|
130
|
+
const { signals, finishDraining, exit } = install({ cleanup });
|
|
131
|
+
signals.get("SIGTERM")?.();
|
|
132
|
+
signals.get("SIGINT")?.();
|
|
133
|
+
finishDraining();
|
|
134
|
+
await vi.waitFor(() => expect(exit).toHaveBeenCalled());
|
|
135
|
+
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// The gap the audit was pointing at.
|
|
139
|
+
it("forces the remaining connections closed when draining outruns the deadline", async () => {
|
|
140
|
+
vi.useFakeTimers();
|
|
141
|
+
try {
|
|
142
|
+
const { spies, signals, exit } = install({ drainTimeoutMs: 5_000 });
|
|
143
|
+
signals.get("SIGTERM")?.();
|
|
144
|
+
expect(spies.closeAllConnections).not.toHaveBeenCalled();
|
|
145
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
146
|
+
expect(spies.closeAllConnections).toHaveBeenCalled();
|
|
147
|
+
expect(exit).toHaveBeenCalled();
|
|
148
|
+
} finally {
|
|
149
|
+
vi.useRealTimers();
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("exits non-zero on a forced close, so the drain outcome is visible", async () => {
|
|
154
|
+
// An orchestrator that only ever sees exit 0 cannot tell a clean drain
|
|
155
|
+
// from one that ran out of time and cut requests off.
|
|
156
|
+
vi.useFakeTimers();
|
|
157
|
+
try {
|
|
158
|
+
const { signals, exit, logger } = install({ drainTimeoutMs: 5_000 });
|
|
159
|
+
signals.get("SIGTERM")?.();
|
|
160
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
161
|
+
expect(exit).toHaveBeenCalledWith(1);
|
|
162
|
+
expect(logger.error).toHaveBeenCalled();
|
|
163
|
+
} finally {
|
|
164
|
+
vi.useRealTimers();
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("does not force-close a drain that finished in time", async () => {
|
|
169
|
+
vi.useFakeTimers();
|
|
170
|
+
try {
|
|
171
|
+
const { spies, signals, finishDraining, exit } = install({ drainTimeoutMs: 5_000 });
|
|
172
|
+
signals.get("SIGTERM")?.();
|
|
173
|
+
finishDraining();
|
|
174
|
+
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(0));
|
|
175
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
176
|
+
expect(spies.closeAllConnections).not.toHaveBeenCalled();
|
|
177
|
+
expect(exit).toHaveBeenCalledTimes(1);
|
|
178
|
+
} finally {
|
|
179
|
+
vi.useRealTimers();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("reports a cleanup failure through the app logger, not console", async () => {
|
|
184
|
+
// Every other line this service emits is NDJSON through pino; a bare
|
|
185
|
+
// console.error on the shutdown path is the one a log pipeline drops.
|
|
186
|
+
const { logger, exit, signals, finishDraining } = install({
|
|
187
|
+
cleanup: () => {
|
|
188
|
+
throw new Error("redis quit failed");
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
signals.get("SIGTERM")?.();
|
|
192
|
+
finishDraining();
|
|
193
|
+
await vi.waitFor(() => expect(exit).toHaveBeenCalled());
|
|
194
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
195
|
+
expect.objectContaining({ err: expect.any(Error) }),
|
|
196
|
+
expect.stringContaining("cleanup"),
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("still exits when cleanup throws — a failed dispose must not wedge the process", async () => {
|
|
201
|
+
const { exit, signals, finishDraining } = install({
|
|
202
|
+
cleanup: async () => {
|
|
203
|
+
throw new Error("dispose failed");
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
signals.get("SIGTERM")?.();
|
|
207
|
+
finishDraining();
|
|
208
|
+
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("does not report a failed close as a clean drain", async () => {
|
|
212
|
+
// `server.close` reports through its callback -- "Server is not running"
|
|
213
|
+
// is the common one, but any listener teardown failure lands there.
|
|
214
|
+
// Exiting 0 on it would tell an orchestrator the listener came down
|
|
215
|
+
// when it did not.
|
|
216
|
+
const { exit, logger, signals, failClose } = install();
|
|
217
|
+
signals.get("SIGTERM")?.();
|
|
218
|
+
failClose(new Error("Server is not running"));
|
|
219
|
+
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
|
|
220
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
221
|
+
expect.objectContaining({ err: expect.any(Error) }),
|
|
222
|
+
expect.stringContaining("close"),
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("still runs cleanup when close reports a failure", async () => {
|
|
227
|
+
// The listener failing to come down is no reason to leak the Redis
|
|
228
|
+
// connections behind it.
|
|
229
|
+
const cleanup = vi.fn();
|
|
230
|
+
const { exit, signals, failClose } = install({ cleanup });
|
|
231
|
+
signals.get("SIGTERM")?.();
|
|
232
|
+
failClose(new Error("teardown failed"));
|
|
233
|
+
await vi.waitFor(() => expect(exit).toHaveBeenCalled());
|
|
234
|
+
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("removes its own signal listeners once shutting down", async () => {
|
|
238
|
+
// Otherwise a repeated signal keeps re-entering a handler that has
|
|
239
|
+
// already handed the process over to `close`.
|
|
240
|
+
const { signals } = install();
|
|
241
|
+
signals.get("SIGTERM")?.();
|
|
242
|
+
expect(signals.size).toBe(0);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -469,6 +469,42 @@ describe("standalone smoke test", () => {
|
|
|
469
469
|
expect(names).not.toContain("standalone:stores");
|
|
470
470
|
expect(names).toContain("standalone:redis-clients");
|
|
471
471
|
});
|
|
472
|
+
|
|
473
|
+
// #321's `redisSessionStoresModule` requires the two subject-level client
|
|
474
|
+
// slots (`subjectSessionIndexClient`, `subjectRevocationClient`) its
|
|
475
|
+
// adapters consume, and the scaffold's shared ioredis module did not
|
|
476
|
+
// provide them — so `userSessionStores.adapter = "redis"`, the
|
|
477
|
+
// template's own multi-replica path, failed stage-1 boot with
|
|
478
|
+
// `missing-required-component`. Nothing here booted that branch: the
|
|
479
|
+
// tests above check which modules are selected, not whether the
|
|
480
|
+
// selection is satisfiable. This pins the invariant the shared clients
|
|
481
|
+
// module exists for: every `*Client` slot any selected module requires
|
|
482
|
+
// is one it provides.
|
|
483
|
+
it("standalone:redis-clients provides every *Client slot the Redis branches require", () => {
|
|
484
|
+
const allRedisConfig = {
|
|
485
|
+
...config,
|
|
486
|
+
userSessionStores: { adapter: "redis" as const },
|
|
487
|
+
rateLimiter: { adapter: "redis" as const },
|
|
488
|
+
accessTokenDenylist: { adapter: "redis" as const },
|
|
489
|
+
oauth: { ...config.oauth, code: { adapter: "redis" as const } },
|
|
490
|
+
};
|
|
491
|
+
// No `refreshTokenFamilyModules` override: the default is the Redis
|
|
492
|
+
// RT-family store, so `refreshTokenFamilyClient` is part of what the
|
|
493
|
+
// invariant covers. Still hermetic — buildModules opens no socket.
|
|
494
|
+
const modules = buildModules(allRedisConfig, {
|
|
495
|
+
keyStoreModule: testKeyStoreModule,
|
|
496
|
+
repositoriesModule: testRepositoriesModule,
|
|
497
|
+
});
|
|
498
|
+
const clients = modules.find((m) => m.name === "standalone:redis-clients");
|
|
499
|
+
expect(clients).toBeDefined();
|
|
500
|
+
const provided = new Set(Object.keys(clients?.provides ?? {}));
|
|
501
|
+
const requiredClientSlots = new Set(
|
|
502
|
+
modules.flatMap((m) => [...(m.requires ?? [])]).filter((key) => key.endsWith("Client")),
|
|
503
|
+
);
|
|
504
|
+
expect(requiredClientSlots.size).toBeGreaterThan(0);
|
|
505
|
+
const missing = [...requiredClientSlots].filter((key) => !provided.has(key));
|
|
506
|
+
expect(missing).toEqual([]);
|
|
507
|
+
});
|
|
472
508
|
});
|
|
473
509
|
|
|
474
510
|
// #277: the scaffold used to wire NO access-token denylist, so the
|
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
|
-
import { gracefulShutdown } from "@o3co/auth.utils";
|
|
18
17
|
import {
|
|
19
18
|
type AppConfig,
|
|
20
19
|
AppConfigSchema,
|
|
@@ -30,6 +29,7 @@ import { buildModules } from "./buildModules.mjs";
|
|
|
30
29
|
import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "./configPath.mjs";
|
|
31
30
|
import { createAppLogger } from "./logger.mjs";
|
|
32
31
|
import { createMetrics } from "./metrics.mjs";
|
|
32
|
+
import { installGracefulShutdown } from "./shutdown.mjs";
|
|
33
33
|
import { createTerminalErrorHandler } from "./terminalError.mjs";
|
|
34
34
|
|
|
35
35
|
// Step 1: Load and validate application config (HOCON → Zod schema).
|
|
@@ -154,5 +154,14 @@ await (async (): Promise<void> => {
|
|
|
154
154
|
// Step 6: Graceful shutdown — handle.dispose() runs reverse-topological
|
|
155
155
|
// per-component cleanup (per A2-β §8.1) plus D-5 LifecycleRegistrar drain
|
|
156
156
|
// (Redis clients, interval timers).
|
|
157
|
-
|
|
157
|
+
//
|
|
158
|
+
// #290: in the template rather than a dependency, because "does SIGTERM
|
|
159
|
+
// wait for in-flight requests, and for how long?" has to be answerable
|
|
160
|
+
// from the code you deploy. See `shutdown.mts` for the stated guarantees
|
|
161
|
+
// and for the deadline the previous implementation did not have. Size
|
|
162
|
+
// `drainTimeoutMs` below your orchestrator's kill grace period.
|
|
163
|
+
installGracefulShutdown(server, {
|
|
164
|
+
logger,
|
|
165
|
+
cleanup: () => handle.dispose(),
|
|
166
|
+
});
|
|
158
167
|
})();
|
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
createInMemorySessionFamilyIndex,
|
|
23
23
|
createInMemorySessionFederationIndex,
|
|
24
24
|
createInMemorySessionRPRegistry,
|
|
25
|
+
createInMemorySubjectRevocation,
|
|
26
|
+
createInMemorySubjectSessionIndex,
|
|
25
27
|
createInMemoryUserSessionStore,
|
|
26
28
|
createKeyStoreFactory,
|
|
27
29
|
createRepositoryFactories,
|
|
@@ -198,6 +200,20 @@ export const inMemorySessionStoresModule: Module = defineModule({
|
|
|
198
200
|
sessionRPRegistry: () => createInMemorySessionRPRegistry(),
|
|
199
201
|
sessionFamilyIndex: () => createInMemorySessionFamilyIndex(),
|
|
200
202
|
sessionFederationIndex: () => createInMemorySessionFederationIndex(),
|
|
203
|
+
// #406: the two subject-level revocation slots. They were missing here
|
|
204
|
+
// on both branches, so a scaffolded deployment got
|
|
205
|
+
// `subjectRevocation: undefined` — `verifyJwt` skipped the watermark,
|
|
206
|
+
// the #376 refresh gate was inert, and `revokeAllForSubject` reported
|
|
207
|
+
// `unavailable`. Nothing failed and nothing warned, which is what #406
|
|
208
|
+
// is about; the boot guard now refuses that state, and the scaffold's
|
|
209
|
+
// answer is to wire them rather than to declare the capability absent.
|
|
210
|
+
//
|
|
211
|
+
// Single-process only, like every other store on this branch. The
|
|
212
|
+
// redis branch swaps in `redisSessionStoresModule`, which has provided
|
|
213
|
+
// both since #321 — before that there was no distributed adapter to
|
|
214
|
+
// point at, which is why this gap outlived #296.
|
|
215
|
+
subjectSessionIndex: () => createInMemorySubjectSessionIndex(),
|
|
216
|
+
subjectRevocation: () => createInMemorySubjectRevocation(),
|
|
201
217
|
},
|
|
202
218
|
});
|
|
203
219
|
|
|
@@ -304,10 +320,11 @@ export const storesModule: Module = defineModule({
|
|
|
304
320
|
|
|
305
321
|
/**
|
|
306
322
|
* Shared ioredis clients module — opens ONE long-lived ioredis connection
|
|
307
|
-
* per replica, wraps it via `makeIoredisClients()` (returns
|
|
323
|
+
* per replica, wraps it via `makeIoredisClients()` (returns 13 typed
|
|
308
324
|
* per-purpose clients), and exposes the slots consumed by the standalone's
|
|
309
325
|
* Redis-backed adapters (refresh-token-family + 4 user-session stores +
|
|
310
|
-
*
|
|
326
|
+
* subject-level revocation index + watermark + rate limiter + code
|
|
327
|
+
* repository + access-token denylist).
|
|
311
328
|
*
|
|
312
329
|
* Per F4 PR1 (D-2 v2) + Wave 5d unification: the previous design opened a
|
|
313
330
|
* separate ioredis socket per Redis-backed module (3+ sockets per replica).
|
|
@@ -407,6 +424,27 @@ export const standaloneRedisClientsModule: Module = defineModule({
|
|
|
407
424
|
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
408
425
|
.accessTokenDenylistClient;
|
|
409
426
|
},
|
|
427
|
+
// #321: the two subject-level revocation client slots, off the same
|
|
428
|
+
// shared socket. `redisSessionStoresModule` requires both — the index
|
|
429
|
+
// enumerates what a credential change cascades over and the watermark
|
|
430
|
+
// refuses what the cascade missed — so without them the scaffold's
|
|
431
|
+
// Redis branch had no provider for the slots and stage-1 boot refused
|
|
432
|
+
// the manifest with `missing-required-component`. Nothing in the
|
|
433
|
+
// standalone consumes these slots directly; they exist so the module
|
|
434
|
+
// that does can be selected.
|
|
435
|
+
subjectSessionIndexClient: async ({
|
|
436
|
+
config,
|
|
437
|
+
lifecycleRegistrar,
|
|
438
|
+
readinessRegistrar,
|
|
439
|
+
logger,
|
|
440
|
+
}) => {
|
|
441
|
+
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
442
|
+
.subjectSessionIndexClient;
|
|
443
|
+
},
|
|
444
|
+
subjectRevocationClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
|
|
445
|
+
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
446
|
+
.subjectRevocationClient;
|
|
447
|
+
},
|
|
410
448
|
},
|
|
411
449
|
});
|
|
412
450
|
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 1o1 Co. Ltd.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Server } from "node:http";
|
|
18
|
+
import type { Logger } from "@o3co/auth-provider-core";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Graceful shutdown for the scaffolded server (#290).
|
|
22
|
+
*
|
|
23
|
+
* ## Why this is in the template rather than a dependency
|
|
24
|
+
*
|
|
25
|
+
* It used to be `gracefulShutdown` from `@o3co/auth.utils@0.0.4`. The ops
|
|
26
|
+
* review's objection was not that the package is bad — it is that for the
|
|
27
|
+
* component which terminates every user session, "does SIGTERM wait for
|
|
28
|
+
* in-flight requests, and for how long?" has to be answerable from the code an
|
|
29
|
+
* operator deploys, and it was answerable only by reading a pre-1.0 package
|
|
30
|
+
* with no contract pinned here.
|
|
31
|
+
*
|
|
32
|
+
* Reading it answered the question, and the answer was the reason to move it:
|
|
33
|
+
* **there was no deadline**. `server.close()` waits for in-flight requests
|
|
34
|
+
* indefinitely, so a single stuck request meant the process never exited on
|
|
35
|
+
* its own and the orchestrator's SIGKILL took it down mid-flight — the
|
|
36
|
+
* opposite of a graceful shutdown, arriving only under the load that produces
|
|
37
|
+
* a stuck request. The cleanup-failure path also wrote to `console.error`, one
|
|
38
|
+
* bare line in a service whose every other line is NDJSON.
|
|
39
|
+
*
|
|
40
|
+
* Neither is fixed by documenting or pinning the dependency, which is why this
|
|
41
|
+
* is ~40 lines here with tests, rather than a version range and a README
|
|
42
|
+
* paragraph.
|
|
43
|
+
*
|
|
44
|
+
* ## The guarantees, stated
|
|
45
|
+
*
|
|
46
|
+
* 1. **SIGTERM and SIGINT** both start it; the second signal is ignored rather
|
|
47
|
+
* than starting a second dispose over the first one's stores.
|
|
48
|
+
* 2. **New connections stop immediately** (`server.close`), and idle keep-alive
|
|
49
|
+
* sockets are released (`closeIdleConnections`) — they hold the server open
|
|
50
|
+
* with no request behind them, so a quiet server would otherwise wait out
|
|
51
|
+
* the whole deadline for nothing.
|
|
52
|
+
* 3. **In-flight requests are given `drainTimeoutMs`** (default 10s) to finish.
|
|
53
|
+
* 4. **Past the deadline, remaining connections are cut** (`closeAllConnections`)
|
|
54
|
+
* and the process exits **non-zero** — an orchestrator that only ever sees
|
|
55
|
+
* `0` cannot tell a clean drain from one that ran out of time.
|
|
56
|
+
* 5. **`cleanup` runs after draining, before exit**, and its failure is logged
|
|
57
|
+
* through the app logger and reflected in the exit code. It never wedges the
|
|
58
|
+
* process: a dispose that throws still exits.
|
|
59
|
+
* 6. **A `close` that fails is not reported as a clean drain.** `server.close`
|
|
60
|
+
* reports through its callback, and treating that as success would tell an
|
|
61
|
+
* orchestrator the listener came down when it did not.
|
|
62
|
+
*
|
|
63
|
+
* Size `drainTimeoutMs` **below** the orchestrator's own kill grace period
|
|
64
|
+
* (Kubernetes `terminationGracePeriodSeconds`, compose `stop_grace_period`,
|
|
65
|
+
* both 30s by default) — the point is to close on our terms before SIGKILL
|
|
66
|
+
* arrives on someone else's.
|
|
67
|
+
*/
|
|
68
|
+
export interface GracefulShutdownOptions {
|
|
69
|
+
readonly logger: Logger;
|
|
70
|
+
/** Reverse-topological component cleanup — normally `handle.dispose()`. */
|
|
71
|
+
readonly cleanup?: () => void | Promise<void>;
|
|
72
|
+
/** How long in-flight requests get before connections are cut. Default 10s. */
|
|
73
|
+
readonly drainTimeoutMs?: number;
|
|
74
|
+
/** Injected in tests; defaults to `process.exit`. */
|
|
75
|
+
readonly exit?: (code: number) => void;
|
|
76
|
+
/** Injected in tests; defaults to `process.on`. */
|
|
77
|
+
readonly onSignal?: (signal: NodeJS.Signals, handler: () => void) => void;
|
|
78
|
+
/** Injected in tests; defaults to `process.removeListener`. */
|
|
79
|
+
readonly offSignal?: (signal: NodeJS.Signals, handler: () => void) => void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const DEFAULT_DRAIN_TIMEOUT_MS = 10_000;
|
|
83
|
+
const SIGNALS: readonly NodeJS.Signals[] = ["SIGTERM", "SIGINT"];
|
|
84
|
+
|
|
85
|
+
export function installGracefulShutdown(server: Server, options: GracefulShutdownOptions): void {
|
|
86
|
+
const {
|
|
87
|
+
logger,
|
|
88
|
+
cleanup,
|
|
89
|
+
drainTimeoutMs = DEFAULT_DRAIN_TIMEOUT_MS,
|
|
90
|
+
exit = (code: number): void => {
|
|
91
|
+
process.exit(code);
|
|
92
|
+
},
|
|
93
|
+
onSignal = (signal, handler): void => {
|
|
94
|
+
process.on(signal, handler);
|
|
95
|
+
},
|
|
96
|
+
offSignal = (signal, handler): void => {
|
|
97
|
+
process.removeListener(signal, handler);
|
|
98
|
+
},
|
|
99
|
+
} = options;
|
|
100
|
+
|
|
101
|
+
let shuttingDown = false;
|
|
102
|
+
let finished = false;
|
|
103
|
+
|
|
104
|
+
/** Run `cleanup` and exit. Called by whichever of drain / deadline wins. */
|
|
105
|
+
const finish = async (code: number, reason: string): Promise<void> => {
|
|
106
|
+
if (finished) return;
|
|
107
|
+
finished = true;
|
|
108
|
+
let exitCode = code;
|
|
109
|
+
try {
|
|
110
|
+
await cleanup?.();
|
|
111
|
+
} catch (err) {
|
|
112
|
+
// Through the app logger, not `console.error`: a shutdown that
|
|
113
|
+
// failed to release its Redis connections is exactly the line an
|
|
114
|
+
// operator needs to find later, and a bare write is the one their
|
|
115
|
+
// pipeline drops.
|
|
116
|
+
logger.error({ err }, "graceful shutdown: cleanup failed");
|
|
117
|
+
exitCode = 1;
|
|
118
|
+
}
|
|
119
|
+
logger.info({ reason, exitCode }, "graceful shutdown: complete");
|
|
120
|
+
exit(exitCode);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const handler = (): void => {
|
|
124
|
+
if (shuttingDown) return;
|
|
125
|
+
shuttingDown = true;
|
|
126
|
+
for (const signal of SIGNALS) offSignal(signal, handler);
|
|
127
|
+
logger.info({ drainTimeoutMs }, "graceful shutdown: draining");
|
|
128
|
+
|
|
129
|
+
const deadline = setTimeout(() => {
|
|
130
|
+
logger.error(
|
|
131
|
+
{ drainTimeoutMs },
|
|
132
|
+
"graceful shutdown: drain deadline exceeded, closing remaining connections",
|
|
133
|
+
);
|
|
134
|
+
server.closeAllConnections();
|
|
135
|
+
void finish(1, "drain-timeout");
|
|
136
|
+
}, drainTimeoutMs);
|
|
137
|
+
// The deadline must not be what keeps the process alive once the drain
|
|
138
|
+
// has already finished.
|
|
139
|
+
deadline.unref?.();
|
|
140
|
+
|
|
141
|
+
server.close((err) => {
|
|
142
|
+
clearTimeout(deadline);
|
|
143
|
+
if (err) {
|
|
144
|
+
// `close` reports through its callback — "Server is not running"
|
|
145
|
+
// is the common one, but any listener teardown failure lands
|
|
146
|
+
// here. Reporting "drained" and exiting 0 on it would tell an
|
|
147
|
+
// orchestrator the shutdown went cleanly when the listener did
|
|
148
|
+
// not actually come down.
|
|
149
|
+
logger.error({ err }, "graceful shutdown: server close failed");
|
|
150
|
+
void finish(1, "close-failed");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
void finish(0, "drained");
|
|
154
|
+
});
|
|
155
|
+
// Idle keep-alive sockets have no request behind them, so nothing is
|
|
156
|
+
// lost by releasing them — and without this a quiet server waits out
|
|
157
|
+
// the whole deadline for connections that will never send anything.
|
|
158
|
+
server.closeIdleConnections();
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
for (const signal of SIGNALS) onSignal(signal, handler);
|
|
162
|
+
}
|
package/templates/versions.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
|
-
"@o3co/auth-provider-core": "0.
|
|
3
|
-
"@o3co/auth-provider-
|
|
4
|
-
"@o3co/auth-provider-
|
|
5
|
-
"@o3co/auth-provider-
|
|
6
|
-
"@o3co/auth-provider-federation-
|
|
7
|
-
"@o3co/auth-provider-
|
|
8
|
-
"@o3co/auth-provider-oauth
|
|
9
|
-
"@o3co/auth-provider-
|
|
10
|
-
"@o3co/auth-provider-
|
|
11
|
-
"@o3co/auth-provider-
|
|
12
|
-
"@o3co/auth-provider-
|
|
2
|
+
"@o3co/auth-provider-core": "0.11.0",
|
|
3
|
+
"@o3co/auth-provider-device-grant": "0.11.0",
|
|
4
|
+
"@o3co/auth-provider-dpop": "0.11.0",
|
|
5
|
+
"@o3co/auth-provider-mtls": "0.11.0",
|
|
6
|
+
"@o3co/auth-provider-federation-google": "0.11.0",
|
|
7
|
+
"@o3co/auth-provider-federation-github": "0.11.0",
|
|
8
|
+
"@o3co/auth-provider-oauth": "0.11.0",
|
|
9
|
+
"@o3co/auth-provider-oauth-token-exchange": "0.11.0",
|
|
10
|
+
"@o3co/auth-provider-session": "0.11.0",
|
|
11
|
+
"@o3co/auth-provider-foundation": "0.11.0",
|
|
12
|
+
"@o3co/auth-provider-redis": "0.11.0",
|
|
13
|
+
"@o3co/auth-provider-webauthn": "0.11.0"
|
|
13
14
|
}
|