Compare commits

...

2 Commits

Author SHA1 Message Date
Raul Lugo
5e4cd49d0a 1.0.1 2026-01-28 02:21:23 +01:00
Raul Lugo
4e33401387 fix: get env variable dynamically to avoid secret leakage 2026-01-28 02:17:06 +01:00
11 changed files with 241 additions and 61 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@resuely/astro-oidc-rp", "name": "@resuely/astro-oidc-rp",
"version": "0.1.0", "version": "1.0.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@resuely/astro-oidc-rp", "name": "@resuely/astro-oidc-rp",
"version": "0.1.0", "version": "1.0.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"jose": "^5.0.0" "jose": "^5.0.0"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@resuely/astro-oidc-rp", "name": "@resuely/astro-oidc-rp",
"version": "1.0.0", "version": "1.0.1",
"description": "Astro integration providing OIDC relying-party routes, middleware, and types.", "description": "Astro integration providing OIDC relying-party routes, middleware, and types.",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
@@ -26,8 +26,8 @@
"typescript": "^5.0.0" "typescript": "^5.0.0"
}, },
"scripts": { "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", "build": "tsup",
"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", "dev": "tsup --watch",
"postbuild": "mkdir -p dist/types && cp -R types/*.d.ts dist/types/ || true", "postbuild": "mkdir -p dist/types && cp -R types/*.d.ts dist/types/ || true",
"prepublishOnly": "npm run build" "prepublishOnly": "npm run build"
}, },

View File

