@o3co/create-auth-provider 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/templates/standalone/.env.example +22 -0
- package/templates/standalone/README.ja.md +74 -1
- package/templates/standalone/README.md +138 -1
- package/templates/standalone/config/application.conf +52 -0
- package/templates/standalone/docker-compose.production.yml +7 -0
- package/templates/standalone/docker-compose.yml +7 -0
- package/templates/standalone/package.json +3 -2
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +21 -0
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +384 -0
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +431 -0
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +57 -0
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +14 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +96 -2
- package/templates/standalone/src/app.mts +9 -1
- package/templates/standalone/src/buildModules.mts +66 -1
- package/templates/standalone/src/modules.mts +76 -4
- package/templates/standalone/src/shutdown.mts +70 -4
- package/templates/standalone/vitest.supertest-loopback.mts +22 -3
- package/templates/versions.json +15 -14
package/package.json
CHANGED
|
@@ -63,6 +63,28 @@ SESSION_STORAGE_REDIS_URL=redis://redis:6379
|
|
|
63
63
|
# container, so leaving it unset makes the compose boot dial nothing.
|
|
64
64
|
REFRESH_TOKEN_FAMILY_STORE_REDIS_URL=redis://redis:6379
|
|
65
65
|
|
|
66
|
+
# --- Federation grants (#593, optional) --------------------------------------
|
|
67
|
+
# Offline delegation of an upstream IdP's tokens to a backend or an agent, on a
|
|
68
|
+
# consent that survives logout. Off by default; on, boot refuses what is
|
|
69
|
+
# missing by name. The consent page is yours (README: Federation Grants). The
|
|
70
|
+
# connections and the encryption key ring are lists, so they live in a HOCON
|
|
71
|
+
# layer, not here. Both stores ship on Redis; Redis GRANTS need
|
|
72
|
+
# USER_SESSION_STORES_ADAPTER=redis beside them (the compose files set it) —
|
|
73
|
+
# the grants would outlive a memory boundary that ends them.
|
|
74
|
+
# FEDERATION_GRANTS_ENABLED=true
|
|
75
|
+
# FEDERATION_GRANTS_CONSENT_URL=/consent/grants
|
|
76
|
+
# `required` seals credentials under federationGrants.encryptionKeys (HOCON);
|
|
77
|
+
# `allow-plaintext` is refused in production/staging and under multi.
|
|
78
|
+
# FEDERATION_GRANTS_ENCRYPTION_MODE=required
|
|
79
|
+
# `required` needs a user repository that covers every connection's
|
|
80
|
+
# registration: the shipped http one asks your Store at the URL below and
|
|
81
|
+
# covers what repositories.user.http.federatedIdentityLookupCoverage (HOCON)
|
|
82
|
+
# declares; `unsupported` records that the check is not made.
|
|
83
|
+
# FEDERATION_GRANTS_IDENTITY_LOOKUP=required
|
|
84
|
+
# CLIENT_USER_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL=https://users.example.com/find-subject-by-federated-identity
|
|
85
|
+
# FEDERATION_GRANT_STORE_ADAPTER=redis
|
|
86
|
+
# FEDERATION_GRANT_INTENT_STORE_ADAPTER=redis
|
|
87
|
+
|
|
66
88
|
# Development (override for local HTTP).
|
|
67
89
|
# The default cookie name carries the `__Host-` prefix, which browsers only
|
|
68
90
|
# accept on a Secure cookie — so dropping Secure means renaming the cookie too,
|
|
@@ -155,6 +155,7 @@ openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
|
|
|
155
155
|
| `CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL` | — | トークン認証用のユーザー認証 URL。**https 必須**(下記参照) |
|
|
156
156
|
| `CLIENT_USER_TIMEOUT` | `5000` | HTTP リクエストタイムアウト(ミリ秒)。`2147483647` 以下の正の整数 |
|
|
157
157
|
| `CLIENT_USER_MAX_RESPONSE_BYTES` | `1048576` | 上流レスポンスボディの受け入れ上限(バイト) |
|
|
158
|
+
| `CLIENT_USER_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL` | — | 任意。上流 identity の持ち主を Store に問う URL(#613、フェデレーショングラントの check 5)。設定したら Store が cover する registration を `repositories.user.http.federatedIdentityLookupCoverage`(HOCON)に宣言する。**https 必須** |
|
|
158
159
|
|
|
159
160
|
ユーザー認証 URL は 2 つとも上流ストアへ**平文のユーザー資格情報**を運ぶため、いずれも絶対 `https://` URL でなければならない。`http://` は loopback ホスト(`localhost`、`127.0.0.0/8` 内のアドレス、`[::1]`)に限って許可され、ローカル開発で証明書を用意せずに済むようにしている。プライベートレンジのアドレス(`http://10.0.0.5/…`)やコンテナネットワークのサービス名(`http://user-service/…`)には `https://` が必要 — これらはデプロイが端から端まで制御していないネットワークを越えるため。URL・タイムアウト・レスポンス上限のいずれかが不正なら、最初のログイン時ではなく起動時に失敗する。
|
|
160
161
|
|
|
@@ -167,6 +168,76 @@ openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
|
|
|
167
168
|
| `CLIENT_CODE_PASSWORD` | — | コードストア用 Redis パスワード |
|
|
168
169
|
| `CLIENT_CODE_DEFAULT_EXPIRES_IN` | `600` | 認可コードのデフォルト有効期間(秒) |
|
|
169
170
|
|
|
171
|
+
### フェデレーショングラント(#593)
|
|
172
|
+
|
|
173
|
+
上流 IdP のトークンをオフラインで委譲する仕組み。ユーザーが一度同意すると、クライアント(バックエンドやエージェント)は本人不在のまま、後から HTTP で上流のアクセストークンを取得できる。グラントは**ログアウトを越えて残り**、資格情報の変更(subject-revocation service)で終わる。既定はオフで、オフなら何もインストールされない。ルートと各応答の意味は[パッケージ README](../../packages/federation-grants/README.md)、IdP ごとに必要な設定は [`docs/offline-access.md`](../../packages/federation-grants/docs/offline-access.md) を参照。
|
|
174
|
+
|
|
175
|
+
| 変数 | デフォルト | 説明 |
|
|
176
|
+
|---|---|---|
|
|
177
|
+
| `FEDERATION_GRANTS_ENABLED` | `false` | ルート、下記 2 つのストア、subject-revocation service をインストールする |
|
|
178
|
+
| `FEDERATION_GRANTS_CONSENT_URL` | — | グラント用の同意ページ(デプロイ側が用意する)。パス、またはプロバイダー origin 上の絶対 URL。既定値なし — 有効化は「ページがある」という宣言で、無ければ起動を拒否する |
|
|
179
|
+
| `FEDERATION_GRANTS_IDENTITY_LOOKUP` | `required` | connect callback で、別ローカルユーザーに紐づいた上流アカウントを拒否するか。`required` は各 connection の registration を cover するユーザーリポジトリが必要(下記)。`unsupported` は「この検査をしない」という記録された決定 |
|
|
180
|
+
| `FEDERATION_GRANT_STORE_ADAPTER` | `redis` | グラントの保存先: `memory`(1 レプリカ、再起動で消え全ユーザーが再接続)/ `redis`(共有ソケット) |
|
|
181
|
+
| `FEDERATION_GRANT_INTENT_STORE_ADAPTER` | `redis` | 取得フローの記録(intent、同意チャレンジ、connect トランザクション)の保存先: `memory`(1 レプリカ、再起動で進行中のフローだけ失う)/ `redis` |
|
|
182
|
+
| `FEDERATION_GRANTS_ENCRYPTION_MODE` | `required` | `required` / `allow-plaintext`。平文は production/staging と `DEPLOYMENT_MODE=multi` で拒否される(`FEDERATION_TOKENS_ALLOW_INSECURE=1` を除く) |
|
|
183
|
+
| `FEDERATION_GRANTS_ALLOW_KEEP_ON_SUBJECT_REVOCATION` | `false` | subject 全体の失効で、確立済みグラントを残すよう*求められる*ことを許すか。許可であって指示ではない |
|
|
184
|
+
| `REDIS_FEDERATION_GRANT_STORE_KEY_PREFIX` | `fg:` | Redis グラントストアのキー名前空間 |
|
|
185
|
+
|
|
186
|
+
**環境変数の形を持たないものが 2 つ**ある — connection と暗号鍵リング。リストは HOCON のものなので、デプロイ側のレイヤー(例: `config/production.conf`)に書く:
|
|
187
|
+
|
|
188
|
+
```hocon
|
|
189
|
+
federationGrants {
|
|
190
|
+
encryptionKeys = [
|
|
191
|
+
# 先頭の鍵で封じ、列挙した全鍵で開く。GRANT_KEY_2026_09 は自分で決める名前
|
|
192
|
+
# (openssl rand -base64 32) で、テンプレートの override ではない。
|
|
193
|
+
{ id = "2026-09", key = ${GRANT_KEY_2026_09} }
|
|
194
|
+
]
|
|
195
|
+
connections {
|
|
196
|
+
files {
|
|
197
|
+
federation = "entra-files" # 有効な type = "oidc" の federations.<name>。専用の app registration を持つ
|
|
198
|
+
scopes = ["openid", "profile", "offline_access", "Files.Read"]
|
|
199
|
+
boundary = "production"
|
|
200
|
+
maxAccessTokenLifetime = 3600
|
|
201
|
+
# グラントフロー自身の callback(このプロバイダーの origin 上)。federation のログイン callback ではない
|
|
202
|
+
callbackURL = "https://auth.example/session/federation-grants/callback/files"
|
|
203
|
+
allowScopeSubsets = false
|
|
204
|
+
identityClaims = ["oid", "tid"]
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
**起動時に拒否されるもの**(ユーザーがフロー途中で出会う代わりに、名指しで): 同意ページ未設定 / `callbackURL` のない connection、無効な federation を指す connection、委譲 capability を持たないアダプターの federation(持つのは汎用 OIDC アダプターだけ — Google の connection は `type = "oidc"` の federation にする)/ `required` なのに connection の registration を cover しないユーザーリポジトリ / Redis グラント + memory のユーザーセッションストア(`USER_SESSION_STORES_ADAPTER=memory` — グラントがそれを終わらせる境界より長生きしてしまう。compose 2 本は `redis` を設定済み)/ Redis グラントストアで `encryptionMode = "required"` なのにリングに鍵がない(memory ストアは何も封じないので鍵不要)/ `DEPLOYMENT_MODE=multi` でどちらかのストアが `memory`。
|
|
211
|
+
|
|
212
|
+
**ユーザーリポジトリ。** `required` では、起動時に各 connection の registration を cover するかをリポジトリに問い、connect callback で上流アカウントの持ち主を問う。テンプレート既定の `http` リポジトリはこれを Store に問う(#613): `CLIENT_USER_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL` にエンドポイントを設定し、そのエンドポイントが cover するもの(registration ごとに 1 エントリ、Store の戦略が必要とする claim 付き)を HOCON レイヤーに宣言する:
|
|
213
|
+
|
|
214
|
+
```hocon
|
|
215
|
+
repositories.user.http.federatedIdentityLookupCoverage = [
|
|
216
|
+
{ provider = "entra-files", issuer = "https://login.microsoftonline.com/<tenant>/v2.0",
|
|
217
|
+
clientId = "<グラント用 app registration>", requiredClaims = ["tid", "oid"] }
|
|
218
|
+
]
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
起動時にすべての connection がこれと突き合わされる: registration が宣言されていない、または `identityClaims` に必要な claim が欠けている connection は名指しで拒否される。Store が実装する wire 契約と、`unlinked` と答えるために何を確認していなければならないかは [foundation README](../../packages/foundation/README.md#the-identity-lookup-613) にある。起動時に Store は問われない。同梱の in-memory リポジトリはどの registration も cover しない。cover する Store が無いなら `FEDERATION_GRANTS_IDENTITY_LOOKUP=unsupported`(別ローカルユーザーが既に持つ上流アカウントを拒否しない、という記録された決定)にする。connection が無ければ何も要求されない。
|
|
222
|
+
|
|
223
|
+
**クライアント登録。** `config/clients.yaml` で、グラントを持てる confidential クライアントに、要求できる connection とブラウザの戻り先を書く。`allowedRedirectUris` や `firstParty` はどちらも代わりにならない:
|
|
224
|
+
|
|
225
|
+
```yaml
|
|
226
|
+
worker:
|
|
227
|
+
tokenEndpointAuthMethod: "client_secret_basic"
|
|
228
|
+
clientSecret: "$2b$10$…"
|
|
229
|
+
allowedFederationGrantConnections: ["files"]
|
|
230
|
+
federationGrantRedirectUris: ["https://worker.example/connected"]
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
**クライアント側から見たフロー。** `POST /oauth/federation-grants`(クライアント認証)で `sub` に対する intent を登録すると `grant_id` と `connect_uri` が返るので、ユーザーのブラウザをそこへ送る。プロバイダーは必要ならログインさせ、同意ページを見せ、上流へ送り、`grant_id` と `state` を付けてクライアントの `redirect_uri` に戻す — トークンは決して載らない。以後クライアントは自身の資格情報とユーザーの `sub` で `/oauth/federation-grants/:grantId/token` / `/status` / `/revoke` を呼ぶ。本人は不在でよい。
|
|
234
|
+
|
|
235
|
+
**同意ページ**はデプロイ側のもので、プロバイダーと same-origin。契約は `/oauth/consent` と同じなので 1 ページで両方を担える。`GET /session/federation-grants/consent?challenge=…` は JSON を返す: `client_id`、`client_name`、`connection`、`scopes`、`resource`、`grant_expires_in`(承認後の期間。日付ではない)、`continues_after_logout`(ページが必ず表示するもの)、`expires_in`(フローの残り時間)。`POST` に `challenge` と `decision`(`accept` | `deny`)を送ると `303` — 上流へ、または クライアントへ戻る。取得も更新も必ずここを通る。
|
|
236
|
+
|
|
237
|
+
**ログインページ**は `ENDPOINTS_LOGIN_URL` のもので、`redirect_to=<connect リンク>` 付きで呼ばれる。ログイン後はそのリンクへそのまま戻す(`/oauth/authorize` と同じ往復)。そのリンクを `POST /session/login` の `redirect_to` に送ってはいけない — あちらの完全一致 allowlist はランディングページ用で、フローごとの handle を拒否する。
|
|
238
|
+
|
|
239
|
+
**運用。** 鍵リングのローテーションは[オペレーター runbook](../../docs/operator-runbook.md)の手順で(新しい鍵を末尾に → 先頭に、古い鍵は 365 日残す)。シャットダウンは機能が有効なら cleanup に 45 秒を与え(`upstreamHardTimeoutMs` / `persistRetryBudgetMs` / `lockWaitMs` を上げればその合計 + 余裕まで増える)、compose 2 本はプロセスに 60 秒を与える([シャットダウンの保証](#シャットダウンの保証))。無効化は失効ではない: グラントは失効させるまで Redis に残るので、先に終わらせること。一時停止中も鍵と失効境界は保持する。
|
|
240
|
+
|
|
170
241
|
### エンドポイント
|
|
171
242
|
|
|
172
243
|
| 変数 | デフォルト | 説明 |
|
|
@@ -297,7 +368,9 @@ installGracefulShutdown(server, { logger, cleanup: () => handle.dispose() });
|
|
|
297
368
|
4. **deadline を超えたら残接続を切り、プロセスは非ゼロ終了する。** 常に `0` しか見えない orchestrator では、正常な drain と時間切れの強制切断を区別できない。
|
|
298
369
|
5. **`cleanup` は drain 後・exit 前**に走る(`handle.dispose()` = 逆トポロジカルなコンポーネント cleanup + Redis/タイマーの drain)。失敗はこのサービス自身の logger(他の行と同じ NDJSON)に出し、exit code にも反映する。dispose が throw してもプロセスは終了する。
|
|
299
370
|
|
|
300
|
-
|
|
371
|
+
6. **フェデレーショングラントが有効なら `cleanup` に「refresh の最長の尻尾 + 余裕」**(`src/shutdown.mts` の `cleanupAllowanceFor`): `federationGrants.upstreamHardTimeoutMs` + `persistRetryBudgetMs` + `lockWaitMs` + 12 秒、下限 45 秒 — 同梱の予算(25 + 3 + 5 + 12)ではちょうど 45 秒。drain の 10 秒を継承しないのは、dispose がローテーションした上流資格情報の書き込みを待つため。予算を上げれば allowance も増えるので、orchestrator の grace も合わせて上げる。無効なら cleanup の予算は drain のまま。
|
|
372
|
+
|
|
373
|
+
**`drainTimeoutMs` と `cleanupTimeoutMs` の合計が orchestrator の kill grace period を下回るようにすること。** Kubernetes の `terminationGracePeriodSeconds` は既定 30 秒、compose の `stop_grace_period` は既定 10 秒。フェデレーショングラントが有効なときの最悪値は drain 10 秒 + cleanup 45 秒 = 55 秒なので、**grace は 60 秒以上**にする — 同梱の compose 2 本はそうしてあり、Kubernetes では `terminationGracePeriodSeconds: 60` を自分で設定する。さもないとローリング再起動のたびに、cleanup が待っている書き込みの途中で `SIGKILL` される。他人の都合の `SIGKILL` が来る前に、自分の都合で閉じるのが目的。
|
|
301
374
|
|
|
302
375
|
## npm スクリプト
|
|
303
376
|
|
|
@@ -115,6 +115,7 @@ Other multi-replica considerations covered by the default modules:
|
|
|
115
115
|
- The replay seen-set — the `jti` single-use record behind `private_key_jwt` client authentication (#484) — switches on `replaySeenSet.adapter` (`REPLAY_SEEN_SET_ADAPTER`); the template ships `"redis"` on the shared connection, and `memory` is refused under `DEPLOYMENT_MODE=multi` because a captured client assertion would replay once per replica.
|
|
116
116
|
- The consent step for clients that are not first-party (#527) switches on `consentStore.adapter` (`CONSENT_STORE_ADAPTER`). It is off (`none`) by default; `memory` is refused under `DEPLOYMENT_MODE=multi`, because a consent granted on one replica would be asked for again on every other and a consent page's parked request would be unknown to the replica that receives the answer. `redis` (#561) keeps both on the shared connection. See [Consent Store](#consent-store).
|
|
117
117
|
- 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).
|
|
118
|
+
- Federation grants (#593), when enabled, keep two more stores: the grants themselves (`FEDERATION_GRANT_STORE_ADAPTER`) and acquisition's records (`FEDERATION_GRANT_INTENT_STORE_ADAPTER`). Both ship `redis` on the shared socket; `memory` for either is refused under `DEPLOYMENT_MODE=multi`, and Redis grants beside memory user-session stores are refused on any replica count, because the grants would outlive the boundary that ends them. See [Federation Grants](#federation-grants).
|
|
118
119
|
|
|
119
120
|
## Usage
|
|
120
121
|
|
|
@@ -391,6 +392,7 @@ is documented in [its README](../../packages/federation-oidc/README.md).
|
|
|
391
392
|
| `CLIENT_USER_AUTHENTICATE_URL` | — | URL for password-based user authentication. **https required** (see below) |
|
|
392
393
|
| `CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL` | — | URL for token-based user authentication. **https required** (see below) |
|
|
393
394
|
| `CLIENT_USER_LINK_FEDERATED_IDENTITY_URL` | — | Optional. URL the Store links a federated identity at (#482); enables `?link=1` on the federation start route. **https required** |
|
|
395
|
+
| `CLIENT_USER_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL` | — | Optional. URL the Store answers who holds an upstream identity at (#613, federation grants' check 5). With it, declare what the Store covers in `repositories.user.http.federatedIdentityLookupCoverage` (HOCON). **https required** |
|
|
394
396
|
| `CLIENT_USER_TIMEOUT` | `5000` | HTTP request timeout in milliseconds. Positive integer ≤ `2147483647` |
|
|
395
397
|
| `CLIENT_USER_MAX_RESPONSE_BYTES` | `1048576` | Largest upstream response body accepted, in bytes |
|
|
396
398
|
|
|
@@ -443,6 +445,139 @@ socket configured by `REFRESH_TOKEN_FAMILY_STORE_REDIS_URL`.
|
|
|
443
445
|
|---|---|---|
|
|
444
446
|
| `CONSENT_STORE_ADAPTER` | `none` | Consent store for clients that are not first-party (#527): `none` (such clients are refused), `memory` (single replica) or `redis` (shared, #561) |
|
|
445
447
|
|
|
448
|
+
### Federation Grants
|
|
449
|
+
|
|
450
|
+
Offline delegation of an upstream IdP's tokens (#593): a user consents once
|
|
451
|
+
that a client — a backend, an agent — may obtain access tokens for one
|
|
452
|
+
upstream connection on their behalf, and the client obtains them over HTTP
|
|
453
|
+
later, with the user not present. A grant **survives logout**; a credential
|
|
454
|
+
change ends it through the subject-revocation service. Off by default, and
|
|
455
|
+
off installs nothing. The routes and what each answer means are in the
|
|
456
|
+
[package README](../../packages/federation-grants/README.md); what each IdP
|
|
457
|
+
needs before it issues a refresh token is in
|
|
458
|
+
[`docs/offline-access.md`](../../packages/federation-grants/docs/offline-access.md).
|
|
459
|
+
|
|
460
|
+
| Variable | Default | Description |
|
|
461
|
+
|---|---|---|
|
|
462
|
+
| `FEDERATION_GRANTS_ENABLED` | `false` | Installs the routes, the two stores below and the subject-revocation service |
|
|
463
|
+
| `FEDERATION_GRANTS_CONSENT_URL` | — | The deployment's consent page for grants: a path, or an absolute URL on the provider's origin. No default — enabling grants is a statement that the page exists, and boot refuses without it |
|
|
464
|
+
| `FEDERATION_GRANTS_IDENTITY_LOOKUP` | `required` | Whether the connect callback refuses an upstream account already linked to another local user. `required` needs a user repository that covers every connection's registration (below); `unsupported` records that the check is not made |
|
|
465
|
+
| `FEDERATION_GRANT_STORE_ADAPTER` | `redis` | Where grants live: `memory` (one replica; lost on restart, every user reconnects) or `redis` (the shared socket) |
|
|
466
|
+
| `FEDERATION_GRANT_INTENT_STORE_ADAPTER` | `redis` | Where acquisition's records live — the intent a backend lodged, the consent challenge, the connect transaction: `memory` (one replica; a restart loses flows in progress and nothing else) or `redis` |
|
|
467
|
+
| `FEDERATION_GRANTS_ENCRYPTION_MODE` | `required` | `required` or `allow-plaintext`. Plaintext is refused in production/staging and under `DEPLOYMENT_MODE=multi` unless `FEDERATION_TOKENS_ALLOW_INSECURE=1` |
|
|
468
|
+
| `FEDERATION_GRANTS_ALLOW_KEEP_ON_SUBJECT_REVOCATION` | `false` | Whether a subject-wide revocation may be *asked* to leave established grants standing. An allowance, not an instruction |
|
|
469
|
+
| `REDIS_FEDERATION_GRANT_STORE_KEY_PREFIX` | `fg:` | Key namespace of the Redis grant store |
|
|
470
|
+
|
|
471
|
+
**Two things have no environment form**, because a list is HOCON's: the
|
|
472
|
+
connections, and the encryption key ring. Write them in a deployment-owned
|
|
473
|
+
layer — `config/production.conf`, say:
|
|
474
|
+
|
|
475
|
+
```hocon
|
|
476
|
+
federationGrants {
|
|
477
|
+
encryptionKeys = [
|
|
478
|
+
# The first key seals; every listed key opens. GRANT_KEY_2026_09 is a name
|
|
479
|
+
# you choose (openssl rand -base64 32), not a template override.
|
|
480
|
+
{ id = "2026-09", key = ${GRANT_KEY_2026_09} }
|
|
481
|
+
]
|
|
482
|
+
connections {
|
|
483
|
+
files {
|
|
484
|
+
federation = "entra-files" # an enabled federations.<name> of type "oidc", with an app registration of its own
|
|
485
|
+
scopes = ["openid", "profile", "offline_access", "Files.Read"]
|
|
486
|
+
boundary = "production"
|
|
487
|
+
maxAccessTokenLifetime = 3600
|
|
488
|
+
# The grant flow's own callback on this provider's origin — not the
|
|
489
|
+
# federation's login callback.
|
|
490
|
+
callbackURL = "https://auth.example/session/federation-grants/callback/files"
|
|
491
|
+
allowScopeSubsets = false
|
|
492
|
+
identityClaims = ["oid", "tid"]
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
**What boot refuses**, each named, rather than met by a user mid-flow: no
|
|
499
|
+
consent page; a connection without `callbackURL`, on a federation that is not
|
|
500
|
+
enabled, or on one whose adapter lacks the delegated capability — only the
|
|
501
|
+
generic OIDC adapter has it, so a Google connection is a `type = "oidc"`
|
|
502
|
+
federation; `required` with a user repository that does not cover a
|
|
503
|
+
connection's registration; Redis grants beside memory user-session stores
|
|
504
|
+
(`USER_SESSION_STORES_ADAPTER=memory` — the grants would outlive the boundary
|
|
505
|
+
that ends them; both compose files set `redis`); the Redis grant store under
|
|
506
|
+
`encryptionMode = "required"` with no key in the ring (the memory store seals
|
|
507
|
+
nothing and needs none); `memory` for either store under `DEPLOYMENT_MODE=multi`.
|
|
508
|
+
|
|
509
|
+
**The user repository.** Under `required`, boot asks the repository whether it
|
|
510
|
+
covers each connection's registration, and the connect callback asks it who
|
|
511
|
+
holds the upstream account. The template's `http` repository asks your Store
|
|
512
|
+
(#613): set `CLIENT_USER_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL` to the
|
|
513
|
+
endpoint, and declare what that endpoint covers — one entry per registration,
|
|
514
|
+
with the claims the Store's strategy needs — in a HOCON layer:
|
|
515
|
+
|
|
516
|
+
```hocon
|
|
517
|
+
repositories.user.http.federatedIdentityLookupCoverage = [
|
|
518
|
+
{ provider = "entra-files", issuer = "https://login.microsoftonline.com/<tenant>/v2.0",
|
|
519
|
+
clientId = "<the grants app registration>", requiredClaims = ["tid", "oid"] }
|
|
520
|
+
]
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
Boot then holds every connection to it: one whose registration is not
|
|
524
|
+
declared, or whose `identityClaims` misses a required claim, is refused by
|
|
525
|
+
name. The wire contract the Store implements — and what "unlinked" obliges it
|
|
526
|
+
to have checked — is in the
|
|
527
|
+
[foundation README](../../packages/foundation/README.md#the-identity-lookup-613);
|
|
528
|
+
the Store is not asked at boot. The bundled in-memory repository covers no
|
|
529
|
+
registration. Without a covering Store, set
|
|
530
|
+
`FEDERATION_GRANTS_IDENTITY_LOOKUP=unsupported` — the recorded decision not to
|
|
531
|
+
refuse an upstream account another local user already holds. With no
|
|
532
|
+
connection configured nothing is required.
|
|
533
|
+
|
|
534
|
+
**Registering a client.** In `config/clients.yaml`, a confidential client that
|
|
535
|
+
may hold grants names the connections it may ask for and where the browser
|
|
536
|
+
may be returned. Neither its `allowedRedirectUris` nor `firstParty` grants
|
|
537
|
+
either:
|
|
538
|
+
|
|
539
|
+
```yaml
|
|
540
|
+
worker:
|
|
541
|
+
tokenEndpointAuthMethod: "client_secret_basic"
|
|
542
|
+
clientSecret: "$2b$10$…"
|
|
543
|
+
allowedFederationGrantConnections: ["files"]
|
|
544
|
+
federationGrantRedirectUris: ["https://worker.example/connected"]
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
**The flow, from the client's side.** `POST /oauth/federation-grants`
|
|
548
|
+
(client-authenticated) lodges an intent for a `sub` and answers a `grant_id`
|
|
549
|
+
and a `connect_uri`; the client sends the user's browser there. The provider
|
|
550
|
+
signs the user in if needed, shows your consent page, sends them to the
|
|
551
|
+
upstream, and returns the browser to the client's `redirect_uri` with
|
|
552
|
+
`grant_id` and `state` — never a token. The client then calls
|
|
553
|
+
`/oauth/federation-grants/:grantId/token`, `/status` and `/revoke` with its
|
|
554
|
+
own credentials and the user's `sub`, with no user present.
|
|
555
|
+
|
|
556
|
+
**The consent page** is yours, same-origin with the provider, and its contract
|
|
557
|
+
is `/oauth/consent`'s, so one page can serve both. `GET
|
|
558
|
+
/session/federation-grants/consent?challenge=…` answers JSON: `client_id`,
|
|
559
|
+
`client_name`, `connection`, `scopes`, `resource`, `grant_expires_in` (a
|
|
560
|
+
duration after approval, not a date), `continues_after_logout` (which the page
|
|
561
|
+
must show), and `expires_in` (what is left of the flow). `POST` with
|
|
562
|
+
`challenge` and `decision` (`accept` | `deny`) answers `303` — to the upstream,
|
|
563
|
+
or back to the client. Every acquisition and renewal goes through it.
|
|
564
|
+
|
|
565
|
+
**The login page** is the one `ENDPOINTS_LOGIN_URL` names, reached with
|
|
566
|
+
`redirect_to=<the connect link>`; it signs the user in and navigates back to
|
|
567
|
+
that link unchanged, as it does for `/oauth/authorize`. Do not submit the link
|
|
568
|
+
to `POST /session/login` as its `redirect_to`: that route's exact-match
|
|
569
|
+
allowlist is for landing pages and refuses a per-flow handle.
|
|
570
|
+
|
|
571
|
+
**Operating it.** Rotate the key ring by the procedure in the
|
|
572
|
+
[operator runbook](../../docs/operator-runbook.md) — new key last, then first,
|
|
573
|
+
old key kept 365 days. A shutdown gives cleanup 45 seconds while the feature
|
|
574
|
+
is on — more if you raise `upstreamHardTimeoutMs`, `persistRetryBudgetMs` or
|
|
575
|
+
`lockWaitMs`, since the allowance is their sum plus a margin — and both
|
|
576
|
+
compose files give the process 60; see
|
|
577
|
+
[Shutdown guarantees](#shutdown-guarantees). Disabling the feature is not
|
|
578
|
+
revoking: grants stay in Redis until revoked, so end them first, and keep the
|
|
579
|
+
keys and the revocation boundaries through any temporary shutdown.
|
|
580
|
+
|
|
446
581
|
### Redis Namespacing
|
|
447
582
|
|
|
448
583
|
For multi-tenant Redis clusters, set deployment-specific key prefixes so two
|
|
@@ -695,7 +830,9 @@ installGracefulShutdown(server, { logger, cleanup: () => handle.dispose() });
|
|
|
695
830
|
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.
|
|
696
831
|
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.
|
|
697
832
|
|
|
698
|
-
**
|
|
833
|
+
6. **With federation grants on, `cleanup` gets the longest refresh tail plus a margin** (`cleanupAllowanceFor` in `src/shutdown.mts`): `federationGrants.upstreamHardTimeoutMs` + `persistRetryBudgetMs` + `lockWaitMs` + 12 s, never below 45 s — exactly 45 s under the shipped budgets (25 + 3 + 5 + 12) — instead of inheriting the drain's ten, because the dispose waits for a rotated upstream credential's write. Raise a budget and the allowance grows with it; raise your orchestrator's grace to match. Off, the cleanup budget stays the drain's.
|
|
834
|
+
|
|
835
|
+
**Size `drainTimeoutMs` and `cleanupTimeoutMs` together below your orchestrator's kill grace period.** Kubernetes `terminationGracePeriodSeconds` is 30s by default and compose's `stop_grace_period` is 10s; with federation grants on, the worst case is drain (10s) + cleanup (45s) = 55s, so **set the grace to 60s or more** — the shipped compose files do, and a Kubernetes deployment must set `terminationGracePeriodSeconds: 60` itself, or a rolling restart SIGKILLs the process in the middle of the write the cleanup exists to finish. The point is to close on your terms before `SIGKILL` arrives on someone else's.
|
|
699
836
|
|
|
700
837
|
## npm Scripts
|
|
701
838
|
|
|
@@ -159,6 +159,44 @@ replaySeenSet {
|
|
|
159
159
|
adapter = ${?REPLAY_SEEN_SET_ADAPTER}
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
# #593: federation grants — a user's standing consent that a backend or an
|
|
163
|
+
# agent may obtain upstream IdP tokens with no session behind the call. Off by
|
|
164
|
+
# default; FEDERATION_GRANTS_ENABLED=true installs the routes, the two stores
|
|
165
|
+
# below and the subject-revocation service. Enabling it is a statement that
|
|
166
|
+
# the deployment has a consent page (FEDERATION_GRANTS_CONSENT_URL, no default),
|
|
167
|
+
# a `callbackURL` on every connection, and a user repository that covers each
|
|
168
|
+
# connection's registration (or FEDERATION_GRANTS_IDENTITY_LOOKUP=unsupported,
|
|
169
|
+
# the recorded decision not to make that check) — each refused at boot when
|
|
170
|
+
# missing. Connections and the encryption key ring have no environment form;
|
|
171
|
+
# write them in a deployment-owned HOCON layer (README: Federation Grants).
|
|
172
|
+
federationGrants {
|
|
173
|
+
enabled = false
|
|
174
|
+
enabled = ${?FEDERATION_GRANTS_ENABLED}
|
|
175
|
+
identityLookup = "required"
|
|
176
|
+
identityLookup = ${?FEDERATION_GRANTS_IDENTITY_LOOKUP}
|
|
177
|
+
consent {
|
|
178
|
+
url = ${?FEDERATION_GRANTS_CONSENT_URL}
|
|
179
|
+
}
|
|
180
|
+
encryptionMode = "required"
|
|
181
|
+
encryptionMode = ${?FEDERATION_GRANTS_ENCRYPTION_MODE}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
# "redis" for both, as for the denylist above: a grant is an offline credential
|
|
185
|
+
# that must outlive a restart, and every store this file already selects shares
|
|
186
|
+
# the same socket. Redis grants need the subject boundary in Redis too
|
|
187
|
+
# (USER_SESSION_STORES_ADAPTER=redis, which both compose files set) — boot
|
|
188
|
+
# refuses the pairing with a memory boundary, because the grants would outlive
|
|
189
|
+
# what ends them. Single-instance local work can set both to "memory".
|
|
190
|
+
federationGrantStore {
|
|
191
|
+
adapter = "redis"
|
|
192
|
+
adapter = ${?FEDERATION_GRANT_STORE_ADAPTER}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
federationGrantIntentStore {
|
|
196
|
+
adapter = "redis"
|
|
197
|
+
adapter = ${?FEDERATION_GRANT_INTENT_STORE_ADAPTER}
|
|
198
|
+
}
|
|
199
|
+
|
|
162
200
|
# #527: where consent to a client that is not first-party is recorded.
|
|
163
201
|
# "none" (the default) wires no store and such clients are refused at
|
|
164
202
|
# /authorize; "memory" is single-replica only (refused under
|
|
@@ -269,6 +307,12 @@ federations {
|
|
|
269
307
|
|
|
270
308
|
# Where the browser goes after a callback carrying no `redirect_to`.
|
|
271
309
|
# clientUrl = ${?FEDERATIONS_GOOGLE_CLIENT_URL}
|
|
310
|
+
|
|
311
|
+
# RFC 9207: Google always returns `iss` on the callback, so a callback
|
|
312
|
+
# without one is refused. `false` is the way out if Google ever stops
|
|
313
|
+
# sending it. It permits a missing `iss` only — one that is sent and is not
|
|
314
|
+
# Google's is refused either way.
|
|
315
|
+
# requireAuthorizationResponseIss = ${?FEDERATIONS_GOOGLE_REQUIRE_AUTHORIZATION_RESPONSE_ISS}
|
|
272
316
|
}
|
|
273
317
|
|
|
274
318
|
# Any OpenID Connect provider (Okta, Entra ID, Auth0, Keycloak, your own
|
|
@@ -338,6 +382,14 @@ repositories {
|
|
|
338
382
|
# layer this file gets the same override.
|
|
339
383
|
# Without it, link=1 is refused with link_unsupported.
|
|
340
384
|
linkFederatedIdentityUrl = ${?CLIENT_USER_LINK_FEDERATED_IDENTITY_URL}
|
|
385
|
+
# #613: optional. The Store's identity lookup for federation grants —
|
|
386
|
+
# what lets this deployment keep FEDERATION_GRANTS_IDENTITY_LOOKUP=required
|
|
387
|
+
# with a connection configured. Same https rule. Declare what the Store
|
|
388
|
+
# covers, per registration, in a HOCON layer (README: Federation Grants):
|
|
389
|
+
# federatedIdentityLookupCoverage = [
|
|
390
|
+
# { provider = "entra-files", issuer = "...", clientId = "...", requiredClaims = ["tid", "oid"] }
|
|
391
|
+
# ]
|
|
392
|
+
findSubjectByFederatedIdentityUrl = ${?CLIENT_USER_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL}
|
|
341
393
|
# Positive integer, milliseconds, <= 2147483647. Boot fails otherwise —
|
|
342
394
|
# including on a blank env override, which HOCON substitutes as "".
|
|
343
395
|
timeout = 5000
|
|
@@ -49,6 +49,13 @@ services:
|
|
|
49
49
|
# security group, a firewall rule, or the proxy's own network. Do not
|
|
50
50
|
# widen this line and call it done.
|
|
51
51
|
- "127.0.0.1:3000:3000"
|
|
52
|
+
# #593 slice 7: compose's default grace is ten seconds — the HTTP drain
|
|
53
|
+
# budget alone. With federation grants on, `src/app.mts` gives cleanup 45
|
|
54
|
+
# more for the drain of a rotated upstream credential's write, and SIGKILL
|
|
55
|
+
# has to arrive after that: drain (10) + cleanup (45) + an exit margin.
|
|
56
|
+
# The cleanup allowance grows by itself with `federationGrants.upstreamHardTimeoutMs`,
|
|
57
|
+
# `persistRetryBudgetMs` and `lockWaitMs`; raise this to match when you raise them.
|
|
58
|
+
stop_grace_period: 60s
|
|
52
59
|
depends_on:
|
|
53
60
|
redis:
|
|
54
61
|
condition: service_healthy
|
|
@@ -8,6 +8,13 @@ services:
|
|
|
8
8
|
volumes:
|
|
9
9
|
- ./src:/home/node/src
|
|
10
10
|
- ./config:/home/node/config
|
|
11
|
+
# #593 slice 7: compose's default grace is ten seconds — the HTTP drain
|
|
12
|
+
# budget alone. With federation grants on, `src/app.mts` gives cleanup 45
|
|
13
|
+
# more for the drain of a rotated upstream credential's write, and SIGKILL
|
|
14
|
+
# has to arrive after that: drain (10) + cleanup (45) + an exit margin.
|
|
15
|
+
# The cleanup allowance grows by itself with `federationGrants.upstreamHardTimeoutMs`,
|
|
16
|
+
# `persistRetryBudgetMs` and `lockWaitMs`; raise this to match when you raise them.
|
|
17
|
+
stop_grace_period: 60s
|
|
11
18
|
depends_on:
|
|
12
19
|
redis:
|
|
13
20
|
condition: service_healthy
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@o3co/auth-provider-standalone",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"engines": {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"build": "tsc",
|
|
13
13
|
"start": "node dist/app.mjs",
|
|
14
14
|
"debug": "NODE_OPTIONS='--conditions=development' tsx watch src/app.mts",
|
|
15
|
-
"test": "vitest run --passWithNoTests"
|
|
15
|
+
"test": "tsc --noEmit && vitest run --passWithNoTests"
|
|
16
16
|
},
|
|
17
17
|
"imports": {
|
|
18
18
|
"#/*": {
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@o3co/auth-provider-core": "workspace:*",
|
|
25
25
|
"@o3co/auth-provider-federation-google": "workspace:*",
|
|
26
|
+
"@o3co/auth-provider-federation-grants": "workspace:*",
|
|
26
27
|
"@o3co/auth-provider-federation-oidc": "workspace:*",
|
|
27
28
|
"@o3co/auth-provider-foundation": "workspace:*",
|
|
28
29
|
"@o3co/auth-provider-oauth": "workspace:*",
|
|
@@ -174,6 +174,20 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
|
|
|
174
174
|
REDIS_FEDERATION_TOKEN_STORE_KEY_PREFIX: "ft:",
|
|
175
175
|
REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_MODE: "required",
|
|
176
176
|
REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
|
|
177
|
+
// #593: federation grants — a user's standing consent that a client may
|
|
178
|
+
// obtain upstream tokens without them. The overrides `reference.conf`
|
|
179
|
+
// declares, and (slice 7) the two adapter switches the template composes
|
|
180
|
+
// the feature from.
|
|
181
|
+
FEDERATION_GRANTS_ENABLED: "true",
|
|
182
|
+
FEDERATION_GRANT_STORE_ADAPTER: "redis",
|
|
183
|
+
FEDERATION_GRANT_INTENT_STORE_ADAPTER: "redis",
|
|
184
|
+
FEDERATION_GRANTS_ENCRYPTION_MODE: "required",
|
|
185
|
+
FEDERATION_GRANTS_ALLOW_KEEP_ON_SUBJECT_REVOCATION: "false",
|
|
186
|
+
// Slice 6: acquisition's two deployment decisions — whether the callback
|
|
187
|
+
// refuses an upstream account linked to another user, and the consent page.
|
|
188
|
+
FEDERATION_GRANTS_IDENTITY_LOOKUP: "required",
|
|
189
|
+
FEDERATION_GRANTS_CONSENT_URL: "/consent/grants",
|
|
190
|
+
REDIS_FEDERATION_GRANT_STORE_KEY_PREFIX: "fg:",
|
|
177
191
|
|
|
178
192
|
// --- federation ---------------------------------------------------
|
|
179
193
|
FEDERATIONS_GOOGLE_ENABLED: "true",
|
|
@@ -195,6 +209,9 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
|
|
|
195
209
|
CLIENT_USER_AUTHENTICATE_URL: "https://users.example.com/authenticate",
|
|
196
210
|
CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL: "https://users.example.com/authenticate-by-token",
|
|
197
211
|
CLIENT_USER_LINK_FEDERATED_IDENTITY_URL: "https://users.example.com/link-federated-identity",
|
|
212
|
+
// #613: the Store's identity lookup for federation grants (D7 check 5).
|
|
213
|
+
CLIENT_USER_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL:
|
|
214
|
+
"https://users.example.com/find-subject-by-federated-identity",
|
|
198
215
|
CLIENT_USER_TIMEOUT: "5000",
|
|
199
216
|
CLIENT_USER_MAX_RESPONSE_BYTES: "1048576",
|
|
200
217
|
CLIENT_CODE_TYPE: "redis",
|
|
@@ -323,6 +340,10 @@ describe("#288: the shipped config boots with every documented override supplied
|
|
|
323
340
|
expect(config.oauth.resourceIndicator?.enabled).toBe(true);
|
|
324
341
|
expect(config.federations.google?.enabled).toBe(true);
|
|
325
342
|
expect(config.federations.oidc?.enabled).toBe(true);
|
|
343
|
+
// #593: the newest one, and the one where a leftover string would be
|
|
344
|
+
// read as "on" by a truthiness check and as "off" by `=== true` — a
|
|
345
|
+
// feature whose whole default is off.
|
|
346
|
+
expect(config.federationGrants?.enabled).toBe(true);
|
|
326
347
|
});
|
|
327
348
|
|
|
328
349
|
it("turns every non-boolean override into its declared type", () => {
|