Skip to content

fix(auth): correct callback URL resolution across SSR and hydration - #6217

Merged
waleedlatif1 merged 3 commits into
stagingfrom
fix-sso-callback-url-derived-state
Aug 3, 2026
Merged

fix(auth): correct callback URL resolution across SSR and hydration#6217
waleedlatif1 merged 3 commits into
stagingfrom
fix-sso-callback-url-derived-state

Conversation

@waleedlatif1

@waleedlatif1waleedlatif1 commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Three follow-ups to #6214, all on the same URL-resolution surface.

1. Derive the SSO callback URL during render (fb97b2761)
callbackUrl was seeded into useState('/workspace') and then overwritten from a useEffect, so the first painted frame always carried the default. On any deep link with ?callbackUrl=, the "Sign in with email" and "Sign up" links briefly pointed at /workspace — a click landing in that window navigated to the wrong place. Now derived during render, matching login-form.tsx:104 and signup-form.tsx:118, which already did this; /sso was the lone outlier. The rejected-value warning moves to an effect keyed on the param itself rather than the searchParams object, so it fires once per actual change.

2. Resolve callback URLs against the app origin server-side (73587c7ea)
Both reviewers flagged an SSR/client divergence. validateCallbackUrl resolved against a sentinel base (https://callback-url-validator.invalid) when window was undefined, so the server rejected every absolute URL — including the same-origin ones its own docstring documents as valid. Server and client disagreed, and a callback URL derived during render produced one destination in the SSR markup and another after hydration.

That exposure was not new to this PRlogin-form.tsx and signup-form.tsx already derive during render on force-dynamic pages, so both carried it. Fixed at the root rather than worked around:

functiongetCallbackValidationOrigin(): string{if(typeofwindow!=='undefined')returnwindow.location.origintry{returnnewURL(getBaseUrl()).origin}catch{returnCALLBACK_URL_SERVER_BASE}}

This makes the server match the documented contract rather than loosening it. The base is operator-controlled env, never attacker-controlled, and it stays fail-closed — an unset or unparseable app URL falls back to the sentinel and rejects every absolute URL exactly as before.

3. Drop the getBaseUrl browser-origin fallback (24f09409a)
The window.location.origin fallback was added in #6214 as a safety net while the real cause — the hosted env script losing beforeInteractive — was fixed in the same PR. With injection ordering restored, window.__ENV is populated before hydration, so the fallback is unreachable in any correctly configured deployment.

It was also unsafe in the one case it could still fire: an opaque origin — a sandboxed iframe, and /chat/* is deliberately embeddable — serializes to the string'null', which is truthy. getBaseUrl() would have returned 'null' and every call site would have silently built null/api/.... A throw surfaces the misconfiguration instead of encoding it into request URLs.

Server-side behavior is unchanged throughout: there was never a window to fall back to, so getBaseDomain() and getCallbackValidationOrigin() keep their existing fail-closed paths.

Type of Change

  • Bug fix

Testing

sso-form.test.tsx renders via renderToString, which runs no effects and so captures exactly the first-frame window the old code got wrong: a valid callbackUrl present on the first frame, no param falling back to /workspace, and an off-origin value (https://evil.example.com/steal) rejected and absent from the markup. Confirmed the first-frame test fails against the pre-fix implementation.

input-validation.test.ts gains the absolute same-origin acceptance and the unset-app-URL fail-closed fallback. All 15 existing open-redirect rejection cases (//evil.com, userinfo smuggling, subdomain confusion, wrong port, wrong protocol, javascript:, data:, tab/newline-stripped variants) pass unchanged. Confirmed the new acceptance test fails against the sentinel-only version.

urls.test.ts flips the two fallback tests to assert the throw, keeping whitespace-only coverage.

503 tests pass across the directly affected suites. Full apps/sim suite: 18195 passing — the single failure is executor/handlers/pi/cloud-review-tools.test.ts, which shells out to rg via a python subprocess; ripgrep is a shell function rather than a binary on my machine, so it is an environment artifact unrelated to these files. tsc clean, bun run lint:check clean. Structural gates all pass: check:boundaries, check:client-boundary, check:realtime-prune, check:tool-registry-boundary, check:react-query, check:utils, check:api-validation.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

`callbackUrl` was seeded into `useState('/workspace')` and overwritten from
a `useEffect` that read `searchParams`, so the first painted frame always
carried the default. On any deep link with `?callbackUrl=`, the "Sign in
with email" and "Sign up" links briefly pointed at `/workspace` instead of
the requested destination, and a click landing in that window navigated to
the wrong place.
- derive `callbackUrl` from `searchParams` during render; the validation
gate is unchanged, so an off-origin or malformed value still falls back
to `/workspace`
- keep the warning for a rejected value in an effect, now keyed on the
param itself rather than the `searchParams` object, so it fires once per
actual change instead of once per identity change
- add first-frame tests via `renderToString`, which runs no effects and so
pins exactly the window the old code got wrong
@vercel

vercelBot commented Aug 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
docsSkippedSkippedAug 3, 2026 7:43pm

Request Review

@cursor

cursorBot commented Aug 3, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches auth redirect links and open-redirect validation; changes are aligned with existing login/signup behavior and tighten base URL configuration rather than widening redirects.

Overview
Fixes callback URL handling on SSO and shared validators so deep links and SSR markup match what the browser applies after hydration.

SSO form:callbackUrl is derived during render from ?callbackUrl= (with validateCallbackUrl) instead of defaulting to /workspace in state and updating in an effect. The first painted frame now carries the correct “Sign in with email” / “Sign up” links; invalid off-origin values still fall back to /workspace.

validateCallbackUrl: Server-side resolution uses the configured app origin via getBaseUrl() (browser still uses window.location.origin), so absolute same-origin URLs are accepted consistently with the documented contract. Unset/unparseable app URL keeps the fail-closed sentinel behavior for absolute URLs.

getBaseUrl: Removes the window.location.origin fallback when NEXT_PUBLIC_APP_URL is missing; callers throw instead of guessing (avoids silent null/api/... from opaque iframe origins).

Adds sso-form.test.tsx (first-frame renderToString cases) and extends input-validation / urls tests; testing mock urls.mock.ts mirrors the stricter getBaseUrl.

Reviewed by Cursor Bugbot for commit 24f0940. Configure here.

@greptile-apps

greptile-appsBot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR fixes callback URL resolution so SSR and hydration use consistent validation and SSO links carry the intended destination on their first render.

  • Derives the SSO callback URL synchronously during render and logs rejected values in an effect.
  • Resolves server-side absolute callback URLs against the configured application origin while retaining a fail-closed fallback.
  • Removes the browser-origin fallback from getBaseUrl and aligns tests and shared mocks with the stricter configuration contract.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the prior SSR/client-origin divergence is addressed by resolving server validation against the configured application origin while browser validation continues to use the browser origin.

Important Files Changed

FilenameOverview
apps/sim/ee/sso/components/sso-form.tsxDerives and validates the callback parameter during render, removing the incorrect first-frame default while retaining invalid-value logging.
apps/sim/lib/core/security/input-validation.tsUses the configured application origin for server-side callback validation and safely falls back to the sentinel when configuration cannot be resolved.
apps/sim/lib/core/utils/urls.tsRemoves the unsafe browser-origin fallback and now throws consistently when the application URL is absent.
packages/testing/src/mocks/urls.mock.tsAligns the shared URL mock with the production helper’s stricter missing-configuration behavior.

Sequence Diagram

sequenceDiagram
participant Server as SSR
participant Config as App URL configuration
participant Browser as Hydration
participant Form as Auth form
Server->>Config: Read NEXT_PUBLIC_APP_URL
Config-->>Server: Configured application origin
Server->>Form: Validate callback and render links
Browser->>Form: Validate against window.location.origin
Form-->>Browser: Preserve matching callback destination
Loading

Reviews (4): Last reviewed commit: "fix(env): drop the getBaseUrl browser-or..." | Re-trigger Greptile

Comment threadapps/sim/ee/sso/components/sso-form.tsx
@waleedlatif1

Copy link
Copy Markdown
CollaboratorAuthor

@greptile

@waleedlatif1

Copy link
Copy Markdown
CollaboratorAuthor

@cursor review

Comment threadapps/sim/ee/sso/components/sso-form.tsx

@cursorcursorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit fb97b27. Configure here.

`validateCallbackUrl` compared against a sentinel base
(`https://callback-url-validator.invalid`) when `window` was undefined, so
the server rejected every absolute URL — including the same-origin ones the
function documents as valid. A component deriving a callback URL during
render therefore produced one destination in the SSR markup and a different
one after hydration.
The exposure was not new to the SSO form: `login-form.tsx` and
`signup-form.tsx` already derive their callback URL during render on
`force-dynamic` pages, so both carried the same divergence.
- resolve against the deployment's own origin server-side, so the server
reaches the same verdict the browser will after hydration
- fall back to the sentinel when the app URL is unset or unparseable, which
keeps the server fail-closed: absolute URLs are rejected, as before
- cover the absolute same-origin case and the unset-app-URL fallback in the
existing suite; all 15 open-redirect rejection cases are unchanged
@waleedlatif1

Copy link
Copy Markdown
CollaboratorAuthor

@greptile

@waleedlatif1

Copy link
Copy Markdown
CollaboratorAuthor

@cursor review

@cursorcursorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 73587c7. Configure here.

The fallback was added in #6214 as a safety net while the real cause — the
hosted env script losing its `beforeInteractive` strategy — was fixed in the
same PR. With the injection ordering restored, `window.__ENV` is populated
before hydration, so the fallback is unreachable in any correctly configured
deployment.
Guessing the origin was also unsafe in the one case it could still fire. An
opaque origin — a sandboxed iframe, and `/chat/*` is deliberately embeddable
— serializes to the string `'null'`, which is truthy, so `getBaseUrl()` would
have returned `'null'` and every call site would have silently built
`null/api/...`. A throw surfaces the misconfiguration instead of encoding it
into request URLs.
- restore the unconditional throw when NEXT_PUBLIC_APP_URL is unset or blank
- mirror it back in the shared testing mock
- flip the two fallback tests to assert the throw, keeping whitespace-only
coverage
Server-side behavior is unchanged: there was never a `window` to fall back
to, so callers that already guard `getBaseUrl()` (`getBaseDomain`,
`validateCallbackUrl`) keep their existing fail-closed paths.
@waleedlatif1waleedlatif1 changed the title fix(sso): derive the SSO callback URL during renderfix(auth): correct callback URL resolution across SSR and hydrationAug 3, 2026
@waleedlatif1

Copy link
Copy Markdown
CollaboratorAuthor

@greptile

@waleedlatif1

Copy link
Copy Markdown
CollaboratorAuthor

@cursor review

@cursorcursorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 24f0940. Configure here.

@waleedlatif1
waleedlatif1 merged commit 36aace7 into stagingAug 3, 2026
27 of 28 checks passed
@waleedlatif1
waleedlatif1 deleted the fix-sso-callback-url-derived-state branch August 3, 2026 19:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@waleedlatif1