Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Let a registered OAuth app declare a loopback callback (
callbackPort, optionalcallbackPath, default/callback), so providers that do not support dynamicclient registration and only accept a
redirect_uriregistered on their own appcan be connected from a local Executor. Against Slack's MCP server today the
authorize request is refused outright:
Declaring a port makes the flow send exactly
http://127.0.0.1:<port><path>asits
redirect_urion both the authorization request and the token exchange, andmakes 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 isstored, reported in listings, handed to the host before the flow starts, and
sent as
redirect_urion both legs. The token-exchange assertion matters most:the OAuth test server rejects the code unless the exchange's
redirect_urimatches 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
redirectUrinot being able to override a declared callback, and theup-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: areal
GETto the bound port comes back302to the daemon's completion routewith the whole query string intact (
code,state, and a provider extra), thedeclared path is the only one served (
404otherwise), a port another processholds fails with the "already in use" message, rebinding the same URL is
idempotent,
closeAllfrees the port, and a non-loopback daemon origin refusesto 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 realOAuth authorization server:
oauth.startbinds the declared port and returnsan 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 portfails the start with the reason.
packages/react/src/components/oauth-client-form.test.ts— the form'sdeclaration helpers (off means nothing is declared; a bad port or path declares
nothing and is reported).
packages/core/sdk/src/oauth-loopback-callback.test.tsalso pins the guardthat 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.starttool (which cannot serve a loopback callback, and is now refusedwith 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:checkbun run typecheck—@executor-js/sdk,@executor-js/api,@executor-js/react,@executor-js/localbun run lint— could not run: oxlint 1.60 aborts in its own allocatoron 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.tscases that spawn oxlint cannot runhere.
bun run lint:changelog-stubspasses.bun run test—@executor-js/sdk(940 passed; the only failures are those13 oxlint-spawn cases),
@executor-js/api132 passed,@executor-js/react456 passed,
@executor-js/local115 passedprojects are
cloudandselfhost, neither of which runsapps/local, sono 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
.changeset/oauth-loopback-callback-port.md).Notes for reviewers
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 hostit 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 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.
oauth_clientcolumns are nullable text (parsed on read, like therest 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.
handed to the existing
/api/oauth/callbackroute, so completion, the popuphandoff, and the completion page keep exactly one implementation.
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.
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.