diff --git a/doc/plans/2026-10-21-tool-access-boundaries.md b/doc/plans/2026-10-21-tool-access-boundaries.md index 1c9aa0c2eea..0d36bc30c6d 100644 --- a/doc/plans/2026-10-21-tool-access-boundaries.md +++ b/doc/plans/2026-10-21-tool-access-boundaries.md @@ -61,3 +61,50 @@ coverage pass before and after; the combined post-move run passes 506 tests. Server TypeScript checking passes. The first typecheck exposed that the public ToolConnection DTO allows absent config; the module now declares the required validated settings shape directly instead of adding internal fallback checks. + +## Third extraction + +OAuth protocol handling now has explicit owners for endpoint policy, metadata +interpretation and discovery, client registration/token exchange, provider error +normalization, and the public client metadata document. Each owner accepts +settings or named request capabilities. None imports the database or the parent +service. Connection URL normalization stays with connection-method settings. + +Registration returns validated client material before the coordinator stores it. +Token exchange returns normalized token fields without the unused raw provider +payload. The coordinator retains registration single-flight state, refresh +leases, current membership and grant checks, secret writes, audit records, +transaction ownership, and callback state validation. Endpoint policy reads the +existing live private-endpoint setting through a callback. It does not snapshot +options or create another cache. + +Thirty-seven moved function bodies match after syntax normalization. Registration +request/validation and persistence statements match their original halves. +Preflight takes explicit defaults and a clock callback. Remaining coordinator +function bodies match except for these deliberate delegation points. + +The original service becomes 19,293 lines / 749,158 bytes, a reduction of 2,232 +lines across the stack. The seven OAuth modules total 1,697 lines; the largest +has 582 lines. Connection-methods grows to 368 lines with URL normalization. + +A new route integration fixture uses a real local HTTP OAuth/MCP server and a +disposable PostgreSQL database. It covers public and Basic-authenticated clients, +discovery, registration, PKCE token exchange, callback completion, secret storage, +catalog review, and connection activation. Both cases passed before and after +the move. The combined post-move protocol/service run passed 508 tests. +The first fixture run asserted a nonexistent catalog enabled field; the assertion +now checks persisted active status and reviewer identity. An initial typecheck +exposed optional method defaults across the extraction boundary; the coordinator +passes the already-validated defaults explicitly. No runtime fallback was added. +Repository-wide typechecking and module-boundary checks pass. The PR records +the full test and build results for the batch handoff. + +## Next ownership cuts + +The service still coordinates grant lifecycle, profile resolution, token +brokering, and app setup. Profile resolution/review is the next candidate because +it can own scoped queries and projection without importing OAuth protocol code. +Token brokering follows after its authorization, issuance audit, and locking +contract is mapped. The large setup and callback functions should shrink through +those ownership cuts rather than by distributing their local variables into a +shared service context. diff --git a/server/src/__tests__/tool-access-oauth-protocol.integration.test.ts b/server/src/__tests__/tool-access-oauth-protocol.integration.test.ts new file mode 100644 index 00000000000..d6658e70be4 --- /dev/null +++ b/server/src/__tests__/tool-access-oauth-protocol.integration.test.ts @@ -0,0 +1,147 @@ +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import express from "express"; +import request from "supertest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { companies, companyMemberships, createDb, toolCatalogEntries, toolConnections, toolOauthStates } from "@paperclipai/db"; +import { eq } from "drizzle-orm"; +import { toolAccessRoutes } from "../routes/tool-access.js"; +import { errorHandler } from "../middleware/index.js"; +import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; + +describe("tool OAuth over local HTTP", () => { + let fixture: Awaited>; + let db: ReturnType; + let keyDir: string; + + beforeAll(async () => { + keyDir = await mkdtemp(join(tmpdir(), "tool-oauth-protocol-")); + vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY_FILE", join(keyDir, "key")); + vi.stubEnv("PAPERCLIP_PUBLIC_URL", "http://127.0.0.1:3100"); + vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", ""); + vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", ""); + fixture = await startEmbeddedPostgresTestDatabase("tool-oauth-protocol-"); + db = createDb(fixture.connectionString); + }); + + afterAll(async () => { + await fixture?.cleanup(); + vi.unstubAllEnvs(); + if (keyDir) await rm(keyDir, { recursive: true, force: true }); + }); + + it.each(["none", "client_secret_basic"] as const)("registers, exchanges, and activates a %s client through the routes", async (authMethod) => { + const userId = `oauth-user-${randomUUID()}`; + const [company] = await db.insert(companies).values({ + name: "OAuth protocol fixture", issuePrefix: `O${randomUUID().slice(0, 5)}`, + }).returning(); + await db.insert(companyMemberships).values({ + companyId: company.id, principalType: "user", principalId: userId, + status: "active", membershipRole: "admin", + }); + const calls: Array<{ path: string; authorization?: string; body: string }> = []; + let origin: string; + const provider = createServer(async (req, res) => { + let body = ""; + for await (const chunk of req) body += chunk; + const path = new URL(req.url!, origin).pathname; + calls.push({ path, authorization: req.headers.authorization, body }); + const json = (value: unknown, status = 200) => { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(value)); + }; + if (path.startsWith("/.well-known/oauth-protected-resource")) { + return json({ resource: `${origin}/mcp`, authorization_servers: [origin] }); + } + if (path === "/.well-known/oauth-authorization-server") { + return json({ + issuer: origin, authorization_endpoint: `${origin}/authorize`, + token_endpoint: `${origin}/token`, registration_endpoint: `${origin}/register`, + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: [authMethod], + scopes_supported: ["tools.read", "offline_access"], + grant_types_supported: ["authorization_code", "refresh_token"], + }); + } + if (path === "/register") { + return json({ ...JSON.parse(body), client_id: "fixture-client", + ...(authMethod === "client_secret_basic" ? { client_secret: "fixture-secret" } : {}), + }); + } + if (path === "/token") { + return json({ access_token: "fixture-access", refresh_token: "fixture-refresh", + expires_in: 3600, token_type: "Bearer", scope: "tools.read offline_access" }); + } + if (path === "/mcp") { + if (req.headers.authorization !== "Bearer fixture-access") { + res.setHeader("www-authenticate", `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp"`); + return json({}, 401); + } + const rpc = JSON.parse(body); + if (rpc.method === "notifications/initialized") { + res.writeHead(202).end(); + return; + } + return json({ jsonrpc: "2.0", id: rpc.id, result: rpc.method === "initialize" + ? { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "fixture", version: "1" } } + : { tools: [{ name: "read_fixture", description: "Read fixture state", inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: true } }] }, + }); + } + json({}, 404); + }); + await new Promise((resolve) => provider.listen(0, "127.0.0.1", resolve)); + origin = `http://127.0.0.1:${(provider.address() as AddressInfo).port}`; + try { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = { type: "board", userId, source: "local_implicit", isInstanceAdmin: true }; + next(); + }); + app.use("/api", toolAccessRoutes(db, { deploymentMode: "local_trusted", deploymentExposure: "private", paperclipCloudConnector: null })); + app.use(errorHandler); + + const connected = await request(app).post(`/api/companies/${company.id}/tools/apps/connect`) + .send({ link: `${origin}/mcp`, name: "Local OAuth" }).expect(201); + const authorization = new URL(connected.body.auth.startUrl); + expect(authorization.origin).toBe(origin); + expect(authorization.searchParams.get("client_id")).toBe("fixture-client"); + expect(authorization.searchParams.get("code_challenge_method")).toBe("S256"); + expect(authorization.searchParams.get("resource")).toBe(`${origin}/mcp`); + const state = authorization.searchParams.get("state")!; + const [pending] = await db.select().from(toolOauthStates).where(eq(toolOauthStates.state, state)); + expect(pending.companyId).toBe(company.id); + + const completed = await request(app).get("/api/tools/oauth/callback") + .query({ state, code: "fixture-code", iss: origin }).expect(200); + expect(completed.body.catalog).toEqual([expect.objectContaining({ toolName: "read_fixture", riskLevel: "read" })]); + await request(app).post(`/api/companies/${company.id}/tools/apps/${connected.body.connectionId}/finish`) + .send({ enabledCatalogEntryIds: completed.body.catalog.map((entry: { id: string }) => entry.id), askFirstCatalogEntryIds: [], access: "all_agents" }).expect(200); + + const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.body.connectionId)); + expect(connection).toMatchObject({ companyId: company.id, status: "active", enabled: true, ownership: "dcr" }); + expect(connection.config.oauth).toMatchObject({ clientId: "fixture-client", clientTokenEndpointAuthMethod: authMethod, clientIssuer: origin }); + expect(JSON.stringify(connection.config)).not.toMatch(/fixture-(access|refresh|secret)/); + expect(connection.credentialSecretRefs.map((ref) => ref.configPath)).toEqual(expect.arrayContaining(["oauth.access_token", "oauth.refresh_token"])); + expect(await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, connection.id))) + .toEqual([expect.objectContaining({ toolName: "read_fixture", status: "active", reviewedByUserId: userId })]); + const tokenCall = calls.find((call) => call.path === "/token")!; + const tokenBody = new URLSearchParams(tokenCall.body); + expect(tokenBody.get("code")).toBe("fixture-code"); + expect(tokenBody.get("code_verifier")).toBe(pending.codeVerifier); + expect(tokenBody.get("resource")).toBe(`${origin}/mcp`); + expect(tokenCall.authorization).toBe(authMethod === "client_secret_basic" + ? `Basic ${Buffer.from("fixture-client:fixture-secret").toString("base64")}` : undefined); + expect(tokenBody.has("client_secret")).toBe(false); + expect(calls.some((call) => call.path === "/mcp" && call.authorization === "Bearer fixture-access")).toBe(true); + expect(calls.filter((call) => call.path === "/register")).toHaveLength(1); + } finally { + provider.closeAllConnections(); + await new Promise((resolve, reject) => provider.close((error) => error ? reject(error) : resolve())); + } + }); +}); diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 3fdbc226e3f..ed24bf0268e 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -1,4 +1,13 @@ +import { createOAuthEndpointPolicy } from "./tool-access/oauth/endpoint-policy.js"; +import { createOAuthDiscovery } from "./tool-access/oauth/discovery.js"; +import { createOAuthClientProtocol, storedOAuthTokenEndpointAuthMethod } from "./tool-access/oauth/client.js"; +import { normalizeOAuthProviderError, oauthProviderErrorMessage } from "./tool-access/oauth/provider-errors.js"; +import { oauthConfig, normalizeOauthScopes, resolveGrantedOauthScopes, sameOAuthIssuer, originOf, isSmokeLabOAuthFixture } from "./tool-access/oauth/metadata.js"; +import { resolveOAuthClientIdMetadataDocumentUrl } from "./tool-access/oauth/client-metadata.js"; +import type { OAuthProviderEndpoints, OAuthClientRegistrationSource } from "./tool-access/oauth/types.js"; +export { OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH, resolveOAuthClientIdMetadataDocumentUrl, oauthClientIdMetadataDocument } from "./tool-access/oauth/client-metadata.js"; import { + remoteEndpoint, connectionMethodFor, connectionMethodForConnection, credentialFieldsFor, @@ -217,12 +226,6 @@ import { mcpRemoteHeaderNameFromConfigPath, mcpRemoteHeaderRejectionMessage, } from "@paperclipai/shared"; -import { - checkOAuthEndpointUrl, - oauthEndpointUrlRejectionMessage, - type OAuthEndpointKind, - type OAuthEndpointUrlRejection, -} from "@paperclipai/shared"; import { badRequest, conflict, @@ -323,348 +326,10 @@ type ActorInfo = { const ACTIVE_BROKER_RUN_STATUSES = new Set(["running"]); const REMOTE_HTTP_REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); const MAX_REMOTE_HTTP_REDIRECTS = 5; -const MAX_OAUTH_DCR_CLIENT_ID_LENGTH = 4_096; -const MAX_OAUTH_DCR_CLIENT_SECRET_LENGTH = 16_384; const OAUTH_REFRESH_LEASE_MS = 120_000; const OAUTH_REFRESH_LEASE_WAIT_MS = 30_000; const OAUTH_REFRESH_LEASE_POLL_MS = 25; -/** - * Upstream OAuth error redaction (PAP-17108). - * - * A generic remote MCP connection points at an arbitrary authorization server, - * so everything that server says about a failure is attacker-chosen: `error`, - * `error_description`, `error_uri`, and the response body. Paperclip surfaces - * connection failures to the operator through API responses, board UI copy, - * audit rows and logs, so reflecting any of that text would let a hostile - * provider plant secrets, ANSI escapes, or instructions ("paste your recovery - * key here") into Paperclip's own voice. - * - * The rule is therefore: the operator only ever reads text Paperclip authored. - * The provider's `error` code survives — as a *label* in structured `details`, - * never in a message — and only when it is one of the codes the RFCs define, - * because a label is still untrusted input. Everything else is dropped, and an - * unrecognized code collapses to `unrecognized` rather than being echoed. - * - * `error_description` and the response body are never read at all: no call site - * below parses them, which is what keeps a future edit from quietly - * reintroducing the reflection. - */ -const OAUTH_PROVIDER_ERROR_CODES = new Set([ - // RFC 6749 §4.1.2.1 — authorization endpoint (the callback-denial path). - "access_denied", - "invalid_request", - "invalid_scope", - "server_error", - "temporarily_unavailable", - "unauthorized_client", - "unsupported_response_type", - // RFC 6749 §5.2 — token endpoint (authorization-code and refresh exchanges). - "invalid_client", - "invalid_grant", - "unsupported_grant_type", - // RFC 7591 §3.2.2 — dynamic client registration. - "invalid_client_metadata", - "invalid_redirect_uri", - "invalid_software_statement", - "unapproved_software_statement", - // OpenID Connect Core §3.1.2.6 — interactive re-authentication prompts. - "account_selection_required", - "consent_required", - "interaction_required", - "login_required", -]); - -/** What an `error` that is absent, malformed, or off the allowlist becomes. */ -const UNRECOGNIZED_OAUTH_PROVIDER_ERROR = "unrecognized"; -const MAX_OAUTH_PROVIDER_ERROR_LENGTH = 64; -const OAUTH_PROVIDER_ERROR_PATTERN = /^[a-z0-9_-]+$/; - -/** - * Stable, Paperclip-authored operator copy for each allowlisted provider error. - * Deliberately keyed on the code alone: the calling context is already carried - * by the Paperclip `code` in `details`, so one table serves the callback, - * token-exchange and registration paths without any of them composing a message - * out of provider text. - */ -const OAUTH_PROVIDER_ERROR_MESSAGES: Record = { - access_denied: "The authorization server denied the request.", - account_selection_required: - "The authorization server needs an account to be selected. Try connecting again.", - consent_required: - "The authorization server needs consent to be granted. Try connecting again.", - interaction_required: - "The authorization server needs to be signed in to interactively. Try connecting again.", - invalid_client: "The authorization server rejected Paperclip's OAuth client.", - invalid_client_metadata: - "The authorization server rejected Paperclip's client registration details.", - invalid_grant: - "The authorization server rejected the authorization code or refresh token.", - invalid_redirect_uri: - "The authorization server rejected Paperclip's callback URL.", - invalid_request: - "The authorization server rejected the request as malformed.", - invalid_scope: "The authorization server rejected the requested permissions.", - invalid_software_statement: - "The authorization server rejected Paperclip's client registration details.", - login_required: - "The authorization server needs to be signed in to. Try connecting again.", - server_error: - "The authorization server reported an internal error. Try again shortly.", - temporarily_unavailable: - "The authorization server is temporarily unavailable. Try again shortly.", - unapproved_software_statement: - "The authorization server rejected Paperclip's client registration details.", - unauthorized_client: - "The authorization server refused to authorize Paperclip's OAuth client.", - unsupported_grant_type: - "The authorization server does not support the grant Paperclip uses.", - unsupported_response_type: - "The authorization server does not support the sign-in flow Paperclip uses.", -}; - -/** - * Reduce a provider-supplied `error` to a bounded, allowlisted label safe to - * keep in structured `details`. Returns `null` only when the provider sent no - * `error` at all, so the caller can tell "silent failure" from "said something - * Paperclip does not recognize". - */ -function normalizeOAuthProviderError(value: unknown): string | null { - if (typeof value !== "string" || value.length === 0) return null; - // Bound length and character class before the allowlist even though - // membership implies both: these limits are what keeps the label safe if the - // allowlist above ever grows a pattern-matched entry. - if (value.length > MAX_OAUTH_PROVIDER_ERROR_LENGTH) - return UNRECOGNIZED_OAUTH_PROVIDER_ERROR; - if (!OAUTH_PROVIDER_ERROR_PATTERN.test(value)) - return UNRECOGNIZED_OAUTH_PROVIDER_ERROR; - return OAUTH_PROVIDER_ERROR_CODES.has(value) - ? value - : UNRECOGNIZED_OAUTH_PROVIDER_ERROR; -} - -/** Paperclip's own message for a provider failure, never the provider's. */ -function oauthProviderErrorMessage( - providerError: string | null, - fallback: string, -): string { - if (!providerError) return fallback; - return OAUTH_PROVIDER_ERROR_MESSAGES[providerError] ?? fallback; -} - -/** - * Where this deployment publishes its Client ID Metadata Document. The document's - * own URL is the `client_id` Paperclip presents, so this path is a stable part of - * the deployment's public contract with every authorization server that has seen - * it — changing it invalidates existing CIMD registrations. - */ -export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH = - "/api/tools/oauth/client-metadata"; - -/** - * Resolve the URL Paperclip would use as a CIMD client id, but only when its - * hostname is not known to resolve into a private network. - * - * An authorization server fetches this URL from outside Paperclip's network and - * will normally apply an SSRF guard. Tailscale/MagicDNS names are HTTPS but - * resolve into 100.64.0.0/10, so presenting one as a client id can only produce - * an `invalid_client` response. A local DNS failure remains inconclusive because - * split-horizon public DNS may still let the authorization server resolve it. - */ -export async function resolveOAuthClientIdMetadataDocumentUrl( - redirectUri: string, - lookup?: RemoteHttpEndpointLookup, -): Promise { - try { - const parsed = new URL(redirectUri); - if (parsed.protocol !== "https:") return null; - const hostname = parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase(); - const isLoopback = - hostname === "localhost" || - hostname.endsWith(".localhost") || - hostname === "::1" || - /^127(?:\.\d{1,3}){3}$/.test(hostname); - if (isLoopback) return null; - const metadataUrl = new URL( - OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH, - parsed.origin, - ).toString(); - try { - await assertPublicRemoteHttpEndpoint( - new URL(metadataUrl), - { allowPrivateNetwork: false, lookup }, - (message, code) => Object.assign(new Error(message), { code }), - ); - } catch (error) { - if ( - error instanceof Error && - "code" in error && - error.code === "remote_http_private_endpoint" - ) { - return null; - } - } - return metadataUrl; - } catch { - return null; - } -} - -/** - * Paperclip's client metadata for CIMD (RFC 7591 metadata, served rather than - * registered). Only the callback for this deployment appears in it, so an - * authorization server that fetches it can see exactly one legal redirect target. - */ -export function oauthClientIdMetadataDocument(input: { - clientId: string; - redirectUri: string; -}): Record { - return { - client_id: input.clientId, - client_name: `Paperclip (${new URL(input.redirectUri).host})`, - client_uri: new URL("/", input.clientId).toString(), - redirect_uris: [input.redirectUri], - grant_types: ["authorization_code", "refresh_token"], - response_types: ["code"], - token_endpoint_auth_method: "none", - application_type: "web", - }; -} - -type OAuthProviderEndpoints = { - provider: string; - scopes: string[]; - /** Refresh permission belongs to authorization-server metadata, not MCP tool scopes. */ - offlineAccessSupported?: boolean; - authorizationUrl: string; - tokenUrl: string; - registrationUrl?: string | null; - codeChallengeMethodsSupported?: string[]; - tokenEndpointAuthMethodsSupported?: string[]; - grantTypesSupported?: string[]; - grantType?: "authorization_code" | "client_credentials"; - metadataUrl?: string | null; - /** - * Canonical authorization-server issuer, when discovery found one. Registered - * client material is bound to it, `iss` on the callback is validated against - * it, and reconnect/refresh reuse it instead of re-deriving a provider key. - */ - issuer?: string | null; - /** - * RFC 8707 resource indicator: the MCP endpoint the token is for. Sent on both - * authorization and token requests so the authorization server can audience- - * restrict the access token to this server rather than to everything Paperclip - * has ever connected. - */ - resource?: string | null; - /** The authorization server advertised support for Client ID Metadata Documents. */ - clientIdMetadataDocumentSupported?: boolean; -}; - -/** - * Where an OAuth client came from, in the preference order the current MCP - * client-registration guidance recommends (PAP-17087). - */ -type OAuthClientRegistrationSource = - "preconfigured" | "cimd" | "dcr" | "manual"; - -/** - * RFC 8414 §3.1 requires the well-known path to be *inserted between* the - * issuer host and its path component, but a large amount of deployed software - * only serves the naive suffix form. OpenID Connect Discovery 1.0 in turn - * specifies the suffix form. Try all of them for an issuer that has a path, and - * the plain origin form when it doesn't. - */ -function wellKnownMetadataUrls(issuer: string): string[] { - let parsed: URL; - try { - parsed = new URL(issuer); - } catch { - return []; - } - const suffixes = ["oauth-authorization-server", "openid-configuration"]; - const path = parsed.pathname.replace(/\/+$/, ""); - const urls: string[] = []; - for (const suffix of suffixes) { - if (path) { - // RFC 8414: https://host/.well-known/ - urls.push( - new URL(`/.well-known/${suffix}${path}`, parsed.origin).toString(), - ); - // OIDC Discovery / widely deployed: https://host/.well-known/ - urls.push( - new URL(`${path}/.well-known/${suffix}`, parsed.origin).toString(), - ); - } - urls.push(new URL(`/.well-known/${suffix}`, parsed.origin).toString()); - } - return [...new Set(urls)]; -} - -/** - * Protected-resource metadata lives at `/.well-known/oauth-protected-resource` - * with the resource's path appended (RFC 9728 §3.1). Probe the path-aware form - * first so a multi-tenant host that serves several MCP servers resolves to the - * right one, then fall back to the origin form. - */ -function protectedResourceMetadataUrls(endpoint: URL): string[] { - const path = endpoint.pathname.replace(/\/+$/, ""); - const urls: string[] = []; - if (path) - urls.push( - new URL( - `/.well-known/oauth-protected-resource${path}`, - endpoint.origin, - ).toString(), - ); - urls.push( - new URL( - "/.well-known/oauth-protected-resource", - endpoint.origin, - ).toString(), - ); - return [...new Set(urls)]; -} - -/** - * Canonical RFC 8707 resource indicator for an MCP endpoint: origin + path, with - * query, fragment and any trailing slash removed. Vendor query parameters belong - * to the connection config, not to the token audience. - */ -function canonicalResourceIndicator(endpoint: string): string | null { - try { - const parsed = new URL(endpoint); - const path = parsed.pathname.replace(/\/+$/, ""); - return `${parsed.origin}${path}`; - } catch { - return null; - } -} - -/** - * Two issuers are the same authorization server only when scheme, host, port and - * path match exactly (a trailing slash is not significant). Used for `iss` - * validation on the callback and for detecting that a stored registration is - * bound to a different server than the one we just discovered. - */ -function sameOAuthIssuer( - a: string | null | undefined, - b: string | null | undefined, -): boolean { - if (!a || !b) return false; - try { - const left = new URL(a); - const right = new URL(b); - return ( - left.protocol === right.protocol && - left.host === right.host && - left.pathname.replace(/\/+$/, "") === right.pathname.replace(/\/+$/, "") - ); - } catch { - return false; - } -} - const oauthRegistrationFlights = new Map>(); async function singleFlight( @@ -2284,22 +1949,11 @@ function managedConnectorProfile(value: string | undefined): { return null; } - - /** * Did this error come from the OAuth endpoint gate (PAP-17099)? Such a refusal * is Paperclip's own decision about an unsafe address, so it must keep its code * and its 422 instead of being folded into a generic upstream failure. */ -function originOf(value: string | null | undefined): string | null { - if (!value) return null; - try { - return new URL(value).origin; - } catch { - return null; - } -} - function isOAuthEndpointRejection(error: unknown): boolean { if (!(error instanceof HttpError)) return false; const code = asRecord(error.details).code; @@ -2441,14 +2095,6 @@ function sanitizeHttpFailure(error: unknown): { }; } -function remoteEndpoint(config: Record): string { - const value = config.url ?? config.endpoint ?? config.remoteUrl; - const parsed = parseRemoteHttpEndpoint(value, (message, code) => - badRequest(message, { code }), - ); - return parsed.toString(); -} - function vercelConnectResourcesFor( connection: typeof toolConnections.$inferSelect, ): string[] { @@ -2547,6 +2193,13 @@ export function toolAccessService( options: ToolAccessServiceOptions = {}, ) { const secrets = secretService(db); + const { assertOAuthEndpointUrl, safeOAuthEndpointUrl } = createOAuthEndpointPolicy(allowPrivateRemoteEndpoints); + const { oauthEndpointsForConnection, discoverOAuthEndpoints, preflightConnectionMetadata } = createOAuthDiscovery({ + fetchRemoteHttpUrl, assertRemoteEndpointAllowed, assertOAuthEndpointUrl, safeOAuthEndpointUrl, + }); + const { requestOAuthClientRegistration, exchangeOAuthToken } = createOAuthClientProtocol({ + fetchRemoteHttpUrl, assertOAuthEndpointUrl, + }); const composioSyncJobs = new Map>(); const composioSyncTtlMs = 5 * 60_000; const composioSyncLeaseMs = 4 * 60_000; @@ -2962,110 +2615,6 @@ export function toolAccessService( return endpoint; } - /** - * OAuth endpoint scheme/transport gate (PAP-17099). - * - * Every OAuth endpoint Paperclip acts on is attacker-influenced: discovered - * metadata, a `WWW-Authenticate` hint, a pasted config, or a gallery default. - * The authorization endpoint is the sharpest one because it is handed to the - * operator's browser as a top-level navigation, so `javascript:`/`data:` there - * would run in the board's origin. `checkOAuthEndpointUrl` is the single place - * that decides; loopback `http:` is accepted only under the same - * local-development policy that governs private remote endpoints, and - * Paperclip's own origin is exempt from the transport rule because a - * first-party endpoint (the smoke-lab fixture) is served exactly as the board - * itself is. - */ - function oauthEndpointRejected( - kind: OAuthEndpointKind, - reason: OAuthEndpointUrlRejection, - ): HttpError { - return new HttpError(422, oauthEndpointUrlRejectionMessage(kind, reason), { - code: `oauth_${kind}_endpoint_rejected`, - reason, - }); - } - - /** - * Origins that are Paperclip itself: this deployment's configured public URL, - * plus the callback origin of the request in hand when there is one. Only the - * plaintext-transport rule is relaxed for these. - */ - function firstPartyOrigins(candidate?: string | null): string[] { - const configured = - process.env.PAPERCLIP_PUBLIC_URL?.trim() || - process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim() || - process.env.BETTER_AUTH_URL?.trim() || - process.env.BETTER_AUTH_BASE_URL?.trim() || - null; - return [originOf(candidate), originOf(configured)].filter( - (origin): origin is string => Boolean(origin), - ); - } - - /** - * Origins for which the plaintext-transport rule is relaxed when checking - * `value`. Adds the smoke-lab fixture's own origin, because that provider is - * mounted on this deployment's own routes — `assertNotSmokeLabOAuthEndpoints` - * is what stops any other connection from claiming those paths — and a smoke - * run may be driven against a deployment served over plaintext HTTP. - */ - function insecureTransportExemptions( - value: unknown, - candidate?: string | null, - ): string[] { - const origins = firstPartyOrigins(candidate); - if (typeof value === "string" && isSmokeLabOAuthUrl(value)) { - const origin = originOf(value); - if (origin) origins.push(origin); - } - return origins; - } - - /** Throws unless `value` is an endpoint Paperclip may use (and navigate to). */ - function assertOAuthEndpointUrl( - kind: OAuthEndpointKind, - value: unknown, - options: { firstPartyOrigin?: string | null } = {}, - ): string { - const check = checkOAuthEndpointUrl(value, { - allowInsecureLoopback: allowPrivateRemoteEndpoints(), - allowInsecureOrigins: insecureTransportExemptions( - value, - options.firstPartyOrigin, - ), - }); - if (!check.ok) throw oauthEndpointRejected(kind, check.reason); - return check.url; - } - - /** - * Discovery variant: an unusable endpoint is dropped rather than thrown, so a - * second advertised authorization server (or a later metadata candidate) still - * gets a chance. Rejections are recorded in `rejections`; discovery raises the - * first one only if it ends up with nothing safe to use, so the operator sees - * *why* instead of a bare "does not advertise OAuth sign in". - */ - function safeOAuthEndpointUrl( - kind: OAuthEndpointKind, - value: unknown, - rejections: HttpError[], - firstPartyOrigin?: string | null, - ): string | null { - if (value === null || value === undefined || value === "") return null; - const check = checkOAuthEndpointUrl(value, { - allowInsecureLoopback: allowPrivateRemoteEndpoints(), - allowInsecureOrigins: insecureTransportExemptions( - value, - firstPartyOrigin, - ), - }); - if (check.ok) return check.url; - if (check.reason !== "missing") - rejections.push(oauthEndpointRejected(kind, check.reason)); - return null; - } - function trustedRuntimeHost() { return ( options.trustedLocalStdioRuntimeHost ?? @@ -8329,41 +7878,6 @@ export function toolAccessService( }; } - function isSmokeLabOAuthFixture( - connection: typeof toolConnections.$inferSelect, - ) { - const config = asRecord(connection.config); - const oauth = oauthConfig(connection); - return ( - config.smokeLabFixture === "oauth-http" && oauth.smokeLabFixture === true - ); - } - - function smokeLabOAuthEndpoints( - connection: typeof toolConnections.$inferSelect, - redirectUri?: string, - ): OAuthProviderEndpoints | null { - if (!isSmokeLabOAuthFixture(connection) || !redirectUri) return null; - let origin: string; - try { - origin = new URL(redirectUri).origin; - } catch { - return null; - } - const oauthBasePath = `/api/companies/${encodeURIComponent(connection.companyId)}/smoke-lab/oauth`; - return { - provider: "smoke_lab", - scopes: normalizeOauthScopes(oauthConfig(connection).scopes), - authorizationUrl: new URL( - `${oauthBasePath}/authorize`, - origin, - ).toString(), - tokenUrl: new URL(`${oauthBasePath}/token`, origin).toString(), - metadataUrl: null, - grantType: "authorization_code", - }; - } - function configuredOAuthClientForConnection( connection: typeof toolConnections.$inferSelect, provider: string, @@ -8485,20 +7999,6 @@ export function toolAccessService( return randomBytes(bytes).toString("base64url"); } - function oauthConfig(connection: typeof toolConnections.$inferSelect) { - const oauth = asRecord(connection.config).oauth - ? asRecord(asRecord(connection.config).oauth) - : {}; - const { - access_token: _accessToken, - refresh_token: _refreshToken, - accessToken: _camelAccessToken, - refreshToken: _camelRefreshToken, - ...metadata - } = oauth; - return metadata; - } - function connectionSetupUrl(connection: typeof toolConnections.$inferSelect) { return `/apps/${connection.id}/permissions`; } @@ -8542,164 +8042,6 @@ export function toolAccessService( }; } - function normalizeOauthScopes(value: unknown): string[] { - if (Array.isArray(value)) - return value.filter( - (item): item is string => - typeof item === "string" && item.trim().length > 0, - ); - if (typeof value === "string") - return value - .split(/\s+/) - .map((item) => item.trim()) - .filter(Boolean); - return []; - } - - /** - * Decide what a token grant actually carries, keeping the provider's assertion separate from - * our own request. - * - * RFC 6749 §5.1 permits a provider to omit `scope` only when the grant is identical to the - * request, so reading the request as the grant is the specified fallback. A provider that - * over-grants *and* omits `scope` breaks that contract, and recording the request unmarked - * turns what we asked for into a confident-looking record of what we got. `scopeSource` keeps - * the two readable apart so nothing downstream can mistake an inference for an assertion. - * - * `unrequestedScopes` is only meaningful when we asked for something specific; with no - * requested scopes there is no baseline to compare against, so it stays empty rather than - * flagging every scope on connections that keep their scopes in provider-side app config. - * - * `previous` is the provenance already recorded for this grant. A refresh response that omits - * `scope` asserts nothing at all — least of all that a permission was taken away — so the - * earlier record is carried forward verbatim instead of being overwritten with a clean-looking - * inference. Only a fresh provider assertion replaces a recorded over-grant. - */ - function resolveGrantedOauthScopes(input: { - tokenScope: unknown; - requestedScopes: unknown; - previous?: { - scopes?: unknown; - scopeSource?: unknown; - unrequestedScopes?: unknown; - }; - }): { - scopes: string[]; - scopeSource: "provider" | "requested_fallback"; - unrequestedScopes: string[]; - } { - const requested = normalizeOauthScopes(input.requestedScopes); - const asserted = - input.tokenScope === undefined || input.tokenScope === null - ? [] - : normalizeOauthScopes(input.tokenScope); - if (asserted.length === 0) { - const previousScopes = normalizeOauthScopes(input.previous?.scopes); - const previousUnrequested = normalizeOauthScopes( - input.previous?.unrequestedScopes, - ); - if (previousScopes.length > 0) { - return { - scopes: previousScopes, - scopeSource: - input.previous?.scopeSource === "provider" - ? "provider" - : "requested_fallback", - unrequestedScopes: previousUnrequested, - }; - } - return { - scopes: requested, - scopeSource: "requested_fallback", - unrequestedScopes: previousUnrequested, - }; - } - return { - scopes: asserted, - scopeSource: "provider", - unrequestedScopes: - requested.length === 0 - ? [] - : asserted.filter((scope) => !requested.includes(scope)), - }; - } - - function isSmokeLabOAuthUrl(value: string | null | undefined) { - if (!value) return false; - try { - return /\/smoke-lab\/oauth(?:\/|$)/.test(new URL(value).pathname); - } catch { - return false; - } - } - - function assertNotSmokeLabOAuthEndpoints( - connection: typeof toolConnections.$inferSelect, - endpoints: OAuthProviderEndpoints, - ) { - const blockedUrl = [ - endpoints.authorizationUrl, - endpoints.tokenUrl, - endpoints.metadataUrl, - ].find(isSmokeLabOAuthUrl); - if (blockedUrl && !isSmokeLabOAuthFixture(connection)) { - throw unprocessable( - "Smoke Lab OAuth provider cannot be used for tool app sign-in", - ); - } - } - - function oauthProviderForConnection( - connection: typeof toolConnections.$inferSelect, - metadataUrl?: string | null, - ): string { - const oauth = oauthConfig(connection); - if (typeof oauth.provider === "string" && oauth.provider.trim()) - return oauth.provider.trim(); - const sourceTemplateKey = - typeof connection.config.sourceTemplateKey === "string" - ? connection.config.sourceTemplateKey.trim() - : ""; - if (sourceTemplateKey) return sourceTemplateKey; - const url = metadataUrl ?? remoteEndpoint(connection.config); - try { - return ( - new URL(url).hostname - .replace(/[^a-z0-9]+/gi, "_") - .replace(/^_+|_+$/g, "") - .toLowerCase() || "generic" - ); - } catch { - return "generic"; - } - } - - function parseWwwAuthenticateParams(value: string): Record { - const params: Record = {}; - const input = value.replace(/^\s*Bearer\s+/i, ""); - const re = /([a-zA-Z_][a-zA-Z0-9_-]*)=(?:"([^"]*)"|([^,\s]+))/g; - let match: RegExpExecArray | null; - while ((match = re.exec(input))) { - params[match[1]!.toLowerCase()] = match[2] ?? match[3] ?? ""; - } - return params; - } - - function challengeOAuthHints(wwwAuthenticate: string) { - const params = parseWwwAuthenticateParams(wwwAuthenticate); - return { - metadataUrl: - params.resource_metadata ?? - params.resource_metadata_url ?? - params.metadata_url ?? - null, - authorizationUrl: - params.authorization_uri ?? params.authorization_url ?? null, - tokenUrl: params.token_uri ?? params.token_url ?? null, - scope: params.scope ?? null, - }; - } - function oauthSecretRef( connection: typeof toolConnections.$inferSelect, configPath: @@ -8721,465 +8063,6 @@ export function toolAccessService( return Number.isFinite(ms) ? ms : null; } - async function fetchJsonRecord( - url: string, - ): Promise | null> { - try { - const response = await fetchRemoteHttpUrl(url); - if (!response.ok) return null; - return asRecord((await response.json()) as unknown) ?? null; - } catch { - return null; - } - } - - /** - * Candidate authorization-server metadata URLs advertised by protected-resource - * metadata, paired with the issuer that advertised them so the caller can bind - * the resulting client material to a canonical issuer. - */ - function authServerMetadataUrls( - metadata: Record, - ): Array<{ issuer: string; metadataUrl: string }> { - const candidates: Array<{ issuer: string; metadataUrl: string }> = []; - const issuers: string[] = []; - if (Array.isArray(metadata.authorization_servers)) { - for (const server of metadata.authorization_servers) { - if (typeof server === "string" && server.trim()) - issuers.push(server.trim()); - } - } - if (typeof metadata.issuer === "string" && metadata.issuer.trim()) - issuers.push(metadata.issuer.trim()); - for (const issuer of [...new Set(issuers)]) { - for (const metadataUrl of wellKnownMetadataUrls(issuer)) { - candidates.push({ issuer, metadataUrl }); - } - } - const seen = new Set(); - return candidates.filter((candidate) => { - if (seen.has(candidate.metadataUrl)) return false; - seen.add(candidate.metadataUrl); - return true; - }); - } - - async function endpointsFromMetadataUrl( - connection: typeof toolConnections.$inferSelect, - metadataUrl: string, - rejections: HttpError[] = [], - firstPartyOrigin?: string | null, - ): Promise { - const metadata = await fetchJsonRecord(metadataUrl); - if (!metadata) return null; - // Every endpoint below is a string the remote server chose, so none of them - // is adopted before `safeOAuthEndpointUrl` has vetted its scheme and host. - let authorizationUrl = safeOAuthEndpointUrl( - "authorization", - metadata.authorization_endpoint, - rejections, - firstPartyOrigin, - ); - let tokenUrl = safeOAuthEndpointUrl( - "token", - metadata.token_endpoint, - rejections, - firstPartyOrigin, - ); - let registrationUrl = safeOAuthEndpointUrl( - "registration", - metadata.registration_endpoint, - rejections, - firstPartyOrigin, - ); - let scopes = normalizeOauthScopes(metadata.scopes_supported); - let offlineAccessSupported = Boolean( - authorizationUrl && scopes.includes("offline_access"), - ); - let codeChallengeMethodsSupported = normalizeOauthScopes( - metadata.code_challenge_methods_supported, - ); - let tokenEndpointAuthMethodsSupported = normalizeOauthScopes( - metadata.token_endpoint_auth_methods_supported, - ); - let grantTypesSupported = normalizeOauthScopes( - metadata.grant_types_supported, - ); - let clientIdMetadataDocumentSupported = - metadata.client_id_metadata_document_supported === true; - // A document that carries the authorization endpoint itself *is* the - // authorization-server metadata, so its own `issuer` is the canonical one. - // Otherwise this was protected-resource metadata and the issuer comes from - // whichever advertised authorization server answered. - let issuer = - authorizationUrl && - typeof metadata.issuer === "string" && - metadata.issuer.trim() - ? metadata.issuer.trim() - : null; - const resource = - typeof metadata.resource === "string" && metadata.resource.trim() - ? metadata.resource.trim() - : null; - for (const candidate of authServerMetadataUrls(metadata)) { - const authMetadata = await fetchJsonRecord(candidate.metadataUrl); - if (!authMetadata) continue; - const candidateAuthorizationUrl = safeOAuthEndpointUrl( - "authorization", - authMetadata.authorization_endpoint, - rejections, - firstPartyOrigin, - ); - const candidateTokenUrl = safeOAuthEndpointUrl( - "token", - authMetadata.token_endpoint, - rejections, - firstPartyOrigin, - ); - if (!candidateAuthorizationUrl && !candidateTokenUrl) continue; - // RFC 8414 §3.3: the metadata document's `issuer` must match the issuer we - // used to build the discovery URL, or the document is not authoritative. - const advertisedIssuer = - typeof authMetadata.issuer === "string" && authMetadata.issuer.trim() - ? authMetadata.issuer.trim() - : null; - if ( - advertisedIssuer && - !sameOAuthIssuer(advertisedIssuer, candidate.issuer) - ) - continue; - // A document can carry its own endpoints and also list other servers. - // Adopt capabilities only from metadata bound to the selected endpoints - // and issuer; otherwise offline access could break an unrelated sign-in. - if ( - (authorizationUrl && candidateAuthorizationUrl !== authorizationUrl) || - (tokenUrl && candidateTokenUrl !== tokenUrl) || - (issuer && !sameOAuthIssuer(issuer, advertisedIssuer ?? candidate.issuer)) - ) - continue; - authorizationUrl = authorizationUrl ?? candidateAuthorizationUrl; - tokenUrl = tokenUrl ?? candidateTokenUrl; - registrationUrl = - registrationUrl ?? - safeOAuthEndpointUrl( - "registration", - authMetadata.registration_endpoint, - rejections, - firstPartyOrigin, - ); - issuer = issuer ?? advertisedIssuer ?? candidate.issuer; - offlineAccessSupported = offlineAccessSupported || - normalizeOauthScopes(authMetadata.scopes_supported).includes("offline_access"); - if (scopes.length === 0) - scopes = normalizeOauthScopes(authMetadata.scopes_supported); - if (codeChallengeMethodsSupported.length === 0) { - codeChallengeMethodsSupported = normalizeOauthScopes( - authMetadata.code_challenge_methods_supported, - ); - } - if (tokenEndpointAuthMethodsSupported.length === 0) { - tokenEndpointAuthMethodsSupported = normalizeOauthScopes( - authMetadata.token_endpoint_auth_methods_supported, - ); - } - if (grantTypesSupported.length === 0) { - grantTypesSupported = normalizeOauthScopes( - authMetadata.grant_types_supported, - ); - } - if (!clientIdMetadataDocumentSupported) { - clientIdMetadataDocumentSupported = - authMetadata.client_id_metadata_document_supported === true; - } - if (authorizationUrl && tokenUrl) break; - } - if (!authorizationUrl || !tokenUrl) return null; - return { - provider: oauthProviderForConnection(connection, metadataUrl), - scopes, - offlineAccessSupported, - authorizationUrl, - tokenUrl, - registrationUrl, - codeChallengeMethodsSupported, - tokenEndpointAuthMethodsSupported, - grantTypesSupported, - metadataUrl, - issuer, - resource, - clientIdMetadataDocumentSupported, - }; - } - - async function discoverOAuthEndpoints( - connection: typeof toolConnections.$inferSelect, - challenge?: string | null, - firstPartyOrigin?: string | null, - ): Promise { - const oauth = oauthConfig(connection); - const hints = challenge ? challengeOAuthHints(challenge) : null; - // A configured endpoint was pasted by an operator or persisted from an - // earlier discovery, and a hint came straight out of the endpoint's - // `WWW-Authenticate` header. Neither is more trusted than metadata, so both - // go through the same gate; an unusable one is dropped so full metadata - // discovery still runs below. - const rejections: HttpError[] = []; - const configuredAuthorizationUrl = safeOAuthEndpointUrl( - "authorization", - typeof oauth.authorizationUrl === "string" - ? oauth.authorizationUrl - : (hints?.authorizationUrl ?? null), - rejections, - firstPartyOrigin, - ); - const configuredTokenUrl = safeOAuthEndpointUrl( - "token", - typeof oauth.tokenUrl === "string" - ? oauth.tokenUrl - : (hints?.tokenUrl ?? null), - rejections, - firstPartyOrigin, - ); - const provider = oauthProviderForConnection( - connection, - typeof oauth.metadataUrl === "string" - ? oauth.metadataUrl - : hints?.metadataUrl, - ); - const scopes = - normalizeOauthScopes(oauth.scopes).length > 0 - ? normalizeOauthScopes(oauth.scopes) - : normalizeOauthScopes(oauth.scope).length > 0 - ? normalizeOauthScopes(oauth.scope) - : normalizeOauthScopes(hints?.scope); - const grantType = - oauth.grantType === "client_credentials" || - oauth.clientCredentials === true - ? ("client_credentials" as const) - : ("authorization_code" as const); - // The resource indicator is the MCP endpoint itself, independent of which - // authorization server ends up serving it. - const configuredResource = - typeof oauth.resource === "string" && oauth.resource.trim() - ? oauth.resource.trim() - : canonicalResourceIndicator(remoteEndpoint(connection.config)); - if (configuredAuthorizationUrl && configuredTokenUrl) { - // Pre-existing generic connections cached endpoints before refresh-scope - // support was recorded. Recover that capability from their bound metadata - // without adopting a different issuer or changing their requested tool scopes. - let refreshMetadata: OAuthProviderEndpoints | null = null; - if ( - !connection.config.sourceTemplateKey && - typeof oauth.offlineAccessSupported !== "boolean" && - typeof oauth.metadataUrl === "string" - ) { - const discovered = await endpointsFromMetadataUrl( - connection, oauth.metadataUrl, rejections, firstPartyOrigin, - ); - if ( - discovered?.authorizationUrl === configuredAuthorizationUrl && - discovered.tokenUrl === configuredTokenUrl && - (typeof oauth.issuer !== "string" || - (discovered.issuer && sameOAuthIssuer(discovered.issuer, oauth.issuer))) - ) refreshMetadata = discovered; - } - return { - provider, - scopes, - offlineAccessSupported: typeof oauth.offlineAccessSupported === "boolean" - ? oauth.offlineAccessSupported - : refreshMetadata?.offlineAccessSupported, - authorizationUrl: configuredAuthorizationUrl, - tokenUrl: configuredTokenUrl, - registrationUrl: safeOAuthEndpointUrl( - "registration", - oauth.registrationUrl, - rejections, - firstPartyOrigin, - ), - codeChallengeMethodsSupported: normalizeOauthScopes( - oauth.codeChallengeMethodsSupported, - ), - tokenEndpointAuthMethodsSupported: normalizeOauthScopes( - oauth.tokenEndpointAuthMethodsSupported, - ), - grantTypesSupported: Array.isArray(oauth.grantTypesSupported) - ? normalizeOauthScopes(oauth.grantTypesSupported) - : refreshMetadata?.grantTypesSupported, - grantType, - metadataUrl: - typeof oauth.metadataUrl === "string" - ? oauth.metadataUrl - : (hints?.metadataUrl ?? null), - issuer: - typeof oauth.issuer === "string" && oauth.issuer.trim() - ? oauth.issuer.trim() - : null, - resource: configuredResource, - clientIdMetadataDocumentSupported: - oauth.clientIdMetadataDocumentSupported === true, - }; - } - - const metadataCandidates = [ - typeof oauth.metadataUrl === "string" ? oauth.metadataUrl : null, - hints?.metadataUrl ?? null, - ].filter((value): value is string => Boolean(value)); - if (metadataCandidates.length === 0) { - const endpoint = new URL( - await assertRemoteEndpointAllowed(connection.config), - ); - metadataCandidates.push(...protectedResourceMetadataUrls(endpoint)); - // The MCP server may double as its own authorization server, in which case - // it serves authorization-server metadata directly at (or under) its path. - metadataCandidates.push(...wellKnownMetadataUrls(endpoint.toString())); - } - for (const metadataUrl of [...new Set(metadataCandidates)]) { - const endpoints = await endpointsFromMetadataUrl( - connection, - metadataUrl, - rejections, - firstPartyOrigin, - ); - if (endpoints) { - return { - ...endpoints, - scopes: scopes.length > 0 ? scopes : endpoints.scopes, - grantType, - resource: endpoints.resource ?? configuredResource, - }; - } - } - // Nothing usable was found *and* something was refused on the way: report the - // refusal rather than the generic "does not advertise OAuth sign in", so the - // operator learns the server offered an unsafe address. - if (rejections.length > 0) throw rejections[0]; - return null; - } - - async function oauthProviderEndpoints( - app: AppDefinition, - methodKey?: string | null, - ): Promise { - const method = connectionMethodFor(app, methodKey); - if (method.auth !== "oauth") - throw unprocessable("This app does not support sign in"); - let authorizationUrl = method.defaults?.authorizationEndpoint ?? null; - let tokenUrl = method.defaults?.tokenEndpoint ?? null; - const metadataUrl = method.defaults?.metadataUrl ?? null; - if ((!authorizationUrl || !tokenUrl) && metadataUrl) { - const response = await fetchRemoteHttpUrl(metadataUrl); - if (!response.ok) - throw new HttpError( - 502, - "OAuth provider metadata could not be loaded", - { code: "oauth_metadata_failed" }, - ); - const metadata = asRecord((await response.json()) as unknown); - authorizationUrl = - authorizationUrl ?? - (typeof metadata.authorization_endpoint === "string" - ? metadata.authorization_endpoint - : null); - tokenUrl = - tokenUrl ?? - (typeof metadata.token_endpoint === "string" - ? metadata.token_endpoint - : null); - } - if (!authorizationUrl || !tokenUrl) { - throw unprocessable( - "OAuth provider endpoints are not configured for this app", - ); - } - // A gallery default is Paperclip's own data, but it is still a URL that ends - // up as a browser navigation, and the metadata branch above reads the same - // untrusted document a generic connection does. Both go through the gate. - return { - provider: app.slug, - scopes: method.defaults?.scopesHint ?? [], - authorizationUrl: assertOAuthEndpointUrl( - "authorization", - authorizationUrl, - ), - tokenUrl: assertOAuthEndpointUrl("token", tokenUrl), - grantType: "authorization_code", - metadataUrl, - }; - } - - async function oauthEndpointsForConnection( - connection: typeof toolConnections.$inferSelect, - challenge?: string | null, - redirectUri?: string, - ): Promise { - const smokeLabEndpoints = smokeLabOAuthEndpoints(connection, redirectUri); - const sourceTemplateKey = - typeof connection.config.sourceTemplateKey === "string" - ? connection.config.sourceTemplateKey - : null; - const galleryEntry = sourceTemplateKey - ? getConnectableAppDefinition(sourceTemplateKey) - : null; - const galleryMethod = galleryEntry - ? connectionMethodForConnection(galleryEntry, connection) - : null; - // Pinned endpoints describe the provider's console-registered OAuth app. - // A method that also offers dynamic registration registers against the MCP - // server's own authorization server (Linear: mcp.linear.app, not - // linear.app), which only discovery finds. So the pins stand in for - // discovery only when no registration is possible or the connection - // already carries an operator-entered client. - const storedOAuth = oauthConfig(connection); - const usesOperatorClient = - storedOAuth.clientRegistrationSource === "manual" && - connection.ownership !== "dcr" && - typeof storedOAuth.clientId === "string" && - storedOAuth.clientId.trim().length > 0; - // A reviewed protected-resource document is authoritative for this method. - // Some hosts still serve metadata for a retired MCP server at the root. - // Resolve it before cached endpoints, including on existing draft reconnects. - if (!smokeLabEndpoints && galleryMethod?.defaults?.discoveryUrl) { - const endpoints = await endpointsFromMetadataUrl( - connection, galleryMethod.defaults.discoveryUrl, [], originOf(redirectUri), - ); - if (!endpoints) throw unprocessable("OAuth provider metadata could not be loaded"); - assertNotSmokeLabOAuthEndpoints(connection, endpoints); - return endpoints; - } - const hasCompleteGalleryEndpointHints = Boolean( - galleryMethod?.defaults?.authorizationEndpoint && - galleryMethod.defaults.tokenEndpoint && - (!galleryMethod.ownershipModes.includes("dcr") || usesOperatorClient), - ); - // The smoke-lab fixture's endpoints are first-party and complete, so - // discovery is not just unnecessary there, it must not run: an unreachable - // fixture endpoint would fail the whole callback. - const firstPartyOrigin = originOf(redirectUri); - const discovered = - !smokeLabEndpoints && - connection.transport === "mcp_remote" && - !hasCompleteGalleryEndpointHints - ? await discoverOAuthEndpoints(connection, challenge, firstPartyOrigin) - : null; - const endpoints = - smokeLabEndpoints ?? - discovered ?? - (galleryEntry && galleryMethod?.auth === "oauth" - ? await oauthProviderEndpoints(galleryEntry, galleryMethod.key) - : await discoverOAuthEndpoints( - connection, - challenge, - firstPartyOrigin, - )); - if (!endpoints) - throw unprocessable( - "This app connection does not advertise OAuth sign in", - ); - assertNotSmokeLabOAuthEndpoints(connection, endpoints); - return endpoints; - } - async function oauthGalleryEntryForConnection( connection: typeof toolConnections.$inferSelect, ) { @@ -9300,299 +8183,16 @@ export function toolAccessService( ); } - function invalidOAuthDcrResponse(field: string, reason: string): HttpError { - return new HttpError( - 502, - "OAuth provider returned incompatible dynamic client metadata", - { - code: "oauth_dcr_response_invalid", - field, - reason, - }, - ); - } - - function parseOAuthDcrString( - record: Record, - field: "client_id" | "client_secret", - input: { required: boolean; maxLength: number }, - ): string | null { - const value = record[field]; - if (value === undefined || value === null) { - if (input.required) throw invalidOAuthDcrResponse(field, "missing"); - return null; - } - if ( - typeof value !== "string" || - value.length === 0 || - value.length > input.maxLength - ) { - throw invalidOAuthDcrResponse(field, "invalid_string"); - } - if (field === "client_id" && value.trim() !== value) { - throw invalidOAuthDcrResponse(field, "invalid_string"); - } - return value; - } - - function assertOAuthDcrArray( - record: Record, - field: "redirect_uris" | "grant_types" | "response_types", - expected: string[], - options: { allowAdditional?: boolean; allowOmitted?: boolean } = {}, - ) { - if (record[field] === undefined) { - if (options.allowOmitted) return; - throw invalidOAuthDcrResponse(field, "missing"); - } - const value = record[field]; - if ( - !Array.isArray(value) || - value.length < expected.length || - value.length > 32 || - value.some( - (entry) => - typeof entry !== "string" || - entry.length === 0 || - entry.length > 2_048, - ) - ) { - throw invalidOAuthDcrResponse(field, "invalid_array"); - } - const actual = new Set(value); - if ( - expected.some((entry) => !actual.has(entry)) || - (!options.allowAdditional && actual.size !== expected.length) - ) { - throw invalidOAuthDcrResponse(field, "registered_value_mismatch"); - } - } - - function parseOAuthDcrTimestamp( - record: Record, - field: string, - ): number | null { - const value = record[field]; - if (value === undefined || value === null) return null; - if ( - typeof value !== "number" || - !Number.isSafeInteger(value) || - value < 0 - ) { - throw invalidOAuthDcrResponse(field, "invalid_timestamp"); - } - return value; - } - - type OAuthTokenEndpointAuthMethod = - "none" | "client_secret_basic" | "client_secret_post"; - - function selectOAuthDcrTokenEndpointAuthMethod( - supported: string[] | undefined, - ): OAuthTokenEndpointAuthMethod { - if (!supported?.length || supported.includes("none")) return "none"; - // When a confidential client is required, preserve the provider's advertised - // ordering. Miro advertises both methods but only completes its MCP exchange - // for the first one (`client_secret_post`), while Supabase and Hugging Face - // advertise `client_secret_basic` first. Treating Basic as a global preference - // creates a valid-looking registration that fails only after user consent. - for (const method of supported) { - if (method === "client_secret_basic" || method === "client_secret_post") - return method; - } - throw unprocessable( - "OAuth provider does not support a compatible dynamic client authentication method", - { - code: "oauth_dcr_client_auth_unsupported", - supportedMethods: supported, - }, - ); - } - - function storedOAuthTokenEndpointAuthMethod( - oauth: Record, - clientSecret: string | null | undefined, - ): OAuthTokenEndpointAuthMethod { - const method = oauth.clientTokenEndpointAuthMethod; - if ( - method === "none" || - method === "client_secret_basic" || - method === "client_secret_post" - ) { - return method; - } - const advertised = Array.isArray(oauth.tokenEndpointAuthMethodsSupported) - ? oauth.tokenEndpointAuthMethodsSupported.filter( - (value): value is string => typeof value === "string", - ) - : []; - // Manual OAuth clients do not carry a DCR-selected method. Follow the - // authorization server's advertised preference when it offers a - // confidential-client method; Xero, for example, documents Basic auth and - // rejects an otherwise valid code when the secret is posted in the body. - if (clientSecret && advertised.includes("client_secret_basic")) - return "client_secret_basic"; - if (clientSecret && advertised.includes("client_secret_post")) - return "client_secret_post"; - // Existing manually configured and preconfigured clients predate the - // persisted method field and already use client_secret_post successfully. - return clientSecret ? "client_secret_post" : "none"; - } - async function registerOAuthClient(input: { connection: typeof toolConnections.$inferSelect; endpoints: OAuthProviderEndpoints; redirectUri: string; actor?: ActorInfo; }) { - if (!input.endpoints.registrationUrl) { - throw unprocessable( - "OAuth provider does not advertise dynamic client registration", - { - code: "oauth_dcr_not_supported", - }, - ); - } - if ( - input.endpoints.codeChallengeMethodsSupported?.length && - !input.endpoints.codeChallengeMethodsSupported.includes("S256") - ) { - throw unprocessable( - "OAuth provider does not support the required PKCE S256 method", - { - code: "oauth_pkce_s256_required", - }, - ); - } - const tokenEndpointAuthMethod = selectOAuthDcrTokenEndpointAuthMethod( - input.endpoints.tokenEndpointAuthMethodsSupported, - ); - - const host = new URL(input.redirectUri).host; - const clientNameHost = host - .replace(/[^A-Za-z0-9-]/g, "-") - .replace(/-+/g, "-") - .replace(/^-|-$/g, ""); - const requestedMetadata = { - // Some DCR servers restrict client_name to alphanumeric characters, - // hyphens and spaces. Keep the deployment host recognizable without - // sending punctuation such as dots, colons or parentheses. - client_name: `Paperclip ${clientNameHost}`, - redirect_uris: [input.redirectUri], - grant_types: [ - "authorization_code", - ...(!input.endpoints.grantTypesSupported?.length || - input.endpoints.grantTypesSupported.includes("refresh_token") - ? ["refresh_token"] - : []), - ], - response_types: ["code"], - token_endpoint_auth_method: tokenEndpointAuthMethod, - // RFC 7591: Paperclip's callback is a server-side HTTPS endpoint, so this - // is a `web` client, not a `native` one. Some authorization servers reject - // an https redirect URI when the default (`web`) is left implicit, and - // others apply native-client redirect rules without it. - application_type: "web", - }; - const response = await fetchRemoteHttpUrl( - assertOAuthEndpointUrl("registration", input.endpoints.registrationUrl), - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(requestedMetadata), - }, - ); - const record = asRecord( - (await response.json().catch(() => ({}))) as unknown, - ); - if (!response.ok) { - const providerError = normalizeOAuthProviderError(record.error); - throw new HttpError( - 502, - oauthProviderErrorMessage( - providerError, - "OAuth dynamic client registration failed", - ), - { - code: "oauth_dynamic_client_registration_failed", - providerError, - status: response.status, - }, - ); - } - const clientId = parseOAuthDcrString(record, "client_id", { - required: true, - maxLength: MAX_OAUTH_DCR_CLIENT_ID_LENGTH, - })!; - const clientSecret = parseOAuthDcrString(record, "client_secret", { - required: tokenEndpointAuthMethod !== "none", - maxLength: MAX_OAUTH_DCR_CLIENT_SECRET_LENGTH, + const { clientId, clientSecret, tokenEndpointAuthMethod, clientIdIssuedAt, clientSecretExpiresAt } = await requestOAuthClientRegistration({ + endpoints: input.endpoints, + redirectUri: input.redirectUri, }); - // Some authorization servers add provider-owned metadata to the registered - // client. Paperclip still uses only the exact redirect, grant and response - // types it requested, so accept bounded supersets while requiring every - // requested value to remain present. Hugging Face, for example, adds the - // device-code grant to an otherwise valid authorization-code registration. - assertOAuthDcrArray( - record, - "redirect_uris", - requestedMetadata.redirect_uris, - { allowAdditional: true }, - ); - // RFC 7591 registration responses do not consistently echo every accepted - // request field. Supabase, for example, returns only the client material and - // redirect URIs. Redirect binding remains mandatory; omitted grant/response - // metadata inherits the values Paperclip requested. Additional provider-owned - // values do not widen Paperclip's behavior because they are never persisted as - // a flow choice or sent in authorization/token requests. - assertOAuthDcrArray(record, "grant_types", requestedMetadata.grant_types, { - allowAdditional: true, - allowOmitted: true, - }); - assertOAuthDcrArray( - record, - "response_types", - requestedMetadata.response_types, - { - allowAdditional: true, - allowOmitted: true, - }, - ); - if ( - record.token_endpoint_auth_method !== undefined && - record.token_endpoint_auth_method !== - requestedMetadata.token_endpoint_auth_method - ) { - throw invalidOAuthDcrResponse( - "token_endpoint_auth_method", - "registered_value_mismatch", - ); - } - const clientIdIssuedAt = parseOAuthDcrTimestamp( - record, - "client_id_issued_at", - ); - const returnedClientSecretExpiresAt = parseOAuthDcrTimestamp( - record, - "client_secret_expires_at", - ); - // A few public-client registrars (including Mixpanel) return the RFC 7591 - // `0` sentinel even though they issued no secret. It carries no credential - // lifetime in that case, so normalize it away. A positive expiry without a - // secret is still contradictory and remains a hard failure. - if ( - returnedClientSecretExpiresAt !== null && - returnedClientSecretExpiresAt > 0 && - clientSecret === null - ) { - throw invalidOAuthDcrResponse( - "client_secret_expires_at", - "client_secret_missing", - ); - } - const clientSecretExpiresAt = clientSecret - ? returnedClientSecretExpiresAt - : null; const existingClientSecretRef = oauthSecretRef( input.connection, "oauth.client_secret", @@ -10107,141 +8707,6 @@ export function toolAccessService( }); } - async function exchangeOAuthToken(input: { - tokenUrl: string; - clientId: string; - clientSecret?: string | null; - tokenEndpointAuthMethod?: OAuthTokenEndpointAuthMethod; - grantType?: "authorization_code" | "refresh_token" | "client_credentials"; - scopes?: string[]; - redirectUri?: string | null; - codeVerifier?: string | null; - code?: string | null; - refreshToken?: string | null; - /** RFC 8707 resource indicator: the MCP server this token is for. */ - resource?: string | null; - }) { - const body = new URLSearchParams(); - if (input.grantType === "client_credentials") { - body.set("grant_type", "client_credentials"); - if (input.scopes && input.scopes.length > 0) - body.set("scope", input.scopes.join(" ")); - } else if (input.refreshToken) { - body.set("grant_type", "refresh_token"); - body.set("refresh_token", input.refreshToken); - } else { - body.set("grant_type", "authorization_code"); - body.set("code", input.code ?? ""); - body.set("redirect_uri", input.redirectUri ?? ""); - body.set("code_verifier", input.codeVerifier ?? ""); - } - const tokenEndpointAuthMethod = - input.tokenEndpointAuthMethod ?? - (input.clientSecret ? "client_secret_post" : "none"); - if (tokenEndpointAuthMethod !== "client_secret_basic") - body.set("client_id", input.clientId); - if (tokenEndpointAuthMethod === "client_secret_post") { - if (!input.clientSecret) { - throw unprocessable( - "OAuth client secret is missing for client_secret_post authentication", - { - code: "oauth_client_secret_missing", - }, - ); - } - body.set("client_secret", input.clientSecret); - } - // RFC 8707: repeat the resource indicator on the token request so the - // authorization server audience-restricts the access token (and any refresh - // exchange) to this MCP server. - if (input.resource) body.set("resource", input.resource); - - // The token URL can come from a connection row written before the endpoint - // gate existed, so a client secret / authorization code never leaves - // Paperclip without re-checking the transport it would leave over. - const tokenUrl = assertOAuthEndpointUrl("token", input.tokenUrl, { - // Paperclip's own callback origin, so a first-party token endpoint keeps - // working on a deployment that is itself served over plaintext HTTP. - firstPartyOrigin: originOf(input.redirectUri), - }); - const headers: Record = { - "content-type": "application/x-www-form-urlencoded", - // Some providers otherwise return a legacy form-encoded token response. - accept: "application/json", - }; - if (tokenEndpointAuthMethod === "client_secret_basic") { - if (!input.clientSecret) { - throw unprocessable( - "OAuth client secret is missing for client_secret_basic authentication", - { - code: "oauth_client_secret_missing", - }, - ); - } - const formEncode = (value: string) => - new URLSearchParams({ value }).toString().slice("value=".length); - headers.Authorization = `Basic ${Buffer.from( - `${formEncode(input.clientId)}:${formEncode(input.clientSecret)}`, - "utf8", - ).toString("base64")}`; - } - const response = await fetchRemoteHttpUrl(tokenUrl, { - method: "POST", - headers, - body, - }); - const payload = (await response.json().catch(() => ({}))) as unknown; - const record = asRecord(payload); - if (!response.ok || record.ok === false) { - const providerError = normalizeOAuthProviderError(record.error); - const message = oauthProviderErrorMessage( - providerError, - "OAuth token exchange failed", - ); - if ( - input.grantType === "refresh_token" && - providerError === "invalid_grant" - ) { - throw new HttpError( - 422, - "OAuth authorization has expired. Reconnect this app to continue.", - { - code: "oauth_reauthorization_required", - providerError, - status: response.status, - }, - ); - } - throw new HttpError(502, message, { - code: "oauth_token_exchange_failed", - providerError, - status: response.status, - }); - } - const accessToken = - typeof record.access_token === "string" ? record.access_token : null; - if (!accessToken) - throw new HttpError( - 502, - "OAuth provider did not return an access token", - { code: "oauth_access_token_missing" }, - ); - const expiresIn = - typeof record.expires_in === "number" - ? record.expires_in - : Number(record.expires_in); - return { - accessToken, - refreshToken: - typeof record.refresh_token === "string" ? record.refresh_token : null, - expiresIn: Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : null, - scope: typeof record.scope === "string" ? record.scope : null, - tokenType: - typeof record.token_type === "string" ? record.token_type : "Bearer", - raw: record, - }; - } - function withoutOAuthRefreshLease(oauth: Record) { const { refreshLease: _refreshLease, ...rest } = oauth; return rest; @@ -16810,96 +15275,7 @@ export function toolAccessService( const serverUrl = await assertRemoteHttpUrlAllowed( method.defaults.serverUrl, ); - const attempts: ToolAppMetadataPreflightResult["attempts"] = []; - const endpointResponse = await fetchRemoteHttpUrl(serverUrl, { - method: "GET", - headers: { Accept: "application/json, text/event-stream" }, - }); - attempts.push({ - kind: "endpoint", - url: serverUrl, - status: endpointResponse.status, - ok: endpointResponse.ok, - contentType: endpointResponse.headers.get("content-type"), - }); - - if (method.auth !== "oauth") { - return { - galleryKey: app.slug, - methodKey: method.key, - serverUrl, - endpointReachable: endpointResponse.status < 500, - oauth: null, - attempts, - checkedAt: (options.now?.() ?? new Date()).toISOString(), - }; - } - - const endpoint = new URL(serverUrl); - const metadataQueue = [ - method.defaults.discoveryUrl ?? null, - method.defaults.metadataUrl ?? null, - ...protectedResourceMetadataUrls(endpoint), - ...wellKnownMetadataUrls(endpoint.toString()), - ].filter((url): url is string => Boolean(url)); - const visited = new Set(); - let metadataFound = false; - let registrationAdvertised = false; - let clientIdMetadataDocumentSupported = false; - - while (metadataQueue.length > 0 && visited.size < 16) { - const metadataUrl = metadataQueue.shift()!; - if (visited.has(metadataUrl)) continue; - visited.add(metadataUrl); - const response = await fetchRemoteHttpUrl(metadataUrl, { - method: "GET", - headers: { Accept: "application/json" }, - }); - attempts.push({ - kind: "oauth_metadata", - url: metadataUrl, - status: response.status, - ok: response.ok, - contentType: response.headers.get("content-type"), - }); - if (!response.ok) continue; - let metadata: Record; - try { - metadata = asRecord((await response.json()) as unknown); - } catch { - continue; - } - const looksLikeOAuthMetadata = Boolean( - metadata.authorization_endpoint || - metadata.token_endpoint || - metadata.authorization_servers || - metadata.resource, - ); - if (!looksLikeOAuthMetadata) continue; - metadataFound = true; - registrationAdvertised ||= - typeof metadata.registration_endpoint === "string"; - clientIdMetadataDocumentSupported ||= - metadata.client_id_metadata_document_supported === true; - for (const candidate of authServerMetadataUrls(metadata)) { - if (!visited.has(candidate.metadataUrl)) - metadataQueue.push(candidate.metadataUrl); - } - } - - return { - galleryKey: app.slug, - methodKey: method.key, - serverUrl, - endpointReachable: endpointResponse.status < 500, - oauth: { - metadataFound, - registrationAdvertised, - clientIdMetadataDocumentSupported, - }, - attempts, - checkedAt: (options.now?.() ?? new Date()).toISOString(), - }; + return preflightConnectionMetadata(app, method, method.defaults, serverUrl, () => options.now?.() ?? new Date()); } async function resolveEffectiveProfiles( diff --git a/server/src/services/tool-access/connection-methods.ts b/server/src/services/tool-access/connection-methods.ts index 31d6e538fb5..4ddd35ca07b 100644 --- a/server/src/services/tool-access/connection-methods.ts +++ b/server/src/services/tool-access/connection-methods.ts @@ -1,3 +1,4 @@ +import { parseRemoteHttpEndpoint } from "../remote-http-endpoint-guard.js"; import type { AppDefinition, ConnectionMethodDef, ToolConnectionTransport } from "@paperclipai/shared"; import { credentialConfigPath, @@ -357,3 +358,11 @@ export function projectedConnectionToolInputSchema( inputSchema, ); } + +export function remoteEndpoint(config: Record): string { + const value = config.url ?? config.endpoint ?? config.remoteUrl; + const parsed = parseRemoteHttpEndpoint(value, (message, code) => + badRequest(message, { code }), + ); + return parsed.toString(); +} diff --git a/server/src/services/tool-access/oauth/client-metadata.ts b/server/src/services/tool-access/oauth/client-metadata.ts new file mode 100644 index 00000000000..2aeecad72f7 --- /dev/null +++ b/server/src/services/tool-access/oauth/client-metadata.ts @@ -0,0 +1,80 @@ +import { assertPublicRemoteHttpEndpoint, type RemoteHttpEndpointLookup } from "../../remote-http-endpoint-guard.js"; + +/** + * Where this deployment publishes its Client ID Metadata Document. The document's + * own URL is the `client_id` Paperclip presents, so this path is a stable part of + * the deployment's public contract with every authorization server that has seen + * it — changing it invalidates existing CIMD registrations. + */ +export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH = + "/api/tools/oauth/client-metadata"; + +/** + * Resolve the URL Paperclip would use as a CIMD client id, but only when its + * hostname is not known to resolve into a private network. + * + * An authorization server fetches this URL from outside Paperclip's network and + * will normally apply an SSRF guard. Tailscale/MagicDNS names are HTTPS but + * resolve into 100.64.0.0/10, so presenting one as a client id can only produce + * an `invalid_client` response. A local DNS failure remains inconclusive because + * split-horizon public DNS may still let the authorization server resolve it. + */ +export async function resolveOAuthClientIdMetadataDocumentUrl( + redirectUri: string, + lookup?: RemoteHttpEndpointLookup, +): Promise { + try { + const parsed = new URL(redirectUri); + if (parsed.protocol !== "https:") return null; + const hostname = parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase(); + const isLoopback = + hostname === "localhost" || + hostname.endsWith(".localhost") || + hostname === "::1" || + /^127(?:\.\d{1,3}){3}$/.test(hostname); + if (isLoopback) return null; + const metadataUrl = new URL( + OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH, + parsed.origin, + ).toString(); + try { + await assertPublicRemoteHttpEndpoint( + new URL(metadataUrl), + { allowPrivateNetwork: false, lookup }, + (message, code) => Object.assign(new Error(message), { code }), + ); + } catch (error) { + if ( + error instanceof Error && + "code" in error && + error.code === "remote_http_private_endpoint" + ) { + return null; + } + } + return metadataUrl; + } catch { + return null; + } +} + +/** + * Paperclip's client metadata for CIMD (RFC 7591 metadata, served rather than + * registered). Only the callback for this deployment appears in it, so an + * authorization server that fetches it can see exactly one legal redirect target. + */ +export function oauthClientIdMetadataDocument(input: { + clientId: string; + redirectUri: string; +}): Record { + return { + client_id: input.clientId, + client_name: `Paperclip (${new URL(input.redirectUri).host})`, + client_uri: new URL("/", input.clientId).toString(), + redirect_uris: [input.redirectUri], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + application_type: "web", + }; +} diff --git a/server/src/services/tool-access/oauth/client.ts b/server/src/services/tool-access/oauth/client.ts new file mode 100644 index 00000000000..5c5dee5bbdb --- /dev/null +++ b/server/src/services/tool-access/oauth/client.ts @@ -0,0 +1,434 @@ +import { HttpError, unprocessable } from "../../../errors.js"; +import { asRecord, originOf } from "./metadata.js"; +import { normalizeOAuthProviderError, oauthProviderErrorMessage } from "./provider-errors.js"; +import type { OAuthProviderEndpoints, OAuthTokenEndpointAuthMethod, OAuthTransport } from "./types.js"; + +const MAX_OAUTH_DCR_CLIENT_ID_LENGTH = 4_096; + +const MAX_OAUTH_DCR_CLIENT_SECRET_LENGTH = 16_384; + +function invalidOAuthDcrResponse(field: string, reason: string): HttpError { + return new HttpError( + 502, + "OAuth provider returned incompatible dynamic client metadata", + { + code: "oauth_dcr_response_invalid", + field, + reason, + }, + ); +} + +function parseOAuthDcrString( + record: Record, + field: "client_id" | "client_secret", + input: { required: boolean; maxLength: number }, +): string | null { + const value = record[field]; + if (value === undefined || value === null) { + if (input.required) throw invalidOAuthDcrResponse(field, "missing"); + return null; + } + if ( + typeof value !== "string" || + value.length === 0 || + value.length > input.maxLength + ) { + throw invalidOAuthDcrResponse(field, "invalid_string"); + } + if (field === "client_id" && value.trim() !== value) { + throw invalidOAuthDcrResponse(field, "invalid_string"); + } + return value; +} + +function assertOAuthDcrArray( + record: Record, + field: "redirect_uris" | "grant_types" | "response_types", + expected: string[], + options: { allowAdditional?: boolean; allowOmitted?: boolean } = {}, +) { + if (record[field] === undefined) { + if (options.allowOmitted) return; + throw invalidOAuthDcrResponse(field, "missing"); + } + const value = record[field]; + if ( + !Array.isArray(value) || + value.length < expected.length || + value.length > 32 || + value.some( + (entry) => + typeof entry !== "string" || + entry.length === 0 || + entry.length > 2_048, + ) + ) { + throw invalidOAuthDcrResponse(field, "invalid_array"); + } + const actual = new Set(value); + if ( + expected.some((entry) => !actual.has(entry)) || + (!options.allowAdditional && actual.size !== expected.length) + ) { + throw invalidOAuthDcrResponse(field, "registered_value_mismatch"); + } +} + +function parseOAuthDcrTimestamp( + record: Record, + field: string, +): number | null { + const value = record[field]; + if (value === undefined || value === null) return null; + if ( + typeof value !== "number" || + !Number.isSafeInteger(value) || + value < 0 + ) { + throw invalidOAuthDcrResponse(field, "invalid_timestamp"); + } + return value; +} + +function selectOAuthDcrTokenEndpointAuthMethod( + supported: string[] | undefined, +): OAuthTokenEndpointAuthMethod { + if (!supported?.length || supported.includes("none")) return "none"; + // When a confidential client is required, preserve the provider's advertised + // ordering. Miro advertises both methods but only completes its MCP exchange + // for the first one (`client_secret_post`), while Supabase and Hugging Face + // advertise `client_secret_basic` first. Treating Basic as a global preference + // creates a valid-looking registration that fails only after user consent. + for (const method of supported) { + if (method === "client_secret_basic" || method === "client_secret_post") + return method; + } + throw unprocessable( + "OAuth provider does not support a compatible dynamic client authentication method", + { + code: "oauth_dcr_client_auth_unsupported", + supportedMethods: supported, + }, + ); +} + +export function storedOAuthTokenEndpointAuthMethod( + oauth: Record, + clientSecret: string | null | undefined, +): OAuthTokenEndpointAuthMethod { + const method = oauth.clientTokenEndpointAuthMethod; + if ( + method === "none" || + method === "client_secret_basic" || + method === "client_secret_post" + ) { + return method; + } + const advertised = Array.isArray(oauth.tokenEndpointAuthMethodsSupported) + ? oauth.tokenEndpointAuthMethodsSupported.filter( + (value): value is string => typeof value === "string", + ) + : []; + // Manual OAuth clients do not carry a DCR-selected method. Follow the + // authorization server's advertised preference when it offers a + // confidential-client method; Xero, for example, documents Basic auth and + // rejects an otherwise valid code when the secret is posted in the body. + if (clientSecret && advertised.includes("client_secret_basic")) + return "client_secret_basic"; + if (clientSecret && advertised.includes("client_secret_post")) + return "client_secret_post"; + // Existing manually configured and preconfigured clients predate the + // persisted method field and already use client_secret_post successfully. + return clientSecret ? "client_secret_post" : "none"; +} + +export function createOAuthClientProtocol({ fetchRemoteHttpUrl, assertOAuthEndpointUrl }: OAuthTransport) { + async function requestOAuthClientRegistration(input: { endpoints: OAuthProviderEndpoints; redirectUri: string }) { + if (!input.endpoints.registrationUrl) { + throw unprocessable( + "OAuth provider does not advertise dynamic client registration", + { + code: "oauth_dcr_not_supported", + }, + ); + } + if ( + input.endpoints.codeChallengeMethodsSupported?.length && + !input.endpoints.codeChallengeMethodsSupported.includes("S256") + ) { + throw unprocessable( + "OAuth provider does not support the required PKCE S256 method", + { + code: "oauth_pkce_s256_required", + }, + ); + } + const tokenEndpointAuthMethod = selectOAuthDcrTokenEndpointAuthMethod( + input.endpoints.tokenEndpointAuthMethodsSupported, + ); + + const host = new URL(input.redirectUri).host; + const clientNameHost = host + .replace(/[^A-Za-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + const requestedMetadata = { + // Some DCR servers restrict client_name to alphanumeric characters, + // hyphens and spaces. Keep the deployment host recognizable without + // sending punctuation such as dots, colons or parentheses. + client_name: `Paperclip ${clientNameHost}`, + redirect_uris: [input.redirectUri], + grant_types: [ + "authorization_code", + ...(!input.endpoints.grantTypesSupported?.length || + input.endpoints.grantTypesSupported.includes("refresh_token") + ? ["refresh_token"] + : []), + ], + response_types: ["code"], + token_endpoint_auth_method: tokenEndpointAuthMethod, + // RFC 7591: Paperclip's callback is a server-side HTTPS endpoint, so this + // is a `web` client, not a `native` one. Some authorization servers reject + // an https redirect URI when the default (`web`) is left implicit, and + // others apply native-client redirect rules without it. + application_type: "web", + }; + const response = await fetchRemoteHttpUrl( + assertOAuthEndpointUrl("registration", input.endpoints.registrationUrl), + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(requestedMetadata), + }, + ); + const record = asRecord( + (await response.json().catch(() => ({}))) as unknown, + ); + if (!response.ok) { + const providerError = normalizeOAuthProviderError(record.error); + throw new HttpError( + 502, + oauthProviderErrorMessage( + providerError, + "OAuth dynamic client registration failed", + ), + { + code: "oauth_dynamic_client_registration_failed", + providerError, + status: response.status, + }, + ); + } + const clientId = parseOAuthDcrString(record, "client_id", { + required: true, + maxLength: MAX_OAUTH_DCR_CLIENT_ID_LENGTH, + })!; + const clientSecret = parseOAuthDcrString(record, "client_secret", { + required: tokenEndpointAuthMethod !== "none", + maxLength: MAX_OAUTH_DCR_CLIENT_SECRET_LENGTH, + }); + // Some authorization servers add provider-owned metadata to the registered + // client. Paperclip still uses only the exact redirect, grant and response + // types it requested, so accept bounded supersets while requiring every + // requested value to remain present. Hugging Face, for example, adds the + // device-code grant to an otherwise valid authorization-code registration. + assertOAuthDcrArray( + record, + "redirect_uris", + requestedMetadata.redirect_uris, + { allowAdditional: true }, + ); + // RFC 7591 registration responses do not consistently echo every accepted + // request field. Supabase, for example, returns only the client material and + // redirect URIs. Redirect binding remains mandatory; omitted grant/response + // metadata inherits the values Paperclip requested. Additional provider-owned + // values do not widen Paperclip's behavior because they are never persisted as + // a flow choice or sent in authorization/token requests. + assertOAuthDcrArray(record, "grant_types", requestedMetadata.grant_types, { + allowAdditional: true, + allowOmitted: true, + }); + assertOAuthDcrArray( + record, + "response_types", + requestedMetadata.response_types, + { + allowAdditional: true, + allowOmitted: true, + }, + ); + if ( + record.token_endpoint_auth_method !== undefined && + record.token_endpoint_auth_method !== + requestedMetadata.token_endpoint_auth_method + ) { + throw invalidOAuthDcrResponse( + "token_endpoint_auth_method", + "registered_value_mismatch", + ); + } + const clientIdIssuedAt = parseOAuthDcrTimestamp( + record, + "client_id_issued_at", + ); + const returnedClientSecretExpiresAt = parseOAuthDcrTimestamp( + record, + "client_secret_expires_at", + ); + // A few public-client registrars (including Mixpanel) return the RFC 7591 + // `0` sentinel even though they issued no secret. It carries no credential + // lifetime in that case, so normalize it away. A positive expiry without a + // secret is still contradictory and remains a hard failure. + if ( + returnedClientSecretExpiresAt !== null && + returnedClientSecretExpiresAt > 0 && + clientSecret === null + ) { + throw invalidOAuthDcrResponse( + "client_secret_expires_at", + "client_secret_missing", + ); + } + const clientSecretExpiresAt = clientSecret + ? returnedClientSecretExpiresAt + : null; + return { clientId, clientSecret, tokenEndpointAuthMethod, clientIdIssuedAt, clientSecretExpiresAt }; + } + + async function exchangeOAuthToken(input: { + tokenUrl: string; + clientId: string; + clientSecret?: string | null; + tokenEndpointAuthMethod?: OAuthTokenEndpointAuthMethod; + grantType?: "authorization_code" | "refresh_token" | "client_credentials"; + scopes?: string[]; + redirectUri?: string | null; + codeVerifier?: string | null; + code?: string | null; + refreshToken?: string | null; + /** RFC 8707 resource indicator: the MCP server this token is for. */ + resource?: string | null; + }) { + const body = new URLSearchParams(); + if (input.grantType === "client_credentials") { + body.set("grant_type", "client_credentials"); + if (input.scopes && input.scopes.length > 0) + body.set("scope", input.scopes.join(" ")); + } else if (input.refreshToken) { + body.set("grant_type", "refresh_token"); + body.set("refresh_token", input.refreshToken); + } else { + body.set("grant_type", "authorization_code"); + body.set("code", input.code ?? ""); + body.set("redirect_uri", input.redirectUri ?? ""); + body.set("code_verifier", input.codeVerifier ?? ""); + } + const tokenEndpointAuthMethod = + input.tokenEndpointAuthMethod ?? + (input.clientSecret ? "client_secret_post" : "none"); + if (tokenEndpointAuthMethod !== "client_secret_basic") + body.set("client_id", input.clientId); + if (tokenEndpointAuthMethod === "client_secret_post") { + if (!input.clientSecret) { + throw unprocessable( + "OAuth client secret is missing for client_secret_post authentication", + { + code: "oauth_client_secret_missing", + }, + ); + } + body.set("client_secret", input.clientSecret); + } + // RFC 8707: repeat the resource indicator on the token request so the + // authorization server audience-restricts the access token (and any refresh + // exchange) to this MCP server. + if (input.resource) body.set("resource", input.resource); + + // The token URL can come from a connection row written before the endpoint + // gate existed, so a client secret / authorization code never leaves + // Paperclip without re-checking the transport it would leave over. + const tokenUrl = assertOAuthEndpointUrl("token", input.tokenUrl, { + // Paperclip's own callback origin, so a first-party token endpoint keeps + // working on a deployment that is itself served over plaintext HTTP. + firstPartyOrigin: originOf(input.redirectUri), + }); + const headers: Record = { + "content-type": "application/x-www-form-urlencoded", + // Some providers otherwise return a legacy form-encoded token response. + accept: "application/json", + }; + if (tokenEndpointAuthMethod === "client_secret_basic") { + if (!input.clientSecret) { + throw unprocessable( + "OAuth client secret is missing for client_secret_basic authentication", + { + code: "oauth_client_secret_missing", + }, + ); + } + const formEncode = (value: string) => + new URLSearchParams({ value }).toString().slice("value=".length); + headers.Authorization = `Basic ${Buffer.from( + `${formEncode(input.clientId)}:${formEncode(input.clientSecret)}`, + "utf8", + ).toString("base64")}`; + } + const response = await fetchRemoteHttpUrl(tokenUrl, { + method: "POST", + headers, + body, + }); + const payload = (await response.json().catch(() => ({}))) as unknown; + const record = asRecord(payload); + if (!response.ok || record.ok === false) { + const providerError = normalizeOAuthProviderError(record.error); + const message = oauthProviderErrorMessage( + providerError, + "OAuth token exchange failed", + ); + if ( + input.grantType === "refresh_token" && + providerError === "invalid_grant" + ) { + throw new HttpError( + 422, + "OAuth authorization has expired. Reconnect this app to continue.", + { + code: "oauth_reauthorization_required", + providerError, + status: response.status, + }, + ); + } + throw new HttpError(502, message, { + code: "oauth_token_exchange_failed", + providerError, + status: response.status, + }); + } + const accessToken = + typeof record.access_token === "string" ? record.access_token : null; + if (!accessToken) + throw new HttpError( + 502, + "OAuth provider did not return an access token", + { code: "oauth_access_token_missing" }, + ); + const expiresIn = + typeof record.expires_in === "number" + ? record.expires_in + : Number(record.expires_in); + return { + accessToken, + refreshToken: + typeof record.refresh_token === "string" ? record.refresh_token : null, + expiresIn: Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : null, + scope: typeof record.scope === "string" ? record.scope : null, + tokenType: + typeof record.token_type === "string" ? record.token_type : "Bearer", + }; + } + + return { requestOAuthClientRegistration, exchangeOAuthToken }; +} diff --git a/server/src/services/tool-access/oauth/discovery.ts b/server/src/services/tool-access/oauth/discovery.ts new file mode 100644 index 00000000000..a9847979ed4 --- /dev/null +++ b/server/src/services/tool-access/oauth/discovery.ts @@ -0,0 +1,582 @@ +import { getConnectableAppDefinition, type AppDefinition, type ConnectionMethodDef, type ToolAppMetadataPreflightResult } from "@paperclipai/shared"; +import { HttpError, unprocessable } from "../../../errors.js"; +import { connectionMethodFor, connectionMethodForConnection, remoteEndpoint } from "../connection-methods.js"; +import type { OAuthConnectionSettings, OAuthProviderEndpoints, OAuthTransport } from "./types.js"; +import type { createOAuthEndpointPolicy } from "./endpoint-policy.js"; +import { asRecord, oauthConfig, normalizeOauthScopes, isSmokeLabOAuthFixture, isSmokeLabOAuthUrl, originOf, oauthProviderForConnection, challengeOAuthHints, canonicalResourceIndicator, sameOAuthIssuer, protectedResourceMetadataUrls, wellKnownMetadataUrls, authServerMetadataUrls } from "./metadata.js"; + +type DiscoveryDependencies = OAuthTransport & Pick, "safeOAuthEndpointUrl"> & { + assertRemoteEndpointAllowed: (config: Record) => Promise; +}; + +export function createOAuthDiscovery({ fetchRemoteHttpUrl, assertRemoteEndpointAllowed, assertOAuthEndpointUrl, safeOAuthEndpointUrl }: DiscoveryDependencies) { + function smokeLabOAuthEndpoints( + connection: OAuthConnectionSettings, + redirectUri?: string, + ): OAuthProviderEndpoints | null { + if (!isSmokeLabOAuthFixture(connection) || !redirectUri) return null; + let origin: string; + try { + origin = new URL(redirectUri).origin; + } catch { + return null; + } + const oauthBasePath = `/api/companies/${encodeURIComponent(connection.companyId)}/smoke-lab/oauth`; + return { + provider: "smoke_lab", + scopes: normalizeOauthScopes(oauthConfig(connection).scopes), + authorizationUrl: new URL( + `${oauthBasePath}/authorize`, + origin, + ).toString(), + tokenUrl: new URL(`${oauthBasePath}/token`, origin).toString(), + metadataUrl: null, + grantType: "authorization_code", + }; + } + + function assertNotSmokeLabOAuthEndpoints( + connection: OAuthConnectionSettings, + endpoints: OAuthProviderEndpoints, + ) { + const blockedUrl = [ + endpoints.authorizationUrl, + endpoints.tokenUrl, + endpoints.metadataUrl, + ].find(isSmokeLabOAuthUrl); + if (blockedUrl && !isSmokeLabOAuthFixture(connection)) { + throw unprocessable( + "Smoke Lab OAuth provider cannot be used for tool app sign-in", + ); + } + } + + async function fetchJsonRecord( + url: string, + ): Promise | null> { + try { + const response = await fetchRemoteHttpUrl(url); + if (!response.ok) return null; + return asRecord((await response.json()) as unknown) ?? null; + } catch { + return null; + } + } + + async function endpointsFromMetadataUrl( + connection: OAuthConnectionSettings, + metadataUrl: string, + rejections: HttpError[] = [], + firstPartyOrigin?: string | null, + ): Promise { + const metadata = await fetchJsonRecord(metadataUrl); + if (!metadata) return null; + // Every endpoint below is a string the remote server chose, so none of them + // is adopted before `safeOAuthEndpointUrl` has vetted its scheme and host. + let authorizationUrl = safeOAuthEndpointUrl( + "authorization", + metadata.authorization_endpoint, + rejections, + firstPartyOrigin, + ); + let tokenUrl = safeOAuthEndpointUrl( + "token", + metadata.token_endpoint, + rejections, + firstPartyOrigin, + ); + let registrationUrl = safeOAuthEndpointUrl( + "registration", + metadata.registration_endpoint, + rejections, + firstPartyOrigin, + ); + let scopes = normalizeOauthScopes(metadata.scopes_supported); + let offlineAccessSupported = Boolean( + authorizationUrl && scopes.includes("offline_access"), + ); + let codeChallengeMethodsSupported = normalizeOauthScopes( + metadata.code_challenge_methods_supported, + ); + let tokenEndpointAuthMethodsSupported = normalizeOauthScopes( + metadata.token_endpoint_auth_methods_supported, + ); + let grantTypesSupported = normalizeOauthScopes( + metadata.grant_types_supported, + ); + let clientIdMetadataDocumentSupported = + metadata.client_id_metadata_document_supported === true; + // A document that carries the authorization endpoint itself *is* the + // authorization-server metadata, so its own `issuer` is the canonical one. + // Otherwise this was protected-resource metadata and the issuer comes from + // whichever advertised authorization server answered. + let issuer = + authorizationUrl && + typeof metadata.issuer === "string" && + metadata.issuer.trim() + ? metadata.issuer.trim() + : null; + const resource = + typeof metadata.resource === "string" && metadata.resource.trim() + ? metadata.resource.trim() + : null; + for (const candidate of authServerMetadataUrls(metadata)) { + const authMetadata = await fetchJsonRecord(candidate.metadataUrl); + if (!authMetadata) continue; + const candidateAuthorizationUrl = safeOAuthEndpointUrl( + "authorization", + authMetadata.authorization_endpoint, + rejections, + firstPartyOrigin, + ); + const candidateTokenUrl = safeOAuthEndpointUrl( + "token", + authMetadata.token_endpoint, + rejections, + firstPartyOrigin, + ); + if (!candidateAuthorizationUrl && !candidateTokenUrl) continue; + // RFC 8414 §3.3: the metadata document's `issuer` must match the issuer we + // used to build the discovery URL, or the document is not authoritative. + const advertisedIssuer = + typeof authMetadata.issuer === "string" && authMetadata.issuer.trim() + ? authMetadata.issuer.trim() + : null; + if ( + advertisedIssuer && + !sameOAuthIssuer(advertisedIssuer, candidate.issuer) + ) + continue; + // A document can carry its own endpoints and also list other servers. + // Adopt capabilities only from metadata bound to the selected endpoints + // and issuer; otherwise offline access could break an unrelated sign-in. + if ( + (authorizationUrl && candidateAuthorizationUrl !== authorizationUrl) || + (tokenUrl && candidateTokenUrl !== tokenUrl) || + (issuer && !sameOAuthIssuer(issuer, advertisedIssuer ?? candidate.issuer)) + ) + continue; + authorizationUrl = authorizationUrl ?? candidateAuthorizationUrl; + tokenUrl = tokenUrl ?? candidateTokenUrl; + registrationUrl = + registrationUrl ?? + safeOAuthEndpointUrl( + "registration", + authMetadata.registration_endpoint, + rejections, + firstPartyOrigin, + ); + issuer = issuer ?? advertisedIssuer ?? candidate.issuer; + offlineAccessSupported = offlineAccessSupported || + normalizeOauthScopes(authMetadata.scopes_supported).includes("offline_access"); + if (scopes.length === 0) + scopes = normalizeOauthScopes(authMetadata.scopes_supported); + if (codeChallengeMethodsSupported.length === 0) { + codeChallengeMethodsSupported = normalizeOauthScopes( + authMetadata.code_challenge_methods_supported, + ); + } + if (tokenEndpointAuthMethodsSupported.length === 0) { + tokenEndpointAuthMethodsSupported = normalizeOauthScopes( + authMetadata.token_endpoint_auth_methods_supported, + ); + } + if (grantTypesSupported.length === 0) { + grantTypesSupported = normalizeOauthScopes( + authMetadata.grant_types_supported, + ); + } + if (!clientIdMetadataDocumentSupported) { + clientIdMetadataDocumentSupported = + authMetadata.client_id_metadata_document_supported === true; + } + if (authorizationUrl && tokenUrl) break; + } + if (!authorizationUrl || !tokenUrl) return null; + return { + provider: oauthProviderForConnection(connection, metadataUrl), + scopes, + offlineAccessSupported, + authorizationUrl, + tokenUrl, + registrationUrl, + codeChallengeMethodsSupported, + tokenEndpointAuthMethodsSupported, + grantTypesSupported, + metadataUrl, + issuer, + resource, + clientIdMetadataDocumentSupported, + }; + } + + async function discoverOAuthEndpoints( + connection: OAuthConnectionSettings, + challenge?: string | null, + firstPartyOrigin?: string | null, + ): Promise { + const oauth = oauthConfig(connection); + const hints = challenge ? challengeOAuthHints(challenge) : null; + // A configured endpoint was pasted by an operator or persisted from an + // earlier discovery, and a hint came straight out of the endpoint's + // `WWW-Authenticate` header. Neither is more trusted than metadata, so both + // go through the same gate; an unusable one is dropped so full metadata + // discovery still runs below. + const rejections: HttpError[] = []; + const configuredAuthorizationUrl = safeOAuthEndpointUrl( + "authorization", + typeof oauth.authorizationUrl === "string" + ? oauth.authorizationUrl + : (hints?.authorizationUrl ?? null), + rejections, + firstPartyOrigin, + ); + const configuredTokenUrl = safeOAuthEndpointUrl( + "token", + typeof oauth.tokenUrl === "string" + ? oauth.tokenUrl + : (hints?.tokenUrl ?? null), + rejections, + firstPartyOrigin, + ); + const provider = oauthProviderForConnection( + connection, + typeof oauth.metadataUrl === "string" + ? oauth.metadataUrl + : hints?.metadataUrl, + ); + const scopes = + normalizeOauthScopes(oauth.scopes).length > 0 + ? normalizeOauthScopes(oauth.scopes) + : normalizeOauthScopes(oauth.scope).length > 0 + ? normalizeOauthScopes(oauth.scope) + : normalizeOauthScopes(hints?.scope); + const grantType = + oauth.grantType === "client_credentials" || + oauth.clientCredentials === true + ? ("client_credentials" as const) + : ("authorization_code" as const); + // The resource indicator is the MCP endpoint itself, independent of which + // authorization server ends up serving it. + const configuredResource = + typeof oauth.resource === "string" && oauth.resource.trim() + ? oauth.resource.trim() + : canonicalResourceIndicator(remoteEndpoint(connection.config)); + if (configuredAuthorizationUrl && configuredTokenUrl) { + // Pre-existing generic connections cached endpoints before refresh-scope + // support was recorded. Recover that capability from their bound metadata + // without adopting a different issuer or changing their requested tool scopes. + let refreshMetadata: OAuthProviderEndpoints | null = null; + if ( + !connection.config.sourceTemplateKey && + typeof oauth.offlineAccessSupported !== "boolean" && + typeof oauth.metadataUrl === "string" + ) { + const discovered = await endpointsFromMetadataUrl( + connection, oauth.metadataUrl, rejections, firstPartyOrigin, + ); + if ( + discovered?.authorizationUrl === configuredAuthorizationUrl && + discovered.tokenUrl === configuredTokenUrl && + (typeof oauth.issuer !== "string" || + (discovered.issuer && sameOAuthIssuer(discovered.issuer, oauth.issuer))) + ) refreshMetadata = discovered; + } + return { + provider, + scopes, + offlineAccessSupported: typeof oauth.offlineAccessSupported === "boolean" + ? oauth.offlineAccessSupported + : refreshMetadata?.offlineAccessSupported, + authorizationUrl: configuredAuthorizationUrl, + tokenUrl: configuredTokenUrl, + registrationUrl: safeOAuthEndpointUrl( + "registration", + oauth.registrationUrl, + rejections, + firstPartyOrigin, + ), + codeChallengeMethodsSupported: normalizeOauthScopes( + oauth.codeChallengeMethodsSupported, + ), + tokenEndpointAuthMethodsSupported: normalizeOauthScopes( + oauth.tokenEndpointAuthMethodsSupported, + ), + grantTypesSupported: Array.isArray(oauth.grantTypesSupported) + ? normalizeOauthScopes(oauth.grantTypesSupported) + : refreshMetadata?.grantTypesSupported, + grantType, + metadataUrl: + typeof oauth.metadataUrl === "string" + ? oauth.metadataUrl + : (hints?.metadataUrl ?? null), + issuer: + typeof oauth.issuer === "string" && oauth.issuer.trim() + ? oauth.issuer.trim() + : null, + resource: configuredResource, + clientIdMetadataDocumentSupported: + oauth.clientIdMetadataDocumentSupported === true, + }; + } + + const metadataCandidates = [ + typeof oauth.metadataUrl === "string" ? oauth.metadataUrl : null, + hints?.metadataUrl ?? null, + ].filter((value): value is string => Boolean(value)); + if (metadataCandidates.length === 0) { + const endpoint = new URL( + await assertRemoteEndpointAllowed(connection.config), + ); + metadataCandidates.push(...protectedResourceMetadataUrls(endpoint)); + // The MCP server may double as its own authorization server, in which case + // it serves authorization-server metadata directly at (or under) its path. + metadataCandidates.push(...wellKnownMetadataUrls(endpoint.toString())); + } + for (const metadataUrl of [...new Set(metadataCandidates)]) { + const endpoints = await endpointsFromMetadataUrl( + connection, + metadataUrl, + rejections, + firstPartyOrigin, + ); + if (endpoints) { + return { + ...endpoints, + scopes: scopes.length > 0 ? scopes : endpoints.scopes, + grantType, + resource: endpoints.resource ?? configuredResource, + }; + } + } + // Nothing usable was found *and* something was refused on the way: report the + // refusal rather than the generic "does not advertise OAuth sign in", so the + // operator learns the server offered an unsafe address. + if (rejections.length > 0) throw rejections[0]; + return null; + } + + async function oauthProviderEndpoints( + app: AppDefinition, + methodKey?: string | null, + ): Promise { + const method = connectionMethodFor(app, methodKey); + if (method.auth !== "oauth") + throw unprocessable("This app does not support sign in"); + let authorizationUrl = method.defaults?.authorizationEndpoint ?? null; + let tokenUrl = method.defaults?.tokenEndpoint ?? null; + const metadataUrl = method.defaults?.metadataUrl ?? null; + if ((!authorizationUrl || !tokenUrl) && metadataUrl) { + const response = await fetchRemoteHttpUrl(metadataUrl); + if (!response.ok) + throw new HttpError( + 502, + "OAuth provider metadata could not be loaded", + { code: "oauth_metadata_failed" }, + ); + const metadata = asRecord((await response.json()) as unknown); + authorizationUrl = + authorizationUrl ?? + (typeof metadata.authorization_endpoint === "string" + ? metadata.authorization_endpoint + : null); + tokenUrl = + tokenUrl ?? + (typeof metadata.token_endpoint === "string" + ? metadata.token_endpoint + : null); + } + if (!authorizationUrl || !tokenUrl) { + throw unprocessable( + "OAuth provider endpoints are not configured for this app", + ); + } + // A gallery default is Paperclip's own data, but it is still a URL that ends + // up as a browser navigation, and the metadata branch above reads the same + // untrusted document a generic connection does. Both go through the gate. + return { + provider: app.slug, + scopes: method.defaults?.scopesHint ?? [], + authorizationUrl: assertOAuthEndpointUrl( + "authorization", + authorizationUrl, + ), + tokenUrl: assertOAuthEndpointUrl("token", tokenUrl), + grantType: "authorization_code", + metadataUrl, + }; + } + + async function oauthEndpointsForConnection( + connection: OAuthConnectionSettings, + challenge?: string | null, + redirectUri?: string, + ): Promise { + const smokeLabEndpoints = smokeLabOAuthEndpoints(connection, redirectUri); + const sourceTemplateKey = + typeof connection.config.sourceTemplateKey === "string" + ? connection.config.sourceTemplateKey + : null; + const galleryEntry = sourceTemplateKey + ? getConnectableAppDefinition(sourceTemplateKey) + : null; + const galleryMethod = galleryEntry + ? connectionMethodForConnection(galleryEntry, connection) + : null; + // Pinned endpoints describe the provider's console-registered OAuth app. + // A method that also offers dynamic registration registers against the MCP + // server's own authorization server (Linear: mcp.linear.app, not + // linear.app), which only discovery finds. So the pins stand in for + // discovery only when no registration is possible or the connection + // already carries an operator-entered client. + const storedOAuth = oauthConfig(connection); + const usesOperatorClient = + storedOAuth.clientRegistrationSource === "manual" && + connection.ownership !== "dcr" && + typeof storedOAuth.clientId === "string" && + storedOAuth.clientId.trim().length > 0; + // A reviewed protected-resource document is authoritative for this method. + // Some hosts still serve metadata for a retired MCP server at the root. + // Resolve it before cached endpoints, including on existing draft reconnects. + if (!smokeLabEndpoints && galleryMethod?.defaults?.discoveryUrl) { + const endpoints = await endpointsFromMetadataUrl( + connection, galleryMethod.defaults.discoveryUrl, [], originOf(redirectUri), + ); + if (!endpoints) throw unprocessable("OAuth provider metadata could not be loaded"); + assertNotSmokeLabOAuthEndpoints(connection, endpoints); + return endpoints; + } + const hasCompleteGalleryEndpointHints = Boolean( + galleryMethod?.defaults?.authorizationEndpoint && + galleryMethod.defaults.tokenEndpoint && + (!galleryMethod.ownershipModes.includes("dcr") || usesOperatorClient), + ); + // The smoke-lab fixture's endpoints are first-party and complete, so + // discovery is not just unnecessary there, it must not run: an unreachable + // fixture endpoint would fail the whole callback. + const firstPartyOrigin = originOf(redirectUri); + const discovered = + !smokeLabEndpoints && + connection.transport === "mcp_remote" && + !hasCompleteGalleryEndpointHints + ? await discoverOAuthEndpoints(connection, challenge, firstPartyOrigin) + : null; + const endpoints = + smokeLabEndpoints ?? + discovered ?? + (galleryEntry && galleryMethod?.auth === "oauth" + ? await oauthProviderEndpoints(galleryEntry, galleryMethod.key) + : await discoverOAuthEndpoints( + connection, + challenge, + firstPartyOrigin, + )); + if (!endpoints) + throw unprocessable( + "This app connection does not advertise OAuth sign in", + ); + assertNotSmokeLabOAuthEndpoints(connection, endpoints); + return endpoints; + } + + async function preflightConnectionMetadata( + app: Pick, + method: Pick, + defaults: NonNullable, + serverUrl: string, + now: () => Date, + ): Promise { + const attempts: ToolAppMetadataPreflightResult["attempts"] = []; + const endpointResponse = await fetchRemoteHttpUrl(serverUrl, { + method: "GET", + headers: { Accept: "application/json, text/event-stream" }, + }); + attempts.push({ + kind: "endpoint", + url: serverUrl, + status: endpointResponse.status, + ok: endpointResponse.ok, + contentType: endpointResponse.headers.get("content-type"), + }); + + if (method.auth !== "oauth") { + return { + galleryKey: app.slug, + methodKey: method.key, + serverUrl, + endpointReachable: endpointResponse.status < 500, + oauth: null, + attempts, + checkedAt: now().toISOString(), + }; + } + + const endpoint = new URL(serverUrl); + const metadataQueue = [ + defaults.discoveryUrl ?? null, + defaults.metadataUrl ?? null, + ...protectedResourceMetadataUrls(endpoint), + ...wellKnownMetadataUrls(endpoint.toString()), + ].filter((url): url is string => Boolean(url)); + const visited = new Set(); + let metadataFound = false; + let registrationAdvertised = false; + let clientIdMetadataDocumentSupported = false; + + while (metadataQueue.length > 0 && visited.size < 16) { + const metadataUrl = metadataQueue.shift()!; + if (visited.has(metadataUrl)) continue; + visited.add(metadataUrl); + const response = await fetchRemoteHttpUrl(metadataUrl, { + method: "GET", + headers: { Accept: "application/json" }, + }); + attempts.push({ + kind: "oauth_metadata", + url: metadataUrl, + status: response.status, + ok: response.ok, + contentType: response.headers.get("content-type"), + }); + if (!response.ok) continue; + let metadata: Record; + try { + metadata = asRecord((await response.json()) as unknown); + } catch { + continue; + } + const looksLikeOAuthMetadata = Boolean( + metadata.authorization_endpoint || + metadata.token_endpoint || + metadata.authorization_servers || + metadata.resource, + ); + if (!looksLikeOAuthMetadata) continue; + metadataFound = true; + registrationAdvertised ||= + typeof metadata.registration_endpoint === "string"; + clientIdMetadataDocumentSupported ||= + metadata.client_id_metadata_document_supported === true; + for (const candidate of authServerMetadataUrls(metadata)) { + if (!visited.has(candidate.metadataUrl)) + metadataQueue.push(candidate.metadataUrl); + } + } + + return { + galleryKey: app.slug, + methodKey: method.key, + serverUrl, + endpointReachable: endpointResponse.status < 500, + oauth: { + metadataFound, + registrationAdvertised, + clientIdMetadataDocumentSupported, + }, + attempts, + checkedAt: now().toISOString(), + }; + } + + return { oauthEndpointsForConnection, discoverOAuthEndpoints, preflightConnectionMetadata }; +} diff --git a/server/src/services/tool-access/oauth/endpoint-policy.ts b/server/src/services/tool-access/oauth/endpoint-policy.ts new file mode 100644 index 00000000000..95fb52099c8 --- /dev/null +++ b/server/src/services/tool-access/oauth/endpoint-policy.ts @@ -0,0 +1,111 @@ +import { checkOAuthEndpointUrl, oauthEndpointUrlRejectionMessage, type OAuthEndpointKind, type OAuthEndpointUrlRejection } from "@paperclipai/shared"; +import { HttpError } from "../../../errors.js"; +import { originOf, isSmokeLabOAuthUrl } from "./metadata.js"; + +export function createOAuthEndpointPolicy(allowPrivateRemoteEndpoints: () => boolean) { + /** + * OAuth endpoint scheme/transport gate (PAP-17099). + * + * Every OAuth endpoint Paperclip acts on is attacker-influenced: discovered + * metadata, a `WWW-Authenticate` hint, a pasted config, or a gallery default. + * The authorization endpoint is the sharpest one because it is handed to the + * operator's browser as a top-level navigation, so `javascript:`/`data:` there + * would run in the board's origin. `checkOAuthEndpointUrl` is the single place + * that decides; loopback `http:` is accepted only under the same + * local-development policy that governs private remote endpoints, and + * Paperclip's own origin is exempt from the transport rule because a + * first-party endpoint (the smoke-lab fixture) is served exactly as the board + * itself is. + */ + function oauthEndpointRejected( + kind: OAuthEndpointKind, + reason: OAuthEndpointUrlRejection, + ): HttpError { + return new HttpError(422, oauthEndpointUrlRejectionMessage(kind, reason), { + code: `oauth_${kind}_endpoint_rejected`, + reason, + }); + } + + /** + * Origins that are Paperclip itself: this deployment's configured public URL, + * plus the callback origin of the request in hand when there is one. Only the + * plaintext-transport rule is relaxed for these. + */ + function firstPartyOrigins(candidate?: string | null): string[] { + const configured = + process.env.PAPERCLIP_PUBLIC_URL?.trim() || + process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim() || + process.env.BETTER_AUTH_URL?.trim() || + process.env.BETTER_AUTH_BASE_URL?.trim() || + null; + return [originOf(candidate), originOf(configured)].filter( + (origin): origin is string => Boolean(origin), + ); + } + + /** + * Origins for which the plaintext-transport rule is relaxed when checking + * `value`. Adds the smoke-lab fixture's own origin, because that provider is + * mounted on this deployment's own routes — `assertNotSmokeLabOAuthEndpoints` + * is what stops any other connection from claiming those paths — and a smoke + * run may be driven against a deployment served over plaintext HTTP. + */ + function insecureTransportExemptions( + value: unknown, + candidate?: string | null, + ): string[] { + const origins = firstPartyOrigins(candidate); + if (typeof value === "string" && isSmokeLabOAuthUrl(value)) { + const origin = originOf(value); + if (origin) origins.push(origin); + } + return origins; + } + + /** Throws unless `value` is an endpoint Paperclip may use (and navigate to). */ + function assertOAuthEndpointUrl( + kind: OAuthEndpointKind, + value: unknown, + options: { firstPartyOrigin?: string | null } = {}, + ): string { + const check = checkOAuthEndpointUrl(value, { + allowInsecureLoopback: allowPrivateRemoteEndpoints(), + allowInsecureOrigins: insecureTransportExemptions( + value, + options.firstPartyOrigin, + ), + }); + if (!check.ok) throw oauthEndpointRejected(kind, check.reason); + return check.url; + } + + /** + * Discovery variant: an unusable endpoint is dropped rather than thrown, so a + * second advertised authorization server (or a later metadata candidate) still + * gets a chance. Rejections are recorded in `rejections`; discovery raises the + * first one only if it ends up with nothing safe to use, so the operator sees + * *why* instead of a bare "does not advertise OAuth sign in". + */ + function safeOAuthEndpointUrl( + kind: OAuthEndpointKind, + value: unknown, + rejections: HttpError[], + firstPartyOrigin?: string | null, + ): string | null { + if (value === null || value === undefined || value === "") return null; + const check = checkOAuthEndpointUrl(value, { + allowInsecureLoopback: allowPrivateRemoteEndpoints(), + allowInsecureOrigins: insecureTransportExemptions( + value, + firstPartyOrigin, + ), + }); + if (check.ok) return check.url; + if (check.reason !== "missing") + rejections.push(oauthEndpointRejected(kind, check.reason)); + return null; + } + + return { assertOAuthEndpointUrl, safeOAuthEndpointUrl }; +} diff --git a/server/src/services/tool-access/oauth/metadata.ts b/server/src/services/tool-access/oauth/metadata.ts new file mode 100644 index 00000000000..c112f78eca5 --- /dev/null +++ b/server/src/services/tool-access/oauth/metadata.ts @@ -0,0 +1,311 @@ +import type { OAuthConnectionSettings } from "./types.js"; +import { remoteEndpoint } from "../connection-methods.js"; + +export function asRecord(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) + return value as Record; + return {}; +} + +/** + * RFC 8414 §3.1 requires the well-known path to be *inserted between* the + * issuer host and its path component, but a large amount of deployed software + * only serves the naive suffix form. OpenID Connect Discovery 1.0 in turn + * specifies the suffix form. Try all of them for an issuer that has a path, and + * the plain origin form when it doesn't. + */ +export function wellKnownMetadataUrls(issuer: string): string[] { + let parsed: URL; + try { + parsed = new URL(issuer); + } catch { + return []; + } + const suffixes = ["oauth-authorization-server", "openid-configuration"]; + const path = parsed.pathname.replace(/\/+$/, ""); + const urls: string[] = []; + for (const suffix of suffixes) { + if (path) { + // RFC 8414: https://host/.well-known/ + urls.push( + new URL(`/.well-known/${suffix}${path}`, parsed.origin).toString(), + ); + // OIDC Discovery / widely deployed: https://host/.well-known/ + urls.push( + new URL(`${path}/.well-known/${suffix}`, parsed.origin).toString(), + ); + } + urls.push(new URL(`/.well-known/${suffix}`, parsed.origin).toString()); + } + return [...new Set(urls)]; +} + +/** + * Protected-resource metadata lives at `/.well-known/oauth-protected-resource` + * with the resource's path appended (RFC 9728 §3.1). Probe the path-aware form + * first so a multi-tenant host that serves several MCP servers resolves to the + * right one, then fall back to the origin form. + */ +export function protectedResourceMetadataUrls(endpoint: URL): string[] { + const path = endpoint.pathname.replace(/\/+$/, ""); + const urls: string[] = []; + if (path) + urls.push( + new URL( + `/.well-known/oauth-protected-resource${path}`, + endpoint.origin, + ).toString(), + ); + urls.push( + new URL( + "/.well-known/oauth-protected-resource", + endpoint.origin, + ).toString(), + ); + return [...new Set(urls)]; +} + +/** + * Canonical RFC 8707 resource indicator for an MCP endpoint: origin + path, with + * query, fragment and any trailing slash removed. Vendor query parameters belong + * to the connection config, not to the token audience. + */ +export function canonicalResourceIndicator(endpoint: string): string | null { + try { + const parsed = new URL(endpoint); + const path = parsed.pathname.replace(/\/+$/, ""); + return `${parsed.origin}${path}`; + } catch { + return null; + } +} + +/** + * Two issuers are the same authorization server only when scheme, host, port and + * path match exactly (a trailing slash is not significant). Used for `iss` + * validation on the callback and for detecting that a stored registration is + * bound to a different server than the one we just discovered. + */ +export function sameOAuthIssuer( + a: string | null | undefined, + b: string | null | undefined, +): boolean { + if (!a || !b) return false; + try { + const left = new URL(a); + const right = new URL(b); + return ( + left.protocol === right.protocol && + left.host === right.host && + left.pathname.replace(/\/+$/, "") === right.pathname.replace(/\/+$/, "") + ); + } catch { + return false; + } +} + +export function originOf(value: string | null | undefined): string | null { + if (!value) return null; + try { + return new URL(value).origin; + } catch { + return null; + } +} + +export function oauthConfig(connection: Pick) { + const oauth = asRecord(connection.config).oauth + ? asRecord(asRecord(connection.config).oauth) + : {}; + const { + access_token: _accessToken, + refresh_token: _refreshToken, + accessToken: _camelAccessToken, + refreshToken: _camelRefreshToken, + ...metadata + } = oauth; + return metadata; +} + +export function normalizeOauthScopes(value: unknown): string[] { + if (Array.isArray(value)) + return value.filter( + (item): item is string => + typeof item === "string" && item.trim().length > 0, + ); + if (typeof value === "string") + return value + .split(/\s+/) + .map((item) => item.trim()) + .filter(Boolean); + return []; +} + +/** + * Decide what a token grant actually carries, keeping the provider's assertion separate from + * our own request. + * + * RFC 6749 §5.1 permits a provider to omit `scope` only when the grant is identical to the + * request, so reading the request as the grant is the specified fallback. A provider that + * over-grants *and* omits `scope` breaks that contract, and recording the request unmarked + * turns what we asked for into a confident-looking record of what we got. `scopeSource` keeps + * the two readable apart so nothing downstream can mistake an inference for an assertion. + * + * `unrequestedScopes` is only meaningful when we asked for something specific; with no + * requested scopes there is no baseline to compare against, so it stays empty rather than + * flagging every scope on connections that keep their scopes in provider-side app config. + * + * `previous` is the provenance already recorded for this grant. A refresh response that omits + * `scope` asserts nothing at all — least of all that a permission was taken away — so the + * earlier record is carried forward verbatim instead of being overwritten with a clean-looking + * inference. Only a fresh provider assertion replaces a recorded over-grant. + */ +export function resolveGrantedOauthScopes(input: { + tokenScope: unknown; + requestedScopes: unknown; + previous?: { + scopes?: unknown; + scopeSource?: unknown; + unrequestedScopes?: unknown; + }; +}): { + scopes: string[]; + scopeSource: "provider" | "requested_fallback"; + unrequestedScopes: string[]; +} { + const requested = normalizeOauthScopes(input.requestedScopes); + const asserted = + input.tokenScope === undefined || input.tokenScope === null + ? [] + : normalizeOauthScopes(input.tokenScope); + if (asserted.length === 0) { + const previousScopes = normalizeOauthScopes(input.previous?.scopes); + const previousUnrequested = normalizeOauthScopes( + input.previous?.unrequestedScopes, + ); + if (previousScopes.length > 0) { + return { + scopes: previousScopes, + scopeSource: + input.previous?.scopeSource === "provider" + ? "provider" + : "requested_fallback", + unrequestedScopes: previousUnrequested, + }; + } + return { + scopes: requested, + scopeSource: "requested_fallback", + unrequestedScopes: previousUnrequested, + }; + } + return { + scopes: asserted, + scopeSource: "provider", + unrequestedScopes: + requested.length === 0 + ? [] + : asserted.filter((scope) => !requested.includes(scope)), + }; +} + +export function isSmokeLabOAuthFixture( + connection: Pick, +) { + const config = asRecord(connection.config); + const oauth = oauthConfig(connection); + return ( + config.smokeLabFixture === "oauth-http" && oauth.smokeLabFixture === true + ); +} + +export function isSmokeLabOAuthUrl(value: string | null | undefined) { + if (!value) return false; + try { + return /\/smoke-lab\/oauth(?:\/|$)/.test(new URL(value).pathname); + } catch { + return false; + } +} + +export function oauthProviderForConnection( + connection: Pick, + metadataUrl?: string | null, +): string { + const oauth = oauthConfig(connection); + if (typeof oauth.provider === "string" && oauth.provider.trim()) + return oauth.provider.trim(); + const sourceTemplateKey = + typeof connection.config.sourceTemplateKey === "string" + ? connection.config.sourceTemplateKey.trim() + : ""; + if (sourceTemplateKey) return sourceTemplateKey; + const url = metadataUrl ?? remoteEndpoint(connection.config); + try { + return ( + new URL(url).hostname + .replace(/[^a-z0-9]+/gi, "_") + .replace(/^_+|_+$/g, "") + .toLowerCase() || "generic" + ); + } catch { + return "generic"; + } +} + +export function parseWwwAuthenticateParams(value: string): Record { + const params: Record = {}; + const input = value.replace(/^\s*Bearer\s+/i, ""); + const re = /([a-zA-Z_][a-zA-Z0-9_-]*)=(?:"([^"]*)"|([^,\s]+))/g; + let match: RegExpExecArray | null; + while ((match = re.exec(input))) { + params[match[1]!.toLowerCase()] = match[2] ?? match[3] ?? ""; + } + return params; +} + +export function challengeOAuthHints(wwwAuthenticate: string) { + const params = parseWwwAuthenticateParams(wwwAuthenticate); + return { + metadataUrl: + params.resource_metadata ?? + params.resource_metadata_url ?? + params.metadata_url ?? + null, + authorizationUrl: + params.authorization_uri ?? params.authorization_url ?? null, + tokenUrl: params.token_uri ?? params.token_url ?? null, + scope: params.scope ?? null, + }; +} + +/** + * Candidate authorization-server metadata URLs advertised by protected-resource + * metadata, paired with the issuer that advertised them so the caller can bind + * the resulting client material to a canonical issuer. + */ +export function authServerMetadataUrls( + metadata: Record, +): Array<{ issuer: string; metadataUrl: string }> { + const candidates: Array<{ issuer: string; metadataUrl: string }> = []; + const issuers: string[] = []; + if (Array.isArray(metadata.authorization_servers)) { + for (const server of metadata.authorization_servers) { + if (typeof server === "string" && server.trim()) + issuers.push(server.trim()); + } + } + if (typeof metadata.issuer === "string" && metadata.issuer.trim()) + issuers.push(metadata.issuer.trim()); + for (const issuer of [...new Set(issuers)]) { + for (const metadataUrl of wellKnownMetadataUrls(issuer)) { + candidates.push({ issuer, metadataUrl }); + } + } + const seen = new Set(); + return candidates.filter((candidate) => { + if (seen.has(candidate.metadataUrl)) return false; + seen.add(candidate.metadataUrl); + return true; + }); +} diff --git a/server/src/services/tool-access/oauth/provider-errors.ts b/server/src/services/tool-access/oauth/provider-errors.ts new file mode 100644 index 00000000000..fdeb822059f --- /dev/null +++ b/server/src/services/tool-access/oauth/provider-errors.ts @@ -0,0 +1,126 @@ + + +/** + * Upstream OAuth error redaction (PAP-17108). + * + * A generic remote MCP connection points at an arbitrary authorization server, + * so everything that server says about a failure is attacker-chosen: `error`, + * `error_description`, `error_uri`, and the response body. Paperclip surfaces + * connection failures to the operator through API responses, board UI copy, + * audit rows and logs, so reflecting any of that text would let a hostile + * provider plant secrets, ANSI escapes, or instructions ("paste your recovery + * key here") into Paperclip's own voice. + * + * The rule is therefore: the operator only ever reads text Paperclip authored. + * The provider's `error` code survives — as a *label* in structured `details`, + * never in a message — and only when it is one of the codes the RFCs define, + * because a label is still untrusted input. Everything else is dropped, and an + * unrecognized code collapses to `unrecognized` rather than being echoed. + * + * `error_description` and the response body are never read at all: no call site + * below parses them, which is what keeps a future edit from quietly + * reintroducing the reflection. + */ +const OAUTH_PROVIDER_ERROR_CODES = new Set([ + // RFC 6749 §4.1.2.1 — authorization endpoint (the callback-denial path). + "access_denied", + "invalid_request", + "invalid_scope", + "server_error", + "temporarily_unavailable", + "unauthorized_client", + "unsupported_response_type", + // RFC 6749 §5.2 — token endpoint (authorization-code and refresh exchanges). + "invalid_client", + "invalid_grant", + "unsupported_grant_type", + // RFC 7591 §3.2.2 — dynamic client registration. + "invalid_client_metadata", + "invalid_redirect_uri", + "invalid_software_statement", + "unapproved_software_statement", + // OpenID Connect Core §3.1.2.6 — interactive re-authentication prompts. + "account_selection_required", + "consent_required", + "interaction_required", + "login_required", +]); + +/** What an `error` that is absent, malformed, or off the allowlist becomes. */ +const UNRECOGNIZED_OAUTH_PROVIDER_ERROR = "unrecognized"; + +const MAX_OAUTH_PROVIDER_ERROR_LENGTH = 64; + +const OAUTH_PROVIDER_ERROR_PATTERN = /^[a-z0-9_-]+$/; + +/** + * Stable, Paperclip-authored operator copy for each allowlisted provider error. + * Deliberately keyed on the code alone: the calling context is already carried + * by the Paperclip `code` in `details`, so one table serves the callback, + * token-exchange and registration paths without any of them composing a message + * out of provider text. + */ +const OAUTH_PROVIDER_ERROR_MESSAGES: Record = { + access_denied: "The authorization server denied the request.", + account_selection_required: + "The authorization server needs an account to be selected. Try connecting again.", + consent_required: + "The authorization server needs consent to be granted. Try connecting again.", + interaction_required: + "The authorization server needs to be signed in to interactively. Try connecting again.", + invalid_client: "The authorization server rejected Paperclip's OAuth client.", + invalid_client_metadata: + "The authorization server rejected Paperclip's client registration details.", + invalid_grant: + "The authorization server rejected the authorization code or refresh token.", + invalid_redirect_uri: + "The authorization server rejected Paperclip's callback URL.", + invalid_request: + "The authorization server rejected the request as malformed.", + invalid_scope: "The authorization server rejected the requested permissions.", + invalid_software_statement: + "The authorization server rejected Paperclip's client registration details.", + login_required: + "The authorization server needs to be signed in to. Try connecting again.", + server_error: + "The authorization server reported an internal error. Try again shortly.", + temporarily_unavailable: + "The authorization server is temporarily unavailable. Try again shortly.", + unapproved_software_statement: + "The authorization server rejected Paperclip's client registration details.", + unauthorized_client: + "The authorization server refused to authorize Paperclip's OAuth client.", + unsupported_grant_type: + "The authorization server does not support the grant Paperclip uses.", + unsupported_response_type: + "The authorization server does not support the sign-in flow Paperclip uses.", +}; + +/** + * Reduce a provider-supplied `error` to a bounded, allowlisted label safe to + * keep in structured `details`. Returns `null` only when the provider sent no + * `error` at all, so the caller can tell "silent failure" from "said something + * Paperclip does not recognize". + */ +export function normalizeOAuthProviderError(value: unknown): string | null { + if (typeof value !== "string" || value.length === 0) return null; + // Bound length and character class before the allowlist even though + // membership implies both: these limits are what keeps the label safe if the + // allowlist above ever grows a pattern-matched entry. + if (value.length > MAX_OAUTH_PROVIDER_ERROR_LENGTH) + return UNRECOGNIZED_OAUTH_PROVIDER_ERROR; + if (!OAUTH_PROVIDER_ERROR_PATTERN.test(value)) + return UNRECOGNIZED_OAUTH_PROVIDER_ERROR; + return OAUTH_PROVIDER_ERROR_CODES.has(value) + ? value + : UNRECOGNIZED_OAUTH_PROVIDER_ERROR; +} + +/** Paperclip's own message for a provider failure, never the provider's. */ +export function oauthProviderErrorMessage( + providerError: string | null, + fallback: string, +): string { + if (!providerError) return fallback; + return OAUTH_PROVIDER_ERROR_MESSAGES[providerError] ?? fallback; +} diff --git a/server/src/services/tool-access/oauth/types.ts b/server/src/services/tool-access/oauth/types.ts new file mode 100644 index 00000000000..c9782e07160 --- /dev/null +++ b/server/src/services/tool-access/oauth/types.ts @@ -0,0 +1,53 @@ +import type { ToolConnectionOwnership, ToolConnectionTransport, OAuthEndpointKind } from "@paperclipai/shared"; + +export type OAuthProviderEndpoints = { + provider: string; + scopes: string[]; + /** Refresh permission belongs to authorization-server metadata, not MCP tool scopes. */ + offlineAccessSupported?: boolean; + authorizationUrl: string; + tokenUrl: string; + registrationUrl?: string | null; + codeChallengeMethodsSupported?: string[]; + tokenEndpointAuthMethodsSupported?: string[]; + grantTypesSupported?: string[]; + grantType?: "authorization_code" | "client_credentials"; + metadataUrl?: string | null; + /** + * Canonical authorization-server issuer, when discovery found one. Registered + * client material is bound to it, `iss` on the callback is validated against + * it, and reconnect/refresh reuse it instead of re-deriving a provider key. + */ + issuer?: string | null; + /** + * RFC 8707 resource indicator: the MCP endpoint the token is for. Sent on both + * authorization and token requests so the authorization server can audience- + * restrict the access token to this server rather than to everything Paperclip + * has ever connected. + */ + resource?: string | null; + /** The authorization server advertised support for Client ID Metadata Documents. */ + clientIdMetadataDocumentSupported?: boolean; +}; + +/** + * Where an OAuth client came from, in the preference order the current MCP + * client-registration guidance recommends (PAP-17087). + */ +export type OAuthClientRegistrationSource = + "preconfigured" | "cimd" | "dcr" | "manual"; + +export type OAuthTokenEndpointAuthMethod = + "none" | "client_secret_basic" | "client_secret_post"; + +export type OAuthConnectionSettings = { + config: Record; + companyId: string; + transport: ToolConnectionTransport; + ownership: ToolConnectionOwnership; +}; + +export type OAuthTransport = { + fetchRemoteHttpUrl: (value: string, init?: RequestInit) => Promise; + assertOAuthEndpointUrl: (kind: OAuthEndpointKind, value: unknown, options?: { firstPartyOrigin?: string | null }) => string; +};