Skip to content

Support CIMD OAuth for API sources#1177

Merged
RhysSullivan merged 2 commits into
mainfrom
support-posthog-cimd-oauth
Jun 28, 2026
Merged

Support CIMD OAuth for API sources#1177
RhysSullivan merged 2 commits into
mainfrom
support-posthog-cimd-oauth

Conversation

@RhysSullivan

Copy link
Copy Markdown
Collaborator

What

Adds OAuth Client ID Metadata Document (CIMD) support so an API source can be
connected without Dynamic Client Registration or a manually registered app.

When an authorization server advertises client_id_metadata_document_supported,
the connect flow uses a public PKCE client whose client_id is this host's
metadata-document URL. The provider fetches that document to learn the client's
name and redirect URIs, so there is no provider-side app to create. This is what
lets a provider like PostHog be connected directly from its API spec.

Changes

  • Detection: parse client_id_metadata_document_supported from RFC 8414
    authorization-server metadata; carry supportsClientIdMetadataDocument and the
    RFC 8707 resource indicator on the auth method and probe result.
  • Flow: when CIMD is advertised, skip DCR and use the client-id-as-URL public
    client; the existing metadata-document route serves the client document.
  • OpenAPI plugin: derive and preview CIMD-capable OAuth from a spec, and wire
    it through the add-source path.
  • UI: add-account and OAuth client form handle the public (no-secret) client.
  • Reconnect: spec-derived OAuth scopes are the full per-operation catalog
    (an OpenAPI source can declare hundreds). They are requested broadly but not
    individually required, so a provider that narrows the grant to the user's
    actual access is healthy. Only custom (user-configured) scopes stay required,
    so large API sources no longer show a spurious "reconnect to grant access".

Tests

Unit coverage across core SDK (discovery, scope handling), core API (client
metadata document), the OpenAPI plugin, and React (add-account, client form,
reconnect). bun run typecheck and the affected package tests pass.

Notes

Verified by hand against a live provider: detection, document fetch, and the
authorize handshake work. A full redirect round-trip needs the host's metadata
document to be publicly reachable (a loopback or tunneled origin), since the
provider fetches the client_id URL.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 28, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
executor-marketing ae8cb60 Commit Preview URL

Branch Preview URL
Jun 28 2026, 09:56 AM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 28, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
executor-cloud ae8cb60 Jun 28 2026, 09:56 AM

@RhysSullivan RhysSullivan force-pushed the support-posthog-cimd-oauth branch from 3d5505c to 74f1186 Compare June 28, 2026 09:46
@pkg-pr-new

pkg-pr-new Bot commented Jun 28, 2026

Copy link
Copy Markdown

Open in StackBlitz

@executor-js/cli

npm i https://pkg.pr.new/@executor-js/cli@1177

@executor-js/config

npm i https://pkg.pr.new/@executor-js/config@1177

@executor-js/execution

npm i https://pkg.pr.new/@executor-js/execution@1177

@executor-js/sdk

npm i https://pkg.pr.new/@executor-js/sdk@1177

@executor-js/codemode-core

npm i https://pkg.pr.new/@executor-js/codemode-core@1177

@executor-js/runtime-quickjs

npm i https://pkg.pr.new/@executor-js/runtime-quickjs@1177

@executor-js/plugin-file-secrets

npm i https://pkg.pr.new/@executor-js/plugin-file-secrets@1177

@executor-js/plugin-graphql

npm i https://pkg.pr.new/@executor-js/plugin-graphql@1177

@executor-js/plugin-keychain

npm i https://pkg.pr.new/@executor-js/plugin-keychain@1177

@executor-js/plugin-mcp

npm i https://pkg.pr.new/@executor-js/plugin-mcp@1177

@executor-js/plugin-onepassword

npm i https://pkg.pr.new/@executor-js/plugin-onepassword@1177

@executor-js/plugin-openapi

npm i https://pkg.pr.new/@executor-js/plugin-openapi@1177

executor

npm i https://pkg.pr.new/executor@1177

commit: 74f1186

@github-actions

github-actions Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Cloudflare preview

Torn down — the PR is closed.

