@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/create-auth-provider",
3
- "version": "0.14.0",
3
+ "version": "0.15.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",
@@ -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
- **`drainTimeoutMs` は orchestrator の kill grace period より短く設定すること** — Kubernetes の `terminationGracePeriodSeconds`、compose の `stop_grace_period` はいずれも既定 30 秒。他人の都合の `SIGKILL` が来る前に、自分の都合で閉じるのが目的。
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
- **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.
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.14.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", () => {