fix: get env variable dynamically to avoid secret leakage
This commit is contained in:
68
src/index.ts
68
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<OidcIntegrationOptions, "issuer" | "clientId">
|
||||
> & {
|
||||
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<OidcIntegrationOptions["cookie"]>,
|
||||
"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(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function signPayload(
|
||||
return `${payloadB64u}.${sigB64u}`;
|
||||
}
|
||||
|
||||
export async function verifyAndDecode<T = any>(
|
||||
export async function verifyAndDecode<T = unknown>(
|
||||
token: string,
|
||||
secret: string,
|
||||
): Promise<{ valid: boolean; payload?: T }> {
|
||||
|
||||
@@ -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<string, string> {
|
||||
@@ -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];
|
||||
|
||||
@@ -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<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;
|
||||
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();
|
||||
|
||||
@@ -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<typeof getOptions>,
|
||||
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);
|
||||
|
||||
@@ -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<typeof getOptions>,
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user