Skip to content

Let an app acquire an account through its own interactive sign-in #2414

Open

Description

@JustYannicc

Problem

Some services can only be signed in to through a flow the host can't run itself:

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 start in 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 polls status.
  • session stays on the server with the pending connection. The browser never sees it.
  • connected returns an opaque ref and optional accountInfo. The account stores only the ref as a plain field; the host never sees, refreshes or revokes the service's tokens. App code reads account.fields.ref.
  • The account is bound to the app that acquired it, since its ref means nothing to another app. Unlinking it there, or deleting it, calls remove so the app can delete the credential where it lives.
  • Closing the dialog before it finishes calls cancel.
  • health works unchanged, so an app can report a ref its 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

  1. alexg0 commented on Nov 5, 2026

    @alexg0

    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. Its redirect_uri allowlist is fixed: a few vendor callbacks plus http://localhost/http://127.0.0.1. The host-wide EXECUTOR_OAUTH_CALLBACK_URL is 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 redirectUri through 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:

    • an SDK option such as oauth.redirectUriByIssuer: Record<issuer, url>, applied in resolveSetup once the authorization server's issuer is known;
    • a self-host variable, for example 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;
    • the connection and app pages show the effective callback for that app;
    • docs in 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:

    1. Would a PR for this against v2 be acceptable, or does it conflict with the direction here?
    2. If the answer is 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.
  2. JustYannicc commented on Nov 5, 2026

    @JustYannicc
    ContributorAuthor

    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.

    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, resolveSetup already carries redirectUri per attempt through authorize, exchange and refresh.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions