@o3co/create-auth-provider 0.13.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.
Files changed (27) hide show
  1. package/package.json +2 -2
  2. package/templates/standalone/.env.example +27 -0
  3. package/templates/standalone/Dockerfile +1 -1
  4. package/templates/standalone/README.ja.md +80 -2
  5. package/templates/standalone/README.md +160 -4
  6. package/templates/standalone/config/application.conf +56 -1
  7. package/templates/standalone/docker-compose.production.yml +7 -0
  8. package/templates/standalone/docker-compose.yml +7 -0
  9. package/templates/standalone/package.json +4 -3
  10. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +101 -0
  11. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +62 -3
  12. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +384 -0
  13. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +431 -0
  14. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +57 -0
  15. package/templates/standalone/src/__tests__/metrics.test.mts +14 -2
  16. package/templates/standalone/src/__tests__/replica-safety.test.mts +58 -0
  17. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +37 -0
  18. package/templates/standalone/src/__tests__/shutdown.test.mts +96 -2
  19. package/templates/standalone/src/__tests__/smoke.test.mts +1 -0
  20. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +125 -0
  21. package/templates/standalone/src/app.mts +9 -1
  22. package/templates/standalone/src/buildModules.mts +110 -6
  23. package/templates/standalone/src/modules.mts +96 -6
  24. package/templates/standalone/src/shutdown.mts +70 -4
  25. package/templates/standalone/vitest.config.mts +6 -0
  26. package/templates/standalone/vitest.supertest-loopback.mts +250 -0
  27. package/templates/versions.json +15 -14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/create-auth-provider",
3
- "version": "0.13.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",
@@ -28,7 +28,7 @@
28
28
  "node": ">=22.0.0"
29
29
  },
30
30
  "devDependencies": {
31
- "@types/node": "^26.5.0",
31
+ "@types/node": "^26.5.1",
32
32
  "vitest": "^5.0.0"
33
33
  },
