fix(native): SSO in the system browser hands back to the app even when the provider finishes in another tab, and the handoff button's label shows

This commit is contained in:
Maurice
2026-11-08 17:43:28 +01:00
parent ab6cd7009e
commit 4ba7db90e6
6 changed files with 86 additions and 6 deletions
+33
View File
@@ -6,8 +6,10 @@ import {
BrowserSignInCancelled,
createHandoffSecret,
handoffUrl,
rememberHandoffReturn,
requestHandoffCode,
signInThroughBrowser,
takeHandoffReturn,
} from './browserSignIn';
import { TrekShell } from './trekShell';
@@ -77,3 +79,34 @@ describe('signInThroughBrowser', () => {
expect(nativeAuthApi.exchange).not.toHaveBeenCalled();
});
});
describe('the remembered handoff return', () => {
const path = `/native-handoff?challenge=${'a'.repeat(64)}`;
beforeEach(() => {
localStorage.clear();
vi.useRealTimers();
});
it('survives a sign-in that comes back in another tab, once', () => {
rememberHandoffReturn(path);
expect(takeHandoffReturn()).toBe(path);
expect(takeHandoffReturn()).toBeNull();
});
it('is dropped once it is older than a quarter of an hour', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-11-08T12:00:00Z'));
rememberHandoffReturn(path);
vi.setSystemTime(new Date('2026-11-08T12:16:00Z'));
expect(takeHandoffReturn()).toBeNull();
vi.useRealTimers();
});
it('only ever leads back to the handoff page', () => {
rememberHandoffReturn('/admin');
expect(takeHandoffReturn()).toBeNull();
localStorage.setItem('trek_native_handoff_return', 'not json');
expect(takeHandoffReturn()).toBeNull();
});
});
+34
View File
@@ -21,6 +21,40 @@ export const HANDOFF_PATH = '/native-handoff';
export type BrowserSignInStart = 'oidc';
const HANDOFF_RETURN_KEY = 'trek_native_handoff_return';
const HANDOFF_RETURN_TTL_MS = 15 * 60 * 1000;
/**
* Remembers the handoff page an SSO round trip has to come back to. The login
* keeps its return path in sessionStorage, which is per tab, and an identity
* provider such as Google may finish its sign-in in another tab or in its own
* app on Android. The return then arrives without it and the user lands in the
* web app instead of being handed back, so the handoff page also leaves a short
* lived copy here.
*/
export function rememberHandoffReturn(path: string): void {
try {
localStorage.setItem(HANDOFF_RETURN_KEY, JSON.stringify({ path, at: Date.now() }));
} catch {
/* storage blocked: the sessionStorage copy still covers a single-tab sign-in */
}
}
/** The remembered handoff page, once: only a fresh path to the handoff page itself. */
export function takeHandoffReturn(): string | null {
try {
const raw = localStorage.getItem(HANDOFF_RETURN_KEY);
localStorage.removeItem(HANDOFF_RETURN_KEY);
if (!raw) return null;
const { path, at } = JSON.parse(raw) as { path?: unknown; at?: unknown };
if (typeof path !== 'string' || typeof at !== 'number') return null;
if (Date.now() - at > HANDOFF_RETURN_TTL_MS) return null;
return path.startsWith(`${HANDOFF_PATH}?challenge=`) ? path : null;
} catch {
return null;
}
}
export function createHandoffSecret(): { verifier: string; challenge: string } {
const bytes = crypto.getRandomValues(new Uint8Array(32));
const verifier = bytesToHex(bytes);
+1 -1
View File
@@ -32,7 +32,7 @@ export default function NativeHandoffPage() {
void openApp();
}}
disabled={status === 'sending'}
className="w-full rounded-lg bg-accent py-3 font-semibold text-accent-on disabled:opacity-60"
className="w-full rounded-lg bg-accent py-3 font-semibold text-accent-text disabled:opacity-60"
>
{t('native.handoff.open')}
</button>
+9 -2
View File
@@ -10,7 +10,12 @@ import { getApiErrorMessage } from '../../types'
import { START_DESTINATION_ROUTE } from '../../utils/startDestination'
import { passwordErrorKey } from '../../utils/passwordError'
import { isNativeApp } from '../../native/platform'
import { BrowserSignInCancelled, signInThroughBrowser, type BrowserSignInStart } from '../../native/browserSignIn'
import {
BrowserSignInCancelled,
signInThroughBrowser,
takeHandoffReturn,
type BrowserSignInStart,
} from '../../native/browserSignIn'
interface AppConfig {
has_users: boolean
@@ -221,7 +226,9 @@ export function useLogin() {
// body is never read, so it can go without breaking SSO sign-in.
if (ok && data.success === true) {
await loadUser()
const savedRedirect = sessionStorage.getItem('oidc_redirect') || START_DESTINATION_ROUTE
// Taken either way, so a handoff left from an abandoned sign-in cannot catch a later one.
const handoffReturn = takeHandoffReturn()
const savedRedirect = sessionStorage.getItem('oidc_redirect') || handoffReturn || START_DESTINATION_ROUTE
sessionStorage.removeItem('oidc_redirect')
navigate(savedRedirect, { replace: true })
} else {
@@ -4,7 +4,7 @@ import { MemoryRouter } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, renderHook, waitFor } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import { requestHandoffCode } from '../../native/browserSignIn';
import { rememberHandoffReturn, requestHandoffCode } from '../../native/browserSignIn';
import { useAuthStore } from '../../store/authStore';
import { useNativeHandoff } from './useNativeHandoff';
@@ -13,7 +13,11 @@ vi.mock('react-router', async () => {
const actual = await vi.importActual<typeof import('react-router')>('react-router');
return { ...actual, useNavigate: () => mockNavigate };
});
vi.mock('../../native/browserSignIn', () => ({ HANDOFF_PATH: '/native-handoff', requestHandoffCode: vi.fn() }));
vi.mock('../../native/browserSignIn', () => ({
HANDOFF_PATH: '/native-handoff',
rememberHandoffReturn: vi.fn(),
requestHandoffCode: vi.fn(),
}));
const challenge = 'ab'.repeat(32);
const realLocation = window.location;
@@ -85,6 +89,7 @@ describe('useNativeHandoff', () => {
renderHandoff();
expect(window.location.replace).toHaveBeenCalledWith('/api/auth/oidc/login?remember=1');
expect(sessionStorage.getItem('oidc_redirect')).toBe(`/native-handoff?challenge=${challenge}`);
expect(rememberHandoffReturn).toHaveBeenCalledWith(`/native-handoff?challenge=${challenge}`);
});
it('FE-NATIVE-HANDOFF-005: waits while the session is still being checked', () => {
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { useShallow } from 'zustand/react/shallow';
import { HANDOFF_PATH, requestHandoffCode } from '../../native/browserSignIn';
import { HANDOFF_PATH, rememberHandoffReturn, requestHandoffCode } from '../../native/browserSignIn';
import { APP_URL_SCHEME } from '../../native/trekShell';
import { useAuthStore } from '../../store/authStore';
@@ -35,6 +35,7 @@ export function useNativeHandoff() {
if (!valid || isLoading || isAuthenticated) return;
if (params.get('start') === 'oidc') {
sessionStorage.setItem('oidc_redirect', returnPath);
rememberHandoffReturn(returnPath);
window.location.replace('/api/auth/oidc/login?remember=1');
return;
}