managarten/services/mana-mcp/src/auth.ts
Till JS 16c8818338 feat(mcp): M1+M1.5 MCP gateway + tool-registry + shared-crypto
Foundation for autonomous Claude-driven testing. Plan:
docs/plans/mana-mcp-and-personas.md.

New packages
- @mana/tool-registry — schema-first ToolSpec<InputSchema, OutputSchema>
  with zod generics, scope ('user-space' | 'admin') and policyHint
  ('read' | 'write' | 'destructive'). sync-client helpers speak the
  mana-sync push/pull protocol directly so RLS and field-level LWW are
  preserved. MasterKeyClient fetches per-user MKs via the existing
  mana-auth GET /api/v1/me/encryption-vault/key endpoint (JWT-gated,
  ZK-aware, already audited) — no new service-key endpoint built.
  ZeroKnowledgeUserError surfaced as a typed throw.
- @mana/shared-crypto — AES-GCM-256 primitives extracted from the web
  app's $lib/data/crypto/aes.ts so the server-side tool handlers and the
  browser produce byte-for-byte identical wire format
  (enc:1:{b64(iv)}.{b64(ct)}). Web app aes.ts now re-exports from
  shared-crypto — 5 existing importers unchanged, svelte-check stays
  green.

New service
- services/mana-mcp (:3069, Bun/Hono) — MCP Streamable HTTP gateway.
  JWKS auth against mana-auth, per-user session isolation (session-id
  belongs to the user who opened it — cross-user access returns 403),
  admin-scoped tools filtered out before registration. MasterKeyClient
  cached per process with a 5-minute TTL.

11 tools registered
- habits.{create,list,update,archive}, spaces.list (plaintext, M1)
- todo.{create,list,complete}, notes.{create,search}, journal.add
  (encrypted — field lists match
  apps/mana/apps/web/src/lib/data/crypto/registry.ts verbatim)

Infra
- Port 3069 added to docs/PORT_SCHEMA.md
- services/mana-mcp/CLAUDE.md with architecture, auth model,
  tool-authoring recipe, local smoke-test steps
- Root CLAUDE.md services list updated

Type-check green across shared-crypto, mana-tool-registry, mana-mcp.
svelte-check on apps/mana/apps/web stays at 0 errors / 0 warnings.
Boot smoke verified: /health returns registry.loaded=true, unauthed
/mcp → 401, invalid-JWT /mcp → 401 with descriptive message.

Decisions locked in for later milestones (per plan D1–D10):
- Personas will be real mana-auth users (users.kind='persona'), no
  service-key bypass (D1, D2)
- Tool-registry is the SSOT; mana-ai and the legacy
  apps/api/src/mcp/server.ts get merged into it in M4 (three current
  parallel tool catalogs collapse to one)
- Persona-runner (:3070) will be a separate service using the Claude
  Agent SDK + MCP client (D5)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:18:35 +02:00

93 lines
3 KiB
TypeScript

/**
* JWT verification for MCP requests.
*
* Mirrors the pattern in services/mana-research/src/middleware/jwt-auth.ts —
* JWKS-cached verification against mana-auth, audience pinned to "mana".
*
* Returns the resolved user context (or throws 401) so the MCP transport
* handler can hand it directly to the registry adapter.
*/
import { createRemoteJWKSet, jwtVerify } from 'jose';
export interface VerifiedUser {
userId: string;
email: string;
role: string;
tier: string;
/** Active Space at the moment of the request. May be overridden by `X-Mana-Space`. */
spaceId: string;
/** The raw JWT, forwarded to downstream services in tool handlers. */
jwt: string;
}
let cachedJwks: ReturnType<typeof createRemoteJWKSet> | null = null;
let cachedJwksUrl: string | null = null;
function getJwks(authUrl: string): ReturnType<typeof createRemoteJWKSet> {
const url = `${authUrl}/api/auth/jwks`;
if (cachedJwks && cachedJwksUrl === url) return cachedJwks;
cachedJwks = createRemoteJWKSet(new URL(url));
cachedJwksUrl = url;
return cachedJwks;
}
export class UnauthorizedError extends Error {
constructor(message: string) {
super(message);
this.name = 'UnauthorizedError';
}
}
export async function verifyJwt(
token: string,
authUrl: string,
audience: string
): Promise<Omit<VerifiedUser, 'spaceId' | 'jwt'>> {
try {
const { payload } = await jwtVerify(token, getJwks(authUrl), { audience });
const userId = (payload.sub as string | undefined) ?? '';
if (!userId) throw new UnauthorizedError('Token has no `sub` claim');
return {
userId,
email: (payload.email as string | undefined) ?? '',
role: (payload.role as string | undefined) ?? 'user',
tier: (payload.tier as string | undefined) ?? 'public',
};
} catch (err) {
if (err instanceof UnauthorizedError) throw err;
throw new UnauthorizedError(
err instanceof Error ? `Invalid token: ${err.message}` : 'Invalid token'
);
}
}
/**
* Pull `Authorization: Bearer ...` and `X-Mana-Space: ...` out of an
* incoming Request, verify the token, and return the assembled user
* context. Throws UnauthorizedError on any auth failure.
*/
export async function authenticateRequest(
req: Request,
authUrl: string,
audience: string
): Promise<VerifiedUser> {
const header = req.headers.get('authorization');
if (!header || !header.startsWith('Bearer ')) {
throw new UnauthorizedError('Missing or malformed Authorization header');
}
const token = header.slice('Bearer '.length).trim();
const verified = await verifyJwt(token, authUrl, audience);
const spaceHeader = req.headers.get('x-mana-space');
const spaceId = spaceHeader && spaceHeader.length > 0 ? spaceHeader : '';
if (!spaceId) {
// We *could* default to the user's personal Space, but resolving that
// requires another round-trip to mana-auth. For M1 we require the
// caller to set X-Mana-Space explicitly — Persona-Runner and Claude
// Desktop both set it from `spaces.list` results.
throw new UnauthorizedError('Missing X-Mana-Space header (set to the active Space ID)');
}
return { ...verified, spaceId, jwt: token };
}