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>
64 lines
1.9 KiB
TypeScript
64 lines
1.9 KiB
TypeScript
import { db } from "@/lib/db/client";
|
|
import { users } from "@/lib/db/schema";
|
|
import { and, eq } from "drizzle-orm";
|
|
import type { AuthenticatedClaims } from "@/lib/auth/jwt";
|
|
|
|
/**
|
|
* Per-request user context for MCP tool handlers.
|
|
*
|
|
* Resolves (or creates) the internal `users` row from the Authentik OIDC
|
|
* claims so tools work with stable UUID foreign keys rather than raw `sub`
|
|
* strings.
|
|
*/
|
|
export interface UserContext {
|
|
/** Internal users.id UUID. */
|
|
userId: string;
|
|
/** OIDC sub claim (stable identifier from Authentik). */
|
|
sub: string;
|
|
/** OIDC issuer. */
|
|
iss: string;
|
|
/** Optional profile fields if present in the access token. */
|
|
email: string | null;
|
|
name: string | null;
|
|
}
|
|
|
|
export async function userContextFromClaims(claims: AuthenticatedClaims): Promise<UserContext> {
|
|
const email = (claims.email as string | undefined) ?? null;
|
|
const name = (claims.name as string | undefined) ?? null;
|
|
const picture = (claims.picture as string | undefined) ?? null;
|
|
|
|
const row = await db
|
|
.insert(users)
|
|
.values({
|
|
oidcSub: claims.sub,
|
|
oidcIss: claims.iss,
|
|
email,
|
|
name,
|
|
picture,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [users.oidcIss, users.oidcSub],
|
|
set: {
|
|
email,
|
|
name,
|
|
picture,
|
|
lastSeenAt: new Date(),
|
|
},
|
|
})
|
|
.returning({ id: users.id });
|
|
|
|
const userId = row[0]?.id;
|
|
if (!userId) {
|
|
// Race against another upsert — fall back to a select.
|
|
const existing = await db
|
|
.select({ id: users.id })
|
|
.from(users)
|
|
.where(and(eq(users.oidcIss, claims.iss), eq(users.oidcSub, claims.sub)))
|
|
.limit(1);
|
|
if (!existing[0]) throw new Error("user upsert failed and not found on re-read");
|
|
return { userId: existing[0].id, sub: claims.sub, iss: claims.iss, email, name };
|
|
}
|
|
|
|
return { userId, sub: claims.sub, iss: claims.iss, email, name };
|
|
}
|