/** * GET/POST /_emdash/oauth/authorize * * OAuth 2.1 Authorization Endpoint. Handles both the consent page (GET) * and consent submission (POST). * * GET: Renders an HTML consent page showing which client is requesting * access and which scopes are being requested. * POST: Processes the user's decision (approve/deny) and redirects * to the client's redirect_uri with an authorization code or error. * * Requires an authenticated session (not token auth). If unauthenticated, * redirects to login with a return URL. */ import type { APIRoute } from "astro"; import { resolveUserAuthz } from "#auth/authz.js"; import { AuthzRepository } from "#db/repositories/authz.js"; import { escapeHtml } from "#api/escape.js"; import { buildDeniedRedirect, handleAuthorizationApproval, validateRedirectUri, } from "#api/handlers/oauth-authorization.js"; import { lookupOAuthClient, validateClientRedirectUri } from "#api/handlers/oauth-clients.js"; import { getPublicOrigin } from "#api/public-url.js"; export const prerender = false; // --------------------------------------------------------------------------- // CSRF (SEC-18): Double-submit cookie pattern // --------------------------------------------------------------------------- const CSRF_COOKIE_NAME = "emdash_oauth_csrf"; /** Generate a 32-byte random token as hex. */ function generateCsrfToken(): string { const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); } /** Build the Set-Cookie header value for the CSRF token. */ function csrfCookieHeader(token: string, request: Request, siteUrl?: string): string { // SameSite=Strict prevents cross-site form submission. // HttpOnly: the token value is embedded in the form hidden field server-side, // so JS never needs to read the cookie. HttpOnly adds defense-in-depth. // Secure is set when: // - siteUrl is configured and uses https (proxy case — request may be http internally), OR // - the actual request is over https (non-proxy case, preserve existing behaviour) const isSecure = siteUrl ? siteUrl.startsWith("https:") : new URL(request.url).protocol === "https:"; const secure = isSecure ? "; Secure" : ""; return `${CSRF_COOKIE_NAME}=${token}; Path=/_emdash/oauth/authorize; HttpOnly; SameSite=Strict${secure}`; } /** Extract the CSRF token from the request's cookies. */ function getCsrfCookie(request: Request): string | null { const cookieHeader = request.headers.get("Cookie"); if (!cookieHeader) return null; const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${CSRF_COOKIE_NAME}=([^;]+)`)); return match?.[1] ?? null; } // --------------------------------------------------------------------------- // OAuth scopes are policy slugs. The consent page shows each requested // policy by name; an empty request means "act as me" (every policy the // user's role holds). // --------------------------------------------------------------------------- const POLICY_SLUG = /^[a-z0-9][a-z0-9_-]*$/; function parseScopeParam(value: string | null): string[] { return [...new Set((value ?? "").split(" ").filter((s) => POLICY_SLUG.test(s)))]; } // --------------------------------------------------------------------------- // GET: Render consent page // --------------------------------------------------------------------------- export const GET: APIRoute = async ({ url, request, locals }) => { const { emdash, user } = locals; // Validate required OAuth params before rendering const clientId = url.searchParams.get("client_id"); const redirectUri = url.searchParams.get("redirect_uri"); const responseType = url.searchParams.get("response_type"); const codeChallenge = url.searchParams.get("code_challenge"); const codeChallengeMethod = url.searchParams.get("code_challenge_method"); const scope = url.searchParams.get("scope"); const state = url.searchParams.get("state"); // Basic validation — detailed validation happens on POST if (!clientId || !redirectUri || responseType !== "code" || !codeChallenge) { return new Response( renderErrorPage("Invalid authorization request. Missing required parameters."), { status: 400, headers: { "Content-Type": "text/html; charset=utf-8" }, }, ); } if (codeChallengeMethod && codeChallengeMethod !== "S256") { return new Response(renderErrorPage("Only S256 code challenge method is supported."), { status: 400, headers: { "Content-Type": "text/html; charset=utf-8" }, }); } // Validate client_id is registered and redirect_uri is in the allowlist. // This check happens BEFORE authentication so we never redirect to an // unregistered URI (even for the login redirect, we only redirect to our // own login page, not to the client's redirect_uri). if (emdash?.db) { const client = await lookupOAuthClient(emdash.db, clientId); if (!client) { return new Response(renderErrorPage("Unknown client application."), { status: 400, headers: { "Content-Type": "text/html; charset=utf-8" }, }); } const clientUriError = validateClientRedirectUri(redirectUri, client.redirectUris); if (clientUriError) { return new Response(renderErrorPage("The redirect URI is not registered for this client."), { status: 400, headers: { "Content-Type": "text/html; charset=utf-8" }, }); } } // If not authenticated, redirect to login with return URL if (!user) { const loginUrl = new URL("/_emdash/admin/login", getPublicOrigin(url, emdash?.config)); loginUrl.searchParams.set("redirect", url.pathname + url.search); return Response.redirect(loginUrl.toString(), 302); } // Requested policies, or everything the user's role holds when the // client asked for nothing specific. const held = await resolveUserAuthz(emdash.db, user); const requestedScopes = (() => { const asked = parseScopeParam(scope); if (asked.length === 0) return [...held.rolePolicies]; const mine = new Set(held.rolePolicies); return asked.filter((s) => mine.has(s)); })(); if (requestedScopes.length === 0) { return new Response(renderErrorPage("None of the requested policies are held by your role."), { status: 400, headers: { "Content-Type": "text/html; charset=utf-8" }, }); } const policyNames = new Map(); for (const p of await new AuthzRepository(emdash.db).listPolicies()) policyNames.set(p.slug, p.name); // SEC-18: Generate CSRF token for the consent form (double-submit cookie pattern) const csrfToken = generateCsrfToken(); // Render the consent page const html = renderConsentPage({ clientId, scopes: requestedScopes.map((slug) => ({ slug, label: policyNames.get(slug) ?? slug })), redirectUri, responseType, codeChallenge, codeChallengeMethod: codeChallengeMethod ?? "S256", state: state ?? "", resource: url.searchParams.get("resource") ?? "", userName: user.name ?? user.email, csrfToken, }); return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8", "Set-Cookie": csrfCookieHeader(csrfToken, request, getPublicOrigin(url, emdash?.config)), }, }); }; // --------------------------------------------------------------------------- // POST: Process consent // --------------------------------------------------------------------------- export const POST: APIRoute = async ({ request, locals }) => { const { emdash, user } = locals; if (!emdash?.db) { return new Response(renderErrorPage("EmDash is not initialized."), { status: 500, headers: { "Content-Type": "text/html; charset=utf-8" }, }); } if (!user) { return new Response(renderErrorPage("Authentication required."), { status: 401, headers: { "Content-Type": "text/html; charset=utf-8" }, }); } const formData = await request.formData(); const field = (name: string, fallback = ""): string => { const v = formData.get(name); return typeof v === "string" ? v : fallback; }; const asked = parseScopeParam(new URL(request.url).searchParams.get("scope")); const requestedScopes = new Set( asked.length ? asked : user ? (await resolveUserAuthz(emdash.db, user)).rolePolicies : [], ); const selectedScopes = [ ...new Set( formData .getAll("scope") .filter((value): value is string => typeof value === "string") .filter((s) => POLICY_SLUG.test(s)) .filter((scope) => requestedScopes.has(scope)), ), ]; // SEC-18: Validate CSRF token (double-submit cookie pattern). // The form includes a hidden csrf_token field; the cookie has the same value. // An attacker cannot read the cookie to forge the form field (HttpOnly + SameSite=Strict). const formCsrf = field("csrf_token"); const cookieCsrf = getCsrfCookie(request); const csrfError = new Response( renderErrorPage("Invalid or missing CSRF token. Please try again."), { status: 403, headers: { "Content-Type": "text/html; charset=utf-8" } }, ); if (!formCsrf || !cookieCsrf) return csrfError; // Constant-time comparison: hash both values to fixed-length 32-byte digests, // then XOR every byte pair. This avoids crypto.subtle.timingSafeEqual which is // a Cloudflare Workers extension and doesn't exist in Node.js. // The SHA-256 pre-hash ensures fixed length, eliminating length-leaking. const csrfEncoder = new TextEncoder(); const [csrfHashA, csrfHashB] = await Promise.all([ crypto.subtle.digest("SHA-256", csrfEncoder.encode(formCsrf)), crypto.subtle.digest("SHA-256", csrfEncoder.encode(cookieCsrf)), ]); const a = new Uint8Array(csrfHashA); const b = new Uint8Array(csrfHashB); let diff = 0; // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- tsgo needs these for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!; if (diff !== 0) return csrfError; const action = field("action"); const redirectUri = field("redirect_uri"); const state = field("state") || undefined; if (!redirectUri) { return new Response(renderErrorPage("Missing redirect_uri."), { status: 400, headers: { "Content-Type": "text/html; charset=utf-8" }, }); } // Validate redirect_uri scheme/host before using it for any redirect const uriError = validateRedirectUri(redirectUri); if (uriError) { return new Response(renderErrorPage(escapeHtml(uriError)), { status: 400, headers: { "Content-Type": "text/html; charset=utf-8" }, }); } // User denied — SEC-44: validate redirect_uri against client's registered URIs // before redirecting, to prevent open redirect on the deny path. if (action === "deny") { const clientId = field("client_id"); if (!clientId) { return new Response(renderErrorPage("Missing client_id."), { status: 400, headers: { "Content-Type": "text/html; charset=utf-8" }, }); } const client = await lookupOAuthClient(emdash.db, clientId); if (!client) { return new Response(renderErrorPage("Unknown client application."), { status: 400, headers: { "Content-Type": "text/html; charset=utf-8" }, }); } const clientUriError = validateClientRedirectUri(redirectUri, client.redirectUris); if (clientUriError) { return new Response(renderErrorPage("The redirect URI is not registered for this client."), { status: 400, headers: { "Content-Type": "text/html; charset=utf-8" }, }); } const denyUrl = buildDeniedRedirect(redirectUri, state); return Response.redirect(denyUrl, 302); } // Clearing every checkbox is a refusal, not "act as me": nothing is granted. if (selectedScopes.length === 0) { try { const errorUrl = new URL(redirectUri); errorUrl.searchParams.set("error", "invalid_scope"); errorUrl.searchParams.set("error_description", "No selected permission can be granted"); if (state) errorUrl.searchParams.set("state", state); return Response.redirect(errorUrl.toString(), 302); } catch { return new Response(renderErrorPage("No permission selected."), { status: 400, headers: { "Content-Type": "text/html; charset=utf-8" }, }); } } // User approved — process the authorization const result = await handleAuthorizationApproval(emdash.db, user.id, { role: user.role, roleId: user.roleId ?? null }, { response_type: field("response_type", "code"), client_id: field("client_id"), redirect_uri: redirectUri, scope: selectedScopes.join(" "), state, code_challenge: field("code_challenge"), code_challenge_method: field("code_challenge_method", "S256"), resource: field("resource") || undefined, }); if (!result.success) { const errMsg = result.error?.message ?? "Authorization failed"; const invalidScope = result.error?.code === "INVALID_SCOPE"; // On error, redirect back with error params — use generic description to avoid // leaking internal error details to the (already-validated) redirect target try { const errorUrl = new URL(redirectUri); errorUrl.searchParams.set("error", invalidScope ? "invalid_scope" : "server_error"); errorUrl.searchParams.set( "error_description", invalidScope ? "No selected permission can be granted" : "Authorization failed", ); if (state) errorUrl.searchParams.set("state", state); return Response.redirect(errorUrl.toString(), 302); } catch { return new Response(renderErrorPage(escapeHtml(errMsg)), { status: 400, headers: { "Content-Type": "text/html; charset=utf-8" }, }); } } return Response.redirect(result.data.redirect_url, 302); }; // --------------------------------------------------------------------------- // HTML rendering // --------------------------------------------------------------------------- function renderConsentPage(params: { clientId: string; scopes: Array<{ slug: string; label: string }>; redirectUri: string; responseType: string; codeChallenge: string; codeChallengeMethod: string; state: string; resource: string; userName: string; csrfToken: string; }): string { const scopeList = params.scopes .map( (s) => `
  • `, ) .join("\n"); return ` Authorize Application — EmDash

    Authorize Application

    ${escapeHtml(params.clientId)}

    Signed in as ${escapeHtml(params.userName)}

    Permissions requested

      ${scopeList}
    `; } function renderErrorPage(message: string): string { return ` Authorization Error — EmDash

    Authorization Error

    ${escapeHtml(message)}

    `; }