diff --git a/apps/backend/src/lib/redirect-urls.test.tsx b/apps/backend/src/lib/redirect-urls.test.tsx index 5d60be1ae8..f2e327d4b3 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,74 @@ 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 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); + + // 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); + + // 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); + + 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..3b83a78901 100644 --- a/apps/backend/src/lib/redirect-urls.tsx +++ b/apps/backend/src/lib/redirect-urls.tsx @@ -1,14 +1,29 @@ 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), + `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"); }