Commit graph

579 commits

Author SHA1 Message Date
Tin Chi Lo
2f349f6cd1 fix(mcp): coerce numeric expires_in and make the active-key check total
Two correctness gaps in the bridge mint. _bridge_grant_from_token_response only
accepted an int expires_in, dropping a float (3600.0) or numeric-string ('3600')
lifetime to None so the envelope fell back to its 1h cap and could outlive a
shorter-lived upstream token; coerce it to a positive int (bool excluded). And
_key_is_active called datetime.fromisoformat on the str|datetime expires outside
the resolver's try, so a malformed stored expiry raised an unhandled 500 instead
of the fail-closed invalid_request; it now fails closed (inactive) on an
unparseable expiry. Regression tests cover int/float/string/bool coercion, the
short-float TTL, and the malformed-expiry fail-closed path.
2026-07-11 19:20:03 -07:00
Tin Chi Lo
362c78e308 fix(mcp): let a keyless-user active key mint a bridge envelope
_resolve_active_litellm_key gated on _active_key_user_id, which returns None both
for blocked/expired keys AND for valid keys with no user_id, so a team-scoped or
service-account key was wrongly rejected with invalid_request at bridge token
exchange. Split the active-state gate (_key_is_active: blocked/expiry only) from
the user_id extraction; the mint seals the key hash, not the user, and admission
already handles a keyless-user key. The per-user token store still gets no user
for such a key, as there is none to key a stored credential by.
2026-07-11 19:20:03 -07:00
Tin Chi Lo
7df848aa6c fix(mcp): return 502 not KeyError when a bridge upstream response lacks access_token
The eager access_token = token_response["access_token"] extraction ran before
the dcr_bridge branch, so a missing upstream access_token raised an unhandled
KeyError and _bridge_grant_from_token_response's nil guard (which maps to a clean
502) was dead code. Move the extraction onto the non-bridge result path so the
bridge branch reaches its 502 guard.
2026-07-11 19:20:03 -07:00
Tin Chi Lo
85255c96fb feat(mcp): seal the authorizing key hash in the dcr_bridge envelope
The mint bound only user_id/server_id into the envelope, which gave admission
no way to reload the caller's key and enforce its current restrictions. Seal the
hashed authorizing key instead (a one-way digest, not a usable credential), so
admission reloads the live UserAPIKeyAuth by it and the key's team/org/tool
permissions and revocation apply per request.

Extract the token endpoint's key resolution into a shared _resolve_active_litellm_key
so the per-user token store (user_id) and the bridge mint (key hash) derive from one
active-key-gated path, and fail the mint closed with invalid_request when no active
key accompanies the request.
2026-07-11 19:20:03 -07:00
Tin Chi Lo
34c6cce705 feat(mcp): mint gateway-bound envelope at the token endpoint for dcr_bridge oauth_delegate 2026-07-11 19:20:03 -07:00
Tin Chi Lo
1c3c1af529 fix(mcp): run proxy-wide pre-DB gates on bridge envelope admission
The envelope arm bypasses user_api_key_auth, so it never ran
pre_db_read_auth_checks (request-size and body-safety limits, the IP allowlist,
and the general_settings route allowlist) that the normal MCP admission path
runs before any key lookup. A caller blocked by IP or a disallowed proxy route
could be admitted through an envelope where the same principal on the normal
path is rejected. Run those gates before the envelope crypto, mirroring the
pipeline's pre-DB ordering; a blocked IP or route surfaces its own 403.
2026-07-11 14:39:57 -07:00
Tin Chi Lo
688f535bbf fix(mcp): run the route gate on bridge admission so allowed_routes are enforced
The envelope arm reloaded the identity and ran _run_centralized_common_checks
but skipped RouteChecks.should_call_route, which the standard pipeline runs
between the builder and common_checks. Because the centralized checks treat MCP
as an inference route and never re-check allowed_routes, a key barred from MCP
routes could mint an envelope at the token endpoint (not itself an MCP route)
and replay it against MCP. Run the route gate before admitting, and clear the
request-scoped budget_reservation, matching the wrapper's sequence; a disallowed
route now surfaces the gate's own 403.
2026-07-11 13:34:23 -07:00
Tin Chi Lo
f5f03cbd63 fix(mcp): map a DB outage during bridge key reload to a retryable 503
get_key_object's raw transport error propagated uncaught out of
_reload_admitted_key as an opaque 500; classify it via the shared
_raise_503_if_db_unavailable helper (also used by the live-policy gate) so a
database outage is a retryable 503, while a key-not-found ProxyException stays
the fail-closed 401.
2026-07-11 12:46:09 -07:00
Tin Chi Lo
ea64ef7a2a fix(mcp): surface real status from bridge admission policy gate instead of flattening to 401
Over-budget rendered 401 (should be 429), model-access and other typed
failures collapsed to 401, and a transient DB outage was masked as an auth
error. Mirror UserAPIKeyAuthExceptionHandler: budget maps to 429, a sub-check's
own HTTPException/ProxyException keeps its status, a DB outage is a retryable
503, and only a genuinely unresolvable failure stays the fail-closed 401.
2026-07-11 12:23:15 -07:00
Tin Chi Lo
9e6d6e509c fix(mcp): route bridge admission through the centralized policy gate and mirror the SCIM owner check 2026-07-11 12:23:15 -07:00
Tin Chi Lo
c59f16f42e fix(mcp): enforce team block and alias-priority token injection on bridge admission
Two follow-ups on the envelope admission arm flagged in review.

