mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
## 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>
583 lines
22 KiB
TypeScript
583 lines
22 KiB
TypeScript
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 };
|
|
}
|