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:
@@ -59,6 +59,13 @@ EMBEDDING_DIM=384
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
NEXTAUTH_SECRET=replace-me-with-32-bytes-of-random
|
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
|
# App
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState } from "react";
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
token: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
action: (prev: State) => Promise<State>;
|
||||||
|
ttlDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initial: State = { token: null, error: null };
|
||||||
|
|
||||||
|
export default function ConnectForm({ action, ttlDays }: Props) {
|
||||||
|
const [state, formAction, pending] = useActionState(action, initial);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ marginTop: "2rem" }}>
|
||||||
|
{state.token ? (
|
||||||
|
<>
|
||||||
|
<h2 style={{ color: "#7ee787" }}>
|
||||||
|
New token (copy now — won't be shown again)
|
||||||
|
</h2>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-all",
|
||||||
|
userSelect: "all",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{state.token}
|
||||||
|
</pre>
|
||||||
|
<h3>Add to Claude Code</h3>
|
||||||
|
<pre>{`claude mcp add --transport http \\
|
||||||
|
--header "Authorization: Bearer ${state.token}" \\
|
||||||
|
shared-memory https://memory.dnspegasus.net/api/mcp`}</pre>
|
||||||
|
<p className="muted">
|
||||||
|
Valid for {ttlDays} days. To revoke all outstanding CLI tokens at
|
||||||
|
once, rotate <code>CLI_TOKEN_SECRET</code> on the server.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<form action={formAction}>
|
||||||
|
<button type="submit" disabled={pending}>
|
||||||
|
{pending ? "Generating…" : "Generate token"}
|
||||||
|
</button>
|
||||||
|
{state.error ? (
|
||||||
|
<p style={{ color: "#ff6b6b" }}>error: {state.error}</p>
|
||||||
|
) : null}
|
||||||
|
<p className="muted" style={{ marginTop: "0.75rem" }}>
|
||||||
|
Tokens carry your full Authentik identity. Valid for {ttlDays}{" "}
|
||||||
|
days. Treat them like a password.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,8 +27,13 @@ export default async function HomePage() {
|
|||||||
<hr style={{ borderColor: "var(--border)", margin: "2rem 0" }} />
|
<hr style={{ borderColor: "var(--border)", margin: "2rem 0" }} />
|
||||||
<h2>MCP endpoint</h2>
|
<h2>MCP endpoint</h2>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Connect a Claude Code session to <code>/api/mcp</code> with a bearer token
|
Connect a Claude Code session to <code>/api/mcp</code> with a bearer
|
||||||
issued by Authentik for this resource. See the README for setup steps.
|
token. For containerized clients without OAuth loopback,{" "}
|
||||||
|
{session?.user ? (
|
||||||
|
<Link href="/connect">generate a CLI token →</Link>
|
||||||
|
) : (
|
||||||
|
<>sign in and visit <code>/connect</code></>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<string> {
|
||||||
|
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<CliClaims> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
import { createRemoteJWKSet, jwtVerify, errors as joseErrors } from "jose";
|
import { createRemoteJWKSet, jwtVerify, errors as joseErrors } from "jose";
|
||||||
import type { JWTPayload } from "jose";
|
import type { JWTPayload } from "jose";
|
||||||
import { env } from "@/lib/env";
|
import { env } from "@/lib/env";
|
||||||
|
import { CLI_TOKEN_KID, tokenKid, verifyCliToken } from "./cli-token";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authenticates a bearer token issued by Authentik against the configured
|
* Authenticates a bearer token presented to the MCP endpoint. Two token
|
||||||
* OIDC issuer. Verifies signature (via JWKS), issuer, audience, and expiry.
|
* kinds are accepted, dispatched by the JWT `kid` header:
|
||||||
*
|
*
|
||||||
* Used by the MCP endpoint to authenticate incoming Claude Code requests.
|
* - Authentik-issued OIDC access tokens (any kid) — verified against
|
||||||
* Distinct from the NextAuth session cookie path used by the Web UI.
|
* 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 & {
|
type GlobalWithJwks = typeof globalThis & {
|
||||||
@@ -64,7 +72,24 @@ export async function authenticateBearer(authHeader: string | null): Promise<Aut
|
|||||||
throw new UnauthorizedError("empty bearer token", buildWwwAuthenticate("invalid_token"));
|
throw new UnauthorizedError("empty bearer token", buildWwwAuthenticate("invalid_token"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dispatch by kid: CLI tokens are verified locally, everything else goes
|
||||||
|
// through Authentik JWKS. We never attempt JWKS verification for CLI
|
||||||
|
// tokens (or vice versa) so a kid mismatch fails fast.
|
||||||
|
const isCliToken = tokenKid(token) === CLI_TOKEN_KID;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (isCliToken) {
|
||||||
|
const claims = await verifyCliToken(token);
|
||||||
|
// CLI tokens carry the user's real Authentik identity in oidc_iss /
|
||||||
|
// oidc_sub. Surface those on the standard claims shape so user
|
||||||
|
// context resolution is identical to the Authentik path.
|
||||||
|
return {
|
||||||
|
...claims,
|
||||||
|
iss: claims.oidc_iss,
|
||||||
|
sub: claims.oidc_sub,
|
||||||
|
} as AuthenticatedClaims;
|
||||||
|
}
|
||||||
|
|
||||||
const { payload } = await jwtVerify(token, jwks(), {
|
const { payload } = await jwtVerify(token, jwks(), {
|
||||||
issuer: env().OIDC_ISSUER,
|
issuer: env().OIDC_ISSUER,
|
||||||
audience: env().OIDC_AUDIENCE,
|
audience: env().OIDC_AUDIENCE,
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ const envSchema = z.object({
|
|||||||
// NextAuth
|
// NextAuth
|
||||||
NEXTAUTH_SECRET: z.string().min(32, "NEXTAUTH_SECRET must be at least 32 chars"),
|
NEXTAUTH_SECRET: z.string().min(32, "NEXTAUTH_SECRET must be at least 32 chars"),
|
||||||
|
|
||||||
|
// Signing key for CLI tokens minted at /connect. Rotate this to invalidate
|
||||||
|
// every issued CLI token at once.
|
||||||
|
CLI_TOKEN_SECRET: z.string().min(32, "CLI_TOKEN_SECRET must be at least 32 chars"),
|
||||||
|
|
||||||
// Behavior flags
|
// Behavior flags
|
||||||
ALLOW_INSECURE_HTTP: Bool.optional().default(false),
|
ALLOW_INSECURE_HTTP: Bool.optional().default(false),
|
||||||
});
|
});
|
||||||
@@ -72,6 +76,7 @@ function buildPhaseStub(): Env {
|
|||||||
EMBEDDING_MODEL: "Xenova/bge-small-en-v1.5",
|
EMBEDDING_MODEL: "Xenova/bge-small-en-v1.5",
|
||||||
EMBEDDING_DIM: 384,
|
EMBEDDING_DIM: 384,
|
||||||
NEXTAUTH_SECRET: "build-phase-secret-not-used-at-runtime-xxxxxxxx",
|
NEXTAUTH_SECRET: "build-phase-secret-not-used-at-runtime-xxxxxxxx",
|
||||||
|
CLI_TOKEN_SECRET: "build-phase-secret-not-used-at-runtime-xxxxxxxx",
|
||||||
ALLOW_INSECURE_HTTP: false,
|
ALLOW_INSECURE_HTTP: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user