diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index a5762fe0..160b6f8e 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -6,6 +6,8 @@ export interface OAuthCallbackResponse { refresh_token: string token_type: string is_new_user: boolean + idle_expires_at?: string | null + absolute_expires_at?: string | null } export const authApi = { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 89920130..a8aa9516 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -136,15 +136,18 @@ apiClient.interceptors.response.use( }, }) - const { access_token, refresh_token } = response.data + const { access_token, refresh_token, idle_expires_at, absolute_expires_at } = response.data localStorage.setItem('access_token', access_token) localStorage.setItem('refresh_token', refresh_token) - // Sync Zustand auth store + // Sync Zustand auth store — include the new expiry fields so + // useAuthSessionExpiry stays accurate after each refresh. useAuthStore.getState().setTokens({ access_token, refresh_token, token_type: 'bearer', + idle_expires_at, + absolute_expires_at, }) isRefreshing = false @@ -159,11 +162,28 @@ apiClient.interceptors.response.use( isRefreshing = false onRefreshFailed(refreshError) - // Refresh failed — clear tokens and redirect to login + // Refresh failed — clear tokens and redirect to login. The redirect + // target depends on WHY the refresh failed (plan §4.10): + // - session_expired_idle / session_expired_absolute: the user hit a + // policy boundary. Show the calm "session ended for security" + // banner via ?reason=session_expired. + // - invalid_refresh_token (or anything else): plain logout, no + // banner — the user wasn't kicked by policy, the token just + // wasn't recognized. + const refreshAxiosErr = refreshError as AxiosError + const refreshDetail = (refreshAxiosErr.response?.data as { detail?: string })?.detail + const isPolicyExpiry = + refreshDetail === 'session_expired_idle' || + refreshDetail === 'session_expired_absolute' + localStorage.removeItem('access_token') localStorage.removeItem('refresh_token') useAuthStore.getState().logout() - window.location.href = '/login' + if (!window.location.pathname.startsWith('/login')) { + window.location.href = isPolicyExpiry + ? '/login?reason=session_expired' + : '/login' + } return Promise.reject(refreshError) } } diff --git a/frontend/src/pages/OAuthCallbackPage.tsx b/frontend/src/pages/OAuthCallbackPage.tsx index 6fc7ed02..b32dd080 100644 --- a/frontend/src/pages/OAuthCallbackPage.tsx +++ b/frontend/src/pages/OAuthCallbackPage.tsx @@ -103,6 +103,8 @@ export function OAuthCallbackPage() { access_token: result.access_token, refresh_token: result.refresh_token, token_type: result.token_type || 'bearer', + idle_expires_at: result.idle_expires_at, + absolute_expires_at: result.absolute_expires_at, }) // Hydrate user / account / subscription. await fetchUser() diff --git a/frontend/src/types/auth.ts b/frontend/src/types/auth.ts index 717c298d..a524e6c6 100644 --- a/frontend/src/types/auth.ts +++ b/frontend/src/types/auth.ts @@ -3,6 +3,12 @@ export interface Token { refresh_token: string token_type: string must_change_password?: boolean + // ISO 8601 UTC strings derived from the refresh JWT's idle and absolute + // session windows. Used by useAuthSessionExpiry to drive the + // "your session ends soon" toast and the forced-logout fallback when + // /auth/refresh rejects with session_expired_{idle,absolute}. + idle_expires_at?: string | null + absolute_expires_at?: string | null } export interface AuthState {