@@ -1,43 +1,60 @@
import type { AstroIntegration } from "astro"; import type { AstroIntegration } from "astro";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
export type EnvString = {
env: string;
fallback?: string;
};
export type RequiredSecret = {
env: string;
};
export type OidcIntegrationOptions = { export type OidcIntegrationOptions = {
issuer: string; issuer: EnvString;
clientId: string; clientId: EnvString;
scopes?: string; // default "openid email profile" scopes?: string; // default "openid email profile"
routes?: { login?: string; callback?: string; logout?: string }; // defaults: /login, /oidc/callback, /logout routes?: { login?: string; callback?: string; logout?: string }; // defaults: /login, /oidc/callback, /logout
redirectUri?: { mode: "infer-from-request" } | { absolute: string }; redirectUri?: { mode: "infer-from-request" } | { absolute: string };
cookie?: { cookie: {
name?: string; name?: string;
sameSite?: "Lax" | "Strict" | "None"; sameSite?: "Lax" | "Strict" | "None";
secure?: boolean; secure?: boolean;
domain?: string; domain?: string;
path?: string; path?: string;
signingSecret: string; signingSecret: RequiredSecret;
maxAgeSec?: number; maxAgeSec?: number;
}; };
protected?: string[]; // patterns to guard, e.g., ["/app/*", "/me"] protected?: string[]; // patterns to guard, e.g., ["/app/*", "/me"]
}; };
export type OidcResolvedOptions = Required< export type OidcInjectedOptions = {
Pick<OidcIntegrationOptions, "issuer" | "clientId"> issuerEnv: string;
> & { issuerFallback?: string;
clientIdEnv: string;
clientIdFallback?: string;
scopes: string; scopes: string;
routes: { login: string; callback: string; logout: string }; routes: { login: string; callback: string; logout: string };
redirectUri: { mode: "infer-from-request" } | { absolute: string }; redirectUri: { mode: "infer-from-request" } | { absolute: string };
cookie: Required< cookie: {
Pick< name: string;
NonNullable<OidcIntegrationOptions["cookie"]>, sameSite: "Lax" | "Strict" | "None";
"name" | "sameSite" | "secure" | "path" | "signingSecret" secure: boolean;
> path: string;
> & { signingSecretEnv: string;
domain?: string; domain?: string;
maxAgeSec?: number; maxAgeSec?: number;
}; };
protected: string[]; protected: string[];
}; };
function resolveOptions(user: OidcIntegrationOptions): OidcResolvedOptions { function requireEnvName(name: string, label: string): string {
if (typeof name !== "string" || name.trim().length === 0)
throw new Error(`@resuely/astro-oidc-rp: ${label} must be a non-empty string`);
return name;
}
function resolveOptions(user: OidcIntegrationOptions): OidcInjectedOptions {
const routes = { const routes = {
login: user.routes?.login ?? "/login", login: user.routes?.login ?? "/login",
callback: user.routes?.callback ?? "/oidc/callback", callback: user.routes?.callback ?? "/oidc/callback",
@@ -48,19 +65,23 @@ function resolveOptions(user: OidcIntegrationOptions): OidcResolvedOptions {
sameSite: user.cookie?.sameSite ?? "Lax", sameSite: user.cookie?.sameSite ?? "Lax",
secure: user.cookie?.secure ?? true, secure: user.cookie?.secure ?? true,
path: user.cookie?.path ?? "/", path: user.cookie?.path ?? "/",
signingSecret: user.cookie?.signingSecret ?? "", signingSecretEnv: requireEnvName(
user.cookie?.signingSecret?.env,
"cookie.signingSecret.env",
),
domain: user.cookie?.domain, domain: user.cookie?.domain,
maxAgeSec: user.cookie?.maxAgeSec, 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 { return {
issuer: user.issuer, issuerEnv: requireEnvName(user.issuer.env, "issuer.env"),
clientId: user.clientId, issuerFallback:
typeof user.issuer.fallback === "string" ? user.issuer.fallback : undefined,
clientIdEnv: requireEnvName(user.clientId.env, "clientId.env"),
clientIdFallback:
typeof user.clientId.fallback === "string"
? user.clientId.fallback
: undefined,
scopes: user.scopes ?? "openid email profile", scopes: user.scopes ?? "openid email profile",
routes, routes,
redirectUri: user.redirectUri ?? { mode: "infer-from-request" }, redirectUri: user.redirectUri ?? { mode: "infer-from-request" },
@@ -109,4 +130,3 @@ export default function resuelyOidc(
}, },
}; };
} }

View File

@@ -40,7 +40,7 @@ export async function signPayload(
return `${payloadB64u}.${sigB64u}`; return `${payloadB64u}.${sigB64u}`;
} }
export async function verifyAndDecode<T = any>( export async function verifyAndDecode<T = unknown>(
token: string, token: string,
secret: string, secret: string,
): Promise<{ valid: boolean; payload?: T }> { ): Promise<{ valid: boolean; payload?: T }> {

View File

@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "astro"; import type { MiddlewareHandler } from "astro";
import { options } from "./runtime.js"; import { getOptions } from "./runtime.js";
import { verifyAndDecode } from "./lib/sign.js"; import { verifyAndDecode } from "./lib/sign.js";
function parseCookies(cookieHeader: string | null): Record<string, string> { function parseCookies(cookieHeader: string | null): Record<string, string> {
@@ -31,6 +31,7 @@ function isProtected(pathname: string, patterns: string[]): boolean {
export const onRequest: MiddlewareHandler = async (context, next) => { export const onRequest: MiddlewareHandler = async (context, next) => {
const { request, locals, url } = context; const { request, locals, url } = context;
const options = getOptions();
const cookieName = options.cookie.name; const cookieName = options.cookie.name;
const cookies = parseCookies(request.headers.get("cookie")); const cookies = parseCookies(request.headers.get("cookie"));
const token = cookies[cookieName]; const token = cookies[cookieName];

View File

@@ -1,9 +1,16 @@
import type { APIContext } from "astro"; import type { APIContext } from "astro";
import { options } from "../runtime.js"; import { getOptions } from "../runtime.js";
import { clearCookie, serializeCookie } from "../lib/cookies.js"; import { clearCookie, serializeCookie } from "../lib/cookies.js";
import { signPayload } from "../lib/sign.js"; import { signPayload } from "../lib/sign.js";
import { createRemoteJWKSet, jwtVerify } from "jose"; import { createRemoteJWKSet, jwtVerify } from "jose";
type InitCookie = {
state: string;
nonce: string;
verifier: string;
returnTo?: string;
};
async function discover(issuer: string) { async function discover(issuer: string) {
const res = await fetch( const res = await fetch(
new URL("/.well-known/openid-configuration", issuer).toString(), new URL("/.well-known/openid-configuration", issuer).toString(),
@@ -15,13 +22,66 @@ async function discover(issuer: string) {
}; };
} }
function inferRedirectUri(reqUrl: URL): string { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function parseInitCookie(
cookieHeader: string,
initCookieName: string,
): InitCookie | null {
const match = cookieHeader.match(
new RegExp(`${escapeRegex(initCookieName)}=([^;]+)`),
);
const raw = match?.[1];
if (!raw) return null;
let decoded: string;
try {
decoded = decodeURIComponent(raw);
} catch {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(decoded);
} catch {
return null;
}
if (!isRecord(parsed)) return null;
if (
typeof parsed.state !== "string" ||
typeof parsed.nonce !== "string" ||
typeof parsed.verifier !== "string"
) {
return null;
}
const returnTo = typeof parsed.returnTo === "string" ? parsed.returnTo : undefined;
return {
state: parsed.state,
nonce: parsed.nonce,
verifier: parsed.verifier,
returnTo,
};
}
function inferRedirectUri(
options: ReturnType<typeof getOptions>,
reqUrl: URL,
): string {
if ("absolute" in options.redirectUri) return options.redirectUri.absolute; if ("absolute" in options.redirectUri) return options.redirectUri.absolute;
const u = new URL(options.routes.callback, reqUrl); const u = new URL(options.routes.callback, reqUrl);
return u.toString(); return u.toString();
} }
export async function GET(ctx: APIContext) { export async function GET(ctx: APIContext) {
const options = getOptions();
const { url, request } = ctx; const { url, request } = ctx;
const code = url.searchParams.get("code"); const code = url.searchParams.get("code");
const state = url.searchParams.get("state"); const state = url.searchParams.get("state");
@@ -31,20 +91,12 @@ export async function GET(ctx: APIContext) {
const initCookieName = `${options.cookie.name}_init`; const initCookieName = `${options.cookie.name}_init`;
const cookieHeader = request.headers.get("cookie") || ""; const cookieHeader = request.headers.get("cookie") || "";
const init = (() => { const init = parseInitCookie(cookieHeader, initCookieName);
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) if (!init || init.state !== state)
return new Response("Invalid state", { status: 400 }); return new Response("Invalid state", { status: 400 });
const redirectUri = inferRedirectUri(url); const redirectUri = inferRedirectUri(options, url);
const disco = await discover(options.issuer); const disco = await discover(options.issuer);
const form = new URLSearchParams({ const form = new URLSearchParams({
@@ -62,8 +114,11 @@ export async function GET(ctx: APIContext) {
}); });
if (!tokenRes.ok) if (!tokenRes.ok)
return new Response("Token exchange failed", { status: 400 }); return new Response("Token exchange failed", { status: 400 });
const token = await tokenRes.json(); const tokenJson: unknown = await tokenRes.json();
const idToken = token.id_token as string | undefined; const idToken =
isRecord(tokenJson) && typeof tokenJson.id_token === "string"
? tokenJson.id_token
: undefined;
if (!idToken) return new Response("Missing id_token", { status: 400 }); if (!idToken) return new Response("Missing id_token", { status: 400 });
const jwks = createRemoteJWKSet(new URL(disco.jwks_uri)); const jwks = createRemoteJWKSet(new URL(disco.jwks_uri));
@@ -91,7 +146,7 @@ export async function GET(ctx: APIContext) {
domain: options.cookie.domain, domain: options.cookie.domain,
}); });
const returnTo = typeof init.return_to === "string" ? init.return_to : "/"; const returnTo = typeof init.returnTo === "string" ? init.returnTo : "/";
const location = new URL(returnTo, url).toString(); const location = new URL(returnTo, url).toString();
const headers = new Headers(); const headers = new Headers();

View File

@@ -1,5 +1,5 @@
import type { APIContext } from "astro"; import type { APIContext } from "astro";
import { options } from "../runtime.js"; import { getOptions } from "../runtime.js";
import { import {
generateCodeVerifier, generateCodeVerifier,
codeChallengeS256, codeChallengeS256,
@@ -18,13 +18,17 @@ async function discover(issuer: string) {
}; };
} }
function inferRedirectUri(reqUrl: URL): string { function inferRedirectUri(
options: ReturnType<typeof getOptions>,
reqUrl: URL,
): string {
if ("absolute" in options.redirectUri) return options.redirectUri.absolute; if ("absolute" in options.redirectUri) return options.redirectUri.absolute;
const u = new URL(options.routes.callback, reqUrl); const u = new URL(options.routes.callback, reqUrl);
return u.toString(); return u.toString();
} }
export async function GET(ctx: APIContext) { export async function GET(ctx: APIContext) {
const options = getOptions();
const { url } = ctx; const { url } = ctx;
const verifier = generateCodeVerifier(); const verifier = generateCodeVerifier();
const challenge = await codeChallengeS256(verifier); const challenge = await codeChallengeS256(verifier);
@@ -37,7 +41,7 @@ export async function GET(ctx: APIContext) {
state, state,
nonce, nonce,
verifier, verifier,
return_to: returnTo, returnTo,
}); });
const initCookieName = `${options.cookie.name}_init`; const initCookieName = `${options.cookie.name}_init`;
const cookie = serializeCookie(initCookieName, initPayload, { const cookie = serializeCookie(initCookieName, initPayload, {
@@ -50,7 +54,7 @@ export async function GET(ctx: APIContext) {
}); });
const disco = await discover(options.issuer); const disco = await discover(options.issuer);
const redirectUri = inferRedirectUri(url); const redirectUri = inferRedirectUri(options, url);
const authorize = new URL(disco.authorization_endpoint); const authorize = new URL(disco.authorization_endpoint);
authorize.searchParams.set("client_id", options.clientId); authorize.searchParams.set("client_id", options.clientId);
authorize.searchParams.set("redirect_uri", redirectUri); authorize.searchParams.set("redirect_uri", redirectUri);

View File

@@ -1,5 +1,5 @@
import type { APIContext } from "astro"; import type { APIContext } from "astro";
import { options } from "../runtime.js"; import { getOptions } from "../runtime.js";
import { clearCookie } from "../lib/cookies.js"; import { clearCookie } from "../lib/cookies.js";
async function discover(issuer: string) { async function discover(issuer: string) {
@@ -12,7 +12,10 @@ async function discover(issuer: string) {
}; };
} }
function inferPostLogoutRedirectUri(reqUrl: URL): string { function inferPostLogoutRedirectUri(
options: ReturnType<typeof getOptions>,
reqUrl: URL,
): string {
if ("absolute" in options.redirectUri) return options.redirectUri.absolute; if ("absolute" in options.redirectUri) return options.redirectUri.absolute;
// Default to site root // Default to site root
const u = new URL("/", reqUrl); const u = new URL("/", reqUrl);
@@ -20,12 +23,13 @@ function inferPostLogoutRedirectUri(reqUrl: URL): string {
} }
export async function GET(ctx: APIContext) { export async function GET(ctx: APIContext) {
const options = getOptions();
const { url } = ctx; const { url } = ctx;
const disco = await discover(options.issuer); const disco = await discover(options.issuer);
const endSession = const endSession =
disco.end_session_endpoint || new URL("/logout", options.issuer).toString(); disco.end_session_endpoint || new URL("/logout", options.issuer).toString();
const postLogout = inferPostLogoutRedirectUri(url); const postLogout = inferPostLogoutRedirectUri(options, url);
const redirect = new URL(endSession); const redirect = new URL(endSession);
redirect.searchParams.set("client_id", options.clientId); redirect.searchParams.set("client_id", options.clientId);
redirect.searchParams.set("post_logout_redirect_uri", postLogout); redirect.searchParams.set("post_logout_redirect_uri", postLogout);

View File

@@ -1,11 +1,86 @@
import type { OidcResolvedOptions } from "./index.js"; import type { OidcInjectedOptions } from "./index.js";
import { getSecret } from "astro:env/server";
// This constant is provided via Vite define by the integration during astro:config:setup // This constant is provided via Vite define by the integration during astro:config:setup
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
declare const __RESUELY_OIDC_OPTIONS: OidcResolvedOptions; declare const __RESUELY_OIDC_OPTIONS: OidcInjectedOptions;
export const options: OidcResolvedOptions = ( export type OidcRuntimeOptions = {
typeof __RESUELY_OIDC_OPTIONS !== "undefined" issuer: string;
? __RESUELY_OIDC_OPTIONS clientId: string;
: (undefined as any) scopes: string;
) as OidcResolvedOptions; 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[];
};
function getInjected(): OidcInjectedOptions {
if (typeof __RESUELY_OIDC_OPTIONS === "undefined") {
throw new Error(
"@resuely/astro-oidc-rp: missing injected options; is the integration configured?",
);
}
return __RESUELY_OIDC_OPTIONS;
}
function readEnv(name: string): string | undefined {
const v = getSecret(name);
if (typeof v !== "string") return undefined;
if (v.trim().length === 0) return undefined;
return v;
}
function requireEnv(name: string): string {
const v = readEnv(name);
if (typeof v === "string") return v;
throw new Error(`@resuely/astro-oidc-rp: missing env var ${name}`);
}
let cached: OidcRuntimeOptions | null = null;
export function getOptions(): OidcRuntimeOptions {
if (cached) return cached;
const injected = getInjected();
const issuer = readEnv(injected.issuerEnv) ?? injected.issuerFallback;
if (!issuer)
throw new Error(
`@resuely/astro-oidc-rp: missing env var ${injected.issuerEnv}`,
);
const clientId = readEnv(injected.clientIdEnv) ?? injected.clientIdFallback;
if (!clientId)
throw new Error(
`@resuely/astro-oidc-rp: missing env var ${injected.clientIdEnv}`,
);
cached = {
issuer,
clientId,
scopes: injected.scopes,
routes: injected.routes,
redirectUri: injected.redirectUri,
cookie: {
name: injected.cookie.name,
sameSite: injected.cookie.sameSite,
secure: injected.cookie.secure,
domain: injected.cookie.domain,
path: injected.cookie.path,
signingSecret: requireEnv(injected.cookie.signingSecretEnv),
maxAgeSec: injected.cookie.maxAgeSec,
},
protected: injected.protected,
};
return cached;
}

View File

@@ -12,7 +12,7 @@
"sourceMap": true, "sourceMap": true,
"outDir": "dist", "outDir": "dist",
"lib": ["ES2020", "DOM"], "lib": ["ES2020", "DOM"],
"types": ["node"] "types": ["node", "astro/client"]
}, },
"include": ["src", "types"], "include": ["src", "types"],
"exclude": ["dist", "node_modules"] "exclude": ["dist", "node_modules"]

21
tsup.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: [
"src/index.ts",
"src/runtime.ts",
"src/middleware.ts",
"src/routes/login.ts",
"src/routes/callback.ts",
"src/routes/logout.ts",
"src/lib/cookies.ts",
"src/lib/pkce.ts",
"src/lib/sign.ts",
],
format: ["esm"],
dts: true,
sourcemap: true,
clean: true,
outDir: "dist",
external: ["astro:env/server"],
});