From 4e33401387d23076054d0e04a337021d39a1369c Mon Sep 17 00:00:00 2001 From: Raul Lugo Date: Wed, 28 Jan 2026 02:17:06 +0100 Subject: [PATCH] fix: get env variable dynamically to avoid secret leakage --- package.json | 4 +- src/index.ts | 68 ++++++++++++++++++++------------ src/lib/sign.ts | 2 +- src/middleware.ts | 3 +- src/routes/callback.ts | 85 +++++++++++++++++++++++++++++++++------- src/routes/login.ts | 12 ++++-- src/routes/logout.ts | 10 +++-- src/runtime.ts | 89 ++++++++++++++++++++++++++++++++++++++---- tsconfig.json | 2 +- tsup.config.ts | 21 ++++++++++ 10 files changed, 238 insertions(+), 58 deletions(-) create mode 100644 tsup.config.ts diff --git a/package.json b/package.json index 440dfe6..dfa5fc9 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,8 @@ "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", + "build": "tsup", + "dev": "tsup --watch", "postbuild": "mkdir -p dist/types && cp -R types/*.d.ts dist/types/ || true", "prepublishOnly": "npm run build" }, diff --git a/src/index.ts b/src/index.ts index e744e03..e33df36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,43 +1,60 @@ import type { AstroIntegration } from "astro"; import { fileURLToPath } from "node:url"; +export type EnvString = { + env: string; + fallback?: string; +}; + +export type RequiredSecret = { + env: string; +}; + export type OidcIntegrationOptions = { - issuer: string; - clientId: string; + issuer: EnvString; + clientId: EnvString; 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?: { + cookie: { name?: string; sameSite?: "Lax" | "Strict" | "None"; secure?: boolean; domain?: string; path?: string; - signingSecret: string; + signingSecret: RequiredSecret; maxAgeSec?: number; }; protected?: string[]; // patterns to guard, e.g., ["/app/*", "/me"] }; -export type OidcResolvedOptions = Required< - Pick -> & { +export type OidcInjectedOptions = { + issuerEnv: string; + issuerFallback?: string; + clientIdEnv: string; + clientIdFallback?: string; scopes: string; routes: { login: string; callback: string; logout: string }; redirectUri: { mode: "infer-from-request" } | { absolute: string }; - cookie: Required< - Pick< - NonNullable, - "name" | "sameSite" | "secure" | "path" | "signingSecret" - > - > & { + cookie: { + name: string; + sameSite: "Lax" | "Strict" | "None"; + secure: boolean; + path: string; + signingSecretEnv: string; domain?: string; maxAgeSec?: number; }; 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 = { login: user.routes?.login ?? "/login", callback: user.routes?.callback ?? "/oidc/callback", @@ -48,19 +65,23 @@ function resolveOptions(user: OidcIntegrationOptions): OidcResolvedOptions { sameSite: user.cookie?.sameSite ?? "Lax", secure: user.cookie?.secure ?? true, path: user.cookie?.path ?? "/", - signingSecret: user.cookie?.signingSecret ?? "", + signingSecretEnv: requireEnvName( + user.cookie?.signingSecret?.env, + "cookie.signingSecret.env", + ), 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, + issuerEnv: requireEnvName(user.issuer.env, "issuer.env"), + 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", routes, redirectUri: user.redirectUri ?? { mode: "infer-from-request" }, @@ -109,4 +130,3 @@ export default function resuelyOidc( }, }; } - diff --git a/src/lib/sign.ts b/src/lib/sign.ts index 3f6d4aa..691c3c9 100644 --- a/src/lib/sign.ts +++ b/src/lib/sign.ts @@ -40,7 +40,7 @@ export async function signPayload( return `${payloadB64u}.${sigB64u}`; } -export async function verifyAndDecode( +export async function verifyAndDecode( token: string, secret: string, ): Promise<{ valid: boolean; payload?: T }> { diff --git a/src/middleware.ts b/src/middleware.ts index ff30bf3..779141c 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,5 +1,5 @@ import type { MiddlewareHandler } from "astro"; -import { options } from "./runtime.js"; +import { getOptions } from "./runtime.js"; import { verifyAndDecode } from "./lib/sign.js"; function parseCookies(cookieHeader: string | null): Record { @@ -31,6 +31,7 @@ function isProtected(pathname: string, patterns: string[]): boolean { export const onRequest: MiddlewareHandler = async (context, next) => { const { request, locals, url } = context; + const options = getOptions(); const cookieName = options.cookie.name; const cookies = parseCookies(request.headers.get("cookie")); const token = cookies[cookieName]; diff --git a/src/routes/callback.ts b/src/routes/callback.ts index 5a26acd..c91472c 100644 --- a/src/routes/callback.ts +++ b/src/routes/callback.ts @@ -1,9 +1,16 @@ import type { APIContext } from "astro"; -import { options } from "../runtime.js"; +import { getOptions } from "../runtime.js"; import { clearCookie, serializeCookie } from "../lib/cookies.js"; import { signPayload } from "../lib/sign.js"; import { createRemoteJWKSet, jwtVerify } from "jose"; +type InitCookie = { + state: string; + nonce: string; + verifier: string; + returnTo?: string; +}; + async function discover(issuer: string) { const res = await fetch( 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 { + 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, + 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 options = getOptions(); const { url, request } = ctx; const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); @@ -31,20 +91,12 @@ export async function GET(ctx: APIContext) { 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; - } - })(); + const init = parseInitCookie(cookieHeader, initCookieName); if (!init || init.state !== state) return new Response("Invalid state", { status: 400 }); - const redirectUri = inferRedirectUri(url); + const redirectUri = inferRedirectUri(options, url); const disco = await discover(options.issuer); const form = new URLSearchParams({ @@ -62,8 +114,11 @@ export async function GET(ctx: APIContext) { }); if (!tokenRes.ok) return new Response("Token exchange failed", { status: 400 }); - const token = await tokenRes.json(); - const idToken = token.id_token as string | undefined; + const tokenJson: unknown = await tokenRes.json(); + const idToken = + isRecord(tokenJson) && typeof tokenJson.id_token === "string" + ? tokenJson.id_token + : undefined; if (!idToken) return new Response("Missing id_token", { status: 400 }); const jwks = createRemoteJWKSet(new URL(disco.jwks_uri)); @@ -91,7 +146,7 @@ export async function GET(ctx: APIContext) { 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 headers = new Headers(); diff --git a/src/routes/login.ts b/src/routes/login.ts index d4031b5..f32d239 100644 --- a/src/routes/login.ts +++ b/src/routes/login.ts @@ -1,5 +1,5 @@ import type { APIContext } from "astro"; -import { options } from "../runtime.js"; +import { getOptions } from "../runtime.js"; import { generateCodeVerifier, codeChallengeS256, @@ -18,13 +18,17 @@ async function discover(issuer: string) { }; } -function inferRedirectUri(reqUrl: URL): string { +function inferRedirectUri( + options: ReturnType, + 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 options = getOptions(); const { url } = ctx; const verifier = generateCodeVerifier(); const challenge = await codeChallengeS256(verifier); @@ -37,7 +41,7 @@ export async function GET(ctx: APIContext) { state, nonce, verifier, - return_to: returnTo, + returnTo, }); const initCookieName = `${options.cookie.name}_init`; const cookie = serializeCookie(initCookieName, initPayload, { @@ -50,7 +54,7 @@ export async function GET(ctx: APIContext) { }); const disco = await discover(options.issuer); - const redirectUri = inferRedirectUri(url); + const redirectUri = inferRedirectUri(options, url); const authorize = new URL(disco.authorization_endpoint); authorize.searchParams.set("client_id", options.clientId); authorize.searchParams.set("redirect_uri", redirectUri); diff --git a/src/routes/logout.ts b/src/routes/logout.ts index 38c506e..02c2f05 100644 --- a/src/routes/logout.ts +++ b/src/routes/logout.ts @@ -1,5 +1,5 @@ import type { APIContext } from "astro"; -import { options } from "../runtime.js"; +import { getOptions } from "../runtime.js"; import { clearCookie } from "../lib/cookies.js"; async function discover(issuer: string) { @@ -12,7 +12,10 @@ async function discover(issuer: string) { }; } -function inferPostLogoutRedirectUri(reqUrl: URL): string { +function inferPostLogoutRedirectUri( + options: ReturnType, + reqUrl: URL, +): string { if ("absolute" in options.redirectUri) return options.redirectUri.absolute; // Default to site root const u = new URL("/", reqUrl); @@ -20,12 +23,13 @@ function inferPostLogoutRedirectUri(reqUrl: URL): string { } export async function GET(ctx: APIContext) { + const options = getOptions(); 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 postLogout = inferPostLogoutRedirectUri(options, url); const redirect = new URL(endSession); redirect.searchParams.set("client_id", options.clientId); redirect.searchParams.set("post_logout_redirect_uri", postLogout); diff --git a/src/runtime.ts b/src/runtime.ts index a0a0791..f3e880c 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -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 // 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 = ( - typeof __RESUELY_OIDC_OPTIONS !== "undefined" - ? __RESUELY_OIDC_OPTIONS - : (undefined as any) -) as OidcResolvedOptions; +export type OidcRuntimeOptions = { + issuer: string; + clientId: string; + scopes: string; + 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; +} diff --git a/tsconfig.json b/tsconfig.json index 9005eaf..0222ebe 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,7 @@ "sourceMap": true, "outDir": "dist", "lib": ["ES2020", "DOM"], - "types": ["node"] + "types": ["node", "astro/client"] }, "include": ["src", "types"], "exclude": ["dist", "node_modules"] diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..d0a6632 --- /dev/null +++ b/tsup.config.ts @@ -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"], +});