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>
81 lines
2.6 KiB
TypeScript
81 lines
2.6 KiB
TypeScript
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;
|
|
}
|
|
}
|