first commit

This commit is contained in:
Raul Lugo
2026-01-22 12:31:05 +01:00
commit 261be4ea0e
16 changed files with 7031 additions and 0 deletions

112
src/index.ts Normal file
View File

@@ -0,0 +1,112 @@
import type { AstroIntegration } from "astro";
import { fileURLToPath } from "node:url";
export type OidcIntegrationOptions = {
issuer: string;
clientId: string;
scopes?: string; // default "openid email profile"
routes?: { login?: string; callback?: string; logout?: string }; // defaults: /login, /oidc/callback, /logout
redirectUri?: { mode: "infer-from-request" } | { absolute: string };
cookie?: {
name?: string;
sameSite?: "Lax" | "Strict" | "None";
secure?: boolean;
domain?: string;
path?: string;
signingSecret: string;
maxAgeSec?: number;
};
protected?: string[]; // patterns to guard, e.g., ["/app/*", "/me"]
};
export type OidcResolvedOptions = Required<
Pick<OidcIntegrationOptions, "issuer" | "clientId">
> & {
scopes: string;
routes: { login: string; callback: string; logout: string };
redirectUri: { mode: "infer-from-request" } | { absolute: string };
cookie: Required<
Pick<
NonNullable<OidcIntegrationOptions["cookie"]>,
"name" | "sameSite" | "secure" | "path" | "signingSecret"
>
> & {
domain?: string;
maxAgeSec?: number;
};
protected: string[];
};
function resolveOptions(user: OidcIntegrationOptions): OidcResolvedOptions {
const routes = {
login: user.routes?.login ?? "/login",
callback: user.routes?.callback ?? "/oidc/callback",
logout: user.routes?.logout ?? "/logout",
};
const cookie = {
name: user.cookie?.name ?? "oidc_session",
sameSite: user.cookie?.sameSite ?? "Lax",
secure: user.cookie?.secure ?? true,
path: user.cookie?.path ?? "/",
signingSecret: user.cookie?.signingSecret ?? "",
domain: user.cookie?.domain,
maxAgeSec: user.cookie?.maxAgeSec,
};
if (!user.issuer)
throw new Error("@resuely/astro-oidc-rp: 'issuer' is required");
if (!user.clientId)
throw new Error("@resuely/astro-oidc-rp: 'clientId' is required");
if (!cookie.signingSecret)
throw new Error("@resuely/astro-oidc-rp: cookie.signingSecret is required");
return {
issuer: user.issuer,
clientId: user.clientId,
scopes: user.scopes ?? "openid email profile",
routes,
redirectUri: user.redirectUri ?? { mode: "infer-from-request" },
cookie,
protected: user.protected ?? [],
};
}
export default function resuelyOidc(
options: OidcIntegrationOptions,
): AstroIntegration {
const resolved = resolveOptions(options);
return {
name: "@resuely/astro-oidc-rp",
hooks: {
"astro:config:setup": ({ injectRoute, addMiddleware, updateConfig }) => {
// Provide runtime options to middleware/routes via Vite define replacement
updateConfig({
vite: {
define: {
__RESUELY_OIDC_OPTIONS: JSON.stringify(resolved),
},
},
});
// Add middleware (run early to populate locals and guard protected patterns)
addMiddleware({
entrypoint: fileURLToPath(new URL("./middleware.js", import.meta.url)),
order: "pre",
});
// Inject routes
injectRoute({
pattern: resolved.routes.login,
entrypoint: fileURLToPath(new URL("./routes/login.js", import.meta.url)),
});
injectRoute({
pattern: resolved.routes.callback,
entrypoint: fileURLToPath(new URL("./routes/callback.js", import.meta.url)),
});
injectRoute({
pattern: resolved.routes.logout,
entrypoint: fileURLToPath(new URL("./routes/logout.js", import.meta.url)),
});
},
},
};
}

38
src/lib/cookies.ts Normal file
View File