RhysSullivan and others added 2 commits June 28, 2026 02:54
Detect OAuth Client ID Metadata Document (CIMD) support on an API's
authorization server (client_id_metadata_document_supported) and use the
client-id-as-URL public PKCE flow instead of Dynamic Client Registration,
so adding PostHog's API connects without minting a provider-side app.

- oauth-discovery: parse client_id_metadata_document_supported from AS metadata
- oauth-client: carry supportsClientIdMetadataDocument + RFC 8707 resource
- openapi plugin: derive/preview CIMD auth and wire the add-source flow
- serve the client-id metadata document; add-account + oauth-client-form UI
- tests across core/sdk, core/api, openapi, and react
An OpenAPI source declares the full per-operation scope catalog (PostHog
declares ~200). Those are requested broadly to unlock as many tools as
possible, but a provider that narrows the grant to the user's actual access
is healthy, not in need of reconnect. Only treat custom (user-configured)
scopes as required, never the spec catalog, via reconsentRequiredScopes.

Also fixes a garbled reconnect message in the accounts list.
@greptile-apps

greptile-apps Bot commented Jun 28, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds CIMD (Client ID Metadata Document) OAuth support so an API source like PostHog can be connected without Dynamic Client Registration or a manually-registered provider app — instead, the host's metadata-document URL is used directly as the client_id. It also fixes provider-error handling in the OAuth callback popup and suppresses spurious reconnect prompts for OpenAPI sources that declare hundreds of opportunistic scopes.

  • CIMD flow: detects client_id_metadata_document_supported via RFC 8414 discovery, creates a local public PKCE client keyed by the metadata-document URL, and wires a "No app registration" UI path parallel to the existing DCR path.
  • OpenAPI discovery: probes candidate URLs from the spec and server list; if an authorization server advertises CIMD, synthesizes a DiscoveredOAuth2 preset and carries resource / supportsClientIdMetadataDocument through the full add-source path.
  • Scope pruning: filterAuthorizationCodeScopes intersects declared scopes against the AS's scopes_supported at oauth.start time; reconsentRequiredScopes suppresses the reconnect banner for spec-derived OAuth scopes, treating them as opportunistic rather than individually required.

Confidence Score: 3/5

The CIMD and OpenAPI discovery paths are well-tested and look correct, but the reconnect-suppression change uses source === "spec" which catches all catalog-declared OAuth methods, not only OpenAPI ones — existing non-OpenAPI integrations with declared scopes would silently lose reconnect notifications after this lands.

The CIMD orchestration, provider-error short-circuit, and scope-pruning logic are solid and well-covered by tests. The concern is reconsentRequiredScopes: it returns undefined for every source: "spec" auth method, but authMethodFromDescriptor stamps every catalog-declared OAuth method with source: "spec" — not only OpenAPI sources. Non-OpenAPI integrations that later add a required scope would no longer prompt existing connections to reconnect, which is a silent behavioral change that's hard to notice without end-to-end testing across non-OpenAPI integrations.

packages/react/src/plugins/oauth-reconnect.ts and packages/react/src/components/accounts-section.tsx need a second look to confirm the intent is to suppress reconnect for all spec-declared OAuth methods, not only OpenAPI spec sources.

Important Files Changed

