muddle/integrations/auth.ts
2026-10-23 09:12:49 -04:00

248 lines
7.9 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;
}