@@ -0,0 +1,38 @@
export type CookieOptions = {
httpOnly?: boolean;
secure?: boolean;
sameSite?: "Lax" | "Strict" | "None";
domain?: string;
path?: string;
maxAge?: number; // seconds
expires?: Date;
};
function toUTC(date: Date): string {
return date.toUTCString();
}
export function serializeCookie(
name: string,
value: string,
opts: CookieOptions = {},
): string {
const parts: string[] = [];
parts.push(`${encodeURIComponent(name)}=${encodeURIComponent(value)}`);
if (opts.maxAge != null) parts.push(`Max-Age=${Math.floor(opts.maxAge)}`);
if (opts.expires) parts.push(`Expires=${toUTC(opts.expires)}`);
if (opts.domain) parts.push(`Domain=${opts.domain}`);
if (opts.path) parts.push(`Path=${opts.path}`);
if (opts.sameSite) parts.push(`SameSite=${opts.sameSite}`);
if (opts.secure) parts.push(`Secure`);
if (opts.httpOnly ?? true) parts.push(`HttpOnly`);
return parts.join("; ");
}
export function clearCookie(name: string, opts: CookieOptions = {}): string {
return serializeCookie(name, "", {
...opts,
maxAge: 0,
expires: new Date(0),
});
}

46
src/lib/pkce.ts Normal file
View File

@@ -0,0 +1,46 @@
import { webcrypto } from "node:crypto";
const te = new TextEncoder();
const cryptoRef: any = (globalThis as any).crypto ?? webcrypto;
export function base64url(data: ArrayBuffer | Uint8Array | string): string {
let bytes: Uint8Array;
if (typeof data === "string") bytes = te.encode(data);
else bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
let str = Buffer.from(bytes).toString("base64");
return str.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
}
export async function sha256(input: string | Uint8Array): Promise<ArrayBuffer> {
const bytes = typeof input === "string" ? te.encode(input) : input;
return await cryptoRef.subtle.digest("SHA-256", bytes as unknown as BufferSource);
}
function randomBytes(len: number): Uint8Array {
const arr = new Uint8Array(len);
cryptoRef.getRandomValues(arr);
return arr;
}
function randomUrlSafe(size = 32): string {
// 32 bytes -> 43 chars base64url
return base64url(randomBytes(size));
}
export function generateCodeVerifier(): string {
// RFC 7636: between 43 and 128 characters. Use 64 for good entropy.
return randomUrlSafe(48); // ~64 chars
}
export async function codeChallengeS256(verifier: string): Promise<string> {
const hash = await sha256(verifier);
return base64url(hash);
}
export function generateState(): string {
return randomUrlSafe(32);
}
export function generateNonce(): string {
return randomUrlSafe(32);
}

68
src/lib/sign.ts Normal file
View File

@@ -0,0 +1,68 @@
const te = new TextEncoder();
function b64uToBytes(input: string): Uint8Array {
// pad and replace
input = input.replace(/-/g, "+").replace(/_/g, "/");
const pad = input.length % 4;
if (pad) input += "=".repeat(4 - pad);
return new Uint8Array(Buffer.from(input, "base64"));
}
function bytesToB64u(bytes: Uint8Array | ArrayBuffer): string {
const buf = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
return Buffer.from(buf)
.toString("base64")
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
}
async function importKey(secret: string): Promise<CryptoKey> {
return await crypto.subtle.importKey(
"raw",
te.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"],
);
}
export async function signPayload(
payload: unknown,
secret: string,
): Promise<string> {
const payloadJson =
typeof payload === "string" ? payload : JSON.stringify(payload);
const payloadB64u = bytesToB64u(te.encode(payloadJson));
const key = await importKey(secret);
const sig = await crypto.subtle.sign("HMAC", key, te.encode(payloadB64u));
const sigB64u = bytesToB64u(new Uint8Array(sig));
return `${payloadB64u}.${sigB64u}`;
}
export async function verifyAndDecode<T = any>(
token: string,
secret: string,
): Promise<{ valid: boolean; payload?: T }> {
const [payloadB64u, sigB64u] = token.split(".");
if (!payloadB64u || !sigB64u) return { valid: false };
const key = await importKey(secret);
const expected = await crypto.subtle.sign(
"HMAC",
key,
te.encode(payloadB64u),
);
const given = b64uToBytes(sigB64u);
const expectedBytes = new Uint8Array(expected);
if (given.length !== expectedBytes.length) return { valid: false };
// Constant-time compare
let diff = 0;
for (let i = 0; i < given.length; i++) diff |= given[i] ^ expectedBytes[i];
if (diff !== 0) return { valid: false };
try {
const json = Buffer.from(b64uToBytes(payloadB64u)).toString("utf8");
return { valid: true, payload: JSON.parse(json) as T };
} catch {
return { valid: false };
}
}

57
src/middleware.ts Normal file
View File