Filename Overview
packages/react/src/plugins/oauth-reconnect.ts Adds reconsentRequiredScopes to gate the reconnect banner; returns undefined for all source: "spec" methods, which silently disables reconnect for non-OpenAPI integrations too.
packages/core/sdk/src/oauth-service.ts Adds filterAuthorizationCodeScopes (scope intersection against AS metadata) and surfaces clientIdMetadataDocumentSupported from probe; discovery HTTP calls now run on every authorization-code start with a resource URL.
packages/react/src/components/add-account-modal.tsx Adds runCimdConnect orchestration and handleCimdConnect UI handler; CIMD path has no fallback on failure and analytics event fires before popup completes.
packages/core/api/src/oauth-popup.ts Refactors provider-error handling: short-circuits complete when error/error_description is present in URL params, rendering the error page directly instead; cleanly removes the dead error-forwarding path.
packages/plugins/openapi/src/sdk/plugin.ts Adds enrichPreviewWithDiscoveredOAuth to probe candidate URLs and synthesize a DiscoveredOAuth2 preset from AS metadata; logic is sound but calls parse(specText) inside the candidate loop.
packages/react/src/components/oauth-client-form.tsx Extracts canSubmitOAuthClientForm as a testable pure function and relaxes clientSecret requirement for authorization_code public clients; logic and UX copy are correct.
packages/react/src/plugins/oauth-sign-in.tsx Adds oauthClientIdMetadataDocumentUrl that derives the CIMD URL from the active org slug in the window path; handles server-side and configured base URL overrides correctly.
packages/react/src/components/accounts-section.tsx Switches to reconsentRequiredScopes for declared-scope computation; inherits the concern that all spec-sourced OAuth methods now return undefined required scopes.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant UI as AddAccountModal
    participant Plugin as OpenAPI Plugin
    participant Probe as oauth.probe
    participant AS as Authorization Server
    participant DB as OAuth Client Store
    participant Popup as OAuth Popup

    UI->>Plugin: previewSpec(specUrl)
    Plugin->>Probe: probe(candidate URL)
    Probe->>AS: GET /.well-known/oauth-authorization-server
    AS-->>Probe: "{ client_id_metadata_document_supported: true }"
    Probe-->>Plugin: "OAuthProbeResult { clientIdMetadataDocumentSupported: true }"
    Plugin-->>UI: SpecPreview with DiscoveredOAuth2 preset

    UI->>UI: "isCimd = true (skip DCR)"
    UI->>UI: handleCimdConnect()
    UI->>DB: "createOAuthClient({ clientId: metadataDocUrl, clientSecret: "" })"
    DB-->>UI: OAuthClientSlug

    UI->>Popup: "oauth.start({ client: slug, ... })"
    Popup->>AS: "GET /oauth/authorize?client_id=metadataDocUrl"
    AS->>AS: Fetch client metadata document
    AS-->>Popup: authorization code
    Popup-->>UI: Connection created
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant UI as AddAccountModal
    participant Plugin as OpenAPI Plugin
    participant Probe as oauth.probe
    participant AS as Authorization Server
    participant DB as OAuth Client Store
    participant Popup as OAuth Popup

    UI->>Plugin: previewSpec(specUrl)
    Plugin->>Probe: probe(candidate URL)
    Probe->>AS: GET /.well-known/oauth-authorization-server
    AS-->>Probe: "{ client_id_metadata_document_supported: true }"
    Probe-->>Plugin: "OAuthProbeResult { clientIdMetadataDocumentSupported: true }"
    Plugin-->>UI: SpecPreview with DiscoveredOAuth2 preset

    UI->>UI: "isCimd = true (skip DCR)"
    UI->>UI: handleCimdConnect()
    UI->>DB: "createOAuthClient({ clientId: metadataDocUrl, clientSecret: "" })"
    DB-->>UI: OAuthClientSlug

    UI->>Popup: "oauth.start({ client: slug, ... })"
    Popup->>AS: "GET /oauth/authorize?client_id=metadataDocUrl"
    AS->>AS: Fetch client metadata document
    AS-->>Popup: authorization code
    Popup-->>UI: Connection created
Loading

Comments Outside Diff (1)

  1. packages/react/src/components/add-account-modal.tsx, line 1717-1726 (link)

    P2 Analytics success flag reflects start, not completion

    trackEvent("connection_oauth_started", { success: outcome.kind === "started" }) is fired immediately after runCimdConnect returns, which is before the OAuth popup resolves. outcome.kind === "started" only means the CIMD client was created/reused and the popup was opened — a user who then cancels or encounters a provider error is still counted as a success. The DCR path has the same pattern, but CIMD is a new flow being instrumented now.

Reviews (2): Last reviewed commit: "Don't nag reconnect for spec-derived OAu..." | Re-trigger Greptile

