diff --git a/.claude/CLAUDE-KNOWLEDGE.md b/.claude/CLAUDE-KNOWLEDGE.md index 3eee9c435..4ae98fc1e 100644 --- a/.claude/CLAUDE-KNOWLEDGE.md +++ b/.claude/CLAUDE-KNOWLEDGE.md @@ -518,5 +518,8 @@ A: Track startup auth transitions as pending client-app promises and make `_getS ## 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. +## Q: Should built-with hosted handler domains be manually configured as trusted domains? +A: No. Treat the hosted handler origin for the project, such as `https://.built-with-stack-auth.com` or the origin derived from `NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE`, as an implicit trusted redirect domain on both client and backend validation paths. The hosted template must put `{projectId}` in the hostname so every project has its own origin; path-based templates like `https://host/{projectId}/{hostedPath}` are not safe for implicit origin trust. + ## Q: How should the npm publish workflow create the post-publish dev version bump? A: The workflow needs a full checkout using the fine-grained `NPM_PUBLISH_VERSION_UPDATE_PR_PAT` secret. It then fetches `origin/dev`, checks out `dev`, creates a non-interactive patch changeset, runs `pnpm changeset version`, copies the generated `packages/template/package.json` version line back into `packages/template/package-template.json`, and commit/pushes `chore: update package versions`. Because direct pushes to `dev` are blocked by repository rules requiring PRs and the `all-good` status check, the PAT's owning user or bot account must be added to the ruleset bypass list with "Always allow" rather than "For pull requests only". diff --git a/apps/backend/src/lib/redirect-urls.test.tsx b/apps/backend/src/lib/redirect-urls.test.tsx index 5d60be1ae..a7c131693 100644 --- a/apps/backend/src/lib/redirect-urls.test.tsx +++ b/apps/backend/src/lib/redirect-urls.test.tsx @@ -1,8 +1,48 @@ import { describe, expect, it } from 'vitest'; -import { isAcceptedNativeAppUrl, validateRedirectUrl } from './redirect-urls'; +import { getOAuthRedirectUrisForTenancy, validateRedirectHostname, isAcceptedNativeAppUrl, validateRedirectUrl } from './redirect-urls'; import { Tenancy } from './tenancies'; describe('validateRedirectUrl', () => { + const withHostedHandlerEnv = ( + values: { + hostedHandlerUrlTemplate?: string, + hostedHandlerDomainSuffix?: string, + stackPortPrefix?: string, + }, + callback: () => T, + ): T => { + const processEnv = Reflect.get(process, "env"); + const oldHostedHandlerUrlTemplate = Reflect.get(processEnv, "NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE"); + const oldHostedHandlerDomainSuffix = Reflect.get(processEnv, "NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX"); + const oldStackPortPrefix = Reflect.get(processEnv, "NEXT_PUBLIC_STACK_PORT_PREFIX"); + try { + for (const [key, value] of Object.entries({ + NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE: values.hostedHandlerUrlTemplate, + NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX: values.hostedHandlerDomainSuffix, + NEXT_PUBLIC_STACK_PORT_PREFIX: values.stackPortPrefix, + })) { + if (value == null) { + Reflect.deleteProperty(processEnv, key); + } else { + Reflect.set(processEnv, key, value); + } + } + return callback(); + } finally { + for (const [key, value] of Object.entries({ + NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE: oldHostedHandlerUrlTemplate, + NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX: oldHostedHandlerDomainSuffix, + NEXT_PUBLIC_STACK_PORT_PREFIX: oldStackPortPrefix, + })) { + if (value == null) { + Reflect.deleteProperty(processEnv, key); + } else { + Reflect.set(processEnv, key, value); + } + } + } + }; + const createMockTenancy = (config: Partial): Tenancy => { return { config: { @@ -13,10 +53,87 @@ describe('validateRedirectUrl', () => { }, ...config, }, + project: { + id: "12345678-1234-4234-8234-123456789abc", + }, } as Tenancy; }; describe('exact domain matching', () => { + it('should implicitly validate hosted handler domains for the project', () => { + withHostedHandlerEnv({ + hostedHandlerUrlTemplate: "http://{projectId}.localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}09/{hostedPath}", + stackPortPrefix: "92", + }, () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: {}, + }, + }); + + expect(validateRedirectUrl('http://12345678-1234-4234-8234-123456789abc.localhost:9209/anything', tenancy)).toBe(true); + expect(validateRedirectUrl('http://other-project.localhost:9209/anything', tenancy)).toBe(false); + }); + }); + + it('should reject hosted handler URL templates that put the project ID in the path', () => { + withHostedHandlerEnv({ + hostedHandlerUrlTemplate: "http://localhost:9209/{projectId}/{hostedPath}", + }, () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: {}, + }, + }); + + expect(() => validateRedirectUrl('http://localhost:9209/anything', tenancy)) + .toThrowErrorMatchingInlineSnapshot(` + [StackAssertionError: The hosted handler URL template must put {projectId} in the hostname. + + This is likely an error in Stack. Please make sure you are running the newest version and report it.] + `); + }); + }); + + it('should include the implicit hosted callback in OAuth redirect URIs', () => { + withHostedHandlerEnv({}, () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: { + '1': { baseUrl: 'https://example.com', handlerPath: '/handler' }, + }, + }, + }); + + expect(getOAuthRedirectUrisForTenancy(tenancy)).toMatchInlineSnapshot(` + [ + "https://example.com/handler", + "https://12345678-1234-4234-8234-123456789abc.built-with-stack-auth.com/handler/oauth-callback", + ] + `); + }); + }); + + it('should validate Turnstile hostnames through the same trusted domains', () => { + withHostedHandlerEnv({}, () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: { + '1': { baseUrl: 'https://*.example.com', handlerPath: '/handler' }, + }, + }, + }); + + expect(validateRedirectHostname('app.example.com', tenancy)).toBe(true); + expect(validateRedirectHostname('12345678-1234-4234-8234-123456789abc.built-with-stack-auth.com', tenancy)).toBe(true); + expect(validateRedirectHostname('evil.example.test', tenancy)).toBe(false); + }); + }); + it('should validate exact domain matches', () => { const tenancy = createMockTenancy({ domains: { diff --git a/apps/backend/src/lib/redirect-urls.tsx b/apps/backend/src/lib/redirect-urls.tsx index f24532e91..56a22c28e 100644 --- a/apps/backend/src/lib/redirect-urls.tsx +++ b/apps/backend/src/lib/redirect-urls.tsx @@ -1,14 +1,52 @@ -import { isAcceptedNativeAppUrl, validateRedirectUrl as validateRedirectUrlAgainstTrustedDomains } from "@stackframe/stack-shared/dist/utils/redirect-urls"; +import { getEnvVariable, getProcessEnv } from "@stackframe/stack-shared/dist/utils/env"; +import { getHostedHandlerTrustedDomain as getHostedHandlerTrustedDomainFromConfig, isAcceptedNativeAppUrl, validateRedirectUrl as validateRedirectUrlAgainstTrustedDomains } from "@stackframe/stack-shared/dist/utils/redirect-urls"; import { Tenancy } from "./tenancies"; export { isAcceptedNativeAppUrl }; +export function getHostedHandlerTrustedDomain(projectId: string): string { + return getHostedHandlerTrustedDomainFromConfig({ + projectId, + hostedHandlerDomainSuffix: getProcessEnv("NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX"), + hostedHandlerUrlTemplate: getProcessEnv("NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE"), + stackPortPrefix: getEnvVariable("NEXT_PUBLIC_STACK_PORT_PREFIX", "81"), + }); +} + +export function getTrustedDomainsForTenancy(tenancy: Tenancy): string[] { + return [ + ...Object.values(tenancy.config.domains.trustedDomains) + .map(domain => domain.baseUrl) + .filter((baseUrl): baseUrl is string => baseUrl != null), + getHostedHandlerTrustedDomain(tenancy.project.id), + ]; +} + +export function getOAuthRedirectUrisForTenancy(tenancy: Tenancy): string[] { + return [ + ...Object.values(tenancy.config.domains.trustedDomains) + .filter((domain) => domain.baseUrl) + .map((domain) => new URL(domain.handlerPath, domain.baseUrl).toString()), + new URL("/handler/oauth-callback", getHostedHandlerTrustedDomain(tenancy.project.id)).toString(), + ]; +} + export function validateRedirectUrl( urlOrString: string | URL, tenancy: Tenancy, ): boolean { return validateRedirectUrlAgainstTrustedDomains(urlOrString, { allowLocalhost: tenancy.config.domains.allowLocalhost, - trustedDomains: Object.values(tenancy.config.domains.trustedDomains).map(domain => domain.baseUrl), + trustedDomains: getTrustedDomainsForTenancy(tenancy), + }); +} + +export function validateRedirectHostname(hostname: string, tenancy: Tenancy): boolean { + return validateRedirectUrlAgainstTrustedDomains(`https://${hostname}`, { + allowLocalhost: tenancy.config.domains.allowLocalhost, + trustedDomains: getTrustedDomainsForTenancy(tenancy), + }) || validateRedirectUrlAgainstTrustedDomains(`http://${hostname}`, { + allowLocalhost: tenancy.config.domains.allowLocalhost, + trustedDomains: getTrustedDomainsForTenancy(tenancy), }); } diff --git a/apps/backend/src/lib/turnstile.tsx b/apps/backend/src/lib/turnstile.tsx index 8dbe37b3b..d68ca9539 100644 --- a/apps/backend/src/lib/turnstile.tsx +++ b/apps/backend/src/lib/turnstile.tsx @@ -10,8 +10,8 @@ import { turnstileDevelopmentKeys, turnstilePhaseValues, } 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 { validateRedirectHostname } from "./redirect-urls"; import { Tenancy } from "./tenancies"; @@ -47,15 +47,7 @@ type SiteverifyResponse = { const FETCH_TIMEOUT_MS = 10_000; function isAllowedTurnstileHostname(hostname: string, tenancy: Tenancy): boolean { - if (tenancy.config.domains.allowLocalhost && isLocalhost(`http://${hostname}`)) { - return true; - } - return Object.values(tenancy.config.domains.trustedDomains).some(({ baseUrl }) => { - if (baseUrl == null) return false; - const pattern = createUrlIfValid(baseUrl)?.hostname - ?? baseUrl.match(/^[^:]+:\/\/([^/:]+)/)?.[1]; - return pattern != null && matchHostnamePattern(pattern, hostname); - }); + return validateRedirectHostname(hostname, tenancy); } function getSecretKey(override?: string): string { @@ -324,7 +316,7 @@ import.meta.vitest?.describe("verifyTurnstileToken(...)", () => { expect(captureErrorSpy).not.toHaveBeenCalled(); }); - const allowMyapp = (h: string) => h === "myapp.com" || matchHostnamePattern("*.myapp.com", h); + const allowMyapp = (h: string) => h === "myapp.com" || h.endsWith(".myapp.com"); test("returns invalid when hostname does not match allowed hostnames", async ({ expect }) => { stubFetch({ success: true, action: "sign_up_with_credential", hostname: "evil.example.com" }); diff --git a/apps/backend/src/oauth/model.tsx b/apps/backend/src/oauth/model.tsx index ab021bd6d..82f33bbf7 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 { getOAuthRedirectUrisForTenancy, 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"; @@ -60,13 +60,9 @@ export class OAuthModel implements AuthorizationCodeModel { let redirectUris: string[] = []; try { - redirectUris = Object.entries(tenancy.config.domains.trustedDomains) - // note that this may include wildcard domains, which is fine because we correctly account for them in - // model.validateRedirectUri(...) - .filter(([_, domain]) => { - return domain.baseUrl; - }) - .map(([_, domain]) => new URL(domain.handlerPath, domain.baseUrl).toString()); + // This may include wildcard domains and the implicit hosted handler domain; + // model.validateRedirectUri(...) performs the authoritative trust check. + redirectUris = getOAuthRedirectUrisForTenancy(tenancy); } catch (e) { captureError("get-oauth-redirect-urls", { error: e, diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx index 4e1b8ea02..553d254c9 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx @@ -747,7 +747,7 @@ export default function PageClient() {
Same-email social login policy
-
Determines what happens when a user uses a new social login provider with an email that's already connected to an account
+
Determines what happens when a user uses a new social login provider with an email that's already connected to an account
([['https:', '443'], ['http:', '80']]); +const hostedHandlerTemplateProjectIdA = "00000000-0000-4000-8000-000000000000"; +const hostedHandlerTemplateProjectIdB = "11111111-1111-4111-8111-111111111111"; + +function replaceStackPortPrefix(input: string | undefined, stackPortPrefix: string | undefined): string | undefined { + if (input == null) return undefined; + return stackPortPrefix ? input.replace(/\$\{NEXT_PUBLIC_STACK_PORT_PREFIX:-81\}/g, stackPortPrefix) : input; +} + +function getHostedHandlerUrlFromTemplate(template: string, projectId: string, hostedPath: string): string { + return template + .replaceAll(hostedHandlerProjectIdPlaceholder, projectId) + .replaceAll(hostedHandlerPathPlaceholder, hostedPath); +} + +function assertHostedHandlerTemplateHasProjectOrigin(template: string): void { + const projectUrlA = new URL(getHostedHandlerUrlFromTemplate(template, hostedHandlerTemplateProjectIdA, "handler")); + const projectUrlB = new URL(getHostedHandlerUrlFromTemplate(template, hostedHandlerTemplateProjectIdB, "handler")); + if (projectUrlA.origin === projectUrlB.origin || !projectUrlA.hostname.includes(hostedHandlerTemplateProjectIdA)) { + throw new StackAssertionError("The hosted handler URL template must put {projectId} in the hostname.", { + hostedHandlerUrlTemplate: template, + hint: "Use a project-specific origin like 'https://{projectId}.built-with-stack-auth.com/{hostedPath}', not a shared-origin path like 'https://example.com/{projectId}/{hostedPath}'.", + }); + } +} + +export function getHostedHandlerTrustedDomain(options: { + projectId: string, + hostedHandlerDomainSuffix?: string, + hostedHandlerUrlTemplate?: string, + stackPortPrefix?: string, +}): string { + return new URL(getHostedHandlerUrlFromConfig({ + ...options, + hostedPath: "handler", + })).origin; +} + +export function getHostedHandlerUrlFromConfig(options: { + projectId: string, + hostedPath: string, + hostedHandlerDomainSuffix?: string, + hostedHandlerUrlTemplate?: string, + stackPortPrefix?: string, +}): string { + const configuredTemplate = replaceStackPortPrefix(options.hostedHandlerUrlTemplate, options.stackPortPrefix); + return configuredTemplate == null + ? (() => { + const domainSuffix = replaceStackPortPrefix(options.hostedHandlerDomainSuffix, options.stackPortPrefix) ?? defaultHostedHandlerDomainSuffix; + if (!domainSuffix.startsWith(".")) { + throw new StackAssertionError("The hosted handler domain suffix must start with a dot.", { + domainSuffix, + hint: "Set NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX to a value like '.built-with-stack-auth.com'.", + }); + } + return `https://${options.projectId}${domainSuffix}/${options.hostedPath}`; + })() + : (() => { + if (!configuredTemplate.includes(hostedHandlerProjectIdPlaceholder) || !configuredTemplate.includes(hostedHandlerPathPlaceholder)) { + throw new StackAssertionError("The hosted handler URL template must contain {projectId} and {hostedPath}.", { + hostedHandlerUrlTemplate: configuredTemplate, + hint: "Set NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE to a value like 'https://{projectId}.built-with-stack-auth.com/{hostedPath}'.", + }); + } + assertHostedHandlerTemplateHasProjectOrigin(configuredTemplate); + return getHostedHandlerUrlFromTemplate(configuredTemplate, options.projectId, options.hostedPath); + })(); +} + +export function getImplicitlyTrustedDomainsForProject(options: { + projectId: string, + hostedHandlerDomainSuffix?: string, + hostedHandlerUrlTemplate?: string, + stackPortPrefix?: string, +}): string[] { + return [getHostedHandlerTrustedDomain(options)]; +} function normalizePort(url: URL): string { const port = url.port || defaultPorts.get(url.protocol) || ''; @@ -137,6 +216,37 @@ import.meta.vitest?.test("validateRedirectUrl matches exact and wildcard trusted })).toBe(false); }); +import.meta.vitest?.test("validateRedirectUrl trusts implicit hosted handler domains", ({ expect }) => { + const projectId = "12345678-1234-4234-8234-123456789abc"; + expect(validateRedirectUrl(`https://${projectId}.built-with-stack-auth.com/anything`, { + allowLocalhost: false, + trustedDomains: getImplicitlyTrustedDomainsForProject({ projectId }), + })).toBe(true); + expect(validateRedirectUrl("https://other-project.built-with-stack-auth.com/anything", { + allowLocalhost: false, + trustedDomains: getImplicitlyTrustedDomainsForProject({ projectId }), + })).toBe(false); + expect(validateRedirectUrl(`http://${projectId}.localhost:9209/anything`, { + allowLocalhost: false, + trustedDomains: getImplicitlyTrustedDomainsForProject({ + projectId, + hostedHandlerUrlTemplate: "http://{projectId}.localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}09/{hostedPath}", + stackPortPrefix: "92", + }), + })).toBe(true); +}); + +import.meta.vitest?.test("getImplicitlyTrustedDomainsForProject rejects shared-origin hosted templates", ({ expect }) => { + expect(() => getImplicitlyTrustedDomainsForProject({ + projectId: "12345678-1234-4234-8234-123456789abc", + hostedHandlerUrlTemplate: "https://host.example.com/{projectId}/{hostedPath}", + })).toThrowErrorMatchingInlineSnapshot(` + [StackAssertionError: The hosted handler URL template must put {projectId} in the hostname. + + This is likely an error in Stack. Please make sure you are running the newest version and report it.] + `); +}); + import.meta.vitest?.test("validateRedirectUrl respects default and explicit ports", ({ expect }) => { expect(validateRedirectUrl("https://example.com:443/path", { allowLocalhost: false, 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 922f6f80e..356cf83a3 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 @@ -56,7 +56,7 @@ import { NotificationCategory } from "../../notification-categories"; import { TeamPermission } from "../../permissions"; import { AdminOwnedProject, AdminProjectUpdateOptions, Project, adminProjectCreateOptionsToCrud } from "../../projects"; import { EditableTeamMemberProfile, ReceivedTeamInvitation, SentTeamInvitation, Team, TeamCreateOptions, TeamUpdateOptions, TeamUser, teamCreateOptionsToCrud, teamUpdateOptionsToCrud } from "../../teams"; -import { buildCliAuthConfirmUrl, isHostedHandlerUrlForProject, resolveHandlerUrls } from "../../url-targets"; +import { buildCliAuthConfirmUrl, getHostedHandlerUrl, isHostedHandlerUrlForProject, resolveHandlerUrls } from "../../url-targets"; import { ActiveSession, Auth, BaseUser, CurrentUser, InternalUserExtra, OAuthProvider, ProjectCurrentUser, SyncedPartialUser, TokenPartialUser, UserExtra, UserUpdateOptions, userUpdateOptionsToCrud, withUserDestructureGuard } from "../../users"; import { StackClientApp, StackClientAppConstructorOptions, StackClientAppJson } from "../interfaces/client-app"; import { _StackAdminAppImplIncomplete } from "./admin-app-impl"; @@ -1190,7 +1190,10 @@ export class _StackClientAppImplIncomplete d.domain), + trustedDomains: [ + ...project.config.domains.map(d => d.domain), + new URL(getHostedHandlerUrl({ projectId: this.projectId, pagePath: "" })).origin, + ], }; } diff --git a/packages/template/src/lib/stack-app/url-targets.test.ts b/packages/template/src/lib/stack-app/url-targets.test.ts index f87022014..8e167d41f 100644 --- a/packages/template/src/lib/stack-app/url-targets.test.ts +++ b/packages/template/src/lib/stack-app/url-targets.test.ts @@ -171,7 +171,7 @@ describe("handler URL targets", () => { }); it("uses the full hosted handler URL template when configured", () => { - vi.stubEnv("NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE", "http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}09/{projectId}/{hostedPath}"); + vi.stubEnv("NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE", "http://{projectId}.localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}09/{hostedPath}"); vi.stubEnv("NEXT_PUBLIC_STACK_PORT_PREFIX", "93"); const urls = resolveHandlerUrls({ @@ -181,8 +181,8 @@ describe("handler URL targets", () => { }, }); - expect(urls.signIn).toBe("http://localhost:9309/project-id/handler/sign-in"); - expect(urls.accountSettings).toBe("http://localhost:9309/project-id/handler/account-settings"); + expect(urls.signIn).toBe("http://project-id.localhost:9309/handler/sign-in"); + expect(urls.accountSettings).toBe("http://project-id.localhost:9309/handler/account-settings"); }); it("validates the hosted handler URL template placeholders", () => { @@ -195,6 +195,21 @@ describe("handler URL targets", () => { }, })).toThrowError(/\{projectId\} and \{hostedPath\}/); }); + + it("rejects hosted handler URL templates that put the project ID in the path", () => { + vi.stubEnv("NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE", "http://localhost:9309/{projectId}/{hostedPath}"); + + expect(() => resolveHandlerUrls({ + projectId: "project-id", + urls: { + default: { type: "hosted" }, + }, + })).toThrowErrorMatchingInlineSnapshot(` + [StackAssertionError: The hosted handler URL template must put {projectId} in the hostname. + + This is likely an error in Stack. Please make sure you are running the newest version and report it.] + `); + }); }); describe("isLocalHandlerUrlTarget", () => { diff --git a/packages/template/src/lib/stack-app/url-targets.ts b/packages/template/src/lib/stack-app/url-targets.ts index 1f5045fd0..6bbf1f743 100644 --- a/packages/template/src/lib/stack-app/url-targets.ts +++ b/packages/template/src/lib/stack-app/url-targets.ts @@ -1,22 +1,14 @@ import { getCustomPagePrompts, type CustomPagePrompt } from "@stackframe/stack-shared/dist/interface/handler-urls"; import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { getHostedHandlerUrlFromConfig } from "@stackframe/stack-shared/dist/utils/redirect-urls"; import { envVars } from "../env"; import { DefaultHandlerUrlTarget, HandlerPageUrls, HandlerUrlOptions, HandlerUrlTarget, HandlerUrls, ResolvedHandlerUrls } from "./common"; -const defaultHostedHandlerDomainSuffix = ".built-with-stack-auth.com"; -const hostedHandlerProjectIdPlaceholder = "{projectId}"; -const hostedHandlerPathPlaceholder = "{hostedPath}"; const localUrlPlaceholderOrigin = "http://example.com"; const schemePrefixRegex = /^[a-zA-Z][a-zA-Z\d+\-.]*:/; const customPagePrompts: Record, CustomPagePrompt> = getCustomPagePrompts(); -const replaceStackPortPrefix = (input: T): T => { - if (!input) return input; - const prefix = envVars.NEXT_PUBLIC_STACK_PORT_PREFIX; - return prefix ? input.replace(/\$\{NEXT_PUBLIC_STACK_PORT_PREFIX:-81\}/g, prefix) as T : input; -}; - const joinHandlerComponentPath = (basePath: string, pagePath: string): string => { const normalizedBasePath = basePath.endsWith("/") && basePath.length > 1 ? basePath.slice(0, -1) @@ -92,33 +84,6 @@ const getHostedPagePathForHandlerName = (handlerName: keyof HandlerUrls): string } }; -export const getHostedHandlerDomainSuffix = (): string => { - const configuredValue = envVars.NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX - ?? defaultHostedHandlerDomainSuffix; - const domainSuffix = replaceStackPortPrefix(configuredValue); - if (!domainSuffix.startsWith(".")) { - throw new StackAssertionError("The hosted handler domain suffix must start with a dot.", { - domainSuffix, - hint: "Set NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX to a value like '.built-with-stack-auth.com'.", - }); - } - return domainSuffix; -}; - -const getHostedHandlerUrlTemplate = (): string => { - const configuredTemplate = replaceStackPortPrefix(envVars.NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE); - if (configuredTemplate != null) { - if (!configuredTemplate.includes(hostedHandlerProjectIdPlaceholder) || !configuredTemplate.includes(hostedHandlerPathPlaceholder)) { - throw new StackAssertionError("The hosted handler URL template must contain {projectId} and {hostedPath}.", { - hostedHandlerUrlTemplate: configuredTemplate, - hint: "Set NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE to a value like 'https://{projectId}.built-with-stack-auth.com/{hostedPath}'.", - }); - } - return configuredTemplate; - } - return `https://${hostedHandlerProjectIdPlaceholder}${getHostedHandlerDomainSuffix()}/${hostedHandlerPathPlaceholder}`; -}; - const resolveCustomTargetUrl = (options: { target: { type: "custom", url: string, version: number }, handlerName: keyof HandlerUrls, @@ -139,11 +104,13 @@ const resolveCustomTargetUrl = (options: { export const getHostedHandlerUrl = (options: { projectId: string, pagePath: string }): string => { const normalizedPagePath = options.pagePath.replace(/^\/+/, ""); const hostedPath = normalizedPagePath.length > 0 ? `handler/${normalizedPagePath}` : "handler"; - const template = getHostedHandlerUrlTemplate(); - const templateFilled = template - .replaceAll(hostedHandlerProjectIdPlaceholder, options.projectId) - .replaceAll(hostedHandlerPathPlaceholder, hostedPath); - return new URL(templateFilled).toString(); + return getHostedHandlerUrlFromConfig({ + projectId: options.projectId, + hostedPath, + hostedHandlerDomainSuffix: envVars.NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX, + hostedHandlerUrlTemplate: envVars.NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE, + stackPortPrefix: envVars.NEXT_PUBLIC_STACK_PORT_PREFIX, + }); }; const isRelativeUrlString = (url: string): boolean => {