@@ -0,0 +1,57 @@
import type { MiddlewareHandler } from "astro";
import { options } from "./runtime.js";
import { verifyAndDecode } from "./lib/sign.js";
function parseCookies(cookieHeader: string | null): Record<string, string> {
const out: Record<string, string> = {};
if (!cookieHeader) return out;
const parts = cookieHeader.split(/;\s*/);
for (const part of parts) {
const idx = part.indexOf("=");
if (idx === -1) continue;
const name = decodeURIComponent(part.slice(0, idx));
const val = decodeURIComponent(part.slice(idx + 1));
out[name] = val;
}
return out;
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function globToRegExp(pattern: string): RegExp {
const escaped = pattern.split("*").map(escapeRegex).join(".*");
return new RegExp(`^${escaped}$`);
}
function isProtected(pathname: string, patterns: string[]): boolean {
return patterns.some((p) => globToRegExp(p).test(pathname));
}
export const onRequest: MiddlewareHandler = async (context, next) => {
const { request, locals, url } = context;
const cookieName = options.cookie.name;
const cookies = parseCookies(request.headers.get("cookie"));
const token = cookies[cookieName];
locals.user = null;
if (token) {
const res = await verifyAndDecode<{ sub: string; email?: string }>(
token,
options.cookie.signingSecret,
);
if (res.valid && res.payload && typeof res.payload.sub === "string") {
locals.user = { sub: res.payload.sub, email: res.payload.email };
}
}
if (isProtected(url.pathname, options.protected) && !locals.user) {
const returnTo = url.pathname + (url.search || "");
const loginUrl = new URL(options.routes.login, url);
loginUrl.searchParams.set("return_to", returnTo);
return Response.redirect(loginUrl, 302);
}
return next();
};

106
src/routes/callback.ts Normal file
View File

@@ -0,0 +1,106 @@
import type { APIContext } from "astro";
import { options } from "../runtime.js";
import { clearCookie, serializeCookie } from "../lib/cookies.js";
import { signPayload } from "../lib/sign.js";
import { createRemoteJWKSet, jwtVerify } from "jose";
async function discover(issuer: string) {
const res = await fetch(
new URL("/.well-known/openid-configuration", issuer).toString(),
);
if (!res.ok) throw new Error(`OIDC discovery failed: ${res.status}`);
return (await res.json()) as {
token_endpoint: string;
jwks_uri: string;
};
}
function inferRedirectUri(reqUrl: URL): string {
if ("absolute" in options.redirectUri) return options.redirectUri.absolute;
const u = new URL(options.routes.callback, reqUrl);
return u.toString();
}
export async function GET(ctx: APIContext) {
const { url, request } = ctx;
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
if (!code || !state)
return new Response("Missing code/state", { status: 400 });
const initCookieName = `${options.cookie.name}_init`;
const cookieHeader = request.headers.get("cookie") || "";
const init = (() => {
const match = cookieHeader.match(new RegExp(`${initCookieName}=([^;]+)`));
if (!match) return null as any;
try {
return JSON.parse(decodeURIComponent(match[1]));
} catch {
return null as any;
}
})();
if (!init || init.state !== state)
return new Response("Invalid state", { status: 400 });
const redirectUri = inferRedirectUri(url);
const disco = await discover(options.issuer);
const form = new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
client_id: options.clientId,
code_verifier: init.verifier,
});
const tokenRes = await fetch(disco.token_endpoint, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: form.toString(),
});
if (!tokenRes.ok)
return new Response("Token exchange failed", { status: 400 });
const token = await tokenRes.json();
const idToken = token.id_token as string | undefined;
if (!idToken) return new Response("Missing id_token", { status: 400 });
const jwks = createRemoteJWKSet(new URL(disco.jwks_uri));
const { payload } = await jwtVerify(idToken, jwks, {
issuer: options.issuer,
audience: options.clientId,
});
const session = {
sub: String(payload.sub),
email: typeof payload.email === "string" ? payload.email : undefined,
};
const signed = await signPayload(session, options.cookie.signingSecret);
const setSession = serializeCookie(options.cookie.name, signed, {
path: options.cookie.path,
domain: options.cookie.domain,
sameSite: options.cookie.sameSite,
secure: options.cookie.secure,
httpOnly: true,
maxAge: options.cookie.maxAgeSec,
});
const clearInit = clearCookie(initCookieName, {
path: options.cookie.path,
domain: options.cookie.domain,
});
const returnTo = typeof init.return_to === "string" ? init.return_to : "/";
const location = new URL(returnTo, url).toString();
const headers = new Headers();
headers.set("Location", location);
headers.append("Set-Cookie", setSession);
headers.append("Set-Cookie", clearInit);
return new Response(null, {
status: 303,
headers,
});
}