Team revocation bypass: _reload_admitted_key checked only the key's own
blocked/expires, so blocking a key's team left every envelope minted under it
live until expiry. Reload the team and reject a blocked team, mirroring
common_checks, so a team block revokes its envelopes immediately.

Caller-overridable upstream token: egress resolves the per-server auth header
alias-first, but injection keyed under server_name, so for a server with a
distinct alias a caller-forwarded x-mcp-{alias}-authorization sat at the
higher-priority slot and paired the admitted identity with an attacker's
upstream credential. Inject under alias-first so the sealed token owns the slot
egress resolves.
2026-07-11 12:23:15 -07:00
Tin Chi Lo
50cc2c01cf fix(mcp): admit dcr_bridge envelopes under the live key, not a frozen identity
The bridge envelope sealed only user_id/server_id, and admission fabricated a
UserAPIKeyAuth(user_id=...) with no object_permission, team_id, org_id, or key
identity. Downstream MCP permission checks read the missing restrictions as
unrestricted, so a caller holding a valid envelope for a restricted key could
reach tools and servers that key was never granted, and a revoked key kept
working until the envelope expired.

Bind the hashed authorizing key into the envelope identity and reload the live
UserAPIKeyAuth by it at admission via get_key_object, failing closed with a 401
when the key is missing, blocked, or expired. Authorization is resolved fresh
per request instead of frozen at mint time, so current key/team/org and tool
restrictions plus revocation are enforced.
2026-07-11 12:23:15 -07:00
Tin Chi Lo
46977d6e4c feat(mcp): admit dcr_bridge oauth_delegate clients via a single envelope bearer 2026-07-11 12:23:15 -07:00
tin-berri
2631ce7bc9
Merge pull request #32527 from BerriAI/litellm_gh32473_dcr_redirect_uri
fix(mcp): re-register DCR client when proxy origin no longer matches its registered redirect_uri
2026-07-11 11:25:07 -07:00
Tin Chi Lo
168bc38d81 fix(mcp): strip scheme default port from get_request_base_url netloc 2026-07-11 10:44:23 -07:00
Tin Chi Lo
f308bd99d9 fix(mcp): re-register DCR client when proxy origin no longer matches its registered redirect_uri
A dynamically registered (RFC 7591) OAuth client persisted onto the MCP server row is bound to the redirect_uri it was first registered with, but that binding was never recorded. After the proxy's public origin changed, every authorize paired the reused client with the new callback and the IdP rejected it permanently.

The DCR persist now records redirect_uris alongside the client identity. The admin register path treats a positive mismatch between the recording and the current callback as stale and re-registers a replacement client; rows without a recording (pre-existing installs and admin-configured clients) are grandfathered so upgrades never re-mint client_ids or orphan refresh tokens. The persist also writes client_secret and token_endpoint_auth_method explicitly as None when absent so the credential blob merge cannot pair a re-registered public client with the previous client's secret. Public register routes and non-admin callers keep existing behavior.

Closes #32473
2026-07-11 10:12:20 -07:00
tin-berri
5205af9d13
Merge pull request #32815 from BerriAI/litellm_mcp_credential_class_merge
fix(mcp): merge credentials within the client-forwarded class on an auth-type switch
2026-07-10 17:06:31 -07:00
Tin Chi Lo
0053527ccf fix(mcp): keep bridge server-id match total on non-ascii and strip bearer with any whitespace 2026-07-10 15:30:03 -07:00
Tin Chi Lo
ef9637035b fix(mcp): derive bridge envelope keys with memory-hard scrypt, cached per master key 2026-07-10 15:04:37 -07:00
Tin Chi Lo
08963b744f feat(mcp): bind bridge envelope to its server, key derivation via HMAC, add producer + shape helpers 2026-07-10 14:36:56 -07:00
Tin Chi Lo
424532e63d feat(mcp): add dcr_bridge envelope consumer helpers (key derivation, authorization classifier) 2026-07-10 14:36:56 -07:00
Tin
7d64f9d26b fix(mcp): merge credentials within the client-forwarded class on an auth-type switch 2026-07-10 14:15:24 -07:00
Tin Chi Lo
9176744735 perf(mcp): O(1) character precheck before the exact byte size guard in open_envelope 2026-07-10 13:56:49 -07:00
Tin Chi Lo
2883e36a97 fix(mcp): measure envelope open-side size cap in utf-8 bytes to match mint 2026-07-10 13:32:00 -07:00
Tin Chi Lo
d6503d1d87 fix(mcp): make open_envelope total over hostile jwt claim types and cap candidate size 2026-07-10 13:16:10 -07:00
Tin Chi Lo
dd38e9f1a0 fix(mcp): return malformed_payload for signed envelopes with empty identity claims
A correctly signed JWT whose user_id or server_id claim was an empty string
passed claims validation but raised ValidationError from the EnvelopeIdentity
constructor inside open_envelope, breaking its never-raises guarantee. The
claims model now mirrors the identity's min_length constraints, so any claim
set that validates also constructs, and the empty-identity case maps to
MalformedPayload like every other bad claim shape.
2026-07-10 13:16:10 -07:00
Tin Chi Lo
65c80919aa feat(mcp): add sealed envelope module for dcr_bridge client-held credentials
Pure, unwired module: mints and opens the single client-held bearer that
carries both a litellm identity and the encrypted upstream OAuth grant with
zero server-side storage. HS256 JWT signing (same approach as the BYOK
session bearer) plus the existing encrypt_value/decrypt_value symmetric
helpers, with all key material and the clock injected as parameters. Opening
returns typed frozen error values (not_an_envelope, bad_signature, expired,
malformed_payload, decrypt_failed); minting rejects envelopes over
MAX_ENVELOPE_BYTES with a typed error instead of truncating. Error values
and reprs never carry token material.
2026-07-10 13:16:10 -07:00
Tin Chi Lo
849ceb0c19 feat(mcp): relay upstream registration errors to the client on the dcr_bridge arm 2026-07-10 12:37:47 -07:00
Tin Chi Lo
04a11a439a feat(mcp): dcr_bridge discovery facade and register relay 2026-07-10 12:37:47 -07:00
tin-berri
69f0a6d5b6
Merge pull request #32747 from BerriAI/litellm_lit4337_dcr_bridge_authorize_relay
feat(mcp): dcr_bridge authorize and token relay redirect handling with mandatory S256
2026-07-10 12:36:52 -07:00
tin-berri
220aad0e7f
Merge pull request #32715 from BerriAI/litellm_lit4284_semantic_filter_fail_closed
fix(mcp): fail closed and surface semantic filter context window errors
2026-07-10 12:33:46 -07:00
Tin Chi Lo
58f1814cd7 fix(mcp): surface rejected delegate-auth upstream tokens as connect-time 401
For MCP servers with auth_type=oauth2 + delegate_auth_to_upstream=true, a
client-supplied upstream token that the upstream rejects was masked: the
upstream 401 raised during tools/list is absorbed by the list handler, so on a
single-server route a rejected token became HTTP 200 with an empty tool list.
Clients showed "0 tools" instead of re-authenticating, and monitoring never saw
an unauthorized signal.

Extend the connect-time preflight _check_passthrough_upstream_auth to probe
delegate-auth servers with the caller's bare Authorization bearer, reusing the
existing _probe_upstream_auth and the RFC 6750 challenge builder, so a rejected
token fails the connect with 401 + WWW-Authenticate error="invalid_token" and a
compliant client re-runs the upstream OAuth flow.

