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

@@ -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()));
}
});
});
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;
};