Repository navigation
Description
Problem
Some services can only be signed in to through a flow the host can't run itself:
- a device code (RFC 8628): open a page, enter a code, and the service finishes on its own;
- a sign-in whose only accepted
redirect_uriis a fixed loopback address, so the person copies the address the browser ends on and pastes it back (the case behind Support a per-app loopback callback (callbackPort) for pre-registered OAuth apps without DCR #2153 and Self-hosted Executor needs an OAuth callback handoff for loopback-only MCP providers #1439); - a backend that already holds the client and keeps the rotating refresh token, so the app must never hand the token to the host.
defineProvider offers secrets and oauth2 only. oauth2 covers authorization_code with a host-owned client and redirect_uri, and client_credentials. None of the three flows above fits, so an app that connects them has to build its own sign-in screens and tools beside the app's Accounts tab. People then connect one account from Connect new account and another from a custom page or a tool run, and removing the account in one place doesn't remove it in the other.
Proposal
A provider method whose credential the requiring app acquires, driven by the same Connect new account dialog.
const subscription = defineProvider({
name: "Example subscription",
auth: {
signIn: interactive({
label: "Sign in",
start: async (ctx) => ({ session, url, code?, paste?, expiresAt? }),
submit: async (ctx, { session, value }) => status, // paste-back only
status: async (ctx, { session }) => status, // waiting | connected | failed
cancel: async (ctx, { session }) => {},
remove: async (ctx, { ref }) => {},
}),
},
});- The host calls
startin the app the dialog belongs to, with the caller and that app's own accounts, like any operation. The dialog shows what the result asks for: a link to open, a code to copy, a box to paste into, and a waiting state while it pollsstatus. sessionstays on the server with the pending connection. The browser never sees it.connectedreturns an opaquerefand optionalaccountInfo. The account stores only therefas a plain field; the host never sees, refreshes or revokes the service's tokens. App code readsaccount.fields.ref.- The account is bound to the app that acquired it, since its
refmeans nothing to another app. Unlinking it there, or deleting it, callsremoveso the app can delete the credential where it lives. - Closing the dialog before it finishes calls
cancel. healthworks unchanged, so an app can report arefits backend no longer serves.
The host still owns which slot, app and caller a connection is for, who may connect (the requirement's usual rules), the account's name and links, and the timeouts. The app owns only the service conversation.
Not in scope
- Refreshing or rotating credentials in the host for this method.
- Opening a popup or reading the loopback address automatically; the person pastes it.
Activity
alexg0 commentedon Nov 5, 2026
Before I open a PR, I'd like to check whether one would be welcome, and how it fits with this proposal.
Case. A self-hosted v2 on a non-loopback HTTPS origin, connecting Monarch's remote MCP (
https://api.monarch.com/mcp) as a quick-imported app with no app code. Monarch supports dynamic client registration with a public PKCE client. Itsredirect_uriallowlist is fixed: a few vendor callbacks plushttp://localhost/http://127.0.0.1. The host-wideEXECUTOR_OAUTH_CALLBACK_URLis either the public origin, which Monarch rejects, or a loopback address, which would break every other OAuth app on the instance. This is the same gap as #1439 and #2153 / #2154 (closed in the pre-v2 sweep with an invitation to reopen against v2) and is close to #2408.What I'd propose. A per-issuer callback override, kept small because the SDK already does most of the work. Each sign-in already carries its own
redirectUrithrough registration, the authorize URL, the callback check, code exchange, refresh (#2366) and the browser callback page. So the change is only in how that value is chosen:oauth.redirectUriByIssuer: Record<issuer, url>, applied inresolveSetuponce the authorization server's issuer is known;EXECUTOR_OAUTH_CALLBACK_OVERRIDES='{"https://api.monarch.com":"http://localhost:33418/api/oauth/callback"}'. It is validated at startup with the existing URL policy and the reserved-query-key check;self-host.mdx, plus a unit test and an e2e case alongside the existing non-origin-callback fixture (e2e/support/managed-server.ts).I keyed it on the issuer rather than the app because the constraint comes from the provider's allowlist, and app names are user-chosen and can be renamed (#2334). For the browser leg, the person either runs a tiny local forwarder on that port or edits the address back to the Executor origin. The callback page already rebuilds the callback from the saved
redirectUri, so either way works. On beta.36 I ran the same flow against a mock loopback-only provider, using the host-wide variable set to a loopback callback. Registration, code exchange, a later automatic refresh and tool calls all passed.Questions:
v2be acceptable, or does it conflict with the direction here?interactive()paste-back (Add app-owned interactive account sign-in #2421) or the host acquisition drivers (feat(accounts): add reusable host acquisition drivers #2479), will that cover quick-imported OAuth MCP apps that have no app code? Or would a generic built-in loopback paste-back driver be needed for them? If that's the plan, I'm happy to help there instead of opening a competing PR.JustYannicc commentedon Nov 5, 2026
On question 2, from the author of #2421 (I can't speak for what the maintainers want, so question 1 is theirs): neither #2421 nor #2479 covers a quick-imported OAuth MCP app with no app code.
interactive()as an auth method that the requiring app's own code drives. The app implementsconnect.<slot>.start/status/submit/cancel, does the sign-in against its own backend, and returns an opaqueref. Executor stores only that reference. A quick import has no app code to run those steps, so it never reaches this path.interactive({ label, acquisition: "host" }). It still needs an app declaring that method and a host driver registered for it. A quick-imported MCP app gets the built-inoauth2method, so this doesn't apply either.oauth2flow. Both leaveredirectUriselection, registration, the callback page and refresh exactly as they are, so a per-issuer callback override doesn't overlap with either.A generic loopback paste-back driver could in principle be built on #2479's driver model. But it would turn a standard OAuth flow into an interactive one, and it would need the quick-import path to pick that driver for loopback-only issuers. That's a larger change than choosing the redirect URI per issuer. As you note,
resolveSetupalready carriesredirectUriper attempt through authorize, exchange and refresh.