diff --git a/.claude/CLAUDE-KNOWLEDGE.md b/.claude/CLAUDE-KNOWLEDGE.md index 595e04c1b0..3eee9c4355 100644 --- a/.claude/CLAUDE-KNOWLEDGE.md +++ b/.claude/CLAUDE-KNOWLEDGE.md @@ -512,5 +512,11 @@ 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. + +## 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: 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.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..40410c468d 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)); @@ -141,6 +142,158 @@ it("returns static app.urls.signOut for hosted flows", 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; + 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()).toBe(`${localRedirectUrl}/callback-page?foo=bar`); + } finally { + globalThis.window = previousWindow; + globalThis.document = previousDocument; + } + }); +}); + +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); + 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("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"; @@ -253,3 +406,268 @@ 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 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"; + 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..49c556a09f --- /dev/null +++ b/packages/stack-shared/src/utils/redirect-urls.tsx @@ -0,0 +1,187 @@ +import { StackAssertionError, captureError } from "./errors"; +import { createUrlIfValid, isLocalhost, matchHostnamePattern } from "./urls"; + +type TrustedDomainConfig = { + allowLocalhost?: boolean, + trustedDomains: readonly (string | null | undefined)[], +}; + +const defaultPorts = new Map([['https:', '443'], ['http:', '80']]); + +function normalizePort(url: URL): string { + 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 { + if (!hostPatternHasExplicitPort(hostPattern)) { + return hostPattern; + } + const portSeparatorIndex = hostPattern.lastIndexOf(":"); + 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 !== "" && [...port].every(char => char >= "0" && char <= "9")); +} + +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 = hostPatternHasExplicitPort(parsedPattern.hostPattern); + 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 | 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("*")) { + 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; +} + +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); + 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 }) => { + 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:*", + ])).toBe("example.com"); +}); 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..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 @@ -29,12 +29,13 @@ 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 { isRelative } from "@stackframe/stack-shared/dist/utils/urls"; +import { BotChallengeExecutionFailedError, BotChallengeUserCancelledError, withBotChallengeFlow } from "@stackframe/stack-shared/dist/utils/turnstile-flow"; +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 @@ -43,8 +44,8 @@ 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 { CookieHelper, createBrowserCookieHelper, createCookieHelper, createPlaceholderCookieHelper, deleteCookie, deleteCookieClient, isSecure as isSecureCookieContext, saveVerifierAndState, setOrDeleteCookie, setOrDeleteCookieClient } from "../../../cookie"; +import { callOAuthCallback, getNewOAuthProviderOrScopeUrl } from "../../../auth"; +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"; @@ -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,18 @@ 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 oauthCallbackResponseQueryParams = ["code", "state", "error", "error_description", "errorCode", "message", "details"] as const; + const allClientApps = new Map]>(); const STACK_AUTHORIZATION_VALUE_PREFIX = "stackauth_"; @@ -314,7 +327,7 @@ export class _StackClientAppImplIncomplete[] = []; protected async _createCookieHelper(overrideTokenStoreInit?: TokenStoreInit): Promise { const tokenStoreInit = overrideTokenStoreInit === undefined ? this._tokenStoreInit : overrideTokenStoreInit; @@ -518,7 +532,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 +722,222 @@ export class _StackClientAppImplIncomplete Promise) { + const promise = (async () => { + await Promise.resolve(); + 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 () => { + 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 _currentUrlLooksLikeStackOAuthCallback(): boolean { + if (typeof window === "undefined") { + return false; + } + const currentUrl = new URL(window.location.href); + const state = currentUrl.searchParams.get("state"); + if (!currentUrl.searchParams.has("code") || state == null) { + return false; + } + return getCookieClient(`stack-oauth-outer-${state}`) != null; + } + + protected _getOAuthCallbackRedirectUri(): 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); + for (const param of oauthCallbackResponseQueryParams) { + currentUrl.searchParams.delete(param); + } + 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, + awaitPendingAuthResolutions?: boolean, + }): Promise { + const targetUrl = new URL(options.url, options.currentUrl); + if (targetUrl.origin === options.currentUrl.origin) { + return options.url; + } + + const refreshTokenId = await this._getCurrentRefreshTokenIdIfSignedIn({ + awaitPendingAuthResolutions: options.awaitPendingAuthResolutions, + }); + 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"); + } + if (isRelative(redirectUri)) { + throw new Error("Nested cross-domain auth redirect URI must be absolute."); + } + const redirectUriUrl = new URL(redirectUri); + if (!await this._isTrusted(redirectUriUrl.toString())) { + throw new Error(`Nested cross-domain auth redirect URI ${redirectUri} is not trusted.`); + } + const afterCallbackRedirectUrlString = currentUrl.searchParams.get(nestedCrossDomainAuthQueryParams.afterCallbackRedirectUrl); + const afterCallbackRedirectUrl = afterCallbackRedirectUrlString == null + ? redirectUriUrl + : new URL(afterCallbackRedirectUrlString, redirectUriUrl); + if (!await this._isTrusted(afterCallbackRedirectUrl.toString())) { + throw new Error(`Nested cross-domain auth after-callback redirect URL ${afterCallbackRedirectUrlString} is not trusted.`); + } + 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: redirectUriUrl.toString(), + state, + codeChallenge, + afterCallbackRedirectUrl: afterCallbackRedirectUrl.toString(), + 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"); + if (isRelative(callbackUrlString)) { + throw new Error("Nested cross-domain auth callback URL must be absolute."); + } + const callbackUrl = new URL(callbackUrlString); + const isTrusted = await this._isTrusted(callbackUrl.toString()); + if (!isTrusted) { + throw new Error(`Nested cross-domain auth callback URL ${callbackUrlString} is not trusted.`); + } + + 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 +1186,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 +1424,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 +1436,7 @@ export class _StackClientAppImplIncomplete void) => { return subscribeSessionRefresh({ @@ -1807,7 +2053,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 = createUrlIfValid(url); + if (parsedUrl == null) { + return false; + } + 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 +2737,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", { @@ -2596,7 +2856,11 @@ 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", @@ -3028,7 +3300,10 @@ 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 +3664,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", + }, + })).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", () => { + expect(() => resolveHandlerUrls({ + projectId: "project-id", + urls: { + oauthCallback: { type: "custom", url: "https://app.example.test/oauth-callback", version: 0 }, + }, + })).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", () => { 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..1f5045fd0d 100644 --- a/packages/template/src/lib/stack-app/url-targets.ts +++ b/packages/template/src/lib/stack-app/url-targets.ts @@ -202,9 +202,29 @@ 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" }; + 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; @@ -286,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,