From e8a60f5ebc3e3bbee7bf77e1b61d4b61c766ea93 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 20:48:31 +0000 Subject: [PATCH 1/2] fix(console): the sign-in page finishes the mobile sign-in hand-off instead of landing on a missing Console page A redirect naming the one-time-token hand-off route (exact path under the auth base the Console talks to, built from AuthEndpointPaths) is navigated to unprefixed by script from the sign-in page, before the organization steps. Every other redirect keeps withConsoleBase. On a hand-off redirect a session created more than five minutes ago keeps the sign-in form up; a sign-in on it makes a new session on top of the old one and forwards. RegisterPage does the same after a sign-up. Claude-Session: https://claude.ai/code/session_01WGufzfukSC9MVr7odALmfg Co-authored-by: Claude --- .changeset/29971-sign-in-mobile-handoff.md | 15 + ui/apps/console/src/pages/auth/LoginPage.tsx | 38 ++- .../console/src/pages/auth/RegisterPage.tsx | 28 +- .../__tests__/handoffRedirect-29971.test.ts | 137 +++++++++ .../__tests__/signInHandoff-29971.test.tsx | 263 ++++++++++++++++++ .../console/src/pages/auth/handoffRedirect.ts | 141 ++++++++++ 6 files changed, 615 insertions(+), 7 deletions(-) create mode 100644 .changeset/29971-sign-in-mobile-handoff.md create mode 100644 ui/apps/console/src/pages/auth/__tests__/handoffRedirect-29971.test.ts create mode 100644 ui/apps/console/src/pages/auth/__tests__/signInHandoff-29971.test.tsx create mode 100644 ui/apps/console/src/pages/auth/handoffRedirect.ts diff --git a/.changeset/29971-sign-in-mobile-handoff.md b/.changeset/29971-sign-in-mobile-handoff.md new file mode 100644 index 00000000000..c4678af1a07 --- /dev/null +++ b/.changeset/29971-sign-in-mobile-handoff.md @@ -0,0 +1,15 @@ +--- +'@object-ui/console': patch +--- + +fix(console): the sign-in page finishes the mobile sign-in hand-off instead of landing on a missing Console page + +Clause-②: no +Metadata-protocol: none + +The mobile app opens the Console's sign-in page in the system browser with `?redirect=` naming the hand-off route (`/api/v1/auth/one-time-token/handoff?...`), so that a user whose identity provider refuses embedded WebViews can still sign in. After sign-in the page sent every safe redirect through the Console mount, so the browser landed on `/_console/apps/.../api/v1/auth/one-time-token/handoff?...` and the page said it does not exist. Now: + +- A `redirect` whose path is exactly the hand-off route under the auth base the Console talks to is navigated to unprefixed, by script from the sign-in page. Every other redirect, `/api/v1/data/x` included, stays a Console route as before; a lookalike path, another base path or an absolute URL is never followed out of the mount. +- The hand-off comes before the organization steps: a user with several organizations is no longer sent to the picker first, and one with no active organization does not wait for it to be chosen. +- A session created more than five minutes ago, which the hand-off refuses, keeps the sign-in form on screen instead of forwarding to a JSON refusal. Signing in on that form makes a new session and forwards. +- The sign-up page does the same after a sign-up. diff --git a/ui/apps/console/src/pages/auth/LoginPage.tsx b/ui/apps/console/src/pages/auth/LoginPage.tsx index fe5e4423789..5339378f123 100644 --- a/ui/apps/console/src/pages/auth/LoginPage.tsx +++ b/ui/apps/console/src/pages/auth/LoginPage.tsx @@ -10,8 +10,9 @@ * in the query string (better-auth's oauth-provider redirects the * unauthenticated user here before issuing a code). * - Post-login orchestration: replay the original `/oauth2/authorize` - * request, auto-select the user's single organization, or honour a - * safe `?redirect=` target. + * request, forward to the mobile sign-in hand-off when `?redirect=` names + * it (#29971, `./handoffRedirect`), auto-select the user's single + * organization, or honour a safe `?redirect=` target. * - Offers the "Sign up" link only when the server would accept a sign-up * from this visitor: never under `emailPassword.disableSignUp === true`, * and under an audience posture closed to strangers (`invite_only`) only @@ -73,6 +74,7 @@ import { import { AuthLayout } from './AuthLayout'; import { AuthFooter, readLegalLinks } from './AuthFooter'; import { followOauthAuthorize } from './followAuthorize'; +import { useHandoffRedirect } from './handoffRedirect'; // Was module-private here; lifted to a shared module so `SetupPage` (whose // first-run exits went without it) and `RegisterPage` (which had copied it) // share ONE implementation — objectui#4181. Behaviour here is unchanged. @@ -103,6 +105,7 @@ export function LoginPage() { const redirect = params.get('redirect'); const { user, + session, isLoading, organizations, isOrganizationsLoading, @@ -110,6 +113,13 @@ export function LoginPage() { switchOrganization, getAuthConfig, } = useAuth(); + // #29971 — a `redirect` naming the mobile sign-in hand-off: where to go + // after sign-in, and whether the signed-in session is too old for it, in + // which case the form shows instead (see `./handoffRedirect`). + const handoff = useHandoffRedirect(redirect, { user, session, isLoading }); + // Nobody signed in, or a session the hand-off would refuse: either way this + // page is talking to someone who has to sign in. + const signInNeeded = !user || handoff.holdForSignIn; // The public auth config and where its read stands (objectui#11806): // `loading` until `getAuthConfig()` settles, `failed` once it rejected — the @@ -141,6 +151,8 @@ export function LoginPage() { // The OAuth hand-off fetch must fire at most once even though the post-login // effect re-runs as org state settles (it navigates away on success). const ssoHandoffStartedRef = useRef(false); + // The same once-only latch for the mobile sign-in hand-off navigation. + const mobileHandoffStartedRef = useRef(false); // `isLoading` from useAuth() is overloaded: it is true both during the // initial session check AND during every in-flight signIn. The full-page @@ -159,7 +171,7 @@ export function LoginPage() { // did not come from an invitation; see app-shell's `decideSignUpOffer`. const invitationRedirect = isInvitationRedirect(redirect); const bootstrap = useBootstrapStatus( - hasBootstrapped && !user && needsBootstrapProbe(authConfig, invitationRedirect), + hasBootstrapped && signInNeeded && needsBootstrapProbe(authConfig, invitationRedirect), ); const signUpOffer = decideSignUpOffer(authConfig, { invitationRedirect, bootstrap }); // #26473 — who invited this visitor, and to what; `null` keeps the generic @@ -249,7 +261,21 @@ export function LoginPage() { } } - // 2. Auto-select a single org so post-login redirect doesn't bounce + // 2. The mobile sign-in hand-off (#29971), before the organization steps: + // it carries the session and nothing else, and the app's WebView picks + // the organization after it. Navigated to unprefixed, by script from + // this page, so the route sees `Sec-Fetch-Site: same-origin`. A session + // the route would refuse for its age keeps the form up instead. + if (handoff.target) { + if (handoff.holdForSignIn) return; + if (!mobileHandoffStartedRef.current) { + mobileHandoffStartedRef.current = true; + window.location.assign(handoff.target); + } + return; + } + + // 3. Auto-select a single org so post-login redirect doesn't bounce // off `RequireOrganization`. if (!activeOrganization) { if (isOrganizationsLoading || autoSelectingOrg) return; @@ -282,9 +308,11 @@ export function LoginPage() { autoSelectingOrg, redirect, switchOrganization, + handoff.target, + handoff.holdForSignIn, ]); - if ((isLoading && !hasBootstrapped) || user) { + if ((isLoading && !hasBootstrapped) || !signInNeeded) { return (
diff --git a/ui/apps/console/src/pages/auth/RegisterPage.tsx b/ui/apps/console/src/pages/auth/RegisterPage.tsx index a98ef426764..add7da81e90 100644 --- a/ui/apps/console/src/pages/auth/RegisterPage.tsx +++ b/ui/apps/console/src/pages/auth/RegisterPage.tsx @@ -23,6 +23,8 @@ * verification mail's link so it survives the inbox (objectui#10893). * - Replays an `/oauth2/authorize` query string when the user landed * here mid-SSO so the IdP can continue the flow post-signup. + * - Forwards to the mobile sign-in hand-off when `?redirect=` names it, + * exactly as `/login` does (#29971, `./handoffRedirect`). * - Draws `/login`'s footer under the form — the locale switch and the * privacy / terms links the server's auth config serves, from the same * reader (#25885): sign-up is where a visitor accepts the terms. @@ -52,6 +54,7 @@ import { } from '@object-ui/app-shell'; import { AuthLayout } from './AuthLayout'; import { followOauthAuthorize } from './followAuthorize'; +import { useHandoffRedirect } from './handoffRedirect'; // Was a second module-private copy of LoginPage's helper; both now share one // implementation — objectui#4181. Behaviour here is unchanged. import { withConsoleBase, withConsoleBaseRootRelative } from '../../utils/consoleBase'; @@ -77,6 +80,7 @@ export function RegisterPage() { const redirect = params.get('redirect'); const { user, + session, isLoading, organizations, isOrganizationsLoading, @@ -84,6 +88,11 @@ export function RegisterPage() { switchOrganization, getAuthConfig, } = useAuth(); + // #29971 — the mobile sign-in hand-off, as on `/login`: a sign-up from the + // system auth session ends there too, and a session too old for it keeps + // the form up (see `./handoffRedirect`). + const handoff = useHandoffRedirect(redirect, { user, session, isLoading }); + const signUpNeeded = !user || handoff.holdForSignIn; // The public auth config and where its read stands (objectui#11806), as on // `/login`: `loading` until `getAuthConfig()` settles, `failed` once it @@ -105,6 +114,8 @@ export function RegisterPage() { const [autoSelectingOrg, setAutoSelectingOrg] = useState(false); // Fire the OAuth hand-off fetch at most once (see LoginPage). const ssoHandoffStartedRef = useRef(false); + // And the mobile sign-in hand-off navigation. + const mobileHandoffStartedRef = useRef(false); // See LoginPage: `isLoading` is overloaded (initial session check + every // in-flight signUp). Latch once the first check resolves so a failed @@ -143,7 +154,7 @@ export function RegisterPage() { const bootstrap = useBootstrapStatus( configRead === 'known' && hasBootstrapped && - !user && + signUpNeeded && needsBootstrapProbe(authConfig, invitationRedirect), ); const signUpOffer = decideSignUpOffer(authConfig, { invitationRedirect, bootstrap }); @@ -176,6 +187,17 @@ export function RegisterPage() { } } + // #29971 — the mobile sign-in hand-off, before the organization steps, + // by script from this page (see LoginPage). + if (handoff.target) { + if (handoff.holdForSignIn) return; + if (!mobileHandoffStartedRef.current) { + mobileHandoffStartedRef.current = true; + window.location.assign(handoff.target); + } + return; + } + if (!activeOrganization) { if (isOrganizationsLoading || autoSelectingOrg) return; if (organizations.length === 1) { @@ -203,11 +225,13 @@ export function RegisterPage() { autoSelectingOrg, redirect, switchOrganization, + handoff.target, + handoff.holdForSignIn, ]); // The first session check has answered and nobody is signed in: the page is // talking to a visitor, so a failed config read is theirs to see. - const awaitingSession = (isLoading && !hasBootstrapped) || !!user; + const awaitingSession = (isLoading && !hasBootstrapped) || !signUpNeeded; if (!awaitingSession && serverUnreachable) { return ( diff --git a/ui/apps/console/src/pages/auth/__tests__/handoffRedirect-29971.test.ts b/ui/apps/console/src/pages/auth/__tests__/handoffRedirect-29971.test.ts new file mode 100644 index 00000000000..2cfe7fc31fa --- /dev/null +++ b/ui/apps/console/src/pages/auth/__tests__/handoffRedirect-29971.test.ts @@ -0,0 +1,137 @@ +/** + * The mobile sign-in hand-off as a sign-in redirect target (#29971): which + * `?redirect=` values name the hand-off route, and which sessions it carries. + * The sign-in pages' behaviour is pinned in `authExitBasename.test.tsx`. + */ + +import { describe, it, expect } from 'vitest'; +import { AuthEndpointPaths } from '@objectstack/spec/api'; +import { + HANDOFF_SESSION_MAX_AGE_MS, + handoffNavigationTarget, + isHandoffSessionFresh, + sessionCreatedAtMs, +} from '../handoffRedirect'; + +const ORIGIN = 'https://os.example.com'; +const AUTH_BASE = '/api/v1/auth'; +const HANDOFF_PATH = '/api/v1/auth/one-time-token/handoff'; +const QUERY = + '?redirect_uri=ai.objectos.mobile%3A%2F%2Fauth%2Fcallback' + + '&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256'; + +const target = (redirect: string | null, authBase = AUTH_BASE) => + handoffNavigationTarget(redirect, { origin: ORIGIN, authBase }); + +describe('handoffNavigationTarget — the hand-off route and nothing wider', () => { + it('is the route the spec names under the auth base', () => { + expect(`${AUTH_BASE}${AuthEndpointPaths.oneTimeTokenHandoff}`).toBe(HANDOFF_PATH); + }); + + it('answers the hand-off path with its query, unprefixed', () => { + expect(target(`${HANDOFF_PATH}${QUERY}`)).toBe(`${HANDOFF_PATH}${QUERY}`); + expect(target(HANDOFF_PATH)).toBe(HANDOFF_PATH); + }); + + it('CONTROL: an ordinary API path is not the hand-off (it stays a Console route)', () => { + expect(target('/api/v1/data/x')).toBeNull(); + expect(target('/settings')).toBeNull(); + expect(target('/')).toBeNull(); + }); + + it('refuses lookalike paths', () => { + for (const lookalike of [ + '/api/v1/auth/one-time-token/handoffX', + `/api/v1/auth/one-time-token/handoffX${QUERY}`, + '/api/v1/auth/one-time-token/handoff/', + '/api/v1/auth/one-time-token/handoff/x', + '/api/v1/auth/one-time-token/verify', + '/API/v1/auth/one-time-token/handoff', + '/api/v1/auth/one-time-token%2Fhandoff', + '/_console/api/v1/auth/one-time-token/handoff', + ]) { + expect(target(lookalike), lookalike).toBeNull(); + } + }); + + it('refuses the hand-off under a different base path', () => { + // A Console whose auth base carries a prefix: only that prefix's hand-off. + expect(target(`${HANDOFF_PATH}${QUERY}`, '/tenant-a/api/v1/auth')).toBeNull(); + expect(target(`/tenant-a${HANDOFF_PATH}${QUERY}`, '/tenant-a/api/v1/auth')).toBe( + `/tenant-a${HANDOFF_PATH}${QUERY}`, + ); + // And the other way round. + expect(target(`/tenant-a${HANDOFF_PATH}`)).toBeNull(); + }); + + it('refuses an absolute, protocol-relative or backslash-smuggled URL', () => { + for (const offsite of [ + `https://evil.example${HANDOFF_PATH}${QUERY}`, + `${ORIGIN}${HANDOFF_PATH}${QUERY}`, + `//evil.example${HANDOFF_PATH}${QUERY}`, + `/\\evil.example${HANDOFF_PATH}${QUERY}`, + `/\t/evil.example${HANDOFF_PATH}`, + `javascript:alert(1)//${HANDOFF_PATH}`, + ]) { + expect(target(offsite), offsite).toBeNull(); + } + }); + + it('refuses when the auth base is on another origin (the route is not this page's to reach)', () => { + expect(target(`${HANDOFF_PATH}${QUERY}`, 'https://api.example.com/api/v1/auth')).toBeNull(); + }); + + it('answers null for no redirect or a relative one', () => { + expect(target(null)).toBeNull(); + expect(target('')).toBeNull(); + expect(target('api/v1/auth/one-time-token/handoff')).toBeNull(); + }); + + it('answers the parsed URL, so what is navigated to is what was checked', () => { + expect(target(`/api/v1/data/../auth/one-time-token/handoff${QUERY}`)).toBe( + `${HANDOFF_PATH}${QUERY}`, + ); + expect(target(`${HANDOFF_PATH}${QUERY}#fragment`)).toBe(`${HANDOFF_PATH}${QUERY}`); + }); +}); + +describe('isHandoffSessionFresh — the five-minute window', () => { + const now = Date.parse('2026-11-03T12:00:00Z'); + const ago = (ms: number) => new Date(now - ms); + const arrival = { session: null }; + + it('carries a session created within five minutes', () => { + expect(isHandoffSessionFresh({ createdAt: ago(4 * 60_000) }, { arrival, now })).toBe(true); + expect(isHandoffSessionFresh({ createdAt: ago(HANDOFF_SESSION_MAX_AGE_MS) }, { arrival, now })).toBe(true); + }); + + it('refuses a session created more than five minutes ago', () => { + expect(isHandoffSessionFresh({ createdAt: ago(6 * 60_000) }, { arrival, now })).toBe(false); + expect(isHandoffSessionFresh({ createdAt: ago(7 * 24 * 3_600_000) }, { arrival, now })).toBe(false); + }); + + it('reads createdAt as a Date or as the ISO string a raw fetch leaves', () => { + const iso = ago(6 * 60_000).toISOString(); + expect(sessionCreatedAtMs({ createdAt: iso })).toBe(now - 6 * 60_000); + expect(isHandoffSessionFresh({ createdAt: iso }, { arrival, now })).toBe(false); + }); + + it('a session without createdAt is fresh only when the page did not find it on arrival', () => { + const found = { token: 't0' }; + // Found on arrival: treated as too old. + expect(isHandoffSessionFresh(found, { arrival: { session: found }, now })).toBe(false); + // Before the first session check answered: not judged fresh either. + expect(isHandoffSessionFresh(found, { arrival: null, now })).toBe(false); + // Made by a sign-in on the page (better-auth's sign-in answer has no + // session record, so the provider holds `undefined` or `{ token }`). + expect(isHandoffSessionFresh(undefined, { arrival: { session: null }, now })).toBe(true); + expect(isHandoffSessionFresh({ token: 't1' }, { arrival: { session: found }, now })).toBe(true); + }); + + it('an unreadable createdAt counts as none', () => { + expect(sessionCreatedAtMs({ createdAt: 'not a date' })).toBeNull(); + expect(sessionCreatedAtMs({ createdAt: new Date(Number.NaN) })).toBeNull(); + const found = { createdAt: 'not a date' }; + expect(isHandoffSessionFresh(found, { arrival: { session: found }, now })).toBe(false); + }); +}); diff --git a/ui/apps/console/src/pages/auth/__tests__/signInHandoff-29971.test.tsx b/ui/apps/console/src/pages/auth/__tests__/signInHandoff-29971.test.tsx new file mode 100644 index 00000000000..76c99dab105 --- /dev/null +++ b/ui/apps/console/src/pages/auth/__tests__/signInHandoff-29971.test.tsx @@ -0,0 +1,263 @@ +/** + * The sign-in pages finish the mobile sign-in hand-off (#29971). + * + * The mobile shell opens `/login?redirect=` naming the hand-off route in the + * system auth session; after sign-in the page must navigate to that API route, + * which `withConsoleBase` used to turn into a Console route that does not + * exist. One pin per rule of `../handoffRedirect`'s header: + * + * 1. the hand-off is navigated to unprefixed, and every other redirect keeps + * today's Console-route behaviour (CONTROL `/api/v1/data/x`, a lookalike, + * an absolute URL); + * 2. a provider sign-in returns to this page, never to the hand-off; + * 3. the hand-off comes before the organization steps; + * 4. a session older than five minutes shows the form, and a sign-in on it + * forwards. + * + * The real `AuthProvider` runs over a mock client, so a sign-in reaches the + * page the way it does in the browser: better-auth's sign-in answer carries no + * session record, and the provider holds what the client hands back. + * + * The Console is mounted as the shipped embeddable build is (``, `BASE_URL` `./`), so "outside the mount" and "a Console + * route" are told apart by where the browser would land. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { AuthProvider } from '@object-ui/auth'; +import type { AuthClient } from '@object-ui/auth'; +import { LoginPage } from '../LoginPage'; +import { RegisterPage } from '../RegisterPage'; + +const HANDOFF = + '/api/v1/auth/one-time-token/handoff?redirect_uri=ai.objectos.mobile%3A%2F%2Fauth%2Fcallback' + + '&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256'; +const HANDOFF_PATH = '/api/v1/auth/one-time-token/handoff'; + +const USER = { id: 'u1', name: 'Ada', email: 'ada@example.com' }; +const minutesAgo = (n: number) => new Date(Date.now() - n * 60_000); + +let baseEl: HTMLBaseElement | null = null; +let assign: ReturnType; + +beforeEach(() => { + window.localStorage.clear(); + baseEl = document.createElement('base'); + baseEl.setAttribute('href', '/_console/'); + document.head.appendChild(baseEl); + vi.stubEnv('BASE_URL', './'); + assign = vi.fn(); + vi.spyOn(window.location, 'assign').mockImplementation(assign as never); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + cleanup(); + baseEl?.remove(); + baseEl = null; + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +interface ClientOptions { + /** What `get-session` answers on arrival: `null` = signed out. */ + arrival?: { createdAt?: Date } | null; + config?: Record; + organizations?: Array<{ id: string; name: string; slug: string }>; +} + +function createMockClient({ arrival = null, config = {}, organizations }: ClientOptions = {}) { + const client = { + getSession: vi.fn().mockResolvedValue( + arrival ? { user: USER, session: { token: 'arrival-token', ...arrival } } : null, + ), + getConfig: vi.fn().mockResolvedValue(config), + // better-auth's `/sign-in/email` answers `{ redirect, token, url, user }`: + // the client hands back no session record (`session: undefined`). + signIn: vi.fn().mockResolvedValue({ user: USER, session: undefined }), + signUp: vi.fn().mockResolvedValue({ user: USER, session: undefined, requiresVerification: false }), + signInWithProvider: vi.fn().mockResolvedValue(undefined), + ...(organizations + ? { + listOrganizations: vi.fn().mockResolvedValue(organizations), + getActiveOrganization: vi.fn().mockResolvedValue(null), + setActiveOrganization: vi.fn().mockResolvedValue(undefined), + } + : {}), + }; + return client as typeof client & AuthClient; +} + +function renderPage(page: 'login' | 'register', client: AuthClient, redirect?: string) { + const search = redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''; + window.history.replaceState({}, '', `/_console/${page}${search}`); + return render( + + + {page === 'login' ? : } + + , + ); +} + +/** The single full-page navigation the page performed. */ +async function exitTarget(): Promise { + await waitFor(() => expect(assign).toHaveBeenCalled()); + expect(assign).toHaveBeenCalledTimes(1); + return assign.mock.calls[0][0] as string; +} + +/** Where that navigation lands, per the browser's own resolution. */ +const lands = (target: string) => new URL(target, document.baseURI).pathname; + +async function signInOnTheForm() { + const user = userEvent.setup(); + await user.type(await screen.findByLabelText('Email'), USER.email); + await user.type(screen.getByLabelText('Password'), 'hunter2hunter2'); + await user.click(screen.getByRole('button', { name: 'Sign In' })); +} + +describe('LoginPage — rule 1: the hand-off route, and nothing wider', () => { + it('navigates to the hand-off unprefixed, outside the Console mount', async () => { + renderPage('login', createMockClient({ arrival: { createdAt: minutesAgo(1) } }), HANDOFF); + const target = await exitTarget(); + expect(target).toBe(HANDOFF); + expect(lands(target)).toBe(HANDOFF_PATH); + }); + + it('CONTROL: /api/v1/data/x stays a Console route, as it is today', async () => { + renderPage('login', createMockClient({ arrival: { createdAt: minutesAgo(1) } }), '/api/v1/data/x'); + expect(lands(await exitTarget())).toBe('/_console/api/v1/data/x'); + }); + + it('a lookalike path stays a Console route', async () => { + renderPage( + 'login', + createMockClient({ arrival: { createdAt: minutesAgo(1) } }), + `${HANDOFF_PATH}X?redirect_uri=x`, + ); + expect(lands(await exitTarget())).toBe(`/_console${HANDOFF_PATH}X`); + }); + + it('an absolute URL naming the hand-off is not followed', async () => { + renderPage( + 'login', + createMockClient({ arrival: { createdAt: minutesAgo(1) } }), + `https://evil.example${HANDOFF}`, + ); + const target = await exitTarget(); + expect(lands(target)).toBe('/_console/'); + expect(target).not.toContain('evil.example'); + }); +}); + +describe('LoginPage — rule 2: by script from this page, never as a provider callbackURL', () => { + it('a provider sign-in returns to this sign-in page, which still carries the hand-off', async () => { + const client = createMockClient({ + config: { socialProviders: [{ id: 'google', name: 'Google', enabled: true, type: 'social' }] }, + }); + renderPage('login', client, HANDOFF); + await userEvent.setup().click(await screen.findByRole('button', { name: 'Continue with Google' })); + + await waitFor(() => expect(client.signInWithProvider).toHaveBeenCalledTimes(1)); + const [, options] = client.signInWithProvider.mock.calls[0] as [string, { callbackURL: string; errorCallbackURL: string }]; + for (const url of [options.callbackURL, options.errorCallbackURL]) { + const callback = new URL(url); + expect(callback.pathname).toBe('/_console/login'); + expect(callback.searchParams.get('redirect')).toBe(HANDOFF); + } + expect(assign).not.toHaveBeenCalled(); + }); +}); + +describe('LoginPage — rule 3: before the organization steps', () => { + it('several organizations and none active: the hand-off, not the /organizations picker', async () => { + const client = createMockClient({ + arrival: { createdAt: minutesAgo(1) }, + organizations: [ + { id: 'org_1', name: 'One', slug: 'one' }, + { id: 'org_2', name: 'Two', slug: 'two' }, + ], + }); + renderPage('login', client, HANDOFF); + expect(await exitTarget()).toBe(HANDOFF); + }); + + it('a single organization and none active: no switchOrganization wait', async () => { + const client = createMockClient({ + arrival: { createdAt: minutesAgo(1) }, + organizations: [{ id: 'org_1', name: 'One', slug: 'one' }], + }); + renderPage('login', client, HANDOFF); + expect(await exitTarget()).toBe(HANDOFF); + expect(client.setActiveOrganization).not.toHaveBeenCalled(); + }); +}); + +describe('LoginPage — rule 4: a session older than five minutes', () => { + it('shows the sign-in form instead of forwarding', async () => { + renderPage('login', createMockClient({ arrival: { createdAt: minutesAgo(6) } }), HANDOFF); + expect(await screen.findByLabelText('Email')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Sign In' })).toBeTruthy(); + expect(assign).not.toHaveBeenCalled(); + }); + + it('a sign-in on that form makes a new session and forwards to the hand-off', async () => { + const client = createMockClient({ arrival: { createdAt: minutesAgo(6) } }); + renderPage('login', client, HANDOFF); + await signInOnTheForm(); + + expect(client.signIn).toHaveBeenCalledWith({ email: USER.email, password: 'hunter2hunter2' }); + expect(await exitTarget()).toBe(HANDOFF); + }); + + it('a session found on arrival without createdAt is held to the form too', async () => { + renderPage('login', createMockClient({ arrival: {} }), HANDOFF); + expect(await screen.findByLabelText('Email')).toBeTruthy(); + expect(assign).not.toHaveBeenCalled(); + }); + + it('a visitor who arrives signed out is forwarded once signed in', async () => { + const client = createMockClient(); + renderPage('login', client, HANDOFF); + await signInOnTheForm(); + expect(await exitTarget()).toBe(HANDOFF); + }); + + it('CONTROL: the window applies only to the hand-off; an old session on an ordinary redirect forwards', async () => { + renderPage('login', createMockClient({ arrival: { createdAt: minutesAgo(60) } }), '/settings'); + expect(lands(await exitTarget())).toBe('/_console/settings'); + }); +}); + +describe('RegisterPage — the same hand-off after a sign-up', () => { + it('navigates to the hand-off unprefixed for a fresh session', async () => { + renderPage('register', createMockClient({ arrival: { createdAt: minutesAgo(1) } }), HANDOFF); + const target = await exitTarget(); + expect(target).toBe(HANDOFF); + expect(lands(target)).toBe(HANDOFF_PATH); + }); + + it('CONTROL: /api/v1/data/x stays a Console route', async () => { + renderPage('register', createMockClient({ arrival: { createdAt: minutesAgo(1) } }), '/api/v1/data/x'); + expect(lands(await exitTarget())).toBe('/_console/api/v1/data/x'); + }); + + it('a session older than five minutes shows the sign-up form, and a sign-up on it forwards', async () => { + const client = createMockClient({ arrival: { createdAt: minutesAgo(6) } }); + renderPage('register', client, HANDOFF); + const user = userEvent.setup(); + await user.type(await screen.findByLabelText('Name'), USER.name); + expect(assign).not.toHaveBeenCalled(); + await user.type(screen.getByLabelText('Email'), USER.email); + await user.type(screen.getByLabelText('Password'), 'hunter2hunter2'); + await user.type(screen.getByLabelText('Confirm Password'), 'hunter2hunter2'); + await user.click(screen.getByRole('button', { name: 'Create Account' })); + + await waitFor(() => expect(client.signUp).toHaveBeenCalledTimes(1)); + expect(await exitTarget()).toBe(HANDOFF); + }); +}); diff --git a/ui/apps/console/src/pages/auth/handoffRedirect.ts b/ui/apps/console/src/pages/auth/handoffRedirect.ts new file mode 100644 index 00000000000..39805c7b780 --- /dev/null +++ b/ui/apps/console/src/pages/auth/handoffRedirect.ts @@ -0,0 +1,141 @@ +/** + * The mobile sign-in hand-off as a sign-in redirect target (#29971). + * + * The mobile shell opens this Console's sign-in page in the platform's system + * auth session with `?redirect=` naming the hand-off route + * (`GET {auth base}/one-time-token/handoff?redirect_uri=…&code_challenge=…`), + * which answers `302 SCHEME://…?token=T` for the signed-in session. That route + * is an API route OUTSIDE the Console mount, so the ordinary post-sign-in + * navigation (`withConsoleBase`, which treats every redirect as a Console + * route) lands on a Console page that does not exist. + * + * The route's own rules (plugin-auth `one-time-token-handoff.ts`, maintainer + * ruling A on the server card) decide what the sign-in pages do with it: + * + * 1. **That route, and nothing wider.** A redirect whose path is exactly the + * hand-off under the auth base this Console talks to is navigated to + * unprefixed ({@link handoffNavigationTarget}); every other redirect stays + * a Console route. This is not a general way out of the Console mount. + * 2. **By script, from the sign-in page.** The route refuses + * `Sec-Fetch-Site: cross-site`, which a provider's redirect chain reports, + * so the hand-off is never handed to a provider as its `callbackURL`: the + * provider returns to the sign-in page, which then calls `location.assign` + * (`same-origin`). + * 3. **Before the organization steps.** The hand-off carries the session and + * nothing else; the app's WebView picks the organization after it. + * 4. **Only a fresh session.** The route refuses a session created more than + * five minutes ago with a raw JSON `401`; on a hand-off redirect the page + * shows its sign-in form for such a session instead of forwarding, and a + * sign-in on that form makes a new session on top of the old one + * ({@link useHandoffRedirect}). + */ + +import { useLayoutEffect, useState } from 'react'; +import { getAuthEndpointUrl } from '@objectstack/spec/api'; + +/** The auth API base this Console talks to: the same formula `App.tsx` hands `AuthProvider`. */ +const AUTH_BASE = `${import.meta.env.VITE_SERVER_URL || ''}/api/v1/auth`; + +/** + * How old a session may be for the hand-off to carry it: the hand-off route's + * `ONE_TIME_TOKEN_HANDOFF_SESSION_MAX_AGE_SECONDS` (plugin-auth), restated + * here because that module is server-only. The route is the authority; this + * copy only decides whether the page forwards or shows its form. + */ +export const HANDOFF_SESSION_MAX_AGE_MS = 5 * 60 * 1000; + +/** + * The navigation target for a hand-off redirect, or `null` when `redirect` is + * anything else (which then stays a Console route). + * + * `redirect` must be a root-relative path on this page's origin whose path is + * EXACTLY the hand-off route under `authBase`: a lookalike path, a different + * base path, an absolute or protocol-relative URL, or an auth base on another + * origin never matches. The answer is re-serialized from the parsed URL, so + * what the page navigates to is what was checked. + */ +export function handoffNavigationTarget( + redirect: string | null, + { + origin = typeof window === 'undefined' ? '' : window.location.origin, + authBase = AUTH_BASE, + }: { origin?: string; authBase?: string } = {}, +): string | null { + if (!redirect || !origin || !redirect.startsWith('/') || redirect.startsWith('//')) return null; + let target: URL; + let route: URL; + try { + target = new URL(redirect, origin); + route = new URL(getAuthEndpointUrl(authBase, 'oneTimeTokenHandoff'), origin); + } catch { + return null; + } + if (target.origin !== origin || route.origin !== origin) return null; + if (target.pathname !== route.pathname) return null; + return `${target.pathname}${target.search}`; +} + +/** + * The session's creation time in epoch ms, or `null` when it carries none. + * + * `get-session` answers carry the server's `createdAt` (a `Date` through + * better-auth's client parser, an ISO string through a raw `fetch`); a + * sign-in answer carries no session record at all. + */ +export function sessionCreatedAtMs(session: unknown): number | null { + if (!session || typeof session !== 'object') return null; + const raw = (session as { createdAt?: unknown }).createdAt; + if (!(raw instanceof Date) && typeof raw !== 'string' && typeof raw !== 'number') return null; + const ms = new Date(raw).getTime(); + return Number.isNaN(ms) ? null : ms; +} + +/** + * Whether the hand-off will carry `session`. + * + * With a `createdAt`, its age decides. Without one, the session is fresh only + * when it is not the one the page found on arrival: it came from a sign-in on + * this page, which the server has just made. A session found on arrival with + * no `createdAt` is treated as too old, so the visitor signs in again rather + * than being forwarded to a refusal. + */ +export function isHandoffSessionFresh( + session: unknown, + { arrival, now }: { arrival: { session: unknown } | null; now: number }, +): boolean { + const createdAt = sessionCreatedAtMs(session); + if (createdAt !== null) return now - createdAt <= HANDOFF_SESSION_MAX_AGE_MS; + return arrival !== null && session !== arrival.session; +} + +/** What a sign-in page does with its `?redirect=` when it names the hand-off. */ +export interface HandoffRedirect { + /** Where to navigate after sign-in, unprefixed; `null` when the redirect is not the hand-off. */ + target: string | null; + /** + * The hand-off would refuse the signed-in session (rule 4): the page shows + * its sign-in form instead of forwarding. Always `false` when nobody is + * signed in or `target` is `null`. + */ + holdForSignIn: boolean; +} + +/** + * The hand-off decision for a sign-in page, from its `?redirect=` and the auth + * state. Remembers the session the first session check answered, which is + * what tells a session found on arrival from one a sign-in on the page made. + */ +export function useHandoffRedirect( + redirect: string | null, + { user, session, isLoading }: { user: unknown; session: unknown; isLoading: boolean }, +): HandoffRedirect { + const [arrival, setArrival] = useState<{ session: unknown } | null>(null); + useLayoutEffect(() => { + if (!isLoading && arrival === null) setArrival({ session }); + }, [isLoading, arrival, session]); + + const target = handoffNavigationTarget(redirect); + const holdForSignIn = + target !== null && !!user && !isHandoffSessionFresh(session, { arrival, now: Date.now() }); + return { target, holdForSignIn }; +} From c6ac92e178ae6d07c871b307db8fb2f708933180 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 20:55:40 +0000 Subject: [PATCH 2/2] fix(console): judge the hand-off session in a layout effect, not during render The five-minute verdict reads the clock in a layout effect (react-hooks/purity refuses Date.now during render) and holds the form until a session is judged. The single-organization pin now holds the activation unanswered, since the auth provider activates a single organization itself. Claude-Session: https://claude.ai/code/session_01WGufzfukSC9MVr7odALmfg Co-authored-by: Claude --- .../__tests__/handoffRedirect-29971.test.ts | 2 +- .../__tests__/signInHandoff-29971.test.tsx | 7 ++-- .../console/src/pages/auth/handoffRedirect.ts | 34 ++++++++++++++----- 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/ui/apps/console/src/pages/auth/__tests__/handoffRedirect-29971.test.ts b/ui/apps/console/src/pages/auth/__tests__/handoffRedirect-29971.test.ts index 2cfe7fc31fa..4863d56503d 100644 --- a/ui/apps/console/src/pages/auth/__tests__/handoffRedirect-29971.test.ts +++ b/ui/apps/console/src/pages/auth/__tests__/handoffRedirect-29971.test.ts @@ -77,7 +77,7 @@ describe('handoffNavigationTarget — the hand-off route and nothing wider', () } }); - it('refuses when the auth base is on another origin (the route is not this page's to reach)', () => { + it('refuses when the auth base is on another origin: that route is not reachable from this page', () => { expect(target(`${HANDOFF_PATH}${QUERY}`, 'https://api.example.com/api/v1/auth')).toBeNull(); }); diff --git a/ui/apps/console/src/pages/auth/__tests__/signInHandoff-29971.test.tsx b/ui/apps/console/src/pages/auth/__tests__/signInHandoff-29971.test.tsx index 76c99dab105..455d6919ebb 100644 --- a/ui/apps/console/src/pages/auth/__tests__/signInHandoff-29971.test.tsx +++ b/ui/apps/console/src/pages/auth/__tests__/signInHandoff-29971.test.tsx @@ -186,14 +186,17 @@ describe('LoginPage — rule 3: before the organization steps', () => { expect(await exitTarget()).toBe(HANDOFF); }); - it('a single organization and none active: no switchOrganization wait', async () => { + it('a single organization and none active: no wait for it to become active', async () => { const client = createMockClient({ arrival: { createdAt: minutesAgo(1) }, organizations: [{ id: 'org_1', name: 'One', slug: 'one' }], }); + // Activating the organization never answers: a page that waited on it + // (the provider's own activation or the page's switchOrganization) would + // never navigate. + client.setActiveOrganization.mockImplementation(() => new Promise(() => {})); renderPage('login', client, HANDOFF); expect(await exitTarget()).toBe(HANDOFF); - expect(client.setActiveOrganization).not.toHaveBeenCalled(); }); }); diff --git a/ui/apps/console/src/pages/auth/handoffRedirect.ts b/ui/apps/console/src/pages/auth/handoffRedirect.ts index 39805c7b780..b36e888d61d 100644 --- a/ui/apps/console/src/pages/auth/handoffRedirect.ts +++ b/ui/apps/console/src/pages/auth/handoffRedirect.ts @@ -120,22 +120,38 @@ export interface HandoffRedirect { holdForSignIn: boolean; } +/** The page's judgement of one session, and the session the first session check answered. */ +interface SessionVerdict { + session: unknown; + fresh: boolean; + arrival: { session: unknown }; +} + /** * The hand-off decision for a sign-in page, from its `?redirect=` and the auth - * state. Remembers the session the first session check answered, which is - * what tells a session found on arrival from one a sign-in on the page made. + * state. + * + * Each session the page holds is judged once, when it arrives, in a layout + * effect (the clock is read there, never during render); until it is judged + * the page holds the form rather than forward. The first judgement also + * records the session the first session check answered, which is what tells + * a session found on arrival from one a sign-in on the page made. */ export function useHandoffRedirect( redirect: string | null, { user, session, isLoading }: { user: unknown; session: unknown; isLoading: boolean }, ): HandoffRedirect { - const [arrival, setArrival] = useState<{ session: unknown } | null>(null); + const target = handoffNavigationTarget(redirect); + const [verdict, setVerdict] = useState(null); useLayoutEffect(() => { - if (!isLoading && arrival === null) setArrival({ session }); - }, [isLoading, arrival, session]); + if (target === null) return; + // The first session check has not answered yet: nothing has arrived. + if (verdict === null && isLoading) return; + if (verdict !== null && verdict.session === session) return; + const arrival = verdict?.arrival ?? { session }; + setVerdict({ session, arrival, fresh: isHandoffSessionFresh(session, { arrival, now: Date.now() }) }); + }, [target, isLoading, session, verdict]); - const target = handoffNavigationTarget(redirect); - const holdForSignIn = - target !== null && !!user && !isHandoffSessionFresh(session, { arrival, now: Date.now() }); - return { target, holdForSignIn }; + const judgedFresh = verdict !== null && verdict.session === session && verdict.fresh; + return { target, holdForSignIn: target !== null && !!user && !judgedFresh }; }