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