From 09ec01fe30288d26913114f70a68b0520f80307f Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Thu, 21 May 2026 12:29:33 -0700 Subject: [PATCH 1/4] Cross domain handoffs --- .claude/CLAUDE-KNOWLEDGE.md | 3 + apps/backend/src/lib/redirect-urls.tsx | 95 +----- apps/e2e/tests/js/cross-domain-auth.test.ts | 235 +++++++++++++- examples/demo/src/stack.tsx | 3 +- .../src/interface/crud/projects.ts | 1 + .../stack-shared/src/utils/redirect-urls.tsx | 109 +++++++ packages/template/src/lib/auth.ts | 13 +- .../apps/implementations/client-app-impl.ts | 288 ++++++++++++++++-- .../src/lib/stack-app/url-targets.test.ts | 18 ++ .../template/src/lib/stack-app/url-targets.ts | 15 + 10 files changed, 655 insertions(+), 125 deletions(-) create mode 100644 packages/stack-shared/src/utils/redirect-urls.tsx diff --git a/.claude/CLAUDE-KNOWLEDGE.md b/.claude/CLAUDE-KNOWLEDGE.md index 25bb9b3eae..18a094f01e 100644 --- a/.claude/CLAUDE-KNOWLEDGE.md +++ b/.claude/CLAUDE-KNOWLEDGE.md @@ -502,3 +502,6 @@ A: Seed the normal initial environment config before marking the project as `isD ## Q: What can cause React error #185 immediately on dashboard load? A: React error #185 is a maximum update depth error. In the dashboard root, `useSyncExternalStore` snapshot getters must return cached referentially stable values. Returning a fresh object such as `{ status: "healthy" }` from `getSnapshot` on every call can make React think the external store changed on every render and loop immediately. Use module-level constants for stable snapshots. + +## Q: How should client-side OAuth callback and nested cross-domain auth avoid racing session consumers? +A: Track startup auth transitions as pending client-app promises and make `_getSession`/react-like `_useSession` wait for them when using the default persistent token store. Auth-transition code that needs to inspect the current session should explicitly call `_getSession(..., { awaitPendingAuthResolutions: false })` instead of relying on a global reentrancy flag. diff --git a/apps/backend/src/lib/redirect-urls.tsx b/apps/backend/src/lib/redirect-urls.tsx index 6eeb1c4c37..f24532e913 100644 --- a/apps/backend/src/lib/redirect-urls.tsx +++ b/apps/backend/src/lib/redirect-urls.tsx @@ -1,97 +1,14 @@ -import { StackAssertionError, captureError } from "@stackframe/stack-shared/dist/utils/errors"; -import { createUrlIfValid, isLocalhost, matchHostnamePattern } from "@stackframe/stack-shared/dist/utils/urls"; +import { isAcceptedNativeAppUrl, validateRedirectUrl as validateRedirectUrlAgainstTrustedDomains } from "@stackframe/stack-shared/dist/utils/redirect-urls"; import { Tenancy } from "./tenancies"; -/** - * Normalizes a URL to include explicit default ports for comparison - */ -function normalizePort(url: URL): string { - const defaultPorts = new Map([['https:', '443'], ['http:', '80']]); - const port = url.port || defaultPorts.get(url.protocol) || ''; - return port ? `${url.hostname}:${port}` : url.hostname; -} - -/** - * Checks if a URL uses the default port for its protocol - */ -function isDefaultPort(url: URL): boolean { - return !url.port || - (url.protocol === 'https:' && url.port === '443') || - (url.protocol === 'http:' && url.port === '80'); -} - -/** - * Checks if two URLs have matching ports (considering default ports) - */ -function portsMatch(url1: URL, url2: URL): boolean { - return normalizePort(url1) === normalizePort(url2); -} - -/** - * Validates a URL against a domain pattern (with or without wildcards) - */ -function matchesDomain(testUrl: URL, pattern: string): boolean { - const baseUrl = createUrlIfValid(pattern); - - // If pattern is invalid as a URL, it might contain wildcards - if (!baseUrl || pattern.includes('*')) { - // Parse wildcard pattern manually - const match = pattern.match(/^([^:]+:\/\/)([^/]*)(.*)$/); - if (!match) { - captureError("invalid-redirect-domain", new StackAssertionError("Invalid domain pattern", { pattern })); - return false; - } - - const [, protocol, hostPattern] = match; - - // Check protocol - if (testUrl.protocol + '//' !== protocol) { - return false; - } - - // Check host with wildcard pattern - const hasPortInPattern = hostPattern.includes(':'); - if (hasPortInPattern) { - // Pattern includes port - match against normalized host:port - return matchHostnamePattern(hostPattern, normalizePort(testUrl)); - } else { - // Pattern doesn't include port - match hostname only, require default port - return matchHostnamePattern(hostPattern, testUrl.hostname) && isDefaultPort(testUrl); - } - } - - // For non-wildcard patterns, use URL comparison - return baseUrl.protocol === testUrl.protocol && - baseUrl.hostname === testUrl.hostname && - portsMatch(baseUrl, testUrl); -} - -/** - * Checks if URL is an accepted native app SDK redirect URL. - * These are safe because they can only be handled by native apps, - * not web browsers. - */ -export function isAcceptedNativeAppUrl(urlOrString: string): boolean { - const url = createUrlIfValid(urlOrString); - if (!url) return false; - - return url.protocol === 'stack-auth-mobile-oauth-url:'; -} +export { isAcceptedNativeAppUrl }; export function validateRedirectUrl( urlOrString: string | URL, tenancy: Tenancy, ): boolean { - const url = createUrlIfValid(urlOrString); - if (!url) return false; - - // Check localhost permission - if (tenancy.config.domains.allowLocalhost && isLocalhost(url)) { - return true; - } - - // Check trusted domains - return Object.values(tenancy.config.domains.trustedDomains).some(domain => - domain.baseUrl && matchesDomain(url, domain.baseUrl) - ); + return validateRedirectUrlAgainstTrustedDomains(urlOrString, { + allowLocalhost: tenancy.config.domains.allowLocalhost, + trustedDomains: Object.values(tenancy.config.domains.trustedDomains).map(domain => domain.baseUrl), + }); } diff --git a/apps/e2e/tests/js/cross-domain-auth.test.ts b/apps/e2e/tests/js/cross-domain-auth.test.ts index f48f0fa9ed..d5ab237584 100644 --- a/apps/e2e/tests/js/cross-domain-auth.test.ts +++ b/apps/e2e/tests/js/cross-domain-auth.test.ts @@ -74,7 +74,8 @@ it("adds secure cross-domain handoff parameters when redirecting to hosted sign- expect(redirectUrl.searchParams.get("stack_cross_domain_after_callback_redirect_url")).toBe(`${localRedirectUrl}/private-page?foo=bar`); const callbackUrl = new URL(redirectUrl.searchParams.get("after_auth_return_to") ?? ""); expect(callbackUrl.origin).toBe(new URL(localRedirectUrl).origin); - expect(callbackUrl.pathname).toBe("/handler/oauth-callback"); + expect(callbackUrl.pathname).toBe(new URL(`${localRedirectUrl}/private-page`).pathname); + expect(callbackUrl.searchParams.get("foo")).toBe("bar"); expect(callbackUrl.searchParams.get("stack_cross_domain_auth")).toBe("1"); expect(callbackUrl.searchParams.get("stack_cross_domain_state")).toEqual(expect.any(String)); expect(callbackUrl.searchParams.get("stack_cross_domain_code_challenge")).toEqual(expect.any(String)); @@ -253,3 +254,235 @@ it("keeps cross-domain handoff working when after_auth_return_to is rewritten to expect(redirectedUrl).toBe(crossDomainAuthorizeRedirect); }); }); + +it("adds nested cross-domain auth params when redirecting signed-in users to hosted account settings", async ({ expect }) => { + await withHostedDomainSuffix(async () => { + const projectId = "66666666-6666-4666-8666-666666666666"; + const refreshTokenId = "source-refresh-token-id"; + const currentHref = `${localRedirectUrl}/dashboard?tab=settings`; + const clientApp = createClientApp(projectId); + + vi.spyOn(clientApp as any, "_getCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(refreshTokenId); + + const previousWindow = globalThis.window; + const previousDocument = globalThis.document; + let redirectedUrl = ""; + globalThis.document = { cookie: "", createElement: () => ({}) } as any; + globalThis.window = { + location: { + href: currentHref, + assign: (url: string) => { + redirectedUrl = url; + throw new Error("INTENTIONAL_TEST_ABORT"); + }, + }, + } as any; + + try { + await expect(clientApp.redirectToAccountSettings()).rejects.toThrowError("INTENTIONAL_TEST_ABORT"); + } finally { + globalThis.window = previousWindow; + globalThis.document = previousDocument; + } + + const redirectUrl = new URL(redirectedUrl); + expect(redirectUrl.origin).toBe(`https://${projectId}.example-stack-hosted.test`); + expect(redirectUrl.pathname).toBe("/handler/account-settings"); + expect(redirectUrl.searchParams.get("stack_nested_cross_domain_auth_refresh_token_id")).toBe(refreshTokenId); + expect(redirectUrl.searchParams.get("stack_nested_cross_domain_auth_callback_url")).toBe(currentHref); + }); +}); + +it("adds nested cross-domain auth params for other cross-domain handler redirects", async ({ expect }) => { + await withHostedDomainSuffix(async () => { + const projectId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + const refreshTokenId = "source-refresh-token-id"; + const currentHref = `${localRedirectUrl}/private-page`; + const clientApp = createClientApp(projectId); + + vi.spyOn(clientApp as any, "_getCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(refreshTokenId); + + const previousWindow = globalThis.window; + const previousDocument = globalThis.document; + let redirectedUrl = ""; + globalThis.document = { cookie: "", createElement: () => ({}) } as any; + globalThis.window = { + location: { + href: currentHref, + assign: (url: string) => { + redirectedUrl = url; + throw new Error("INTENTIONAL_TEST_ABORT"); + }, + }, + } as any; + + try { + await expect(clientApp.redirectToTeamInvitation()).rejects.toThrowError("INTENTIONAL_TEST_ABORT"); + } finally { + globalThis.window = previousWindow; + globalThis.document = previousDocument; + } + + const redirectUrl = new URL(redirectedUrl); + expect(redirectUrl.origin).toBe(`https://${projectId}.example-stack-hosted.test`); + expect(redirectUrl.pathname).toBe("/handler/team-invitation"); + expect(redirectUrl.searchParams.get("stack_nested_cross_domain_auth_refresh_token_id")).toBe(refreshTokenId); + expect(redirectUrl.searchParams.get("stack_nested_cross_domain_auth_callback_url")).toBe(currentHref); + }); +}); + +it("starts nested cross-domain auth from the target domain", async ({ expect }) => { + await withHostedDomainSuffix(async () => { + const projectId = "77777777-7777-4777-8777-777777777777"; + const clientApp = createClientApp(projectId); + const currentHref = `https://${projectId}.example-stack-hosted.test/handler/account-settings?stack_nested_cross_domain_auth_refresh_token_id=source-session&stack_nested_cross_domain_auth_callback_url=${encodeURIComponent(`https://${projectId}.example-stack-hosted.test/handler/oauth-callback`)}`; + const previousWindow = globalThis.window; + const previousDocument = globalThis.document; + let redirectedUrl = ""; + + vi.spyOn(clientApp as any, "_getCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(null); + vi.spyOn(clientApp as any, "_getCrossDomainHandoffParamsForRedirect").mockResolvedValue({ + state: "nested-state", + codeChallenge: "nested-code-challenge", + }); + + globalThis.document = { cookie: "", createElement: () => ({}) } as any; + globalThis.window = { + location: { + href: currentHref, + replace: (url: string) => { + redirectedUrl = url; + throw new Error("INTENTIONAL_TEST_ABORT"); + }, + }, + } as any; + + try { + await expect((clientApp as any)._maybeHandleNestedCrossDomainAuth()).rejects.toThrowError("INTENTIONAL_TEST_ABORT"); + } finally { + globalThis.window = previousWindow; + globalThis.document = previousDocument; + } + + const redirectUrl = new URL(redirectedUrl); + expect(redirectUrl.pathname).toBe("/handler/oauth-callback"); + expect(redirectUrl.searchParams.get("stack_nested_cross_domain_auth_refresh_token_id")).toBe("source-session"); + expect(redirectUrl.searchParams.get("redirect_uri")).toBe(currentHref); + expect(redirectUrl.searchParams.get("state")).toBe("nested-state"); + expect(redirectUrl.searchParams.get("code_challenge")).toBe("nested-code-challenge"); + expect(redirectUrl.searchParams.get("code_challenge_method")).toBe("S256"); + expect(redirectUrl.searchParams.get("after_callback_redirect_url")).toBe(`https://${projectId}.example-stack-hosted.test/handler/account-settings`); + }); +}); + +it("continues nested cross-domain auth on the source domain", async ({ expect }) => { + await withHostedDomainSuffix(async () => { + const projectId = "88888888-8888-4888-8888-888888888888"; + const clientApp = createClientApp(projectId); + const sourceRefreshTokenId = "source-session"; + const redirectUri = `https://${projectId}.example-stack-hosted.test/handler/account-settings?stack_nested_cross_domain_auth_refresh_token_id=source-session`; + const currentUrl = new URL(`${localRedirectUrl}/nested-provider`); + currentUrl.searchParams.set("stack_nested_cross_domain_auth_refresh_token_id", sourceRefreshTokenId); + currentUrl.searchParams.set("redirect_uri", redirectUri); + currentUrl.searchParams.set("state", "nested-state"); + currentUrl.searchParams.set("code_challenge", "nested-code-challenge"); + currentUrl.searchParams.set("code_challenge_method", "S256"); + currentUrl.searchParams.set("after_callback_redirect_url", `https://${projectId}.example-stack-hosted.test/handler/account-settings`); + + const previousWindow = globalThis.window; + const previousDocument = globalThis.document; + let redirectedUrl = ""; + const crossDomainRedirect = `https://${projectId}.example-stack-hosted.test/handler/account-settings?code=nested-code&state=nested-state`; + const createCrossDomainAuthRedirectUrlSpy = vi + .spyOn(clientApp as any, "_createCrossDomainAuthRedirectUrl") + .mockResolvedValue(crossDomainRedirect); + vi.spyOn(clientApp as any, "_getCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(sourceRefreshTokenId); + + globalThis.document = { cookie: "", createElement: () => ({}) } as any; + globalThis.window = { + location: { + href: currentUrl.toString(), + replace: (url: string) => { + redirectedUrl = url; + throw new Error("INTENTIONAL_TEST_ABORT"); + }, + }, + } as any; + + try { + await expect((clientApp as any)._maybeHandleNestedCrossDomainAuth()).rejects.toThrowError("INTENTIONAL_TEST_ABORT"); + } finally { + globalThis.window = previousWindow; + globalThis.document = previousDocument; + } + + expect(createCrossDomainAuthRedirectUrlSpy).toHaveBeenCalledWith({ + redirectUri, + state: "nested-state", + codeChallenge: "nested-code-challenge", + afterCallbackRedirectUrl: `https://${projectId}.example-stack-hosted.test/handler/account-settings`, + awaitPendingAuthResolutions: false, + }); + expect(redirectedUrl).toBe(crossDomainRedirect); + }); +}); + +it("rejects nested cross-domain auth when the callback URL is untrusted", async ({ expect }) => { + await withHostedDomainSuffix(async () => { + const projectId = "99999999-9999-4999-8999-999999999999"; + const clientApp = createClientApp(projectId); + const currentHref = `https://${projectId}.example-stack-hosted.test/handler/account-settings?stack_nested_cross_domain_auth_refresh_token_id=source-session&stack_nested_cross_domain_auth_callback_url=${encodeURIComponent("https://evil.example.test/oauth-callback")}`; + const previousWindow = globalThis.window; + const previousDocument = globalThis.document; + + vi.spyOn(clientApp as any, "_getCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(null); + vi.spyOn(clientApp as any, "_isTrusted").mockResolvedValue(false); + + globalThis.document = { cookie: "", createElement: () => ({}) } as any; + globalThis.window = { + location: { + href: currentHref, + }, + } as any; + + try { + await expect((clientApp as any)._maybeHandleNestedCrossDomainAuth()).rejects.toThrowError(/not trusted/); + } finally { + globalThis.window = previousWindow; + globalThis.document = previousDocument; + } + }); +}); + +it("rejects nested cross-domain auth when the source session does not match", async ({ expect }) => { + await withHostedDomainSuffix(async () => { + const projectId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const clientApp = createClientApp(projectId); + const currentUrl = new URL(`${localRedirectUrl}/nested-provider`); + currentUrl.searchParams.set("stack_nested_cross_domain_auth_refresh_token_id", "requested-source-session"); + currentUrl.searchParams.set("redirect_uri", `https://${projectId}.example-stack-hosted.test/handler/account-settings`); + currentUrl.searchParams.set("state", "nested-state"); + currentUrl.searchParams.set("code_challenge", "nested-code-challenge"); + + const previousWindow = globalThis.window; + const previousDocument = globalThis.document; + const createCrossDomainAuthRedirectUrlSpy = vi.spyOn(clientApp as any, "_createCrossDomainAuthRedirectUrl"); + vi.spyOn(clientApp as any, "_getCurrentRefreshTokenIdIfSignedIn").mockResolvedValue("different-source-session"); + + globalThis.document = { cookie: "", createElement: () => ({}) } as any; + globalThis.window = { + location: { + href: currentUrl.toString(), + }, + } as any; + + try { + await expect((clientApp as any)._maybeHandleNestedCrossDomainAuth()).rejects.toThrowError(/does not match/); + } finally { + globalThis.window = previousWindow; + globalThis.document = previousDocument; + } + + expect(createCrossDomainAuthRedirectUrlSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/examples/demo/src/stack.tsx b/examples/demo/src/stack.tsx index 9b43aadaf5..ac3cb4e41f 100644 --- a/examples/demo/src/stack.tsx +++ b/examples/demo/src/stack.tsx @@ -5,11 +5,10 @@ import { StackServerApp } from "@stackframe/stack"; export const stackServerApp = new StackServerApp({ tokenStore: "nextjs-cookie", urls: { - accountSettings: '/settings', signIn: { type: "hosted" }, signUp: { type: "custom", url: "/auth/sign-up", version: 0 }, default: { - "type": "handler-component", + "type": "hosted", }, } }); diff --git a/packages/stack-shared/src/interface/crud/projects.ts b/packages/stack-shared/src/interface/crud/projects.ts index 098b9273eb..2feaa8df5b 100644 --- a/packages/stack-shared/src/interface/crud/projects.ts +++ b/packages/stack-shared/src/interface/crud/projects.ts @@ -124,6 +124,7 @@ export const projectsCrudClientReadSchema = yupObject({ passkey_enabled: schemaFields.projectPasskeyEnabledSchema.defined(), client_team_creation_enabled: schemaFields.projectClientTeamCreationEnabledSchema.defined(), client_user_deletion_enabled: schemaFields.projectClientUserDeletionEnabledSchema.defined(), + allow_localhost: schemaFields.projectAllowLocalhostSchema.defined(), allow_user_api_keys: schemaFields.yupBoolean().defined(), allow_team_api_keys: schemaFields.yupBoolean().defined(), enabled_oauth_providers: yupArray(enabledOAuthProviderSchema.defined()).defined().meta({ openapiField: { hidden: true } }), diff --git a/packages/stack-shared/src/utils/redirect-urls.tsx b/packages/stack-shared/src/utils/redirect-urls.tsx new file mode 100644 index 0000000000..bfe2cdb862 --- /dev/null +++ b/packages/stack-shared/src/utils/redirect-urls.tsx @@ -0,0 +1,109 @@ +import { StackAssertionError, captureError } from "./errors"; +import { createUrlIfValid, isLocalhost, matchHostnamePattern } from "./urls"; + +type TrustedDomainConfig = { + allowLocalhost?: boolean, + trustedDomains: readonly (string | null | undefined)[], +}; + +function normalizePort(url: URL): string { + const defaultPorts = new Map([['https:', '443'], ['http:', '80']]); + const port = url.port || defaultPorts.get(url.protocol) || ''; + return port ? `${url.hostname}:${port}` : url.hostname; +} + +function isDefaultPort(url: URL): boolean { + return !url.port || + (url.protocol === 'https:' && url.port === '443') || + (url.protocol === 'http:' && url.port === '80'); +} + +function portsMatch(url1: URL, url2: URL): boolean { + return normalizePort(url1) === normalizePort(url2); +} + +function parseWildcardUrlPattern(pattern: string): { protocol: string, hostPattern: string } | null { + const protocolSeparatorIndex = pattern.indexOf("://"); + if (protocolSeparatorIndex === -1) return null; + + const protocol = `${pattern.slice(0, protocolSeparatorIndex)}:`; + const hostAndPath = pattern.slice(protocolSeparatorIndex + "://".length); + const pathStartIndex = hostAndPath.indexOf("/"); + const hostPattern = pathStartIndex === -1 ? hostAndPath : hostAndPath.slice(0, pathStartIndex); + if (hostPattern === "") return null; + return { protocol, hostPattern }; +} + +function hostPatternWithoutPort(hostPattern: string): string { + const portSeparatorIndex = hostPattern.lastIndexOf(":"); + return portSeparatorIndex === -1 ? hostPattern : hostPattern.slice(0, portSeparatorIndex); +} + +function matchesTrustedDomain(testUrl: URL, pattern: string): boolean { + const baseUrl = createUrlIfValid(pattern); + + if (baseUrl != null && !pattern.includes('*')) { + return baseUrl.protocol === testUrl.protocol && + baseUrl.hostname === testUrl.hostname && + portsMatch(baseUrl, testUrl); + } + + const parsedPattern = parseWildcardUrlPattern(pattern); + if (parsedPattern == null) { + captureError("invalid-redirect-domain", new StackAssertionError("Invalid domain pattern", { pattern })); + return false; + } + + if (testUrl.protocol !== parsedPattern.protocol) { + return false; + } + + const hasPortInPattern = parsedPattern.hostPattern.includes(':'); + return hasPortInPattern + ? matchHostnamePattern(parsedPattern.hostPattern, normalizePort(testUrl)) + : matchHostnamePattern(parsedPattern.hostPattern, testUrl.hostname) && isDefaultPort(testUrl); +} + +export function isAcceptedNativeAppUrl(urlOrString: string): boolean { + const url = createUrlIfValid(urlOrString); + if (!url) return false; + + return url.protocol === 'stack-auth-mobile-oauth-url:'; +} + +export function validateRedirectUrl( + urlOrString: string | URL, + config: TrustedDomainConfig, +): boolean { + const url = createUrlIfValid(urlOrString); + if (!url) return false; + + if (config.allowLocalhost === true && isLocalhost(url)) { + return true; + } + + return config.trustedDomains.some(domain => domain != null && matchesTrustedDomain(url, domain)); +} + +export function getTrustedParentDomain(currentDomain: string, trustedDomains: readonly string[]): string | null { + const hostPatterns = trustedDomains + .map((domain) => { + const url = createUrlIfValid(domain); + if (url != null && !domain.includes("*")) { + return url.hostname.toLowerCase(); + } + const parsedPattern = parseWildcardUrlPattern(domain); + return parsedPattern == null ? null : hostPatternWithoutPort(parsedPattern.hostPattern).toLowerCase(); + }) + .filter((domain): domain is string => domain != null); + + const parts = currentDomain.toLowerCase().split('.'); + for (let i = parts.length - 2; i >= 0; i--) { + const parentDomain = parts.slice(i).join('.'); + if (hostPatterns.includes(parentDomain) && hostPatterns.includes(`**.${parentDomain}`)) { + return parentDomain; + } + } + + return null; +} diff --git a/packages/template/src/lib/auth.ts b/packages/template/src/lib/auth.ts index a695b4ad4f..0d124e10ee 100644 --- a/packages/template/src/lib/auth.ts +++ b/packages/template/src/lib/auth.ts @@ -46,7 +46,9 @@ type OAuthCallbackConsumptionResult = error: KnownError, }; -function consumeOAuthCallbackQueryParams(): OAuthCallbackConsumptionResult | null { +function consumeOAuthCallbackQueryParams(options?: { + dontWarnAboutMissingQueryParams?: boolean, +}): OAuthCallbackConsumptionResult | null { const oauthErrorParams = ["error", "error_description", "errorCode", "message", "details"] as const; const requiredParams = ["code", "state"]; const originalUrl = new URL(window.location.href); @@ -84,7 +86,9 @@ function consumeOAuthCallbackQueryParams(): OAuthCallbackConsumptionResult | nul for (const param of requiredParams) { if (!originalUrl.searchParams.has(param)) { - console.warn(new Error(`Missing required query parameter on OAuth callback: ${param}. Maybe you opened or reloaded the oauth-callback page from your history?`)); + if (!options?.dontWarnAboutMissingQueryParams) { + console.warn(new Error(`Missing required query parameter on OAuth callback: ${param}. Maybe you opened or reloaded the oauth-callback page from your history?`)); + } return null; } } @@ -135,11 +139,14 @@ function consumeOAuthCallbackQueryParams(): OAuthCallbackConsumptionResult | nul export async function callOAuthCallback( iface: StackClientInterface, redirectUrl: string, + options?: { + dontWarnAboutMissingQueryParams?: boolean, + }, ) { // note: this part of the function (until the return) needs // to be synchronous, to prevent race conditions when // callOAuthCallback is called multiple times in parallel - const consumed = consumeOAuthCallbackQueryParams(); + const consumed = consumeOAuthCallbackQueryParams(options); if (!consumed) return Result.ok(undefined); if (consumed.type === "known-error") { throw consumed.error; diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index be79cbd72c..2374a6955e 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -29,11 +29,12 @@ import { ProviderType } from "@stackframe/stack-shared/dist/utils/oauth"; import { deepPlainEquals, omit } from "@stackframe/stack-shared/dist/utils/objects"; import { neverResolve, runAsynchronously, wait } from "@stackframe/stack-shared/dist/utils/promises"; import { suspend, suspendIfSsr, use } from "@stackframe/stack-shared/dist/utils/react"; +import { getTrustedParentDomain, validateRedirectUrl } from "@stackframe/stack-shared/dist/utils/redirect-urls"; import { Result } from "@stackframe/stack-shared/dist/utils/results"; import { Store, storeLock } from "@stackframe/stack-shared/dist/utils/stores"; import { deindent, mergeScopeStrings } from "@stackframe/stack-shared/dist/utils/strings"; -import { BotChallengeExecutionFailedError, BotChallengeUserCancelledError, withBotChallengeFlow } from "@stackframe/stack-shared/dist/utils/turnstile-flow"; import type { TurnstileAction } from "@stackframe/stack-shared/dist/utils/turnstile"; +import { BotChallengeExecutionFailedError, BotChallengeUserCancelledError, withBotChallengeFlow } from "@stackframe/stack-shared/dist/utils/turnstile-flow"; import { isRelative } from "@stackframe/stack-shared/dist/utils/urls"; import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; import * as tanstackStartServerContext from "@stackframe/tanstack-start/tanstack-start-server-context"; // THIS_LINE_PLATFORM tanstack-start @@ -43,7 +44,7 @@ import * as NextNavigationUnscrambled from "next/navigation"; // import the enti import React, { useCallback, useMemo } from "react"; // THIS_LINE_PLATFORM react-like import type * as yup from "yup"; import { constructRedirectUrl } from "../../../../utils/url"; -import { getNewOAuthProviderOrScopeUrl, callOAuthCallback } from "../../../auth"; +import { callOAuthCallback, getNewOAuthProviderOrScopeUrl } from "../../../auth"; import { CookieHelper, createBrowserCookieHelper, createCookieHelper, createPlaceholderCookieHelper, deleteCookie, deleteCookieClient, isSecure as isSecureCookieContext, saveVerifierAndState, setOrDeleteCookie, setOrDeleteCookieClient } from "../../../cookie"; import { envVars } from "../../../env"; import { ApiKey, ApiKeyCreationOptions, ApiKeyUpdateOptions, apiKeyCreationOptionsToCrud } from "../../api-keys"; @@ -61,8 +62,8 @@ import { StackClientApp, StackClientAppConstructorOptions, StackClientAppJson } import { _StackAdminAppImplIncomplete } from "./admin-app-impl"; import { TokenObject, clientVersion, createCache, createCacheBySession, createEmptyTokenStore, getAnalyticsBaseUrl, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getUrls, resolveApiUrls, resolveConstructorOptions } from "./common"; import { EventTracker } from "./event-tracker"; -import { crossDomainAuthQueryParams, getCrossDomainHandoffParamsFromCurrentUrl, planRedirectToHandler } from "./redirect-page-urls"; import type { CrossDomainHandoffParams } from "./redirect-page-urls"; +import { crossDomainAuthQueryParams, getCrossDomainHandoffParamsFromCurrentUrl, planRedirectToHandler } from "./redirect-page-urls"; import { subscribeSessionRefresh } from "./session-refresh-subscription"; import { AnalyticsOptions, SessionRecorder, analyticsOptionsFromJson, analyticsOptionsToJson } from "./session-replay"; @@ -85,6 +86,16 @@ const NextNavigation = scrambleDuringCompileTime(NextNavigationUnscrambled); const prefetchedCrossDomainHandoffTtlMs = 55 * 60 * 1000; +const nestedCrossDomainAuthQueryParams = { + refreshTokenId: "stack_nested_cross_domain_auth_refresh_token_id", + callbackUrl: "stack_nested_cross_domain_auth_callback_url", + redirectUri: "redirect_uri", + state: "state", + codeChallenge: "code_challenge", + codeChallengeMethod: "code_challenge_method", + afterCallbackRedirectUrl: "after_callback_redirect_url", +} as const; + const allClientApps = new Map]>(); const STACK_AUTHORIZATION_VALUE_PREFIX = "stackauth_"; @@ -314,7 +325,7 @@ export class _StackClientAppImplIncomplete[] = []; protected async _createCookieHelper(overrideTokenStoreInit?: TokenStoreInit): Promise { const tokenStoreInit = overrideTokenStoreInit === undefined ? this._tokenStoreInit : overrideTokenStoreInit; @@ -518,7 +530,7 @@ export class _StackClientAppImplIncomplete { + if (isBrowserLike()) { + await this.callOAuthCallback({ dontWarnAboutMissingQueryParams: true }); + } + }); + } + + if (isBrowserLike()) { + this._trackPendingAuthResolution(async () => { + await this._maybeHandleNestedCrossDomainAuth(); + }); + } + // IF_PLATFORM js-like if (isBrowserLike() && resolvedOptions.devTool !== false) { mountDevTool(this as any); @@ -694,6 +720,185 @@ export class _StackClientAppImplIncomplete Promise) { + const promise = (async () => { + await Promise.resolve(); + await callback(); + })(); + this._pendingAuthResolutionPromises.push(promise); + runAsynchronously(async () => { + try { + await promise; + } finally { + this._pendingAuthResolutionPromises = this._pendingAuthResolutionPromises.filter(p => p !== promise); + } + }); + } + + protected async _awaitPendingAuthResolutions( + overrideTokenStoreInit?: TokenStoreInit, + options?: { awaitPendingAuthResolutions?: boolean }, + ) { + if ( + options?.awaitPendingAuthResolutions === false + || overrideTokenStoreInit !== undefined + || !this._hasPersistentTokenStore() + || this._pendingAuthResolutionPromises.length === 0 + ) { + return; + } + // A page may construct the app while OAuth callback or nested cross-domain auth is still + // deciding whether it will replace the current session. Until those startup transitions + // finish, auth consumers should not treat the current token store as final. + await Promise.all(this._pendingAuthResolutionPromises); + } + + // IF_PLATFORM react-like + protected _usePendingAuthResolutions(overrideTokenStoreInit?: TokenStoreInit) { + if ( + overrideTokenStoreInit !== undefined + || !this._hasPersistentTokenStore() + || this._pendingAuthResolutionPromises.length === 0 + ) { + return; + } + use(Promise.all(this._pendingAuthResolutionPromises)); + } + // END_PLATFORM + + protected _isOAuthCallbackUrlHosted(): boolean { + const oauthCallbackTarget = this._urlOptions.oauthCallback ?? this._urlOptions.default; + return typeof oauthCallbackTarget !== "string" && oauthCallbackTarget?.type === "hosted"; + } + + protected _currentUrlLooksLikeOAuthCallback(): boolean { + if (typeof window === "undefined") { + return false; + } + const currentUrl = new URL(window.location.href); + return ( + currentUrl.searchParams.has("code") && currentUrl.searchParams.has("state") + ) || ( + currentUrl.searchParams.has("errorCode") && currentUrl.searchParams.has("message") + ); + } + + protected _getOAuthCallbackRedirectUri(options?: { forCallback?: boolean }): string { + if (!this._isOAuthCallbackUrlHosted()) { + return this.urls.oauthCallback; + } + if (typeof window === "undefined") { + throw new StackAssertionError("Hosted OAuth callback URLs require a browser environment to use the current URL as the redirect URI"); + } + + const currentUrl = new URL(window.location.href); + if (options?.forCallback === true) { + currentUrl.searchParams.delete("code"); + currentUrl.searchParams.delete("state"); + } + return currentUrl.toString(); + } + + protected async _getCurrentRefreshTokenIdIfSignedIn(options?: { awaitPendingAuthResolutions?: boolean }): Promise { + const session = await this._getSession(undefined, options); + const tokens = await session.getOrFetchLikelyValidTokens(0, null); + if (tokens?.refreshToken == null) { + return null; + } + return tokens.accessToken.payload.refresh_token_id; + } + + protected async _addNestedCrossDomainAuthParamsToRedirectUrl(options: { + url: string, + currentUrl: URL, + }): Promise { + const targetUrl = new URL(options.url, options.currentUrl); + if (targetUrl.origin === options.currentUrl.origin) { + return options.url; + } + + const refreshTokenId = await this._getCurrentRefreshTokenIdIfSignedIn(); + if (refreshTokenId == null) { + return options.url; + } + + targetUrl.searchParams.set(nestedCrossDomainAuthQueryParams.refreshTokenId, refreshTokenId); + targetUrl.searchParams.set( + nestedCrossDomainAuthQueryParams.callbackUrl, + new URL(this._getOAuthCallbackRedirectUri(), options.currentUrl).toString(), + ); + return targetUrl.toString(); + } + + protected async _maybeHandleNestedCrossDomainAuth(): Promise { + if (typeof window === "undefined") return false; + const currentUrl = new URL(window.location.href); + // A real OAuth callback wins over nested handoff detection on the final return to b.com. + if (currentUrl.searchParams.has("code") && currentUrl.searchParams.has("state")) return false; + const refreshTokenId = currentUrl.searchParams.get(nestedCrossDomainAuthQueryParams.refreshTokenId); + if (refreshTokenId == null) return false; + + const redirectUri = currentUrl.searchParams.get(nestedCrossDomainAuthQueryParams.redirectUri); + const state = currentUrl.searchParams.get(nestedCrossDomainAuthQueryParams.state); + const codeChallenge = currentUrl.searchParams.get(nestedCrossDomainAuthQueryParams.codeChallenge); + if (redirectUri != null || state != null || codeChallenge != null) { + if (redirectUri == null || state == null || codeChallenge == null) { + throw new StackAssertionError("Nested cross-domain auth callback URL is missing OAuth request parameters", { + redirectUri, + state, + codeChallenge, + }); + } + + // We are back on a.com acting as the OAuth provider. Only mint the code if the current + // source session matches the refresh-token ID that b.com requested. + if ((currentUrl.searchParams.get(nestedCrossDomainAuthQueryParams.codeChallengeMethod) ?? "S256") !== "S256") { + throw new StackAssertionError("Nested cross-domain auth only supports S256 PKCE"); + } + const currentRefreshTokenId = await this._getCurrentRefreshTokenIdIfSignedIn({ awaitPendingAuthResolutions: false }); + if (currentRefreshTokenId !== refreshTokenId) { + throw new Error("Nested cross-domain auth source session does not match the requested refresh token ID."); + } + await this._redirectTo({ + url: await this._createCrossDomainAuthRedirectUrl({ + redirectUri, + state, + codeChallenge, + afterCallbackRedirectUrl: currentUrl.searchParams.get(nestedCrossDomainAuthQueryParams.afterCallbackRedirectUrl) ?? redirectUri, + awaitPendingAuthResolutions: false, + }), + replace: true, + }); + return true; + } + + // We are on b.com. Bounce to the trusted callback on a.com with a normal OAuth request + // shape; a.com will verify the source session and issue the one-time code. + const currentRefreshTokenId = await this._getCurrentRefreshTokenIdIfSignedIn({ awaitPendingAuthResolutions: false }); + if (currentRefreshTokenId === refreshTokenId) return false; + const callbackUrlString = currentUrl.searchParams.get(nestedCrossDomainAuthQueryParams.callbackUrl); + if (callbackUrlString == null) throw new StackAssertionError("Nested cross-domain auth URL is missing callback URL"); + const isTrusted = await this._isTrusted(callbackUrlString); + if (!isTrusted) { + throw new Error(`Nested cross-domain auth callback URL ${callbackUrlString} is not trusted.`); + } + + const callbackUrl = new URL(callbackUrlString, currentUrl); + const afterCallbackRedirectUrl = new URL(currentUrl); + afterCallbackRedirectUrl.searchParams.delete(nestedCrossDomainAuthQueryParams.refreshTokenId); + afterCallbackRedirectUrl.searchParams.delete(nestedCrossDomainAuthQueryParams.callbackUrl); + const { state: newState, codeChallenge: newCodeChallenge } = await this._getCrossDomainHandoffParamsForRedirect(currentUrl); + + callbackUrl.searchParams.set(nestedCrossDomainAuthQueryParams.refreshTokenId, refreshTokenId); + callbackUrl.searchParams.set(nestedCrossDomainAuthQueryParams.redirectUri, new URL(this._getOAuthCallbackRedirectUri(), currentUrl).toString()); + callbackUrl.searchParams.set(nestedCrossDomainAuthQueryParams.state, newState); + callbackUrl.searchParams.set(nestedCrossDomainAuthQueryParams.codeChallenge, newCodeChallenge); + callbackUrl.searchParams.set(nestedCrossDomainAuthQueryParams.codeChallengeMethod, "S256"); + callbackUrl.searchParams.set(nestedCrossDomainAuthQueryParams.afterCallbackRedirectUrl, afterCallbackRedirectUrl.toString()); + await this._redirectTo({ url: callbackUrl, replace: true }); + return true; + } + /** * Cloudflare workers does not allow use of randomness on the global scope (on which the Stack app is probably * initialized). For that reason, we generate the unique identifier lazily when it is first needed instead of in the @@ -942,19 +1147,16 @@ export class _StackClientAppImplIncomplete { - const project = Result.orThrow(await this._interface.getClientProject()); - const domains = project.config.domains.map(d => d.domain.trim().replace(/^https?:\/\//, "").split("/")[0]?.toLowerCase()); - const trustedWildcards = domains.filter(d => d.startsWith("**.")); - const parts = currentDomain.split('.'); - for (let i = parts.length - 2; i >= 0; i--) { - const parentDomain = parts.slice(i).join('.'); - if (domains.includes(parentDomain) && trustedWildcards.includes("**." + parentDomain)) { - return parentDomain; - } - } + private async _getTrustedRedirectConfig(): Promise<{ allowLocalhost: boolean, trustedDomains: string[] }> { + const project = Result.orThrow(await this._currentProjectCache.getOrWait([], "write-only")); + return { + allowLocalhost: project.config.allow_localhost, + trustedDomains: project.config.domains.map(d => d.domain), + }; + } - return null; + private async _getTrustedParentDomain(currentDomain: string): Promise { + return getTrustedParentDomain(currentDomain, (await this._getTrustedRedirectConfig()).trustedDomains); } protected _getBrowserCookieTokenStore(): Store { @@ -1183,7 +1385,11 @@ export class _StackClientAppImplIncomplete { + protected async _getSession( + overrideTokenStoreInit?: TokenStoreInit, + options?: { awaitPendingAuthResolutions?: boolean }, + ): Promise { + await this._awaitPendingAuthResolutions(overrideTokenStoreInit, options); const tokenStore = this._getOrCreateTokenStore(await this._createCookieHelper(overrideTokenStoreInit), overrideTokenStoreInit); const session = this._getSessionFromTokenStore(tokenStore); return session; @@ -1191,6 +1397,7 @@ export class _StackClientAppImplIncomplete void) => { return subscribeSessionRefresh({ @@ -1807,7 +2014,7 @@ export class _StackClientAppImplIncomplete { - // TODO: At some point, we should use the project's trusted domains for this instead of just requiring the URL to be relative - // (note that when we do this, that should be on-top of the relativity check, not replacing it) if (isRelative(url)) { return true; } - if (typeof window !== "undefined" && window.location.origin === new URL(url).origin) { + const parsedUrl = new URL(url); + if (typeof window !== "undefined" && window.location.origin === parsedUrl.origin) { + return true; + } + if (isHostedHandlerUrlForProject({ url, projectId: this.projectId })) { return true; } - return isHostedHandlerUrlForProject({ url, projectId: this.projectId }); + const trustedRedirectConfig = await this._getTrustedRedirectConfig(); + return validateRedirectUrl(parsedUrl, { + allowLocalhost: trustedRedirectConfig.allowLocalhost, + trustedDomains: trustedRedirectConfig.trustedDomains, + }); } get urls(): Readonly { @@ -2482,6 +2695,10 @@ export class _StackClientAppImplIncomplete { - const session = await this._getSession(); + const session = await this._getSession(undefined, { awaitPendingAuthResolutions: options.awaitPendingAuthResolutions }); const response = await this._interface.sendClientRequest( "/auth/oauth/cross-domain/authorize", { @@ -2626,7 +2844,10 @@ export class _StackClientAppImplIncomplete { return await this._interface.authorizeOAuth({ provider, - redirectUrl: constructRedirectUrl(this.urls.oauthCallback, "redirectUrl"), + redirectUrl: constructRedirectUrl(this._getOAuthCallbackRedirectUri(), "redirectUrl"), errorRedirectUrl: constructRedirectUrl(this.urls.error, "errorRedirectUrl"), afterCallbackRedirectUrl, type: "authenticate", @@ -3357,12 +3578,18 @@ export class _StackClientAppImplIncomplete { - return await callOAuthCallback(this._interface, oauthCallbackRedirectUri); + return await callOAuthCallback(this._interface, oauthCallbackRedirectUri, options); }); } catch (e) { if (KnownErrors.InvalidTotpCode.isInstance(e)) { @@ -3383,6 +3610,7 @@ export class _StackClientAppImplIncomplete { expect(urls.cliAuthConfirm).toBe("https://project-id.example-stack-hosted.test/handler/cli-auth-confirm"); }); + it("rejects absolute OAuth callback string targets", () => { + expect(() => resolveHandlerUrls({ + projectId: "project-id", + urls: { + oauthCallback: "https://app.example.test/oauth-callback", + }, + })).toThrowError(/OAuth callback URLs must be relative/); + }); + + it("rejects absolute OAuth callback custom targets", () => { + expect(() => resolveHandlerUrls({ + projectId: "project-id", + urls: { + oauthCallback: { type: "custom", url: "https://app.example.test/oauth-callback", version: 0 }, + }, + })).toThrowError(/OAuth callback URLs must be relative/); + }); + it("supports custom CLI auth confirmation targets", () => { const cliAuthConfirmPrompt = getPagePrompt("cliAuthConfirm"); if (cliAuthConfirmPrompt == null) { diff --git a/packages/template/src/lib/stack-app/url-targets.ts b/packages/template/src/lib/stack-app/url-targets.ts index cb43f1ce34..67a98baedd 100644 --- a/packages/template/src/lib/stack-app/url-targets.ts +++ b/packages/template/src/lib/stack-app/url-targets.ts @@ -202,9 +202,24 @@ const resolveUrlTarget = (options: { } }; +const assertOAuthCallbackTargetIsRelative = (target: HandlerUrlTarget): void => { + const url = typeof target === "string" + ? target + : target.type === "custom" + ? target.url + : null; + if (url != null && !isRelativeUrlString(url)) { + throw new StackAssertionError("OAuth callback URLs must be relative.", { + oauthCallbackUrl: url, + hint: "Use a relative URL like '/handler/oauth-callback', or use { type: 'hosted' } to let Stack use the current page for hosted callbacks.", + }); + } +}; + export const resolveHandlerUrls = (options: { urls: HandlerUrlOptions | undefined, projectId: string }): ResolvedHandlerUrls => { const configuredUrls = options.urls; const defaultTarget: HandlerUrlTarget = configuredUrls?.default ?? { type: "handler-component" }; + assertOAuthCallbackTargetIsRelative(configuredUrls?.oauthCallback ?? defaultTarget); let handlerComponentBasePath = "/handler"; if (typeof configuredUrls?.handler === "string") { handlerComponentBasePath = configuredUrls.handler; From eb496558d14635a959429490141b676a5333aafc Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Thu, 21 May 2026 14:06:40 -0700 Subject: [PATCH 2/4] Comments --- apps/e2e/tests/js/cross-domain-auth.test.ts | 79 ++++++++++++++++++ .../stack-shared/src/utils/redirect-urls.tsx | 82 ++++++++++++++++++- .../apps/implementations/client-app-impl.ts | 50 ++++++++--- .../src/lib/stack-app/url-targets.test.ts | 24 +++++- .../template/src/lib/stack-app/url-targets.ts | 9 +- 5 files changed, 226 insertions(+), 18 deletions(-) diff --git a/apps/e2e/tests/js/cross-domain-auth.test.ts b/apps/e2e/tests/js/cross-domain-auth.test.ts index d5ab237584..69e6e2e834 100644 --- a/apps/e2e/tests/js/cross-domain-auth.test.ts +++ b/apps/e2e/tests/js/cross-domain-auth.test.ts @@ -142,6 +142,52 @@ it("returns static app.urls.signOut for hosted flows", async ({ expect }) => { }); }); +it("strips stale OAuth callback params from hosted callback redirect URIs", async ({ expect }) => { + await withHostedDomainSuffix(async () => { + const clientApp = createClientApp("cccccccc-cccc-4ccc-8ccc-cccccccccccc"); + const previousWindow = globalThis.window; + const previousDocument = globalThis.document; + const currentUrl = new URL(`${localRedirectUrl}/callback-page?foo=bar`); + currentUrl.searchParams.set("code", "oauth-code"); + currentUrl.searchParams.set("state", "oauth-state"); + currentUrl.searchParams.set("error", "access_denied"); + currentUrl.searchParams.set("error_description", "Denied"); + currentUrl.searchParams.set("errorCode", "KnownError"); + currentUrl.searchParams.set("message", "Known message"); + currentUrl.searchParams.set("details", "{}"); + + globalThis.document = { cookie: "", createElement: () => ({}) } as any; + globalThis.window = { + location: { + href: currentUrl.toString(), + }, + } as any; + + try { + expect((clientApp as any)._getOAuthCallbackRedirectUri({ forCallback: true })).toBe(`${localRedirectUrl}/callback-page?foo=bar`); + } finally { + globalThis.window = previousWindow; + globalThis.document = previousDocument; + } + }); +}); + +it("keeps rejected pending auth resolutions from leaking into session consumers", async ({ expect }) => { + const clientApp = createClientApp("dddddddd-dddd-4ddd-8ddd-dddddddddddd"); + vi.spyOn(clientApp as any, "_hasPersistentTokenStore").mockReturnValue(true); + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + (clientApp as any)._trackPendingAuthResolution(async () => { + throw new Error("INTENTIONAL_TEST_AUTH_RESOLUTION_FAILURE"); + }); + + await expect((clientApp as any)._awaitPendingAuthResolutions()).resolves.toBeUndefined(); + } finally { + consoleErrorSpy.mockRestore(); + } +}); + it("keeps cross-domain handoff working when top-level params are dropped before after-sign-in", async ({ expect }) => { await withHostedDomainSuffix(async () => { const projectId = "22222222-2222-4222-8222-222222222222"; @@ -427,6 +473,39 @@ it("continues nested cross-domain auth on the source domain", async ({ expect }) }); }); +it("rejects nested cross-domain auth when the source redirect URI is untrusted", async ({ expect }) => { + await withHostedDomainSuffix(async () => { + const projectId = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"; + const clientApp = createClientApp(projectId); + const currentUrl = new URL(`${localRedirectUrl}/nested-provider`); + currentUrl.searchParams.set("stack_nested_cross_domain_auth_refresh_token_id", "source-session"); + currentUrl.searchParams.set("redirect_uri", "https://evil.example.test/handler/account-settings"); + currentUrl.searchParams.set("state", "nested-state"); + currentUrl.searchParams.set("code_challenge", "nested-code-challenge"); + + const previousWindow = globalThis.window; + const previousDocument = globalThis.document; + const createCrossDomainAuthRedirectUrlSpy = vi.spyOn(clientApp as any, "_createCrossDomainAuthRedirectUrl"); + vi.spyOn(clientApp as any, "_isTrusted").mockResolvedValue(false); + + globalThis.document = { cookie: "", createElement: () => ({}) } as any; + globalThis.window = { + location: { + href: currentUrl.toString(), + }, + } as any; + + try { + await expect((clientApp as any)._maybeHandleNestedCrossDomainAuth()).rejects.toThrowError(/not trusted/); + } finally { + globalThis.window = previousWindow; + globalThis.document = previousDocument; + } + + expect(createCrossDomainAuthRedirectUrlSpy).not.toHaveBeenCalled(); + }); +}); + it("rejects nested cross-domain auth when the callback URL is untrusted", async ({ expect }) => { await withHostedDomainSuffix(async () => { const projectId = "99999999-9999-4999-8999-999999999999"; diff --git a/packages/stack-shared/src/utils/redirect-urls.tsx b/packages/stack-shared/src/utils/redirect-urls.tsx index bfe2cdb862..4cfb0e1520 100644 --- a/packages/stack-shared/src/utils/redirect-urls.tsx +++ b/packages/stack-shared/src/utils/redirect-urls.tsx @@ -6,8 +6,9 @@ type TrustedDomainConfig = { trustedDomains: readonly (string | null | undefined)[], }; +const defaultPorts = new Map([['https:', '443'], ['http:', '80']]); + function normalizePort(url: URL): string { - const defaultPorts = new Map([['https:', '443'], ['http:', '80']]); const port = url.port || defaultPorts.get(url.protocol) || ''; return port ? `${url.hostname}:${port}` : url.hostname; } @@ -35,8 +36,20 @@ function parseWildcardUrlPattern(pattern: string): { protocol: string, hostPatte } function hostPatternWithoutPort(hostPattern: string): string { + if (!hostPatternHasExplicitPort(hostPattern)) { + return hostPattern; + } const portSeparatorIndex = hostPattern.lastIndexOf(":"); - return portSeparatorIndex === -1 ? hostPattern : hostPattern.slice(0, portSeparatorIndex); + return hostPattern.slice(0, portSeparatorIndex); +} + +function hostPatternHasExplicitPort(hostPattern: string): boolean { + const portSeparatorIndex = hostPattern.lastIndexOf(":"); + if (portSeparatorIndex === -1) { + return false; + } + const port = hostPattern.slice(portSeparatorIndex + 1); + return port !== "" && [...port].every(char => char >= "0" && char <= "9"); } function matchesTrustedDomain(testUrl: URL, pattern: string): boolean { @@ -58,7 +71,7 @@ function matchesTrustedDomain(testUrl: URL, pattern: string): boolean { return false; } - const hasPortInPattern = parsedPattern.hostPattern.includes(':'); + const hasPortInPattern = hostPatternHasExplicitPort(parsedPattern.hostPattern); return hasPortInPattern ? matchHostnamePattern(parsedPattern.hostPattern, normalizePort(testUrl)) : matchHostnamePattern(parsedPattern.hostPattern, testUrl.hostname) && isDefaultPort(testUrl); @@ -85,8 +98,9 @@ export function validateRedirectUrl( return config.trustedDomains.some(domain => domain != null && matchesTrustedDomain(url, domain)); } -export function getTrustedParentDomain(currentDomain: string, trustedDomains: readonly string[]): string | null { +export function getTrustedParentDomain(currentDomain: string, trustedDomains: readonly (string | null | undefined)[]): string | null { const hostPatterns = trustedDomains + .filter((domain): domain is string => domain != null) .map((domain) => { const url = createUrlIfValid(domain); if (url != null && !domain.includes("*")) { @@ -107,3 +121,63 @@ export function getTrustedParentDomain(currentDomain: string, trustedDomains: re return null; } + +import.meta.vitest?.test("validateRedirectUrl matches exact and wildcard trusted domains", ({ expect }) => { + expect(validateRedirectUrl("https://example.com", { + allowLocalhost: false, + trustedDomains: ["https://example.com"], + })).toBe(true); + expect(validateRedirectUrl("https://api.example.com", { + allowLocalhost: false, + trustedDomains: ["https://*.example.com"], + })).toBe(true); + expect(validateRedirectUrl("https://api.v2.example.com", { + allowLocalhost: false, + trustedDomains: ["https://*.example.com"], + })).toBe(false); +}); + +import.meta.vitest?.test("validateRedirectUrl respects default and explicit ports", ({ expect }) => { + expect(validateRedirectUrl("https://example.com:443/path", { + allowLocalhost: false, + trustedDomains: ["https://example.com"], + })).toBe(true); + expect(validateRedirectUrl("http://api.example.com:3000", { + allowLocalhost: false, + trustedDomains: ["http://*.example.com:3000"], + })).toBe(true); + expect(validateRedirectUrl("http://api.example.com", { + allowLocalhost: false, + trustedDomains: ["http://*.example.com:3000"], + })).toBe(false); +}); + +import.meta.vitest?.test("validateRedirectUrl respects localhost allowance and invalid patterns", ({ expect }) => { + const originalConsoleError = console.error; + console.error = () => {}; + try { + expect(validateRedirectUrl("http://localhost:3000", { + allowLocalhost: true, + trustedDomains: [], + })).toBe(true); + expect(validateRedirectUrl("http://localhost:3000", { + allowLocalhost: false, + trustedDomains: [], + })).toBe(false); + expect(validateRedirectUrl("https://example.com", { + allowLocalhost: false, + trustedDomains: ["not a url"], + })).toBe(false); + } finally { + console.error = originalConsoleError; + } +}); + +import.meta.vitest?.test("getTrustedParentDomain ignores empty entries and strips ports", ({ expect }) => { + expect(getTrustedParentDomain("app.example.com", [ + null, + undefined, + "https://example.com", + "https://**.example.com:443", + ])).toBe("example.com"); +}); diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index 2374a6955e..cdafd038f3 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -35,7 +35,7 @@ import { Store, storeLock } from "@stackframe/stack-shared/dist/utils/stores"; import { deindent, mergeScopeStrings } from "@stackframe/stack-shared/dist/utils/strings"; import type { TurnstileAction } from "@stackframe/stack-shared/dist/utils/turnstile"; import { BotChallengeExecutionFailedError, BotChallengeUserCancelledError, withBotChallengeFlow } from "@stackframe/stack-shared/dist/utils/turnstile-flow"; -import { isRelative } from "@stackframe/stack-shared/dist/utils/urls"; +import { createUrlIfValid, isRelative } from "@stackframe/stack-shared/dist/utils/urls"; import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; import * as tanstackStartServerContext from "@stackframe/tanstack-start/tanstack-start-server-context"; // THIS_LINE_PLATFORM tanstack-start import * as TanStackRouter from "@tanstack/react-router"; // THIS_LINE_PLATFORM tanstack-start @@ -723,7 +723,13 @@ export class _StackClientAppImplIncomplete Promise) { const promise = (async () => { await Promise.resolve(); - await callback(); + try { + await callback(); + } catch (error) { + // Startup auth transitions gate session finality, but malformed nested-auth URLs should + // not make every app-level session consumer fail while the tracker is cleaning up. + captureError("pending-auth-resolution-failed", error); + } })(); this._pendingAuthResolutionPromises.push(promise); runAsynchronously(async () => { @@ -793,8 +799,9 @@ export class _StackClientAppImplIncomplete { urls: { oauthCallback: "https://app.example.test/oauth-callback", }, - })).toThrowError(/OAuth callback URLs must be relative/); + })).toThrowErrorMatchingInlineSnapshot(` + [StackAssertionError: OAuth callback URLs must be relative. + + This is likely an error in Stack. Please make sure you are running the newest version and report it.] + `); }); it("rejects absolute OAuth callback custom targets", () => { @@ -111,7 +115,23 @@ describe("handler URL targets", () => { urls: { oauthCallback: { type: "custom", url: "https://app.example.test/oauth-callback", version: 0 }, }, - })).toThrowError(/OAuth callback URLs must be relative/); + })).toThrowErrorMatchingInlineSnapshot(` + [StackAssertionError: OAuth callback URLs must be relative. + + This is likely an error in Stack. Please make sure you are running the newest version and report it.] + `); + }); + + it("does not inherit an absolute default target for the OAuth callback", () => { + const urls = resolveHandlerUrls({ + projectId: "project-id", + urls: { + default: "https://app.example.test/handler", + }, + }); + + expect(urls.signIn).toBe("https://app.example.test/handler"); + expect(urls.oauthCallback).toBe("/handler/oauth-callback"); }); it("supports custom CLI auth confirmation targets", () => { diff --git a/packages/template/src/lib/stack-app/url-targets.ts b/packages/template/src/lib/stack-app/url-targets.ts index 67a98baedd..1f5045fd0d 100644 --- a/packages/template/src/lib/stack-app/url-targets.ts +++ b/packages/template/src/lib/stack-app/url-targets.ts @@ -219,7 +219,12 @@ const assertOAuthCallbackTargetIsRelative = (target: HandlerUrlTarget): void => export const resolveHandlerUrls = (options: { urls: HandlerUrlOptions | undefined, projectId: string }): ResolvedHandlerUrls => { const configuredUrls = options.urls; const defaultTarget: HandlerUrlTarget = configuredUrls?.default ?? { type: "handler-component" }; - assertOAuthCallbackTargetIsRelative(configuredUrls?.oauthCallback ?? defaultTarget); + const oauthCallbackTarget: HandlerUrlTarget = configuredUrls?.oauthCallback ?? ( + typeof defaultTarget !== "string" && defaultTarget.type === "hosted" + ? defaultTarget + : { type: "handler-component" } + ); + assertOAuthCallbackTargetIsRelative(oauthCallbackTarget); let handlerComponentBasePath = "/handler"; if (typeof configuredUrls?.handler === "string") { handlerComponentBasePath = configuredUrls.handler; @@ -301,7 +306,7 @@ export const resolveHandlerUrls = (options: { urls: HandlerUrlOptions | undefine }), home, oauthCallback: resolveUrlTarget({ - target: configuredUrls?.oauthCallback ?? defaultTarget, + target: oauthCallbackTarget, fallbackPath: joinHandlerComponentPath(handlerComponentBasePath, "oauth-callback"), handlerName: "oauthCallback", projectId: options.projectId, From 237c3076f3aa7fa3be55e65efe10061d6503770d Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Thu, 21 May 2026 14:20:02 -0700 Subject: [PATCH 3/4] More --- apps/e2e/tests/js/cross-domain-auth.test.ts | 4 ++-- .../apps/implementations/client-app-impl.ts | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/e2e/tests/js/cross-domain-auth.test.ts b/apps/e2e/tests/js/cross-domain-auth.test.ts index 69e6e2e834..96478e42ab 100644 --- a/apps/e2e/tests/js/cross-domain-auth.test.ts +++ b/apps/e2e/tests/js/cross-domain-auth.test.ts @@ -142,7 +142,7 @@ it("returns static app.urls.signOut for hosted flows", async ({ expect }) => { }); }); -it("strips stale OAuth callback params from hosted callback redirect URIs", async ({ expect }) => { +it("strips stale OAuth callback params from hosted current-page redirect URIs", async ({ expect }) => { await withHostedDomainSuffix(async () => { const clientApp = createClientApp("cccccccc-cccc-4ccc-8ccc-cccccccccccc"); const previousWindow = globalThis.window; @@ -164,7 +164,7 @@ it("strips stale OAuth callback params from hosted callback redirect URIs", asyn } as any; try { - expect((clientApp as any)._getOAuthCallbackRedirectUri({ forCallback: true })).toBe(`${localRedirectUrl}/callback-page?foo=bar`); + expect((clientApp as any)._getOAuthCallbackRedirectUri()).toBe(`${localRedirectUrl}/callback-page?foo=bar`); } finally { globalThis.window = previousWindow; globalThis.document = previousDocument; diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index cdafd038f3..809ee111cc 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -96,6 +96,8 @@ const nestedCrossDomainAuthQueryParams = { afterCallbackRedirectUrl: "after_callback_redirect_url", } as const; +const oauthCallbackResponseQueryParams = ["code", "state", "error", "error_description", "errorCode", "message", "details"] as const; + const allClientApps = new Map]>(); const STACK_AUTHORIZATION_VALUE_PREFIX = "stackauth_"; @@ -789,7 +791,7 @@ export class _StackClientAppImplIncomplete Date: Thu, 21 May 2026 16:57:09 -0700 Subject: [PATCH 4/4] Fix more comments --- .claude/CLAUDE-KNOWLEDGE.md | 3 + apps/e2e/tests/js/cross-domain-auth.test.ts | 106 ++++++++++++++++++ .../stack-shared/src/utils/redirect-urls.tsx | 8 +- .../apps/implementations/client-app-impl.ts | 42 +++++-- 4 files changed, 148 insertions(+), 11 deletions(-) diff --git a/.claude/CLAUDE-KNOWLEDGE.md b/.claude/CLAUDE-KNOWLEDGE.md index 18a094f01e..87236e2fc3 100644 --- a/.claude/CLAUDE-KNOWLEDGE.md +++ b/.claude/CLAUDE-KNOWLEDGE.md @@ -505,3 +505,6 @@ A: React error #185 is a maximum update depth error. In the dashboard root, `use ## Q: How should client-side OAuth callback and nested cross-domain auth avoid racing session consumers? A: Track startup auth transitions as pending client-app promises and make `_getSession`/react-like `_useSession` wait for them when using the default persistent token store. Auth-transition code that needs to inspect the current session should explicitly call `_getSession(..., { awaitPendingAuthResolutions: false })` instead of relying on a global reentrancy flag. + +## Q: When should hosted OAuth callback handling auto-start on a client app page? +A: Only auto-start hosted OAuth callback handling when the current URL has `code` and `state` and the matching `stack-oauth-outer-${state}` verifier cookie exists. Generic `code/state` or `errorCode/message` query parameters are not Stack-owned enough to run callback processing automatically on every hosted app page. diff --git a/apps/e2e/tests/js/cross-domain-auth.test.ts b/apps/e2e/tests/js/cross-domain-auth.test.ts index 96478e42ab..40410c468d 100644 --- a/apps/e2e/tests/js/cross-domain-auth.test.ts +++ b/apps/e2e/tests/js/cross-domain-auth.test.ts @@ -172,6 +172,30 @@ it("strips stale OAuth callback params from hosted current-page redirect URIs", }); }); +it("only treats hosted OAuth callback URLs as Stack callbacks when the matching state cookie exists", async ({ expect }) => { + await withHostedDomainSuffix(async () => { + const clientApp = createClientApp("ffffffff-ffff-4fff-8fff-ffffffffffff"); + const previousWindow = globalThis.window; + const previousDocument = globalThis.document; + + globalThis.document = { cookie: "", createElement: () => ({}) } as any; + globalThis.window = { + location: { + href: `${localRedirectUrl}/callback-page?code=oauth-code&state=oauth-state`, + }, + } as any; + + try { + expect((clientApp as any)._currentUrlLooksLikeStackOAuthCallback()).toBe(false); + globalThis.document.cookie = "stack-oauth-outer-oauth-state=verifier"; + expect((clientApp as any)._currentUrlLooksLikeStackOAuthCallback()).toBe(true); + } finally { + globalThis.window = previousWindow; + globalThis.document = previousDocument; + } + }); +}); + it("keeps rejected pending auth resolutions from leaking into session consumers", async ({ expect }) => { const clientApp = createClientApp("dddddddd-dddd-4ddd-8ddd-dddddddddddd"); vi.spyOn(clientApp as any, "_hasPersistentTokenStore").mockReturnValue(true); @@ -188,6 +212,88 @@ it("keeps rejected pending auth resolutions from leaking into session consumers" } }); +it("does not await pending auth resolutions when post-callback redirect mints a cross-domain code", async ({ expect }) => { + await withHostedDomainSuffix(async () => { + const projectId = "12121212-1212-4212-8212-121212121212"; + const clientApp = createClientApp(projectId); + const currentUrl = new URL(`${localRedirectUrl}/callback-page`); + const redirectBackUrl = new URL(`${localRedirectUrl}/handler/oauth-callback`); + redirectBackUrl.searchParams.set("stack_cross_domain_auth", "1"); + redirectBackUrl.searchParams.set("stack_cross_domain_state", "state"); + redirectBackUrl.searchParams.set("stack_cross_domain_code_challenge", "challenge"); + redirectBackUrl.searchParams.set("stack_cross_domain_after_callback_redirect_url", `${localRedirectUrl}/after`); + currentUrl.searchParams.set("after_auth_return_to", redirectBackUrl.toString()); + + const previousWindow = globalThis.window; + const previousDocument = globalThis.document; + const createCrossDomainAuthRedirectUrlSpy = vi + .spyOn(clientApp as any, "_createCrossDomainAuthRedirectUrl") + .mockResolvedValue(`https://${projectId}.example-stack-hosted.test/handler/final`); + + globalThis.document = { cookie: "", createElement: () => ({}) } as any; + globalThis.window = { + location: { + href: currentUrl.toString(), + replace: () => { + throw new Error("INTENTIONAL_TEST_ABORT"); + }, + }, + } as any; + + try { + await expect((clientApp as any)._redirectToHandler( + "afterSignIn", + { replace: true }, + { awaitPendingAuthResolutions: false }, + )).rejects.toThrowError("INTENTIONAL_TEST_ABORT"); + } finally { + globalThis.window = previousWindow; + globalThis.document = previousDocument; + } + + expect(createCrossDomainAuthRedirectUrlSpy).toHaveBeenCalledWith(expect.objectContaining({ + awaitPendingAuthResolutions: false, + })); + }); +}); + +it("does not await pending auth resolutions when post-callback redirect adds nested auth params", async ({ expect }) => { + await withHostedDomainSuffix(async () => { + const projectId = "13131313-1313-4313-8313-131313131313"; + const clientApp = createClientApp(projectId); + const getCurrentRefreshTokenIdIfSignedInSpy = vi + .spyOn(clientApp as any, "_getCurrentRefreshTokenIdIfSignedIn") + .mockResolvedValue(null); + + const previousWindow = globalThis.window; + const previousDocument = globalThis.document; + globalThis.document = { cookie: "", createElement: () => ({}) } as any; + globalThis.window = { + location: { + href: `${localRedirectUrl}/callback-page`, + replace: () => { + throw new Error("INTENTIONAL_TEST_ABORT"); + }, + }, + } as any; + + try { + await expect((clientApp as any)._redirectToHandler( + "afterSignIn", + { replace: true }, + { awaitPendingAuthResolutions: false }, + )).rejects.toThrowError("INTENTIONAL_TEST_ABORT"); + } finally { + globalThis.window = previousWindow; + globalThis.document = previousDocument; + } + + expect(getCurrentRefreshTokenIdIfSignedInSpy).toHaveBeenCalledWith({ + awaitPendingAuthResolutions: false, + }); + }); +}); + it("keeps cross-domain handoff working when top-level params are dropped before after-sign-in", async ({ expect }) => { await withHostedDomainSuffix(async () => { const projectId = "22222222-2222-4222-8222-222222222222"; diff --git a/packages/stack-shared/src/utils/redirect-urls.tsx b/packages/stack-shared/src/utils/redirect-urls.tsx index 4cfb0e1520..49c556a09f 100644 --- a/packages/stack-shared/src/utils/redirect-urls.tsx +++ b/packages/stack-shared/src/utils/redirect-urls.tsx @@ -49,7 +49,7 @@ function hostPatternHasExplicitPort(hostPattern: string): boolean { return false; } const port = hostPattern.slice(portSeparatorIndex + 1); - return port !== "" && [...port].every(char => char >= "0" && char <= "9"); + return port === "*" || (port !== "" && [...port].every(char => char >= "0" && char <= "9")); } function matchesTrustedDomain(testUrl: URL, pattern: string): boolean { @@ -150,6 +150,10 @@ import.meta.vitest?.test("validateRedirectUrl respects default and explicit port allowLocalhost: false, trustedDomains: ["http://*.example.com:3000"], })).toBe(false); + expect(validateRedirectUrl("http://api.example.com:1234", { + allowLocalhost: false, + trustedDomains: ["http://*.example.com:*"], + })).toBe(true); }); import.meta.vitest?.test("validateRedirectUrl respects localhost allowance and invalid patterns", ({ expect }) => { @@ -178,6 +182,6 @@ import.meta.vitest?.test("getTrustedParentDomain ignores empty entries and strip null, undefined, "https://example.com", - "https://**.example.com:443", + "https://**.example.com:*", ])).toBe("example.com"); }); diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index 809ee111cc..922f6f80e0 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -45,7 +45,7 @@ import React, { useCallback, useMemo } from "react"; // THIS_LINE_PLATFORM react import type * as yup from "yup"; import { constructRedirectUrl } from "../../../../utils/url"; import { callOAuthCallback, getNewOAuthProviderOrScopeUrl } from "../../../auth"; -import { CookieHelper, createBrowserCookieHelper, createCookieHelper, createPlaceholderCookieHelper, deleteCookie, deleteCookieClient, isSecure as isSecureCookieContext, saveVerifierAndState, setOrDeleteCookie, setOrDeleteCookieClient } from "../../../cookie"; +import { CookieHelper, createBrowserCookieHelper, createCookieHelper, createPlaceholderCookieHelper, deleteCookie, deleteCookieClient, getCookieClient, isSecure as isSecureCookieContext, saveVerifierAndState, setOrDeleteCookie, setOrDeleteCookieClient } from "../../../cookie"; import { envVars } from "../../../env"; import { ApiKey, ApiKeyCreationOptions, ApiKeyUpdateOptions, apiKeyCreationOptionsToCrud } from "../../api-keys"; import { ConvexCtx, GetCurrentPartialUserOptions, GetCurrentUserOptions, HandlerUrlOptions, HandlerUrls, OAuthScopesOnSignIn, RedirectMethod, RedirectToOptions, RequestLike, ResolvedHandlerUrls, TokenStoreInit, stackAppInternalsSymbol } from "../../common"; @@ -691,7 +691,7 @@ export class _StackClientAppImplIncomplete { if (isBrowserLike()) { await this.callOAuthCallback({ dontWarnAboutMissingQueryParams: true }); @@ -791,6 +791,18 @@ export class _StackClientAppImplIncomplete { const targetUrl = new URL(options.url, options.currentUrl); if (targetUrl.origin === options.currentUrl.origin) { return options.url; } - const refreshTokenId = await this._getCurrentRefreshTokenIdIfSignedIn(); + const refreshTokenId = await this._getCurrentRefreshTokenIdIfSignedIn({ + awaitPendingAuthResolutions: options.awaitPendingAuthResolutions, + }); if (refreshTokenId == null) { return options.url; } @@ -2841,7 +2856,11 @@ export class _StackClientAppImplIncomplete