Fix fallback URL JWT issuer

This commit is contained in:
Konstantin Wohlwend 2026-05-27 17:33:29 -07:00 committed by Madison
parent 39e5496694
commit f633b03438

View File

@ -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<readonly [string, string]> = [
/**
* 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/<provider>`, 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<readonly [string, string]> = [
* 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<string>(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<string, string>(
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<string>(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<string, string[] | un
const host = req.headers["x-forwarded-host"]?.[0] ?? req.headers["host"]?.[0];
return getApiUrlForHost(host);
}
import.meta.vitest?.test("getApiUrlForHost maps cloud sibling hosts to canonical API hosts", ({ expect }) => {
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");
});