From f633b0343869fa2e1d23ea96e2ba8bd82c0018ce Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Wed, 27 May 2026 17:33:29 -0700 Subject: [PATCH] Fix fallback URL JWT issuer --- apps/backend/src/lib/request-api-url.ts | 90 ++++++++++++++++++++----- 1 file changed, 72 insertions(+), 18 deletions(-) diff --git a/apps/backend/src/lib/request-api-url.ts b/apps/backend/src/lib/request-api-url.ts index 6c0288a00..0b053812b 100644 --- a/apps/backend/src/lib/request-api-url.ts +++ b/apps/backend/src/lib/request-api-url.ts @@ -1,12 +1,13 @@ import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { captureError, HexclaveAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; /** * Single source of truth for the stack-auth ↔ hexclave host pairs that this * backend treats as equivalent siblings. Each `[stackAuthHost, hexclaveHost]` * pair is used in two places: * - * 1. `CLOUD_API_HOSTS` below — the allowlist of hosts whose name we are - * willing to stamp into a JWT `iss` claim or an OAuth `redirect_uri`. + * 1. `CLOUD_API_HOST_BY_REQUEST_HOST` below — the allowlist of hosts we are + * willing to resolve into a JWT `iss` claim or an OAuth `redirect_uri`. * 2. `issuerHostAliases` in `tokens.tsx` — the bidirectional validator alias * map, so a token issued under either host validates against the other. * @@ -22,19 +23,25 @@ export const CLOUD_HOST_PAIRS: ReadonlyArray = [ /** * Cloud hosts where this backend serves customer SDK traffic. Each request - * that arrives on one of these hosts is treated as "branded" to that host: - * the JWT `iss` claim and the OAuth `redirect_uri` we send to providers - * (Google, GitHub, ...) both use the same host the SDK targeted. That way a - * customer whose SDK is on `api.stack-auth.com` continues to receive - * `iss: api.stack-auth.com/...` tokens and OAuth redirect URIs registered - * with their provider apps as + * that arrives on one of these hosts is treated as "branded" to its canonical + * API host: the JWT `iss` claim and the OAuth `redirect_uri` we send to + * providers (Google, GitHub, ...) both use the same brand the SDK targeted. + * That way a customer whose SDK is on `api.stack-auth.com` continues to + * receive `iss: api.stack-auth.com/...` tokens and OAuth redirect URIs + * registered with their provider apps as * `https://api.stack-auth.com/api/v1/auth/oauth/callback/`, and a * customer whose SDK is on `api.hexclave.com` gets the hexclave-branded * equivalents. * - * Hosts NOT in this Set (localhost, vercel preview URLs, self-host custom + * Fallback/analytics hosts (`api1`, `api2`, `api3`, `r`) map back to the + * canonical `api` host for the same brand/environment. We should never stamp + * those load-balancing or recording hosts into customer-facing OAuth callback + * URLs or JWT issuers. + * + * Hosts NOT in this map (localhost, vercel preview URLs, self-host custom * domains) fall back to `NEXT_PUBLIC_STACK_API_URL` so single-host deployments - * keep behaving exactly as before. + * keep behaving exactly as before. We capture those fallbacks as errors so + * missed cloud host aliases are visible during the rebrand rollout. * * Trust model: on Vercel, `x-forwarded-host` is set by the edge from the * customer-facing hostname and cannot be spoofed by a client. The blast @@ -44,7 +51,32 @@ export const CLOUD_HOST_PAIRS: ReadonlyArray = [ * helper does NOT gate on a trusted-proxy signal; it assumes the deployment's * proxy chain sets `x-forwarded-host` from a trusted source. */ -const CLOUD_API_HOSTS = new Set(CLOUD_HOST_PAIRS.flat()); +function apiHostAliasesForCanonicalHost(canonicalHost: string): string[] { + const suffix = canonicalHost.slice("api.".length); + return [ + canonicalHost, + `api1.${suffix}`, + `api2.${suffix}`, + `api3.${suffix}`, + `r.${suffix}`, + ...suffix.startsWith("dev.") ? [`app.${suffix}`] : [], + ]; +} + +const CLOUD_API_HOST_BY_REQUEST_HOST = new Map( + CLOUD_HOST_PAIRS + .flat() + .flatMap((canonicalHost) => ( + apiHostAliasesForCanonicalHost(canonicalHost).map((requestHost) => [requestHost, canonicalHost] as const) + )), +); + +function normalizeRequestHost(host: string | undefined | null): string | undefined { + if (!host) return undefined; + const firstHost = host.split(",")[0]?.trim(); + if (!firstHost) return undefined; + return firstHost.split(":")[0].toLowerCase(); +} /** * Map a request's host header to the canonical API URL to use for any outward- @@ -52,15 +84,20 @@ const CLOUD_API_HOSTS = new Set(CLOUD_HOST_PAIRS.flat()); * etc.). Pass the bare hostname (no scheme, no port). */ export function getApiUrlForHost(host: string | undefined | null): string { - if (host) { - // Strip port if present and lowercase for case-insensitive comparison — - // hostnames are case-insensitive per RFC 3986. - const hostLower = host.split(":")[0].toLowerCase(); - if (CLOUD_API_HOSTS.has(hostLower)) { - return `https://${hostLower}`; + const normalizedHost = normalizeRequestHost(host); + if (normalizedHost) { + const apiHost = CLOUD_API_HOST_BY_REQUEST_HOST.get(normalizedHost); + if (apiHost) { + return `https://${apiHost}`; } } - return getEnvVariable("NEXT_PUBLIC_STACK_API_URL"); + const fallbackApiUrl = getEnvVariable("NEXT_PUBLIC_STACK_API_URL"); + captureError("request-api-url.fallback", new HexclaveAssertionError(`Falling back to NEXT_PUBLIC_STACK_API_URL while resolving request API URL`, { + host, + normalizedHost, + fallbackApiUrl, + })); + return fallbackApiUrl; } /** @@ -75,3 +112,20 @@ export function getApiUrlForRequest(req: { headers: Record { + for (const [stackAuthHost, hexclaveHost] of CLOUD_HOST_PAIRS) { + for (const canonicalHost of [stackAuthHost, hexclaveHost]) { + const suffix = canonicalHost.slice("api.".length); + for (const prefix of ["api", "api1", "api2", "api3", "r"]) { + expect(getApiUrlForHost(`${prefix}.${suffix}`)).toBe(`https://${canonicalHost}`); + expect(getApiUrlForHost(`${prefix.toUpperCase()}.${suffix}:443`)).toBe(`https://${canonicalHost}`); + } + } + } +}); + +import.meta.vitest?.test("getApiUrlForHost maps app.dev sibling hosts to canonical dev API hosts", ({ expect }) => { + expect(getApiUrlForHost("app.dev.stack-auth.com")).toBe("https://api.dev.stack-auth.com"); + expect(getApiUrlForHost("app.dev.hexclave.com")).toBe("https://api.dev.hexclave.com"); +});