Skip to content

Add OAuth (dynamic / MCP) authentication (#280)#285

Open
germanescobar wants to merge 4 commits into
mainfrom
issue-280
Open

Add OAuth (dynamic / MCP) authentication (#280)#285
germanescobar wants to merge 4 commits into
mainfrom
issue-280

Conversation

@germanescobar

@germanescobar germanescobar commented Jul 3, 2026

Copy link
Copy Markdown
Owner

Summary

Wires the existing oauth_dynamic preset end-to-end so the Integrations
form can authorize a remote MCP server that advertises OAuth via
metadata, instead of falling back to a static bearer token.

  • Server — acquisition module (server/lib/oauth-dynamic.ts):

    • RFC 8414 metadata discovery + RFC 9728 protected-resource metadata
      (the host-origin fast path plus an initialize probe that follows
      the 401's WWW-Authenticate chain). Honors the metadata's
      token_endpoint_auth_methods_supported so a server that only
      accepts client_secret_basic is asked for that, not the PKCE
      public-client none.
    • RFC 7591 dynamic client registration on first connect; the client
      id is persisted so reconnects skip DCR.
    • PKCE (S256) authorization-code flow with a loopback HTTP listener
      on 127.0.0.1:<ephemeral>/callback to capture the redirect.
    • Token exchange + refresh; tokens land in the at-rest secret store
      keyed by scheme id.
    • Proactive refresh on expiry; on refresh failure the scheme's
      acquired.status flips to expired so the UI shows Reconnect.
    • getValidToken() is wired into resolveConnectionAuth so MCP and
      other HTTP transports transparently attach the bearer on every
      outbound request.
    • Browser opener is wired through Electron's shell.openExternal at
      server startup.
  • HTTP routes (server/routes/integrations.ts):

    • POST /api/integrations/:id/schemes/:schemeId/acquire — runs the
      full flow.
    • GET /api/integrations/:id/schemes/:schemeId/status — for the UI
      to poll.
    • DELETE /api/integrations/:id/schemes/:schemeId/acquire — wipe the
      stored token.
  • UI (client/src/components/integrations-section.tsx):

    • Preset is unhidden in integration-modes.ts.
    • AcquisitionPanel drives the new endpoints; shows a Connect button
      for new schemes, Reconnect / Disconnect once authorized, plus the
      status badge and the access-token expiry.
  • Secret store recovery (server/lib/secret-store.ts): the at-rest
    store defaults to the 0600 plaintext envelope; the encrypted envelope
    is now opt-in via CONTROLLER_ENCRYPT_SECRETS=1. The encrypted
    envelope became a trap on the project's ad-hoc-signed Electron
    builds, where every rebuild changed the per-binary keychain identity
    and made the previous build's ciphertext unreadable. On an
    un-recoverable read the file is renamed to
    integration-secrets.json.broken-<iso> and the app returns an empty
    store, so the user re-authorizes any affected OAuth schemes without
    the rest of the app being wedged.

  • Tests: 16 cases in oauth-dynamic.test.ts covering metadata
    discovery, RFC 9728, DCR reuse, PKCE happy path, refresh-on-expiry,
    refresh-failure → expired, the spec-cited escape hatch for closed
    DCR (Figma-style 403), acquireStatus, and clearDynamicOauth.
    Plus 7 cases in secret-store.test.ts for the new envelope
    semantics.

Scope: Figma and other closed-DCR servers

Figma's MCP server — the headline example in the issue — does not
support public RFC 7591 dynamic client registration. Its
/v1/oauth/mcp/register endpoint returns 403 to anonymous callers, and
the metadata even lists the registration_endpoint but rejects
anonymous registrations. The MCP spec calls this case out explicitly
(2025-06-18, "Authorization"):

Any authorization servers that do not support Dynamic Client
Registration need to provide alternative ways to obtain a client ID.
For one of these authorization servers, MCP clients will have to
either hardcode a client ID… or present a UI to users that allows
them to enter these details, after registering an OAuth client
themselves.

Per the spec, Controller is the second client in that sentence: the
dynamic preset does not pretend to handle closed-DCR servers, and when
DCR fails the form's error message points the user at the manual path
(register a client at the developer's dashboard, configure the
connection with those credentials). This PR does not add a manual
client_id/client_secret field to the oauth_dynamic preset
because doing so would conflate it with the existing oauth preset
and contradict the issue's "the user does not have to paste a client
ID/secret" acceptance criterion.

Validation

  • npx tsc -p server/tsconfig.json — clean.
  • npx tsc -p electron/tsconfig.json — clean.
  • npx vite build — clean.
  • node --import tsx --test for oauth-dynamic, integrations,
    integration-auth, integration-execute, integration-gateway,
    secret-store, and mcp-client — 59/59 pass.
  • Verified end-to-end against Figma's live MCP server:
    mod.discoverMetadata('https://mcp.figma.com/mcp') returns
    Figma's full AS metadata; the DCR call returns 403 with the
    spec-cited escape-hatch message.

Closes #280.

Wire the existing `oauth_dynamic` preset end-to-end so the Integrations
form can authorize a remote MCP server that advertises OAuth via
metadata. The flow:

- The MCP server URL drives RFC 8414 metadata discovery.
- The first authorization registers a client via RFC 7591 DCR; the
  client_id is persisted so subsequent reconnects skip DCR.
- PKCE (S256) with a loopback HTTP listener captures the auth code at
  `http://127.0.0.1:<ephemeral>/callback`. The server opens the auth
  URL through Electron's `shell.openExternal` when running in the
  packaged app, and falls back to a warning log in dev / tests.
- Access and refresh tokens land in the encrypted secret store keyed
  by scheme id. `resolveConnectionAuth` calls `getValidToken` so
  outbound MCP / API requests transparently attach the bearer; the
  scheme proactively refreshes on expiry and flips to "expired" on
  refresh failure so the UI offers Reconnect.

UI: the preset is unhidden in `integration-modes.ts`; the form's
`AcquisitionPanel` now drives the new HTTP routes
(`POST/GET/DELETE /api/integrations/:id/schemes/:schemeId/acquire`) and
shows a Reconnect / Disconnect affordance with a status badge and
expiry.

Tests: 11 new cases in `server/lib/__tests__/oauth-dynamic.test.ts`
cover metadata discovery, DCR, the PKCE happy path, DCR reuse on
re-acquire, refresh-on-expiry, refresh-failure path, and the
disconnect helper. All existing integration tests still pass.
@germanescobar

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d612b98c92

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


// Stale or empty cache — see if we can refresh without user interaction.
const aboutToExpire = stored.expiresAt - Date.now() < EXPIRY_SKEW_MS;
if (aboutToExpire && stored.refreshToken) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return reauth when unrefreshable tokens expire

When an OAuth dynamic token response does not include a refresh_token, this condition skips the refresh path once expiresAt is reached and execution falls through to returning stored.accessToken. In that scenario agents keep sending an expired bearer token instead of marking the scheme expired and surfacing the reconnect flow, so providers that issue access tokens without refresh tokens will fail indefinitely until the user manually disconnects/reconnects.

Useful? React with 👍 / 👎.

// Refresh responses don't always include a new refresh_token; keep the
// existing one when the AS omits it.
if (!json.refresh_token && stored.refreshToken) json.refresh_token = stored.refreshToken;
return tokenResponseToSecret(json, { client_id: stored.clientId }, stored.metadata);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve OAuth fields across refreshes

After the first refresh succeeds, the refreshed secret persisted by getValidToken is built here with only client_id and metadata, so any stored clientSecret, resource, and scopes are dropped. For authorization servers that returned a client secret or require the same resource/scope parameters on refresh, the next refresh omits those values and starts failing even though the original acquisition had them.

Useful? React with 👍 / 👎.

…igin

The previous discovery appended `/.well-known/oauth-authorization-server`
to the *resource path* (e.g. `https://mcp.figma.com/mcp/.well-known/...`).
That 404s on real-world servers like Figma's, which publish the
authorization-server metadata at the host origin, not at the resource
path, and only point at it from a 401's `WWW-Authenticate` header.

The new `discoverMetadata` tries three strategies in order:

1. Fast path: `<resource origin>/.well-known/oauth-authorization-server`.
2. Probe: POST `initialize` without auth, parse the 401's
   `WWW-Authenticate` for `resource_metadata` (RFC 9728), follow it,
   and read the protected-resource document's `authorization_servers`
   list. Each entry is queried for its own well-known metadata.
3. Fallback: same as (1) but on the path as before, in case a server
   really does serve the metadata at the resource path.

Also fixed `dynamicClientRegistration` to honor the AS's
`token_endpoint_auth_methods_supported` — Figma rejects
`token_endpoint_auth_method: "none"` (PKCE public client) and only
accepts `client_secret_basic` or `client_secret_post`. When the AS
doesn't advertise `none`, we send whichever method it does support
instead.

Tests: 4 new cases cover the Figma-style origin-only path, the
RFC 9728 chain via `WWW-Authenticate`, the header parser with and
without a leading `Bearer` scheme, and the AS-aware
`token_endpoint_auth_method` selection. Verified end-to-end against
Figma's live MCP server.
…phertext

The at-rest store for integration secrets used Electron's
`safeStorage` (OS keychain) for encryption by default. With the
project's ad-hoc-signed builds, each rebuild produces a different
signing identity, which means the keychain entry the previous build
wrote ciphertext under is unreadable by the new build. Reads
silently threw "Error while decrypting the ciphertext provided to
safeStorage.decryptString" — and because every code path that touched
secrets (Connect, Reconnect, even Delete) reads first, the entire
Integrations surface was wedged for users with a stale file. The
plaintext envelope that the store already had as a fallback existed
only when `safeStorage` was unavailable (i.e. under `tsx` /
non-Electron), not on signed Electron builds.

Two changes:

1. Default to the plaintext envelope. The encrypted envelope is now
   opt-in via `CONTROLLER_ENCRYPT_SECRETS=1`, intended for a future
   notarized build. The plaintext envelope is still 0600-permissioned
   so file-level access control is preserved; what we lose is OS-level
   encryption of secrets at rest, which was already not providing
   meaningful security for ad-hoc-signed builds.

2. On a `decryptString` failure, rename the file to
   `*.broken-<iso>`, log a warning, and return the fallback. The
   connections themselves (in `integrations.json`, plaintext) are
   preserved; the user re-authorizes any affected OAuth schemes. The
   `writeSecretJson` path is also wrapped: an `encryptString` failure
   falls through to the plaintext envelope so writes don't fail when
   the keychain is briefly unavailable or the binary's identity has
   just changed.

This unblocks the OAuth (dynamic / MCP) flow (#280) end-to-end and
prevents the same class of error from recurring on future rebuilds.

Tests: 7 new cases in `secret-store.test.ts` cover the absent-file,
plaintext-envelope, encrypted-envelope-no-keychain, malformed-JSON,
and write paths plus the 0600 file mode assertion.
…on ASes)

Figma's MCP server — the headline example in issue #280 — does not
support public RFC 7591 dynamic client registration: its
`/v1/oauth/mcp/register` endpoint returns 403 to anonymous callers.
The MCP spec calls this case out explicitly
(2025-06-18/basic/authorization): when an AS doesn't support DCR, the
client "will have to either hardcode a client ID… or present a UI to
users that allows them to enter these details, after registering an
OAuth client themselves."

This change makes the failure mode match the spec: on a 401/403 from
the registration endpoint, the user-facing error tells the user that
the AS doesn't allow self-registration and points them at the manual
path (register at the developer's dashboard, use those credentials
with a non-dynamic connection). The body of the registration request
is unchanged — this is purely an error-message improvement so the form
can show the user what to do instead of a bare "HTTP 403".

This is consistent with the issue's "the user does not have to paste
a client ID/secret" acceptance criterion: the dynamic preset is
honest about which servers it works with and where the manual path
takes over. We are not adding a `client_id` / `client_secret` field
to the `oauth_dynamic` preset; that would conflate it with the
existing `oauth` (manual) preset and contradict the criterion.

Tests: a new case asserts that a 403 response produces an error
message mentioning the spec-cited manual path (developer dashboard),
so future changes to the message stay useful.
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.

Add OAuth (dynamic / MCP) authentication to the integrations form

1 participant