diff --git a/apps/pwa/src/api/client.ts b/apps/pwa/src/api/client.ts index ea2d8ca..cc7d73a 100644 --- a/apps/pwa/src/api/client.ts +++ b/apps/pwa/src/api/client.ts @@ -5,8 +5,12 @@ * every cross-origin request (Vite dev proxy routes to :3000; production is * same-origin via Pangolin). * - * 401 responses mean the session has expired — the browser will follow the - * 302 redirect to Authelia on the next API call automatically (full-page nav). + * Auth note: the OIDC guard's 302 to Authelia is CORS-blocked for fetch/XHR — + * browsers do not follow cross-origin redirects from XHR to an external IdP. + * Re-authentication therefore requires a TOP-LEVEL navigation to /api/login + * (see apps/pwa/src/lib/loginRedirect.ts). fetchMe and other fetch calls here + * are pure data fetches; they throw on non-ok responses and leave the redirect + * decision to the caller (CalendarShell via maybeRedirectToLogin). */ // ── /api/me ──────────────────────────────────────────────────────────────── @@ -27,8 +31,9 @@ export async function fetchMe(): Promise { }) if (!res.ok) { - // 302 → browser follows redirect to Authelia automatically. - // For 4xx/5xx, throw so React Query can surface the error. + // Throw on any non-ok response. The OIDC guard's 302 to Authelia cannot be + // followed by fetch (CORS-blocked for XHR). Callers that handle auth errors + // should use maybeRedirectToLogin() for a top-level navigation to /api/login. throw new Error(`GET /api/me failed: ${res.status}`) } diff --git a/apps/pwa/src/lib/loginRedirect.test.ts b/apps/pwa/src/lib/loginRedirect.test.ts new file mode 100644 index 0000000..2639c72 --- /dev/null +++ b/apps/pwa/src/lib/loginRedirect.test.ts @@ -0,0 +1,71 @@ +/** + * loginRedirect — one-shot sessionStorage-guarded redirect helper. + * + * Covers: + * - maybeRedirectToLogin() redirects to /api/login on first call and returns true + * - maybeRedirectToLogin() does NOT redirect on second call (one-shot guard) and returns false + * - clearLoginRedirect() removes the flag so a subsequent call redirects again + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest' + +// Stub window.location with a writable href before importing the module. +// jsdom sets window.location to a read-only getter backed by a Location object; +// we need to replace it with a plain object so href assignment is detectable. +const locationStub = { href: '' } +Object.defineProperty(window, 'location', { + value: locationStub, + writable: true, +}) + +import { maybeRedirectToLogin, clearLoginRedirect } from './loginRedirect.js' + +describe('maybeRedirectToLogin', () => { + beforeEach(() => { + sessionStorage.clear() + locationStub.href = '' + vi.clearAllMocks() + }) + + it('sets window.location.href to /api/login and returns true on first call', () => { + const result = maybeRedirectToLogin() + expect(result).toBe(true) + expect(locationStub.href).toBe('/api/login') + }) + + it('sets the sessionStorage flag after first call', () => { + maybeRedirectToLogin() + expect(sessionStorage.getItem('familysync.loginRedirectAttempted')).toBe('1') + }) + + it('does NOT change href on second call and returns false (one-shot guard)', () => { + maybeRedirectToLogin() // first call — redirects + locationStub.href = '' // reset the stub to detect a second assignment + const result = maybeRedirectToLogin() // second call — should NOT redirect + expect(result).toBe(false) + expect(locationStub.href).toBe('') // href must NOT have been reassigned + }) +}) + +describe('clearLoginRedirect', () => { + beforeEach(() => { + sessionStorage.clear() + locationStub.href = '' + vi.clearAllMocks() + }) + + it('removes the sessionStorage flag', () => { + sessionStorage.setItem('familysync.loginRedirectAttempted', '1') + clearLoginRedirect() + expect(sessionStorage.getItem('familysync.loginRedirectAttempted')).toBeNull() + }) + + it('allows maybeRedirectToLogin to redirect again after the flag is cleared', () => { + maybeRedirectToLogin() // first call — sets flag + clearLoginRedirect() // clear the flag + locationStub.href = '' // reset stub + const result = maybeRedirectToLogin() // should redirect again + expect(result).toBe(true) + expect(locationStub.href).toBe('/api/login') + }) +}) diff --git a/apps/pwa/src/lib/loginRedirect.ts b/apps/pwa/src/lib/loginRedirect.ts new file mode 100644 index 0000000..aec41f4 --- /dev/null +++ b/apps/pwa/src/lib/loginRedirect.ts @@ -0,0 +1,64 @@ +/** + * One-shot login-redirect helper. + * + * Problem: the OIDC guard issues a 302 to Authelia, but fetch()/XHR cannot follow a + * cross-origin 302 to Authelia — the browser enforces CORS on XHR redirects, so the + * response is opaque and the SPA never reaches Authelia. Re-auth requires a TOP-LEVEL + * browser navigation to /api/login, where the guard's redirect is followed at the + * document level (no CORS restriction). + * + * Solution: when /api/me fails (indicating an unauthenticated session), perform a + * full-page navigation to /api/login. A sessionStorage flag prevents infinite loops: + * if the navigation completes and /api/me still fails (genuine error), the second + * maybeRedirectToLogin() call is a no-op and the "Sign-in required" UI is shown. + */ + +export const LOGIN_REDIRECT_KEY = 'familysync.loginRedirectAttempted' + +/** + * Navigate to /api/login if this is the first attempt. + * + * Returns true when a redirect was triggered (caller can bail early — the page + * is navigating away). Returns false when the flag was already set (already + * bounced through login once; let the caller render the error UI instead). + * + * Safe under SSR/test environments: no-ops and returns false when window or + * sessionStorage is unavailable. + */ +export function maybeRedirectToLogin(): boolean { + if (typeof window === 'undefined') return false + if (typeof sessionStorage === 'undefined') return false + + try { + if (sessionStorage.getItem(LOGIN_REDIRECT_KEY) !== null) { + // Already attempted — do not redirect again (loop guard). + return false + } + sessionStorage.setItem(LOGIN_REDIRECT_KEY, '1') + window.location.href = '/api/login' + return true + } catch { + // sessionStorage access can throw in private-browsing mode or with storage quota + // exceeded. Fail open: do not redirect, let the caller render the error. + return false + } +} + +/** + * Clear the one-shot flag. + * + * Call on a successful /api/me load so that a later session expiry can trigger + * another redirect instead of silently showing "Sign-in required". + * + * Safe under SSR/test environments (guarded). + */ +export function clearLoginRedirect(): void { + if (typeof window === 'undefined') return + if (typeof sessionStorage === 'undefined') return + + try { + sessionStorage.removeItem(LOGIN_REDIRECT_KEY) + } catch { + // Ignore storage errors — clearing the flag is best-effort. + } +}