diff --git a/apps/gateway/src/apps.rs b/apps/gateway/src/apps.rs index b52bd3b0..74a5285d 100644 --- a/apps/gateway/src/apps.rs +++ b/apps/gateway/src/apps.rs @@ -100,6 +100,10 @@ pub(crate) enum ClientCredentialMethod { Body, /// Send `Authorization: Basic base64(client_id:client_secret)` header (Notion). BasicAuth, + /// Public client (no secret exists): send `client_id` in the body only. + /// Used by dynamically registered clients — providers advertising + /// `token_endpoint_auth_methods_supported: ["none"]`, e.g. Vanta's MCP. + PublicClient, } /// Configuration for refreshing expired OAuth tokens. @@ -114,6 +118,15 @@ pub(crate) struct RefreshConfig { pub(crate) body_format: TokenBodyFormat, /// How client credentials are sent (body vs Basic auth header). pub(crate) client_auth: ClientCredentialMethod, + /// For dynamically registered clients: the credential JSON field holding the + /// client id this connection's refresh token was issued to. The id is + /// per-connection (not per-deployment), so it can only come from the + /// credentials — there is no AppConfig pair or env var to fall back to. + pub(crate) client_id_credential_field: Option<&'static str>, + /// For providers with regional token endpoints: the credential JSON field + /// holding the endpoint this connection was minted against. Falls back to + /// `token_url` when absent, so a refresh never crosses regions. + pub(crate) token_url_credential_field: Option<&'static str>, } /// Maps a credential JSON field to an HTTP header injected on every request. @@ -173,6 +186,8 @@ static ATLASSIAN_REFRESH: RefreshConfig = RefreshConfig { client_secret_env: "ATLASSIAN_CLIENT_SECRET", body_format: TokenBodyFormat::Json, client_auth: ClientCredentialMethod::Body, + client_id_credential_field: None, + token_url_credential_field: None, }; /// Refresh config for Todoist OAuth API. @@ -182,6 +197,8 @@ static TODOIST_REFRESH: RefreshConfig = RefreshConfig { client_secret_env: "TODOIST_CLIENT_SECRET", body_format: TokenBodyFormat::Form, client_auth: ClientCredentialMethod::Body, + client_id_credential_field: None, + token_url_credential_field: None, }; /// Shared refresh config for all Google OAuth APIs. @@ -191,6 +208,8 @@ static GOOGLE_REFRESH: RefreshConfig = RefreshConfig { client_secret_env: "GOOGLE_CLIENT_SECRET", body_format: TokenBodyFormat::Form, client_auth: ClientCredentialMethod::Body, + client_id_credential_field: None, + token_url_credential_field: None, }; /// Refresh config for Supabase Management API OAuth (uses Basic auth). @@ -200,6 +219,8 @@ static SUPABASE_REFRESH: RefreshConfig = RefreshConfig { client_secret_env: "SUPABASE_CLIENT_SECRET", body_format: TokenBodyFormat::Form, client_auth: ClientCredentialMethod::BasicAuth, + client_id_credential_field: None, + token_url_credential_field: None, }; /// Refresh config for GitLab OAuth API. @@ -209,6 +230,8 @@ static GITLAB_REFRESH: RefreshConfig = RefreshConfig { client_secret_env: "GITLAB_CLIENT_SECRET", body_format: TokenBodyFormat::Form, client_auth: ClientCredentialMethod::Body, + client_id_credential_field: None, + token_url_credential_field: None, }; /// Refresh config for Notion OAuth API (uses Basic auth + token rotation). @@ -218,6 +241,8 @@ static NOTION_REFRESH: RefreshConfig = RefreshConfig { client_secret_env: "NOTION_CLIENT_SECRET", body_format: TokenBodyFormat::Json, client_auth: ClientCredentialMethod::BasicAuth, + client_id_credential_field: None, + token_url_credential_field: None, }; /// Refresh config for Dropbox OAuth API. @@ -227,6 +252,8 @@ static DROPBOX_REFRESH: RefreshConfig = RefreshConfig { client_secret_env: "DROPBOX_CLIENT_SECRET", body_format: TokenBodyFormat::Form, client_auth: ClientCredentialMethod::Body, + client_id_credential_field: None, + token_url_credential_field: None, }; /// Refresh config for LinkedIn OAuth API. @@ -236,6 +263,26 @@ static LINKEDIN_REFRESH: RefreshConfig = RefreshConfig { client_secret_env: "LINKEDIN_CLIENT_SECRET", body_format: TokenBodyFormat::Form, client_auth: ClientCredentialMethod::Body, + client_id_credential_field: None, + token_url_credential_field: None, +}; + +/// Refresh config for Vanta's MCP server OAuth. +/// +/// A public client: Vanta's authorization-server metadata advertises +/// `token_endpoint_auth_methods_supported: ["none"]`, so the client is +/// registered dynamically (RFC 7591) per project and there is no secret. Both +/// the client id and the regional token endpoint therefore travel in the +/// connection's own credentials — `token_url` below is only the US default for +/// credentials predating that field. +static VANTA_REFRESH: RefreshConfig = RefreshConfig { + token_url: "https://api.vanta.com/oauth/token", + client_id_env: "VANTA_CLIENT_ID", + client_secret_env: "VANTA_CLIENT_SECRET", + body_format: TokenBodyFormat::Form, + client_auth: ClientCredentialMethod::PublicClient, + client_id_credential_field: Some("client_id"), + token_url_credential_field: Some("token_url"), }; // ── Provider registry ────────────────────────────────────────────────── @@ -1107,6 +1154,46 @@ static APP_PROVIDERS: &[AppProvider] = &[ finalizer: None, body_transform: None, }, + AppProvider { + provider: "vanta", + display_name: "Vanta", + // Vanta's MCP server, one host per region. A connection is authorized + // against exactly one of them, so `credential_host_field` gates + // injection to the region stored on the connection — a US token is + // never handed to `mcp.eu.vanta.com`. Vanta's REST API + // (`api.vanta.com`) is deliberately absent: these are MCP-scoped + // tokens (`mcp-api.*`), which the REST API does not accept. + host_rules: &[ + HostRule { + pattern: HostPattern::Exact("mcp.vanta.com"), + path_prefix: None, + strategy: AuthStrategy::Bearer, + intercept: false, + credential_host_field: Some("mcp_host"), + }, + HostRule { + pattern: HostPattern::Exact("mcp.eu.vanta.com"), + path_prefix: None, + strategy: AuthStrategy::Bearer, + intercept: false, + credential_host_field: Some("mcp_host"), + }, + HostRule { + pattern: HostPattern::Exact("mcp.aus.vanta.com"), + path_prefix: None, + strategy: AuthStrategy::Bearer, + intercept: false, + credential_host_field: Some("mcp_host"), + }, + ], + refresh: Some(&VANTA_REFRESH), + metadata_headers: &[], + credential_headers: &[], + credential_params: &[], + host_rewrite: None, + finalizer: None, + body_transform: None, + }, ]; // ── Public API ───────────────────────────────────────────────────────── @@ -1532,26 +1619,41 @@ pub(crate) fn is_intercept_target(hostname: &str, path: &str) -> bool { /// Returns (new_access_token, expires_at, optional_new_refresh_token). /// /// Client credentials are resolved in order: -/// 1. Explicit `client_id`/`client_secret` (from BYOC AppConfig) +/// 1. Explicit `client_id`/`client_secret` (from BYOC AppConfig, or — for public +/// clients — the connection's own credentials) /// 2. Env vars from `RefreshConfig` (platform defaults) +/// +/// `token_url_override` carries a per-connection token endpoint for providers +/// with regional deployments; `config.token_url` is the default. +/// +/// A `PublicClient` provider has no secret at all: none is read from env and +/// none is sent, so `byoc_client_secret` is ignored for those. pub(crate) async fn refresh_access_token( config: &RefreshConfig, refresh_token: &str, byoc_client_id: Option<&str>, byoc_client_secret: Option<&str>, + token_url_override: Option<&str>, ) -> anyhow::Result<(String, i64, Option)> { + let is_public_client = matches!(config.client_auth, ClientCredentialMethod::PublicClient); + let client_id = match byoc_client_id { Some(id) => id.to_string(), None => std::env::var(config.client_id_env) .map_err(|_| anyhow::anyhow!("{} env var not set", config.client_id_env))?, }; - let client_secret = match byoc_client_secret { - Some(secret) => secret.to_string(), - None => std::env::var(config.client_secret_env) - .map_err(|_| anyhow::anyhow!("{} env var not set", config.client_secret_env))?, + let client_secret = if is_public_client { + String::new() + } else { + match byoc_client_secret { + Some(secret) => secret.to_string(), + None => std::env::var(config.client_secret_env) + .map_err(|_| anyhow::anyhow!("{} env var not set", config.client_secret_env))?, + } }; - let mut req = reqwest::Client::new().post(config.token_url); + let token_url = token_url_override.unwrap_or(config.token_url); + let mut req = reqwest::Client::new().post(token_url); if matches!(config.client_auth, ClientCredentialMethod::BasicAuth) { let b64 = base64::engine::general_purpose::STANDARD; @@ -1560,6 +1662,18 @@ pub(crate) async fn refresh_access_token( } let req = match (&config.body_format, &config.client_auth) { + (TokenBodyFormat::Form, ClientCredentialMethod::PublicClient) => req.form(&[ + ("client_id", client_id.as_str()), + ("refresh_token", refresh_token), + ("grant_type", "refresh_token"), + ]), + (TokenBodyFormat::Json, ClientCredentialMethod::PublicClient) => { + req.json(&serde_json::json!({ + "client_id": client_id, + "refresh_token": refresh_token, + "grant_type": "refresh_token", + })) + } (TokenBodyFormat::Form, ClientCredentialMethod::Body) => req.form(&[ ("client_id", client_id.as_str()), ("client_secret", client_secret.as_str()), @@ -3234,6 +3348,62 @@ mod tests { assert!(refresh_config("jfrog-artifactory").is_none()); } + // ── Vanta (MCP) ─────────────────────────────────────────────────── + + #[test] + fn providers_for_vanta_mcp_hosts() { + assert_eq!(providers_for_host("mcp.vanta.com"), vec!["vanta"]); + assert_eq!(providers_for_host("mcp.eu.vanta.com"), vec!["vanta"]); + assert_eq!(providers_for_host("mcp.aus.vanta.com"), vec!["vanta"]); + } + + #[test] + fn vanta_rest_api_is_not_a_provider_host() { + // MCP-scoped tokens (`mcp-api.*`) are not REST API credentials, so + // `api.vanta.com` must never receive an injection. + assert!(providers_for_host("api.vanta.com").is_empty()); + assert!(providers_for_host("app.vanta.com").is_empty()); + } + + #[test] + fn vanta_mcp_uses_bearer() { + let injections = build_app_injections("vanta", "mcp.vanta.com", "vanta_tok"); + assert_eq!(injections.len(), 1); + assert_eq!( + injections[0], + Injection::SetHeader { + name: "authorization".to_string(), + value: "Bearer vanta_tok".to_string(), + } + ); + } + + #[test] + fn vanta_regions_are_host_gated() { + // Every region carries the gate, so a connection minted for one region + // can never have its token injected into another's host. + for host in ["mcp.vanta.com", "mcp.eu.vanta.com", "mcp.aus.vanta.com"] { + assert_eq!(credential_host_field("vanta", host), Some("mcp_host")); + } + } + + #[test] + fn vanta_refresh_is_a_public_client_with_credential_sourced_endpoint() { + let config = refresh_config("vanta").expect("vanta should have refresh config"); + assert!(matches!(config.body_format, TokenBodyFormat::Form)); + assert!(matches!( + config.client_auth, + ClientCredentialMethod::PublicClient + )); + assert_eq!(config.client_id_credential_field, Some("client_id")); + assert_eq!(config.token_url_credential_field, Some("token_url")); + } + + #[test] + fn vanta_needs_access_token() { + assert!(needs_access_token("vanta")); + } + // ── credential_host_field ───────────────────────────────────────── #[test] diff --git a/apps/gateway/src/connect.rs b/apps/gateway/src/connect.rs index 085c0ec3..ff79fdd2 100644 --- a/apps/gateway/src/connect.rs +++ b/apps/gateway/src/connect.rs @@ -1126,9 +1126,28 @@ impl PolicyEngine { { // Authorized user / default: refresh via OAuth refresh_token if let Some(config) = apps::refresh_config(provider) { - let byoc = self - .resolve_byoc_credentials(project_id, provider, connection_id) - .await; + // A public client (dynamically registered, no secret) + // carries its client id and regional token endpoint in + // the connection's own credentials — the BYOC and env + // tiers have neither, so skip the BYOC lookup entirely. + // Owned copies: `creds` is mutated below. + let cred_client_id = config + .client_id_credential_field + .and_then(|field| creds.get(field)) + .and_then(|v| v.as_str()) + .map(String::from); + let cred_token_url = config + .token_url_credential_field + .and_then(|field| creds.get(field)) + .and_then(|v| v.as_str()) + .map(String::from); + + let byoc = if cred_client_id.is_some() { + None + } else { + self.resolve_byoc_credentials(project_id, provider, connection_id) + .await + }; let (byoc_id, byoc_secret) = match &byoc { Some((id, secret)) => (Some(id.as_str()), Some(secret.as_str())), None => (None, None), @@ -1137,8 +1156,9 @@ impl PolicyEngine { match apps::refresh_access_token( config, refresh_token, - byoc_id, + cred_client_id.as_deref().or(byoc_id), byoc_secret, + cred_token_url.as_deref(), ) .await { diff --git a/apps/web/public/icons/vanta.png b/apps/web/public/icons/vanta.png new file mode 100644 index 00000000..d04a014f Binary files /dev/null and b/apps/web/public/icons/vanta.png differ diff --git a/apps/web/src/app/(connect)/app-connect/[provider]/page.tsx b/apps/web/src/app/(connect)/app-connect/[provider]/page.tsx index 0aa09025..60898ab7 100644 --- a/apps/web/src/app/(connect)/app-connect/[provider]/page.tsx +++ b/apps/web/src/app/(connect)/app-connect/[provider]/page.tsx @@ -15,6 +15,8 @@ interface Props { agent_name?: string; projectId?: string; orgId?: string; + /** Region for providers hosted in several (e.g. Vanta's us/eu/aus). */ + region?: string; }>; } @@ -28,6 +30,7 @@ export default async function ConnectPage({ params, searchParams }: Props) { agent_name, projectId, orgId, + region, } = await searchParams; const app = getApp(provider); @@ -50,6 +53,11 @@ export default async function ConnectPage({ params, searchParams }: Props) { // Auth may not be resolved; treat as false } + // Dynamic-registration apps (RFC 7591) need nothing configured up front: the + // connect route mints their OAuth client on the way out. + const hasDefaults = + hasEnvDefaults || hasAppConfig || !!app.dynamicRegistration; + // An app may offer an API-key alternate alongside its primary OAuth flow. const apiKeyMethod = app.additionalMethods?.find((m) => m.type === "api_key"); const apiKeyFields = @@ -77,7 +85,8 @@ export default async function ConnectPage({ params, searchParams }: Props) { : undefined, apiKeyFields, }} - hasDefaults={hasEnvDefaults || hasAppConfig} + hasDefaults={hasDefaults} + region={region} status={status === "success" || status === "error" ? status : undefined} errorMessage={message} connectionId={connectionId} diff --git a/apps/web/src/app/(connect)/app-connect/_components/connect-flow.tsx b/apps/web/src/app/(connect)/app-connect/_components/connect-flow.tsx index 80af71c5..85416fc6 100644 --- a/apps/web/src/app/(connect)/app-connect/_components/connect-flow.tsx +++ b/apps/web/src/app/(connect)/app-connect/_components/connect-flow.tsx @@ -61,6 +61,9 @@ interface ConnectFlowProps { hiddenFields?: Record; projectId?: string; orgId?: string; + /** Region for providers hosted in several (e.g. Vanta's us/eu/aus); the + * authorize route validates it and falls back to the provider's default. */ + region?: string; } export const ConnectFlow = ({ @@ -75,6 +78,7 @@ export const ConnectFlow = ({ hiddenFields, projectId: explicitProjectId, orgId, + region, }: ConnectFlowProps) => { const [state, setState] = useState( status === "success" ? "success" : status === "error" ? "error" : "ready", @@ -97,6 +101,7 @@ export const ConnectFlow = ({ const params = new URLSearchParams(); if (connectionId) params.set("connectionId", connectionId); if (agentName) params.set("agent_name", agentName); + if (region) params.set("region", region); const token = await getAuthToken(); if (token) params.set("_token", token); @@ -107,7 +112,7 @@ export const ConnectFlow = ({ const qs = params.toString(); const authorizeUrl = `${API_ORIGIN}/v1/apps/${app.id}/authorize${qs ? `?${qs}` : ""}`; window.location.href = authorizeUrl; - }, [app.id, connectionId, agentName, explicitProjectId, orgId]); + }, [app.id, connectionId, agentName, explicitProjectId, orgId, region]); // Countdown timer for auto-redirect useEffect(() => { diff --git a/apps/web/src/app/(dashboard)/connections/_components/app-categories.ts b/apps/web/src/app/(dashboard)/connections/_components/app-categories.ts index fe51f553..34624770 100644 --- a/apps/web/src/app/(dashboard)/connections/_components/app-categories.ts +++ b/apps/web/src/app/(dashboard)/connections/_components/app-categories.ts @@ -4,7 +4,8 @@ export type AppCategory = | "development" | "project-management" | "cloud-data" - | "communication"; + | "communication" + | "security"; export const CATEGORY_LABELS: { id: AppCategory | "all"; label: string }[] = [ { id: "all", label: "All" }, @@ -14,6 +15,7 @@ export const CATEGORY_LABELS: { id: AppCategory | "all"; label: string }[] = [ { id: "project-management", label: "Project Management" }, { id: "cloud-data", label: "Cloud & Data" }, { id: "communication", label: "Communication" }, + { id: "security", label: "Security & Compliance" }, ]; export const APP_CATEGORIES: Record = { @@ -79,4 +81,7 @@ export const APP_CATEGORIES: Record = { granola: "communication", fathom: "communication", x: "communication", + + // Security & Compliance + vanta: "security", }; diff --git a/packages/api/src/apps/oauth/dynamic-registration.test.ts b/packages/api/src/apps/oauth/dynamic-registration.test.ts new file mode 100644 index 00000000..0e38d077 --- /dev/null +++ b/packages/api/src/apps/oauth/dynamic-registration.test.ts @@ -0,0 +1,222 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const getAppConfig = vi.fn(); +const upsertDynamicClientConfig = vi.fn(); + +vi.mock("../../services/app-config-service", () => ({ + getAppConfig, + upsertDynamicClientConfig, +})); + +const { resolveDynamicClient, resolveRegion } = + await import("./dynamic-registration"); + +import type { AppDefinition } from "../types"; + +const appDef: AppDefinition = { + id: "regional", + name: "Regional App", + icon: "/icons/regional.png", + description: "Dynamic-registration test app", + available: true, + connectionMethod: { + type: "oauth", + pkce: true, + buildAuthUrl: () => "https://provider.example/authorize", + exchangeCode: async () => ({ credentials: {}, scopes: [] }), + }, + dynamicRegistration: { + clientName: "OneCLI", + regions: ["us", "eu"], + registrationUrl: (region) => + region === "us" + ? "https://api.provider.example/oauth/register" + : `https://api.${region}.provider.example/oauth/register`, + }, +}; + +const REDIRECT_URI = "https://api.example.com/v1/apps/regional/callback"; +const SCOPES = ["mcp-api.all:read"]; + +const resolve = ( + allowRegister: boolean, + region = "us", + redirectUri = REDIRECT_URI, +) => + resolveDynamicClient( + { projectId: "p1" }, + appDef, + redirectUri, + region, + SCOPES, + { + allowRegister, + }, + ); + +beforeEach(() => { + getAppConfig.mockReset(); + upsertDynamicClientConfig.mockReset(); + upsertDynamicClientConfig.mockResolvedValue({ + id: "cfg1", + provider: "regional", + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("resolveRegion", () => { + const registration = appDef.dynamicRegistration!; + + it("defaults to the first region", () => { + expect(resolveRegion(registration)).toBe("us"); + }); + + it("honors a hosted region", () => { + expect(resolveRegion(registration, "eu")).toBe("eu"); + }); + + it("falls back for a region the provider does not host", () => { + // A caller-supplied query param must never reach the endpoint templates + // unvalidated. + expect(resolveRegion(registration, "moon")).toBe("us"); + }); +}); + +describe("resolveDynamicClient", () => { + it("registers a client and caches it on first connect", async () => { + getAppConfig.mockResolvedValue(null); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 201, + json: async () => ({ client_id: "minted-1" }), + } as Response); + vi.stubGlobal("fetch", fetchMock); + + const resolved = await resolve(true); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.provider.example/oauth/register"); + expect(JSON.parse(init.body as string)).toEqual({ + client_name: "OneCLI", + redirect_uris: [REDIRECT_URI], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + scope: "mcp-api.all:read", + }); + expect(upsertDynamicClientConfig).toHaveBeenCalledWith( + { projectId: "p1" }, + "regional", + { clientId: "minted-1", redirectUri: REDIRECT_URI, region: "us" }, + ); + expect(resolved).toEqual({ + values: { clientId: "minted-1", region: "us" }, + source: "app_config", + registered: true, + }); + }); + + it("reuses the cached client without calling the provider", async () => { + getAppConfig.mockResolvedValue({ + enabled: true, + hasCredentials: false, + settings: { + clientId: "cached-1", + redirectUri: REDIRECT_URI, + region: "us", + }, + }); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const resolved = await resolve(true); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(resolved?.values).toEqual({ clientId: "cached-1", region: "us" }); + // Nothing was written, so the caller has nothing to audit. + expect(resolved?.registered).toBe(false); + }); + + it("re-registers when the redirect URI moved", async () => { + // A client id is only valid for the redirect URIs it was registered with, so + // a new deployment origin needs a new client. + getAppConfig.mockResolvedValue({ + enabled: true, + hasCredentials: false, + settings: { + clientId: "cached-1", + redirectUri: "https://old.example.com/v1/apps/regional/callback", + region: "us", + }, + }); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 201, + json: async () => ({ client_id: "minted-2" }), + } as Response), + ); + + const resolved = await resolve(true); + + expect(resolved?.values.clientId).toBe("minted-2"); + }); + + it("re-registers for a different region", async () => { + getAppConfig.mockResolvedValue({ + enabled: true, + hasCredentials: false, + settings: { + clientId: "cached-us", + redirectUri: REDIRECT_URI, + region: "us", + }, + }); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 201, + json: async () => ({ client_id: "minted-eu" }), + } as Response); + vi.stubGlobal("fetch", fetchMock); + + const resolved = await resolve(true, "eu"); + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + "https://api.eu.provider.example/oauth/register", + ); + expect(resolved?.values).toEqual({ clientId: "minted-eu", region: "eu" }); + }); + + it("never registers on the callback leg", async () => { + // The authorization code is bound to the client that started the flow — a + // fresh client here could only produce a failed exchange. + getAppConfig.mockResolvedValue(null); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + expect(await resolve(false)).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("reports a rejected registration", async () => { + getAppConfig.mockResolvedValue(null); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 400, + statusText: "Bad Request", + json: async () => ({ + error: "invalid_client_metadata", + error_description: "redirect_uris: invalid", + }), + } as Response), + ); + + await expect(resolve(true)).rejects.toThrow(/redirect_uris: invalid/); + }); +}); diff --git a/packages/api/src/apps/oauth/dynamic-registration.ts b/packages/api/src/apps/oauth/dynamic-registration.ts new file mode 100644 index 00000000..7dc92751 --- /dev/null +++ b/packages/api/src/apps/oauth/dynamic-registration.ts @@ -0,0 +1,149 @@ +/** + * OAuth 2.0 Dynamic Client Registration (RFC 7591). + * + * The MCP authorization pattern: the server advertises a `registration_endpoint` + * and `token_endpoint_auth_methods_supported: ["none"]`, so a client registers + * itself, gets a public `client_id`, and authenticates with PKCE alone. There is + * no secret for an operator to configure, which is why these apps declare + * `dynamicRegistration` instead of `configurable` (BYOC). + * + * The minted client id is cached in the project's AppConfig row. A client id is + * only valid for the redirect URIs it was registered with, so the cache is keyed + * by (region, redirectUri) and a change in either re-registers. + */ + +import { + getAppConfig, + upsertDynamicClientConfig, +} from "../../services/app-config-service"; +import { logger } from "../../lib/logger"; +import type { AppDefinition } from "../types"; +import type { ResolvedAppCredentials } from "../resolve-credentials"; + +/** RFC 7591 response fields this resolver reads. */ +interface RegistrationResponse { + client_id?: string; + error?: string; + error_description?: string; +} + +/** Pick the region for a connect: the caller's choice when the app hosts it, + * the app's default (first entry) otherwise. */ +export const resolveRegion = ( + registration: NonNullable, + requested?: string, +): string => { + const fallback = registration.regions[0]!; + if (!requested) return fallback; + return registration.regions.includes(requested) ? requested : fallback; +}; + +const registerClient = async ( + appDef: AppDefinition, + registration: NonNullable, + redirectUri: string, + region: string, + scopes: string[], +): Promise => { + const url = registration.registrationUrl(region); + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ + client_name: registration.clientName, + redirect_uris: [redirectUri], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + ...(scopes.length ? { scope: scopes.join(" ") } : {}), + }), + }); + + const body = (await res + .json() + .catch(() => ({}) as RegistrationResponse)) as RegistrationResponse; + + if (!res.ok || !body.client_id) { + throw new Error( + `${appDef.name} client registration failed (${res.status}): ${ + body.error_description ?? body.error ?? res.statusText + }`, + ); + } + + return body.client_id; +}; + +export interface ResolveDynamicClientOptions { + /** Register a new client when no reusable one is cached. False on the OAuth + * callback: the authorization code is bound to the client that started the + * flow, so minting a fresh one there could only produce a failed exchange. */ + allowRegister: boolean; +} + +/** + * Shaped like {@link ResolvedAppCredentials} so the connect routes treat both + * credential paths alike, plus `registered`: true only when this call minted a + * new client (the state-changing case the caller audits). + */ +export interface DynamicClientResolution extends ResolvedAppCredentials { + registered: boolean; +} + +/** + * The client credentials for a dynamic-registration app. `values` carries the + * `clientId` and the `region` the app definition needs to pick its regional + * endpoints. + */ +export const resolveDynamicClient = async ( + scope: { projectId: string }, + appDef: AppDefinition, + redirectUri: string, + region: string, + scopes: string[], + { allowRegister }: ResolveDynamicClientOptions, +): Promise => { + const registration = appDef.dynamicRegistration; + if (!registration) return null; + + const cached = await getAppConfig(scope, appDef.id); + if ( + cached?.enabled && + cached.settings.clientId && + cached.settings.redirectUri === redirectUri && + cached.settings.region === region + ) { + return { + values: { clientId: cached.settings.clientId, region }, + source: "app_config", + registered: false, + }; + } + + if (!allowRegister) return null; + + const clientId = await registerClient( + appDef, + registration, + redirectUri, + region, + scopes, + ); + + await upsertDynamicClientConfig(scope, appDef.id, { + clientId, + redirectUri, + region, + }); + + logger.info( + { provider: appDef.id, region, ...scope }, + "registered dynamic OAuth client", + ); + + return { + values: { clientId, region }, + source: "app_config", + registered: true, + }; +}; diff --git a/packages/api/src/apps/registry.ts b/packages/api/src/apps/registry.ts index 6387b6ce..81823329 100644 --- a/packages/api/src/apps/registry.ts +++ b/packages/api/src/apps/registry.ts @@ -38,6 +38,7 @@ import { trello } from "./trello"; import { monday } from "./monday"; import { vercel } from "./vercel"; import { jfrogArtifactory } from "./jfrog-artifactory"; +import { vanta } from "./vanta"; const staticApps: AppDefinition[] = [ gmail, @@ -78,6 +79,7 @@ const staticApps: AppDefinition[] = [ trello, vercel, jfrogArtifactory, + vanta, ]; export const getApps = (): AppDefinition[] => { diff --git a/packages/api/src/apps/types.ts b/packages/api/src/apps/types.ts index 65952c7a..d6719536 100644 --- a/packages/api/src/apps/types.ts +++ b/packages/api/src/apps/types.ts @@ -3,12 +3,17 @@ export interface OAuthBuildAuthUrlParams { redirectUri: string; scopes: string[]; state: string; + /** S256 PKCE challenge — present only for methods that set `pkce: true`. */ + codeChallenge?: string; } export interface OAuthExchangeCodeParams { appCredentials: Record; callbackParams: Record; redirectUri: string; + /** The PKCE verifier whose challenge was sent to `buildAuthUrl` — present + * only for methods that set `pkce: true`. */ + codeVerifier?: string; } export interface OAuthExchangeResult { @@ -35,6 +40,11 @@ export type ConnectionMethod = defaultScopes?: string[]; /** Human-friendly permission descriptions. Drives the permissions UI. */ permissions?: OAuthPermission[]; + /** Send a PKCE (RFC 7636) S256 challenge/verifier pair. Required by + * public clients — providers that issue no client secret. The connect + * route generates the pair, hands `codeChallenge` to `buildAuthUrl`, and + * replays `codeVerifier` into `exchangeCode`. */ + pkce?: boolean; /** Providers that return the token in a URL fragment (#token=...) instead * of a query parameter. The bridge page extracts the named param from the * fragment and resubmits it as a query parameter for the server. */ @@ -95,6 +105,28 @@ export type ConnectionMethod = type: "cloud_only"; }; +/** + * RFC 7591 dynamic client registration. For providers that mint a public OAuth + * client on demand instead of asking the operator to pre-register one — the MCP + * authorization pattern, where the server advertises a `registration_endpoint` + * and `token_endpoint_auth_methods_supported: ["none"]`, so there is no client + * secret to configure and BYOC (`configurable`) has nothing to hold. + * + * The minted client id is cached in the project's AppConfig row, keyed by the + * region and redirect URI it was registered for; a change in either + * re-registers, since a client id is only valid for its own redirect URI. + */ +export interface DynamicClientRegistration { + /** Client name sent to the provider — shown on its consent screen. */ + clientName: string; + /** Regions the provider is hosted in; the first is the default. Chosen per + * connect via `?region=` and passed through to `buildAuthUrl` / + * `exchangeCode` as `appCredentials.region`. */ + regions: readonly string[]; + /** The region's RFC 7591 registration endpoint. */ + registrationUrl: (region: string) => string; +} + export interface OAuthConfigField { name: string; label: string; @@ -134,6 +166,10 @@ export interface AppDefinition { name: string; hostPattern: string; }[]; + /** OAuth apps whose provider hands out clients on demand (RFC 7591) instead + * of requiring configured credentials. Mutually exclusive with + * `configurable`: there is no client secret to bring. */ + dynamicRegistration?: DynamicClientRegistration; /** OAuth apps can be configured with custom credentials (BYOC). */ configurable?: { fields: OAuthConfigField[]; diff --git a/packages/api/src/apps/vanta.test.ts b/packages/api/src/apps/vanta.test.ts new file mode 100644 index 00000000..e9016139 --- /dev/null +++ b/packages/api/src/apps/vanta.test.ts @@ -0,0 +1,213 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { vanta, vantaMcpHost } from "./vanta"; + +const oauth = () => { + if (vanta.connectionMethod.type !== "oauth") { + throw new Error("vanta should connect via OAuth"); + } + return vanta.connectionMethod; +}; + +const authUrl = ( + appCredentials: Record, + codeChallenge?: string, +) => + new URL( + oauth().buildAuthUrl({ + appCredentials, + redirectUri: "https://api.example.com/v1/apps/vanta/callback", + scopes: oauth().defaultScopes ?? [], + state: "signed-state", + ...(codeChallenge ? { codeChallenge } : {}), + }), + ); + +const tokenResponse = (body: unknown, ok = true) => + ({ + ok, + status: ok ? 200 : 400, + json: async () => body, + }) as Response; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("vanta buildAuthUrl", () => { + it("authorizes against the region's host with PKCE and a resource indicator", () => { + const url = authUrl({ clientId: "client-123", region: "us" }, "challenge"); + + expect(url.origin + url.pathname).toBe( + "https://app.vanta.com/oauth/authorize", + ); + expect(Object.fromEntries(url.searchParams)).toMatchObject({ + client_id: "client-123", + response_type: "code", + redirect_uri: "https://api.example.com/v1/apps/vanta/callback", + scope: "mcp-api.all:read mcp-api.all:write", + state: "signed-state", + code_challenge: "challenge", + code_challenge_method: "S256", + resource: "https://mcp.vanta.com", + }); + }); + + it("uses the regional hosts for eu and aus", () => { + expect(authUrl({ clientId: "c", region: "eu" }, "challenge").host).toBe( + "app.eu.vanta.com", + ); + expect( + authUrl({ clientId: "c", region: "aus" }, "challenge").searchParams.get( + "resource", + ), + ).toBe("https://mcp.aus.vanta.com"); + }); + + it("refuses to build a URL without a PKCE challenge", () => { + // Vanta issues no client secret, so PKCE is the only proof of the flow. + expect(() => authUrl({ clientId: "c", region: "us" })).toThrow(/PKCE/); + }); + + it("refuses to build a URL before a client is registered", () => { + expect(() => authUrl({ region: "us" }, "challenge")).toThrow( + /not registered/, + ); + }); +}); + +describe("vanta exchangeCode", () => { + const exchange = ( + overrides: { + appCredentials?: Record; + callbackParams?: Record; + codeVerifier?: string; + } = {}, + ) => + oauth().exchangeCode({ + appCredentials: { clientId: "client-123", region: "us" }, + callbackParams: { code: "auth-code" }, + redirectUri: "https://api.example.com/v1/apps/vanta/callback", + codeVerifier: "verifier", + ...overrides, + }); + + it("exchanges the code and stores what the gateway needs to refresh", async () => { + const fetchMock = vi.fn().mockResolvedValue( + tokenResponse({ + access_token: "at-1", + refresh_token: "rt-1", + expires_in: 3600, + token_type: "Bearer", + scope: "mcp-api.all:read mcp-api.all:write", + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await exchange(); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.vanta.com/oauth/token"); + const body = new URLSearchParams(init.body as string); + expect(Object.fromEntries(body)).toEqual({ + grant_type: "authorization_code", + code: "auth-code", + redirect_uri: "https://api.example.com/v1/apps/vanta/callback", + client_id: "client-123", + code_verifier: "verifier", + resource: "https://mcp.vanta.com", + }); + + expect(result.credentials).toMatchObject({ + access_token: "at-1", + refresh_token: "rt-1", + client_id: "client-123", + token_url: "https://api.vanta.com/oauth/token", + mcp_host: "mcp.vanta.com", + region: "us", + }); + expect(result.credentials.expires_at).toBeGreaterThan( + Math.floor(Date.now() / 1000), + ); + // No `type`: the gateway's refresh_token path handles this credential, not + // one of the special-cased credential types. + expect(result.credentials.type).toBeUndefined(); + expect(result.scopes).toEqual(["mcp-api.all:read", "mcp-api.all:write"]); + expect(result.metadata).toMatchObject({ region: "us", name: "Vanta (US)" }); + }); + + it("binds credentials to the region's token endpoint and MCP host", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + tokenResponse({ access_token: "at", refresh_token: "rt" }), + ), + ); + + const result = await exchange({ + appCredentials: { clientId: "c", region: "eu" }, + }); + + expect(result.credentials).toMatchObject({ + token_url: "https://api.eu.vanta.com/oauth/token", + mcp_host: "mcp.eu.vanta.com", + }); + }); + + it("surfaces a denied consent screen", async () => { + await expect( + exchange({ + callbackParams: { + error: "access_denied", + error_description: "User denied", + }, + }), + ).rejects.toThrow(/access_denied/); + }); + + it("requires the PKCE verifier from the authorize leg", async () => { + await expect(exchange({ codeVerifier: undefined })).rejects.toThrow( + /PKCE verifier/, + ); + }); + + it("reports a token endpoint failure", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + tokenResponse( + { error: "invalid_grant", error_description: "code expired" }, + false, + ), + ), + ); + + await expect(exchange()).rejects.toThrow(/code expired/); + }); +}); + +describe("vantaMcpHost", () => { + it("maps regions to MCP hosts", () => { + expect(vantaMcpHost("us")).toBe("mcp.vanta.com"); + expect(vantaMcpHost("eu")).toBe("mcp.eu.vanta.com"); + expect(vantaMcpHost("aus")).toBe("mcp.aus.vanta.com"); + }); +}); + +describe("vanta app definition", () => { + it("registers its OAuth client dynamically instead of asking for BYOC", () => { + // Vanta's authorization server advertises + // `token_endpoint_auth_methods_supported: ["none"]` — there is no secret to + // configure, so `configurable` must stay unset or the connect UI would gate + // on credentials that can never be supplied. + expect(vanta.configurable).toBeUndefined(); + expect(vanta.dynamicRegistration?.regions).toEqual(["us", "eu", "aus"]); + expect(vanta.dynamicRegistration?.registrationUrl("eu")).toBe( + "https://api.eu.vanta.com/oauth/register", + ); + expect(oauth().pkce).toBe(true); + }); +}); diff --git a/packages/api/src/apps/vanta.ts b/packages/api/src/apps/vanta.ts new file mode 100644 index 00000000..cb55744e --- /dev/null +++ b/packages/api/src/apps/vanta.ts @@ -0,0 +1,205 @@ +import type { AppDefinition, OAuthExchangeResult } from "./types"; + +/** + * Vanta's MCP server (https://developer.vanta.com/docs/vanta-mcp). + * + * Authorization follows the MCP pattern rather than Vanta's REST API pattern: + * its protected-resource metadata advertises only `authorization_code` + + * `refresh_token` with `token_endpoint_auth_methods_supported: ["none"]`, so + * there is no client-credentials shortcut and no client secret — a public client + * registered on demand (RFC 7591) authenticating with PKCE. The MCP scopes + * (`mcp-api.*`) are also a different namespace from the REST API's + * (`vanta-api.*`), so this connection grants the MCP server and nothing else. + * + * Connecting requires a Vanta Admin — non-admins cannot authorize MCP access. + */ + +/** Vanta hosts each region separately; `us` is the default deployment. */ +const REGIONS = ["us", "eu", "aus"] as const; + +/** Regional hosts are `..vanta.com`, with US unprefixed. */ +const regionSuffix = (region: string): string => + region === "us" ? "" : `.${region}`; + +const authorizeUrlFor = (region: string): string => + `https://app${regionSuffix(region)}.vanta.com/oauth/authorize`; + +const tokenUrlFor = (region: string): string => + `https://api${regionSuffix(region)}.vanta.com/oauth/token`; + +const registrationUrlFor = (region: string): string => + `https://api${regionSuffix(region)}.vanta.com/oauth/register`; + +/** The host the gateway injects into — also the OAuth resource indicator. */ +export const vantaMcpHost = (region: string): string => + `mcp${regionSuffix(region)}.vanta.com`; + +const SCOPES = ["mcp-api.all:read", "mcp-api.all:write"]; + +interface TokenResponse { + access_token?: string; + refresh_token?: string; + token_type?: string; + expires_in?: number; + scope?: string; + error?: string; + error_description?: string; +} + +const exchangeCode = async ({ + appCredentials, + callbackParams, + redirectUri, + codeVerifier, +}: { + appCredentials: Record; + callbackParams: Record; + redirectUri: string; + codeVerifier?: string; +}): Promise => { + if (callbackParams.error) { + throw new Error( + `Vanta authorization error: ${callbackParams.error} — ${ + callbackParams.error_description ?? "no description" + }`, + ); + } + if (!callbackParams.code) { + throw new Error("Vanta callback missing authorization code"); + } + if (!appCredentials.clientId) { + throw new Error("Vanta OAuth client not registered"); + } + if (!codeVerifier) { + throw new Error("Vanta token exchange requires a PKCE verifier"); + } + + const region = appCredentials.region || REGIONS[0]; + const tokenUrl = tokenUrlFor(region); + const mcpHost = vantaMcpHost(region); + + const tokenRes = await fetch(tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: callbackParams.code, + redirect_uri: redirectUri, + client_id: appCredentials.clientId, + code_verifier: codeVerifier, + // RFC 8707 resource indicator — the MCP spec requires clients to name the + // resource server the token is for. + resource: `https://${mcpHost}`, + }), + }); + + const tokenData = (await tokenRes + .json() + .catch(() => ({}) as TokenResponse)) as TokenResponse; + + if (!tokenRes.ok || tokenData.error || !tokenData.access_token) { + throw new Error( + tokenData.error_description ?? + tokenData.error ?? + `Vanta token exchange failed (${tokenRes.status})`, + ); + } + + return { + credentials: { + access_token: tokenData.access_token, + token_type: tokenData.token_type ?? "Bearer", + expires_at: + Math.floor(Date.now() / 1000) + (tokenData.expires_in ?? 3600), + // The gateway refreshes autonomously: it needs the refresh token, the + // public client id it was issued to (there is no secret and no env + // fallback), and the region's token endpoint. + ...(tokenData.refresh_token + ? { refresh_token: tokenData.refresh_token } + : {}), + client_id: appCredentials.clientId, + token_url: tokenUrl, + // Gated injection: the token is only ever injected into the region it was + // minted for, never a sibling region's MCP host. + mcp_host: mcpHost, + region, + }, + scopes: tokenData.scope?.split(/\s+/).filter(Boolean) ?? SCOPES, + metadata: { + region, + mcpHost, + // Vanta's MCP scopes carry no identity endpoint, so the region stands in + // as the connection label; users can rename it. + name: `Vanta (${region.toUpperCase()})`, + }, + }; +}; + +export const vanta: AppDefinition = { + id: "vanta", + name: "Vanta", + icon: "/icons/vanta.png", + description: + "Compliance automation — remediate failing tests, manage controls, and review vendor risk through Vanta's MCP server.", + connectionMethod: { + type: "oauth", + defaultScopes: SCOPES, + pkce: true, + permissions: [ + { + scope: "mcp-api.all:read", + name: "Compliance data", + description: + "Tests, controls, policies, vendors, personnel, and framework status", + access: "read", + }, + { + scope: "mcp-api.all:write", + name: "Compliance data", + description: + "Remediate tests, update controls, and act on compliance findings", + access: "write", + }, + ], + buildAuthUrl: ({ + appCredentials, + redirectUri, + scopes, + state, + codeChallenge, + }) => { + if (!appCredentials.clientId) { + throw new Error("Vanta OAuth client not registered"); + } + if (!codeChallenge) { + throw new Error("Vanta authorization requires a PKCE challenge"); + } + + const region = appCredentials.region || REGIONS[0]; + const url = new URL(authorizeUrlFor(region)); + url.searchParams.set("client_id", appCredentials.clientId); + url.searchParams.set("response_type", "code"); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set( + "scope", + (scopes.length ? scopes : SCOPES).join(" "), + ); + url.searchParams.set("state", state); + url.searchParams.set("code_challenge", codeChallenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("resource", `https://${vantaMcpHost(region)}`); + return url.toString(); + }, + exchangeCode, + }, + labelHint: 'e.g. "vanta-prod"', + available: true, + dynamicRegistration: { + clientName: "OneCLI", + regions: REGIONS, + registrationUrl: registrationUrlFor, + }, +}; diff --git a/packages/api/src/lib/pkce.ts b/packages/api/src/lib/pkce.ts new file mode 100644 index 00000000..d9b03331 --- /dev/null +++ b/packages/api/src/lib/pkce.ts @@ -0,0 +1,22 @@ +import { createHash, randomBytes } from "crypto"; + +/** + * PKCE (RFC 7636) verifier/challenge pair for OAuth authorization-code flows. + * + * Required by public clients — providers that issue no client secret (e.g. the + * MCP authorization pattern: a dynamically registered client with + * `token_endpoint_auth_method: "none"`). The challenge travels in the + * authorization URL; the verifier stays server-side and is replayed at the + * token exchange, which is what binds the code to this exact flow. + */ +export interface PkcePair { + verifier: string; + challenge: string; +} + +/** 32 random bytes → 43 base64url chars, the RFC 7636 minimum length. */ +export const createPkcePair = (): PkcePair => { + const verifier = randomBytes(32).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +}; diff --git a/packages/api/src/routes/apps.ts b/packages/api/src/routes/apps.ts index e962ac6e..78e3cfbc 100644 --- a/packages/api/src/routes/apps.ts +++ b/packages/api/src/routes/apps.ts @@ -10,7 +10,15 @@ import { getAppPermissionDefinitions, toAppPermissionDefinitionSummary, } from "../apps/app-permissions"; -import { resolveAppCredentials } from "../apps/resolve-credentials"; +import { + resolveAppCredentials, + type ResolvedAppCredentials, +} from "../apps/resolve-credentials"; +import { + resolveDynamicClient, + resolveRegion, + type DynamicClientResolution, +} from "../apps/oauth/dynamic-registration"; import { resolveConnectCredentials, type ConnectRequestBody, @@ -21,6 +29,7 @@ import { verifyOAuthState, generateNonce, } from "../lib/oauth-state"; +import { createPkcePair } from "../lib/pkce"; import { NODE_ENV } from "../lib/env"; import { dashboardUrl } from "../lib/dashboard-url"; import { getRequestOrigin, getAppOrigin } from "../lib/request-origin"; @@ -53,6 +62,7 @@ import { import { parseConfigBody } from "../validations/app-config"; import { withAudit, + recordAuditEvent, AUDIT_ACTIONS, AUDIT_SERVICES, AUDIT_SOURCE, @@ -410,6 +420,16 @@ export const appRoutes = () => { const rawAgentName = c.req.query("agent_name"); const agentName = rawAgentName ? rawAgentName.slice(0, 128) : undefined; + // Providers hosted per region (all of them dynamic-registration apps) + // resolve their authorize/token/registration endpoints from this choice. + // It rides the signed state so the callback exchanges the code against the + // same region that issued it. Non-regional apps leave it undefined and + // resolve credentials exactly as before. + const registration = appDef.dynamicRegistration; + const region = registration + ? resolveRegion(registration, c.req.query("region")) + : undefined; + // Decide where the browser goes *after* consent here, at the authenticated // end, and sign it: the callback is unauthenticated, so re-deriving it // there from request headers lets the caller influence the destination. @@ -420,13 +440,36 @@ export const appRoutes = () => { origin: getAppOrigin(c.req.raw), ...(connectionId ? { connectionId } : {}), ...(agentName ? { agentName } : {}), + ...(region ? { region } : {}), }); - const resolved = await resolveAppCredentials( - projectId, - appDef, - auth.organizationId, - ); + const redirectUri = `${getRequestOrigin(c.req.raw)}/v1/apps/${provider}/callback`; + const scopes = appDef.connectionMethod.defaultScopes ?? []; + + let resolved: ResolvedAppCredentials | DynamicClientResolution | null; + try { + resolved = region + ? await resolveDynamicClient( + { projectId }, + appDef, + redirectUri, + region, + scopes, + { allowRegister: true }, + ) + : await resolveAppCredentials(projectId, appDef, auth.organizationId); + } catch (err) { + logger.warn( + { err, provider, region }, + "dynamic client registration failed", + ); + return c.json( + { + error: `Could not register an OAuth client with ${appDef.name}. Please try again.`, + }, + 502, + ); + } if (!resolved) { return c.json( { @@ -436,25 +479,46 @@ export const appRoutes = () => { ); } + // Minting a client writes the project's AppConfig row — audit it like any + // other config write. Reused clients change nothing, so they log nothing. + if ("registered" in resolved && resolved.registered) { + await recordAuditEvent({ + projectId, + userId: auth.userId, + userEmail: auth.userEmail, + action: AUDIT_ACTIONS.CREATE, + service: AUDIT_SERVICES.APP_CONFIG, + source: AUDIT_SOURCE.API, + metadata: { provider, region, dynamicRegistration: true }, + }); + } + const { values: creds } = resolved; - const redirectUri = `${getRequestOrigin(c.req.raw)}/v1/apps/${provider}/callback`; - const scopes = appDef.connectionMethod.defaultScopes ?? []; + // Public clients (no secret) prove ownership of the flow with PKCE. The + // verifier never leaves this server: it goes into an httpOnly cookie + // scoped to the callback path, the same channel the state cookie uses. + const pkce = appDef.connectionMethod.pkce ? createPkcePair() : null; const authUrl = appDef.connectionMethod.buildAuthUrl({ appCredentials: creds, redirectUri, scopes, state, + ...(pkce ? { codeChallenge: pkce.challenge } : {}), }); - setCookie(c, "oauth_state", state, { + const callbackCookieOpts = { httpOnly: true, secure: NODE_ENV === "production", - sameSite: "Lax", + sameSite: "Lax" as const, path: `/v1/apps/${provider}/callback`, maxAge: 600, - }); + }; + setCookie(c, "oauth_state", state, callbackCookieOpts); + if (pkce) { + setCookie(c, "oauth_pkce", pkce.verifier, callbackCookieOpts); + } return c.redirect(authUrl); }, @@ -571,25 +635,44 @@ export const appRoutes = () => { } } - const resolved = await resolveAppCredentials( - state.projectId, - appDef, - stateOrgId, - ); + const redirectUri = `${apiOrigin}/v1/apps/${provider}/callback`; + + // The region committed to at /authorize — never a value re-derived here, + // where the request is unauthenticated. `allowRegister: false`: the code + // is bound to the client that started the flow, so a client minted now + // could only produce a failed exchange. + const stateRegion = + typeof state.region === "string" ? state.region : undefined; + const resolved = stateRegion + ? await resolveDynamicClient( + { projectId: state.projectId }, + appDef, + redirectUri, + stateRegion, + appDef.connectionMethod.defaultScopes ?? [], + { allowRegister: false }, + ) + : await resolveAppCredentials(state.projectId, appDef, stateOrgId); if (!resolved) { return errorRedirect(`${appDef.name} is not configured`); } - const redirectUri = `${apiOrigin}/v1/apps/${provider}/callback`; - // Extract all query params as callback params const url = new URL(c.req.url); const callbackParams = Object.fromEntries(url.searchParams.entries()); + const codeVerifier = getCookie(c, "oauth_pkce"); + if (appDef.connectionMethod.pkce && !codeVerifier) { + return errorRedirect( + "Authorization expired before it completed. Please connect again.", + ); + } + const result = await appDef.connectionMethod.exchangeCode({ appCredentials: resolved.values, callbackParams, redirectUri, + ...(codeVerifier ? { codeVerifier } : {}), }); const { credentials, scopes, metadata } = result; @@ -668,6 +751,9 @@ export const appRoutes = () => { deleteCookie(c, "oauth_state", { path: `/v1/apps/${provider}/callback`, }); + deleteCookie(c, "oauth_pkce", { + path: `/v1/apps/${provider}/callback`, + }); return c.redirect( `${appOrigin}/app-connect/${provider}?${successParams}`, diff --git a/packages/api/src/services/app-config-service.ts b/packages/api/src/services/app-config-service.ts index 7cda2e5b..0b332f87 100644 --- a/packages/api/src/services/app-config-service.ts +++ b/packages/api/src/services/app-config-service.ts @@ -244,6 +244,38 @@ export const saveAppConfigWithoutDisconnect = async ( }); }; +/** + * Persist an OAuth client obtained by dynamic registration (RFC 7591). + * + * Public clients have no secret, so everything lives in `settings`: the client + * id plus the region and redirect URI it was registered for, which is what + * {@link resolveDynamicClient} compares against before reusing it. + * + * Unlike {@link upsertAppConfig} this never disconnects existing connections — + * each connection stores the client id it was minted with, so re-registering + * (new deployment origin, other region) leaves working connections alone. + */ +export const upsertDynamicClientConfig = async ( + scope: ResourceScope, + provider: string, + settings: { clientId: string; redirectUri: string; region: string }, +) => + db.appConfig.upsert({ + where: appConfigKey(scope, provider), + create: { + ...scopeCreate(scope), + provider, + enabled: true, + settings: { ...settings } as Prisma.InputJsonValue, + credentials: null, + }, + update: { + enabled: true, + settings: { ...settings } as Prisma.InputJsonValue, + }, + select: { id: true, provider: true }, + }); + export const deleteAppConfig = async ( scope: ResourceScope, provider: string,