fix(native): a failed SSO round trip from the app still hands back on the retry

A sign-in the app starts runs through the server's handoff page in the
system browser. When the SSO callback failed (an expired or overwritten
state), the login page dropped the way back to that page, so the retry from
there was a plain web login and ended in the web app inside the app's
browser sheet instead of signing in the app. The login page now keeps a way
back that points at the handoff page, and only that, and renews its
fallback copy for a slow retry. State, nonce, PKCE and the handoff verifier
are unchanged; the handoff page still asks for a tap before it passes
anything on.
This commit is contained in:
Maurice
2026-11-12 16:10:14 +01:00
parent f2ac12ca4f
commit 11472c05ff
4 changed files with 57 additions and 3 deletions
+9
View File
@@ -8,6 +8,7 @@ import {
BrowserSignInCancelled,
createHandoffSecret,
handoffUrl,
isHandoffReturn,
rememberHandoffReturn,
requestHandoffCode,
resumeBrowserSignIn,
@@ -199,6 +200,14 @@ describe('the remembered handoff return', () => {
localStorage.setItem('trek_native_handoff_return', 'not json');
expect(takeHandoffReturn()).toBeNull();
});
it('tells a way back to the handoff page from any other stored path', () => {
expect(isHandoffReturn(path)).toBe(true);
expect(isHandoffReturn('/native-handoff')).toBe(false);
expect(isHandoffReturn('/oauth/consent?client_id=foo')).toBe(false);
expect(isHandoffReturn(null)).toBe(false);
expect(isHandoffReturn(42)).toBe(false);
});
});
describe('signInWithPasskey', () => {
+7 -2
View File
@@ -50,6 +50,11 @@ export function rememberHandoffReturn(path: string): void {
}
}
/** A path back to the handoff page itself, and nothing else a stored value could point at. */
export function isHandoffReturn(path: unknown): path is string {
return typeof path === 'string' && path.startsWith(`${HANDOFF_PATH}?challenge=`);
}
/** The remembered handoff page, once: only a fresh path to the handoff page itself. */
export function takeHandoffReturn(): string | null {
try {
@@ -57,9 +62,9 @@ export function takeHandoffReturn(): string | null {
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 (!isHandoffReturn(path) || typeof at !== 'number') return null;
if (Date.now() - at > HANDOFF_RETURN_TTL_MS) return null;
return path.startsWith(`${HANDOFF_PATH}?challenge=`) ? path : null;
return path;
} catch {
return null;
}
+31
View File
@@ -417,6 +417,37 @@ describe('useLogin — OIDC callback', () => {
const { result } = renderLogin();
await waitFor(() => expect(result.current.error).toBe('some_new_provider_code'));
});
it('FE-LOGIN-HOOK-213: a failed round trip the app started keeps the way back to the handoff page', async () => {
const handoff = `/native-handoff?challenge=${'a'.repeat(64)}`;
sessionStorage.setItem('oidc_redirect', handoff);
localStorage.removeItem('trek_native_handoff_return');
setSearch('?oidc_error=invalid_state');
const { result } = renderLogin();
await waitFor(() => expect(result.current.error).toBe('Invalid session. Please try again.'));
expect(sessionStorage.getItem('oidc_redirect')).toBe(handoff);
expect(JSON.parse(localStorage.getItem('trek_native_handoff_return') ?? '{}').path).toBe(handoff);
});
it('FE-LOGIN-HOOK-214: the SSO retry after that failure ends on the handoff page, not in the web app', async () => {
const handoff = `/native-handoff?challenge=${'b'.repeat(64)}`;
sessionStorage.setItem('oidc_redirect', handoff);
localStorage.removeItem('trek_native_handoff_return');
setSearch('?oidc_error=invalid_state');
const failed = renderLogin();
await waitFor(() => expect(failed.result.current.error).toBe('Invalid session. Please try again.'));
failed.unmount();
setSearch('?oidc_code=code-retry');
server.use(http.get('/api/auth/oidc/exchange', () => HttpResponse.json({ success: true })));
renderLogin();
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith(handoff, { replace: true }));
expect(sessionStorage.getItem('oidc_redirect')).toBeNull();
expect(localStorage.getItem('trek_native_handoff_return')).toBeNull();
});
});
describe('useLogin — language detection', () => {
+10 -1
View File
@@ -12,6 +12,8 @@ import { passwordErrorKey } from '../../utils/passwordError'
import { isNativeApp } from '../../native/platform'
import {
BrowserSignInCancelled,
isHandoffReturn,
rememberHandoffReturn,
resumeBrowserSignIn,
signInThroughBrowser,
takeHandoffReturn,
@@ -256,7 +258,14 @@ export function useLogin() {
email_link_blocked: t('login.oidc.emailLinkBlocked'),
}
setError(errorMessages[oidcError] || oidcError)
sessionStorage.removeItem('oidc_redirect')
// A round trip the app's sign-in started keeps its way back. The next SSO
// attempt from this page is a plain web login, and without the handoff page
// to return to it ends in the web app inside the app's browser sheet. The
// handoff page asks for a tap before it passes anything on, so keeping the
// path hands nothing over by itself; the copy is renewed for a slow retry.
const stashed = sessionStorage.getItem('oidc_redirect')
if (isHandoffReturn(stashed)) rememberHandoffReturn(stashed)
else sessionStorage.removeItem('oidc_redirect')
window.history.replaceState({}, '', '/login')
loadConfig(false)
return