rspace-online/server/mcp-tools/_auth.ts

103 lines
3.4 KiB
TypeScript

/**
* MCP auth helper — centralised access control for all MCP tools.
*
* Every tool calls resolveAccess() before touching data.
*
* Access matrix:
* public → read open (unless forceAuth), write requires token+member
* permissioned → read requires any token, write requires token+member
* private → read+write require token+member
*/
import { verifyToken } from "../auth";
import type { EncryptIDClaims } from "../auth";
import { resolveCallerRole, roleAtLeast } from "../spaces";
import type { SpaceRoleString } from "../spaces";
import { loadCommunity, getDocumentData, normalizeVisibility } from "../community-store";
export interface AccessResult {
allowed: boolean;
claims: EncryptIDClaims | null;
role: SpaceRoleString;
reason?: string;
}
/**
* Resolve access for an MCP tool call.
*
* @param token JWT string (may be undefined for unauthenticated callers)
* @param space Space slug
* @param forWrite true for mutating operations
* @param forceAuth true to always require token+member (e.g. rinbox)
*/
export async function resolveAccess(
token: string | undefined,
space: string,
forWrite = false,
forceAuth = false,
): Promise<AccessResult> {
// Load space doc
await loadCommunity(space);
const data = getDocumentData(space);
if (!data) {
return { allowed: false, claims: null, role: "viewer", reason: "Space not found or access denied" };
}
const visibility = normalizeVisibility(data.meta.visibility || "private");
// Verify token if provided
let claims: EncryptIDClaims | null = null;
if (token) {
try {
claims = await verifyToken(token);
} catch {
return { allowed: false, claims: null, role: "viewer", reason: "Invalid or expired token" };
}
}
// Resolve caller's role in this space
const resolved = claims ? await resolveCallerRole(space, claims) : null;
const role: SpaceRoleString = resolved?.role ?? "viewer";
const isMember = roleAtLeast(role, "member");
// Write always requires token + member
if (forWrite) {
if (!claims) return { allowed: false, claims, role, reason: "Authentication required" };
if (!isMember) return { allowed: false, claims, role, reason: "Space membership required" };
return { allowed: true, claims, role };
}
// forceAuth → always requires token + member (e.g. email/inbox)
if (forceAuth) {
if (!claims) {
// Don't reveal that the space exists for private spaces
return { allowed: false, claims, role, reason: "Space not found or access denied" };
}
if (!isMember) return { allowed: false, claims, role, reason: "Space membership required" };
return { allowed: true, claims, role };
}
// Read access by visibility
switch (visibility) {
case "public":
return { allowed: true, claims, role };
case "permissioned":
if (!claims) return { allowed: false, claims, role, reason: "Authentication required" };
return { allowed: true, claims, role };
case "private":
if (!claims) return { allowed: false, claims, role, reason: "Space not found or access denied" };
if (!isMember) return { allowed: false, claims, role, reason: "Space not found or access denied" };
return { allowed: true, claims, role };
default:
return { allowed: false, claims, role, reason: "Space not found or access denied" };
}
}
/** Standard MCP error response for denied access. */
export function accessDeniedResponse(reason: string) {
return { content: [{ type: "text" as const, text: JSON.stringify({ error: reason }) }], isError: true };
}