refactor(tools): isolate OAuth protocol boundaries (#17147)

## Thinking Path

> - Paperclip manages AI agents and their access to external tools.
> - Tool connections use OAuth to obtain credentials from remote
providers.
> - The tool-access service mixed provider protocol parsing with
database and secret writes.
> - This made protocol changes hard to review without reading the full
coordinator.
> - This PR gives discovery, endpoint policy, and client exchange
explicit owners.
> - The coordinator keeps authorization, leases, persistence, and audit
decisions.
> - A real local provider fixture checks the boundary through public
routes.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The ownership of OAuth discovery, registration, token exchange, and
client metadata.

**Current behavior**

The tool-access service contains over 20,000 lines. Provider responses,
endpoint policy, and stateful operations share the same closure.

**Proposed behavior**

Small protocol modules accept settings and named request capabilities.
They return normalized results. The service owns durable state and
existing public exports. This is part 3 of the stack: #17144, then
#17146, then this PR. It targets #17146's branch. Review only this PR's
diff.

## What Changed

- Extract endpoint policy, metadata parsing/discovery,
registration/token exchange, provider error normalization, and the
public client metadata document.
- Validate remote registration responses in the protocol module. Return
client material to the coordinator for secret and connection writes.
- Remove the unused raw provider response from the internal token
result. Keep every consumed field.
- Preserve process-wide registration single-flight, service-local maps,
refresh leases, membership checks, callback checks, and transaction
ownership.
- Reuse connection-method URL normalization. Keep the endpoint setting
and clock callbacks live.
- Add a real local HTTP OAuth/MCP route fixture for public and
Basic-authenticated clients.
- Reduce `tool-access.ts` from 20,917 lines / 808,777 bytes to 19,293
lines / 749,158 bytes. The seven OAuth modules total 1,697 lines; the
largest has 582 lines. The stack removes 2,232 lines from the original
service.
- Record the next profile-resolution and token-broker boundaries in the
dated plan.

## Verification

- Baseline: 466 service cases and 80 protocol/provider-policy cases
pass. The new two-case HTTP fixture passes before moving production
code.
- After: `pnpm exec vitest run
server/src/__tests__/tool-access-oauth-protocol.integration.test.ts
server/src/__tests__/tool-access-service.test.ts
server/src/__tests__/remote-mcp-connectors.test.ts
server/src/__tests__/remote-http-rebinding.test.ts
server/src/__tests__/oauth-client-metadata.test.ts` — 508 tests pass
with one worker and disposable PostgreSQL.
- The fixture drives connect, metadata discovery, DCR, PKCE code
exchange, callback, catalog review, and finish. It checks persisted
active state, secret references, issuer/auth-method binding, and
wire-level authentication.
- `pnpm -r --workspace-concurrency=2 typecheck` passes. The sandbox-only
attempt could not open a local IPC socket; the same command passed with
local process permissions.
- `node scripts/check-module-boundaries.mjs` and `git diff --check`
pass.
- AST comparison confirms 37 moved bodies are unchanged. Registration
protocol and persistence statements match their original halves. Token
exchange only drops its unused raw result. Preflight uses explicit
defaults and a clock callback.
- Initial fixture authoring used a nonexistent catalog enabled field;
the corrected assertion checks persisted status and reviewer identity.
An initial typecheck found optional defaults at the new boundary; the
caller now passes its already-validated defaults.
- Final-stack local verification: `pnpm -r --workspace-concurrency=2
typecheck` and `pnpm build` pass. Remote CI is green at
`3dc46c132408556ecb31038a9b9621ac20aacefc`. Greptile is 5/5 with no
actionable findings or open inline threads.

- Full local-suite limitation: `pnpm test:run` was interrupted after 79
minutes during the serial general-server phase, after CI had completed
the full test matrix. Before interruption, it flagged the unchanged
`company-search-service.test.ts` suite (26 database cases skipped). One
isolated rerun passed all 28 cases without code changes. The original
log and a pre-interruption copy are preserved. The original failure
cause was not reproduced. The full local suite and its later groups are
not claimed as passed; the complete CI matrix is green on this stack.

## Risks

- OAuth endpoint checks and issuer/resource binding are security
boundaries. Existing denial, redirect, scope, DCR, secret-redaction, and
refresh tests cover the preserved paths.
- A moved closure could capture stale policy or split
registration/refresh ownership. The new modules hold no mutable state.
Policy is read through the existing callback, and state stays in the
service.
- The local fixture proves real HTTP and database behavior. It does not
claim a live third-party provider sign-in.
- No schema, API, authorization policy, or persisted-data migration
changes are intended.

## Model Used

OpenAI Codex, GPT-6 family, with code execution and repository tools.
The exact serving model ID and context-window size are not exposed in
this session.

## Checklist


- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-21 22:21:41 -05:00
1 parent 8e95caf9ae
commit 4413bb262f
11 files changed
+1920 -1644

No files matched your search

@@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
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<void>((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<void>((resolve, reject) => provider.close((error) => error ? reject(error) : resolve()));
}
});
});
File diff suppressed because it is too large. Load diff
@@ -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, unknown>): string {
const value = config.url ?? config.endpoint ?? config.remoteUrl;
const parsed = parseRemoteHttpEndpoint(value, (message, code) =>
badRequest(message, { code }),
);
return parsed.toString();
}
@@ -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<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;
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<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",
};
}
@@ -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<string, unknown>,
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<string, unknown>,
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<string, unknown>,
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<string, unknown>,
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<string, string> = {
"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 };
}
@@ -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<ReturnType<typeof createOAuthEndpointPolicy>, "safeOAuthEndpointUrl"> & {
assertRemoteEndpointAllowed: (config: Record<string, unknown>) => Promise<string>;
};
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<Record<string, unknown> | 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<OAuthProviderEndpoints | null> {
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<OAuthProviderEndpoints | null> {
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<OAuthProviderEndpoints> {
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<OAuthProviderEndpoints> {
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<AppDefinition, "slug">,
method: Pick<ConnectionMethodDef, "auth" | "key">,
defaults: NonNullable<ConnectionMethodDef["defaults"]>,
serverUrl: string,
now: () => Date,
): Promise<ToolAppMetadataPreflightResult> {
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<string>();
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<string, unknown>;
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 };
}
@@ -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 };
}
@@ -0,0 +1,311 @@
import type { OAuthConnectionSettings } from "./types.js";
import { remoteEndpoint } from "../connection-methods.js";
export function asRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value))
return value as Record<string, unknown>;
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/<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.
*/
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<OAuthConnectionSettings, "config">) {
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<OAuthConnectionSettings, "config">,
) {
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<OAuthConnectionSettings, "config">,
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<string, string> {
const params: Record<string, string> = {};
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<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())
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<string>();
return candidates.filter((candidate) => {
if (seen.has(candidate.metadataUrl)) return false;
seen.add(candidate.metadataUrl);
return true;
});
}
@@ -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<string, string> = {
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;
}
@@ -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<string, unknown>;
companyId: string;
transport: ToolConnectionTransport;
ownership: ToolConnectionOwnership;
};
export type OAuthTransport = {
fetchRemoteHttpUrl: (value: string, init?: RequestInit) => Promise<Response>;
assertOAuthEndpointUrl: (kind: OAuthEndpointKind, value: unknown, options?: { firstPartyOrigin?: string | null }) => string;
};