feat(server): make remote MCP OAuth transport-generic and spec-current

An unknown standards-compliant MCP endpoint could discover an OAuth challenge
but never complete one: `ensureOAuthClient` only reached registration when a
gallery `AppDefinition` method listed `ownershipModes: ["dcr"]`, so a URL-only
connection died on "OAuth client id is not configured".

Replace that gate with the registration preference the current MCP
client-registration guidance recommends — a client the deployment preconfigured
for the issuer, then a Client ID Metadata Document, then dynamic registration,
then client credentials the operator preregistered — and allow a generic
connection to register only after protected-resource/authorization-server
discovery actually produced a metadata document. Client material is bound to
issuer, MCP resource, callback URI and company, and re-registers when any of
those move; a manually supplied client refuses to auto-re-register and asks the
operator instead.

Bring the broker in line with current guidance along the way: RFC 8707
`resource` on authorization, token and refresh requests; RFC 8414 and OIDC
path-aware `.well-known` variants for pathful issuers, with the issuer in the
document validated against the one used to build the URL; RFC 9207 `iss`
validated on the callback when returned; `application_type: web` on DCR; and
`authKind`/issuer/resource persisted so refresh, reconnect, revoke and
diagnostics stop treating a generic connection as `authKind: none`.

Header names and values from a pasted config are re-checked here, at the last
point before they become real request headers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5 committed 2026-08-20 21:20:01 +00:00
1 parent ca0f7bc2eb
commit a98c363a92
4 files changed
+657 -60

No files matched your search

