Skip to content

feat(oauth): optional post-login redirect after the proxy callback#978

Merged
QuentinBisson merged 14 commits into
mainfrom
feat/oauth-post-login-redirect
Jul 22, 2026
Merged

feat(oauth): optional post-login redirect after the proxy callback#978
QuentinBisson merged 14 commits into
mainfrom
feat/oauth-post-login-redirect

Conversation

@QuentinBisson

@QuentinBisson QuentinBisson commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Lets the front-end that initiated a connector login observe its completion, and makes login completion visible to live MCP sessions, without affecting other clients of the same muster.

What changed

  • OAuth proxy start endpoint (/oauth/proxy/start): core_auth_login challenges now return a muster-hosted URL that 302s to the upstream authorization URL (stored server-side with the flow's state). One extra transparent hop for every client; the login flow is otherwise unchanged.
  • oauth.mcpClient.postLoginRedirectAllowlist: the initiating caller may append redirect=<url> to the start URL. When the target matches an allowlist entry (exact scheme and host, path extended at a segment boundary; query unconstrained), it is recorded on the flow's state, and a successful callback 303s the browser there with server=<name> appended, instead of rendering the static success page.
  • list_changed after browser-consent connect: the OAuth callback connects the backend outside any MCP request, so a client that had already listed and cached its tools never learned the backend became available and had to re-run core_auth_login manually. The callback-connect path now pushes the same subject-wide tools/resources/prompts list_changed notifications the SSO connect path already sends, so clients that honor list_changed (e.g. Claude Code) see the new tools appear on their own.

Why per-flow instead of a global config URL

The first revision of this PR used a single configured redirect URL. That breaks on shared musters: klaus-gateway, Claude Code, and Backstage AI all drive connector logins through the same instance, and a global redirect would send every client's consent flow to one front-end's landing page. With the start-endpoint parameter, only flows whose login URL was decorated by the initiating front-end redirect; everyone else keeps the static success page.

The redirect target's query is preserved, so a front-end can carry its own correlation state (e.g. klaus-gateway mapping the completion back to a Slack user and prompt) without cookies.

Security notes

  • The redirect target is validated against the operator allowlist at the start endpoint and stored server-side with the CSRF state; the callback never reads a redirect from request input.
  • Validation requires absolute http(s), no userinfo, exact scheme/host match, and the entry path extended at a segment boundary; matching runs on the escaped path and targets containing dot segments (plain or %2e-encoded) are rejected, since browsers normalize them before navigation. A rejected target is logged and dropped; the login still proceeds.
  • Empty allowlist (default) rejects all redirect requests, preserving today's behavior exactly.

Test coverage

  • Unit: start-endpoint redirect/recording/rejection table, allowlist matching (host-suffix, userinfo, scheme tricks), state-store Update (non-consuming, TTL preserved), callback redirect vs. success page.
  • BDD: the simulated browser in the test harness now follows the start-endpoint hop like a real browser, so all OAuth scenarios exercise the new endpoint end to end (178/178).

Consumer

klaus-gateway's Slack connector prompt (giantswarm/klaus-gateway#139): the Connect button gets redirect=<gateway>/connectors/complete?s=<state> appended, and the gateway landing endpoint replaces the ephemeral prompt with "signed in" via the stored response_url.

When oauth.mcpClient.postLoginRedirectUrl is set, a successful OAuth proxy
callback redirects the browser to that URL with the connected server's name
appended as a server query parameter, instead of rendering the static
success page. This lets a front-end (e.g. a chat gateway) observe connector
login completion and update its own UI.

The target is operator config, never request input, and must be an absolute
http(s) URL; an invalid value is ignored with a warning. Failed callbacks
still render the error page.
Auth-challenge login URLs now point at a muster-hosted start endpoint
(/oauth/proxy/start) that redirects to the upstream authorization URL
stored with the flow's state. The initiating caller may append a redirect
query parameter to the start URL; when it matches an entry of the new
oauth.mcpClient.postLoginRedirectAllowlist (exact scheme and host, path
prefix), a successful callback redirects the browser there with the
connected server's name appended as a server query parameter, instead of
rendering the static success page.

The redirect is opt-in per flow, so front-ends like klaus-gateway can
observe connector login completion for the flows they initiated while
other clients of the same muster (Claude Code, Backstage AI) keep the
static success page. A rejected or absent redirect target never blocks
the login; failed callbacks always render the error page.
The simulated browser flow parsed OAuth parameters straight off the
core_auth_login URL, which is now the muster-hosted start endpoint
carrying only the state. Dereference it one hop, like a browser, and
continue with the upstream authorization URL from the Location header.
The OAuth browser callback connects the backend outside any MCP request,
so a client that already listed and cached its tools never learns the
backend became available and has to re-run core_auth_login manually. Push
the same subject-wide list_changed notifications the SSO connect path
sends.
Reject dot segments (plain and %2e-encoded) and require the allowlist
entry's path to be extended at a segment boundary, matching on the
escaped path so percent-encoded slashes cannot fake a boundary. Browsers
normalize dot segments before navigation, so the previous plain prefix
match effectively allowed any path on an allowlisted host.
SET on update now uses XX so it only overwrites a key that still
exists. Without it, an update racing the callback's consuming GETDEL
recreated the key, and KEEPTTL on a fresh key means no expiry.
GenerateState now takes a build callback invoked with the encoded
state, so the upstream authorization URL lands in the same store write
instead of a follow-up Update (one round trip instead of two on valkey,
and no state can exist without its authorization URL).
@QuentinBisson
QuentinBisson marked this pull request as ready for review July 17, 2026 14:02
@QuentinBisson
QuentinBisson requested a review from a team as a code owner July 17, 2026 14:02
@QuentinBisson
QuentinBisson enabled auto-merge (squash) July 22, 2026 07:52
@QuentinBisson
QuentinBisson merged commit 1c571f8 into main Jul 22, 2026
6 checks passed
@QuentinBisson
QuentinBisson deleted the feat/oauth-post-login-redirect branch July 22, 2026 07:59
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.

2 participants