Skip to content

feat(auth): sign in through the gateway in the browser when no admin key is set - #5

Open
mateo-berri wants to merge 9 commits into
BerriAI:mainfrom
mateo-berri:litellm_admin_mcp_browser_sign_in
Open

mateo-berri wants to merge 9 commits into
BerriAI:mainfrom
mateo-berri:litellm_admin_mcp_browser_sign_in

Conversation

@mateo-berri

@mateo-berri mateo-berri commented Oct 29, 2026 •

Copy link
Copy Markdown

Today a local stdio run with no LITELLM_API_KEY exits at startup. This PR signs in through the gateway's browser flow instead, the one lite login --pkce uses

With no saved sign-in, the server lists sign_in plus the admin tools from its own catalog and opens nothing. The agent's first admin call opens the gateway's /authorize page with S256 PKCE, or returns the link when no browser opens. That call never runs the tool, and the agent calls it again in the same turn. Other sessions reuse the saved sign-in, and litellm-admin-mcp logout revokes it. LITELLM_API_KEY and hosted HTTP mode are unchanged

User Flow

Before

The flow fails at startup, because the connector exits with no admin key and offers no sign-in

  1. The admin adds the README's stdio entry for litellm-admin to Claude Code with only LITELLM_BASE_URL in env
  2. They start Claude Code and type /mcp. The panel shows ✘ litellm-admin with Status: ✘ failed
  3. They ask "Create a LiteLLM virtual key for me with a $5 max budget using the litellm-admin MCP"
  4. The agent says the server failed and suggests an admin key, or POST https://litellm-domain/key/generate with the master key

After

The flow succeeds, because the first admin call sends the admin through the gateway's sign-in page and the agent creates the key in the same turn

  1. The admin adds the same stdio entry with only LITELLM_BASE_URL in env
  2. They start Claude Code or Codex. /mcp shows litellm-admin with 68 tools, and no browser opens
  3. They ask for the $5 key as in Before step 3
  4. The agent's first admin call opens their default browser on GET https://litellm-domain/authorize?...&redirect_uri=http://127.0.0.1:<port>/callback
  5. They sign in through SSO or the username form and click Approve on "Authorize CLI access", picking a team if on several
  6. The browser lands on http://127.0.0.1:<port>/callback, which says "Signed in to LiteLLM."
  7. The first call answers "Signed in to LiteLLM as . has not run yet; call it again." The agent calls again and, in the same turn, replies with a new sk-... key with a $5 max budget
  8. Another open session gets its key with no second sign-in, and the next start lists 67 tools, without sign_in
  9. With no browser, the first call returns the link, and the agent creates the key once they approve and say so
  10. With the agent on another computer, the page after Approve does not load, so they paste its http://127.0.0.1:<port>/callback?code=... address into the chat, and the agent creates the key
  11. litellm-admin-mcp logout revokes the sign-in and says when its last access token expires. The next start lists 68 tools again

Screenshots / Proof of Fix

Last updated: 41f2c0b. QA ran on 441cc77, and SSO on ac85d2c. Changes since QA: facae88 removes an unused helper, and 41f2c0b lets copy and pickle rebuild the non-admin error. Proof remains valid because MCP clients see the same tools, text and statuses, and SSO happens on the gateway's pages with the connector's /authorize request and code exchange unchanged since ac85d2c. Verification: 145 tests and CI pass at 41f2c0b

Each case drove a real TUI under tmux with no admin key, Claude Code 2.1.299 (Opus 5.5) or Codex 0.165.0 (GPT-6.1-Sol), against a 2-worker BerriAI/litellm#48080 gateway with Postgres, behind TLS for the first and third rows

Case Before (3d4cd40) After
Start, /mcp ✘ failed 68 tools, no browser
$5 key, Claude Code, HTTPS asks for master key key, same turn
$5 key, Codex not run key, same turn
Browser on another computer not run key after paste
No browser not run link, then key
Other open session not run key, no link
Restart not run 67 tools
logout no such command 68 tools again
SSO admin, 2 teams not run key (ac85d2c)

