Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/29971-sign-in-mobile-handoff.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 33 additions & 5 deletions ui/apps/console/src/pages/auth/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -103,13 +105,21 @@ export function LoginPage() {
const redirect = params.get('redirect');
const {
user,
session,
isLoading,
organizations,
isOrganizationsLoading,
activeOrganization,
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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -282,9 +308,11 @@ export function LoginPage() {
autoSelectingOrg,
redirect,
switchOrganization,
handoff.target,
handoff.holdForSignIn,
]);

if ((isLoading && !hasBootstrapped) || user) {
if ((isLoading && !hasBootstrapped) || !signInNeeded) {
return (
<AuthLayout>
<div className="flex flex-col items-center gap-3 py-10 text-sm text-muted-foreground">
Expand Down
28 changes: 26 additions & 2 deletions ui/apps/console/src/pages/auth/RegisterPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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';
Expand All @@ -77,13 +80,19 @@ export function RegisterPage() {
const redirect = params.get('redirect');
const {
user,
session,
isLoading,
organizations,
isOrganizationsLoading,
activeOrganization,
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
Expand All @@ -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
Expand Down Expand Up @@ -143,7 +154,7 @@ export function RegisterPage() {
const bootstrap = useBootstrapStatus(
configRead === 'known' &&
hasBootstrapped &&
!user &&
signUpNeeded &&
needsBootstrapProbe(authConfig, invitationRedirect),
);
const signUpOffer = decideSignUpOffer(authConfig, { invitationRedirect, bootstrap });
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 (
<AuthLayout formWidth="md">
Expand Down
137 changes: 137 additions & 0 deletions ui/apps/console/src/pages/auth/__tests__/handoffRedirect-29971.test.ts
Original file line number Diff line number Diff line change
@@ -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: that route is not reachable from this page', () => {
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);
});
});
Loading
Loading