/* * Client for a lard memory server (../lard) — kloe's optional memory layer. * * Auth is the OAuth 2.1 device authorization grant (RFC 8628), PER kloe user: * each user's token is stored in the DB keyed by their `sub` (store.ts * lard_tokens) and refreshed lazily. Everything is gated by the deployment-level * `lard` config (which lard, which OAuth client); when it's absent/disabled or a * user isn't connected, callers simply don't reach here. * * Discovery follows RFC 9728 → RFC 8414: ask lard which authorization server * protects it, then ask that server where its device + token endpoints are. * Nothing about the provider is hardcoded (mirrors lard's own client). */ import { createHash, randomBytes } from "node:crypto"; import { authEnabled, getSession, parseCookies } from "./auth"; import { getConfig } from "./settings"; import type { LardToken, Store } from "./store"; /** The implicit user when kloe auth is disabled (single-user/local). */ export const LOCAL_SUB = "local"; const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"; const trimSlash = (s: string): string => s.replace(/\/+$/, ""); const cfg = () => getConfig().lard; export function lardEnabled(): boolean { return cfg().enabled && !!cfg().baseUrl; } export function lardConnected(store: Store, sub: string): boolean { return !!store.getLardToken(sub); } async function getJSON(url: string): Promise { const res = await fetch(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(cfg().timeoutMs), }); if (!res.ok) throw new Error(`lard: ${url} → ${res.status}`); return res.json() as Promise; } // ---- discovery (cached) -------------------------------------------------- // `resource` is lard's own canonical identifier from its protected-resource // metadata — the exact string we must send as the RFC 8707 `resource` (and that // lard checks the token's `aud` against). Sending its advertised value rather // than a guess from baseUrl avoids audience mismatches if the two ever diverge. interface Endpoints { authorization?: string; token: string; deviceAuthorization: string; revocation?: string; resource: string; } let discoveryCache: Promise | null = null; export function resetLardCache(): void { discoveryCache = null; } function discover(): Promise { if (!discoveryCache) { discoveryCache = (async () => { const base = trimSlash(cfg().baseUrl); const prm = await getJSON<{ authorization_servers?: string[]; resource?: string }>( base + "/.well-known/oauth-protected-resource", ); const as = trimSlash(prm.authorization_servers?.[0] ?? ""); if (!as) throw new Error("lard: the server advertises no authorization server"); const meta = await getJSON<{ authorization_endpoint?: string; token_endpoint?: string; device_authorization_endpoint?: string; revocation_endpoint?: string; }>(as + "/.well-known/oauth-authorization-server"); if (!meta.token_endpoint || !meta.device_authorization_endpoint) throw new Error("lard: authorization server metadata is missing endpoints"); return { authorization: meta.authorization_endpoint, token: meta.token_endpoint, deviceAuthorization: meta.device_authorization_endpoint, revocation: meta.revocation_endpoint, resource: prm.resource || base, }; })().catch((e) => { discoveryCache = null; throw e; }); } return discoveryCache; } // The single OAuth client identity kloe presents to lard's AS, used by EVERY // flow (device grant, auth-code connect, refresh) so a token can always be // refreshed with the same client that obtained it. A dedicated lard.clientId // wins; otherwise reuse kloe's own auth client (confidential clientId+secret, or // its CIMD doc); as a last resort ask lard which collector client to be. async function resolveClient(): Promise<{ id: string; secret?: string }> { const l = cfg(), a = getConfig().auth; if (l.clientId) return { id: l.clientId, secret: l.clientSecret || undefined }; if (a.clientId || a.baseUrl) return { id: a.clientId || trimSlash(a.baseUrl) + "/client-metadata.json", secret: a.clientSecret || undefined, }; const reg = await getJSON<{ client_id?: string }>(trimSlash(cfg().baseUrl) + "/auth/collector"); if (!reg.client_id) throw new Error("lard: no client configured and the server publishes none"); return { id: reg.client_id }; } function tokenFrom(j: { access_token: string; refresh_token?: string; expires_in?: number; }): LardToken { return { accessToken: j.access_token, refreshToken: j.refresh_token || undefined, expiresAt: j.expires_in ? Date.now() + j.expires_in * 1000 : 0, }; } async function postForm(url: string, form: URLSearchParams): Promise { return fetch(url, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" }, body: form, signal: AbortSignal.timeout(cfg().timeoutMs), }); } // A token-endpoint request carrying the client id, its secret if confidential, // and the RFC 8707 resource indicator (required by the current MCP auth spec so // the token is bound to lard as its audience). async function tokenRequest(params: Record): Promise { const eps = await discover(); const c = await resolveClient(); const form = new URLSearchParams({ ...params, client_id: c.id }); if (c.secret) form.set("client_secret", c.secret); form.set("resource", eps.resource); return postForm(eps.token, form); } // ---- device grant (login) ------------------------------------------------ export interface DeviceStart { deviceCode: string; userCode: string; verificationUri: string; verificationUriComplete?: string; interval: number; expiresIn: number; } async function startDevice(): Promise { const eps = await discover(); const form = new URLSearchParams({ client_id: (await resolveClient()).id }); if (cfg().scopes) form.set("scope", cfg().scopes); form.set("resource", eps.resource); // RFC 8707: bind to lard (audience) const res = await postForm(eps.deviceAuthorization, form); const j = (await res.json()) as Record; if (!j.device_code || !j.verification_uri) throw new Error(`lard: device authorization failed (${res.status})`); return { deviceCode: String(j.device_code), userCode: String(j.user_code ?? ""), verificationUri: String(j.verification_uri), verificationUriComplete: j.verification_uri_complete ? String(j.verification_uri_complete) : undefined, interval: Number(j.interval) || 5, expiresIn: Number(j.expires_in) || 600, }; } async function pollDevice(deviceCode: string): Promise<{ token?: LardToken; status?: string }> { let res: Response; try { res = await tokenRequest({ grant_type: DEVICE_GRANT, device_code: deviceCode }); } catch { return { status: "authorization_pending" }; } // transient blip → keep waiting const j = (await res.json()) as { access_token?: string; refresh_token?: string; expires_in?: number; error?: string; }; if (j.error) return { status: j.error }; if (!j.access_token) throw new Error(`lard: token endpoint returned ${res.status}`); return { token: tokenFrom(j as { access_token: string }) }; } /** Run the device grant to completion. `onPrompt` shows the user the code + URL. */ export async function deviceLogin(onPrompt: (d: DeviceStart) => void): Promise { const start = await startDevice(); onPrompt(start); let interval = start.interval; const deadline = Date.now() + start.expiresIn * 1000; while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, interval * 1000)); const { token, status } = await pollDevice(start.deviceCode); if (token) return token; if (status === "slow_down") interval += 5; else if (status === "authorization_pending") continue; else if (status === "access_denied") throw new Error("lard: authorization was declined"); else if (status && status !== "authorization_pending") throw new Error(`lard: authorization failed (${status})`); } throw new Error("lard: this login expired; run it again"); } // ---- token lifecycle (per user) ------------------------------------------ async function refresh(tok: LardToken): Promise { if (!tok.refreshToken) throw new Error("lard: access token expired and no refresh token — reconnect"); const res = await tokenRequest({ grant_type: "refresh_token", refresh_token: tok.refreshToken }); const j = (await res.json()) as { access_token?: string; refresh_token?: string; expires_in?: number; }; if (!res.ok || !j.access_token) throw new Error("lard: refresh was rejected — reconnect"); return tokenFrom({ access_token: j.access_token, refresh_token: j.refresh_token || tok.refreshToken, expires_in: j.expires_in, }); } /** A valid access token for `sub`, refreshing + persisting within 60s of expiry. Throws if unconnected. */ async function accessToken(store: Store, sub: string): Promise { const tok = store.getLardToken(sub); if (!tok) throw new Error("lard: not connected"); if (tok.expiresAt && tok.expiresAt - Date.now() < 60_000) { const fresh = await refresh(tok); store.setLardToken(sub, fresh); return fresh.accessToken; } return tok.accessToken; } async function api(store: Store, sub: string, path: string, init?: RequestInit): Promise { const at = await accessToken(store, sub); const res = await fetch(trimSlash(cfg().baseUrl) + path, { ...init, headers: { authorization: `Bearer ${at}`, accept: "application/json", ...(init?.headers ?? {}), }, signal: AbortSignal.timeout(cfg().timeoutMs), }); if (!res.ok) throw new Error(`lard: ${init?.method ?? "GET"} ${path} → ${res.status}`); const ct = res.headers.get("content-type") ?? ""; return (ct.includes("application/json") ? res.json() : res.text()) as Promise; } // ---- memory API (paths: profile, areas/, topics/, people/) ------ export interface SubjectListing { path: string; kind?: string; name?: string; description?: string; } export interface ContextBundle { profile: string; area?: string; listing: SubjectListing[]; projectId?: string; } /** Fold a context bundle into a compact system-prompt block. */ export function contextToText(ctx: ContextBundle): string { const parts: string[] = []; if (ctx.profile?.trim()) parts.push(ctx.profile.trim()); if (ctx.area?.trim()) parts.push("# This project\n" + ctx.area.trim()); if (ctx.listing?.length) { parts.push( "# Subjects on record\n" + ctx.listing .map((s) => `- ${s.path}${s.description ? ` — ${s.description}` : ""}`) .join("\n"), ); } return parts.join("\n\n"); } const cleanPath = (p: string): string => p.replace(/^\/+/, "").replace(/\.\.(\/|$)/g, ""); // no leading slash, no traversal export function getContext(store: Store, sub: string, project?: string): Promise { return api(store, sub, "/context" + (project ? "?project=" + encodeURIComponent(project) : "")); } export interface LardProject { id: string; displayName?: string; names?: string[]; } export function memoryProjects(store: Store, sub: string): Promise { return api(store, sub, "/projects"); } export function memoryList(store: Store, sub: string): Promise { return api(store, sub, "/memory"); } export function memoryRead(store: Store, sub: string, path: string): Promise { return api(store, sub, "/memory/" + cleanPath(path)); } export function memoryWrite( store: Store, sub: string, path: string, body: string, ): Promise { // lard decodes a JSON envelope ({body, description, aliases, repos, version}) — not raw markdown. return api(store, sub, "/memory/" + cleanPath(path), { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ body }), }); } export function memoryDelete(store: Store, sub: string, path: string): Promise { return api(store, sub, "/memory/" + cleanPath(path), { method: "DELETE" }); } export function memoryAppend( store: Store, sub: string, path: string, line: string, ): Promise { return api(store, sub, "/memory/" + cleanPath(path), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ line }), }); } // ---- in-app connect: authorization code + PKCE --------------------------- // lard shares kloe's authorization server (indiko), which supports the code // grant, so linking a user is a browser redirect: /lard/connect → the AS → back // to /lard/callback, where we exchange the code and store the token under the // signed-in kloe user's `sub`. Mirrors src/auth.ts's own login flow. const LARD_COOKIE = "kloe_lard_oauth"; // short-lived: PKCE verifier + state + sub const b64url = (b: Buffer): string => b.toString("base64url"); function pkce(): { verifier: string; challenge: string } { const verifier = b64url(randomBytes(32)); return { verifier, challenge: b64url(createHash("sha256").update(verifier).digest()) }; } function lardCookie(value: string, maxAgeSec: number): string { const secure = trimSlash(getConfig().auth.baseUrl).startsWith("https") ? "; Secure" : ""; return `${LARD_COOKIE}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSec}${secure}`; } const connectRedirectUri = (): string => trimSlash(getConfig().auth.baseUrl) + "/lard/callback"; const redirectTo = (path: string, cookie?: string): Response => { const headers = new Headers({ Location: path }); if (cookie) headers.append("Set-Cookie", cookie); return new Response(null, { status: 302, headers }); }; /** GET /lard/connect — start the auth-code + PKCE flow to link this user's lard. */ export async function handleLardConnect(req: Request, store: Store): Promise { if (!lardEnabled()) return new Response("lard is not enabled", { status: 404 }); const sub = authEnabled() ? getSession(req, store)?.sub : LOCAL_SUB; if (authEnabled() && !sub) return redirectTo("/auth/login?returnTo=" + encodeURIComponent("/lard/connect")); const eps = await discover(); if (!eps.authorization) return new Response("lard's authorization server has no authorization endpoint", { status: 400, }); const { verifier, challenge } = pkce(); const state = b64url(randomBytes(16)); const url = new URL(eps.authorization); url.searchParams.set("response_type", "code"); url.searchParams.set("client_id", (await resolveClient()).id); url.searchParams.set("redirect_uri", connectRedirectUri()); url.searchParams.set("scope", cfg().scopes); url.searchParams.set("state", state); url.searchParams.set("code_challenge", challenge); url.searchParams.set("code_challenge_method", "S256"); url.searchParams.set("resource", eps.resource); // RFC 8707: audience = lard (from lard's PRM) return redirectTo( url.href, lardCookie(JSON.stringify({ state, verifier, sub: sub ?? LOCAL_SUB }), 600), ); } /** GET /lard/callback — verify state, exchange the code, store the token for this user. */ export async function handleLardCallback(req: Request, store: Store): Promise { const url = new URL(req.url); const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); let saved: { state?: string; verifier?: string; sub?: string } = {}; try { saved = JSON.parse(parseCookies(req)[LARD_COOKIE] ?? "{}"); } catch { /* malformed cookie */ } const clear = lardCookie("", 0); const back = (ok: boolean) => redirectTo("/settings?lard=" + (ok ? "connected" : "error"), clear); if (!code || !state || !saved.state || state !== saved.state) return back(false); try { const res = await tokenRequest({ grant_type: "authorization_code", code, redirect_uri: connectRedirectUri(), code_verifier: saved.verifier ?? "", }); const j = (await res.json()) as { access_token?: string; refresh_token?: string; expires_in?: number; }; if (!res.ok || !j.access_token) return back(false); store.setLardToken(saved.sub || LOCAL_SUB, tokenFrom(j as { access_token: string })); return back(true); } catch { return back(false); } } /** Disconnect this user's lard (drop their token). */ // RFC 7009 revocation: tell the AS to invalidate the token. Revoking the refresh // token invalidates the whole grant (§2.1), so a later reconnect can't silently // ride the old authorization — it must obtain consent afresh. async function revokeToken(token: string): Promise { const eps = await discover(); if (!eps.revocation) return; // AS advertises none — nothing we can do const c = await resolveClient(); const form = new URLSearchParams({ token, token_type_hint: "refresh_token", client_id: c.id }); if (c.secret) form.set("client_secret", c.secret); await postForm(eps.revocation, form); } /** * Disconnect a user from lard: revoke the grant upstream, then drop the local * token. The local delete always happens (even if the revoke call fails or the * AS is unreachable) so the UI never gets stuck "connected"; the revoke is * best-effort on top so the disconnect is real, not just local. */ export async function lardDisconnect(store: Store, sub: string): Promise { const tok = store.getLardToken(sub); store.deleteLardToken(sub); if (!tok) return; try { await revokeToken(tok.refreshToken || tok.accessToken); } catch { /* best-effort: the local token is already gone */ } } // ---- ingest -------------------------------------------------------------- export interface IngestTurn { index: number; role: string; content: string; ts: string; } export interface IngestSession { sessionId: string; source: string; startedAt: string; endedAt?: string; projectHints?: Record; turns: IngestTurn[]; } export function ingest(store: Store, sub: string, sessions: IngestSession[]): Promise { return api(store, sub, "/ingest", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ collector: cfg().collector, sessions }), }); }