34
34
  "scripts": {
@@ -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,
@@ -72,6 +94,11 @@ SESSION_NAME=auth.sid
72
94
 
73
95
  # Optional
74
96
  # HTTP_PORT=3000
97
+ # OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN=3600
98
+ # The most a token-exchange `expires_in` can obtain; unset = the default.
99
+ # OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN=3600
100
+ # DEPRECATED alias of OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN, read only while
101
+ # that one is unset.
75
102
  # OAUTH_ACCESS_TOKEN_EXPIRES_IN=3600
76
103
  # OAUTH_REFRESH_TOKEN_EXPIRES_IN=86400
77
104
  # SESSION_MAX_AGE=3600000
@@ -103,7 +103,7 @@ CMD ["node", "dist/app.mjs"]
103
103
  #############################################
104
104
  FROM builder AS test
105
105
 
106
- COPY --chown=node:node vitest.config.mts ./
106
+ COPY --chown=node:node vitest.config.mts vitest.supertest-loopback.mts ./
107
107
  COPY --chown=node:node tests/ ./tests/
108
108
  # #512: the suite reads these from the project root (scaffold-assets,
109
109
  # documented-env-overrides, smoke). The builder stage brings only what the
@@ -79,13 +79,18 @@ openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
79
79
 
80
80
  | 変数 | デフォルト | 説明 |
81
81
  |---|---|---|
82
- | `OAUTH_ACCESS_TOKEN_EXPIRES_IN` | `3600` | アクセストークンの有効期間(秒)。正の整数、上限は 1 年(`31536000`)。 |
82
+ | `OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN` | `3600` | リクエストが有効期間を指定しないときに全グラントが発行するアクセストークンの有効期間(秒)。正の整数、上限は 1 年(`31536000`)。 |
83
+ | `OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN` | デフォルトと同じ | token exchange リクエストの `expires_in` パラメータで得られる上限。超えるリクエストはこの値に切り詰められる。未設定ならデフォルトと同じで、設定しない限り延長されない。デフォルトがこれを超えると両キーを名指しして起動失敗。オフラインで検証するリソースサーバーに対し、交換で発行されたトークンが失効後も通用し得る期間の上限でもある。 |
84
+ | `OAUTH_ACCESS_TOKEN_EXPIRES_IN` | — | `OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN` の**非推奨(deprecated)**エイリアス(config キーでは `oauth.accessToken.expiresIn` が `oauth.accessToken.defaultExpiresIn` のエイリアス)。新しい変数が未設定の間だけ読まれる。値は新しい変数へ移すこと。 |
83
85
  | `OAUTH_REFRESH_TOKEN_EXPIRES_IN` | `86400` | リフレッシュトークンの有効期間(秒)。正の整数、上限は 1 年(`31536000`)。 |
84
86
 
85
87
  これらを空文字で export すると fallback ではなく起動失敗になる: HOCON は `FOO=` を
86
88
  `""` に解決し、それが `0` に coerce され、有効期間 0 は「発行時点で期限切れ」の
87
89
  トークンを作るため。
88
90
 
91
+ `expires_in` リクエストパラメータを読むのは token exchange(RFC 8693)だけで、
92
+ 他のグラントはそれを無視してデフォルトを発行する。
93
+
89
94
  ### グラントタイプ
90
95
 
91
96
  | 変数 | デフォルト | 説明 |
@@ -150,6 +155,7 @@ openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
150
155
  | `CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL` | — | トークン認証用のユーザー認証 URL。**https 必須**(下記参照) |
151
156
  | `CLIENT_USER_TIMEOUT` | `5000` | HTTP リクエストタイムアウト(ミリ秒)。`2147483647` 以下の正の整数 |
152
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 必須** |
153
159
 
154
160
  ユーザー認証 URL は 2 つとも上流ストアへ**平文のユーザー資格情報**を運ぶため、いずれも絶対 `https://` URL でなければならない。`http://` は loopback ホスト(`localhost`、`127.0.0.0/8` 内のアドレス、`[::1]`)に限って許可され、ローカル開発で証明書を用意せずに済むようにしている。プライベートレンジのアドレス(`http://10.0.0.5/…`)やコンテナネットワークのサービス名(`http://user-service/…`)には `https://` が必要 — これらはデプロイが端から端まで制御していないネットワークを越えるため。URL・タイムアウト・レスポンス上限のいずれかが不正なら、最初のログイン時ではなく起動時に失敗する。
155
161
 
@@ -162,6 +168,76 @@ openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
162
168
  | `CLIENT_CODE_PASSWORD` | — | コードストア用 Redis パスワード |
163
169
  | `CLIENT_CODE_DEFAULT_EXPIRES_IN` | `600` | 認可コードのデフォルト有効期間(秒) |
164
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
+
165
241
  ### エンドポイント
166
242
 
167
243
  | 変数 | デフォルト | 説明 |
@@ -292,7 +368,9 @@ installGracefulShutdown(server, { logger, cleanup: () => handle.dispose() });
292
368
  4. **deadline を超えたら残接続を切り、プロセスは非ゼロ終了する。** 常に `0` しか見えない orchestrator では、正常な drain と時間切れの強制切断を区別できない。
293
369
  5. **`cleanup` は drain 後・exit 前**に走る(`handle.dispose()` = 逆トポロジカルなコンポーネント cleanup + Redis/タイマーの drain)。失敗はこのサービス自身の logger(他の行と同じ NDJSON)に出し、exit code にも反映する。dispose が throw してもプロセスは終了する。
294
370
 
295
- **`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` が来る前に、自分の都合で閉じるのが目的。
296
374
 
297
375
  ## npm スクリプト
298
376
 
@@ -113,7 +113,9 @@ Other multi-replica considerations covered by the default modules:
113
113
  - The user-session stores switch on `userSessionStores.adapter = "redis"` (`USER_SESSION_STORES_ADAPTER`), which wires `redisSessionStoresModule` off the shared ioredis connection — the one `REFRESH_TOKEN_FAMILY_STORE_REDIS_URL` configures.
114
114
  - The authorization-code repository switches on `oauth.code.adapter` (`OAUTH_CODE_ADAPTER`); the template ships `"redis"`, on that same connection. The older `repositories.code.type = "redis"` (`CLIENT_CODE_TYPE`) switch is still honoured with a deprecation warning at boot. `CLIENT_CODE_ENDPOINT_URI` (`repositories.code.redis.endpointUri`) is still bound by `config/application.conf` but nothing reads it: the Redis code repository has run on the shared connection since the `{ endpointUri }` builder shape was retired, so there is one Redis URL for every adapter and that variable is legacy.
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
+ - 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).
116
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).
117
119
 
