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