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

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
node_modules
.dist
/.turbo
.vscode
.DS_Store
coverage
npm-debug.log*
yarn-error.log*
*.tsbuildinfo
dist

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Resuely
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

57
README.md Normal file
View File

@@ -0,0 +1,57 @@
# @resuely/astro-oidc-rp
Astro integration that injects OIDC login/callback/logout routes, a middleware that sets `Astro.locals.user`, and type augmentation.
## Install
npm install @resuely/astro-oidc-rp
## Usage (astro.config.mjs)
import { defineConfig } from "astro/config";
import resuelyOidc from "@resuely/astro-oidc-rp";
export default defineConfig({
integrations: [
resuelyOidc({
issuer: "https://your-idp",
clientId: "YOUR_CLIENT_ID",
cookie: { signingSecret: process.env.OIDC_SIGNING_SECRET! },
protected: ["/app/*", "/me"],
}),
],
});
- Injected routes:
- Login: /login
- Callback: /oidc/callback
- Logout: /logout
## Options
- issuer: string (required)
- clientId: string (required)
- scopes?: string (default: "openid email profile")
- routes?: { login?: string; callback?: string; logout?: string }
- 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
## Types: Astro.locals
Enable type augmentation by referencing the package export:
- Add to your tsconfig.json: { "compilerOptions": { "types": ["@resuely/astro-oidc-rp/astro-locals"] } }
Then `locals.user` is typed as `{ sub: string; email?: string } | null | undefined`.
## Security notes
- Always provide a strong `cookie.signingSecret`.
- In production, cookies are `Secure` by default.
- The init cookie used during login is short-lived (5 minutes) and set `HttpOnly` + `SameSite=Lax`.
## Build & Publish
- Build: npm run build
- Publish to npm: npm publish --access public
## License
MIT

6299
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

63
package.json Normal file
View File

@@ -0,0 +1,63 @@
{
"name": "@resuely/astro-oidc-rp",
"version": "0.1.0",
"description": "Astro integration providing OIDC relying-party routes, middleware, and types.",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist",
"README.md",
"LICENSE"
],
"engines": {
"node": ">=18"
},
"dependencies": {
"jose": "^5.0.0"
},
"peerDependencies": {
"astro": ">=4.0.0 <6.0.0"
},
"devDependencies": {
"@types/node": "^25.0.9",
"astro": "^5.16.11",
"tsup": "^8.0.0",
"typescript": "^5.0.0"
},
"scripts": {
"build": "tsup src/index.ts src/runtime.ts src/middleware.ts src/routes/login.ts src/routes/callback.ts src/routes/logout.ts src/lib/*.ts --dts --format esm --clean --sourcemap --out-dir dist",
"dev": "tsup --watch src/index.ts src/runtime.ts src/middleware.ts src/routes/login.ts src/routes/callback.ts src/routes/logout.ts src/lib/*.ts --dts --format esm --out-dir dist",
"postbuild": "mkdir -p dist/types && cp -R types/*.d.ts dist/types/ || true",
"prepublishOnly": "npm run build"
},
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./integration": {
"import": "./dist/index.js"
},
"./middleware": {
"import": "./dist/middleware.js"
},
"./routes/login.ts": {
"import": "./dist/routes/login.js"
},
"./routes/callback.ts": {
"import": "./dist/routes/callback.js"
},
"./routes/logout.ts": {
"import": "./dist/routes/logout.js"
},
"./astro-locals": {
"types": "./dist/types/astro.locals.d.ts"
}
},
"license": "MIT",
"repository": {
"type": "git",
"url": ""
}
}

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;

19
tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"strict": true,
"declaration": true,
"sourceMap": true,
"outDir": "dist",
"lib": ["ES2020", "DOM"],
"types": ["node"]
},
"include": ["src", "types"],
"exclude": ["dist", "node_modules"]
}

8
types/astro.locals.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
declare global {
namespace App {
interface Locals {
user?: { sub: string; email?: string } | null;
}
}
}
export {};