feat(auth): session expiration policy (3d idle / 14d absolute) + per-account override + bulk revoke #168

Merged
chihlasm merged 13 commits from feat/session-expiration-policy into main 2026-05-14 04:33:50 +00:00
4 changed files with 34 additions and 4 deletions
Showing only changes of commit aad554bb9c - Show all commits

View File

@@ -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 = {

View File

@@ -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)
}
}

View File

@@ -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()

View File

@@ -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 {