The bare Authorization header is a valid upstream token only when admission took
the delegate bypass, so the delegate target is resolved through
get_mcp_server_by_name (the same resolver admission uses) rather than the wider
allowed-server prefix/access-group matching. A name that reaches a delegate
server only via server_id or an access group is admitted as a real LiteLLM key,
so probing it would leak that key upstream; requiring the admission-resolver
match closes that gap. The probe is gated to single-server routes (matching the
OBO preflight), keyed to the caller's authorized set by server_id, and the
challenge echoes the requested name so aliased routes get the same
resource_metadata URL as the tokenless preemptive challenge. Tokenless requests
keep flowing to the preemptive discovery challenge unchanged.

Resolves LIT-4194
2026-07-10 12:11:04 -07:00
tin-berri
b9008cca35
Merge pull request #32556 from BerriAI/litellm_mcp_passthrough_call_relay
feat(mcp): relay upstream 401 on client-forwarded pass-through tool calls
2026-07-10 11:56:56 -07:00
Tin Chi Lo
aa4f585e4c feat(mcp): dcr_bridge authorize and token relay redirect handling with mandatory S256 2026-07-10 11:46:01 -07:00
tin-berri
bca3e88c5f
Merge pull request #32745 from BerriAI/litellm_lit4337_dcr_bridge_plumbing
feat(mcp): add dcr_bridge column and plumbing for client-forwarded auth modes
2026-07-10 11:45:21 -07:00
Tin
e33654be91 feat(mcp): relay upstream 401 on client-forwarded pass-through tool calls
The multi-server list path already relays an upstream 401 from a client-forwarded
server (true_passthrough / oauth_delegate) as an MCPUpstreamAuthError so the caller
re-runs its own upstream OAuth. The single-server REST call path did not: an upstream
401 was masked as a graceful isError result, so an MCP client holding an expired
upstream token never learned it had to re-authenticate

Relay the upstream 401 on the call path too. For these modes the manager calls the
client with raise_on_error=True, extracts the WWW-Authenticate through the existing
upstream-auth exception walk, and raises MCPUpstreamAuthError; the REST endpoint turns
it into a real 401 + WWW-Authenticate. Only 401 is treated as a re-auth signal (a 403 is
a genuine authorization failure that re-auth will not fix, so it stays a masked isError
with a visible warning), matching the list path and MCPUpstreamAuthError's contract. The
legacy oauth2 + delegate_auth_to_upstream mode is deliberately left off the call-path
relay since it is being removed

To keep this expected caller-must-reauth signal from tripping error-rate alerts, the
client layer logs at debug when the caller opted into raise_on_error and therefore owns
the exception (both call_tool/list_tools and the run_with_session helper they share, so an
expected re-auth emits no warning per call either), the manager's non-auth branch logs the
exception type only (never str(e), which for an httpx error embeds the upstream URL a
credential can hide in), and the streamable and REST handlers log the relayed 401 at info
rather than as an error with a traceback

Tests cover the manager raising on a client-forwarded 401 while keeping a 403/503 as a
masked isError, the client-layer debug-vs-error logging split, the streamable handler's
informational isError, and the REST endpoint relaying both the direct and virtual
mcp_tool_call branches as a real 401 + WWW-Authenticate; each was mutation-checked to fail
when the corresponding behavior is broken
2026-07-10 10:09:31 -07:00
Tin Chi Lo
898182b0e6 fix(mcp): redact provider error from client-facing semantic filter message
Keep the full provider exception in server-side logs only; the client
receives a fixed actionable message. Also follow implicit exception
context when detecting context window overflows and pin the detection
variants plus the redaction in tests
2026-07-10 00:41:54 -07:00
Tin Chi Lo
1e8c2f7240 fix(mcp): fail closed and surface semantic filter context window errors
Resolves LIT-4284

When the embedding model exceeded its context window, the MCP semantic
tool filter silently passed all tools through and reported N->N success
in the filter header; when the overflow happened while embedding tool
descriptions at router build time, the hook was never registered at all
and filtering was silently disabled