118
120
  ## Usage
119
121
 
@@ -210,13 +212,18 @@ every relying party must be handed the shared secret — which also lets it
210
212
 
211
213
  | Variable | Default | Description |
212
214
  |---|---|---|
213
- | `OAUTH_ACCESS_TOKEN_EXPIRES_IN` | `3600` | Access token lifetime in seconds. Whole positive number, at most one year (`31536000`). |
215
+ | `OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN` | `3600` | Access token lifetime in seconds that every grant mints when the request asks for none. Whole positive number, at most one year (`31536000`). |
216
+ | `OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN` | the default | The most a token-exchange request's `expires_in` parameter can obtain; a larger request is clamped to it. Unset means the default, so no token is extended unless you set this. A default above it is a boot failure naming both keys. It also bounds how long an exchanged token outlives a revocation at a resource server that validates it offline. |
217
+ | `OAUTH_ACCESS_TOKEN_EXPIRES_IN` | — | **Deprecated** alias of `OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN` (config key `oauth.accessToken.expiresIn`, of `oauth.accessToken.defaultExpiresIn`), read only while the new one is unset. Move the value to the new variable. |
214
218
  | `OAUTH_REFRESH_TOKEN_EXPIRES_IN` | `86400` | Refresh token lifetime in seconds. Whole positive number, at most one year (`31536000`). |
215
219
 
216
220
  Exporting one of these as an empty string is a boot failure, not a fallback:
217
221
  HOCON resolves `FOO=` to `""`, which coerces to `0`, and a zero lifetime mints
218
222
  tokens that have already expired.
219
223
 
224
+ Only token exchange (RFC 8693) reads an `expires_in` request parameter; every
225
+ other grant ignores it and mints the default.
226
+
220
227
  ### Grant Types
221
228
 
222
229
  | Variable | Default | Description |
@@ -385,6 +392,7 @@ is documented in [its README](../../packages/federation-oidc/README.md).
385
392
  | `CLIENT_USER_AUTHENTICATE_URL` | — | URL for password-based user authentication. **https required** (see below) |
386
393
  | `CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL` | — | URL for token-based user authentication. **https required** (see below) |
387
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** |
388
396
  | `CLIENT_USER_TIMEOUT` | `5000` | HTTP request timeout in milliseconds. Positive integer ≤ `2147483647` |
389
397
  | `CLIENT_USER_MAX_RESPONSE_BYTES` | `1048576` | Largest upstream response body accepted, in bytes |
390
398
 
@@ -417,7 +425,6 @@ needs a key.
417
425
  | Variable | Default | Description |
418
426
  |---|---|---|
419
427
  | `FEDERATION_TOKEN_STORE_TYPE` | `memory` | Federation token store backend: `memory` or `redis` |
