import { safeReturnPath } from "./oauth-return-path"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { parseIntegrationsOauthClientsJson, type Settings } from "@opengeni/config"; import { OAuthStartResponse, selectCanonicalPersonalSlackConnection, type ConnectionOwnership, type OAuthStartRequest, } from "@opengeni/contracts"; import { requireEnvironmentEncryption } from "@opengeni/core"; import { ExternalActorContinuation } from "@opengeni/contracts/external-identities"; import type { Observability } from "@opengeni/observability"; import { consumeIntegrationOAuthStateNonce, claimConnectOperation, finishConnectOperation, getConnectAttempt, createConnection, decryptEnvironmentValue, encryptEnvironmentValue, getConnectionMetadata, getGlobalCatalogOAuthProfile, listConnectionsMetadata, loadIntegrationOAuthClient, normalizeBearerScheme, replaceIntegrationOAuthClientIfCurrent, storeIntegrationOAuthClient, updateConnection, withDatabaseStatementTimeout, type Database, } from "@opengeni/db"; import { createSignedState, readSignedState } from "@opengeni/github"; import { DestinationPolicyError, McpOAuthDiscoveryError, OAUTH_MAX_RESPONSE_BYTES, RequestDeadlineError, isLocalTestEnvironment, parseMcpOAuthChallenge, pinnedFetch, readResponseJsonBounded, resolveMcpOAuthDiscovery, validateHttpUrl, type McpAuthorizationServerMetadata, type McpOAuthChallenge, type McpOAuthDiscoveryMode, type McpOAuthMetadataFetchResult, type McpProtectedResourceMetadata, } from "@opengeni/network"; import { Buffer } from "node:buffer"; import { createHash, randomBytes } from "node:crypto"; import { HTTPException } from "hono/http-exception"; import { assertConnectionOwnershipAllowedForPrincipal, personalOwnerStateAccepted, personalOwnerVerifiedInState, PERSONAL_CONNECTION_PRINCIPAL_MESSAGE, PERSONAL_OWNER_VERIFIED_STATE_CLAIM, } from "../connection-ownership"; import { ApiHttpError } from "../http/api-error"; import { requireConnectOwnerAuthority } from "./connect-authority"; import { DEFAULT_OAUTH_PROFILE, DEPLOYMENT_MANAGED_CLIENTS, builtInOAuthProfileByKey, assertAuthorizationServerNotReserved, assertAuthorizationServerPins, assertOwnershipAllowed, builtInOAuthProfileFor, catalogMcpUrlKey, defaultOwnershipFor, deploymentManagedClientFor, oauthProfileFromCatalog, type OAuthProviderProfile, type PinnableAuthorizationServer, } from "./oauth-profiles"; import { canonicalProviderDomain } from "./provider-domain"; export const oauthStateTtlMs = 10 * 60 * 1000; export { OFFICIAL_GMAIL_MCP_SCOPES, OFFICIAL_GMAIL_MCP_URL, OFFICIAL_SLACK_MCP_URL, } from "./oauth-profiles"; export { OAUTH_MAX_RESPONSE_BYTES } from "@opengeni/network"; type OAuthClientDeps = { db: Database; settings: Settings; observability?: Observability | undefined; oauthStartDeadlineMs?: number | undefined; oauthCallbackDeadlineMs?: number | undefined; }; export type OAuthStartContext = { connectAttemptId?: string; externalContinuation?: ExternalActorContinuation; accountId: string; workspaceId: string; subjectId: string; /** * False for every principal that cannot own a personal Connection (API keys, * the configured key, services, agent attempts). Resolved by the route from * the live authenticated principal, never inferred here. */ personalOwnershipAllowed: boolean; requestUrl: string; payload: OAuthStartRequest; }; export type OAuthCallbackResult = { redirectTo: string; exactReturn?: boolean; }; type WwwAuthenticateChallenge = McpOAuthChallenge; type ProtectedResourceMetadata = McpProtectedResourceMetadata; type AuthorizationServerMetadata = McpAuthorizationServerMetadata; type OAuthClientRegistration = { method: "operator" | "manual" | "cimd" | "dcr"; issuer: string; authorizationServer: string; clientId: string; clientSecret?: string; tokenEndpointAuthMethod: "none" | "client_secret_post" | "client_secret_basic"; }; type OAuthStatePayload = { connectAttemptId?: string; externalContinuation?: ExternalActorContinuation; returnUrl?: string; accountId: string; workspaceId: string; subjectId: string; ownership: ConnectionOwnership; /** Signed proof that a live managed human authorized personal ownership. */ personalOwnerVerified: boolean; providerDomain: string; mcpUrl: string; resource: string; requestedScopes: string[]; authorizeScopes: string[]; encryptedPkceVerifier: string; clientId: string; tokenEndpoint: string; authorizationServer: string; issuer: string; discoveryMode: McpOAuthDiscoveryMode; discoveryMetadataSha256?: string; protectedResourceMetadataUrl?: string; authorizationServerMetadataUrl?: string; clientRegistrationMethod: OAuthClientRegistration["method"]; tokenEndpointAuthMethod: OAuthClientRegistration["tokenEndpointAuthMethod"]; resourceParameterSupported: boolean; encryptedClientSecret?: string; returnPath: string; connectionId?: string; connectionVersion?: number; nonce: string; iat: number; }; type TokenResponse = { accessToken: string; refreshToken?: string; tokenType: string; expiresAt: Date | null; scopeText?: string; raw: Record; }; type OAuthCallbackStage = | "state_verify" | "client_lookup" | "token_exchange" | "tools_list" | "persist"; export const OAUTH_START_DEADLINE_MS = 15_000; export const OAUTH_CALLBACK_DEADLINE_MS = 30_000; const OAUTH_CALLBACK_DB_STATEMENT_TIMEOUT_MS = 5_000; export type OAuthStartStage = | "connection_lookup" | "mcp_challenge" | "protected_resource_metadata" | "authorization_server_metadata" | "client_registration"; class OAuthStartStageError extends Error { constructor( readonly stage: OAuthStartStage, readonly reason: string, readonly cause: unknown, ) { super(errorMessage(cause)); this.name = "OAuthStartStageError"; } } class OAuthMetadataUpstreamError extends Error { constructor(readonly upstreamStatus: number) { super(`OAuth metadata endpoint returned HTTP ${upstreamStatus}`); this.name = "OAuthMetadataUpstreamError"; } } class OAuthStartDeadline { readonly signal: AbortSignal; private readonly controller = new AbortController(); private readonly timer: ReturnType; constructor(timeoutMs: number) { this.signal = this.controller.signal; this.timer = setTimeout(() => this.controller.abort(), timeoutMs); this.timer.unref?.(); } async run(stage: OAuthStartStage, operation: (signal: AbortSignal) => Promise): Promise { if (this.signal.aborted) { throw new OAuthStartStageError(stage, "timeout", new RequestDeadlineError(stage)); } let removeAbortListener = () => {}; const aborted = new Promise((_resolve, reject) => { const onAbort = () => reject(new OAuthStartStageError(stage, "timeout", new RequestDeadlineError(stage))); this.signal.addEventListener("abort", onAbort, { once: true }); removeAbortListener = () => this.signal.removeEventListener("abort", onAbort); }); try { return await Promise.race([operation(this.signal), aborted]); } catch (error) { if (error instanceof OAuthStartStageError) throw error; if (this.signal.aborted || error instanceof RequestDeadlineError) { throw new OAuthStartStageError(stage, "timeout", error); } throw new OAuthStartStageError(stage, oauthStartFailureReason(error), error); } finally { removeAbortListener(); } } dispose(): void { clearTimeout(this.timer); } } class OAuthCallbackStageError extends Error { constructor( readonly stage: OAuthCallbackStage, readonly reason: string, readonly cause: unknown, ) { super(errorMessage(cause)); this.name = "OAuthCallbackStageError"; } } class OAuthCallbackDeadline { readonly signal: AbortSignal; private readonly controller = new AbortController(); private readonly timer: ReturnType; private readonly expiresAt: number; constructor(timeoutMs: number) { this.signal = this.controller.signal; this.expiresAt = Date.now() + timeoutMs; this.timer = setTimeout(() => this.controller.abort(), timeoutMs); this.timer.unref?.(); } remainingMs(): number { return Math.max(1, this.expiresAt - Date.now()); } async run( stage: OAuthCallbackStage, operation: (signal: AbortSignal) => Promise, ): Promise { if (this.signal.aborted) { throw new OAuthCallbackStageError(stage, "timeout", new RequestDeadlineError(stage)); } let removeAbortListener = () => {}; const aborted = new Promise((_resolve, reject) => { const onAbort = () => reject(new OAuthCallbackStageError(stage, "timeout", new RequestDeadlineError(stage))); this.signal.addEventListener("abort", onAbort, { once: true }); removeAbortListener = () => this.signal.removeEventListener("abort", onAbort); }); try { return await Promise.race([operation(this.signal), aborted]); } catch (error) { if (error instanceof OAuthCallbackStageError) throw error; if ( this.signal.aborted || error instanceof RequestDeadlineError || isDatabaseStatementTimeout(error) ) { throw new OAuthCallbackStageError(stage, "timeout", error); } throw new OAuthCallbackStageError(stage, oauthCallbackFailureReason(stage, error), error); } finally { removeAbortListener(); } } dispose(): void { clearTimeout(this.timer); } } export async function startMcpOAuth( deps: OAuthClientDeps, context: OAuthStartContext, ): Promise { const deadline = new OAuthStartDeadline(deps.oauthStartDeadlineMs ?? OAUTH_START_DEADLINE_MS); try { return await startMcpOAuthWithinDeadline(deps, context, deadline); } catch (error) { const staged = error instanceof OAuthStartStageError ? error : new OAuthStartStageError("connection_lookup", oauthStartFailureReason(error), error); logOAuthStartFailure(deps.observability, staged); throw oauthStartApiError(staged); } finally { deadline.dispose(); } } async function startMcpOAuthWithinDeadline( deps: OAuthClientDeps, context: OAuthStartContext, deadline: OAuthStartDeadline, ): Promise { const { db, settings } = deps; const externalContinuation = context.externalContinuation ? ExternalActorContinuation.parse(context.externalContinuation) : undefined; const returnUrl = context.payload.returnUrl !== undefined ? exactExternalReturnUrl(context.payload.returnUrl) : undefined; if ( (externalContinuation && !returnUrl) || (returnUrl && !externalContinuation && !context.connectAttemptId) ) throw new HTTPException(422, { message: "external MCP OAuth requires verified actor authority and a host returnUrl", }); if ( externalContinuation && (externalContinuation.actor.accountId !== context.accountId || externalContinuation.actor.effectiveSubjectId !== context.subjectId) ) throw new HTTPException(403, { message: "external OAuth actor mismatch" }); const mcpUrl = canonicalMcpResource(context.payload.mcpUrl ?? context.payload.resource); const urlProfile = builtInOAuthProfileFor({ mcpUrl }); const providerDomain = urlProfile?.canonicalProviderDomain ?? canonicalProviderDomain(context.payload.providerDomain ?? new URL(mcpUrl).hostname); const builtInProfile = urlProfile ?? builtInOAuthProfileFor({ mcpUrl, providerDomain }); const profile = builtInProfile ?? (await deadline.run("connection_lookup", async () => { const raw = await getGlobalCatalogOAuthProfile( db, context.workspaceId, catalogMcpUrlKey(mcpUrl), ); return raw === null ? null : oauthProfileFromCatalog(mcpUrl, raw); })) ?? DEFAULT_OAUTH_PROFILE; assertOAuthStartProfile(settings, context.payload, mcpUrl, profile); // Personal-only resources default an omitted ownership to personal, matching // the fence that existed before #1240; an explicit non-personal value is the // only thing rejected. Everything else defaults to workspace as before. const requestedOwnership: ConnectionOwnership = context.payload.ownership ?? defaultOwnershipFor(profile); assertOwnershipAllowed(profile, requestedOwnership); const returnPath = safeReturnPath(context.payload.returnPath ?? "/integrations"); const baseUrl = integrationBaseUrl(settings.publicBaseUrl, context.requestUrl); const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`; const metadataUrl = `${baseUrl}/v1/integrations/oauth/client-metadata.json`; const existing = await deadline.run("connection_lookup", async () => existingOAuthConnectionForStart(db, { workspaceId: context.workspaceId, subjectId: context.subjectId, providerDomain, mcpUrl, connectionSelection: profile.connectionSelection, exactMcpBinding: profile.exactMcpBinding, connectionId: context.payload.connectionId, requestedOwnership: context.payload.ownership, newConnectionOwnership: requestedOwnership, }), ); if (context.payload.connectionId && !existing) { throw new HTTPException(404, { message: "connection not found" }); } const ownership = existing ? ownershipForConnection(existing.subjectId, context.subjectId) : requestedOwnership; // A reconnect of a legacy row must not renew an ownership the profile no // longer allows (e.g. shared authority over one human's hosted-Slack grant). assertOwnershipAllowed(profile, ownership); // Only a managed human can own a personal Connection: personal-authority // execution resolves through a delegation snapshot frozen on a human's causal // turn, and migration 0256 can mint the `user` authority scope only for a // subject that holds an active organization membership. For a personal-only // profile (Gmail, hosted Slack MCP) this refuses the whole flow rather than // silently downgrading to the workspace ownership those profiles forbid. assertConnectionOwnershipAllowedForPrincipal(ownership, context.personalOwnershipAllowed); const discovery = await discoverMcpOAuth(mcpUrl, settings, deadline); assertDiscoveredAuthorizationServer(settings, discovery.as, profile, providerDomain); const resourceParameterSupported = discovery.mode === "rfc9728_protected_resource" && profile.sendResourceParameter; const resource = discovery.resource; const verifier = randomPkceVerifier(); // A profile's exact scope override wins outright: the reviewed connector // never lets a caller widen the capability contract. const authorizeScopes = chooseProfileAuthorizeScopes(profile, { requested: context.payload.requestedScopes, challenged: discovery.challenge.scope, supported: discovery.prm.scopesSupported, }); const client = await deadline.run("client_registration", (signal) => registerOAuthClient( db, settings, discovery.as, metadataUrl, redirectUri, authorizeScopes, context.payload.oauthClient, profile, signal, ), ); const key = requireEnvironmentEncryption(settings); const state = createSignedState(requireIntegrationsStateSecret(settings), { ...(context.connectAttemptId ? { connectAttemptId: context.connectAttemptId } : {}), accountId: context.accountId, workspaceId: context.workspaceId, subjectId: context.subjectId, ownership, // Signed record that a live principal was checked; the callback has no // principal of its own and enforces exactly this decision. [PERSONAL_OWNER_VERIFIED_STATE_CLAIM]: context.personalOwnershipAllowed, ...(externalContinuation ? { encryptedExternalContinuation: encryptEnvironmentValue( requireEnvironmentEncryption(settings), JSON.stringify(externalContinuation), ), } : {}), ...(returnUrl ? { returnUrl } : {}), providerDomain, mcpUrl, resource, requestedScopes: profile.requestedScopes ? [...profile.requestedScopes] : uniqueStrings(context.payload.requestedScopes ?? []), authorizeScopes, encryptedPkceVerifier: encryptEnvironmentValue(key, verifier), clientId: client.clientId, tokenEndpoint: discovery.as.tokenEndpoint, authorizationServer: client.authorizationServer, issuer: client.issuer, discoveryMode: discovery.mode, discoveryMetadataSha256: discovery.provenance.metadataSha256, ...(discovery.provenance.protectedResourceMetadataUrl ? { protectedResourceMetadataUrl: discovery.provenance.protectedResourceMetadataUrl, } : {}), authorizationServerMetadataUrl: discovery.provenance.authorizationServerMetadataUrl, clientRegistrationMethod: client.method, tokenEndpointAuthMethod: client.tokenEndpointAuthMethod, resourceParameterSupported, ...(client.method === "manual" && client.clientSecret ? { encryptedClientSecret: encryptEnvironmentValue(key, client.clientSecret), } : {}), returnPath, ...(existing ? { connectionId: existing.id, connectionVersion: existing.version } : {}), }); const authorizationUrl = buildAuthorizationUrl({ endpoint: discovery.as.authorizationEndpoint, settings, clientId: client.clientId, redirectUri, state, resource, verifier, scopes: authorizeScopes, resourceParameterSupported, ...(profile.extraAuthorizeParams ? { extraParams: profile.extraAuthorizeParams } : {}), }); return OAuthStartResponse.parse({ state, authorizationUrl, expiresAt: new Date(Date.now() + oauthStateTtlMs).toISOString(), }); } /** Ownership fence for the official Gmail profile, keyed by exact MCP URL. */ export function assertOfficialGmailPersonalOwnership( mcpUrl: string, ownership: ConnectionOwnership, ): void { const profile = builtInOAuthProfileFor({ mcpUrl }); if (profile?.key === "official-gmail") { assertOwnershipAllowed(profile, ownership); } } /** * Slack's hosted MCP issues user tokens only. Sharing one human's grant as * workspace authority made every shared agent act as a named employee; the * OpenGeni workspace bot owns shared Slack access instead (bot-token search * covers public channels, files, and users). */ export function assertHostedSlackMcpPersonalOwnership( hostedSlackMcp: boolean, ownership: ConnectionOwnership, ): void { if (hostedSlackMcp) { assertOwnershipAllowed(builtInOAuthProfileByKey("hosted-slack-mcp"), ownership); } } export function isHostedSlackMcpTarget(providerDomain: string, mcpUrl: string): boolean { return builtInOAuthProfileFor({ mcpUrl, providerDomain })?.key === "hosted-slack-mcp"; } /** Start-time payload fences declared by the resolved profile. */ function assertOAuthStartProfile( settings: Settings, payload: OAuthStartRequest, mcpUrl: string, profile: OAuthProviderProfile, ): void { if (profile.rejectCallerOAuthClient && payload.oauthClient) { throw new HTTPException(422, { message: profile.rejectCallerOAuthClient.message }); } if ( profile.requireProviderDomain && payload.providerDomain && canonicalProviderDomain(payload.providerDomain) !== profile.requireProviderDomain.domain ) { throw new HTTPException(422, { message: profile.requireProviderDomain.message }); } if ( profile.requireExactMcpUrl && !isLocalTestEnvironment(settings.environment) && mcpUrl !== profile.requireExactMcpUrl.url ) { throw new HTTPException(422, { message: profile.requireExactMcpUrl.message }); } if ( profile.requireDeploymentClient && !deploymentManagedClientFor(settings, profile.requireDeploymentClient.key) ) { throw new HTTPException(503, { message: profile.requireDeploymentClient.message }); } } /** * Post-discovery fences, in an order that preserves the historical error * surface: the profile's provider identity, then its authorization-server * origin pins, then the reserved-server guard for everything else. */ function assertDiscoveredAuthorizationServer( settings: Settings, as: PinnableAuthorizationServer, profile: OAuthProviderProfile, providerDomain: string, ): void { if ( profile.postDiscoveryProviderDomain && providerDomain !== profile.postDiscoveryProviderDomain.domain ) { throw new HTTPException(422, { message: profile.postDiscoveryProviderDomain.message }); } if ( profile.authorizationServer && !(profile.authorizationServer.skipInLocalTest && isLocalTestEnvironment(settings.environment)) ) { assertAuthorizationServerPins(as, profile.authorizationServer); } assertAuthorizationServerNotReserved(as, profile); } export async function completeMcpOAuthCallback( deps: OAuthClientDeps, input: { code?: string | undefined; state?: string | undefined; requestUrl: string; }, ): Promise { const deadline = new OAuthCallbackDeadline( deps.oauthCallbackDeadlineMs ?? OAUTH_CALLBACK_DEADLINE_MS, ); try { return await completeMcpOAuthCallbackWithinDeadline(deps, input, deadline); } finally { deadline.dispose(); } } async function completeMcpOAuthCallbackWithinDeadline( deps: OAuthClientDeps, input: { code?: string | undefined; state?: string | undefined; requestUrl: string; }, deadline: OAuthCallbackDeadline, ): Promise { const { db, settings, observability } = deps; let state: OAuthStatePayload | null = null; let connectOperation: { attemptId: string; operationId: string; inputDigest: string } | undefined; if (!input.state) { const error = new OAuthCallbackStageError( "state_verify", "state_invalid", new Error("missing OAuth state"), ); logOAuthCallbackFailure(observability, error, state); return { redirectTo: callbackReturnPath("/integrations", "error", { stage: error.stage, reason: error.reason, }), }; } try { state = readOAuthState(input.state, settings); if (state.connectAttemptId) { const stored = await getConnectAttempt(db, state, state.connectAttemptId); if ( !["mcp-oauth", "slack-personal"].includes(stored.attempt.providerId) || stored.attempt.ownership !== state.ownership || stored.returnUrl !== state.returnUrl ) throw new HTTPException(403, { message: "OAuth attempt mismatch" }); connectOperation = { attemptId: state.connectAttemptId, operationId: `oauth:${state.nonce}`, inputDigest: createHash("sha256").update(input.state).digest("hex"), }; const claim = await claimConnectOperation(db, state, { ...connectOperation, expectedRevision: stored.attempt.revision, authorize: (tx, _attempt, origin) => requireConnectOwnerAuthority(tx, state!, "connections:write", origin), }); if (claim.status === "replayed") return { redirectTo: stored.returnUrl, exactReturn: true }; } // Fence state minted by an older deployment too: a rolling update must not // let a still-valid callback persist an ownership the target's profile no // longer allows (workspace-owned Gmail or hosted Slack, for example). const builtInCallbackProfile = builtInOAuthProfileFor({ mcpUrl: state.mcpUrl, providerDomain: state.providerDomain, }); if (builtInCallbackProfile) { assertOwnershipAllowed(builtInCallbackProfile, state.ownership); } // Only a managed human may own a personal Connection. This callback has no // live principal, so it enforces the signed start-time decision; a state // minted before that fence existed carries no claim and is refused, which // closes the in-flight window across a rolling deploy. The legacy // `?? "personal"` decode above therefore cannot land a machine-owned row. if (!personalOwnerStateAccepted(state)) { throw new OAuthCallbackStageError( "state_verify", "state_invalid", new HTTPException(422, { message: PERSONAL_CONNECTION_PRINCIPAL_MESSAGE }), ); } if (!input.code) { if (connectOperation) { await finishConnectOperation(db, state, { ...connectOperation, authorize: (tx, _attempt, origin) => requireConnectOwnerAuthority(tx, state!, "connections:write", origin), commit: async (_tx, current) => ({ ...current, revision: current.revision + 1, state: "failed", nextAction: { type: "none" }, error: { code: "missing_code", message: "Authorization was not completed. Start a new connection attempt.", retryable: false, }, }), }); } return callbackStateResult(state, "error", { stage: "state_verify", reason: "missing_code", }); } const consumed = await runCallbackDatabaseStage( deadline, "state_verify", db, async (scopedDb) => { await requireOAuthCallbackGrant(scopedDb, state!); if (!builtInCallbackProfile) { // Catalog-declared ownership fences get the same rolling-update // treatment as the built-in ones. const raw = await getGlobalCatalogOAuthProfile( scopedDb, state!.workspaceId, catalogMcpUrlKey(state!.mcpUrl), ); if (raw !== null) { assertOwnershipAllowed(oauthProfileFromCatalog(state!.mcpUrl, raw), state!.ownership); } } return await consumeIntegrationOAuthStateNonce(scopedDb, { accountId: state!.accountId, workspaceId: state!.workspaceId, subjectId: state!.subjectId, nonce: state!.nonce, expiresAt: new Date(state!.iat * 1000 + oauthStateTtlMs), now: new Date(), }); }, ); if (!consumed) { throw new HTTPException(400, { message: "OAuth state has already been used", }); } } catch (error) { const staged = error instanceof OAuthCallbackStageError ? error : new OAuthCallbackStageError("state_verify", "state_invalid", error); logOAuthCallbackFailure(observability, staged, state); return callbackStateResult(state, "error", { stage: staged.stage, reason: staged.reason, }); } const ownerSubjectId = state.ownership === "personal" ? state.subjectId : null; try { const baseUrl = integrationBaseUrl(settings.publicBaseUrl, input.requestUrl); const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`; const key = requireEnvironmentEncryption(settings); const verifier = decryptEnvironmentValue(key, state.encryptedPkceVerifier); const client = await runCallbackDatabaseStage(deadline, "client_lookup", db, (scopedDb) => clientForState(scopedDb, settings, state), ); const token = await deadline.run("token_exchange", (signal) => exchangeAuthorizationCode(settings, { code: input.code!, verifier, redirectUri, resource: state.resource, tokenEndpoint: state.tokenEndpoint, client, resourceParameterSupported: state.resourceParameterSupported, signal, }), ); const verification = await verifyMcpToolsListNonFatal( observability, settings, state, token, deadline, ); const scopes = grantedScopes(token.scopeText, state.authorizeScopes); const credential = credentialBundle(token, state, client); const metadata = { resource: state.resource, mcpUrl: state.mcpUrl, authorizationServer: state.authorizationServer, authorizationServerIssuer: state.issuer, tokenEndpoint: state.tokenEndpoint, clientId: client.clientId, clientRegistrationMethod: state.clientRegistrationMethod, oauthDiscovery: { mode: state.discoveryMode, resource: state.resource, issuer: state.issuer, ...(state.discoveryMetadataSha256 ? { metadataSha256: state.discoveryMetadataSha256 } : {}), ...(state.protectedResourceMetadataUrl ? { protectedResourceMetadataUrl: state.protectedResourceMetadataUrl } : {}), ...(state.authorizationServerMetadataUrl ? { authorizationServerMetadataUrl: state.authorizationServerMetadataUrl } : {}), }, mcpToolsVerification: verification.metadata, ...(verification.tools ? { mcpTools: verification.tools } : {}), }; const credentialEncrypted = encryptEnvironmentValue(key, JSON.stringify(credential)); const persist = async (scopedDb: Database) => { await requireOAuthCallbackGrant(scopedDb, state!); return state!.connectionId ? await updateConnection(scopedDb, { workspaceId: state.workspaceId, connectionId: state.connectionId, visibleToSubjectId: state.subjectId, expectedVersion: state.connectionVersion, subjectId: ownerSubjectId, providerDomain: state.providerDomain, kind: "oauth2", status: "active", credentialEncrypted, grantedScopes: scopes, expiresAt: token.expiresAt, metadata, updatedBySubjectId: state.subjectId, }) : await createConnection(scopedDb, { accountId: state.accountId, workspaceId: state.workspaceId, subjectId: ownerSubjectId, providerDomain: state.providerDomain, kind: "oauth2", credentialEncrypted, grantedScopes: scopes, expiresAt: token.expiresAt, metadata, createdBySubjectId: state.subjectId, }); }; if (connectOperation) { await runCallbackDatabaseStage(deadline, "persist", db, (scopedDb) => finishConnectOperation(scopedDb, state!, { ...connectOperation!, authorize: (tx, _attempt, origin) => requireConnectOwnerAuthority(tx, state!, "connections:write", origin), commit: async (tx, current) => { const connection = await persist(tx); if (!connection) throw new HTTPException(409, { message: "connection changed during OAuth reconnect", }); return { ...current, revision: current.revision + 1, state: "complete", completionRequirement: "connection", credentialsCommitted: true, nextAction: { type: "none" }, account: { id: connection.id, version: connection.version, providerId: current.providerId, label: state!.providerDomain, ownership: current.ownership, status: "connected", }, }; }, }), ); return { redirectTo: state.returnUrl!, exactReturn: true }; } const connection = await runCallbackDatabaseStage(deadline, "persist", db, persist); if (!connection) { throw new HTTPException(409, { message: "connection changed during OAuth reconnect; start again", }); } // Carry the canonical providerDomain (not just the id) so the SPA can build // the enable connectionRef straight from the redirect, without a listConnections // round-trip that could fail (transient, or a grant lacking connections:read) // and leave the connection created but the capability un-enabled. return callbackStateResult(state, "success", { connectionId: connection.id, providerDomain: connection.providerDomain, ownership: state.ownership, ...(verification.metadata.status === "failed" ? { verification: "failed" } : {}), }); } catch (error) { const staged = error instanceof OAuthCallbackStageError ? error : new OAuthCallbackStageError("persist", "persist_failed", error); logOAuthCallbackFailure(observability, staged, state); return callbackStateResult(state, "error", { stage: staged.stage, reason: staged.reason, }); } } function exactExternalReturnUrl(raw: string): string { if (!raw || raw.length > 4096 || /[\u0000-\u0020\u007f]/.test(raw)) throw new HTTPException(422, { message: "invalid external OAuth returnUrl" }); let url: URL; try { url = new URL(raw); } catch { throw new HTTPException(422, { message: "invalid external OAuth returnUrl" }); } if (!["https:", "http:"].includes(url.protocol) || url.username || url.password) throw new HTTPException(422, { message: "invalid external OAuth returnUrl" }); return raw; } function callbackStateResult( state: OAuthStatePayload | null, status: Parameters[1], details: Parameters[2], ): OAuthCallbackResult { if ((state?.externalContinuation || state?.connectAttemptId) && state.returnUrl) return { redirectTo: state.returnUrl, exactReturn: true }; return { redirectTo: callbackReturnPath(state?.returnPath ?? "/integrations", status, details) }; } export function integrationBaseUrl(publicBaseUrl: string | undefined, requestUrl: string): string { return (publicBaseUrl ?? new URL(requestUrl).origin).replace(/\/+$/, ""); } export function requireIntegrationsStateSecret(settings: Settings): string { const secret = settings.integrationsStateSecret?.trim(); if (!secret) { throw new HTTPException(503, { message: "integrations OAuth requires OPENGENI_INTEGRATIONS_STATE_SECRET", }); } return secret; } async function requireOAuthCallbackGrant(db: Database, state: OAuthStatePayload): Promise { await requireConnectOwnerAuthority(db, state); } /** The hosted-Slack profile's origin pins, kept exported for its tests. */ export function assertSlackAuthorizationServer(as: AuthorizationServerMetadata): void { const pins = builtInOAuthProfileByKey("hosted-slack-mcp").authorizationServer; if (pins) { assertAuthorizationServerPins(as, pins); } } /** The official-Gmail profile's origin pins, kept exported for its tests. */ export function assertGoogleAuthorizationServer(as: AuthorizationServerMetadata): void { const pins = builtInOAuthProfileByKey("official-gmail").authorizationServer; if (pins) { assertAuthorizationServerPins(as, pins); } } /** Inspect without credentials, registration, or running tools. */ export async function inspectMcpAuthentication( resource: string, settings: Settings, ): Promise<{ kind: "oauth2" | "none" | "unknown"; message?: string; }> { const deadline = new OAuthStartDeadline(12_000); try { const response = await deadline.run("mcp_challenge", (signal) => fetchOAuth(resource, settings, { method: "POST", headers: { accept: "application/json, text/event-stream", "content-type": "application/json", }, body: JSON.stringify({ jsonrpc: "2.0", id: "auth-discovery", method: "initialize", params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "OpenGeni", version: "1.0" }, }, }), signal, }), ); const challenge = parseMcpOAuthChallenge(response.headers.get("www-authenticate")); let initialized = false; if (response.ok) { initialized = await deadline.run("mcp_challenge", async () => { const reader = response.body?.getReader(); if (!reader) return false; const decoder = new TextDecoder(); let text = ""; let bytes = 0; const sse = response.headers.get("content-type")?.includes("text/event-stream"); try { while (true) { const chunk = await reader.read(); if (chunk.done) return false; bytes += chunk.value.byteLength; if (bytes > 262144) return false; text += decoder.decode(chunk.value, { stream: true }); const messages = sse ? text .split(/\r?\n/) .filter((line) => line.startsWith("data:")) .map((line) => line.slice(5).trim()) : [text]; for (const message of messages) { try { const value = JSON.parse(message); if ( value.id === "auth-discovery" && typeof value.result?.protocolVersion === "string" && value.result?.serverInfo && value.result?.capabilities ) return true; } catch { /* Wait for a complete JSON message. */ } } } } finally { await reader.cancel().catch(() => undefined); } }); } else { await cancelResponseBody(response); } let metadataRead = false; let metadataAbsent = true; try { await resolveMcpOAuthDiscovery({ resourceUrl: resource, challenge, fetchMetadata: async ({ url }) => { try { const result = await deadline.run("protected_resource_metadata", (signal) => fetchOAuthMetadata(url, settings, signal), ); metadataRead = true; if (result.status !== "absent") metadataAbsent = false; return result; } catch (error) { metadataAbsent = false; throw error; } }, validateEndpoint: (url, label) => oauthEndpointUrl(url, settings, label), canonicalizeResource: canonicalOAuthResource, }); return { kind: "oauth2" }; } catch { // Public initialization does not exclude optional or tool-level OAuth. // Only explicit absence of metadata permits the unauthenticated path. if ( initialized && !challenge.scheme && !challenge.resourceMetadata && metadataRead && metadataAbsent ) return { kind: "none" }; return { kind: "unknown", message: "This server's sign-in requirements could not be determined. Check its setup instructions.", }; } } catch { return { kind: "unknown", message: "Could not check this server. Retry or consult its setup instructions.", }; } finally { deadline.dispose(); } } async function discoverMcpOAuth( resource: string, settings: Settings, deadline: OAuthStartDeadline, knownChallenge?: WwwAuthenticateChallenge, ): Promise<{ challenge: WwwAuthenticateChallenge; prm: ProtectedResourceMetadata; as: AuthorizationServerMetadata; mode: McpOAuthDiscoveryMode; resource: string; provenance: { protectedResourceMetadataUrl: string | null; authorizationServerMetadataUrl: string; metadataSha256: string; }; }> { const challenge = knownChallenge ?? (await deadline.run("mcp_challenge", (signal) => probeMcpChallenge(resource, settings, signal), )); try { const discovery = await resolveMcpOAuthDiscovery({ resourceUrl: resource, challenge, fetchMetadata: ({ kind, url }) => deadline.run( kind === "protected_resource" ? "protected_resource_metadata" : "authorization_server_metadata", (signal) => fetchOAuthMetadata(url, settings, signal), ), validateEndpoint: (rawUrl, label) => oauthEndpointUrl(rawUrl, settings, label), canonicalizeResource: canonicalOAuthResource, }); return { challenge: discovery.challenge, prm: discovery.protectedResourceMetadata, as: discovery.authorizationServerMetadata, mode: discovery.mode, resource: discovery.resource, provenance: discovery.provenance, }; } catch (error) { if (error instanceof OAuthStartStageError) throw error; if (error instanceof McpOAuthDiscoveryError) { throw new OAuthStartStageError( error.stage, error.classification, new HTTPException(422, { message: error.message }), ); } throw error; } } async function probeMcpChallenge( resource: string, settings: Settings, signal: AbortSignal, ): Promise { const response = await fetchOAuth(resource, settings, { method: "GET", headers: { accept: "application/json" }, signal, }); try { if (response.status !== 401) { return { scheme: null, scope: [] }; } return parseMcpOAuthChallenge(response.headers.get("www-authenticate")); } finally { await cancelResponseBody(response); } } async function fetchOAuthMetadata( url: string, settings: Settings, signal: AbortSignal, ): Promise { const response = await fetchOAuth(url, settings, { headers: { accept: "application/json" }, signal, }); if (response.status === 404 || response.status === 410) { await cancelResponseBody(response); return { status: "absent", url, httpStatus: response.status }; } if (!response.ok) { await cancelResponseBody(response); throw new OAuthMetadataUpstreamError(response.status); } const payload = await readResponseJsonBounded( response, OAUTH_MAX_RESPONSE_BYTES, "OAuth metadata response", { signal }, ); if (!payload || typeof payload !== "object" || Array.isArray(payload)) { throw new Error("OAuth metadata response was not a JSON object"); } return { status: "present", url, document: payload as Record, }; } async function registerOAuthClient( db: Database, settings: Settings, as: AuthorizationServerMetadata, metadataUrl: string, redirectUri: string, scopes: string[], manual: OAuthStartRequest["oauthClient"], profile: OAuthProviderProfile, signal: AbortSignal, ): Promise { const operator = operatorClientForAs(settings, as); if (operator) { return operator; } const selfRegistration = preferredOAuthSelfRegistration(as, profile.clientSource); if (selfRegistration === "dcr") { return await getOrCreateDynamicClientRegistration( db, settings, as, redirectUri, scopes, signal, ); } if (selfRegistration === "cimd") { return { method: "cimd", issuer: as.issuer, authorizationServer: as.authorizationServer, clientId: metadataUrl, tokenEndpointAuthMethod: "none", }; } if (manual) { return { method: "manual", issuer: as.issuer, authorizationServer: as.authorizationServer, clientId: manual.clientId, ...(manual.clientSecret ? { clientSecret: manual.clientSecret } : {}), tokenEndpointAuthMethod: tokenAuthMethod( manual.tokenEndpointAuthMethod, Boolean(manual.clientSecret), ), }; } return await getOrCreateDynamicClientRegistration(db, settings, as, redirectUri, scopes, signal); } export function preferredOAuthSelfRegistration( as: Pick< AuthorizationServerMetadata, "clientIdMetadataDocumentSupported" | "registrationEndpoint" >, clientSource: OAuthProviderProfile["clientSource"], ): "cimd" | "dcr" | null { if (clientSource === "dcr") return "dcr"; if (clientSource === "cimd" && as.clientIdMetadataDocumentSupported) return "cimd"; // DCR produces an authorization-server-issued client id and is therefore the // interoperable default whenever the server advertises both registration // mechanisms. A reviewed provider profile may explicitly force CIMD for a // server whose registration endpoint is unsuitable. if (clientSource !== "cimd" && as.registrationEndpoint) return "dcr"; if (as.clientIdMetadataDocumentSupported) return "cimd"; return null; } async function getOrCreateDynamicClientRegistration( db: Database, settings: Settings, as: AuthorizationServerMetadata, redirectUri: string, scopes: string[], signal: AbortSignal, ): Promise { const storedClient = await loadIntegrationOAuthClient(db, settings, as.issuer); if (storedClient && storedDcrClientSatisfiesPolicy(storedClient, as, redirectUri, scopes)) { return { method: "dcr", issuer: storedClient.issuer, authorizationServer: storedClient.authorizationServer, clientId: storedClient.clientId, ...(storedClient.clientSecret ? { clientSecret: storedClient.clientSecret } : {}), tokenEndpointAuthMethod: tokenAuthMethod( storedClient.tokenEndpointAuthMethod, Boolean(storedClient.clientSecret), ), }; } if (!as.registrationEndpoint) { throw new HTTPException(422, { message: "manual OAuth client credentials are required for this authorization server", }); } const dcr = await dynamicClientRegistration(settings, as, redirectUri, scopes, signal); const key = dcr.clientSecret ? requireEnvironmentEncryption(settings) : null; const storeInput = { issuer: as.issuer, authorizationServer: as.authorizationServer, clientId: dcr.clientId, clientSecretEncrypted: dcr.clientSecret && key ? encryptEnvironmentValue(key, dcr.clientSecret) : null, tokenEndpointAuthMethod: dcr.tokenEndpointAuthMethod, metadata: registrationMetadata(as, redirectUri, scopes), }; if (storedClient) { const replaced = await replaceIntegrationOAuthClientIfCurrent(db, { ...storeInput, expectedClientId: storedClient.clientId, }); if (replaced?.clientId === dcr.clientId) { return dcr; } return await loadCompatibleDcrWinner(db, settings, as, redirectUri, scopes); } const storedWinner = await storeIntegrationOAuthClient(db, storeInput); if (storedWinner.clientId === dcr.clientId) { return dcr; } const winner = await loadIntegrationOAuthClient(db, settings, as.issuer); if (winner && storedDcrClientSatisfiesPolicy(winner, as, redirectUri, scopes)) { return dcrRegistrationFromStored(winner); } if (winner) { const replaced = await replaceIntegrationOAuthClientIfCurrent(db, { ...storeInput, expectedClientId: winner.clientId, }); if (replaced?.clientId === dcr.clientId) { return dcr; } } return await loadCompatibleDcrWinner(db, settings, as, redirectUri, scopes); } function storedDcrClientSatisfiesPolicy( stored: { authorizationServer: string; metadata: Record; }, as: AuthorizationServerMetadata, redirectUri: string, scopes: string[], ): boolean { return ( stored.authorizationServer === as.authorizationServer && stringValue(stored.metadata.registrationEndpoint) === as.registrationEndpoint && stringValue(stored.metadata.authorizationEndpoint) === as.authorizationEndpoint && stringValue(stored.metadata.tokenEndpoint) === as.tokenEndpoint && stringValue(stored.metadata.redirectUri) === redirectUri && registeredScopesMatch(stored.metadata, scopes) ); } function registeredScopesMatch(metadata: Record, scopes: string[]): boolean { return stableScopeKey(stringArray(metadata.registeredScopes)) === stableScopeKey(scopes); } function stableScopeKey(scopes: string[]): string { return uniqueStrings(scopes).sort().join(" "); } function registrationMetadata( as: AuthorizationServerMetadata, redirectUri: string, scopes: string[], ): Record { return { registrationEndpoint: as.registrationEndpoint, authorizationEndpoint: as.authorizationEndpoint, tokenEndpoint: as.tokenEndpoint, redirectUri, registeredAt: new Date().toISOString(), registeredScopes: uniqueStrings(scopes), }; } async function loadCompatibleDcrWinner( db: Database, settings: Settings, as: AuthorizationServerMetadata, redirectUri: string, scopes: string[], ): Promise { const winner = await loadIntegrationOAuthClient(db, settings, as.issuer); if (!winner || !storedDcrClientSatisfiesPolicy(winner, as, redirectUri, scopes)) { throw new HTTPException(409, { message: "OAuth client registration changed concurrently; start again", }); } return dcrRegistrationFromStored(winner); } function dcrRegistrationFromStored(stored: { issuer: string; authorizationServer: string; clientId: string; clientSecret: string | null; tokenEndpointAuthMethod: string; }): OAuthClientRegistration { return { method: "dcr", issuer: stored.issuer, authorizationServer: stored.authorizationServer, clientId: stored.clientId, ...(stored.clientSecret ? { clientSecret: stored.clientSecret } : {}), tokenEndpointAuthMethod: tokenAuthMethod( stored.tokenEndpointAuthMethod, Boolean(stored.clientSecret), ), }; } function operatorClientForAs( settings: Settings, as: AuthorizationServerMetadata, ): OAuthClientRegistration | null { const entry = operatorClientEntryFor(settings, [as.issuer, as.authorizationServer]); if (!entry) { return null; } return { method: "operator", issuer: as.issuer, authorizationServer: as.authorizationServer, clientId: entry.clientId, ...(entry.clientSecret ? { clientSecret: entry.clientSecret } : {}), tokenEndpointAuthMethod: tokenAuthMethod( entry.tokenEndpointAuthMethod, Boolean(entry.clientSecret), ), }; } function operatorClientEntryFor( settings: Settings, candidates: string[], ): ReturnType[string] | null { const normalizedCandidates = new Set(candidates.map(normalizedIssuerKey)); const candidateOrigins = candidates.flatMap((candidate) => { try { return [new URL(candidate).origin]; } catch { return []; } }); for (const entry of DEPLOYMENT_MANAGED_CLIENTS) { if (!candidateOrigins.some((origin) => entry.issuerOrigins.includes(origin))) { continue; } const resolved = entry.resolve(settings); if (resolved) { return resolved; } } const configured = parseIntegrationsOauthClientsJson(settings.integrationsOauthClientsJson); const exactKeys = uniqueStrings( candidates.flatMap((candidate) => [candidate, normalizedIssuerKey(candidate)]), ); for (const key of exactKeys) { const entry = configured[key]; if (entry) { return entry; } } for (const [key, entry] of Object.entries(configured)) { if (normalizedCandidates.has(normalizedIssuerKey(key))) { return entry; } } return null; } function normalizedIssuerKey(value: string): string { return value.replace(/\/+$/, ""); } async function dynamicClientRegistration( settings: Settings, as: AuthorizationServerMetadata, redirectUri: string, scopes: string[], signal: AbortSignal, ): Promise { if (!as.registrationEndpoint) { throw new HTTPException(422, { message: "authorization server does not support dynamic client registration", }); } const response = await fetchOAuth(as.registrationEndpoint, settings, { method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, body: JSON.stringify({ client_name: "OpenGeni", redirect_uris: [redirectUri], token_endpoint_auth_method: "none", grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], ...(scopes.length ? { scope: scopes.join(" ") } : {}), }), signal, }); if (!response.ok) { await cancelResponseBody(response); throw new HTTPException(422, { message: `dynamic client registration failed with HTTP ${response.status}`, }); } const payload = await readResponseJsonBounded>( response, OAUTH_MAX_RESPONSE_BYTES, "OAuth dynamic registration response", { signal }, ); const clientId = stringValue(payload.client_id); if (!clientId) { throw new HTTPException(422, { message: "dynamic client registration response did not include client_id", }); } const clientSecret = stringValue(payload.client_secret); return { method: "dcr", issuer: as.issuer, authorizationServer: as.authorizationServer, clientId, ...(clientSecret ? { clientSecret } : {}), tokenEndpointAuthMethod: tokenAuthMethod( stringValue(payload.token_endpoint_auth_method), Boolean(clientSecret), ), }; } async function existingOAuthConnectionForStart( db: Database, input: { workspaceId: string; subjectId: string; providerDomain: string; mcpUrl: string; connectionSelection: OAuthProviderProfile["connectionSelection"]; exactMcpBinding: boolean; connectionId?: string | undefined; requestedOwnership?: ConnectionOwnership | undefined; newConnectionOwnership: ConnectionOwnership; }, ) { if (input.connectionId) { const connection = await getConnectionMetadata( db, input.workspaceId, input.connectionId, input.subjectId, ); if (!connection || connection.kind !== "oauth2") { return null; } const ownership = ownershipForConnection(connection.subjectId, input.subjectId); if (input.requestedOwnership && input.requestedOwnership !== ownership) { throw new HTTPException(409, { message: "connection ownership cannot be changed during OAuth reconnect", }); } return connection.providerDomain === input.providerDomain && (!input.exactMcpBinding || connection.metadata.mcpUrl === input.mcpUrl) ? connection : null; } const visible = await listConnectionsMetadata(db, input.workspaceId, input.subjectId); const ownerSubjectId = input.newConnectionOwnership === "personal" ? input.subjectId : null; const matching = visible.filter( (connection) => connection.subjectId === ownerSubjectId && connection.kind === "oauth2" && connection.providerDomain === input.providerDomain && (!input.exactMcpBinding || connection.metadata.mcpUrl === input.mcpUrl), ); if (input.connectionSelection === "canonical_personal") { return selectCanonicalPersonalSlackConnection(matching); } return matching.find((connection) => connection.status === "active") ?? null; } function ownershipForConnection( subjectId: string | null, authenticatingSubjectId: string, ): ConnectionOwnership { if (subjectId === null) return "workspace"; if (subjectId === authenticatingSubjectId) return "personal"; throw new HTTPException(404, { message: "connection not found" }); } export function buildAuthorizationUrl(input: { endpoint: string; settings: Settings; clientId: string; redirectUri: string; state: string; resource: string; verifier: string; scopes: string[]; resourceParameterSupported: boolean; /** Profile-declared extra authorize parameters, applied last. */ extraParams?: Readonly> | undefined; }): string { const endpoint = oauthEndpointUrl(input.endpoint, input.settings, "OAuth authorization endpoint"); const url = new URL(endpoint); url.searchParams.set("response_type", "code"); url.searchParams.set("client_id", input.clientId); url.searchParams.set("redirect_uri", input.redirectUri); url.searchParams.set("state", input.state); if (input.resourceParameterSupported) { // Suppressing RFC 8707 is a profile fact (Google's endpoints reject the // parameter); any provider-specific replacement parameters, such as // Google's offline-consent options, are that profile's // `extraAuthorizeParams` rather than behavior implied by the suppression. url.searchParams.set("resource", input.resource); } url.searchParams.set("code_challenge_method", "S256"); url.searchParams.set("code_challenge", pkceChallenge(input.verifier)); if (input.scopes.length > 0) { url.searchParams.set("scope", input.scopes.join(" ")); } for (const [name, value] of Object.entries(input.extraParams ?? {})) { // Defense in depth behind the schema/curation validation: an extra // parameter can never displace a security parameter the client already // set (state, PKCE, scope, resource, redirect_uri, ...). if (!url.searchParams.has(name)) { url.searchParams.set(name, value); } } return url.toString(); } function readOAuthState(state: string, settings: Settings): OAuthStatePayload { const payload = readSignedState(state, requireIntegrationsStateSecret(settings)) as Record< string, unknown > | null; if (!payload) { throw new HTTPException(400, { message: "invalid or expired OAuth state" }); } const nowSeconds = Math.floor(Date.now() / 1000); const iat = numberValue(payload.iat); if (iat === undefined || nowSeconds - iat > oauthStateTtlMs / 1000 || nowSeconds < iat) { throw new HTTPException(400, { message: "invalid or expired OAuth state" }); } const resource = requiredString(payload.resource, "state.resource"); const encodedDiscoveryMode = stringValue(payload.discoveryMode); const discoveryMode = discoveryModeValue(encodedDiscoveryMode); const discoveryMetadataSha256 = stringValue(payload.discoveryMetadataSha256); if ( encodedDiscoveryMode && (!discoveryMetadataSha256 || !/^[0-9a-f]{64}$/.test(discoveryMetadataSha256)) ) { throw new HTTPException(400, { message: "invalid OAuth discovery state" }); } const protectedResourceMetadataUrl = stringValue(payload.protectedResourceMetadataUrl); const authorizationServerMetadataUrl = stringValue(payload.authorizationServerMetadataUrl); if ( encodedDiscoveryMode && (!authorizationServerMetadataUrl || (discoveryMode === "rfc9728_protected_resource" && !protectedResourceMetadataUrl) || (discoveryMode === "legacy_2025_03_26_metadata" && protectedResourceMetadataUrl)) ) { throw new HTTPException(400, { message: "invalid OAuth discovery provenance state" }); } const resourceParameterSupported = payload.resourceParameterSupported !== false; if (discoveryMode === "legacy_2025_03_26_metadata" && resourceParameterSupported) { throw new HTTPException(400, { message: "invalid legacy OAuth resource parameter state" }); } const parsed = { accountId: requiredString(payload.accountId, "state.accountId"), workspaceId: requiredString(payload.workspaceId, "state.workspaceId"), subjectId: requiredString(payload.subjectId, "state.subjectId"), ...(stringValue(payload.encryptedExternalContinuation) ? { externalContinuation: ExternalActorContinuation.parse( JSON.parse( decryptEnvironmentValue( requireEnvironmentEncryption(settings), requiredString(payload.encryptedExternalContinuation, "state.externalContinuation"), ), ), ), } : {}), ...(payload.returnUrl !== undefined ? { returnUrl: exactExternalReturnUrl(requiredString(payload.returnUrl, "state.returnUrl")) } : {}), ...(stringValue(payload.connectAttemptId) ? { connectAttemptId: requiredString(payload.connectAttemptId, "state.connectAttemptId") } : {}), // OAuth states minted before ownership was explicit were always personal. // Preserve that meaning for in-flight reconnects during a rolling deploy. ownership: connectionOwnership(payload.ownership) ?? "personal", // Absent on a legacy state, which therefore cannot land a personal owner. personalOwnerVerified: personalOwnerVerifiedInState(payload), providerDomain: requiredString(payload.providerDomain, "state.providerDomain"), mcpUrl: stringValue(payload.mcpUrl) ?? resource, resource, requestedScopes: stringArray(payload.requestedScopes), authorizeScopes: stringArray(payload.authorizeScopes), encryptedPkceVerifier: requiredString( payload.encryptedPkceVerifier, "state.encryptedPkceVerifier", ), clientId: requiredString(payload.clientId, "state.clientId"), tokenEndpoint: oauthEndpointUrl( requiredString(payload.tokenEndpoint, "state.tokenEndpoint"), settings, "OAuth token endpoint", ), authorizationServer: oauthEndpointUrl( requiredString(payload.authorizationServer, "state.authorizationServer"), settings, "OAuth authorization server", ).replace(/\/+$/, ""), issuer: oauthEndpointUrl( requiredString(payload.issuer, "state.issuer"), settings, "OAuth issuer", ), discoveryMode, ...(discoveryMetadataSha256 ? { discoveryMetadataSha256 } : {}), ...(protectedResourceMetadataUrl ? { protectedResourceMetadataUrl: oauthEndpointUrl( protectedResourceMetadataUrl, settings, "OAuth protected resource metadata", ), } : {}), ...(authorizationServerMetadataUrl ? { authorizationServerMetadataUrl: oauthEndpointUrl( authorizationServerMetadataUrl, settings, "OAuth authorization server metadata", ), } : {}), clientRegistrationMethod: registrationMethod(payload.clientRegistrationMethod), tokenEndpointAuthMethod: tokenAuthMethod(stringValue(payload.tokenEndpointAuthMethod), false), // States minted before provider-specific compatibility was introduced used // the RFC 8707 resource parameter. resourceParameterSupported, ...(stringValue(payload.encryptedClientSecret) ? { encryptedClientSecret: stringValue(payload.encryptedClientSecret)! } : {}), returnPath: safeReturnPath(stringValue(payload.returnPath) ?? "/integrations"), nonce: requiredString(payload.nonce, "state.nonce"), iat, }; const connectionId = stringValue(payload.connectionId); const connectionVersion = numberValue(payload.connectionVersion); if (Boolean(connectionId) !== Boolean(connectionVersion)) { throw new HTTPException(400, { message: "invalid OAuth reconnect state" }); } if ( parsed.discoveryMode === "legacy_2025_03_26_metadata" && (normalizedIssuerKey(parsed.authorizationServer) !== normalizedIssuerKey(parsed.issuer) || new URL(parsed.resource).origin !== new URL(parsed.issuer).origin) ) { throw new HTTPException(400, { message: "invalid OAuth discovery binding state" }); } return { ...parsed, ...(connectionId ? { connectionId } : {}), ...(connectionVersion !== undefined ? { connectionVersion } : {}), }; } function discoveryModeValue(value: string | undefined): McpOAuthDiscoveryMode { if (!value) { // Every state minted before discovery modes existed used RFC 9728 PRM. return "rfc9728_protected_resource"; } if (value === "rfc9728_protected_resource" || value === "legacy_2025_03_26_metadata") { return value; } throw new HTTPException(400, { message: "invalid OAuth discovery mode state" }); } function connectionOwnership(value: unknown): ConnectionOwnership | undefined { return value === "workspace" || value === "personal" ? value : undefined; } async function clientForState( db: Database, settings: Settings, state: OAuthStatePayload, ): Promise { if (state.clientRegistrationMethod === "cimd") { return { method: "cimd", issuer: state.issuer, authorizationServer: state.authorizationServer, clientId: state.clientId, tokenEndpointAuthMethod: "none", }; } if (state.clientRegistrationMethod === "manual") { const key = requireEnvironmentEncryption(settings); return { method: "manual", issuer: state.issuer, authorizationServer: state.authorizationServer, clientId: state.clientId, ...(state.encryptedClientSecret ? { clientSecret: decryptEnvironmentValue(key, state.encryptedClientSecret), } : {}), tokenEndpointAuthMethod: state.tokenEndpointAuthMethod, }; } if (state.clientRegistrationMethod === "dcr") { const stored = await loadIntegrationOAuthClient(db, settings, state.issuer); if ( !stored || stored.clientId !== state.clientId || stored.issuer !== state.issuer || stored.authorizationServer !== state.authorizationServer ) { throw new HTTPException(400, { message: "OAuth client registration is no longer available", }); } return { method: "dcr", issuer: stored.issuer, authorizationServer: stored.authorizationServer, clientId: stored.clientId, ...(stored.clientSecret ? { clientSecret: stored.clientSecret } : {}), tokenEndpointAuthMethod: tokenAuthMethod( stored.tokenEndpointAuthMethod, Boolean(stored.clientSecret), ), }; } const entry = operatorClientEntryFor(settings, [state.issuer, state.authorizationServer]); if (!entry || entry.clientId !== state.clientId) { throw new HTTPException(400, { message: "operator OAuth client credentials are no longer available", }); } return { method: "operator", issuer: state.issuer, authorizationServer: state.authorizationServer, clientId: entry.clientId, ...(entry.clientSecret ? { clientSecret: entry.clientSecret } : {}), tokenEndpointAuthMethod: tokenAuthMethod( entry.tokenEndpointAuthMethod, Boolean(entry.clientSecret), ), }; } async function exchangeAuthorizationCode( settings: Settings, input: { code: string; verifier: string; redirectUri: string; resource: string; resourceParameterSupported: boolean; tokenEndpoint: string; client: OAuthClientRegistration; signal: AbortSignal; }, ): Promise { const body = new URLSearchParams(); body.set("grant_type", "authorization_code"); body.set("code", input.code); body.set("redirect_uri", input.redirectUri); body.set("code_verifier", input.verifier); if (input.resourceParameterSupported) { body.set("resource", input.resource); } const headers: Record = { "content-type": "application/x-www-form-urlencoded", accept: "application/json", }; if (input.client.clientSecret && input.client.tokenEndpointAuthMethod === "client_secret_post") { body.set("client_id", input.client.clientId); body.set("client_secret", input.client.clientSecret); } else if ( input.client.clientSecret && input.client.tokenEndpointAuthMethod === "client_secret_basic" ) { headers.authorization = `Basic ${Buffer.from(`${input.client.clientId}:${input.client.clientSecret}`).toString("base64")}`; } else { body.set("client_id", input.client.clientId); } const response = await fetchOAuth(input.tokenEndpoint, settings, { method: "POST", headers, body, signal: input.signal, }); if (!response.ok) { const oauthError = await oauthErrorFromResponse(response, input.signal); throw new OAuthCallbackStageError( "token_exchange", oauthError ?? "token_exchange_failed", new Error(`OAuth token endpoint returned HTTP ${response.status}`), ); } const payload = await readResponseJsonBounded>( response, OAUTH_MAX_RESPONSE_BYTES, "OAuth token response", { signal: input.signal }, ); const accessToken = stringValue(payload.access_token); if (!accessToken) { throw new Error("OAuth token response did not include access_token"); } return { accessToken, tokenType: stringValue(payload.token_type) ?? "Bearer", expiresAt: expiresAtFromTokenResponse(payload), raw: payload, ...(stringValue(payload.refresh_token) ? { refreshToken: stringValue(payload.refresh_token)! } : {}), ...(stringValue(payload.scope) ? { scopeText: stringValue(payload.scope)! } : {}), }; } async function runCallbackDatabaseStage( deadline: OAuthCallbackDeadline, stage: "state_verify" | "client_lookup" | "persist", db: Database, fn: (db: Database) => Promise, ): Promise { return await deadline.run(stage, async (signal) => { const statementTimeoutMs = Math.min( OAUTH_CALLBACK_DB_STATEMENT_TIMEOUT_MS, deadline.remainingMs(), ); return await withDatabaseStatementTimeout(db, statementTimeoutMs, async (scopedDb) => { throwIfCallbackAborted(signal, stage); const result = await fn(scopedDb); // If the application deadline won the race while Postgres was finishing, // throw inside this outer transaction so the write is rolled back rather // than committing after the browser has received a timeout redirect. throwIfCallbackAborted(signal, stage); return result; }); }); } function throwIfCallbackAborted(signal: AbortSignal, stage: OAuthCallbackStage): void { if (signal.aborted) { throw new RequestDeadlineError(stage); } } function logOAuthCallbackFailure( observability: Observability | undefined, error: OAuthCallbackStageError, _state: OAuthStatePayload | null, ): void { observability?.error("MCP OAuth callback failed", oauthPublicErrorFields(error.cause)); } function logOAuthStartFailure( observability: Observability | undefined, error: OAuthStartStageError, ): void { observability?.warn("MCP OAuth setup failed", oauthPublicErrorFields(error.cause)); } function oauthStartFailureReason(error: unknown): string { if (error instanceof RequestDeadlineError) return "timeout"; if (error instanceof DestinationPolicyError) return error.reason; if (error instanceof OAuthMetadataUpstreamError) { return `upstream_http_${error.upstreamStatus}`; } if (error instanceof HTTPException) return `http_${error.status}`; if (error instanceof SyntaxError) return "invalid_response"; return "request_failed"; } function oauthCallbackFailureReason(stage: OAuthCallbackStage, error: unknown): string { if (error instanceof RequestDeadlineError || isDatabaseStatementTimeout(error)) return "timeout"; switch (stage) { case "state_verify": return "state_invalid"; case "client_lookup": return "client_lookup_failed"; case "token_exchange": return "token_exchange_failed"; case "tools_list": return "tools_list_failed"; case "persist": return "persist_failed"; } } function isDatabaseStatementTimeout(error: unknown): boolean { let current = error; for (let depth = 0; depth < 4 && current && typeof current === "object"; depth += 1) { const candidate = current as { code?: unknown; message?: unknown; cause?: unknown; }; if ( candidate.code === "57014" || (typeof candidate.message === "string" && candidate.message.toLowerCase().includes("statement timeout")) ) { return true; } current = candidate.cause; } return false; } function oauthStartApiError(error: OAuthStartStageError): ApiHttpError { const timeout = error.reason === "timeout"; const metadataUpstream = error.cause instanceof OAuthMetadataUpstreamError ? error.cause : null; const status = timeout ? 408 : metadataUpstream ? 502 : error.cause instanceof HTTPException ? error.cause.status : 422; return new ApiHttpError(status, { code: timeout || status >= 500 ? "upstream_unavailable" : "validation_failed", retryable: timeout || status === 429 || (metadataUpstream ? metadataUpstream.upstreamStatus === 429 || metadataUpstream.upstreamStatus >= 500 : status >= 500), message: timeout ? oauthStartTimeoutMessage(error.stage) : metadataUpstream ? `OAuth provider returned HTTP ${metadataUpstream.upstreamStatus} during ${oauthStartStageLabel(error.stage)}.` : error.cause instanceof HTTPException ? error.cause.message : `Connection setup failed during ${oauthStartStageLabel(error.stage)}.`, details: { oauthStage: error.stage, oauthReason: error.reason, }, }); } function oauthStartTimeoutMessage(stage: OAuthStartStage): string { return `Connection setup timed out during ${oauthStartStageLabel(stage)}. Try again.`; } function oauthStartStageLabel(stage: OAuthStartStage): string { switch (stage) { case "connection_lookup": return "connection lookup"; case "mcp_challenge": return "MCP authorization discovery"; case "protected_resource_metadata": return "protected-resource discovery"; case "authorization_server_metadata": return "authorization-server discovery"; case "client_registration": return "OAuth client registration"; } } function logOAuthVerificationWarning( observability: Observability | undefined, error: OAuthCallbackStageError, _state: OAuthStatePayload, ): void { observability?.warn( "MCP OAuth tools/list verification failed after token exchange", oauthPublicErrorFields(error.cause), ); } export type OAuthPublicErrorFields = { errorClass: "OAuthOperationError"; errorCode: "oauth_operation_failed"; status?: number; origin: "oauth"; }; /** Allowlisted projection for public telemetry; canonical OAuth errors stay exact. */ export function oauthPublicErrorFields(error: unknown): OAuthPublicErrorFields { const fields: OAuthPublicErrorFields = { errorClass: "OAuthOperationError", errorCode: "oauth_operation_failed", origin: "oauth", }; try { const rawStatus = error instanceof HTTPException ? error.status : error && typeof error === "object" ? ((error as { status?: unknown; statusCode?: unknown }).status ?? (error as { statusCode?: unknown }).statusCode) : undefined; const status = Number(rawStatus); if (Number.isInteger(status) && status >= 100 && status <= 599) fields.status = status; } catch { // Public telemetry is best-effort. A hostile getter/proxy must never // replace the exact OAuth failure with a projection failure. } return fields; } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } async function oauthErrorFromResponse( response: Response, signal?: AbortSignal, ): Promise { const contentType = response.headers.get("content-type") ?? ""; if (!contentType.toLowerCase().includes("application/json")) { await cancelResponseBody(response); return null; } // Consume the original response, not a clone. The pinned transport owns a // per-response dispatcher, so leaving the original body unread would retain // its socket pool after a token endpoint error. const payload = await readResponseJsonBounded>( response, OAUTH_MAX_RESPONSE_BYTES, "OAuth token error response", { ...(signal ? { signal } : {}) }, ).catch(() => null); const error = stringValue(payload?.error); if (!error || !/^[a-zA-Z0-9_.-]{1,80}$/.test(error)) { return null; } return error; } async function verifyMcpToolsList( settings: Settings, resource: string, token: TokenResponse, signal: AbortSignal, ): Promise> { const client = new Client( { name: "opengeni-integration-verify", version: "0.1.0" }, { capabilities: {} }, ); try { const transport = new StreamableHTTPClientTransport(new URL(resource), { requestInit: { headers: { authorization: `${normalizeBearerScheme(token.tokenType)} ${token.accessToken}`, }, }, fetch: (url, init) => fetchOAuth(url.toString(), settings, { ...init, signal: init?.signal ? AbortSignal.any([signal, init.signal]) : signal, }), }); await client.connect(transport as unknown as Transport, { timeout: 10_000, maxTotalTimeout: 10_000, }); const listed = await client.listTools(undefined, { timeout: 10_000, maxTotalTimeout: 10_000, }); return listed.tools.map((tool) => ({ name: tool.name, ...(tool.description ? { description: tool.description } : {}), })); } finally { await client.close().catch(() => undefined); } } async function verifyMcpToolsListNonFatal( observability: Observability | undefined, settings: Settings, state: OAuthStatePayload, token: TokenResponse, deadline: OAuthCallbackDeadline, ): Promise<{ metadata: | { status: "ok"; checkedAt: string; toolCount: number } | { status: "failed"; checkedAt: string; reason: string }; tools?: Array<{ name: string; description?: string }>; }> { try { const tools = await deadline.run("tools_list", (signal) => verifyMcpToolsList(settings, state.mcpUrl, token, signal), ); return { metadata: { status: "ok", checkedAt: new Date().toISOString(), toolCount: tools.length, }, tools, }; } catch (error) { const staged = error instanceof OAuthCallbackStageError ? error : new OAuthCallbackStageError("tools_list", "tools_list_failed", error); if (staged.reason === "timeout" && deadline.signal.aborted) { throw staged; } logOAuthVerificationWarning(observability, staged, state); return { metadata: { status: "failed", checkedAt: new Date().toISOString(), reason: staged.reason, }, }; } } function credentialBundle( token: TokenResponse, state: OAuthStatePayload, client: OAuthClientRegistration, ): Record { return { access_token: token.accessToken, ...(token.refreshToken ? { refresh_token: token.refreshToken } : {}), token_type: token.tokenType, ...(token.expiresAt ? { expires_at: token.expiresAt.toISOString() } : {}), resource: state.resource, resource_parameter_supported: state.resourceParameterSupported, mcp_url: state.mcpUrl, ...(token.scopeText ? { scope: token.scopeText } : state.authorizeScopes.length ? { scope: state.authorizeScopes.join(" ") } : {}), token_endpoint: state.tokenEndpoint, client_id: client.clientId, ...(client.clientSecret ? { client_secret: client.clientSecret, token_endpoint_auth_method: client.tokenEndpointAuthMethod, } : {}), }; } function callbackReturnPath( returnPath: string, status: "success" | "error", params: Record, ): string { const url = new URL(returnPath, "https://opengeni.local"); url.searchParams.set("integration_oauth", status); for (const [key, value] of Object.entries(params)) { url.searchParams.set(key, value); } // Defense in depth: a `//host` pathname becomes a protocol-relative absolute // Location — an open redirect from the unauthenticated callback. if (url.pathname.startsWith("//")) { const fallback = new URL("/integrations", "https://opengeni.local"); fallback.search = url.search; return `${fallback.pathname}${fallback.search}`; } return `${url.pathname}${url.search}${url.hash}`; } function canonicalMcpResource(value: string | undefined): string { if (!value) { throw new HTTPException(400, { message: "mcpUrl is required" }); } let url: URL; try { url = new URL(value); } catch { throw new HTTPException(422, { message: "MCP resource URL is invalid" }); } url.hash = ""; return url.toString(); } function canonicalOAuthResource(value: string): string { const trimmed = value.trim(); if (!trimmed) { throw new HTTPException(422, { message: "MCP protected resource metadata advertised an invalid resource", }); } try { const url = new URL(trimmed); if (url.protocol === "http:" || url.protocol === "https:") { url.hash = ""; return url.toString(); } return trimmed; } catch { throw new HTTPException(422, { message: "MCP protected resource metadata advertised an invalid resource", }); } } function oauthEndpointUrl(rawUrl: string, settings: Settings, label: string): string { try { return validateHttpUrl(rawUrl, { label, allowLoopbackHttp: isLocalTestEnvironment(settings.environment), }); } catch (error) { if (error instanceof DestinationPolicyError) { throw new HTTPException(422, { message: error.message }); } throw error; } } async function fetchOAuth( rawUrl: string, settings: Settings, init: RequestInit = {}, hop = 0, ): Promise { let response: Response; try { const endpoint = oauthEndpointUrl(rawUrl, settings, "OAuth endpoint"); response = await pinnedFetch(endpoint, init, settings, { label: "OAuth discovery", requireHttpsOutsideLocalTest: true, }); } catch (error) { if (error instanceof DestinationPolicyError) { throw new HTTPException(422, { message: error.message }); } throw error; } if (response.status < 300 || response.status >= 400) { return response; } // Discovery is the only redirectable OAuth traffic. Replaying a token // exchange, dynamic registration, or authenticated MCP request would send // its body and/or credential headers to a provider-controlled Location. // Keep this allowlist deliberately narrow so future credential headers fail // closed instead of silently becoming redirectable. if (!oauthRequestMayFollowRedirect(init)) { await cancelResponseBody(response); throw new HTTPException(422, { message: "OAuth credential-bearing requests may not follow redirects", }); } if (hop >= 3) { await cancelResponseBody(response); throw new HTTPException(422, { message: "OAuth fetch exceeded maximum redirect hops", }); } const location = response.headers.get("location"); if (!location) { await cancelResponseBody(response); throw new HTTPException(422, { message: "OAuth fetch redirect was missing Location", }); } let nextUrl: string; try { nextUrl = new URL(location, rawUrl).toString(); } catch { await cancelResponseBody(response); throw new HTTPException(422, { message: "OAuth fetch redirect Location was invalid", }); } await cancelResponseBody(response); return await fetchOAuth(nextUrl, settings, init, hop + 1); } function oauthRequestMayFollowRedirect(init: RequestInit): boolean { const method = (init.method ?? "GET").toUpperCase(); if ((method !== "GET" && method !== "HEAD") || init.body != null) { return false; } const headers = new Headers(init.headers); return [...headers.keys()].every((name) => name === "accept"); } async function cancelResponseBody(response: Response): Promise { await response.body?.cancel().catch(() => undefined); } function chooseAuthorizeScopes( requested: string[] | undefined, challenged: string[] | undefined, supported: string[], ): string[] { if (requested?.length) { return uniqueStrings(requested); } if (challenged?.length) { return uniqueStrings(challenged); } return uniqueStrings(supported); } export function chooseMcpAuthorizeScopes(input: { mcpUrl: string; requested: string[] | undefined; challenged: string[] | undefined; supported: string[]; }): string[] { const profile = builtInOAuthProfileFor({ mcpUrl: input.mcpUrl }) ?? DEFAULT_OAUTH_PROFILE; return chooseProfileAuthorizeScopes(profile, input); } function chooseProfileAuthorizeScopes( profile: OAuthProviderProfile, input: { requested: string[] | undefined; challenged: string[] | undefined; supported: string[]; }, ): string[] { return profile.requestedScopes ? [...profile.requestedScopes] : chooseAuthorizeScopes(input.requested, input.challenged, input.supported); } function grantedScopes(scopeText: string | undefined, fallback: string[]): string[] { if (scopeText) { return uniqueStrings(scopeText.split(/\s+/).filter(Boolean)); } return fallback; } function tokenAuthMethod( raw: string | undefined, hasSecret: boolean, ): OAuthClientRegistration["tokenEndpointAuthMethod"] { if (raw === "client_secret_post" || raw === "client_secret_basic") { return raw; } return hasSecret ? "client_secret_post" : "none"; } function registrationMethod(value: unknown): OAuthClientRegistration["method"] { if (value === "operator" || value === "manual" || value === "cimd" || value === "dcr") { return value; } throw new HTTPException(400, { message: "invalid OAuth state" }); } function expiresAtFromTokenResponse(payload: Record): Date | null { const expiresAt = stringValue(payload.expires_at); if (expiresAt) { const parsed = new Date(expiresAt); return Number.isNaN(parsed.getTime()) ? null : parsed; } const expiresIn = typeof payload.expires_in === "number" ? payload.expires_in : Number(payload.expires_in); if (Number.isFinite(expiresIn) && expiresIn > 0) { return new Date(Date.now() + expiresIn * 1000); } return null; } function pkceChallenge(verifier: string): string { return createHash("sha256").update(verifier).digest("base64url"); } function randomPkceVerifier(): string { return randomBytes(32).toString("base64url"); } function uniqueStrings(values: string[]): string[] { return [...new Set(values.map((value) => value.trim()).filter(Boolean))]; } function stringArray(value: unknown): string[] { return Array.isArray(value) ? uniqueStrings(value.filter((entry): entry is string => typeof entry === "string")) : []; } function stringValue(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } function numberValue(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } function requiredString(value: unknown, field: string): string { const result = stringValue(value); if (!result) { throw new HTTPException(400, { message: `invalid OAuth state: missing ${field}`, }); } return result; }