Semantic filtering now fails closed on context window overflows: the
request is rejected with HTTP 400 and a message that names the embedding
model and advises switching to one with a larger context window or
disabling the filter. Build time overflows are recorded on the filter so
the hook still registers and blocks MCP tool requests with the same
actionable error while leaving native-only requests untouched. The
dashboard test panel renders the backend message in an error banner
instead of a success state. OpenAI's embedding overflow message
(maximum input length is N tokens) now maps to ContextWindowExceededError
2026-07-10 00:41:54 -07:00
Tin
a786ba9005 test(mcp): pin credential isolation across server entries sharing an upstream URL 2026-07-10 00:26:37 -07:00
Tin
7e0af8fbbf fix(mcp): stop persisting the DCR client onto true_passthrough and oauth_delegate server rows 2026-07-10 00:26:37 -07:00
Tin Chi Lo
41a43d5283 feat(mcp): add dcr_bridge column and plumbing for client-forwarded auth modes 2026-07-10 00:14:45 -07:00
Tin
9dcc21cd48 refactor(mcp): batch the purge row deletion into one query
The per-row delete_many loop becomes a single delete filtered to the enumerated OAuth users'
(user_id IN, server_id) pairs; same rows deleted, same BYOK-sparing precision, same count-mismatch
detection, one round-trip instead of N
2026-07-09 16:29:17 -07:00
Tin
aa351311c0 fix(mcp): spare BYOK rows when purging stale OAuth tokens and invalidate caches on server delete
LiteLLM_MCPUserCredentials stores BYOK API keys in the same column as per-user
OAuth tokens, so the purge on a mint-relevant config change now deletes only
rows whose payload decodes as an OAuth2 credential, each by its
(user_id, server_id) pair, instead of every row for the server. An api_key
server whose url changes purges nothing. delete_mcp_server now also
invalidates each enumerated user's cached token so a re-created server reusing
the id cannot serve tokens minted for the deleted one, and both cache drops
are best-effort
2026-07-09 16:29:17 -07:00
Tin
42388c3d68 refactor(mcp): align the invalidation code with the v2 DI and typing discipline
The purge takes an injectable invalidate_token_cache callable defaulting to the manager's shared
invalidation, and MCPServerManager takes an injectable per_user_token_cache alongside the existing
per_user_oauth_token_store, so tests inject fakes instead of monkeypatching the global manager and
the module-level cache. The new identity helpers drop Any for object throughout
2026-07-09 16:29:16 -07:00
Tin
48124734a0 fix(mcp): compare the token identity decrypted and invalidate every per-user token store
Review follow-ups on the stale-token invalidation. The backend identity now decrypts client_id and
client_secret before comparing: the stored values are NaCl-encrypted with a fresh nonce on every
write, so comparing ciphertext flagged every routine save as a mint-relevant change and purged
per-user tokens that were still valid. The identity also gains spec_path, the audience for OpenAPI
servers, and parses credentials stored as a JSON string

The purge now routes each (user, server) through the manager's invalidate_user_oauth_token_cache,
which becomes the single invalidation point covering both the legacy per-user token cache and the
v2 per-user OAuth token store; previously the purge evicted only the legacy cache while the revoke
path evicted only the v2 store, so each path left the other cache serving a replaced token until
its TTL. A credential row racing in between the find and the delete is now detected via the
delete_many count and logged; its cache entry expires by TTL

On the dashboard, CLEARED_ON_INVALIDATION and the staleness check move to types.tsx as the single
shared implementation for both forms. The edit form's transport handler now rechecks the identity
after its programmatic setFieldsValue calls, which antd does not report through onValuesChange, so
a token no longer survives a transport switch that clears the mint target. The create form rebuilds
formValues from the post-reset form state after an invalidation instead of publishing the pre-reset
snapshot, so the tool preview can no longer refetch with the discarded DCR client. Both transport
handlers now share the recheck, which also stops the create form from over-invalidating on an
http to sse swap that keeps the same url and therefore the same audience
2026-07-09 16:29:16 -07:00
Tin
05f39bf942 fix(mcp): invalidate a browser-authorized upstream token when a mint-relevant field changes
An admin who ran Authorize & Fetch and then changed a field that determines which upstream OAuth
token gets minted kept using the stale token for tool preview, sessionStorage, and (on the backend)
the stored per-user credential and its cache. Grounded in RFC 8707/8693 and the MCP auth spec, a token
is bound to one tuple: resource/audience (url), OAuth mode/grant (auth_type, oauth_flow_type), the
authorization-server endpoints, and the OAuth client + scopes. A shared getOAuthAuthorizationIdentity
captures exactly those fields; transport (http/sse on the same url is the same audience) and
delegate_auth_to_upstream (a downstream-usage toggle never sent to the authorize request) are excluded.

