feat: Phase 1 — Authentik auth, MCP endpoint, persistent memory
End-to-end Phase 1 of shared-memory: a logged-in Authentik user can sign into the Web UI (/me debug page), and an MCP client with an Authentik- issued bearer token can call memory.write / memory.list / memory.get / memory.delete plus project.identify against /api/mcp. Stack: - Next.js 15 (App Router) + React 19 + TypeScript, pnpm workspaces - Drizzle ORM + Postgres 16 + pgvector + pg_trgm - Auth.js v5 with Authentik provider (Web UI) - jose + Authentik JWKS for MCP bearer-token validation - JSON-RPC 2.0 dispatcher implementing the MCP wire protocol over plain HTTP POST (hand-rolled to fit Next.js App Router; switches to SSE in a later phase if server-initiated events are needed) - bge-small embeddings sidecar deferred to Phase 2; the schema already reserves the vector(384) column + IVFFlat index, FTS via a STORED tsvector column, and the visibility enum (private/shared/team) so cross-user memory sharing can be added without a future migration Deployment supports two modes (set in .env, never committed): - Behind an external reverse proxy (HAProxy / nginx / Cloudflare Tunnel / Traefik) — DEFAULT; the app exposes APP_PORT on the host with X-Forwarded-* trusted, no in-container TLS - Built-in TLS via Caddy — opt-in with `docker compose --profile tls up` Discovery endpoint at /.well-known/oauth-protected-resource (RFC 9728) points MCP clients at the Authentik authorization server after a 401. README walks through both Authentik providers (Web UI + MCP resource server), the audience scope mapping, redirect URIs, and includes a worked HAProxy config snippet. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import { createRemoteJWKSet, jwtVerify, errors as joseErrors } from "jose";
|
||||
import type { JWTPayload } from "jose";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
/**
|
||||
* Authenticates a bearer token issued by Authentik against the configured
|
||||
* OIDC issuer. Verifies signature (via JWKS), issuer, audience, and expiry.
|
||||
*
|
||||
* Used by the MCP endpoint to authenticate incoming Claude Code requests.
|
||||
* Distinct from the NextAuth session cookie path used by the Web UI.
|
||||
*/
|
||||
|
||||
type GlobalWithJwks = typeof globalThis & {
|
||||
__sharedMemoryJwks?: ReturnType<typeof createRemoteJWKSet>;
|
||||
};
|
||||
const g = globalThis as GlobalWithJwks;
|
||||
|
||||
function jwks() {
|
||||
if (g.__sharedMemoryJwks) return g.__sharedMemoryJwks;
|
||||
// Authentik discovery is at `${issuer}/.well-known/openid-configuration`;
|
||||
// the JWKS URI is normally `${issuer}/jwks/` or `${issuer}/.well-known/jwks.json`.
|
||||
// Authentik canonically serves `${issuer}/jwks/`.
|
||||
const issuer = env().OIDC_ISSUER.replace(/\/$/, "");
|
||||
const url = new URL(`${issuer}/jwks/`);
|
||||
g.__sharedMemoryJwks = createRemoteJWKSet(url, {
|
||||
cacheMaxAge: 10 * 60 * 1000, // 10 min
|
||||
cooldownDuration: 30 * 1000,
|
||||
});
|
||||
return g.__sharedMemoryJwks;
|
||||
}
|
||||
|
||||
export interface AuthenticatedClaims extends JWTPayload {
|
||||
sub: string;
|
||||
iss: string;
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends Error {
|
||||
constructor(
|
||||
public readonly reason: string,
|
||||
public readonly wwwAuthenticate: string,
|
||||
) {
|
||||
super(reason);
|
||||
this.name = "UnauthorizedError";
|
||||
}
|
||||
}
|
||||
|
||||
function buildWwwAuthenticate(error?: string, description?: string): string {
|
||||
const parts: string[] = [`Bearer realm="OAuth"`];
|
||||
// RFC 9728 — point clients at our protected-resource metadata so they can
|
||||
// discover the authorization server.
|
||||
parts.push(`resource_metadata="${env().PUBLIC_URL.replace(/\/$/, "")}/.well-known/oauth-protected-resource"`);
|
||||
if (error) parts.push(`error="${error}"`);
|
||||
if (description) parts.push(`error_description="${description.replace(/"/g, "'")}"`);
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
export async function authenticateBearer(authHeader: string | null): Promise<AuthenticatedClaims> {
|
||||
if (!authHeader || !authHeader.toLowerCase().startsWith("bearer ")) {
|
||||
throw new UnauthorizedError("missing bearer token", buildWwwAuthenticate());
|
||||
}
|
||||
|
||||
const token = authHeader.slice("bearer ".length).trim();
|
||||
if (!token) {
|
||||
throw new UnauthorizedError("empty bearer token", buildWwwAuthenticate("invalid_token"));
|
||||
}
|
||||
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, jwks(), {
|
||||
issuer: env().OIDC_ISSUER,
|
||||
audience: env().OIDC_AUDIENCE,
|
||||
});
|
||||
if (!payload.sub) {
|
||||
throw new UnauthorizedError(
|
||||
"token missing sub claim",
|
||||
buildWwwAuthenticate("invalid_token", "missing sub"),
|
||||
);
|
||||
}
|
||||
return payload as AuthenticatedClaims;
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) throw err;
|
||||
const desc =
|
||||
err instanceof joseErrors.JWTExpired
|
||||
? "token expired"
|
||||
: err instanceof joseErrors.JWTInvalid
|
||||
? "token invalid"
|
||||
: err instanceof joseErrors.JWTClaimValidationFailed
|
||||
? `claim invalid: ${err.claim}`
|
||||
: "verification failed";
|
||||
throw new UnauthorizedError(desc, buildWwwAuthenticate("invalid_token", desc));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user