+5
View File
@@ -952,6 +952,11 @@ export interface ToolOAuthStartResult {
provider: string;
authorizationUrl: string;
expiresAt: string;
/** Canonical authorization-server issuer this run is bound to, when discovered. */
issuer?: string | null;
/** RFC 8707 resource indicator sent with the request. */
resource?: string | null;
registrationSource?: ToolOAuthClientRegistrationSource | null;
}
export interface FinishToolAppResult {
@@ -3469,6 +3469,10 @@ describeEmbeddedPostgres("tool access service", () => {
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
// PAP-17087: Paperclip's callback is a server-side HTTPS endpoint, so
// registration must declare a `web` client rather than let the
// authorization server apply native-client redirect rules.
application_type: "web",
}]);
fetchMock.mockClear();
+43 -6
View File
@@ -42,9 +42,13 @@ import {
} from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { getActorInfo, assertBoard, assertCompanyAccess, getAccessibleResource, hasCompanyAccess } from "./authz.js";
import { badRequest, forbidden, unprocessable } from "../errors.js";
import { badRequest, forbidden, HttpError, notFound, unprocessable } from "../errors.js";
import { accessService, googleSheetsRobotEmailFromEnv, logActivity, toolAccessPolicyService, toolAccessService } from "../services/index.js";
import { ToolGatewayHttpError, type ToolGatewayService } from "../services/tool-gateway.js";
import {
OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH,
oauthClientIdMetadataDocument,
} from "../services/tool-access.js";
/** Allowlist (e.g. Google Sheets allowed spreadsheet ids) lives in connection config. */
function allowlistIds(config: Record<string, unknown> | null | undefined): string[] {
@@ -252,17 +256,48 @@ export function toolAccessRoutes(
});
});
/**
* Paperclip's Client ID Metadata Document (PAP-17087).
*
* The document's own URL is the `client_id` Paperclip presents to an
* authorization server that supports CIMD, so this endpoint has to be publicly
* readable — an authorization server fetches it server-to-server with no
* Paperclip session. It contains only this deployment's callback and the
* grant/response/auth methods Paperclip uses: no company, connection or secret
* data of any kind.
*/
router.get(OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH.replace(/^\/api/, ""), (_req, res) => {
const redirectUri = oauthRedirectUri();
const clientId = new URL(OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH, new URL(redirectUri).origin).toString();
res.type("application/json").json(oauthClientIdMetadataDocument({ clientId, redirectUri }));
});
router.post("/companies/:companyId/tools/apps/connect", validate(connectToolAppSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertToolAppMutationAccess(req, companyId);
try {
const result = await svc.connectGalleryApp(companyId, req.body, getActorInfo(req));
if (result.auth?.kind === "oauth") {
const start = await svc.startOAuth(companyId, result.connectionId, {
redirectUri: oauthRedirectUri(),
actor: getActorInfo(req),
});
result.auth.startUrl = start.authorizationUrl;
try {
const start = await svc.startOAuth(companyId, result.connectionId, {
redirectUri: oauthRedirectUri(),
actor: getActorInfo(req),
});
result.auth.startUrl = start.authorizationUrl;
result.auth.issuer = start.issuer ?? result.auth.issuer ?? null;
result.auth.resource = start.resource ?? result.auth.resource ?? null;
result.auth.registrationSource = start.registrationSource ?? null;
} catch (error) {
// An unknown server whose authorization server supports neither CIMD
// nor dynamic registration is not a failed connect: the draft
// connection is real and usable as soon as the operator supplies a
// client they registered themselves. Report that instead of a 4xx so
// the wizard can ask for it rather than losing the draft.
const code = error instanceof HttpError ? String((error.details as { code?: unknown })?.code ?? "") : "";
if (code !== "oauth_manual_client_required" && code !== "oauth_manual_client_rebinding_required") throw error;
result.auth.startUrl = null;
result.auth.manualClientRequired = true;
}
}
await logActivity(db, {
companyId,
@@ -324,6 +359,7 @@ export function toolAccessRoutes(
const code = typeof req.query.code === "string" ? req.query.code : null;
const error = typeof req.query.error === "string" ? req.query.error : null;
const errorDescription = typeof req.query.error_description === "string" ? req.query.error_description : null;
const iss = typeof req.query.iss === "string" ? req.query.iss : null;
const pendingState = state ? await svc.peekOAuthState(state) : null;
if (!pendingState || !hasCompanyAccess(req, pendingState.companyId)) {
throw badRequest("Invalid or expired OAuth state");
@@ -334,6 +370,7 @@ export function toolAccessRoutes(
code,
error,
errorDescription,
iss,
redirectUri: oauthRedirectUri(),
actor: getActorInfo(req),
});
+605 -54
View File
@@ -66,6 +66,7 @@ import type {
ToolConnectionInstallSnapshot,
ToolConnectionHealthCheckResult,
ToolConnectionHealthStatus,
ToolConnectionAuthKind,
ToolConnectionTransport,
ToolOAuthStartResult,
ToolAppsAttentionResponse,
@@ -109,6 +110,12 @@ import type {
UnbindToolProfileBinding,
} from "@paperclipai/shared";
import { CLASS3_STATIC_LEASE_ALLOWLIST, credentialConfigPath, getAvailableConnectionMethod, getAvailableConnectionMethods, getConnectableAppDefinition, isToolConnectionAttentionHealth, recommendedDefaultsForApp } from "@paperclipai/shared";
import {
checkMcpRemoteHeaderName,
checkMcpRemoteHeaderValue,
mcpRemoteHeaderNameFromConfigPath,
mcpRemoteHeaderRejectionMessage,
} from "@paperclipai/shared";
import { badRequest, conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js";
import { logActivity } from "./activity-log.js";
import { mcpHttpRequestHeaders, parseMcpHttpResponseBody } from "./mcp-http.js";
@@ -135,6 +142,35 @@ const OAUTH_REFRESH_LEASE_MS = 120_000;
const OAUTH_REFRESH_LEASE_WAIT_MS = 30_000;
const OAUTH_REFRESH_LEASE_POLL_MS = 25;
/**
* 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";
/**
* 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<string, unknown> {
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[];
@@ -145,8 +181,106 @@ type OAuthProviderEndpoints = {
tokenEndpointAuthMethodsSupported?: 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/<suffix><path>
urls.push(new URL(`/.well-known/${suffix}${path}`, parsed.origin).toString());
// OIDC Discovery / widely deployed: https://host<path>/.well-known/<suffix>
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<string, Promise<unknown>>();
async function oauthSingleFlight<T>(
@@ -2979,12 +3113,23 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
codeChallengeMethodsSupported: endpoints.codeChallengeMethodsSupported ?? [],
tokenEndpointAuthMethodsSupported: endpoints.tokenEndpointAuthMethodsSupported ?? [],
grantType: endpoints.grantType ?? "authorization_code",
issuer: endpoints.issuer ?? null,
resource: endpoints.resource ?? null,
clientIdMetadataDocumentSupported: endpoints.clientIdMetadataDocumentSupported === true,
discoveredAt: new Date().toISOString(),
},
};
await db
.update(toolConnections)
.set({ config: nextConfig, transportConfig: nextConfig, updatedAt: new Date() })
.set({
// Record the discovered auth kind now, so a URL-only connection that
// is waiting on sign-in reads as OAuth everywhere rather than
// falling back to `authKind: none` semantics.
authKind: "oauth",
config: nextConfig,
transportConfig: nextConfig,
updatedAt: new Date(),
})
.where(eq(toolConnections.id, connection.id));
}
throw new HttpError(502, "This app needs you to sign in.", {
@@ -3761,8 +3906,26 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
}
for (const configPath of Object.keys(credentialValues).sort()) {
if (!configPath.startsWith("headers.")) continue;
const headerName = configPath.slice("headers.".length).trim();
if (!headerName) continue;
const headerName = mcpRemoteHeaderNameFromConfigPath(configPath);
if (!headerName) throw badRequest("Header names cannot be blank.", { code: "mcp_header_rejected" });
// The API schema already rejected unsafe headers, but this is the last
// point before a name becomes a real outbound request header — and the
// normalized paste-config path lands here too — so re-check rather than
// trust the caller.
const nameCheck = checkMcpRemoteHeaderName(headerName);
if (!nameCheck.ok) {
throw badRequest(mcpRemoteHeaderRejectionMessage(headerName, nameCheck.reason!), {
code: "mcp_header_rejected",
headerName,
});
}
const valueCheck = checkMcpRemoteHeaderValue(credentialValues[configPath] ?? "");
if (!valueCheck.ok) {
throw badRequest(mcpRemoteHeaderRejectionMessage(headerName, valueCheck.reason!), {
code: "mcp_header_rejected",
headerName,
});
}
fields.push({
label: headerName,
configPath,
@@ -3839,6 +4002,30 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
return oauthClientConfig(provider);
}
/**
* The URL of Paperclip's published Client ID Metadata Document, derived from the
* OAuth callback so the two are guaranteed same-origin.
*
* CIMD requires a `client_id` the authorization server can fetch, so it is only
* available when this deployment has a public HTTPS base URL. Loopback and
* plain-http development fall through to DCR or manual client credentials.
*/
function clientIdMetadataDocumentUrl(redirectUri: string): string | null {
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;
return new URL(OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH, parsed.origin).toString();
} catch {
return null;
}
}
async function oauthClientForConnection(
connection: typeof toolConnections.$inferSelect,
provider: string,
@@ -3851,7 +4038,11 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
? oauth.clientId.trim()
: null;
if (!clientId) return configured;
const clientSecretRef = oauth.clientRegistrationSource === "dcr"
// Only a client the operator preregistered can have a secret worth sending.
// DCR and CIMD clients are public (`token_endpoint_auth_method: none`), so
// resolving a secret for them would leak a credential onto the wire.
const registrationSource = oauth.clientRegistrationSource;
const clientSecretRef = registrationSource === "dcr" || registrationSource === "cimd"
? undefined
: connection.credentialSecretRefs.find((ref) => ref.configPath === "oauth.client_secret");
const clientSecret = clientSecretRef
@@ -4012,27 +4203,31 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
}
}
async function authServerMetadataUrls(metadata: Record<string, unknown>): Promise<string[]> {
const urls: string[] = [];
/**
* 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<string, unknown>): 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()) {
try {
urls.push(new URL("/.well-known/oauth-authorization-server", server).toString());
} catch {
// Ignore malformed advertised issuers. The caller will fail if no usable endpoints remain.
}
}
if (typeof server === "string" && server.trim()) issuers.push(server.trim());
}
}
if (typeof metadata.issuer === "string" && metadata.issuer.trim()) {
try {
urls.push(new URL("/.well-known/oauth-authorization-server", metadata.issuer).toString());
} catch {
// Ignore malformed advertised issuers.
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 });
}
}
return [...new Set(urls)];
const seen = new Set<string>();
return candidates.filter((candidate) => {
if (seen.has(candidate.metadataUrl)) return false;
seen.add(candidate.metadataUrl);
return true;
});
}
async function endpointsFromMetadataUrl(
@@ -4047,12 +4242,35 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
let scopes = normalizeOauthScopes(metadata.scopes_supported);
let codeChallengeMethodsSupported = normalizeOauthScopes(metadata.code_challenge_methods_supported);
let tokenEndpointAuthMethodsSupported = normalizeOauthScopes(metadata.token_endpoint_auth_methods_supported);
for (const authMetadataUrl of await authServerMetadataUrls(metadata)) {
const authMetadata = await fetchJsonRecord(authMetadataUrl);
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;
authorizationUrl = authorizationUrl ?? (typeof authMetadata.authorization_endpoint === "string" ? authMetadata.authorization_endpoint : null);
tokenUrl = tokenUrl ?? (typeof authMetadata.token_endpoint === "string" ? authMetadata.token_endpoint : null);
const candidateAuthorizationUrl = typeof authMetadata.authorization_endpoint === "string"
? authMetadata.authorization_endpoint
: null;
const candidateTokenUrl = typeof authMetadata.token_endpoint === "string" ? authMetadata.token_endpoint : null;
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;
authorizationUrl = authorizationUrl ?? candidateAuthorizationUrl;
tokenUrl = tokenUrl ?? candidateTokenUrl;
registrationUrl = registrationUrl ?? (typeof authMetadata.registration_endpoint === "string" ? authMetadata.registration_endpoint : null);
issuer = issuer ?? advertisedIssuer ?? candidate.issuer;
if (scopes.length === 0) scopes = normalizeOauthScopes(authMetadata.scopes_supported);
if (codeChallengeMethodsSupported.length === 0) {
codeChallengeMethodsSupported = normalizeOauthScopes(authMetadata.code_challenge_methods_supported);
@@ -4060,6 +4278,9 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
if (tokenEndpointAuthMethodsSupported.length === 0) {
tokenEndpointAuthMethodsSupported = normalizeOauthScopes(authMetadata.token_endpoint_auth_methods_supported);
}
if (!clientIdMetadataDocumentSupported) {
clientIdMetadataDocumentSupported = authMetadata.client_id_metadata_document_supported === true;
}
if (authorizationUrl && tokenUrl) break;
}
if (!authorizationUrl || !tokenUrl) return null;
@@ -4072,6 +4293,9 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
codeChallengeMethodsSupported,
tokenEndpointAuthMethodsSupported,
metadataUrl,
issuer,
resource,
clientIdMetadataDocumentSupported,
};
}
@@ -4093,6 +4317,11 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
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) {
return {
provider,
@@ -4104,6 +4333,9 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
tokenEndpointAuthMethodsSupported: normalizeOauthScopes(oauth.tokenEndpointAuthMethodsSupported),
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,
};
}
@@ -4113,17 +4345,21 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
].filter((value): value is string => Boolean(value));
if (metadataCandidates.length === 0) {
const endpoint = new URL(await assertRemoteEndpointAllowed(connection.config));
const protectedResourcePath = endpoint.pathname === "/"
? "/.well-known/oauth-protected-resource"
: `/.well-known/oauth-protected-resource${endpoint.pathname}`;
metadataCandidates.push(new URL(protectedResourcePath, endpoint.origin).toString());
metadataCandidates.push(new URL("/.well-known/oauth-protected-resource", endpoint.origin).toString());
metadataCandidates.push(new URL("/.well-known/oauth-authorization-server", endpoint.origin).toString());
metadataCandidates.push(new URL("/.well-known/openid-configuration", endpoint.origin).toString());
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);
if (endpoints) return { ...endpoints, scopes: scopes.length > 0 ? scopes : endpoints.scopes, grantType };
if (endpoints) {
return {
...endpoints,
scopes: scopes.length > 0 ? scopes : endpoints.scopes,
grantType,
resource: endpoints.resource ?? configuredResource,
};
}
}
return null;
}
@@ -4238,6 +4474,33 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
);
}
/**
* Validate RFC 9207 `iss` on the authorization callback.
*
* When an authorization server returns `iss`, it must name the same issuer the
* authorization request was bound to. A mismatch means the code came back from a
* different server than the one we sent the user to — the mix-up attack RFC 9207
* exists to stop — so the code is refused rather than exchanged. An absent `iss`
* is tolerated: it is optional, and many deployed servers omit it.
*/
function assertOAuthCallbackIssuer(
connection: typeof toolConnections.$inferSelect,
endpoints: OAuthProviderEndpoints,
iss: string | null | undefined,
) {
const returnedIssuer = typeof iss === "string" ? iss.trim() : "";
if (!returnedIssuer) return;
const oauth = oauthConfig(connection);
const expectedIssuer = typeof oauth.expectedIssuer === "string" && oauth.expectedIssuer.trim()
? oauth.expectedIssuer.trim()
: endpoints.issuer ?? null;
if (!expectedIssuer) return;
if (sameOAuthIssuer(returnedIssuer, expectedIssuer)) return;
throw badRequest("Sign-in came back from an unexpected server. Start the connection again.", {
code: "oauth_issuer_mismatch",
});
}
function invalidOAuthDcrResponse(field: string, reason: string): HttpError {
return new HttpError(502, "OAuth provider returned incompatible dynamic client metadata", {
code: "oauth_dcr_response_invalid",
@@ -4330,6 +4593,11 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
// 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(input.endpoints.registrationUrl, {
method: "POST",
@@ -4400,10 +4668,19 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
scopes: input.endpoints.scopes,
codeChallengeMethodsSupported: input.endpoints.codeChallengeMethodsSupported ?? [],
tokenEndpointAuthMethodsSupported: input.endpoints.tokenEndpointAuthMethodsSupported ?? [],
issuer: input.endpoints.issuer ?? oauth.issuer ?? null,
resource: input.endpoints.resource ?? oauth.resource ?? null,
clientId,
clientRegistrationSource: "dcr",
clientRegistrationSource: "dcr" satisfies OAuthClientRegistrationSource,
clientTokenEndpointAuthMethod: "none",
clientRedirectUri: input.redirectUri,
// Registered client material is only valid for the issuer/resource pair
// it was minted against. `assertOAuthClientBinding` re-registers when any
// of these move, so a re-pointed endpoint can never silently reuse a
// client another authorization server issued.
clientIssuer: input.endpoints.issuer ?? null,
clientResource: input.endpoints.resource ?? null,
clientCompanyId: input.connection.companyId,
clientIdIssuedAt,
clientSecretExpiresAt,
},
@@ -4427,6 +4704,144 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
return updated;
}
/**
* Adopt a Client ID Metadata Document as this connection's client: the
* `client_id` *is* the https URL of Paperclip's published client metadata, so
* there is nothing to register with the authorization server. Still recorded on
* the connection so the issuer/resource/callback binding is enforced on reuse
* exactly like a dynamically registered client.
*/
async function adoptClientIdMetadataDocument(input: {
connection: typeof toolConnections.$inferSelect;
endpoints: OAuthProviderEndpoints;
redirectUri: string;
clientId: string;
}) {
const oauth = oauthConfig(input.connection);
const nextConfig = {
...input.connection.config,
oauth: {
...oauth,
provider: input.endpoints.provider,
authorizationUrl: input.endpoints.authorizationUrl,
tokenUrl: input.endpoints.tokenUrl,
registrationUrl: input.endpoints.registrationUrl ?? null,
metadataUrl: input.endpoints.metadataUrl ?? null,
scopes: input.endpoints.scopes,
codeChallengeMethodsSupported: input.endpoints.codeChallengeMethodsSupported ?? [],
tokenEndpointAuthMethodsSupported: input.endpoints.tokenEndpointAuthMethodsSupported ?? [],
clientIdMetadataDocumentSupported: true,
issuer: input.endpoints.issuer ?? oauth.issuer ?? null,
resource: input.endpoints.resource ?? oauth.resource ?? null,
clientId: input.clientId,
clientRegistrationSource: "cimd" satisfies OAuthClientRegistrationSource,
clientTokenEndpointAuthMethod: "none",
clientRedirectUri: input.redirectUri,
clientIssuer: input.endpoints.issuer ?? null,
clientResource: input.endpoints.resource ?? null,
clientCompanyId: input.connection.companyId,
},
};
// A CIMD client has no secret. Drop any leftover one so a stale credential
// from an earlier registration can never be replayed against a new issuer.
const nextCredentialSecretRefs = input.connection.credentialSecretRefs.filter(
(ref) => ref.configPath !== "oauth.client_secret",
);
const [updated] = await db
.update(toolConnections)
.set({
authKind: "oauth",
config: nextConfig,
transportConfig: nextConfig,
credentialSecretRefs: nextCredentialSecretRefs,
updatedAt: now(),
})
.where(and(
eq(toolConnections.id, input.connection.id),
eq(toolConnections.companyId, input.connection.companyId),
))
.returning();
if (!updated) throw notFound("Tool connection not found");
await syncCredentialBindings(updated);
return updated;
}
/**
* How the client already stored on this connection was obtained. Anything
* unrecognised (including connections written before this field existed) reads
* as `manual`, which is the conservative answer: Paperclip will not silently
* re-register over client material it cannot prove it minted.
*/
function storedOAuthClientRegistrationSource(
connection: typeof toolConnections.$inferSelect,
): OAuthClientRegistrationSource {
const source = oauthConfig(connection).clientRegistrationSource;
return source === "cimd" || source === "dcr" || source === "preconfigured" ? source : "manual";
}
/**
* Is the client material already stored on this connection still valid for the
* authorization server, MCP resource, callback URI and company we are about to
* use it with? Client credentials are minted against exactly one such tuple;
* reusing them across a moved binding would let a re-pointed endpoint borrow
* another server's registration.
*/
function oauthClientBindingMatches(
connection: typeof toolConnections.$inferSelect,
endpoints: OAuthProviderEndpoints,
redirectUri: string,
): boolean {
const oauth = oauthConfig(connection);
if (typeof oauth.clientId !== "string" || !oauth.clientId.trim()) return false;
const source = typeof oauth.clientRegistrationSource === "string" ? oauth.clientRegistrationSource : null;
// A manually preregistered client was registered by the operator against
// Paperclip's callback, so it has no recorded callback until first use.
const redirectMatches = source === "manual"
? oauth.clientRedirectUri === undefined
|| oauth.clientRedirectUri === null
|| oauth.clientRedirectUri === redirectUri
: oauth.clientRedirectUri === redirectUri;
if (!redirectMatches) return false;
if (typeof oauth.clientCompanyId === "string" && oauth.clientCompanyId !== connection.companyId) return false;
if (
endpoints.issuer
&& typeof oauth.clientIssuer === "string"
&& oauth.clientIssuer
&& !sameOAuthIssuer(oauth.clientIssuer, endpoints.issuer)
) {
return false;
}
if (
endpoints.resource
&& typeof oauth.clientResource === "string"
&& oauth.clientResource
&& oauth.clientResource !== endpoints.resource
) {
return false;
}
return true;
}
/**
* May Paperclip mint client material for this connection without an operator
* pasting client credentials?
*
* A curated app opts in through its `ownershipModes`. A generic remote MCP
* connection may register once — and only once — protected-resource and
* authorization-server discovery actually produced a metadata document; an
* endpoint that merely returned a 401 does not earn a registration.
*/
function canRegisterOAuthClientDynamically(
connection: typeof toolConnections.$inferSelect,
endpoints: OAuthProviderEndpoints,
galleryEntry: AppDefinition | null,
): boolean {
if (galleryEntry) {
return connectionMethodForConnection(galleryEntry, connection).ownershipModes.includes("dcr");
}
return connection.transport === "mcp_remote" && Boolean(endpoints.metadataUrl);
}
async function ensureOAuthClient(input: {
connection: typeof toolConnections.$inferSelect;
endpoints: OAuthProviderEndpoints;
@@ -4434,40 +4849,77 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
galleryEntry: AppDefinition | null;
actor?: ActorInfo;
}) {
// 1. A client the deployment preconfigured for this issuer wins outright.
const configured = configuredOAuthClientForConnection(input.connection, input.endpoints.provider);
if (configured.clientId) return { connection: input.connection, client: configured };
const oauth = oauthConfig(input.connection);
if (
typeof oauth.clientId === "string"
&& oauth.clientId.trim()
&& oauth.clientRedirectUri === input.redirectUri
) {
if (configured.clientId) {
return { connection: input.connection, client: configured, source: "preconfigured" as const };
}
// 2. Client material already bound to this issuer/resource/callback/company.
if (oauthClientBindingMatches(input.connection, input.endpoints, input.redirectUri)) {
return {
connection: input.connection,
client: await oauthClientForConnection(input.connection, input.endpoints.provider, input.actor),
source: storedOAuthClientRegistrationSource(input.connection),
};
}
const method = input.galleryEntry ? connectionMethodForConnection(input.galleryEntry, input.connection) : null;
if (!method?.ownershipModes.includes("dcr")) {
throw unprocessable(`OAuth client id is not configured for ${input.endpoints.provider}`);
const oauth = oauthConfig(input.connection);
if (
oauth.clientRegistrationSource === "manual"
&& typeof oauth.clientId === "string"
&& oauth.clientId.trim()
) {
// Paperclip cannot re-register on the operator's behalf: the credentials
// came from a console this deployment does not control.
throw unprocessable(
"This connection's sign-in details no longer match the server it points at. Re-enter the client ID and secret to continue.",
{ code: "oauth_manual_client_rebinding_required" },
);
}
if (!canRegisterOAuthClientDynamically(input.connection, input.endpoints, input.galleryEntry)) {
throw unprocessable(`OAuth client id is not configured for ${input.endpoints.provider}`, {
code: "oauth_client_registration_unavailable",
});
}
const key = `${input.connection.id}:${input.redirectUri}`;
return oauthSingleFlight(oauthRegistrationFlights, key, async () => {
const latest = await getConnectionRow(input.connection.id, input.connection.companyId);
const latestConfigured = configuredOAuthClientForConnection(latest, input.endpoints.provider);
if (latestConfigured.clientId) return { connection: latest, client: latestConfigured };
const latestOauth = oauthConfig(latest);
if (
typeof latestOauth.clientId === "string"
&& latestOauth.clientId.trim()
&& latestOauth.clientRedirectUri === input.redirectUri
) {
if (latestConfigured.clientId) {
return { connection: latest, client: latestConfigured, source: "preconfigured" as const };
}
if (oauthClientBindingMatches(latest, input.endpoints, input.redirectUri)) {
return {
connection: latest,
client: await oauthClientForConnection(latest, input.endpoints.provider, input.actor),
source: storedOAuthClientRegistrationSource(latest),
};
}
// 3. Client ID Metadata Documents: no registration call at all, so prefer
// them over DCR when the authorization server advertises support.
const metadataDocumentUrl = input.endpoints.clientIdMetadataDocumentSupported
? clientIdMetadataDocumentUrl(input.redirectUri)
: null;
if (metadataDocumentUrl) {
const adopted = await adoptClientIdMetadataDocument({
connection: latest,
endpoints: input.endpoints,
redirectUri: input.redirectUri,
clientId: metadataDocumentUrl,
});
return {
connection: adopted,
client: await oauthClientForConnection(adopted, input.endpoints.provider, input.actor),
source: "cimd" as const,
};
}
// 4. Dynamic client registration.
if (!input.endpoints.registrationUrl) {
throw unprocessable(
"This server needs sign-in details you create yourself. Add a client ID and secret under Advanced authentication.",
{ code: "oauth_manual_client_required" },
);
}
const registered = await registerOAuthClient({
connection: latest,
endpoints: input.endpoints,
@@ -4477,6 +4929,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
return {
connection: registered,
client: await oauthClientForConnection(registered, input.endpoints.provider, input.actor),
source: "dcr" as const,
};
});
}
@@ -4491,6 +4944,8 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
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") {
@@ -4507,6 +4962,10 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
}
body.set("client_id", input.clientId);
if (input.clientSecret) 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);
const response = await fetchRemoteHttpUrl(input.tokenUrl, {
method: "POST",
@@ -4756,6 +5215,9 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
grantType,
scopes: normalizeOauthScopes(oauth.scopes).length > 0 ? normalizeOauthScopes(oauth.scopes) : normalizeOauthScopes(oauth.scope),
refreshToken,
// Refreshing must stay bound to the same MCP server the original grant
// named, or the authorization server may widen the token's audience.
resource: typeof oauth.resource === "string" && oauth.resource ? oauth.resource : null,
});
} catch (error) {
if (error instanceof HttpError && asRecord(error.details).code === "oauth_reauthorization_required") {
@@ -4945,7 +5407,18 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
quarantineNewEntries: true,
...(galleryEntry.slug === "posthog" ? { safeDefault: true } : {}),
}
: { ...baseConfig, quarantineNewEntries: true };
: { ...baseConfig, quarantineNewEntries: true, unverifiedServer: true };
// A pasted URL may arrive with a client the operator preregistered in the
// provider's own console, because that authorization server supports neither
// CIMD nor dynamic registration. Record the client id now; the secret becomes
// a Paperclip secret ref alongside the other credentials below.
if (!galleryEntry && input.oauthClient) {
config.oauth = {
clientId: input.oauthClient.clientId.trim(),
clientRegistrationSource: "manual" satisfies OAuthClientRegistrationSource,
clientCompanyId: companyId,
};
}
if (galleryEntry?.slug === GOOGLE_SHEETS_GALLERY_KEY) {
const availability = googleSheetsRobotEmailFromEnv();
if (!availability.available) {
@@ -4968,6 +5441,20 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
assertLocalStdioCanBeEnabled(transport, false);
const credentialValues = input.credentialValues ?? {};
// A curated method declares its auth kind. A pasted URL declares one only
// under Advanced authentication; on the simple path it starts from what the
// operator supplied and is upgraded to `oauth` when discovery proves the
// endpoint needs sign-in (see `remoteTools` and `startOAuth`).
const genericAuthKind: ToolConnectionAuthKind = method?.auth
?? (input.authMode === "oauth" || input.oauthClient
? "oauth"
: input.authMode === "bearer" || input.authMode === "custom_headers"
? "api_key"
: input.authMode === "none"
? "none"
: Object.keys(credentialValues).length > 0
? "api_key"
: "none");
const credentialSecretRefs: CreateToolConnection["credentialSecretRefs"] = [];
const credentialRefs: McpConnectionCredentialRef[] = [];
const createdSecretIds: string[] = [];
@@ -5010,6 +5497,27 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
}
}
// A preregistered OAuth client secret is not a request header — it is only
// ever sent to the token endpoint — so it gets a secret ref with no
// credential ref, keeping it out of `projectedConnectionHeaders`.
if (!galleryEntry && input.oauthClient?.clientSecret) {
const secret = await secrets.create(companyId, {
name: `${name} OAuth client secret ${randomUUID().slice(0, 8)}`,
key: `tool_app.${randomUUID()}.oauth_client_secret`,
provider: "local_encrypted",
value: input.oauthClient.clientSecret,
description: `OAuth client secret for ${name}.`,
}, actorForSecret(actor));
createdSecretIds.push(secret.id);
credentialSecretRefs.push({
secretId: secret.id,
versionSelector: "latest",
configPath: "oauth.client_secret",
required: false,
label: "OAuth client secret",
});
}
if (existingApplication) {
if (existingApplication.status !== "active") {
[applicationRow] = await db.update(toolApplications)
@@ -5051,7 +5559,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
if (revivedConnectionPrevious) {
[connectionRow] = await db.update(toolConnections).set({
name,
authKind: method?.auth ?? "none",
authKind: genericAuthKind,
transport,
status: "draft",
enabled: false,
@@ -5070,7 +5578,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
name,
uid: connectionUid(applicationRow.applicationKey ?? applicationRow.name, name, connectionId),
connectionKind: "managed",
authKind: method?.auth ?? "none",
authKind: genericAuthKind,
transport,
status: "draft",
enabled: false,
@@ -5114,7 +5622,18 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
access: "all_agents",
askFirstRiskLevels: ["write", "destructive"],
},
auth: { kind: "oauth", startUrl: null },
// The endpoint asked for authorization and discovery found a real
// authorization server, so the wizard can offer "Sign in to
// continue" instead of a dead end. The caller starts the flow and
// fills in `startUrl`/`registrationSource`; it only needs the issuer
// and resource here to show which server the operator is about to
// trust.
auth: {
kind: "oauth",
startUrl: null,
issuer: endpoints.issuer ?? null,
resource: endpoints.resource ?? null,
},
};
}
throw error;
@@ -5619,6 +6138,9 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
authorizationUrl.searchParams.set("state", state);
authorizationUrl.searchParams.set("code_challenge", base64UrlSha256(codeVerifier));
authorizationUrl.searchParams.set("code_challenge_method", "S256");
// RFC 8707: name the MCP server the resulting token is for, so an
// authorization server that serves several resources can audience-restrict it.
if (endpoints.resource) authorizationUrl.searchParams.set("resource", endpoints.resource);
const authorizationScopes = input.scopes ?? endpoints.scopes;
if (authorizationScopes.length > 0) authorizationUrl.searchParams.set("scope", authorizationScopes.join(" "));
@@ -5685,11 +6207,27 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
clientIdEnv: client.clientIdEnv,
clientSecretEnv: client.clientSecret ? client.clientSecretEnv : null,
credentialScope: credentialScope(connection, input.actor),
// Persist the issuer and resource this authorization run is bound to.
// `iss` on the callback is validated against `expectedIssuer`, and
// refresh/reconnect/revoke reuse the same pair rather than re-deriving it.
issuer: endpoints.issuer ?? oauthConfig(connection).issuer ?? null,
expectedIssuer: endpoints.issuer ?? null,
resource: endpoints.resource ?? oauthConfig(connection).resource ?? null,
clientIdMetadataDocumentSupported: endpoints.clientIdMetadataDocumentSupported === true,
},
};
await db
.update(toolConnections)
.set({ config: nextConfig, transportConfig: nextConfig, updatedAt: new Date() })
.set({
// A generic URL connection starts life as `authKind: none`. Once it has
// completed OAuth discovery and client resolution it is an OAuth
// connection, and refresh, reconnect, revoke and diagnostics must all
// treat it as one.
authKind: "oauth",
config: nextConfig,
transportConfig: nextConfig,
updatedAt: new Date(),
})
.where(eq(toolConnections.id, connection.id));
return {
@@ -5697,6 +6235,9 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
provider: endpoints.provider,
authorizationUrl: authorizationUrl.toString(),
expiresAt: expiresAt.toISOString(),
issuer: endpoints.issuer ?? null,
resource: endpoints.resource ?? null,
registrationSource: resolvedClient.source,
};
}
@@ -5715,6 +6256,8 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
error?: string | null;
errorDescription?: string | null;
redirectUri: string;
/** RFC 9207 `iss`, when the authorization server returns it. */
iss?: string | null;
actor?: ActorInfo;
}): Promise<ConnectToolAppResult> {
if (input.error) throw badRequest(input.errorDescription ?? `OAuth provider returned ${input.error}`);
@@ -5740,6 +6283,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
const galleryEntry = sourceTemplateKey ? getConnectableAppDefinition(sourceTemplateKey) : null;
assertOAuthRedirectConstraints(galleryEntry, input.redirectUri);
const endpoints = await oauthEndpointsForConnection(connection, null, input.redirectUri);
assertOAuthCallbackIssuer(connection, endpoints, input.iss);
const client = await oauthClientForConnection(connection, endpoints.provider, input.actor);
if (!client.clientId) throw unprocessable(`OAuth client id is not configured for ${endpoints.provider}`);
@@ -5750,6 +6294,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
redirectUri: input.redirectUri,
codeVerifier: stateRow.codeVerifier,
code: input.code,
resource: endpoints.resource,
});
const [existingUserGrant] = stateRow.subjectUserId
? await db.select().from(connectionGrants).where(and(
@@ -5854,6 +6399,11 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
clientIdEnv: client.clientIdEnv,
clientSecretEnv: client.clientSecret ? client.clientSecretEnv : null,
credentialScope: credentialScope(connection, input.actor),
// Keep the issuer and resource this grant was minted against so refresh,
// reconnect, revoke and diagnostics resolve the same authorization server
// instead of re-discovering one from a possibly-changed endpoint.
issuer: endpoints.issuer ?? oauthConfig(connection).issuer ?? null,
resource: endpoints.resource ?? oauthConfig(connection).resource ?? null,
expiresAt,
scope: token.scope,
tokenType: token.tokenType,
@@ -5869,6 +6419,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
.set({
status: "active",
enabled: true,
authKind: "oauth",
config: nextConfig,
transportConfig: nextConfig,
credentialSecretRefs: nextCredentialSecretRefs,