Skip to content

Support a per-app loopback callback for OAuth apps whose provider pins one - #2154

Open
sahil7886 wants to merge 2 commits into
UsefulSoftwareCo:mainfrom
sahil7886:feat/oauth-loopback-callback-port
Open

sahil7886 wants to merge 2 commits into
UsefulSoftwareCo:mainfrom
sahil7886:feat/oauth-loopback-callback-port

Conversation

@sahil7886

Copy link
Copy Markdown

Summary

Let a registered OAuth app declare a loopback callback (callbackPort, optional
callbackPath, default /callback), so providers that do not support dynamic
client registration and only accept a redirect_uri registered on their own app
can be connected from a local Executor. Against Slack's MCP server today the
authorize request is refused outright:

redirect_uri did not match any configured URIs. Passed URI: http://localhost:4788/api/oauth/callback

Declaring a port makes the flow send exactly http://127.0.0.1:<port><path> as
its redirect_uri on both the authorization request and the token exchange, and
makes the local daemon serve that URI for the duration of the flow.

Linked issue

Fixes #2153

Verification

New tests, all through real boundaries:

  • packages/core/sdk/src/oauth-loopback-callback.test.ts — the declaration is
    stored, reported in listings, handed to the host before the flow starts, and
    sent as redirect_uri on both legs. The token-exchange assertion matters most:
    the OAuth test server rejects the code unless the exchange's redirect_uri
    matches the one the authorize request used, and the token request itself is
    inspected in the test's ledger so the guarantee is asserted rather than
    inferred from the exchange succeeding. Also covers a caller-supplied
    redirectUri not being able to override a declared callback, and the
    up-front refusal of a port or path the provider could never have registered.
  • apps/local/src/oauth-loopback.test.ts — the listener over real sockets: a
    real GET to the bound port comes back 302 to the daemon's completion route
    with the whole query string intact (code, state, and a provider extra), the
    declared path is the only one served (404 otherwise), a port another process
    holds fails with the "already in use" message, rebinding the same URL is
    idempotent, closeAll frees the port, and a non-loopback daemon origin refuses
    to bind. Two more cover when the listener may go away: it closes behind a
    callback served for a single flow, and it keeps serving a URI that two flows
    share after the first one completes (this one fails against the naive
    "close on first callback" logic — the first completion used to refuse the
    second user's connection after they had already consented).
  • apps/local/src/oauth-loopback-api.test.ts — the real HTTP API plus a real
    OAuth authorization server: oauth.start binds the declared port and returns
    an authorize URL pinned to it; driving the provider's login redirects back to
    that port; the forward lands on the completion route and mints the connection;
    the token request carries the same redirect_uri. Second case: a held port
    fails the start with the reason.
  • packages/react/src/components/oauth-client-form.test.ts — the form's
    declaration helpers (off means nothing is declared; a bad port or path declares
    nothing and is reported).
  • packages/core/sdk/src/oauth-loopback-callback.test.ts also pins the guard
    that keeps a declared-callback flow from starting unbound: the flow is refused
    unless the caller passes the declared URI verbatim, so the agent-facing
    oauth.start tool (which cannot serve a loopback callback, and is now refused
    with a message pointing at the app) fails loudly instead of handing back an
    authorization URL nothing is listening at.

Gates, on this branch:

  • bun run format:check
  • bun run typecheck — @executor-js/sdk, @executor-js/api,
    @executor-js/react, @executor-js/local
  • bun run lint — could not run: oxlint 1.60 aborts in its own allocator
    on this machine (panicked at crates/oxc_allocator/src/pool/fixed_size.rs:112)
    for any input, down to a single file, so the lint gate and the 13
    src/oxlint-plugin-executor.test.ts cases that spawn oxlint cannot run
    here. bun run lint:changelog-stubs passes.
  • bun run test — @executor-js/sdk (940 passed; the only failures are those
    13 oxlint-spawn cases), @executor-js/api 132 passed, @executor-js/react
    456 passed, @executor-js/local 115 passed
  • e2e — not run. The loopback callback is a local-host capability and the e2e
    projects are cloud and selfhost, neither of which runs apps/local, so
    no existing scenario covers it. I did not stand up a browsable local dev
    instance either, so there is no recording for this one — the wire-level
    tests above are the evidence, and the missing piece is a manual pass:
    register an app with callbackPort 3118, client ID only (no secret, PKCE),
    connect an MCP server that pins that URI, and confirm the token exchange
    succeeds without a secret.

Checklist

  • Added a changeset (.changeset/oauth-loopback-callback-port.md).
  • Added or updated tests for the new behaviour.
  • No secrets, credentials, or private data in the diff.

Notes for reviewers

  • The declared callback is authoritative, and passing it is required: a caller
    that starts a declared-callback flow must pass that exact URI, which it can
    only have learned from oauth.loopbackCallback — the call that tells a host
    it has to bind. A caller that did not (the connect UI sends its own origin's
    callback; the agent tool sends nothing) gets an error naming the required URI
    instead of an authorization URL that strands the user mid-flow.
  • A listener closes as soon as it has served the callback for a single flow, but
    a URI that a second flow has claimed is left to its TTL instead of being
    closed by the first completion. A shared declared URI is normal (two tabs
    connecting the same app), and the previous "close on first callback" would
    have refused the second user's redirect after they finished consenting.
  • The two new oauth_client columns are nullable text (parsed on read, like the
    rest of that table's enum-ish columns) and are added by the generated
    migrations for both local and cloud. A stored pair that is not a usable
    unprivileged port + absolute path degrades to "no declared callback" rather
    than failing the read, so a hand-edited row cannot brick an app.
  • The listener is deliberately a forwarder: the provider's query string is
    handed to the existing /api/oauth/callback route, so completion, the popup
    handoff, and the completion page keep exactly one implementation.
  • Left alone deliberately, as discussed during review: a stored callback pair
    that is not a usable port + absolute path degrades to "no declared callback"
    with no log (only reachable by editing the row directly), and the port range
    includes ports a system daemon may hold — which surfaces as the actionable
    in-use message. Happy to add a warning log or tighten the range if you prefer.
  • Support a per-app loopback callback (callbackPort) for pre-registered OAuth apps without DCR #2153 also asks whether a single global extra callback listener would be
    preferred over a per-app port. This implements the per-app shape, since a
    provider app that pins a port cannot be satisfied any other way; happy to
    reshape if you would rather have the global one.

Some providers only accept a redirect_uri that is already registered on their
own OAuth app — Slack's MCP server answers anything else with "redirect_uri did
not match any configured URIs" — and such an app usually pins a loopback port
this Executor could not offer. A registered app can now declare callbackPort
(and optionally callbackPath, default /callback): the host binds
http://127.0.0.1:<port><path> for the flow and sends it as redirect_uri on both
the authorization request and the token exchange, then forwards the provider's
callback to the existing completion route.

A flow for such an app is refused unless the caller passes that exact URI, which
it reads from oauth.loopbackCallback before binding, so a caller that never
asked for it fails loudly instead of handing back an authorization URL nothing
is listening at. A listener shared by two concurrent flows is left to its TTL
rather than closed by the first completion, so one sign-in cannot refuse the
other's callback.

Fixes UsefulSoftwareCo#2153
CI's oxlint runs the repo's own Effect-hygiene rules, which this change tripped
in six ways: inspecting Bun's foreign bind failure with `instanceof Error` and
reading its `message`, a try/catch in the callback-path normalizer, `throw` and
`new Error` in tests, a promise executor `reject`, manual `_tag` checks, and a
raw `<input>` in the connect form.

Each is now modelled rather than probed: the path normalizer uses
`URL.canParse`, the bind failure is read through a named adapter-boundary helper
that only looks at its `code`, the test helpers bind a real socket to find a
free port, tagged failures are asserted through their fields, and the form uses
the design system's `<Checkbox>`.

This branch has not been deployed

No deployments
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.

Support a per-app loopback callback (callbackPort) for pre-registered OAuth apps without DCR

1 participant