fix(frontend): preserve returnTo through the onboarding interstitial (#14353)

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
This commit is contained in:
chuckbutkus
2026-06-01 17:44:47 -04:00
committed by GitHub
co-authored by openhands OpenHands Bot
parent 982994cb8e
commit d04ddf6f8f
7 changed files with 854 additions and 66 deletions
+100 -3
View File
@@ -5,7 +5,7 @@ import warnings
from datetime import datetime, timedelta, timezone
from types import MappingProxyType
from typing import Annotated, Optional, cast
from urllib.parse import quote, urlencode
from urllib.parse import parse_qs, quote, urlencode, urlparse
from uuid import UUID as parse_uuid
from fastapi import (
@@ -586,7 +586,10 @@ async def keycloak_callback(
# Only redirect to onboarding if user has a valid offline token,
# otherwise they need to complete the Keycloak offline token flow first
if valid_offline_token and await _should_redirect_to_onboarding(user_id, user):
redirect_url = f'{web_url}/onboarding'
# Preserve the user's originally requested destination as
# ``?returnTo=...`` so the frontend ``OnboardingForm`` can
# restore it after the user finishes the form.
redirect_url = _build_onboarding_redirect(redirect_url, web_url)
logger.info(
'Redirecting returning user to onboarding',
extra={'user_id': user_id, 'deployment_mode': DEPLOYMENT_MODE},
@@ -690,6 +693,97 @@ async def authenticate(request: Request):
return response
def _extract_login_inner_return_to(relative_url: str) -> str | None:
"""Extract the inner ``returnTo`` from a ``/login?returnTo=...`` URL.
Returns the decoded inner ``returnTo`` value, or ``None`` if
``relative_url`` is not a login URL or has no inner ``returnTo``.
The OAuth flow's ``state`` is set to the full URL of the page that
triggered the login (see ``generateAuthUrl`` in the frontend).
For an unauthenticated deep-link visit, that page is itself
``/login?returnTo=<actual destination>``, so the OAuth callback's
``redirect_url`` ends up *wrapping* the user's true destination
inside a login URL. Sending the user back through ``/login`` after
onboarding works in principle (``LoginPage`` re-redirects authed
users to its own ``returnTo``), but the round-trip adds extra
state and is brittle when query-string layering goes wrong.
Unwrapping here keeps the post-onboarding navigation a single
direct step, e.g. ``/onboarding?returnTo=%2Fsettings%2Fuser``
rather than the doubly-nested
``/onboarding?returnTo=%2Flogin%3FreturnTo%3D%252Fsettings...``.
"""
parsed = urlparse(relative_url)
if parsed.path != '/login':
return None
inner = parse_qs(parsed.query).get('returnTo')
if not inner:
return None
value = inner[0]
if not value.startswith('/'):
return None
return value
def _build_onboarding_redirect(original_url: str, web_url: str) -> str:
"""Build the ``/onboarding`` redirect URL preserving ``returnTo``.
The user's originally requested destination is preserved as a
``returnTo`` query parameter on ``/onboarding``.
Without this, any deep link the user clicked while logged out
(e.g. ``/conversations/abc?foo=bar``) is silently dropped at the
onboarding interstitial because the OAuth callback would clobber
its working ``redirect_url`` with a bare ``f'{web_url}/onboarding'``.
The frontend ``OnboardingForm`` reads this ``returnTo`` query
parameter and restores it after the user finishes the form.
The trivial home-page case (``original_url`` empty, equal to
``web_url``, or pointing at ``web_url/``) returns the bare
``/onboarding`` URL to keep the URL bar clean — that is already
the default landing page once onboarding completes.
The ``returnTo`` value is always a *relative* path (``/foo?bar``)
rather than an absolute URL: that keeps the URL short, avoids
leaking the deployment origin into the browser bar a second time,
and lets the frontend use ``navigate(returnTo)`` directly.
When ``original_url`` is itself a ``/login?returnTo=...`` URL —
which is the common case for unauthenticated deep-link visits,
because the OAuth flow's ``state`` carries the full login page
URL — the *inner* ``returnTo`` is extracted so the user lands at
their real destination in a single navigation rather than
bouncing through ``/login`` after onboarding.
"""
onboarding_url = f'{web_url}/onboarding'
if not original_url:
return onboarding_url
# Compute the path-and-query portion of the original URL. We try
# to strip the deployment origin first so we end up with a
# relative path; if the URL points at a different host we fall
# back to the URL as-is. The ``OnboardingForm`` component's
# ``sanitizeReturnTo`` helper rejects absolute/protocol-relative
# URLs before use, so any unexpected absolute value here is safe.
relative = original_url
if web_url and original_url.startswith(web_url):
relative = original_url[len(web_url) :] or '/'
# If we ended up with a login-page URL, unwrap its inner
# ``returnTo`` so post-onboarding navigation goes straight to the
# user's real destination instead of bouncing through ``/login``.
inner_return_to = _extract_login_inner_return_to(relative)
if inner_return_to is not None:
relative = inner_return_to
# Skip the trivial home-page case to keep the URL clean.
if relative in ('', '/'):
return onboarding_url
return f'{onboarding_url}?returnTo={quote(relative, safe="")}'
async def _should_redirect_to_onboarding(user_id: str, user: User) -> bool:
"""Check if user should be redirected to onboarding after TOS acceptance.
Backend always redirects applicable users to /onboarding.
@@ -749,7 +843,10 @@ async def _get_post_auth_redirect(
'Redirecting user to onboarding',
extra={'user_id': user_id, 'deployment_mode': DEPLOYMENT_MODE},
)
return f'{web_url}/onboarding'
# Preserve the user's originally requested destination as
# ``?returnTo=...`` so the frontend ``OnboardingForm`` can
# restore it after the user finishes the form.
return _build_onboarding_redirect(default_url, web_url)
return default_url
@@ -17,6 +17,7 @@ from fastapi.responses import JSONResponse
from server.auth.saas_user_auth import SaasUserAuth
from server.routes.auth import (
OnboardingSubmission,
_build_onboarding_redirect,
_get_post_auth_redirect,
_should_redirect_to_onboarding,
complete_onboarding,
@@ -237,6 +238,210 @@ class TestGetPostAuthRedirect:
assert call_args[0][0] == 'Redirecting user to onboarding'
assert call_args[1]['extra']['user_id'] == user_id
@pytest.mark.asyncio
async def test_preserves_deep_link_path_as_returnTo(self, mock_user):
"""Regression: deep-link path is preserved as ``returnTo``.
When an onboarding-needing user originally requested a deep
link (e.g. ``/conversations/abc``), the backend must redirect
to ``/onboarding?returnTo=...`` so the frontend
``OnboardingForm`` can restore that destination after the
user finishes the form. Without this, deep links clicked
while logged out are silently dropped at the onboarding
interstitial.
"""
mock_user.onboarding_completed = False
user_id = str(mock_user.id)
with (
patch('server.routes.auth.DEPLOYMENT_MODE', 'cloud'),
patch(
'server.routes.auth.UserStore.get_user_by_id',
new_callable=AsyncMock,
return_value=mock_user,
),
):
result = await _get_post_auth_redirect(
user_id,
'https://example.com/conversations/abc',
'https://example.com',
)
assert result == (
'https://example.com/onboarding?returnTo=%2Fconversations%2Fabc'
)
@pytest.mark.asyncio
async def test_preserves_query_string_as_returnTo(self, mock_user):
"""Regression: query-string destinations are preserved fully.
Destinations like ``/conversations/abc?foo=bar`` must be
preserved in the encoded ``returnTo`` rather than truncated.
"""
mock_user.onboarding_completed = False
user_id = str(mock_user.id)
with (
patch('server.routes.auth.DEPLOYMENT_MODE', 'cloud'),
patch(
'server.routes.auth.UserStore.get_user_by_id',
new_callable=AsyncMock,
return_value=mock_user,
),
):
result = await _get_post_auth_redirect(
user_id,
'https://example.com/conversations/abc?foo=bar',
'https://example.com',
)
assert result == (
'https://example.com/onboarding'
'?returnTo=%2Fconversations%2Fabc%3Ffoo%3Dbar'
)
# --- Tests for _build_onboarding_redirect ---
class TestBuildOnboardingRedirect:
"""Tests for the ``_build_onboarding_redirect`` helper.
These exercise the unit directly — the integration coverage
through ``_get_post_auth_redirect`` lives in
``TestGetPostAuthRedirect`` above.
"""
def test_strips_web_url_prefix_to_relative_path(self):
result = _build_onboarding_redirect(
'https://example.com/foo/bar', 'https://example.com'
)
assert result == ('https://example.com/onboarding?returnTo=%2Ffoo%2Fbar')
def test_preserves_query_string_in_returnTo(self):
result = _build_onboarding_redirect(
'https://example.com/foo?bar=baz&qux=1', 'https://example.com'
)
assert result == (
'https://example.com/onboarding' '?returnTo=%2Ffoo%3Fbar%3Dbaz%26qux%3D1'
)
def test_skips_returnTo_for_bare_home_with_trailing_slash(self):
# ``https://example.com/`` is the home page; appending a
# ``returnTo=%2F`` would be noise since ``/`` is the default
# post-onboarding landing page anyway.
result = _build_onboarding_redirect(
'https://example.com/', 'https://example.com'
)
assert result == 'https://example.com/onboarding'
def test_skips_returnTo_for_bare_home_without_trailing_slash(self):
result = _build_onboarding_redirect(
'https://example.com', 'https://example.com'
)
assert result == 'https://example.com/onboarding'
def test_skips_returnTo_when_original_url_is_empty(self):
result = _build_onboarding_redirect('', 'https://example.com')
assert result == 'https://example.com/onboarding'
def test_passes_through_absolute_url_when_origin_does_not_match(self):
# Defensive: if the original_url doesn't share the deployment
# origin, the helper preserves it as-is so the frontend can
# decide whether to navigate within the SPA or do a hard
# window.location redirect.
result = _build_onboarding_redirect(
'https://other.example.com/foo', 'https://example.com'
)
assert result == (
'https://example.com/onboarding'
'?returnTo=https%3A%2F%2Fother.example.com%2Ffoo'
)
def test_unwraps_cross_origin_login_returnTo(self):
"""Cross-origin login URLs still have their inner ``returnTo`` extracted.
``_extract_login_inner_return_to`` matches on the path only
(``parsed.path == '/login'``); it does not check the host. A
login URL at a different origin therefore has its inner
destination unwrapped to a safe relative path rather than being
preserved as a cross-origin absolute URL.
This differs from ``test_passes_through_absolute_url_when_origin_does_not_match``
(non-login cross-origin path → absolute URL preserved as-is) and
the behaviour is intentionally *safer*: the attacker-controlled
origin is discarded and only the relative ``returnTo`` value from
the query string is used.
"""
result = _build_onboarding_redirect(
'https://other.example.com/login?returnTo=%2Ffoo', 'https://example.com'
)
assert result == 'https://example.com/onboarding?returnTo=%2Ffoo'
def test_unwraps_login_returnTo_to_inner_destination(self):
"""Regression: login-wrapped destinations are unwrapped.
An unauthenticated deep-link visit creates an OAuth ``state``
that wraps the user's real destination inside a
``/login?returnTo=...`` URL. The onboarding redirect must
unwrap that so the user lands directly at their destination
after finishing onboarding instead of bouncing through
``/login`` (which adds round-trips and is brittle when
query-string layering goes wrong).
"""
result = _build_onboarding_redirect(
'https://example.com/login?returnTo=%2Fsettings%2Fuser'
'&login_method=github',
'https://example.com',
)
assert result == (
'https://example.com/onboarding' '?returnTo=%2Fsettings%2Fuser'
)
def test_unwraps_login_returnTo_with_inner_query_string(self):
"""Inner destinations with their own query string survive unwrap.
Destinations like ``/conversations/abc?foo=bar`` must keep
their query string when the outer login URL is unwrapped.
"""
result = _build_onboarding_redirect(
'https://example.com/login'
'?returnTo=%2Fconversations%2Fabc%3Ffoo%3Dbar'
'&login_method=github',
'https://example.com',
)
assert result == (
'https://example.com/onboarding'
'?returnTo=%2Fconversations%2Fabc%3Ffoo%3Dbar'
)
def test_unwraps_login_returnTo_to_bare_home_skips_returnTo(self):
"""Home-page short-circuit applies after the unwrap step.
If the unwrapped destination is the bare home page, the
helper should still emit the clean ``/onboarding`` URL with
no ``returnTo`` query parameter.
"""
result = _build_onboarding_redirect(
'https://example.com/login?returnTo=%2F&login_method=github',
'https://example.com',
)
assert result == 'https://example.com/onboarding'
def test_does_not_unwrap_non_login_path(self):
"""Non-login paths with a ``returnTo`` are preserved verbatim.
Only ``/login`` URLs get the inner-returnTo unwrap; other
paths that happen to carry a ``returnTo`` query parameter
must be preserved as-is.
"""
result = _build_onboarding_redirect(
'https://example.com/foo?returnTo=%2Fbar', 'https://example.com'
)
assert result == (
'https://example.com/onboarding' '?returnTo=%2Ffoo%3FreturnTo%3D%252Fbar'
)
# --- Tests for /complete_onboarding endpoint ---
@@ -0,0 +1,151 @@
import { render } from "@testing-library/react";
import { createRoutesStub } from "react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { OnboardingGuard } from "#/components/features/guards/onboarding-guard";
const mockNavigate = vi.fn();
vi.mock("react-router", async (importOriginal) => {
const original = await importOriginal<typeof import("react-router")>();
return {
...original,
useNavigate: () => mockNavigate,
};
});
const mockUseConfig = vi.fn();
const mockUseOnboardingStatus = vi.fn();
vi.mock("#/hooks/query/use-config", () => ({
useConfig: () => mockUseConfig(),
}));
vi.mock("#/hooks/query/use-onboarding-status", () => ({
useOnboardingStatus: () => mockUseOnboardingStatus(),
}));
const renderGuardAt = (initialEntry: string) => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const Stub = createRoutesStub([
{
path: "*",
Component: () => (
<OnboardingGuard>
<div data-testid="children" />
</OnboardingGuard>
),
},
]);
return render(
<QueryClientProvider client={queryClient}>
<Stub initialEntries={[initialEntry]} />
</QueryClientProvider>,
);
};
describe("OnboardingGuard returnTo preservation", () => {
beforeEach(() => {
mockNavigate.mockReset();
mockUseConfig.mockReturnValue({
data: { feature_flags: { enable_onboarding: true } },
});
mockUseOnboardingStatus.mockReturnValue({
data: { should_complete_onboarding: true },
isLoading: false,
});
});
it("preserves the originally requested path as a returnTo query parameter", async () => {
renderGuardAt("/conversations/abc-123");
await vi.waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith(
`/onboarding?returnTo=${encodeURIComponent("/conversations/abc-123")}`,
{ replace: true },
);
});
});
it("preserves search params alongside the path in returnTo", async () => {
renderGuardAt("/conversations/abc?foo=bar&baz=qux");
await vi.waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith(
`/onboarding?returnTo=${encodeURIComponent("/conversations/abc?foo=bar&baz=qux")}`,
{ replace: true },
);
});
});
it("does not double-encode pre-encoded characters in search params", async () => {
// ``search`` from useLocation() is already percent-encoded. Without
// decoding it first, ``encodeURIComponent`` would re-encode the ``%``
// as ``%25``, causing double-encoding (e.g. ``%20`` → ``%2520``).
// With the fix, ``%20`` in the original search is decoded to a space
// and then re-encoded once, yielding ``%20`` (not ``%2520``) in the
// ``returnTo`` parameter.
renderGuardAt("/conversations/abc?tab=user%20profile");
await vi.waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith(
`/onboarding?returnTo=${encodeURIComponent("/conversations/abc?tab=user profile")}`,
{ replace: true },
);
});
});
it("does not append a returnTo when the originally requested path is /", async () => {
renderGuardAt("/");
await vi.waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith("/onboarding", {
replace: true,
});
});
});
it("does not redirect when already on /onboarding", async () => {
renderGuardAt("/onboarding");
// Allow the effect to settle.
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
expect(mockNavigate).not.toHaveBeenCalled();
});
it("does not redirect when the enable_onboarding feature flag is off", async () => {
mockUseConfig.mockReturnValue({
data: { feature_flags: { enable_onboarding: false } },
});
renderGuardAt("/conversations/abc");
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
expect(mockNavigate).not.toHaveBeenCalled();
});
it("does not redirect when onboarding is already complete", async () => {
mockUseOnboardingStatus.mockReturnValue({
data: { should_complete_onboarding: false },
isLoading: false,
});
renderGuardAt("/conversations/abc");
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
expect(mockNavigate).not.toHaveBeenCalled();
});
});
@@ -5,7 +5,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nextProvider } from "react-i18next";
import i18n from "i18next";
import OnboardingForm, { clientLoader } from "#/routes/onboarding-form";
import OnboardingForm, {
clientLoader,
sanitizeReturnTo,
} from "#/routes/onboarding-form";
import AuthService from "#/api/auth-service/auth-service.api";
import { onboardingService } from "#/api/onboarding-service/onboarding-service.api";
@@ -14,7 +17,9 @@ const mockNavigate = vi.fn();
const mockUseMe = vi.fn();
// Loader data set in beforeEach for each test suite
let loaderData: { config: { app_mode: string; feature_flags: { deployment_mode: string } } };
let loaderData: {
config: { app_mode: string; feature_flags: { deployment_mode: string } };
};
vi.mock("react-router", async (importOriginal) => {
const original = await importOriginal<typeof import("react-router")>();
@@ -51,9 +56,7 @@ vi.mock("#/api/option-service/option-service.api", () => ({
},
}));
const renderOnboardingForm = async () => {
const renderOnboardingForm = async (initialEntry: string = "/") => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
@@ -69,7 +72,7 @@ const renderOnboardingForm = async () => {
const result = render(
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={queryClient}>
<RouterStub initialEntries={["/"]} />
<RouterStub initialEntries={[initialEntry]} />
</QueryClientProvider>
</I18nextProvider>,
);
@@ -177,13 +180,15 @@ describe("OnboardingForm - Cloud Mode", () => {
await user.click(screen.getByRole("button", { name: /finish/i }));
expect(mockMutate).toHaveBeenCalledTimes(1);
expect(mockMutate).toHaveBeenCalledWith({
selections: {
org_size: "org_2_10",
use_case: ["new_features"],
role: "software_engineer",
},
});
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({
selections: {
org_size: "org_2_10",
use_case: ["new_features"],
role: "software_engineer",
},
}),
);
});
it("should render 5 options on step 1 (org size question)", async () => {
@@ -214,13 +219,15 @@ describe("OnboardingForm - Cloud Mode", () => {
await user.click(screen.getByRole("button", { name: /finish/i }));
// Verify all selections were preserved
expect(mockMutate).toHaveBeenCalledWith({
selections: {
org_size: "solo",
use_case: ["fixing_bugs"],
role: "cto_founder",
},
});
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({
selections: {
org_size: "solo",
use_case: ["fixing_bugs"],
role: "cto_founder",
},
}),
);
});
it("should allow selecting multiple options on multi-select steps", async () => {
@@ -241,13 +248,15 @@ describe("OnboardingForm - Cloud Mode", () => {
await user.click(screen.getByTestId("step-option-software_engineer"));
await user.click(screen.getByRole("button", { name: /finish/i }));
expect(mockMutate).toHaveBeenCalledWith({
selections: {
org_size: "solo",
use_case: ["new_features", "fixing_bugs", "refactoring"],
role: "software_engineer",
},
});
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({
selections: {
org_size: "solo",
use_case: ["new_features", "fixing_bugs", "refactoring"],
role: "software_engineer",
},
}),
);
});
it("should allow deselecting options on multi-select steps", async () => {
@@ -269,13 +278,15 @@ describe("OnboardingForm - Cloud Mode", () => {
await user.click(screen.getByTestId("step-option-software_engineer"));
await user.click(screen.getByRole("button", { name: /finish/i }));
expect(mockMutate).toHaveBeenCalledWith({
selections: {
org_size: "solo",
use_case: ["fixing_bugs"],
role: "software_engineer",
},
});
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({
selections: {
org_size: "solo",
use_case: ["fixing_bugs"],
role: "software_engineer",
},
}),
);
});
it("should show all progress bars filled on the last step", async () => {
@@ -397,14 +408,16 @@ describe("OnboardingForm - Self-Hosted Mode", () => {
await user.click(screen.getByRole("button", { name: /finish/i }));
expect(mockMutate).toHaveBeenCalledTimes(1);
expect(mockMutate).toHaveBeenCalledWith({
selections: {
org_name: "Acme Corp",
org_domain: "acme.com",
org_size: "org_2_10",
use_case: ["new_features"],
},
});
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({
selections: {
org_name: "Acme Corp",
org_domain: "acme.com",
org_size: "org_2_10",
use_case: ["new_features"],
},
}),
);
});
it("should show all 3 progress bars filled on the last step", async () => {
@@ -446,7 +459,6 @@ describe("OnboardingForm - Self-Hosted Mode", () => {
const nextButton = screen.getByRole("button", { name: /next/i });
expect(nextButton).not.toBeDisabled();
});
});
describe("OnboardingForm - redirect when already onboarded", () => {
@@ -481,9 +493,98 @@ describe("OnboardingForm - redirect when already onboarded", () => {
expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true });
});
});
it("should restore the returnTo destination when onboarding is already complete", async () => {
// Regression: a stale ``/onboarding`` link must still respect
// a ``returnTo`` query param so post-login deep links survive.
vi.spyOn(onboardingService, "getStatus").mockResolvedValue({
should_complete_onboarding: false,
});
await renderOnboardingForm(
`/?returnTo=${encodeURIComponent("/conversations/abc?foo=bar")}`,
);
await vi.waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith("/conversations/abc?foo=bar", {
replace: true,
});
});
expect(mockNavigate).not.toHaveBeenCalledWith("/", { replace: true });
});
it("should reject absolute URL returnTo and redirect to / when onboarding is already complete", async () => {
// Security: a hand-crafted ``?returnTo=https://evil.example`` must
// never turn the component redirect into an open-redirect vector.
vi.spyOn(onboardingService, "getStatus").mockResolvedValue({
should_complete_onboarding: false,
});
await renderOnboardingForm(
`/?returnTo=${encodeURIComponent("https://evil.example.com/pwn")}`,
);
await vi.waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true });
});
});
it("should reject protocol-relative returnTo and redirect to / when onboarding is already complete", async () => {
// Security: protocol-relative URLs (``//evil.example.com``) are
// also open-redirect vectors and must be rejected.
vi.spyOn(onboardingService, "getStatus").mockResolvedValue({
should_complete_onboarding: false,
});
await renderOnboardingForm(
`/?returnTo=${encodeURIComponent("//evil.example.com/pwn")}`,
);
await vi.waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true });
});
});
it("should forward returnTo to submitOnboarding so the post-submit redirect respects it", async () => {
// Regression: ``OnboardingGuard`` saves the originally requested
// URL as ``?returnTo=...``. ``OnboardingForm`` must thread that
// value through the submit mutation so the post-submit fallback
// (when the server response has no ``redirect_url``) sends the
// user back to where they started.
const user = userEvent.setup();
await renderOnboardingForm(
`/?returnTo=${encodeURIComponent("/conversations/abc?foo=bar")}`,
);
// Step 1 - org size
await user.click(screen.getByTestId("step-option-solo"));
await user.click(screen.getByRole("button", { name: /next/i }));
// Step 2 - use case
await user.click(screen.getByTestId("step-option-new_features"));
await user.click(screen.getByRole("button", { name: /next/i }));
// Step 3 - role
await user.click(screen.getByTestId("step-option-software_engineer"));
await user.click(screen.getByRole("button", { name: /finish/i }));
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({
returnTo: "/conversations/abc?foo=bar",
}),
);
});
});
describe("onboarding-form clientLoader", () => {
// The loader takes a ``{ request }`` arg from react-router. Most of
// the existing tests don't care about the URL, so build a default
// request pointing at bare ``/onboarding`` and let returnTo-aware
// tests override it.
const makeArgs = (url = "https://app.example.com/onboarding") => ({
request: new Request(url),
});
beforeEach(() => {
mockQueryClientGetData.mockReset();
mockQueryClientSetData.mockReset();
@@ -498,7 +599,7 @@ describe("onboarding-form clientLoader", () => {
};
mockQueryClientGetData.mockReturnValue(saasConfig);
const result = await clientLoader();
const result = await clientLoader(makeArgs());
expect(result).toBeDefined();
expect((result as Response).status).toBe(302);
@@ -512,7 +613,7 @@ describe("onboarding-form clientLoader", () => {
};
mockQueryClientGetData.mockReturnValue(ossConfig);
const result = await clientLoader();
const result = await clientLoader(makeArgs());
expect(result).toBeDefined();
expect((result as Response).status).toBe(302);
@@ -526,7 +627,7 @@ describe("onboarding-form clientLoader", () => {
};
mockQueryClientGetData.mockReturnValue(undefinedConfig);
const result = await clientLoader();
const result = await clientLoader(makeArgs());
expect(result).toBeDefined();
expect((result as Response).status).toBe(302);
@@ -537,7 +638,7 @@ describe("onboarding-form clientLoader", () => {
mockQueryClientGetData.mockReturnValue(null);
mockGetConfig.mockResolvedValue(null);
const result = await clientLoader();
const result = await clientLoader(makeArgs());
expect(result).toBeDefined();
expect((result as Response).status).toBe(302);
@@ -551,7 +652,7 @@ describe("onboarding-form clientLoader", () => {
};
mockQueryClientGetData.mockReturnValue(saasCloudConfig);
const result = await clientLoader();
const result = await clientLoader(makeArgs());
expect(result).toEqual({ config: saasCloudConfig });
});
@@ -559,16 +660,116 @@ describe("onboarding-form clientLoader", () => {
it("should allow access and return config when app_mode is saas with self_hosted deployment and enable_onboarding is true", async () => {
const saasSelfHostedConfig = {
app_mode: "saas",
feature_flags: { deployment_mode: "self_hosted", enable_onboarding: true },
feature_flags: {
deployment_mode: "self_hosted",
enable_onboarding: true,
},
};
mockQueryClientGetData.mockReturnValue(saasSelfHostedConfig);
const result = await clientLoader();
const result = await clientLoader(makeArgs());
expect(result).toEqual({ config: saasSelfHostedConfig });
});
});
describe("returnTo handling on redirect", () => {
// The frontend can disagree with the backend about whether
// onboarding applies (e.g. the backend gates on
// ``DEPLOYMENT_MODE='cloud'`` while the frontend gates on
// ``feature_flags.enable_onboarding``). When the frontend
// redirects users away from /onboarding because the flag is
// off, it must still honor the ``?returnTo=`` query parameter
// so deep links survive the disagreement.
it("should honor returnTo when enable_onboarding is false", async () => {
const saasConfig = {
app_mode: "saas",
feature_flags: { deployment_mode: "cloud", enable_onboarding: false },
};
mockQueryClientGetData.mockReturnValue(saasConfig);
const result = await clientLoader(
makeArgs(
"https://app.example.com/onboarding?returnTo=%2Fsettings%2Fuser",
),
);
expect((result as Response).status).toBe(302);
expect((result as Response).headers.get("Location")).toBe(
"/settings/user",
);
});
it("should honor returnTo with query string when app_mode is oss", async () => {
const ossConfig = {
app_mode: "oss",
feature_flags: { deployment_mode: undefined, enable_onboarding: true },
};
mockQueryClientGetData.mockReturnValue(ossConfig);
const result = await clientLoader(
makeArgs(
"https://app.example.com/onboarding" +
"?returnTo=%2Fconversations%2Fabc%3Ffoo%3Dbar",
),
);
expect((result as Response).headers.get("Location")).toBe(
"/conversations/abc?foo=bar",
);
});
it("should reject absolute URL returnTo and fall back to /", async () => {
// Safety: never let a hand-crafted ``?returnTo=https://evil.example``
// turn the loader's redirect into an open-redirect vector.
const ossConfig = {
app_mode: "oss",
feature_flags: { deployment_mode: undefined, enable_onboarding: true },
};
mockQueryClientGetData.mockReturnValue(ossConfig);
const result = await clientLoader(
makeArgs(
"https://app.example.com/onboarding" +
"?returnTo=https%3A%2F%2Fevil.example.com%2Fpwn",
),
);
expect((result as Response).headers.get("Location")).toBe("/");
});
it("should reject protocol-relative returnTo and fall back to /", async () => {
const ossConfig = {
app_mode: "oss",
feature_flags: { deployment_mode: undefined, enable_onboarding: true },
};
mockQueryClientGetData.mockReturnValue(ossConfig);
const result = await clientLoader(
makeArgs(
"https://app.example.com/onboarding" +
"?returnTo=%2F%2Fevil.example.com%2Fpwn",
),
);
expect((result as Response).headers.get("Location")).toBe("/");
});
it("should fall back to / when returnTo is missing", async () => {
const ossConfig = {
app_mode: "oss",
feature_flags: { deployment_mode: undefined, enable_onboarding: true },
};
mockQueryClientGetData.mockReturnValue(ossConfig);
const result = await clientLoader(
makeArgs("https://app.example.com/onboarding"),
);
expect((result as Response).headers.get("Location")).toBe("/");
});
});
describe("config fetching", () => {
it("should use cached config from queryClient when available", async () => {
const cachedConfig = {
@@ -577,9 +778,11 @@ describe("onboarding-form clientLoader", () => {
};
mockQueryClientGetData.mockReturnValue(cachedConfig);
await clientLoader();
await clientLoader(makeArgs());
expect(mockQueryClientGetData).toHaveBeenCalledWith(["web-client-config"]);
expect(mockQueryClientGetData).toHaveBeenCalledWith([
"web-client-config",
]);
expect(mockGetConfig).not.toHaveBeenCalled();
});
@@ -591,7 +794,7 @@ describe("onboarding-form clientLoader", () => {
mockQueryClientGetData.mockReturnValue(null);
mockGetConfig.mockResolvedValue(fetchedConfig);
const result = await clientLoader();
const result = await clientLoader(makeArgs());
expect(mockGetConfig).toHaveBeenCalled();
expect(mockQueryClientSetData).toHaveBeenCalledWith(
@@ -602,3 +805,39 @@ describe("onboarding-form clientLoader", () => {
});
});
});
describe("sanitizeReturnTo", () => {
it("should return / for null", () => {
expect(sanitizeReturnTo(null)).toBe("/");
});
it("should return / for empty string", () => {
expect(sanitizeReturnTo("")).toBe("/");
});
it("should allow same-origin absolute paths", () => {
expect(sanitizeReturnTo("/conversations/abc")).toBe("/conversations/abc");
});
it("should allow paths with query strings", () => {
expect(sanitizeReturnTo("/conversations/abc?foo=bar")).toBe(
"/conversations/abc?foo=bar",
);
});
it("should prepend / to relative paths that lack one", () => {
expect(sanitizeReturnTo("conversations/abc")).toBe("/conversations/abc");
});
it("should reject http:// URLs and fall back to /", () => {
expect(sanitizeReturnTo("http://evil.example.com/pwn")).toBe("/");
});
it("should reject https:// URLs and fall back to /", () => {
expect(sanitizeReturnTo("https://evil.example.com/pwn")).toBe("/");
});
it("should reject protocol-relative URLs and fall back to /", () => {
expect(sanitizeReturnTo("//evil.example.com/pwn")).toBe("/");
});
});
@@ -6,12 +6,19 @@ import { useConfig } from "#/hooks/query/use-config";
/**
* Forces SaaS users with incomplete onboarding to /onboarding before they can
* access any protected route. Mirrors EmailVerificationGuard.
*
* The originally requested URL is preserved as a ``returnTo`` query
* parameter on ``/onboarding`` so it can be restored once the user
* completes the onboarding flow. Without this, post-login deep links
* (set up by ``root-layout.tsx`` as ``/login?returnTo=...``) get
* dropped at the onboarding interstitial and the user always lands
* back on ``/``.
*/
export function OnboardingGuard({ children }: { children: React.ReactNode }) {
const { data, isLoading } = useOnboardingStatus();
const { data: config } = useConfig();
const navigate = useNavigate();
const { pathname } = useLocation();
const { pathname, search } = useLocation();
React.useEffect(() => {
if (isLoading) return;
@@ -21,13 +28,33 @@ export function OnboardingGuard({ children }: { children: React.ReactNode }) {
data?.should_complete_onboarding &&
pathname !== "/onboarding"
) {
navigate("/onboarding", { replace: true });
// Preserve the user's originally requested URL (path + query)
// so OnboardingForm can restore it after the user finishes.
// Skip the trivial ``/`` case to keep the URL clean — that is
// already the default landing page after onboarding.
let destination = "/onboarding";
if (pathname !== "/") {
// Decode ``search`` first so already-encoded characters (e.g.
// ``%20``) are not double-encoded by ``encodeURIComponent``.
// The try/catch handles malformed percent-encoding sequences by
// falling back to the raw ``search`` string.
let decodedSearch: string;
try {
decodedSearch = search ? decodeURIComponent(search) : "";
} catch {
decodedSearch = search;
}
const returnTo = `${pathname}${decodedSearch}`;
destination = `/onboarding?returnTo=${encodeURIComponent(returnTo)}`;
}
navigate(destination, { replace: true });
}
}, [
config?.feature_flags?.enable_onboarding,
data?.should_complete_onboarding,
isLoading,
pathname,
search,
navigate,
]);
@@ -6,6 +6,13 @@ import { displayErrorToast } from "#/utils/custom-toast-handlers";
type SubmitOnboardingArgs = {
selections: Record<string, string | string[]>;
/**
* Fallback destination to navigate to when the server response does
* not include a ``redirect_url``. ``OnboardingForm`` passes the
* caller's ``returnTo`` here so deep links survive the onboarding
* interstitial. Defaults to ``/`` when omitted.
*/
returnTo?: string;
};
interface OnboardingResponse {
@@ -25,11 +32,11 @@ export const useSubmitOnboarding = () => {
);
return data;
},
onSuccess: (data) => {
onSuccess: (data, { returnTo }) => {
queryClient.invalidateQueries({ queryKey: SETTINGS_QUERY_KEYS.all });
queryClient.invalidateQueries({ queryKey: ["onboarding-status"] });
const finalRedirectUrl = data.redirect_url || "/";
const finalRedirectUrl = data.redirect_url || returnTo || "/";
// Check if the redirect URL is an external URL (starts with http or https)
if (
finalRedirectUrl.startsWith("http://") ||
+68 -6
View File
@@ -1,6 +1,11 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, redirect, useLoaderData } from "react-router";
import {
useNavigate,
redirect,
useLoaderData,
useSearchParams,
} from "react-router";
import StepHeader from "#/components/features/onboarding/step-header";
import { StepContent } from "#/components/features/onboarding/step-content";
import { BrandButton } from "#/components/features/settings/brand-button";
@@ -21,22 +26,68 @@ import {
import { queryClient } from "#/query-client-config";
import OptionService from "#/api/option-service/option-service.api";
export const clientLoader = async () => {
/**
* Sanitize a raw ``returnTo`` value, returning a safe same-origin path.
*
* Absolute URLs and protocol-relative URLs fall back to ``"/"`` to
* prevent open-redirect attacks. Relative paths that don't start with
* ``"/"`` are prepended with one.
*/
export function sanitizeReturnTo(raw: string | null): string {
if (!raw) return "/";
// Same-origin paths only — reject protocol-bearing or
// protocol-relative targets.
if (
raw.startsWith("http://") ||
raw.startsWith("https://") ||
raw.startsWith("//")
) {
return "/";
}
return raw.startsWith("/") ? raw : `/${raw}`;
}
/**
* Compute a safe redirect target from the URL's ``returnTo`` query
* parameter, defaulting to ``"/"``.
*
* This loader intentionally redirects same-origin only — absolute URLs
* are dropped to ``"/"`` rather than being followed, since the loader
* This loader intentionally redirects to same-origin paths only. Absolute
* URLs are silently dropped to "/" because a loader's `redirect()` performs
* a client-side navigation — following an external URL would break the SPA.
* URL here would break the SPA.
*/
function safeReturnToFromRequest(request: Request): string {
const url = new URL(request.url);
return sanitizeReturnTo(url.searchParams.get("returnTo"));
}
export const clientLoader = async ({ request }: { request: Request }) => {
let config = queryClient.getQueryData<WebClientConfig>(["web-client-config"]);
if (!config) {
config = await OptionService.getConfig();
queryClient.setQueryData<WebClientConfig>(["web-client-config"], config);
}
// The deployment's frontend may have ``enable_onboarding=false`` or
// a non-SaaS ``app_mode`` even when the backend OAuth callback has
// just sent the user here (the backend gates on ``DEPLOYMENT_MODE``,
// not on the frontend feature flag, so the two can disagree). When
// we redirect away in those cases, honor the user's deep-link
// ``?returnTo=`` so they don't lose their original destination at
// the onboarding interstitial.
const fallback = safeReturnToFromRequest(request);
// Check server feature flag to block access
if (!config?.feature_flags?.enable_onboarding) {
return redirect("/");
return redirect(fallback);
}
// Only allow access to onboarding for SaaS mode (cloud or self-hosted)
// OSS users should never reach /onboarding
if (config?.app_mode !== "saas") {
return redirect("/");
return redirect(fallback);
}
return { config };
@@ -85,6 +136,12 @@ function OnboardingForm() {
const navigate = useNavigate();
const loaderData = useLoaderData<typeof clientLoader>();
const config = loaderData?.config;
const [searchParams] = useSearchParams();
// ``OnboardingGuard`` forwards the user's originally requested URL
// here so we can restore it after they finish the form. Sanitize to
// prevent open-redirect attacks — absolute/protocol-relative URLs
// fall back to ``"/"``.
const returnTo = sanitizeReturnTo(searchParams.get("returnTo"));
const { data: onboardingStatus, isLoading: isOnboardingStatusLoading } =
useOnboardingStatus();
const { mutate: submitOnboarding } = useSubmitOnboarding();
@@ -92,12 +149,17 @@ function OnboardingForm() {
React.useEffect(() => {
if (isOnboardingStatusLoading) return;
if (onboardingStatus?.should_complete_onboarding === false) {
navigate("/", { replace: true });
// Honor returnTo if the user already completed onboarding so a
// stale ``/onboarding`` link still respects their deep-link
// destination. ``returnTo`` is already sanitized above so it is
// always a safe same-origin path.
navigate(returnTo, { replace: true });
}
}, [
onboardingStatus?.should_complete_onboarding,
isOnboardingStatusLoading,
navigate,
returnTo,
]);
const onboardingAppMode: OnboardingAppMode = getOnboardingAppMode(
@@ -181,7 +243,7 @@ function OnboardingForm() {
const handleNext = () => {
if (isLastStep) {
submitOnboarding({ selections: answers });
submitOnboarding({ selections: answers, returnTo });
} else {
setCurrentStepIndex((prev) => prev + 1);
}