Before

mcp
answer

After (441cc77)

The first admin call, describe_admin_tool, opened the default browser through xdg-open on the https /authorize page and answered after 43 seconds that the tool had not run. The agent called it again and created the key in the same turn, with no permission prompt

start
signing-in
consent
again
key
after

❯ Create a LiteLLM virtual key for me with a $5 max budget using the litellm-admin MCP
  Called litellm-admin 3 times
● Key's created. Now I'm checking that the gateway has it saved with the $5 budget.
  Called litellm-admin
● I created your virtual key with a $5 max budget, and checking it on the gateway shows it's active with that budget.
  Key: sk-<redacted>
✻ Worked for 1m 6s · done 3:39 PM
The first call's answer in the detailed transcript, and the gateway's record of the key
● litellm-admin - describe_admin_tool (MCP)(name: "create_key", response: {"view":"compact"})
  ⎿  Error: Signed in to LiteLLM as default_user_id. describe_admin_tool has not run yet; call it again.
● litellm-admin - describe_admin_tool (MCP)(name: "create_key", response: {"view":"compact"})
  ⎿  {"name":"create_key","description":"Create a virtual key with model access, budgets and rate limits. POST /key/generate. ...
$ curl -s --cacert tls/ca.pem -H 'Authorization: Bearer <master key>' 'https://172.17.0.1:29605/key/info?key=2fafddb3...8e525cf0'
{"key_name": "sk-...WGtw", "max_budget": 5.0, "spend": 0.0, "user_id": null, "team_id": null, "created_by": "default_user_id",
 "models": [], "expires": null, "budget_duration": null, "blocked": null, "status": "active"}
Codex (441cc77)

A fresh CODEX_HOME and config directory, with a BROWSER script that returns at once like xdg-open and starts the person 20 seconds later. /mcp listed 68 tools and nothing opened. The model's first MCP call was describe_admin_tool, which ran the sign-in and answered after 31.6 seconds, shown by Codex as Failed. The model then described create_key and get_key, Codex asked approval for create_key (Allow), and the key was created and checked in the same turn. At the end /mcp verbose listed 67 tools, without sign_in

codex 68 tools
codex key in the same turn

• Failed litellm-admin.describe_admin_tool
  └ Signed in to LiteLLM as default_user_id. describe_admin_tool has not run yet; call it again.
...
• Called litellm-admin.create_key
  └ {"key_alias":null,"duration":null,"models":[],"spend":0.0,"max_budget":5.0,"user_id":null,"team_id":null,...
• Called litellm-admin.get_key
  └ {"key":"808033fc…","info":{"key_name":"sk-...LSKg","key_alias":null,"soft_budget_cooldown":false,...
• Created and verified your virtual key with a $5 max budget, with no budget reset or expiration.
  sk-<redacted>
  Worked for 1m 15s • 15:49

Before this commit, at 43fcb86, Codex received the tool-change notice after sign_in but never gave the model the new tools, in that turn, the next one, or after /mcp, until a restart

Browser on another computer (441cc77)

The agent's side had no browser and no display. The person's browser ran in another network namespace, where curl http://127.0.0.1:36001/callback could not connect, so its 127.0.0.1 was not the agent's. The first admin call returned the link in 0.9 seconds with the hint for this case. After Approve the browser showed ERR_CONNECTION_REFUSED, and the person pasted that page's address. The agent called sign_in with callback_url (16.9 seconds) and created the key in that turn

link shown
callback refused
key after the paste

❯ I approved it in my browser on my laptop, but the last page did not load. Its address is
  http://127.0.0.1:36001/callback?code=<redacted>&state=<redacted>
  Called MCP
  Called litellm-admin 2 times
● I've created the key and I'm checking it on the gateway.
  Called litellm-admin
● I created your virtual key with a $5 max budget. I looked it up on the gateway afterward and it's active with $0 spent.
  Your key: sk-<redacted>
✻ Cooked for 44s · done 3:48 PM

/key/info for that key returned max_budget: 5.0, spend: 0.0, created_by: "default_user_id", status: "active". Every QA key was deleted afterwards

No browser, another open session, restart, and logout (441cc77)

Four Claude Code sessions shared one config directory, with BROWSER and DISPLAY unset. Sessions 1 and 2 both listed 68 tools before the sign-in. In session 1 the first admin call returned the link and said to call the tool again, and after "I approved it in the browser, go ahead" the agent created the key

no-browser link and key

Session 2, open since before the sign-in, then created its key in one turn with no link and no browser

other session key

❯ Create a LiteLLM virtual key for me with a $5 max budget using the litellm-admin MCP
  Called litellm-admin 3 times
● The key is created; I'm now checking that it was saved with the $5 budget.
  Called litellm-admin
● I created the virtual key, then looked it up on the gateway: it's active with a $5.00 max budget and $0.00 spent.
  Key: sk-<redacted>
✻ Cooked for 29s · done 3:44 PM

A fresh session 3 listed 67 tools without sign_in. After logout, session 4 listed 68 tools with sign_in again, and no browser opened in either

restart /mcp
logout
after logout /mcp

$ LITELLM_BASE_URL=http://127.0.0.1:26863 XDG_CONFIG_HOME=<qa dir>/xdg <checkout>/.venv/bin/litellm-admin-mcp logout; echo exit=$?
Signed out of http://127.0.0.1:26863. The gateway revoked the sign-in's refresh token, so it is no longer renewed, and its saved copy was removed. The access token it last issued stays valid at the gateway until 2026-10-30 15:41 UTC.
exit=0

$ ls -la <qa dir>/xdg/litellm-admin-mcp/sign-ins/
total 8
drwx------ 2 mateo mateo 4096 Oct 29 15:48 .
drwxrwxr-x 3 mateo mateo 4096 Oct 29 15:41 ..
SSO admin on two teams (ac85d2c)

The gateway used LiteLLM's generic OIDC SSO (GENERIC_CLIENT_ID, GENERIC_AUTHORIZATION_ENDPOINT, GENERIC_TOKEN_ENDPOINT, GENERIC_USERINFO_ENDPOINT, GENERIC_USER_ROLE_ATTRIBUTE=role) against ghcr.io/navikt/mock-oauth2-server:6.1.4. The person signed in as qa7-sso-admin with "role": "proxy_admin", a member of two teams, and picked the second team on the consent page. The gateway's hops were /authorize 303, /sso/key/generate 303, the identity provider, /sso/callback 303, /authorize 200, POST /authorize/complete 303, and POST /token 200. The agent created the key with the team-bound sign-in, and GET /key/info?key=<hash> with the master key returned team_id: null, max_budget: 5.0, created_by: "qa7-sso-admin"

SSO login
consent with team picker

Commands and config

The headline case and the browser on another computer used Envoy 1.35.3 terminating TLS at https://172.17.0.1:29605 in front of the gateway, with PROXY_BASE_URL set to that address, and the connector trusted the QA CA through SSL_CERT_FILE. Claude Code ran in its default auto mode. A Playwright person used the real login and consent pages. The headline case made a .desktop entry for Chromium the default with xdg-settings set default-web-browser under the connector's XDG_CONFIG_HOME and XDG_DATA_HOME. The other-computer case ran the person's headless Chromium with nsenter in another container's network namespace. The other cases used the plain-HTTP gateway at http://127.0.0.1:26863

{
  "mcpServers": {
    "litellm-admin": {
      "command": "<checkout>/.venv/bin/litellm-admin-mcp",
      "args": [],
      "env": {
        "LITELLM_BASE_URL": "https://172.17.0.1:29605",
        "SSL_CERT_FILE": "<qa dir>/tls/ca.pem",
        "DISPLAY": ":162 (headline case only)",
        "XDG_CONFIG_HOME": "<qa dir>/<leg>/xdg-config",
        "XDG_DATA_HOME": "<qa dir>/legA/xdg-data (headline case only)"
      }
    }
  }
}
~/.claude/bin/claude-tui liteadmin-tls13-a --cwd <leg>/cwd --config-dir <leg>/claude-cfg --unset BROWSER --unset DISPLAY \
  --env ANTHROPIC_API_KEY=<key> -- --model claude-opus-5-5 --mcp-config <leg>/mcp-config.json --strict-mcp-config
tmux send-keys -t liteadmin-tls13-a -l '/mcp'
tmux send-keys -t liteadmin-tls13-a Enter
tmux send-keys -t liteadmin-tls13-a -l 'Create a LiteLLM virtual key for me with a $5 max budget using the litellm-admin MCP'
tmux send-keys -t liteadmin-tls13-a Enter

Codex's config.toml

model = "gpt-6.1-sol"

[mcp_servers.litellm-admin]
command = "<checkout>/.venv/bin/litellm-admin-mcp"
args = []
env = { LITELLM_BASE_URL = "http://127.0.0.1:26863", BROWSER = "<qa dir>/person_browser.py", HUMAN_SECONDS = "20", QA_SHOTS = "<qa dir>/browser", XDG_CONFIG_HOME = "<qa dir>/xdg" }

Before, /mcp detail and Claude Code's MCP log

   Litellm-admin MCP Server
   Status:           ✘ failed
  ⎿  Failed to reconnect to litellm-admin: CONNECTION_CLOSED

Server stderr: LiteLLM Admin MCP could not start (AdminError). Check its configuration and gateway connection.

Things the run saw

  • The first admin call's answer renders as an error
  • LiteLLM main's tool list says "upgrade LiteLLM"
  • /key/info called with no key answers 500
  • Non-SSO sign-in uses a login page marked Deprecated
  • A team joined seconds earlier briefly answered 403 once

This PR causes the first on purpose (see Decisions) and leaves the rest alone

Caveats (if any)

Medium

Low

  • Cursor, VS Code, macOS and Windows are untested
  • The sign-in can use every route a proxy-admin key can
  • A slow gateway can push sign_in past 55 seconds
  • Switching account or team needs litellm-admin-mcp logout from a shell
  • Reconnect while a sign-in is open ends that sign-in
  • The consent page, which is LiteLLM's, says lite logout stops renewal
  • A renewal can hide a saved admin result for 5 minutes
  • Bugbot and Greptile are not installed here

Decisions

Design decisions and the alternatives considered

Before sign-in, tools/list answers at once with sign_in plus every admin tool the local config allows (allowed_tools, read_only), built from the connector's reviewed catalog with no gateway request. They carry the signed-in names, descriptions and annotations, with the discovery-mode input schema (body, query and path pointing at describe_admin_tool, and the real response schema), even under schema_mode=full, since the full schema comes from the gateway's OpenAPI document, which needs a credential. After sign-in the list is the gateway's own and drops sign_in. The earlier version listed only sign_in and sent notifications/tools/list_changed after sign-in. Codex 0.165.0 receives that notice and never gives the model the new tools until a restart, and Cursor documents no support for it, so the flow cannot depend on a client listing tools again

The sign-in starts only when the agent calls sign_in or an admin tool, so starting a client or opening another window never opens a browser tab. The first version opened the browser on the client's first tool list, which opened a tab in every new session, even one where nobody asked for the connector. Holding tools/list until sign-in finished left Claude Code with no tools after a 60-second sign-in, and MCP URL elicitation has little client support yet

An admin tool called before sign-in runs the same sign-in as sign_in and never runs the operation in that call. It returns an error result that says the user is signed in and the tool has not run yet, or gives the link and says to call the tool again. A call that waited up to 40 seconds for a person and then loaded the gateway's schema could pass a client's 60-second tool timeout while a write is still running, and the agent would then see a write whose outcome it cannot know. The cost is one more call. The result is marked as an error so neither the client nor the model reads it as the tool's output, which is why Claude Code shows it in red and Codex as Failed. In QA both called the tool again in the same turn

sign_in, and an admin tool's first call, answer within 55 seconds, so a client with a 60-second tool timeout, Codex's default, always gets an answer. They wait up to 40 seconds for the person, counting the time the browser launcher takes, and otherwise return the link, which works for 5 minutes. They return the link at once when no browser opened. After approval the connector loads the gateway's admin tool list until the 55 seconds are up and keeps loading it in the background, and the next tool list reuses that load. It then publishes notifications/tools/list_changed on the subscriptions/listen stream that 2026-07-28 clients open, and also sends it the older way, so a client that listens swaps the stub schemas for the gateway's own. One sign-in runs at a time and outlives the request that started it

The browser opens in the background through Python's webbrowser in a short-lived child process with no stdio, so a console browser or a chatty launcher can never touch the MCP stream. When the browser runs on another computer, the page after approval points at that computer's 127.0.0.1 and cannot load. sign_in takes that page's address as callback_url and checks it exactly like the loopback listener does: only the pending sign-in's state is accepted, and the code is redeemed only with that sign-in's PKCE verifier. A paste with no pending sign-in, or with a wrong state, is refused. The alternative, a listener on an address other computers can reach, would put the code on the network. The link and the waiting message carry the hint for this case

sign_in is marked not read-only, since it saves a credential, and idempotent, since a second call reuses the saved one. In QA Claude Code's default auto mode ran it with no permission prompt

The saved sign-in can do what the proxy-admin key LITELLM_API_KEY takes can do, and what a lite login session can do, since the gateway has no narrower grant for an admin client. It is bound to the admin's team when they are on one, or to the team they pick. Its access token lasts at most 24 hours, and its refresh token stays renewable only while it is used, until 14 idle days or logout. The alternative, asking the admin to mint and paste a key, is the flow this PR replaces

Each gateway's sign-in is its own file under ~/.config/litellm-admin-mcp/sign-ins/ (or under $XDG_CONFIG_HOME), named by the URL-quoted gateway origin so every spelling of one address shares it, mode 0600 in a 0700 directory, written atomically. The directory's mode is set on every save, so one created earlier with a looser mode is tightened. Windows ignores those modes, and there the file relies on the user profile's permissions. Every request reads the file, so a sign-in made in one session serves the others. The alternatives were the OS keychain, which would add the keyring dependency and its headless-Linux failure modes, and reusing lite's own record, which would couple this connector to the CLI's storage format. The access token is renewed 6 minutes before it expires, and a token another process already rotated is picked up

A 408, a 429, a 5xx or a network error from the token endpoint is temporary and keeps the sign-in. Any other 4xx counts as the endpoint rejecting it, so a sign-in it will never renew is replaced by a new browser sign-in instead of locking the user out. When renewal fails temporarily, an unexpired token keeps working, and an expired one is kept while the request fails with an error that says to try again shortly

When the gateway rejects a saved sign-in with a 401, the connector renews it once and repeats the request only when it reads: listing tools, the admin check, and catalog routes marked read-only. A write is never repeated, since a 401 from the check after a write means the write already ran, so the agent is told to check gateway state first, also when that renewal fails. A sign-in still rejected after renewal, or one that is not a proxy admin, is revoked and removed, and the client is told its tools changed, so it lists them as before sign-in. A 403 from a single admin route keeps the sign-in, since signing in again would not change it

litellm-admin-mcp logout posts the refresh token to the discovery document's revocation_endpoint (RFC 7009) and removes the saved file. The gateway's revocation ends only the refresh chain, since its access tokens are stateless, so the message says until when the last one stays valid. When the gateway answers 503 nothing is removed and the user is told to retry. Any other failure removes the file and says the gateway did not confirm

Discovery is trusted only when it is contract version 1, offers S256, and names the configured gateway's origin for the issuer, every endpoint (revocation included), and the resource. Anything else is refused before any request goes to it. An origin mismatch names the address the gateway reported and says to set PROXY_BASE_URL on the gateway, and suggests LITELLM_BASE_URL only when the reported address is one the connector accepts. The loopback listener accepts one response, only with the pending sign-in's state

Separately, a startup AdminError (for example a malformed LITELLM_API_KEY) now prints its own message instead of only the exception class. Those messages are ours and carry no secrets. Other startup errors keep the generic line, since transport errors can include response bodies

Tests

What the new tests cover

tests/test_signin.py runs every case through a real MCP session against a stub gateway that checks the PKCE verifier, the redirect, the client, and the resource the way the proxy does, and records revocations. It covers the tool list before sign-in (the enabled catalog tools and helpers with the signed-in names, descriptions and annotations, a read-only config hiding write tools, and no browser or gateway request), a client that never lists tools again creating a key on the call after the one that signed in, sign_in waiting for approval and loading the admin tools before it answers or linking a slow sign-in, a launcher that blocks counted in the link wait, sign_in and an admin call answering in time while a slow gateway still loads the admin tools, an admin call returning the link until the signed-in tools are announced, a sign-in saved by another session, reuse, renewal and rotation, a rejected read renewed and repeated, a rejected write not repeated, temporary and permanent token endpoint errors during renewal, a sign-in still rejected or not an admin being revoked with the tools listed as before sign-in, finishing from a pasted callback address (refused before a sign-in starts and with a forged state), every spelling of one origin sharing a file, an existing store directory made owner-only, a declined, forged, late, or abandoned sign-in, each kind of foreign or unsupported discovery document and the setting its error names, a gateway without the contract, two gateways side by side, and logout against a gateway that revokes, answers 503, or fails. Two cases run the packaged stdio process, one signing in through a BROWSER launcher that writes to stdout and stderr, and one where a 2026-07-28 client gets the admin tools once a slow sign-in finishes. Full suite is 145 passed. The 7 cases added at 441cc77 fail at 43fcb86, and the copy and pickle case added at 41f2c0b fails at facae88

…key is set

A local stdio run without LITELLM_API_KEY now uses the gateway's native-client
sign-in (/.well-known/litellm-cli-auth, dynamic registration, PKCE with a
loopback redirect) on the first tool call, saves the credential in an
owner-only file keyed by gateway, renews it before it expires, and announces
the tools once the sign-in finishes. A startup AdminError now prints its own
message.
…oke sign-ins on logout

Announce the admin tools over subscriptions/listen so current clients refetch them after a slow sign-in,
return the sign-in link from the sign_in tool, revoke and remove a rejected or non-admin sign-in, add
litellm-admin-mcp logout, keep one sign-in file per gateway, and name the address a foreign discovery
document points at.
@mateo-berri

Copy link
Copy Markdown
Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Author

bugbot run

…nd renew a rejected sign-in before removing it
@mateo-berri

Copy link
Copy Markdown
Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Author

bugbot run

@mateo-berri

Copy link
Copy Markdown
Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Author

bugbot run

…in within a minute and accept a pasted callback address

Only 401 or a 400 invalid_grant, invalid_client or unauthorized_client from /token removes a sign-in. An expired sign-in the gateway cannot renew right now is kept and reported instead of starting a new browser sign-in. The sign-in link wait now counts the browser launcher and lasts 40 seconds, and loading the admin tools stops waiting 55 seconds after sign_in started, so one-minute tool timeouts get an answer. sign_in takes an optional callback_url so a browser on another computer can finish the sign-in. The sign-in store directory is set to 0700 when it already exists, and sign_in is no longer marked read-only.
@mateo-berri

Copy link
Copy Markdown
Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Author

bugbot run

…AdminError

NotAdmin took no constructor arguments, so copy.copy and pickle, which rebuild an
exception by calling its class with its message, raised TypeError where the plain
AdminError it replaced worked.
@mateo-berri

Copy link
Copy Markdown
Author

bugbot run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant