feat(auth): session expiration policy (3d idle / 14d absolute) + per-account override + bulk revoke #168
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user