metatron-open-seo/src/server/mcp/api-key-auth.ts

178 lines
6.7 KiB
TypeScript

import { getAuth, getHostedBaseUrl } from "@/lib/auth";
import { API_KEY_PREFIX } from "@/lib/auth-api-key";
import { MCP_OAUTH_SCOPES } from "@/lib/oauth-resource";
import { resolveExistingActiveHostedOrganization } from "@/server/auth/default-hosted-organization";
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
import { recordMcpAuthorized } from "@/server/features/activation/mcpActivation";
import { createWorkersOAuthMcpProps, MCP_ROUTE } from "@/server/mcp/context";
import { handleAuthenticatedOpenSeoMcpRequest } from "@/server/mcp/transport";
function getApiKey(request: Request) {
// Both branches require the oseo_ prefix: anything else (a Cloudflare OAuth
// access token, a stray foreign x-api-key header) falls through to the
// OAuth provider instead of being consumed here.
const headerKey = request.headers.get("x-api-key");
if (headerKey?.startsWith(API_KEY_PREFIX)) return headerKey;
const bearerToken = request.headers
.get("Authorization")
?.replace(/^Bearer /i, "");
if (bearerToken?.startsWith(API_KEY_PREFIX)) return bearerToken;
return null;
}
function apiKeyErrorResponse(
error: { code?: string | null; message?: unknown; details?: unknown } | null,
) {
const headers = new Headers({ "Content-Type": "application/json" });
const isLimited =
error?.code === "RATE_LIMITED" || error?.code === "USAGE_EXCEEDED";
const isForbidden = error?.code === "FORBIDDEN";
if (isLimited) {
// The plugin reports tryAgainIn (milliseconds) for RATE_LIMITED, but its
// published error type omits `details`, so narrow at runtime.
const details = error?.details;
const tryAgainInMs =
typeof details === "object" && details !== null && "tryAgainIn" in details
? details.tryAgainIn
: undefined;
if (typeof tryAgainInMs === "number" && Number.isFinite(tryAgainInMs)) {
headers.set(
"Retry-After",
String(Math.max(1, Math.ceil(tryAgainInMs / 1000))),
);
}
}
const code = isLimited
? error?.code === "RATE_LIMITED"
? "rate_limited"
: "usage_exceeded"
: isForbidden
? "account_access_revoked"
: "invalid_api_key";
const description = isLimited
? typeof error?.message === "string"
? error.message
: "API key request limit reached"
: isForbidden
? typeof error?.message === "string"
? error.message
: "This API key is no longer associated with an organization"
: "The provided API key is invalid, expired, or disabled";
const status = isLimited ? 429 : isForbidden ? 403 : 401;
// Bad credentials are client-side noise, so 401 logs at debug (mirroring the
// OAuth path); a 429 means we actually cut a caller off, so warn.
const line = `[mcp-api-key] ${status} ${code} (${error?.code ?? "no_hosted_user"})`;
if (status === 429) {
console.warn(line);
} else {
console.debug(line);
}
return new Response(
JSON.stringify({ error: code, error_description: description }),
{ status, headers },
);
}
export async function handleMcpApiKeyRequest(
request: Request,
env: unknown,
ctx: ExecutionContext,
): Promise<Response | null> {
const url = new URL(request.url);
if (url.pathname !== MCP_ROUTE || request.method === "OPTIONS") return null;
const apiKey = getApiKey(request);
if (!apiKey) return null;
try {
// Keep API keys scoped to /mcp: verifyApiKey (rather than Better Auth's
// enableSessionForAPIKeys mock sessions) means a key never becomes a
// session that could reach account or organization endpoints.
const authApi = getAuth().api;
const result = await authApi.verifyApiKey({ body: { key: apiKey } });
if (!result.valid || !result.key) {
return apiKeyErrorResponse(result.error);
}
const userId = result.key.referenceId;
// Per-user request throttle. The binding is declared in alchemy.run.ts
// (hosted prod only); local dev and self-host run without it and skip
// limiting, which is fine single-user. This replaces the better-auth
// plugin limiter, whose broken idle-gap window hard-blocked active MCP
// clients (see lib/auth-api-key.ts). Cloudflare's counter is per-colo
// best-effort, which is all this needs to be: credits bound spend, this
// bounds runaway request volume.
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the binding is declared as a rate limiter in alchemy.run.ts; absent outside hosted prod
const rateLimit = (env as { MCP_RATE_LIMIT?: RateLimit }).MCP_RATE_LIMIT;
if (rateLimit) {
const { success } = await rateLimit.limit({ key: userId });
if (!success) {
return apiKeyErrorResponse({
code: "RATE_LIMITED",
message: "Rate limit exceeded: 5000 requests per minute",
details: { tryAgainIn: 60_000 },
});
}
}
const user = await AuthRepository.getHostedUser(userId);
if (!user?.email) return apiKeyErrorResponse(null);
// API keys bill the user's active organization. Keys are user-scoped and
// the org derives from the project each tool call names (project-level
// authz) — not key→org binding. Fail closed if the user has no existing
// memberships; we never mint a default organization for an API key.
const resolved = await resolveExistingActiveHostedOrganization(userId);
if (!resolved) {
return apiKeyErrorResponse({
code: "FORBIDDEN",
message: "API key is not associated with an organization",
});
}
const { organizationId, role } = resolved;
// clientId "api_key" satisfies the hosted transport's fail-closed props
// schema and counts these calls as external MCP clients in telemetry.
// orgScope "user": the key itself is the credential, not a key→org
// binding — project-scoped tools authorize per call via the caller's
// membership in the project's org, and organizationId above is only the
// fallback for tools with no project argument.
const props = createWorkersOAuthMcpProps({
userId,
userEmail: user.email,
organizationId,
role,
orgScope: "user",
baseUrl: getHostedBaseUrl(),
scopes: [...MCP_OAUTH_SCOPES],
clientId: "api_key",
});
await recordMcpAuthorized(organizationId);
return await handleAuthenticatedOpenSeoMcpRequest(request, props, env, ctx);
} catch (error) {
// Without this, a throw here surfaces as a bare platform 500 with no log
// breadcrumb.
console.error("[mcp-api-key] 500 (internal):", error);
return new Response(
JSON.stringify({
error: "internal_error",
error_description: "API key authentication failed",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
},
);
}
}