diff --git a/.env.example b/.env.example index 3060e44..5cc0972 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,13 @@ EMBEDDING_DIM=384 # ----------------------------------------------------------------------------- NEXTAUTH_SECRET=replace-me-with-32-bytes-of-random +# ----------------------------------------------------------------------------- +# CLI token signing key. Used to mint HMAC-signed JWTs from /connect for +# pasting into MCP clients (Claude Code etc.). Rotate to invalidate all +# outstanding CLI tokens at once. Generate with: openssl rand -base64 32 +# ----------------------------------------------------------------------------- +CLI_TOKEN_SECRET=replace-me-with-32-bytes-of-random + # ----------------------------------------------------------------------------- # App # ----------------------------------------------------------------------------- diff --git a/apps/web/app/connect/connect-form.tsx b/apps/web/app/connect/connect-form.tsx new file mode 100644 index 0000000..036c691 --- /dev/null +++ b/apps/web/app/connect/connect-form.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useActionState } from "react"; + +interface State { + token: string | null; + error: string | null; +} + +interface Props { + action: (prev: State) => Promise; + ttlDays: number; +} + +const initial: State = { token: null, error: null }; + +export default function ConnectForm({ action, ttlDays }: Props) { + const [state, formAction, pending] = useActionState(action, initial); + + return ( +
+ {state.token ? ( + <> +

+ New token (copy now — won't be shown again) +

+
+            {state.token}
+          
+

Add to Claude Code

+
{`claude mcp add --transport http \\
+  --header "Authorization: Bearer ${state.token}" \\
+  shared-memory https://memory.dnspegasus.net/api/mcp`}
+

+ Valid for {ttlDays} days. To revoke all outstanding CLI tokens at + once, rotate CLI_TOKEN_SECRET on the server. +

+ + ) : ( +
+ + {state.error ? ( +

error: {state.error}

+ ) : null} +

+ Tokens carry your full Authentik identity. Valid for {ttlDays}{" "} + days. Treat them like a password. +

+
+ )} +
+ ); +} diff --git a/apps/web/app/connect/page.tsx b/apps/web/app/connect/page.tsx new file mode 100644 index 0000000..36de122 --- /dev/null +++ b/apps/web/app/connect/page.tsx @@ -0,0 +1,73 @@ +import { redirect } from "next/navigation"; +import { eq } from "drizzle-orm"; +import { auth } from "@/auth"; +import { db } from "@/lib/db/client"; +import { users } from "@/lib/db/schema"; +import { mintCliToken, CLI_TOKEN_TTL_SECONDS } from "@/lib/auth/cli-token"; +import ConnectForm from "./connect-form"; + +export const dynamic = "force-dynamic"; + +/** + * Server action — mints a fresh CLI token for the currently signed-in user. + * + * Returned via useActionState to the client; the token only ever exists in + * React state, never in the URL or a persisted cookie. + */ +async function generateToken(_prev: { token: string | null; error: string | null }) { + "use server"; + try { + const session = await auth(); + if (!session?.user?.id) return { token: null, error: "not authenticated" }; + + const row = await db + .select({ + oidcIss: users.oidcIss, + oidcSub: users.oidcSub, + email: users.email, + name: users.name, + }) + .from(users) + .where(eq(users.id, session.user.id)) + .limit(1); + const u = row[0]; + if (!u) return { token: null, error: "user row not found" }; + + const token = await mintCliToken({ + oidcIss: u.oidcIss, + oidcSub: u.oidcSub, + email: u.email, + name: u.name, + }); + return { token, error: null }; + } catch (e) { + return { token: null, error: e instanceof Error ? e.message : "unknown error" }; + } +} + +export default async function ConnectPage() { + const session = await auth(); + if (!session?.user) { + redirect("/api/auth/signin?callbackUrl=/connect"); + } + + const ttlDays = Math.floor(CLI_TOKEN_TTL_SECONDS / 86400); + const userLabel = session.user.email ?? session.user.name ?? session.user.id; + + return ( +
+

Connect an MCP client

+

+ Generate a bearer token for pasting into Claude Code (or any MCP + client) when an OAuth loopback callback isn't practical — for + example, a Claude Code instance running inside a container. +

+ +

+ Signed in as {userLabel}. +

+ + +
+ ); +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index f93f432..0113b0e 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -27,8 +27,13 @@ export default async function HomePage() {

MCP endpoint

- Connect a Claude Code session to /api/mcp with a bearer token - issued by Authentik for this resource. See the README for setup steps. + Connect a Claude Code session to /api/mcp with a bearer + token. For containerized clients without OAuth loopback,{" "} + {session?.user ? ( + generate a CLI token → + ) : ( + <>sign in and visit /connect + )}

); diff --git a/apps/web/lib/auth/cli-token.ts b/apps/web/lib/auth/cli-token.ts new file mode 100644 index 0000000..0153562 --- /dev/null +++ b/apps/web/lib/auth/cli-token.ts @@ -0,0 +1,80 @@ +import { SignJWT, jwtVerify, decodeProtectedHeader } from "jose"; +import type { JWTPayload } from "jose"; +import { env } from "@/lib/env"; + +/** + * "CLI tokens" are HMAC-signed JWTs minted on demand from the /connect page + * after the user logs into the Web UI via Authentik. They're suitable for + * pasting into an MCP client's Authorization header on machines where the + * OAuth loopback callback isn't reachable (containers, headless setups). + * + * Trust model: we trust whoever holds CLI_TOKEN_SECRET. Verification is a + * local HMAC check — no JWKS roundtrip. To revoke ALL outstanding CLI + * tokens, rotate CLI_TOKEN_SECRET. + * + * The payload carries the user's real Authentik identity in `iss` + `sub` + * so the same `users` row resolution path works for both token kinds. + * + * Dispatch from the standard Authentik verifier is by the `kid` header: + * CLI tokens set `kid: "cli-v1"`, Authentik tokens carry whatever key id + * the JWKS published. + */ + +export const CLI_TOKEN_KID = "cli-v1"; +export const CLI_TOKEN_ISSUER = "shared-memory:cli"; +export const CLI_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30; // 30 days + +function secret(): Uint8Array { + return new TextEncoder().encode(env().CLI_TOKEN_SECRET); +} + +export interface CliTokenSubject { + oidcIss: string; + oidcSub: string; + email?: string | null; + name?: string | null; +} + +export async function mintCliToken(subject: CliTokenSubject): Promise { + return await new SignJWT({ + oidc_iss: subject.oidcIss, + oidc_sub: subject.oidcSub, + email: subject.email ?? undefined, + name: subject.name ?? undefined, + }) + .setProtectedHeader({ alg: "HS256", typ: "JWT", kid: CLI_TOKEN_KID }) + .setIssuer(CLI_TOKEN_ISSUER) + .setSubject(subject.oidcSub) + .setAudience(env().OIDC_AUDIENCE) + .setIssuedAt() + .setExpirationTime(`${CLI_TOKEN_TTL_SECONDS}s`) + .sign(secret()); +} + +export interface CliClaims extends JWTPayload { + sub: string; + iss: string; + oidc_iss: string; + oidc_sub: string; +} + +export async function verifyCliToken(token: string): Promise { + const { payload } = await jwtVerify(token, secret(), { + issuer: CLI_TOKEN_ISSUER, + audience: env().OIDC_AUDIENCE, + }); + if (typeof payload.oidc_iss !== "string" || typeof payload.oidc_sub !== "string") { + throw new Error("CLI token missing oidc_iss/oidc_sub claims"); + } + return payload as CliClaims; +} + +/** Peek at the `kid` header without verifying. Used to pick a verifier. */ +export function tokenKid(token: string): string | undefined { + try { + const header = decodeProtectedHeader(token); + return typeof header.kid === "string" ? header.kid : undefined; + } catch { + return undefined; + } +} diff --git a/apps/web/lib/auth/jwt.ts b/apps/web/lib/auth/jwt.ts index a1ff4ec..512f0a6 100644 --- a/apps/web/lib/auth/jwt.ts +++ b/apps/web/lib/auth/jwt.ts @@ -1,13 +1,21 @@ import { createRemoteJWKSet, jwtVerify, errors as joseErrors } from "jose"; import type { JWTPayload } from "jose"; import { env } from "@/lib/env"; +import { CLI_TOKEN_KID, tokenKid, verifyCliToken } from "./cli-token"; /** - * Authenticates a bearer token issued by Authentik against the configured - * OIDC issuer. Verifies signature (via JWKS), issuer, audience, and expiry. + * Authenticates a bearer token presented to the MCP endpoint. Two token + * kinds are accepted, dispatched by the JWT `kid` header: * - * Used by the MCP endpoint to authenticate incoming Claude Code requests. - * Distinct from the NextAuth session cookie path used by the Web UI. + * - Authentik-issued OIDC access tokens (any kid) — verified against + * Authentik's JWKS over the network. + * - CLI tokens minted at /connect (kid="cli-v1") — verified locally + * with the HMAC CLI_TOKEN_SECRET. + * + * Both resolve to the same `AuthenticatedClaims` shape so downstream code + * (`userContextFromClaims`) doesn't care which path produced them. + * + * This is distinct from the NextAuth session cookie path used by the Web UI. */ type GlobalWithJwks = typeof globalThis & { @@ -64,7 +72,24 @@ export async function authenticateBearer(authHeader: string | null): Promise