muddle/integrations/auth.ts
2026-10-23 08:53:07 -04:00

95 lines
7.1 KiB
TypeScript

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<string, unknown>): AuthIdentity }
export interface AuthIntegrationOptions {
login?: (email: string, password: string) => Promise<AuthIdentity | undefined> | AuthIdentity | undefined;
providers?: OAuthProvider[];
route?: string;
store?: SessionStore | ((context: IntegrationContext) => Promise<SessionStore> | 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<string> {
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('<form method="post"><label>Email <input name="email" type="email"></label><label>Password <input name="password" type="password"></label><button>Sign in</button></form>', { 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<string, string>;
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<string, unknown>), store, ttl) } });
});
}
},
};
}
export async function authFromRequest(request: Request, store: SessionStore = defaultStore): Promise<AuthIdentity | undefined> {
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;
}