71
src/routes/login.ts Normal file
View File

@@ -0,0 +1,71 @@
import type { APIContext } from "astro";
import { options } from "../runtime.js";
import {
generateCodeVerifier,
codeChallengeS256,
generateState,
generateNonce,
} from "../lib/pkce.js";
import { serializeCookie } from "../lib/cookies.js";
async function discover(issuer: string) {
const res = await fetch(
new URL("/.well-known/openid-configuration", issuer).toString(),
);
if (!res.ok) throw new Error(`OIDC discovery failed: ${res.status}`);
return (await res.json()) as {
authorization_endpoint: string;
};
}
function inferRedirectUri(reqUrl: URL): string {
if ("absolute" in options.redirectUri) return options.redirectUri.absolute;
const u = new URL(options.routes.callback, reqUrl);
return u.toString();
}
export async function GET(ctx: APIContext) {
const { url } = ctx;
const verifier = generateCodeVerifier();
const challenge = await codeChallengeS256(verifier);
const state = generateState();
const nonce = generateNonce();
const returnTo = url.searchParams.get("return_to") || undefined;
const initPayload = JSON.stringify({
state,
nonce,
verifier,
return_to: returnTo,
});
const initCookieName = `${options.cookie.name}_init`;
const cookie = serializeCookie(initCookieName, initPayload, {
path: options.cookie.path,
domain: options.cookie.domain,
sameSite: "Lax",
secure: options.cookie.secure,
httpOnly: true,
maxAge: 5 * 60, // 5 minutes
});
const disco = await discover(options.issuer);
const redirectUri = inferRedirectUri(url);
const authorize = new URL(disco.authorization_endpoint);
authorize.searchParams.set("client_id", options.clientId);
authorize.searchParams.set("redirect_uri", redirectUri);
authorize.searchParams.set("response_type", "code");
authorize.searchParams.set("scope", options.scopes);
authorize.searchParams.set("code_challenge", challenge);
authorize.searchParams.set("code_challenge_method", "S256");
authorize.searchParams.set("state", state);
authorize.searchParams.set("nonce", nonce);
return new Response(null, {
status: 302,
headers: {
Location: authorize.toString(),
"Set-Cookie": cookie,
},
});
}

45
src/routes/logout.ts Normal file
View File

@@ -0,0 +1,45 @@
import type { APIContext } from "astro";
import { options } from "../runtime.js";
import { clearCookie } from "../lib/cookies.js";
async function discover(issuer: string) {
const res = await fetch(
new URL("/.well-known/openid-configuration", issuer).toString(),
);
if (!res.ok) throw new Error(`OIDC discovery failed: ${res.status}`);
return (await res.json()) as {
end_session_endpoint?: string;
};
}
function inferPostLogoutRedirectUri(reqUrl: URL): string {
if ("absolute" in options.redirectUri) return options.redirectUri.absolute;
// Default to site root
const u = new URL("/", reqUrl);
return u.toString();
}
export async function GET(ctx: APIContext) {
const { url } = ctx;
const disco = await discover(options.issuer);
const endSession =
disco.end_session_endpoint || new URL("/logout", options.issuer).toString();
const postLogout = inferPostLogoutRedirectUri(url);
const redirect = new URL(endSession);
redirect.searchParams.set("client_id", options.clientId);
redirect.searchParams.set("post_logout_redirect_uri", postLogout);
const clear = clearCookie(options.cookie.name, {
path: options.cookie.path,
domain: options.cookie.domain,
});
return new Response(null, {
status: 303,
headers: {
Location: redirect.toString(),
"Set-Cookie": clear,
},
});
}

11
src/runtime.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { OidcResolvedOptions } from "./index.js";
// This constant is provided via Vite define by the integration during astro:config:setup
// eslint-disable-next-line @typescript-eslint/no-unused-vars
declare const __RESUELY_OIDC_OPTIONS: OidcResolvedOptions;
export const options: OidcResolvedOptions = (
typeof __RESUELY_OIDC_OPTIONS !== "undefined"
? __RESUELY_OIDC_OPTIONS
: (undefined as any)
) as OidcResolvedOptions;