feat(mcp): make token_exchange (OBO) production-ready - discovery threading + audit hardening + RFC 9728 challenge - #31622
Conversation
Greptile SummaryThis PR makes the OBO / token_exchange MCP auth mode production-ready by threading the caller's subject token through discovery (tools/list, prompts, resources), hardening the credential resolution path against bypasses and injection conflicts, adding an RFC 9728 challenge for missing/rejected subject tokens, and improving the exchange's reliability contract (tenant-isolated cache keys, a single reactive retry on upstream 401, and proper IdP error classification).
Confidence Score: 4/5Safe to merge with the caveat that OBO tool calls bypass the per-server concurrency semaphore, which matters whenever max_concurrency is configured for a token_exchange server. The OBO retry path (_obo_call_tool_with_retry) does not acquire the per-server concurrency semaphore that all non-OBO tool calls hold, so any max_concurrency limit configured on an OBO server is silently ignored for every OBO tool call. All other changes — credential hardening, challenge routing, cache isolation, IdP error classification, and the _ttl_seconds correction — look correct and are well-covered by the new tests. litellm/proxy/_experimental/mcp_server/mcp_server_manager.py — the _obo_call_tool_with_retry method and its call site need the concurrency semaphore applied.
|
| Filename | Overview |
|---|---|
| litellm/proxy/_experimental/mcp_server/mcp_server_manager.py | Large refactor for OBO discovery threading, retry-on-401, and challenge routing; the OBO retry path (_obo_call_tool_with_retry) does not acquire the per-server concurrency semaphore that the non-OBO path uses. |
| litellm/proxy/_experimental/mcp_server/outbound_credentials/token_exchanger.py | Adds SubjectTokenRejected/TokenExchangeClientError exception types, tenant isolation in cache key, invalidate, logging, float expires_in handling, and a corrected _ttl_seconds that floors at the token's own remaining lifetime. |
| litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py | Extracts oauth_protected_resource_path helper, makes raise_user_oauth_challenge and new raise_token_exchange_challenge pure (root_path injected), and widens _token_exchange_spec to own servers with credentials but no endpoint. |
| litellm/proxy/_experimental/mcp_server/outbound_credentials/token_exchange_provider.py | Adds SubjectTokenRejected / TokenExchangeClientError classification for IdP 4xx responses; gateway-fault OAuth error codes (invalid_client, etc.) map to misconfigured, caller fault maps to unauthorized. |
| litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py | Adds _obo_protected_resource_response and _jwt_auth_issuers for OBO PRM, and fixes the _build_oauth_protected_resource_response order so OBO servers get their own metadata before falling through to the gateway default. |
| litellm/proxy/_experimental/mcp_server/server.py | Forwards oauth2_headers to _get_tools_from_server, uses centralized _should_strip_caller_authorization for OpenAPI path, and adds preemptive RFC 9728 challenge for OBO servers with no subject token at connect time. |
| litellm/experimental_mcp_client/client.py | Adds raise_on_error parameter to call_tool and extracts error_tool_result static helper to avoid code duplication. |
| tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py | New tests for OBO discovery threading, 401 surfacing, token-thread mode gating, and retry behavior; existing tests refactored for readability only. |
| tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_token_exchanger.py | New tests for IdP rejection → unauthorized, gateway fault → misconfigured, transport failure → 503, tenant isolation in cache, invalidate targeting, and logging of bad token_type. |
| tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py | Tests refactored to inject root_path directly instead of monkeypatching; new tests for oauth_protected_resource_path and raise_token_exchange_challenge. |
| tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py | Mostly line-length reformatting of existing tests; no changes to assertions or mock behavior. |
| tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_stale_session.py | Reformatted assertions plus a new test for the preemptive OBO challenge at connect time. |
| tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py | Adds invalidate_credentials tests and updates _FakeExchanger to track tenant_id in call records. |
| tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_token_exchange_provider.py | New file with tests for IdP 4xx classification (SubjectTokenRejected vs TokenExchangeClientError vs None for 5xx). |
Comments Outside Diff (1)
-
litellm/proxy/_experimental/mcp_server/mcp_server_manager.py, line 3368-3392 (link)OBO tool calls bypass the per-server concurrency semaphore
The non-OBO branch wraps
client.call_toolinsideasync with self._limit_outbound_concurrency(mcp_server), but_obo_call_tool_with_retry(both the initial call and the single retry) never acquires that semaphore. If an operator has setmax_concurrencyon an OBO server, concurrent OBO tool calls are not rate-limited and can flood the upstream, defeating the guard entirely.
Reviews (3): Last reviewed commit: "style(mcp): PEP 604 union in the OBO ret..." | Re-trigger Greptile
Greptile SummaryThis PR threads caller OAuth context into MCP tool discovery. The main changes are:
Confidence Score: 5/5The changes are narrow, scoped to OAuth token-exchange discovery, and covered by targeted tests for the new threading behavior and non-leak cases. The implementation matches the described call-path behavior, preserves existing graceful degradation for listing, and includes tests for the relevant auth-mode boundaries.
What T-Rex did
Reviews (1): Last reviewed commit: "feat(mcp): thread the caller token into ..." | Re-trigger Greptile |
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
153fba7 to
915d562
Compare
This comment was marked as outdated.
This comment was marked as outdated.
mateo-berri
left a comment
There was a problem hiding this comment.
Is the veria concern legit?
e9e752c to
967b2f5
Compare
25218ed to
fe9f073
Compare
967b2f5 to
5bcf4aa
Compare
4c8729b to
2d4f870
Compare
2d4f870 to
c5e32f5
Compare
…n_exchange A token_exchange (OBO) server's tools could not be discovered through the aggregator: the list path never threaded the caller's token, so every tools/list hit the no-subject branch. v1 masked this with its client_credentials fallback (discovery used a service token); v2 dropped that fallback, so listing had no credential and the OBO server's tools never appeared - and an MCP client lists before it calls. Thread the inbound subject_token into the list path the same way the call path does, gated on auth_type oauth2_token_exchange so the caller's bearer never leaks into other modes: _get_tools_from_server takes an oauth2_headers param, extracts the token via _extract_bearer_token, and passes it to _create_mcp_client; server.py forwards oauth2_headers at the list call site. authorization_code (resolves off identity plus stored token), the static/config modes, and the background registry refresh are unaffected, and the list path's existing graceful degradation (catch -> empty list) is preserved.
…s_in, subject_token_type) - _should_strip_caller_authorization returns True for oauth2_token_exchange, so the inbound subject token is never forwarded upstream raw - only the IdP-exchanged token is (matches authorization_code). - _parse_expires_in accepts a JSON float / numeric-string expires_in, and _ttl_seconds caps the cache TTL at the token's real remaining lifetime so a short-lived exchanged token is never served stale. - to_server_spec normalizes a falsy subject_token_type to the default URN, parity with v1. The subject/key disambiguation (never exchange the LiteLLM key; Authorization: Bearer <litellm-key> support for /mcp) is intentionally a separate cross-cutting PR off staging, not part of this OBO work.
…nto prompts/resources The per-server x-mcp-* override guard in _create_mcp_client only kept the v2 spec for authorization_code, so a caller-supplied header silently disabled the RFC 8693 exchange on a token_exchange server and forwarded the raw bearer upstream. Extend the guard to token_exchange so the exchange always runs and the caller cannot substitute an arbitrary upstream credential. prompts/list+get, resources/list+read, and resource-templates/list never threaded the OBO subject token, so those operations failed closed (401 / empty) on a token_exchange server. Thread the caller's bearer as the subject for those paths too, gated on the token_exchange mode via a shared _obo_subject_token helper.
…centralize OpenAPI strip A guardrail (e.g. MCPJWTSigner), static_headers, or any other injected Authorization could shadow the resolver-owned credential for token_exchange / authorization_code servers, so the upstream would receive e.g. the signer's JWT instead of the exchanged token and reject it. In _create_mcp_client the resolver-owned credential now wins: a conflicting header is dropped and the minted/stored token reaches upstream. No behavior change for none/passthrough/static modes, where an injected Authorization still wins as before. The OpenAPI/local _request_extra_headers forwarder gated its Authorization strip on has_client_credentials only, so an OpenAPI-backed token_exchange server with extra_headers:[Authorization] forwarded the raw subject token upstream and never exchanged. It now uses the centralized _should_strip_caller_authorization so it matches the managed paths.
OBO previously returned an opaque 401 (Bearer error="invalid_request") with no discovery info, and any IdP exchange failure collapsed to a retryable 503. Now an OBO server behaves like a standards-compliant OAuth resource server: - A missing/rejected subject token returns the RFC 9728 / RFC 6750 challenge: 401 + WWW-Authenticate: Bearer resource_metadata="...", error="invalid_token", so a spec-compliant MCP client can discover the IdP, SSO, and retry with a fresh subject token. - The protected-resource metadata for a token_exchange server advertises the JWT-auth issuer(s) (JWT_ISSUER / litellm_jwtauth.issuers) as authorization_servers -- the IdP that issues and validates the subject -- instead of the gateway. - An IdP 4xx (subject rejected) is now a non-retryable 401 (the challenge) instead of a 503, so a caller with a dead token re-authenticates rather than looping; 5xx/transport stays retryable 503.
…t client can discover the IdP A token_exchange server's tools are not discoverable without a subject token (list is lenient -> empty), and a tool-call-time 401 is wrapped into a JSON-RPC error so the WWW-Authenticate header is lost. So a cold-start client never saw the challenge and could not start discovery. Add a token_exchange branch to the preemptive-401: a no-subject connect to an OBO server now returns 401 + WWW-Authenticate: Bearer resource_metadata=..., error="invalid_token" at the transport level, so a spec-compliant client discovers the IdP (the PRM advertises the JWT-auth issuer), SSOs, and retries with a subject token. Verified live on the per-server endpoint; the with-subject connect still proceeds (no challenge). (Also formats two lines from earlier commits in this stack.)
The adapter's raise_user_oauth_challenge and raise_token_exchange_challenge
reached into os.getenv("SERVER_ROOT_PATH") via get_server_root_path(), a
hidden ambient read in a module that is meant to be a pure edge. That coupling
made the preemptive-challenge test order-dependent under xdist: a sibling test
sets SERVER_ROOT_PATH at import without cleanup, leaking the prefix into the
challenge URL and failing the exact-match assertion.
Resolve the root path at the imperative-shell call sites and pass it in
keyword-only, so both challenge builders become pure functions of their inputs.
Extract the shared resource_metadata path construction into a single
oauth_protected_resource_path helper, collapsing the duplicated prefix/name
logic the two functions carried.
Also reduce _create_mcp_client below the strict complexity ceiling by extracting
the v2 credential resolution into _resolve_v2_auth, and extract the OBO
protected-resource-metadata branch into _obo_protected_resource_response (which
shipped without coverage) so discovery can be unit-tested directly.
Tests are now hermetic: the adapter tests pass root_path as a real input rather
than monkeypatching the environment, the stale-session preemptive test asserts
structural invariants instead of the exact prefixed URL, and five new tests
cover the OBO PRM issuer branch end to end.
…ity logs From a pass over the OBO behavior contract. Three changes to the token_exchange arm, none of which alters any other auth mode. The exchanged-token cache key now folds in the caller's tenant alongside the subject token and exchange config, so two tenants presenting the same opaque token can never share a cache entry; cross-tenant isolation is structural rather than incidental to subject-token uniqueness. tenant_id is threaded from the resolver's Subject; it is keyword-only with an empty default so the no-tenant case and the existing call sites are unchanged. The tool-call path gains one reactive retry. When an upstream rejects the injected token with a 401/403, the gateway invalidates the cached exchange, re-mints once through the IdP by rebuilding the client, and retries the call exactly once before surfacing the upstream error, so a token revoked or rotated upstream mid-TTL self-heals without an infinite loop. It is gated strictly to oauth2_token_exchange; passthrough, authorization_code, client_credentials, api_key, and none keep their single-call behavior. MCPClient.call_tool gains a raise_on_error flag (mirroring list_tools) so the path can tell an upstream 401 apart from an ordinary tool error and avoid re-running a non-idempotent tool on a non-auth failure. The exchanger also emits the v1-parity log lines it had dropped (attempt with server, endpoint and audience; success; cache hit), while never logging the form, subject token, secret, or minted token.
…dpoint A true token_exchange (OBO) server must use only an explicitly configured token endpoint; it must never guess an IdP or silently fall back to a weaker source. Previously an OBO server with client credentials but no token_exchange_endpoint/token_url deferred to v1, which no-op'd and let the request connect to the upstream with no credential (an upstream 401 rather than a clear gateway error). Now such a server is owned by the v2 arm: _token_exchange_spec builds the spec even when the endpoint is absent, and the exchanger fails closed with a precondition_required error that maps to HTTP 412 before any upstream or IdP call, with the caller's subject token never sent anywhere. A missing client_id/secret still maps to misconfigured (500); a present-but-rejected subject still maps to 401; an unreachable IdP still maps to 503. The no-subject case keeps its existing 401 RFC 9728 challenge.
c5e32f5 to
67c7d32
Compare
mateo-berri
left a comment
There was a problem hiding this comment.
The non-OBO branch wraps client.call_tool inside async with self._limit_outbound_concurrency(mcp_server), but _obo_call_tool_with_retry (both the initial call and the single retry) never acquires that semaphore. If an operator has set max_concurrency on an OBO server, concurrent OBO tool calls are not rate-limited and can flood the upstream, defeating the guard entirely.
Is this a legit concern?
no |
Relevant issues
Stacked on #31526 (the token_exchange / OBO arm). Base is that branch so the diff is just this work; it retargets to
litellm_internal_stagingafter #31526 mergesLinear ticket
N/A
What this is
Makes token_exchange (OBO) production-ready on top of #31526. It began as the discovery fix (an OBO server's tools could not be listed through the aggregator) and then folded in a set of hardening fixes found by an adversarial audit of every OBO code path, credential shape, IdP failure mode, and third-party MCP client interaction. Each fix below is a separate commit with its own regression tests
1. Discovery threading (the original fix)
With #31526, OBO tool calls work, but an OBO server's tools could not be discovered. The
tools/listpath never threaded the caller's token, so every list hit the no-subject branch of the resolver. v1 masked this with a no-subject client_credentials fallback (discovery quietly used a service token); #31526 dropped that fallback per the v2 rule against falling through to a weaker source, so listing had no credential and the tools never appeared. Since an MCP client lists before it calls, the tools were invisibleThe fix gives discovery the caller's own token:
_get_tools_from_serverextracts the inboundsubject_tokenvia the existing_extract_bearer_tokenand passes it into_create_mcp_client, gated onauth_type == oauth2_token_exchangeso the bearer never leaks into other modes;server.pyforwardsoauth2_headersat the list call site. The list path's graceful degradation (a failed per-server fetch returns an empty list, never crashing the catalog) is preserved2. Hardening fixes from the audit
a. A caller header can no longer bypass the exchange. The
_create_mcp_clientguard that ignores a per-requestx-mcp-*override only protectedauthorization_code; on a token_exchange server the override dropped the v2 spec, skipped the RFC 8693 exchange, and forwarded the caller's raw header upstream. The guard now also coverstoken_exchange, so the exchange always runs and a caller cannot substitute an arbitrary upstream credential to defeat the audience-scopingb. OBO now works for prompts and resources, not just tools.
prompts/list,prompts/get,resources/list,resources/read, andresource-templates/listnever threaded the subject token, so those operations failed closed (empty or 401) on an OBO server. They now thread the caller's bearer like the tools paths, via a shared_obo_subject_tokenhelper gated on the modec. The resolver-owned credential is authoritative. A guardrail such as
MCPJWTSigner,static_headers, or any injectedAuthorizationcould shadow the exchanged token, so the upstream received the injected JWT instead of the minted token and rejected it (no warning fired). Fortoken_exchangeandauthorization_code,_create_mcp_clientnow drops the conflicting header and keeps the resolved token. No change fornone/ passthrough / static modes, where an injected Authorization still wins. This makes the signer-plus-OBO combination coherent instead of silently broken; the two are alternative identity-propagation strategies (LiteLLM-issued vs IdP-exchanged) that should never both reach one upstreamd. The OpenAPI path no longer leaks the raw subject token. The OpenAPI / local
_request_extra_headersforwarder gated its strip onhas_client_credentialsonly, so an OpenAPI-backed token_exchange server withextra_headers: [Authorization]forwarded the raw subject token upstream and never exchanged. It now uses the centralized_should_strip_caller_authorization, matching the managed pathse. RFC 9728 challenge on unauthorized. OBO returned an opaque
401 Bearer error="invalid_request"with no discovery info, and any IdP exchange failure collapsed to a retryable 503. An OBO server now behaves like a standards-compliant OAuth resource server:The challenge is emitted preemptively at connect (in the per-server preemptive-401), because the OBO tools are not discoverable without a subject and a tool-call-time 401 is wrapped into a JSON-RPC error so the WWW-Authenticate would be lost. The protected-resource metadata for an OBO server advertises the JWT-auth issuer(s) (
JWT_ISSUER/litellm_jwtauth.issuers) asauthorization_servers, which is the same IdP that issues and validates the subject token. So a spec-compliant MCP client discovers the IdP, SSOs, and retries with a fresh subject token, which LiteLLM then exchanges. An IdP 4xx (subject rejected) is a non-retryable 401 (the challenge) so a caller with a dead token re-authenticates instead of looping; 5xx and transport failures stay a retryable 5033. Pure challenge edge (follow-up refactor)
The two challenge builders in the
outbound_credentialsadapter readSERVER_ROOT_PATHambiently viaget_server_root_path(), a hidden environment read inside a module that is supposed to be a pure edge. That coupling made the preemptive-challenge test order-dependent under xdist (a sibling test setsSERVER_ROOT_PATHat import without cleanup, leaking the prefix into the challenge URL). The root path is now resolved at the imperative-shell call sites and injected keyword-only, soraise_user_oauth_challengeandraise_token_exchange_challengeare pure functions of their inputs; the duplicatedresource_metadatapath construction collapses into oneoauth_protected_resource_pathhelper. The adapter tests passroot_pathas a real value instead of monkeypatching the environment, and the stale-session preemptive test asserts structural invariants rather than the exact prefixed URL so a leaked env var can no longer break it.Two adjacent cleanups rode along:
_create_mcp_clientdropped back under the strict complexity ceiling by extracting the v2 credential resolution into_resolve_v2_auth, and the OBO protected-resource-metadata branch (which had shipped without coverage) moved into_obo_protected_resource_responsewith five new tests covering the issuer branch end to end4. Contract-audit follow-ups (cache identity, retry, logging)
A pass over the OBO behavior contract surfaced three more gaps that land here. The exchanged-token cache key now folds in the caller's tenant alongside the subject token and exchange config, so two tenants presenting the same opaque token can never collide on one cache entry; isolation is structural rather than a side effect of subject-token uniqueness. The tool-call path gained a single reactive retry: when an upstream rejects the injected token with a 401/403, the gateway invalidates the cached exchange, re-mints once through the IdP, and retries the call exactly once before surfacing the upstream error, so a token revoked or rotated upstream mid-TTL self-heals without an infinite loop. This is gated strictly to
oauth2_token_exchange, so passthrough, authorization_code, client_credentials, api_key, and none keep their existing single-call behavior. The exchanger also now emits the v1-parity log lines it had dropped (an attempt line with server, endpoint, and audience, a success line, and a cache-hit line), while still never logging the form, subject token, secret, or minted token.One contract item is deliberately left for later. The exchange does not capture or use an RFC 8693
refresh_token: every expiry re-runs the exchange rather than refreshing, matching v1 and avoiding speculative refresh plumbing for IdPs that mostly do not issue OBO refresh tokens. The cache plus single-flight already keep the IdP round-trips off the hot path, so re-exchange-on-expiry is the intended lifetime here. RFC 9728 to RFC 8414 discovery of the token endpoint (so the endpoint need not be configured) is tracked as a separate follow-up rather than bundled hereToken endpoint resolution is now strictly config-driven and fails closed. A token_exchange server uses only an explicitly configured
token_exchange_endpoint/token_url; the gateway never guesses an IdP or falls back to a weaker source. An OBO server that carries client credentials but no endpoint is owned by the v2 arm rather than deferred to v1, so a missing endpoint returns a clean412(precondition) before any upstream or IdP call and the caller's subject token is never sent anywhere. The no-subject case keeps its401RFC 9728 challenge, a rejected subject stays401, and an unreachable IdP stays503.5. List-time 401 no longer masked (contract follow-up)
The list path still had one masking gap. When the resolver raised its 401 challenge while building the client (a present-but-rejected subject token, or an
authorization_codeserver with no stored token),_get_tools_from_servercaught it in the broadexcept Exception: return []and the server showed zero tools with no challenge, so a client could not tell "no tools" from "authenticate and retry". The preemptive connect-time challenge only covers the no-subject case; a subject the IdP rejects fails later, at exchange time, inside that tryThe fix routes a v2 resolver auth failure through the same
MCPUpstreamAuthErrorchannel pass-through already uses._get_tools_from_servernow converts a 401/403HTTPExceptionintoMCPUpstreamAuthError, preserving theWWW-Authenticateheader, so a single-server route surfaces the challenge (the client re-authenticates) while the multi-server aggregator keeps absorbing it into an empty contribution for that one server. A non-auth error (412 no endpoint, 503 IdP down) stays absorbed as before, so one misconfigured server cannot blank the whole catalog. This is mode-agnostic, soauthorization_codelist discovery gets the same un-masking for freeAlongside it, the exchanger now logs a warning when it refuses a non-Bearer
token_typefrom the IdP (the fail-closed check itself lands in #31526; this adds the observability line now that this branch carries the exchanger's logging)Auth model recap
A caller can present credentials two ways. Canonical single-JWT:
Authorization: Bearer <IdP JWT>with nox-litellm-api-key, where the same JWT authenticates to LiteLLM via JWT auth and is the subject that gets exchanged (requires JWT auth configured against that IdP). Two-header:x-litellm-api-key: Bearer <litellm-key>for LiteLLM auth plusAuthorization: Bearer <subject>for the exchange. In both, the subject always comes fromAuthorization, never fromx-litellm-api-key, and only the exchanged token reaches the upstreamScreenshots / Proof of Fix
Verified end to end against a real Keycloak IdP (standing in for Okta), not a mock exchanger. The setup is Keycloak 26.2 with standard RFC 8693 token exchange enabled (realm
litellm, useralice, a confidentiallitellm-exchangeclient the gateway authenticates the exchange with, andupstream-apias the exchange audience), a dedicated Postgres, a mock upstream MCP server that logs everyAuthorizationit receives, and the proxy from this branch run with--use_v2_migration_resolver, JWT auth enabled, and the enterprise license.The flow proven is exactly the OBO contract: SSO with Keycloak to obtain an
<IdP JWT>, send it to LiteLLM, LiteLLM exchanges it at Keycloak's token endpoint for an upstream-scoped access token, and that minted token (never the JWT) is what reaches the upstream. The subject JWT and the token the upstream actually received, decoded side by side:It is a different token, re-scoped to the upstream audience, and the raw subject string never appears in the upstream's request log.
A full battery covering both credential shapes and every fail-closed path:
The no-subject challenge, verbatim:
The two fail-closed call paths surface in the MCP tool result: a token_exchange server with no configured endpoint returns
precondition required: token exchange endpoint is not configured for this server(412, with no IdP guessing and no fallback to a weaker source), and an invalid subject that Keycloak rejects with a 4xx returnsUnauthorized(a non-retryable 401, not a retryable 503). Each behavior above also ships with unit regression tests that fail on the pre-fix codeType
New Feature
🐛 Bug Fix
Changes
See the numbered sections above. Touched:
mcp_server_manager.py,server.py,discoverable_endpoints.py, and theoutbound_credentialsadapter / exchanger / provider, with tests acrosstest_mcp_server_manager.py,test_adapter.py,test_token_exchanger.py,test_resolver.py,test_discoverable_endpoints.py,test_mcp_stale_session.py, and theexperimental_mcp_clientclient testsDeliberately not implemented
The single JWT (SSO) shape, where the caller's IdP JWT both authenticates to LiteLLM and serves as the exchange subject, is the intended mode. The two-header shape (LiteLLM api key plus a separate Authorization subject) remains reachable but is not advertised, and in that shape the gateway does not pre-validate the subject's signature or expiry before the exchange; the IdP is the authority that accepts or rejects it. Delegation via
actor_token(RFC 8693 section 4.1) is intentionally absent; the arm implements impersonation only