mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
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:
1 parent
8e95caf9ae
commit
4413bb262f
11 files changed
+1920
-1644
No files matched your search
@@ -61,3 +61,50 @@ coverage pass before and after; the combined post-move run passes 506 tests.
|
||||
Server TypeScript checking passes. The first typecheck exposed that the public
|
||||
ToolConnection DTO allows absent config; the module now declares the required
|
||||
validated settings shape directly instead of adding internal fallback checks.
|
||||
|
||||
## Third extraction
|
||||
|
||||
OAuth protocol handling now has explicit owners for endpoint policy, metadata
|
||||
interpretation and discovery, client registration/token exchange, provider error
|
||||
normalization, and the public client metadata document. Each owner accepts
|
||||
settings or named request capabilities. None imports the database or the parent
|
||||
service. Connection URL normalization stays with connection-method settings.
|
||||
|
||||
Registration returns validated client material before the coordinator stores it.
|
||||
Token exchange returns normalized token fields without the unused raw provider
|
||||
payload. The coordinator retains registration single-flight state, refresh
|
||||
leases, current membership and grant checks, secret writes, audit records,
|
||||
transaction ownership, and callback state validation. Endpoint policy reads the
|
||||
existing live private-endpoint setting through a callback. It does not snapshot
|
||||
options or create another cache.
|
||||
|
||||
Thirty-seven moved function bodies match after syntax normalization. Registration
|
||||
request/validation and persistence statements match their original halves.
|
||||
Preflight takes explicit defaults and a clock callback. Remaining coordinator
|
||||
function bodies match except for these deliberate delegation points.
|
||||
|
||||
The original service becomes 19,293 lines / 749,158 bytes, a reduction of 2,232
|
||||
lines across the stack. The seven OAuth modules total 1,697 lines; the largest
|
||||
has 582 lines. Connection-methods grows to 368 lines with URL normalization.
|
||||
|
||||
A new route integration fixture uses a real local HTTP OAuth/MCP server and a
|
||||
disposable PostgreSQL database. It covers public and Basic-authenticated clients,
|
||||
discovery, registration, PKCE token exchange, callback completion, secret storage,
|
||||
catalog review, and connection activation. Both cases passed before and after
|
||||
the move. The combined post-move protocol/service run passed 508 tests.
|
||||
The first fixture run asserted a nonexistent catalog enabled field; the assertion
|
||||
now checks persisted active status and reviewer identity. An initial typecheck
|
||||
exposed optional method defaults across the extraction boundary; the coordinator
|
||||
passes the already-validated defaults explicitly. No runtime fallback was added.
|
||||
Repository-wide typechecking and module-boundary checks pass. The PR records
|
||||
the full test and build results for the batch handoff.
|
||||
|
||||
## Next ownership cuts
|
||||
|
||||
The service still coordinates grant lifecycle, profile resolution, token
|
||||
brokering, and app setup. Profile resolution/review is the next candidate because
|
||||
it can own scoped queries and projection without importing OAuth protocol code.
|
||||
Token brokering follows after its authorization, issuance audit, and locking
|
||||
contract is mapped. The large setup and callback functions should shrink through
|
||||
those ownership cuts rather than by distributing their local variables into a
|
||||
shared service context.
|
||||
@@ -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()));
|
||||
}
|
||||
});
|
||||
});
|
||||
+20
-1644
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;
|
||||
};
|
||||
Reference in new issue
Block a user