Milestone v1.0: FamilySync MVP #1
@@ -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<MeResponse> {
|
||||
})
|
||||
|
||||
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}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user