UI: both the create and edit forms now discard the held token (React state / sessionStorage / hook,
plus the fetched token + DCR client in form.credentials) whenever the identity diverges from the one it
was authorized against, re-applying the admin's in-flight edit so it is never wiped. The check lives in
one shared helper so the two forms cannot drift.

Backend: editing an MCP server now compares the pre/post identity and, on a mint-relevant change, purges
every stored per-user OAuth credential for the server (DB row + per-user token cache) so no user
forwards a token minted for a resource/AS/client that no longer matches. Best-effort; a purge failure
never fails the update.
2026-07-09 16:29:16 -07:00
Tin
d0f1c38d6a fix(mcp): log only the origin of the upstream MCP url in tool-call metadata
The redacted resource kept the path, but hosted MCP servers routinely embed the credential in the
path (for example /mcp/s/<token>/mcp), and mcp_tool_call_metadata is readable by a caller who can
invoke the tool, so the path leaked the upstream credential into spend logs. Only scheme, host, and
port are logged now
2026-07-09 15:35:28 -07:00
Tin
65d0dcfb82 fix(mcp): never forward an Authorization header that satisfied admission on the tools preview
Authorization doubles as the admission fallback when x-litellm-api-key is absent, so a caller who
authenticated the preview request that way had their LiteLLM key forwarded to the upstream as the
oauth2/client-forwarded token. The preview now forwards Authorization only when the primary
admission header is present, which is how the dashboard has always sent it; with no primary header
there is no upstream token on the request at all. Applies to oauth2 and both client-forwarded
modes; parametrized regression test plus the admission header added to the existing extraction
tests to mirror the real UI request shape
2026-07-09 15:02:05 -07:00
Tin
7c52cde505 fix(mcp): redact upstream URL in tool-call logs and plug fan-out Authorization bypass
Two review findings on the passthrough modes.

The tool-call log records the upstream MCP server URL as mcp_server_resource,
which is persisted in spend-log metadata and sent to logging callbacks. A URL
carrying embedded userinfo or a secret query parameter would leak into logs, so
the value is now redacted to its bare resource identifier (scheme + host + path);
userinfo, query string, and fragment are stripped before it is logged.

The listing fan-out withholds the request-wide Authorization from a
true_passthrough / oauth_delegate server when another server in scope also
consumes it, so one bearer is not replayed across upstreams. The later
server.extra_headers copy loop did not honor that decision: a server listing
Authorization in extra_headers would re-copy the withheld bearer from raw_headers.
The withhold decision is now computed once and applied to both the forwarding
branch and the extra_headers loop.
2026-07-09 11:39:19 -07:00
Tin
98818df418 fix(mcp): recognize per-server auth header at connect and stop persisting browser-authorize tokens
Two correctness fixes for the client-forwarded token modes.

The preemptive-401 connect gate for true_passthrough and oauth_delegate
only inspected the request-wide Authorization, so a caller who bound the
upstream token via the per-server x-mcp-{alias}-authorization header (the
mandatory shape in a multi-server aggregate, where the request-wide
Authorization is withheld) was spuriously 401'd at connect even though
egress already honors that header. The gate now recognizes the per-server
header for both modes via a shared helper, mode-correctly: true_passthrough
treats any Authorization or the per-server header as the upstream token,
oauth_delegate keeps requiring a distinct x-litellm-api-key so a lone
Authorization consumed for admission is never mistaken for an upstream
token. The preemptive raise is also gated to single-server scopes so a
multi-server aggregate degrades gracefully (the listing absorbs a
per-server failure) instead of one missing token 401-ing the whole connect.

The browser-only Authorize flow was writing the upstream access and refresh
token to LiteLLM_MCPUserCredentials, contradicting the modes' persist-nothing
contract: the temp OAuth-relay server was cached with a hardcoded oauth2
auth_type, so needs_user_oauth_token was true and the token exchange stored
it. The create and edit forms now send the real auth_type for these modes,
so the temp server is not oauth2, needs_user_oauth_token is false, and the
exchange skips storage while still returning the token to the browser
session.
2026-07-09 11:39:19 -07:00