@RhysSullivan RhysSullivan force-pushed the support-posthog-cimd-oauth branch from 74f1186 to ae8cb60 Compare June 28, 2026 09:54
Comment on lines +368 to +387
const filterAuthorizationCodeScopes = (
client: LoadedOAuthClient,
requestedScopes: readonly string[],
): Effect.Effect<readonly string[], never> =>
Effect.gen(function* () {
if (requestedScopes.length === 0) return requestedScopes;
const resource = client.resource
? yield* discoverProtectedResourceMetadata(client.resource, discoveryOptions).pipe(
Effect.catch(() => Effect.succeed(null)),
Effect.provide(httpClientLayer),
)
: null;
const issuer =
resource?.metadata.authorization_servers?.[0] ?? new URL(client.authorizationUrl).origin;
const as = yield* discoverAuthorizationServerMetadata(issuer, discoveryOptions).pipe(
Effect.catch(() => Effect.succeed(null)),
Effect.provide(httpClientLayer),
);
return intersectScopes(requestedScopes, as?.metadata.scopes_supported);
}).pipe(Effect.catch(() => Effect.succeed(requestedScopes)));

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 Outer Effect.catch does not cover synchronous URL-constructor throws

The new URL(client.authorizationUrl).origin call on line 381 sits inside Effect.gen. If authorizationUrl is ever an invalid URL string, it throws synchronously; Effect.gen wraps that as an Effect defect (die), which Effect.catch (typed-error handler) does not intercept — only Effect.catchCause would. In normal operation authorizationUrl is validated at client creation, so this path is almost unreachable, but swapping the outer catch to Effect.catchCause would make the best-effort guarantee unconditional.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@RhysSullivan RhysSullivan merged commit c85d12c into main Jun 28, 2026
9 of 14 checks passed
@RhysSullivan RhysSullivan deleted the support-posthog-cimd-oauth branch June 28, 2026 09:57
Comment on lines +131 to 141
export function reconsentRequiredScopes(
method:
| {
readonly source: "spec" | "custom";
readonly oauth?: { readonly scopes?: readonly string[] };
}
| undefined,
): readonly string[] | undefined {
if (method == null || method.source === "spec") return undefined;
return method.oauth?.scopes;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Reconnect suppressed for all spec-sourced OAuth methods, not only OpenAPI ones

reconsentRequiredScopes returns undefined whenever method.source === "spec". Because authMethodFromDescriptor unconditionally assigns source: "spec" to every catalog-declared auth method — including GitHub, Linear, Notion, or any other non-OpenAPI integration — this change silently disables the "Reconnect to grant access" banner for those integrations too. If a non-OpenAPI plugin adds a new required scope later, existing connections will never be flagged for re-consent.

The intent per the PR description is to prevent spurious reconnect prompts specifically for OpenAPI sources that declare hundreds of opportunistic scopes. A narrower guard — e.g. checking for a scopePolicy: "opportunistic" flag on the oauth descriptor, or comparing the method's template prefix — would achieve that without suppressing reconnect for every other integration.

Comment on lines +368 to +387
const filterAuthorizationCodeScopes = (
client: LoadedOAuthClient,
requestedScopes: readonly string[],
): Effect.Effect<readonly string[], never> =>
Effect.gen(function* () {
if (requestedScopes.length === 0) return requestedScopes;
const resource = client.resource
? yield* discoverProtectedResourceMetadata(client.resource, discoveryOptions).pipe(
Effect.catch(() => Effect.succeed(null)),
Effect.provide(httpClientLayer),
)
: null;
const issuer =
resource?.metadata.authorization_servers?.[0] ?? new URL(client.authorizationUrl).origin;
const as = yield* discoverAuthorizationServerMetadata(issuer, discoveryOptions).pipe(
Effect.catch(() => Effect.succeed(null)),
Effect.provide(httpClientLayer),
);
return intersectScopes(requestedScopes, as?.metadata.scopes_supported);
}).pipe(Effect.catch(() => Effect.succeed(requestedScopes)));

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 Discovery HTTP calls added to every authorization_code OAuth start

filterAuthorizationCodeScopes issues up to two external HTTP requests (protected-resource metadata, then authorization-server metadata) on every oauth.start call for clients that have a resource URL configured. This is unconditional for the "declare" scope policy, meaning BYO OAuth apps and CIMD clients with a resource are also affected, not just the stale-scope pruning path. A slow or unreachable server adds latency to the user-visible "Connecting…" state; the silent fallback (returning the original scopes) means the pruning silently does nothing on failure, which is safe but hard to observe.

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.

1 participant