mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Fix fallback URL JWT issuer
This commit is contained in:
parent
39e5496694
commit
f633b03438
@ -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");
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user