From d762041c9f3cb9a05e40209144762ae7949024e4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 01:00:53 +0000 Subject: [PATCH 1/2] feat: automatically trust project's hosted handler domain (built-with domain) The .built-with-stack-auth.com domain (or whatever the configured hosted handler domain suffix is) is now automatically accepted as a trusted domain for each project. Changes: - redirect-urls.tsx: Include hosted handler domain in trusted domains for redirect URL validation (covers OAuth callbacks, passkeys, etc.) - turnstile.tsx: Accept hosted handler hostname for Turnstile validation - oauth/model.tsx: Include hosted handler in OAuth client redirect URIs Co-Authored-By: Konstantin Wohlwend --- apps/backend/src/lib/redirect-urls.test.tsx | 72 ++++++++++++++++++++- apps/backend/src/lib/redirect-urls.tsx | 18 +++++- apps/backend/src/lib/turnstile.tsx | 8 +++ apps/backend/src/oauth/model.tsx | 6 +- 4 files changed, 100 insertions(+), 4 deletions(-) diff --git a/apps/backend/src/lib/redirect-urls.test.tsx b/apps/backend/src/lib/redirect-urls.test.tsx index 5d60be1ae8..0e2faa71d4 100644 --- a/apps/backend/src/lib/redirect-urls.test.tsx +++ b/apps/backend/src/lib/redirect-urls.test.tsx @@ -1,10 +1,11 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { isAcceptedNativeAppUrl, validateRedirectUrl } from './redirect-urls'; import { Tenancy } from './tenancies'; describe('validateRedirectUrl', () => { - const createMockTenancy = (config: Partial): Tenancy => { + const createMockTenancy = (config: Partial, projectId: string = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'): Tenancy => { return { + project: { id: projectId }, config: { domains: { allowLocalhost: false, @@ -474,6 +475,73 @@ describe('validateRedirectUrl', () => { }); }); + describe('hosted handler domain (built-with domain)', () => { + it('should trust the project hosted handler domain with default suffix', () => { + vi.stubEnv('NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX', '.built-with-stack-auth.com'); + const projectId = '12345678-1234-1234-1234-123456789012'; + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: {}, + }, + }, projectId); + + // HTTPS on the built-with domain should be trusted + expect(validateRedirectUrl(`https://${projectId}.built-with-stack-auth.com/callback`, tenancy)).toBe(true); + expect(validateRedirectUrl(`https://${projectId}.built-with-stack-auth.com/`, tenancy)).toBe(true); + expect(validateRedirectUrl(`https://${projectId}.built-with-stack-auth.com/handler/oauth-callback`, tenancy)).toBe(true); + + // HTTP on the built-with domain should also be trusted + expect(validateRedirectUrl(`http://${projectId}.built-with-stack-auth.com/callback`, tenancy)).toBe(true); + + // Different project IDs should NOT be trusted + expect(validateRedirectUrl('https://other-project.built-with-stack-auth.com/callback', tenancy)).toBe(false); + + // Unrelated domains should NOT be trusted + expect(validateRedirectUrl('https://example.com/callback', tenancy)).toBe(false); + + vi.unstubAllEnvs(); + }); + + it('should trust the hosted handler domain with a custom suffix (e.g. local dev)', () => { + vi.stubEnv('NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX', '.localhost:8109'); + const projectId = '12345678-1234-1234-1234-123456789012'; + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: {}, + }, + }, projectId); + + expect(validateRedirectUrl(`http://${projectId}.localhost:8109/callback`, tenancy)).toBe(true); + expect(validateRedirectUrl(`https://${projectId}.localhost:8109/callback`, tenancy)).toBe(true); + + // Wrong port should NOT be trusted + expect(validateRedirectUrl(`http://${projectId}.localhost:9999/callback`, tenancy)).toBe(false); + + vi.unstubAllEnvs(); + }); + + it('should trust the hosted handler domain even when other trusted domains exist', () => { + vi.stubEnv('NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX', '.built-with-stack-auth.com'); + const projectId = '12345678-1234-1234-1234-123456789012'; + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: { + '1': { baseUrl: 'https://myapp.com', handlerPath: '/handler' }, + }, + }, + }, projectId); + + // Both the configured trusted domain and the hosted handler domain should work + expect(validateRedirectUrl('https://myapp.com/callback', tenancy)).toBe(true); + expect(validateRedirectUrl(`https://${projectId}.built-with-stack-auth.com/callback`, tenancy)).toBe(true); + + vi.unstubAllEnvs(); + }); + }); + describe('native app SDK URLs', () => { it('should not accept native app URLs in validateRedirectUrl (handled separately in OAuth model)', () => { const tenancy = createMockTenancy({ diff --git a/apps/backend/src/lib/redirect-urls.tsx b/apps/backend/src/lib/redirect-urls.tsx index f24532e913..972cfd6fcb 100644 --- a/apps/backend/src/lib/redirect-urls.tsx +++ b/apps/backend/src/lib/redirect-urls.tsx @@ -1,14 +1,30 @@ import { isAcceptedNativeAppUrl, validateRedirectUrl as validateRedirectUrlAgainstTrustedDomains } from "@stackframe/stack-shared/dist/utils/redirect-urls"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { Tenancy } from "./tenancies"; export { isAcceptedNativeAppUrl }; +const defaultHostedHandlerDomainSuffix = ".built-with-stack-auth.com"; + +/** + * Returns the domain suffix for the hosted handler (e.g. ".built-with-stack-auth.com" in + * production, ".localhost:8109" in local dev). + */ +export function getHostedHandlerDomainSuffix(): string { + return getEnvVariable("NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX", defaultHostedHandlerDomainSuffix); +} + export function validateRedirectUrl( urlOrString: string | URL, tenancy: Tenancy, ): boolean { + const hostedDomain = `${tenancy.project.id}${getHostedHandlerDomainSuffix()}`; return validateRedirectUrlAgainstTrustedDomains(urlOrString, { allowLocalhost: tenancy.config.domains.allowLocalhost, - trustedDomains: Object.values(tenancy.config.domains.trustedDomains).map(domain => domain.baseUrl), + trustedDomains: [ + ...Object.values(tenancy.config.domains.trustedDomains).map(domain => domain.baseUrl), + `http://${hostedDomain}`, + `https://${hostedDomain}`, + ], }); } diff --git a/apps/backend/src/lib/turnstile.tsx b/apps/backend/src/lib/turnstile.tsx index 8dbe37b3bd..f0318b3c54 100644 --- a/apps/backend/src/lib/turnstile.tsx +++ b/apps/backend/src/lib/turnstile.tsx @@ -12,6 +12,7 @@ import { } from "@stackframe/stack-shared/dist/utils/turnstile"; import { createUrlIfValid, isLocalhost, matchHostnamePattern } from "@stackframe/stack-shared/dist/utils/urls"; import { BestEffortEndUserRequestContext, getBestEffortEndUserRequestContext } from "./end-users"; +import { getHostedHandlerDomainSuffix } from "./redirect-urls"; import { Tenancy } from "./tenancies"; @@ -50,6 +51,13 @@ function isAllowedTurnstileHostname(hostname: string, tenancy: Tenancy): boolean if (tenancy.config.domains.allowLocalhost && isLocalhost(`http://${hostname}`)) { return true; } + + // The project's hosted handler domain (e.g. .built-with-stack-auth.com) is always trusted + const hostedHandlerUrl = createUrlIfValid(`https://${tenancy.project.id}${getHostedHandlerDomainSuffix()}`); + if (hostedHandlerUrl != null && hostedHandlerUrl.hostname === hostname) { + return true; + } + return Object.values(tenancy.config.domains.trustedDomains).some(({ baseUrl }) => { if (baseUrl == null) return false; const pattern = createUrlIfValid(baseUrl)?.hostname diff --git a/apps/backend/src/oauth/model.tsx b/apps/backend/src/oauth/model.tsx index ab021bd6d8..c7bd2854f3 100644 --- a/apps/backend/src/oauth/model.tsx +++ b/apps/backend/src/oauth/model.tsx @@ -3,7 +3,7 @@ import { usersCrudHandlers } from "@/app/api/latest/users/crud"; import { Prisma } from "@/generated/prisma/client"; import { withExternalDbSyncUpdate } from "@/lib/external-db-sync"; import { checkApiKeySet } from "@/lib/internal-api-keys"; -import { isAcceptedNativeAppUrl, validateRedirectUrl } from "@/lib/redirect-urls"; +import { getHostedHandlerDomainSuffix, isAcceptedNativeAppUrl, validateRedirectUrl } from "@/lib/redirect-urls"; import { getSoleTenancyFromProjectBranch, getTenancy } from "@/lib/tenancies"; import { createRefreshTokenObj, decodeAccessToken, generateAccessTokenFromRefreshTokenIfValid, isRefreshTokenValid } from "@/lib/tokens"; import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; @@ -76,6 +76,10 @@ export class OAuthModel implements AuthorizationCodeModel { throw e; } + // The project's hosted handler domain is always trusted + const hostedDomain = `${tenancy.project.id}${getHostedHandlerDomainSuffix()}`; + redirectUris.push(new URL("/handler", `https://${hostedDomain}`).toString()); + if (redirectUris.length === 0 && tenancy.config.domains.allowLocalhost) { redirectUris.push("http://localhost"); } From cfb9af807aeeaece21d5c31270c85245f5ab1aea Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 01:05:43 +0000 Subject: [PATCH 2/2] fix: only trust https:// for built-with domain, not http:// Co-Authored-By: Konstantin Wohlwend --- apps/backend/src/lib/redirect-urls.test.tsx | 7 ++++--- apps/backend/src/lib/redirect-urls.tsx | 1 - 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/backend/src/lib/redirect-urls.test.tsx b/apps/backend/src/lib/redirect-urls.test.tsx index 0e2faa71d4..f2e327d4b3 100644 --- a/apps/backend/src/lib/redirect-urls.test.tsx +++ b/apps/backend/src/lib/redirect-urls.test.tsx @@ -491,8 +491,8 @@ describe('validateRedirectUrl', () => { expect(validateRedirectUrl(`https://${projectId}.built-with-stack-auth.com/`, tenancy)).toBe(true); expect(validateRedirectUrl(`https://${projectId}.built-with-stack-auth.com/handler/oauth-callback`, tenancy)).toBe(true); - // HTTP on the built-with domain should also be trusted - expect(validateRedirectUrl(`http://${projectId}.built-with-stack-auth.com/callback`, tenancy)).toBe(true); + // HTTP on the built-with domain should NOT be trusted + expect(validateRedirectUrl(`http://${projectId}.built-with-stack-auth.com/callback`, tenancy)).toBe(false); // Different project IDs should NOT be trusted expect(validateRedirectUrl('https://other-project.built-with-stack-auth.com/callback', tenancy)).toBe(false); @@ -513,8 +513,9 @@ describe('validateRedirectUrl', () => { }, }, projectId); - expect(validateRedirectUrl(`http://${projectId}.localhost:8109/callback`, tenancy)).toBe(true); + // Only HTTPS should be trusted, even for localhost-based dev suffix expect(validateRedirectUrl(`https://${projectId}.localhost:8109/callback`, tenancy)).toBe(true); + expect(validateRedirectUrl(`http://${projectId}.localhost:8109/callback`, tenancy)).toBe(false); // Wrong port should NOT be trusted expect(validateRedirectUrl(`http://${projectId}.localhost:9999/callback`, tenancy)).toBe(false); diff --git a/apps/backend/src/lib/redirect-urls.tsx b/apps/backend/src/lib/redirect-urls.tsx index 972cfd6fcb..3b83a78901 100644 --- a/apps/backend/src/lib/redirect-urls.tsx +++ b/apps/backend/src/lib/redirect-urls.tsx @@ -23,7 +23,6 @@ export function validateRedirectUrl( allowLocalhost: tenancy.config.domains.allowLocalhost, trustedDomains: [ ...Object.values(tenancy.config.domains.trustedDomains).map(domain => domain.baseUrl), - `http://${hostedDomain}`, `https://${hostedDomain}`, ], });