Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline (Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/* packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
745 lines
25 KiB
TypeScript
745 lines
25 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import type {
|
|
OAuthClientProvider,
|
|
OAuthDiscoveryState,
|
|
} from "@modelcontextprotocol/sdk/client/auth.js";
|
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
import type {
|
|
OAuthClientInformationMixed,
|
|
OAuthClientMetadata,
|
|
OAuthTokens,
|
|
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
import { isLocalMcpHost } from "@rakazo/contracts";
|
|
import type { PrismaClient } from "@rakazo/db";
|
|
import { secureFetch, validateUrl, withEndpointOriginFallback } from "./mcp-transport.js";
|
|
import type { RemoteTransportDependencies } from "./remote-mcp.js";
|
|
import type { EncryptedSecretStore } from "./secrets.js";
|
|
|
|
type OAuthState = {
|
|
tokens?: OAuthTokens;
|
|
obtainedAt?: number;
|
|
clientInformation?: OAuthClientInformationMixed;
|
|
discoveryState?: OAuthDiscoveryState;
|
|
redirectUri?: string;
|
|
codeVerifier?: string;
|
|
};
|
|
|
|
export type OAuthMaterial = {
|
|
secret?: string;
|
|
env?: Record<string, string>;
|
|
headers?: Record<string, string>;
|
|
oauth?: OAuthState;
|
|
};
|
|
|
|
/** Values that must never appear in model-visible tool results or errors. */
|
|
export function oauthMaterialSecrets(material: OAuthMaterial): string[] {
|
|
const values: string[] = [];
|
|
const add = (value: string | undefined) => {
|
|
if (!value) return;
|
|
values.push(value);
|
|
const bearer = value.match(/^Bearer\s+(.+)$/i);
|
|
if (bearer?.[1]) values.push(bearer[1]);
|
|
};
|
|
add(material.secret);
|
|
add(material.oauth?.tokens?.access_token);
|
|
add(material.oauth?.tokens?.refresh_token);
|
|
const client = material.oauth?.clientInformation;
|
|
if (client && "client_secret" in client && typeof client.client_secret === "string") {
|
|
add(client.client_secret);
|
|
}
|
|
for (const [key, value] of Object.entries(material.headers ?? {})) {
|
|
if (isAuthHeaderKey(key)) {
|
|
// Cookie / X-Session / Authorization always carry auth material, including short values.
|
|
add(value);
|
|
} else if (
|
|
isExplicitCredentialKey(key) &&
|
|
looksLikeSecretValue(value, { allowNumeric: true })
|
|
) {
|
|
add(value);
|
|
} else if (
|
|
isAmbiguousCredentialKey(key) &&
|
|
looksLikeSecretValue(value, { allowNumeric: false })
|
|
) {
|
|
add(value);
|
|
}
|
|
}
|
|
for (const [key, value] of Object.entries(material.env ?? {})) {
|
|
if (isExplicitCredentialKey(key) && looksLikeSecretValue(value, { allowNumeric: true })) {
|
|
add(value);
|
|
} else if (
|
|
isAmbiguousCredentialKey(key) &&
|
|
looksLikeSecretValue(value, { allowNumeric: false })
|
|
) {
|
|
add(value);
|
|
}
|
|
}
|
|
return [...new Set(values)];
|
|
}
|
|
|
|
/** Headers whose values are credentials even when short (e.g. Cookie, X-Session). */
|
|
function isAuthHeaderKey(key: string): boolean {
|
|
const normalized = key.toLowerCase().replace(/-/g, "_");
|
|
return (
|
|
normalized === "authorization" ||
|
|
normalized === "cookie" ||
|
|
normalized === "set_cookie" ||
|
|
normalized === "x_session" ||
|
|
normalized === "x_api_key" ||
|
|
normalized === "api_key" ||
|
|
normalized === "x_auth_token"
|
|
);
|
|
}
|
|
|
|
/** Explicit credential keys (access_token, api_key, …); numeric values stay redacted. */
|
|
function isExplicitCredentialKey(key: string): boolean {
|
|
const normalized = key.toLowerCase().replace(/-/g, "_");
|
|
if (isAuthHeaderKey(key)) return true;
|
|
return /(?:^|_)(secret|password|credential|access_token|refresh_token|id_token|auth_token|session_token|session_id|session_key|session_secret|api_key|api_token)$/.test(
|
|
normalized,
|
|
);
|
|
}
|
|
|
|
/** Ambiguous *_token keys where numeric-only values are usually config, not secrets. */
|
|
function isAmbiguousCredentialKey(key: string): boolean {
|
|
if (isExplicitCredentialKey(key)) return false;
|
|
const normalized = key.toLowerCase().replace(/-/g, "_");
|
|
if (/(?:^|_)token$/.test(normalized)) {
|
|
return !/(timeout|ttl|max|count|type|mode|name)$/.test(normalized);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Values under credential-shaped keys must look like secrets before entering
|
|
* global substring redaction. Ordinary enums such as "production" or "oauth"
|
|
* would otherwise corrupt unrelated tool output. Numeric-only filtering applies
|
|
* only to ambiguous keys — explicit carriers still register OTP-like tokens.
|
|
*/
|
|
function looksLikeSecretValue(value: string, options: { allowNumeric?: boolean } = {}): boolean {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return false;
|
|
if (COMMON_CONFIG_VALUES.has(trimmed.toLowerCase())) return false;
|
|
if (!options.allowNumeric && /^\d+(\.\d+)?$/.test(trimmed)) return false;
|
|
return true;
|
|
}
|
|
|
|
const COMMON_CONFIG_VALUES = new Set([
|
|
"production",
|
|
"development",
|
|
"staging",
|
|
"test",
|
|
"testing",
|
|
"oauth",
|
|
"openid",
|
|
"true",
|
|
"false",
|
|
"yes",
|
|
"no",
|
|
"on",
|
|
"off",
|
|
"none",
|
|
"null",
|
|
"debug",
|
|
"info",
|
|
"warn",
|
|
"error",
|
|
"http",
|
|
"https",
|
|
"local",
|
|
"localhost",
|
|
"enabled",
|
|
"disabled",
|
|
"default",
|
|
"auto",
|
|
"manual",
|
|
"read",
|
|
"write",
|
|
"sync",
|
|
"async",
|
|
]);
|
|
|
|
type ServerRef = { id: string; endpoint: string | null; secretId: string | null };
|
|
type ActorRef = { spaceId: string; userId: string };
|
|
|
|
export class McpReauthorizationRequiredError extends Error {
|
|
readonly code = "MCP_REAUTHORIZATION_REQUIRED";
|
|
constructor(readonly serverId: string) {
|
|
super("MCP authorization expired. Reconnect this server in MCP settings.");
|
|
this.name = "McpReauthorizationRequiredError";
|
|
}
|
|
}
|
|
|
|
type ProviderOptions = {
|
|
redirectUri?: string;
|
|
state?: string;
|
|
onAuthorization?: (url: URL) => void;
|
|
};
|
|
|
|
/** One SDK OAuth provider backed by the same encrypted material used at runtime. */
|
|
export class StoredMcpOAuthProvider implements OAuthClientProvider {
|
|
authorizationUrl?: URL;
|
|
private readonly runtimeState = randomUUID();
|
|
private persistQueue = Promise.resolve();
|
|
|
|
constructor(
|
|
readonly serverId: string,
|
|
private readonly material: OAuthMaterial,
|
|
private readonly persistMaterial: (material: OAuthMaterial) => Promise<void>,
|
|
private readonly options: ProviderOptions = {},
|
|
) {
|
|
if (options.redirectUri) {
|
|
this.material.oauth = { ...(this.material.oauth ?? {}), redirectUri: options.redirectUri };
|
|
}
|
|
}
|
|
|
|
get redirectUrl(): string | undefined {
|
|
return this.options.redirectUri ?? this.material.oauth?.redirectUri;
|
|
}
|
|
|
|
get clientMetadata(): OAuthClientMetadata {
|
|
const redirectUri = this.redirectUrl;
|
|
if (!redirectUri) throw new McpReauthorizationRequiredError(this.serverId);
|
|
const hostname = new URL(redirectUri).hostname;
|
|
const applicationType = hostname === "localhost" || hostname === "127.0.0.1" ? "native" : "web";
|
|
return {
|
|
redirect_uris: [redirectUri],
|
|
client_name: "Rakazo",
|
|
grant_types: ["authorization_code", "refresh_token"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
application_type: applicationType,
|
|
} as OAuthClientMetadata;
|
|
}
|
|
|
|
state(): string {
|
|
return this.options.state ?? this.runtimeState;
|
|
}
|
|
clientInformation(): OAuthClientInformationMixed | undefined {
|
|
return this.material.oauth?.clientInformation;
|
|
}
|
|
async saveClientInformation(value: OAuthClientInformationMixed): Promise<void> {
|
|
this.oauth().clientInformation = value;
|
|
await this.persist();
|
|
}
|
|
tokens(): OAuthTokens | undefined {
|
|
return this.material.oauth?.tokens;
|
|
}
|
|
async saveTokens(value: OAuthTokens): Promise<void> {
|
|
const oauth = this.oauth();
|
|
oauth.tokens = value;
|
|
oauth.obtainedAt = Date.now();
|
|
await this.persist();
|
|
}
|
|
async redirectToAuthorization(url: URL): Promise<void> {
|
|
let authorizationUrl: URL;
|
|
try {
|
|
authorizationUrl = validateUrl(url, { allowHttpLocalhost: true });
|
|
} catch (error) {
|
|
if (!this.options.onAuthorization) {
|
|
await this.invalidateCredentials("tokens");
|
|
}
|
|
throw error;
|
|
}
|
|
this.authorizationUrl = authorizationUrl;
|
|
if (this.options.onAuthorization) {
|
|
this.options.onAuthorization(authorizationUrl);
|
|
return;
|
|
}
|
|
// Runtime re-auth needs the user; drop the dead tokens so status reads "reconnect".
|
|
await this.invalidateCredentials("tokens");
|
|
throw new McpReauthorizationRequiredError(this.serverId);
|
|
}
|
|
async saveCodeVerifier(value: string): Promise<void> {
|
|
this.oauth().codeVerifier = value;
|
|
await this.persist();
|
|
}
|
|
codeVerifier(): string {
|
|
const verifier = this.material.oauth?.codeVerifier;
|
|
if (!verifier) throw new Error("OAuth PKCE verifier is missing");
|
|
return verifier;
|
|
}
|
|
// RFC 8707 resource binding: only accept a self-declared canonical resource
|
|
// that is itself a well-formed HTTPS (or localhost) URL. Providers like Brex
|
|
// advertise an internal alias for their public origin, so cross-host
|
|
// resources are allowed, but malformed or cleartext resources are rejected
|
|
// and the SDK falls back to deriving the resource from the server URL.
|
|
async validateResourceURL(_serverUrl: string | URL, resource?: string): Promise<URL | undefined> {
|
|
if (!resource) return undefined;
|
|
try {
|
|
return validateUrl(resource);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
async saveDiscoveryState(value: OAuthDiscoveryState): Promise<void> {
|
|
this.oauth().discoveryState = value;
|
|
await this.persist();
|
|
}
|
|
discoveryState(): OAuthDiscoveryState | undefined {
|
|
return this.material.oauth?.discoveryState;
|
|
}
|
|
async invalidateCredentials(
|
|
scope: "all" | "client" | "tokens" | "verifier" | "discovery",
|
|
): Promise<void> {
|
|
if (scope === "all") {
|
|
const redirectUri = this.redirectUrl;
|
|
this.material.oauth = redirectUri ? { redirectUri } : undefined;
|
|
} else if (this.material.oauth) {
|
|
if (scope === "client") delete this.material.oauth.clientInformation;
|
|
if (scope === "tokens") {
|
|
delete this.material.oauth.tokens;
|
|
delete this.material.oauth.obtainedAt;
|
|
}
|
|
if (scope === "verifier") delete this.material.oauth.codeVerifier;
|
|
if (scope === "discovery") delete this.material.oauth.discoveryState;
|
|
}
|
|
await this.persist();
|
|
}
|
|
|
|
private oauth(): OAuthState {
|
|
if (!this.material.oauth) this.material.oauth = {};
|
|
return this.material.oauth;
|
|
}
|
|
|
|
private async persist(): Promise<void> {
|
|
const snapshot = structuredClone(this.material);
|
|
const next = this.persistQueue.then(() => this.persistMaterial(snapshot));
|
|
this.persistQueue = next.catch(() => undefined);
|
|
await next;
|
|
}
|
|
}
|
|
|
|
type Pending = {
|
|
serverId: string;
|
|
spaceId: string;
|
|
userId: string;
|
|
endpoint: string;
|
|
provider: StoredMcpOAuthProvider;
|
|
createdAt: number;
|
|
expiry?: ReturnType<typeof setTimeout>;
|
|
};
|
|
|
|
const PENDING_TTL_MS = 10 * 60_000;
|
|
const MAX_PENDING_SESSIONS = 100;
|
|
|
|
/** OAuth traffic runs through the same URL policy as runtime MCP requests
|
|
* (HTTPS enforced, redirects rejected), with the endpoint-origin fallback
|
|
* layered on top for providers like Brex. */
|
|
function oauthFetch(
|
|
endpoint: string,
|
|
network: RemoteTransportDependencies,
|
|
material: OAuthMaterial = {},
|
|
): { fetch: typeof fetch; close: () => Promise<void>; headers: Record<string, string> } {
|
|
const url = new URL(endpoint);
|
|
const localHttp = url.protocol === "http:" && isLocalMcpHost(url.hostname);
|
|
const headers = {
|
|
...material.headers,
|
|
...(material.secret
|
|
? {
|
|
Authorization: material.secret.startsWith("Bearer ")
|
|
? material.secret
|
|
: `Bearer ${material.secret}`,
|
|
}
|
|
: {}),
|
|
};
|
|
const safeFetch = secureFetch(
|
|
url,
|
|
{ allowHttpLocalhost: localHttp, allowLocalHttpCredentials: localHttp },
|
|
{ headers },
|
|
network,
|
|
);
|
|
return {
|
|
headers,
|
|
fetch: withEndpointOriginFallback(url.origin, safeFetch),
|
|
close: () => safeFetch.close(),
|
|
};
|
|
}
|
|
|
|
export class McpOAuthBroker {
|
|
private readonly pending = new Map<string, Pending>();
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaClient,
|
|
private readonly secrets: EncryptedSecretStore,
|
|
private readonly network: RemoteTransportDependencies = {},
|
|
) {}
|
|
|
|
async statusFor(
|
|
server: ServerRef,
|
|
context: ActorRef,
|
|
): Promise<"none" | "connected" | "reconnect"> {
|
|
const { material } = await this.loadMaterial(server, context);
|
|
if (material.oauth?.tokens) return "connected";
|
|
return material.oauth ? "reconnect" : "none";
|
|
}
|
|
|
|
statusForCiphertext(
|
|
ciphertext: string | undefined,
|
|
recordId: string | undefined,
|
|
): "none" | "connected" | "reconnect" {
|
|
const material = ciphertext && recordId ? this.read(ciphertext, recordId) : {};
|
|
if (material.oauth?.tokens) return "connected";
|
|
return material.oauth ? "reconnect" : "none";
|
|
}
|
|
|
|
async providerFor(
|
|
server: ServerRef,
|
|
context: ActorRef,
|
|
loaded?: { material: OAuthMaterial; secretId?: string },
|
|
): Promise<OAuthClientProvider | undefined> {
|
|
const material = loaded ?? (await this.loadMaterial(server, context));
|
|
if (!material.material.oauth) return undefined;
|
|
return this.createProvider(server, context, material);
|
|
}
|
|
|
|
async begin(input: {
|
|
serverId: string;
|
|
spaceId: string;
|
|
userId: string;
|
|
redirectUri: string;
|
|
}): Promise<
|
|
| { status: "authorization_required"; sessionId: string; authorizationUrl: string }
|
|
| { status: "already_connected" | "authorization_not_requested" }
|
|
> {
|
|
const server = await this.prisma.mcpServer.findFirst({
|
|
where: {
|
|
id: input.serverId,
|
|
spaceId: input.spaceId,
|
|
userId: input.userId,
|
|
enabled: true,
|
|
},
|
|
});
|
|
if (!server?.endpoint) throw new Error("MCP server endpoint is required for OAuth");
|
|
await this.sweepExpiredPending();
|
|
let actorPending = 0;
|
|
for (const pending of this.pending.values()) {
|
|
if (pending.spaceId === input.spaceId && pending.userId === input.userId) {
|
|
actorPending += 1;
|
|
}
|
|
}
|
|
if (actorPending >= MAX_PENDING_SESSIONS) {
|
|
throw new Error("Too many pending MCP authorization attempts; wait and try again");
|
|
}
|
|
const activeCount = await this.prisma.mcpOAuthSession.count({
|
|
where: { spaceId: input.spaceId, userId: input.userId },
|
|
});
|
|
if (activeCount >= MAX_PENDING_SESSIONS) {
|
|
throw new Error("Too many pending MCP authorization attempts; wait and try again");
|
|
}
|
|
const sessionId = randomUUID();
|
|
const context = { spaceId: input.spaceId, userId: input.userId };
|
|
const loaded = await this.loadMaterial(server, context);
|
|
let authorizationUrl: URL | undefined;
|
|
const provider = this.createProvider(server, context, loaded, {
|
|
redirectUri: input.redirectUri,
|
|
state: sessionId,
|
|
onAuthorization: (url) => {
|
|
authorizationUrl = url;
|
|
},
|
|
});
|
|
// Never destroy working tokens here: if this attempt fails (network error,
|
|
// cancelled popup), the server keeps its valid connection. The SDK itself
|
|
// invalidates dead tokens when a refresh is rejected with invalid_grant.
|
|
const endpoint = new URL(server.endpoint);
|
|
const networkFetch = oauthFetch(server.endpoint, this.network, loaded.material);
|
|
const transport = new StreamableHTTPClientTransport(endpoint, {
|
|
requestInit: { headers: networkFetch.headers },
|
|
authProvider: provider,
|
|
fetch: networkFetch.fetch,
|
|
});
|
|
const client = new Client({ name: "rakazo-oauth", version: "0.1.0" });
|
|
const signal = AbortSignal.timeout(15_000);
|
|
try {
|
|
await client.connect(transport, { signal, timeout: 15_000 });
|
|
} catch (error) {
|
|
if (!authorizationUrl) throw error;
|
|
} finally {
|
|
await client.close().catch(() => undefined);
|
|
await networkFetch.close().catch(() => undefined);
|
|
}
|
|
if (!authorizationUrl) {
|
|
if (provider.tokens()) {
|
|
return { status: "already_connected" };
|
|
}
|
|
return { status: "authorization_not_requested" };
|
|
}
|
|
const sessionMaterial = await this.secrets.put(
|
|
JSON.stringify(loaded.material),
|
|
{
|
|
operationId: "mcp.oauth.session",
|
|
traceId: "mcp.oauth.session",
|
|
spaceId: input.spaceId,
|
|
userId: input.userId,
|
|
botId: "mcp",
|
|
signal: new AbortController().signal,
|
|
},
|
|
sessionId,
|
|
);
|
|
await this.prisma.mcpOAuthSession.create({
|
|
data: {
|
|
id: sessionId,
|
|
serverId: server.id,
|
|
spaceId: input.spaceId,
|
|
userId: input.userId,
|
|
endpoint: server.endpoint,
|
|
redirectUri: input.redirectUri,
|
|
oauthCiphertext: sessionMaterial.ciphertext,
|
|
},
|
|
});
|
|
const expiry = setTimeout(() => {
|
|
this.pending.delete(sessionId);
|
|
void this.prisma.mcpOAuthSession
|
|
.deleteMany({ where: { id: sessionId } })
|
|
.catch(() => undefined);
|
|
}, PENDING_TTL_MS);
|
|
expiry.unref?.();
|
|
this.pending.set(sessionId, {
|
|
serverId: server.id,
|
|
spaceId: input.spaceId,
|
|
userId: input.userId,
|
|
endpoint: server.endpoint,
|
|
provider,
|
|
createdAt: Date.now(),
|
|
expiry,
|
|
});
|
|
return {
|
|
status: "authorization_required",
|
|
sessionId,
|
|
authorizationUrl: authorizationUrl.toString(),
|
|
};
|
|
}
|
|
|
|
async complete(input: {
|
|
sessionId: string;
|
|
code: string;
|
|
state: string;
|
|
spaceId: string;
|
|
userId: string;
|
|
}): Promise<void> {
|
|
await this.sweepExpiredPending();
|
|
if (input.state !== input.sessionId) {
|
|
throw new Error("MCP OAuth session is invalid or expired");
|
|
}
|
|
let pending = this.pending.get(input.sessionId);
|
|
if (pending && (pending.spaceId !== input.spaceId || pending.userId !== input.userId)) {
|
|
pending = undefined;
|
|
}
|
|
if (!pending) {
|
|
const session = await this.prisma.mcpOAuthSession.findFirst({
|
|
where: {
|
|
id: input.sessionId,
|
|
spaceId: input.spaceId,
|
|
userId: input.userId,
|
|
createdAt: { gte: new Date(Date.now() - PENDING_TTL_MS) },
|
|
},
|
|
});
|
|
if (!session) throw new Error("MCP OAuth session is invalid or expired");
|
|
const server = await this.prisma.mcpServer.findFirst({
|
|
where: {
|
|
id: session.serverId,
|
|
spaceId: input.spaceId,
|
|
userId: input.userId,
|
|
enabled: true,
|
|
},
|
|
});
|
|
if (!server?.endpoint) throw new Error("MCP OAuth session is invalid or expired");
|
|
const context = { spaceId: input.spaceId, userId: input.userId };
|
|
const loaded = {
|
|
material: this.read(session.oauthCiphertext, session.id),
|
|
...(server.secretId ? { secretId: server.secretId } : {}),
|
|
};
|
|
pending = {
|
|
serverId: server.id,
|
|
spaceId: input.spaceId,
|
|
userId: input.userId,
|
|
endpoint: session.endpoint,
|
|
provider: this.createProvider(server, context, loaded, {
|
|
redirectUri: session.redirectUri,
|
|
state: session.id,
|
|
}),
|
|
createdAt: session.createdAt.getTime(),
|
|
expiry: undefined,
|
|
};
|
|
}
|
|
// Consume the session up front so a failed token exchange cannot be
|
|
// retried with a replayed code; the user starts a fresh flow instead.
|
|
this.pending.delete(input.sessionId);
|
|
if (pending.expiry) clearTimeout(pending.expiry);
|
|
const consumed = await this.prisma.mcpOAuthSession.deleteMany({
|
|
where: {
|
|
id: input.sessionId,
|
|
spaceId: input.spaceId,
|
|
userId: input.userId,
|
|
},
|
|
});
|
|
if (consumed.count !== 1) throw new Error("MCP OAuth session is invalid or expired");
|
|
const endpoint = new URL(pending.endpoint);
|
|
const networkFetch = oauthFetch(pending.endpoint, this.network);
|
|
const transport = new StreamableHTTPClientTransport(endpoint, {
|
|
authProvider: pending.provider,
|
|
fetch: networkFetch.fetch,
|
|
});
|
|
try {
|
|
await transport.finishAuth(input.code);
|
|
} finally {
|
|
await transport.close().catch(() => undefined);
|
|
await networkFetch.close().catch(() => undefined);
|
|
}
|
|
if (!pending.provider.tokens()) throw new Error("MCP OAuth authorization failed");
|
|
// Bump the revision so cached runtime sessions rebuild with the fresh tokens.
|
|
await this.prisma.mcpServer.update({
|
|
where: { id: pending.serverId },
|
|
data: { revision: { increment: 1 } },
|
|
});
|
|
}
|
|
|
|
private async sweepExpiredPending(): Promise<void> {
|
|
const cutoff = Date.now() - PENDING_TTL_MS;
|
|
for (const [sessionId, pending] of this.pending) {
|
|
if (pending.createdAt < cutoff) {
|
|
this.discardPending(sessionId, pending);
|
|
}
|
|
}
|
|
await this.prisma.mcpOAuthSession.deleteMany({
|
|
where: { createdAt: { lt: new Date(cutoff) } },
|
|
});
|
|
}
|
|
|
|
private discardPending(sessionId: string, pending: Pending): void {
|
|
if (pending.expiry) clearTimeout(pending.expiry);
|
|
this.pending.delete(sessionId);
|
|
}
|
|
|
|
async disconnect(input: { serverId: string; spaceId: string; userId: string }): Promise<void> {
|
|
const server = await this.prisma.mcpServer.findFirst({
|
|
where: { id: input.serverId, spaceId: input.spaceId, userId: input.userId },
|
|
});
|
|
if (!server?.secretId) return;
|
|
const row = await this.prisma.secret.findFirst({
|
|
where: { id: server.secretId, spaceId: input.spaceId, userId: input.userId },
|
|
});
|
|
if (!row) return;
|
|
const material = this.read(row.ciphertext, row.id);
|
|
delete material.oauth;
|
|
await this.replaceMaterial(server.id, material, input, true);
|
|
}
|
|
|
|
private async loadMaterial(
|
|
server: ServerRef,
|
|
context: ActorRef,
|
|
): Promise<{ material: OAuthMaterial; secretId?: string }> {
|
|
if (!server.secretId) return { material: {} };
|
|
const row = await this.prisma.secret.findFirst({
|
|
where: { id: server.secretId, spaceId: context.spaceId, userId: context.userId },
|
|
});
|
|
return row
|
|
? { material: this.read(row.ciphertext, row.id), secretId: row.id }
|
|
: { material: {} };
|
|
}
|
|
|
|
private createProvider(
|
|
server: ServerRef,
|
|
context: ActorRef,
|
|
loaded: { material: OAuthMaterial; secretId?: string },
|
|
options: ProviderOptions = {},
|
|
): StoredMcpOAuthProvider {
|
|
return new StoredMcpOAuthProvider(
|
|
server.id,
|
|
loaded.material,
|
|
async (material) => {
|
|
await this.replaceMaterial(server.id, material, context, false, server.endpoint);
|
|
},
|
|
options,
|
|
);
|
|
}
|
|
|
|
private async replaceMaterial(
|
|
serverId: string,
|
|
material: OAuthMaterial,
|
|
context: ActorRef,
|
|
incrementRevision: boolean,
|
|
expectedEndpoint?: string | null,
|
|
): Promise<string | undefined> {
|
|
return this.prisma.$transaction(async (tx) => {
|
|
// Serialize every credential rotation across API instances. OAuth
|
|
// providers hold a session snapshot, so merge only their OAuth state
|
|
// into the latest static material after acquiring the lock.
|
|
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext('mcp-oauth-material'), hashtext(${serverId}))`;
|
|
const server = await tx.mcpServer.findFirst({
|
|
where: {
|
|
id: serverId,
|
|
spaceId: context.spaceId,
|
|
userId: context.userId,
|
|
},
|
|
select: { endpoint: true, secretId: true },
|
|
});
|
|
if (!server) throw new Error("MCP server is unavailable");
|
|
if (expectedEndpoint !== undefined && server.endpoint !== expectedEndpoint) {
|
|
throw new Error("MCP server endpoint changed during authorization; reconnect this server");
|
|
}
|
|
const currentSecret = server.secretId
|
|
? await tx.secret.findFirst({
|
|
where: {
|
|
id: server.secretId,
|
|
spaceId: context.spaceId,
|
|
userId: context.userId,
|
|
},
|
|
})
|
|
: null;
|
|
const nextMaterial = currentSecret
|
|
? this.read(currentSecret.ciphertext, currentSecret.id)
|
|
: {};
|
|
if (material.oauth) nextMaterial.oauth = structuredClone(material.oauth);
|
|
else delete nextMaterial.oauth;
|
|
const hasMaterial = Boolean(
|
|
nextMaterial.secret ||
|
|
Object.keys(nextMaterial.env ?? {}).length ||
|
|
Object.keys(nextMaterial.headers ?? {}).length ||
|
|
nextMaterial.oauth,
|
|
);
|
|
const stored = hasMaterial
|
|
? await this.secrets.put(JSON.stringify(nextMaterial), {
|
|
operationId: "mcp.oauth.persist",
|
|
traceId: "mcp.oauth.persist",
|
|
spaceId: context.spaceId,
|
|
userId: context.userId,
|
|
botId: "mcp",
|
|
signal: new AbortController().signal,
|
|
})
|
|
: undefined;
|
|
if (stored) {
|
|
await tx.secret.create({
|
|
data: {
|
|
id: stored.id,
|
|
spaceId: context.spaceId,
|
|
userId: context.userId,
|
|
kind: "mcp",
|
|
ciphertext: stored.ciphertext,
|
|
},
|
|
});
|
|
}
|
|
await tx.mcpServer.update({
|
|
where: { id: serverId },
|
|
data: {
|
|
secretId: stored?.id ?? null,
|
|
...(incrementRevision ? { revision: { increment: 1 } } : {}),
|
|
},
|
|
});
|
|
if (server.secretId && server.secretId !== stored?.id) {
|
|
await tx.secret.deleteMany({ where: { id: server.secretId } });
|
|
}
|
|
return stored?.id;
|
|
});
|
|
}
|
|
|
|
private read(ciphertext: string, recordId: string): OAuthMaterial {
|
|
try {
|
|
const value = JSON.parse(this.secrets.load(ciphertext, recordId));
|
|
return value && typeof value === "object" ? (value as OAuthMaterial) : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
}
|