feat(auth): CLI tokens minted at /connect for containerized MCP clients

Adds a second token kind alongside Authentik OIDC access tokens for MCP
authentication. When the user visits /connect after signing into the Web
UI, the server mints an HMAC-signed JWT (kid="cli-v1") carrying their
Authentik identity in oidc_iss / oidc_sub claims. The token is shown
once in React state — never put in the URL or persisted on the client.

The MCP endpoint's bearer-token verifier dispatches by JWT `kid` header:
CLI tokens are verified locally via HS256(CLI_TOKEN_SECRET); everything
else goes through Authentik JWKS. Both paths resolve to the same
AuthenticatedClaims shape so userContextFromClaims handles them
identically.

This unblocks MCP clients running in containers where the OAuth loopback
callback isn't reachable — paste the token into Claude Code as a static
Authorization header and skip the OAuth flow entirely.

Revocation in v1 is "rotate CLI_TOKEN_SECRET to invalidate every issued
CLI token at once." Per-token revocation can come later if needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 08:36:11 -07:00
co-authored by Claude Opus 4.7
parent 7ab6dfe6f6
commit 2bd8ea705d
7 changed files with 262 additions and 6 deletions
+73
View File
@@ -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 (
<main className="container">
<h1>Connect an MCP client</h1>
<p className="muted">
Generate a bearer token for pasting into Claude Code (or any MCP
client) when an OAuth loopback callback isn&apos;t practical for
example, a Claude Code instance running inside a container.
</p>
<p>
Signed in as <strong>{userLabel}</strong>.
</p>
<ConnectForm action={generateToken} ttlDays={ttlDays} />
</main>
);
}