mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user