import type { Integration, IntegrationContext } from "../contracts/index.ts"; import type { RouteGuard } from "../core/pipeline.ts"; import { createHash } from "node:crypto"; import { MemorySessionStore, type SessionStore } from "../core/sessions.ts"; export const authGuard: RouteGuard = async (context) => { if (!context.locals.auth) { const identity = await authFromRequest(context.raw as Request); if (identity) context.locals.auth = identity; } }; export interface AuthIdentity { id: string; email?: string; roles?: string[]; } export interface OAuthProvider { name: string; authorizationUrl: string; clientId: string; redirectUri: string; scope?: string[]; tokenUrl: string; clientSecret?: string; userInfoUrl: string; identity(profile: Record): AuthIdentity; } export interface AuthIntegrationOptions { login?: ( email: string, password: string, ) => Promise | AuthIdentity | undefined; providers?: OAuthProvider[]; route?: string; store?: | SessionStore | ((context: IntegrationContext) => Promise | SessionStore); sessionTtl?: number; } const defaultStore = new MemorySessionStore(); const sessionKey = (id: string) => `session:${createHash("sha256").update(id).digest("hex")}`; async function cookie( identity: AuthIdentity, store: SessionStore, ttl: number, ): Promise { const id = crypto.randomUUID(); await store.set(sessionKey(id), { value: { identity }, expires: Date.now() + ttl * 1000, }); return `muddle_auth=${id}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${ttl}`; } /** Form and OAuth entry points. Token exchange remains provider-owned. */ export function authIntegration( options: AuthIntegrationOptions = {}, ): Integration { const route = options.route ?? "/auth"; let store: SessionStore = defaultStore; const ttl = options.sessionTtl ?? 3600; return { name: "auth", async request(context) { const identity = await authFromRequest(context.raw as Request, store); if (identity) context.locals.auth = identity; }, async setup(context: IntegrationContext) { store = typeof options.store === "function" ? await options.store(context) : (options.store ?? defaultStore); context.route( "GET", `${route}/login`, () => new Response( '
', { headers: { "content-type": "text/html; charset=utf-8" } }, ), ); context.route("POST", `${route}/login`, async (request, ctx) => { if (request.headers.get("origin") !== new URL(request.url).origin) return new Response("Invalid origin", { status: 403 }); if ( !ctx.body || typeof ctx.body !== "object" || Array.isArray(ctx.body) ) return new Response("Invalid credentials", { status: 400 }); const data = ctx.body as Record; const identity = await options.login?.( String(data.email ?? ""), String(data.password ?? ""), ); if (request.headers.get("content-type")?.includes("application/json")) return identity ? Response.json( { user: identity }, { headers: { "set-cookie": await cookie(identity, store, ttl) }, }, ) : Response.json({ error: "Unauthorized" }, { status: 401 }); return identity ? new Response(null, { status: 302, headers: { location: "/", "set-cookie": await cookie(identity, store, ttl), }, }) : new Response("Unauthorized", { status: 401 }); }); context.route("POST", `${route}/logout`, async (request) => { if (request.headers.get("origin") !== new URL(request.url).origin) return new Response("Invalid origin", { status: 403 }); const id = request.headers .get("cookie") ?.match(/(?:^|;\s*)muddle_auth=([^;]+)/)?.[1]; if (id) await store.delete(sessionKey(id)); return new Response(null, { status: 302, headers: { location: "/", "set-cookie": "muddle_auth=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax", }, }); }); for (const provider of options.providers ?? []) { context.route("GET", `${route}/${provider.name}`, async () => { const state = crypto.randomUUID(); const verifier = Buffer.from( crypto.getRandomValues(new Uint8Array(32)), ).toString("base64url"); await store.set(`oauth:${state}`, { value: { verifier, provider: provider.name }, expires: Date.now() + 600_000, }); const url = new URL(provider.authorizationUrl); url.searchParams.set("client_id", provider.clientId); url.searchParams.set("redirect_uri", provider.redirectUri); url.searchParams.set("response_type", "code"); url.searchParams.set("scope", (provider.scope ?? []).join(" ")); url.searchParams.set("state", state); url.searchParams.set( "code_challenge", Buffer.from( await crypto.subtle.digest( "SHA-256", new TextEncoder().encode(verifier), ), ).toString("base64url"), ); url.searchParams.set("code_challenge_method", "S256"); return new Response(null, { status: 302, headers: { location: url.toString(), "set-cookie": `muddle_oauth=${state}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=600`, }, }); }); context.route( "GET", `${route}/${provider.name}/callback`, async (request) => { const url = new URL(request.url); const state = url.searchParams.get("state") ?? ""; const bound = request.headers .get("cookie") ?.match(/(?:^|;\s*)muddle_oauth=([^;]+)/)?.[1]; if (bound !== state || !url.searchParams.get("code")) return new Response("Invalid OAuth state", { status: 400 }); const record = await store.consume(`oauth:${state}`); const transaction = record?.value as | { provider: string; verifier: string } | undefined; if ( !transaction || record!.expires < Date.now() || transaction.provider !== provider.name ) return new Response("Invalid OAuth state", { status: 400 }); const body = new URLSearchParams({ grant_type: "authorization_code", code: url.searchParams.get("code")!, client_id: provider.clientId, redirect_uri: provider.redirectUri, code_verifier: transaction.verifier, }); if (provider.clientSecret) body.set("client_secret", provider.clientSecret); const tokenResponse = await fetch(provider.tokenUrl, { method: "POST", body, }); if (!tokenResponse.ok) return new Response("OAuth exchange failed", { status: 502 }); const token = (await tokenResponse.json()) as { access_token?: string; }; if (!token.access_token) return new Response("Missing OAuth token", { status: 502 }); const profile = await fetch(provider.userInfoUrl, { headers: { authorization: `Bearer ${token.access_token}` }, }); if (!profile.ok) return new Response("OAuth profile failed", { status: 502 }); return new Response(null, { status: 302, headers: { location: "/", "set-cookie": await cookie( provider.identity( (await profile.json()) as Record, ), store, ttl, ), }, }); }, ); } }, }; } export async function authFromRequest( request: Request, store: SessionStore = defaultStore, ): Promise { const value = request.headers .get("cookie") ?.match(/(?:^|;\s*)muddle_auth=([^;]+)/)?.[1]; if (!value) return undefined; const session = await store.get(sessionKey(value)); if (!session || session.expires < Date.now()) { await store.delete(sessionKey(value)); return undefined; } return session.value.identity as AuthIdentity; }