420
- | `CONSENT_STORE_ADAPTER` | `none` | Consent store for clients that are not first-party (#527): `none` (such clients are refused) or `memory` (single replica) |
421
428
  | `REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY` | — | AES-256-GCM key for records at rest: 32 bytes, base64-encoded (`openssl rand -base64 32`). **Required** with `redis` unless the mode below is `allow-plaintext` |
422
429
  | `REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_MODE` | `required` | `required` or `allow-plaintext`. Plaintext is refused when the config was selected by a production/staging environment (`CONFIG_ENV` or `NODE_ENV`) and under `DEPLOYMENT_MODE=multi` in any environment, unless `FEDERATION_TOKENS_ALLOW_INSECURE=1` is also set — development only |
423
430
 
@@ -425,6 +432,152 @@ needs a key.
425
432
  `scanFallback` migration flag live under `redisFederationTokenStore` in a
426
433
  config layer rather than behind an environment variable.
427
434
 
435
+ ### Consent Store
436
+
437
+ Where an end-user's consent to a client that is not first-party is recorded,
438
+ together with the `/authorize` request parked while the consent page asks
439
+ (#527, #552). One switch wires both. `none` by default: such clients are
440
+ refused and only first-party clients are served. `memory` forks per replica and
441
+ is refused under `DEPLOYMENT_MODE=multi`; `redis` (#561) shares both over the
442
+ socket configured by `REFRESH_TOKEN_FAMILY_STORE_REDIS_URL`.
443
+
444
+ | Variable | Default | Description |
445
+ |---|---|---|
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) |
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
+
428
581
  ### Redis Namespacing
429
582
 
430
583
  For multi-tenant Redis clusters, set deployment-specific key prefixes so two
@@ -436,9 +589,10 @@ auth.provider instances cannot collide in the same database:
436
589
  | `REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX` | `rtfam:` | Prefix for refresh-token family records. |
437
590
  | `CLIENT_CODE_KEY_PREFIX` | `oauth:code:` | Prefix for OAuth authorization codes. |
438
591
  | `REDIS_FEDERATION_TOKEN_STORE_KEY_PREFIX` | `ft:` | Prefix for federation token records, their per-session index and their lock keys. |
592
+ | `REDIS_CONSENT_STORE_KEY_PREFIX` | `consent:` | Prefix for consent records and for parked consent requests with their per-session index (`CONSENT_STORE_ADAPTER=redis`, #561). |
439
593
 
440
594
  Use values that include the deployment name, for example `tenant-a:ss:`,
441
- `tenant-a:rtfam:`, `tenant-a:code:`, and `tenant-a:ft:`.
595
+ `tenant-a:rtfam:`, `tenant-a:code:`, `tenant-a:ft:`, and `tenant-a:consent:`.
442
596
 
443
597
  ### Endpoints
444
598
 
@@ -676,7 +830,9 @@ installGracefulShutdown(server, { logger, cleanup: () => handle.dispose() });
676
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.
677
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.
678
832
 
679
- **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.
680
836
 
681
837
  ## npm Scripts
682
838
 
@@ -159,10 +159,51 @@ 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
165
- # DEPLOYMENT_MODE=multi by name).
203
+ # DEPLOYMENT_MODE=multi by name); "redis" (#561) keeps the consent records and
204
+ # the requests parked while the consent page asks on the shared ioredis socket,
205
+ # and is what a deployment of more than one replica selects. Its key namespace
206
+ # is REDIS_CONSENT_STORE_KEY_PREFIX (default "consent:").
166
207
  consentStore {
167
208
  adapter = "none"
168
209
  adapter = ${?CONSENT_STORE_ADAPTER}
@@ -266,6 +307,12 @@ federations {
266
307
 
267
308
  # Where the browser goes after a callback carrying no `redirect_to`.
268
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}
269
316
  }
270
317
 
271
318
  # Any OpenID Connect provider (Okta, Entra ID, Auth0, Keycloak, your own
@@ -335,6 +382,14 @@ repositories {
335
382
  # layer this file gets the same override.
336
383
  # Without it, link=1 is refused with link_unsupported.
337
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}
338
393
  # Positive integer, milliseconds, <= 2147483647. Boot fails otherwise —
339
394
  # including on a blank env override, which HOCON substitutes as "".
340
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.13.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:*",
@@ -41,7 +42,7 @@
41
42
  "devDependencies": {
42
43
  "@types/express": "^5.0.6",
43
44
  "@types/express-session": "^1",
44
- "@types/node": "^26.5.0",
45
+ "@types/node": "^26.5.1",
45
46
  "@types/supertest": "^7.2.1",
46
47
  "supertest": "^7.1.0",
47
48
  "tsx": "^4.23.13",