1c9d378df8
* feat(auth): enforce session idle and absolute timeouts Hosted browser sessions now carry an HMAC-signed, HttpOnly cookie holding session start, last activity and sign-in method, bound to the Supabase session. Middleware enforces a 30 min idle and 12 h absolute limit (reason-coded redirects to /login), a heartbeat route advances idle activity from real user input, and a client controller warns 2 minutes before expiry. BankID users are routed back to BankID on re-auth via a short-lived method hint. API-key and MCP bearer surfaces are exempt; self-hosted installs default off and can opt in via env vars. Fixes #362 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): derive session-timeout signing key via HKDF The HMAC key is now HKDF-derived with a purpose-bound info string, so the SUPABASE_SERVICE_ROLE_KEY fallback never uses the privileged credential directly as a signing key. Addresses the security review finding on PR #1387. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): back signature bytes with a plain ArrayBuffer crypto.subtle.verify requires a BufferSource; Uint8Array.from is typed over ArrayBufferLike, which the Vercel TypeScript build rejects. Decode base64url into a Uint8Array constructed over a fresh ArrayBuffer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): address session-timeout review findings - signSessionTimeoutState returns null on signing failure instead of throwing, so a missing secret degrades the timeout feature in line with verifySessionTimeoutState rather than crashing authenticated requests; middleware and heartbeat skip the cookie write when null - heartbeat initializes a fresh signed state for a missing or session-mismatched cookie, mirroring middleware, instead of returning SESSION_EXPIRED during normal initialization - sessionStateMatchesUser treats an unresolved current session id as a mismatch for session-bound state so another session's cookie is never accepted on the userId fallback alone - drop aria-live from the countdown DialogDescription so screen readers are not interrupted every second Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
251 lines
7.9 KiB
TypeScript
251 lines
7.9 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import { createClient } from '@/lib/supabase/client'
|
|
import { resetAnalyticsIdentity } from '@/lib/analytics/reset'
|
|
import {
|
|
SESSION_TIMEOUT_CHANNEL,
|
|
type SessionTimeoutClientState,
|
|
type SessionTimeoutReason,
|
|
} from '@/lib/auth/session-timeout-shared'
|
|
import { SessionTimeoutModal } from './SessionTimeoutModal'
|
|
|
|
const ACTIVITY_HEARTBEAT_INTERVAL_MS = 15_000
|
|
const SERVER_RESYNC_INTERVAL_MS = 60_000
|
|
const ACTIVITY_EVENTS = ['pointerdown', 'keydown', 'scroll', 'touchstart'] as const
|
|
|
|
type Warning = { reason: SessionTimeoutReason; seconds: number }
|
|
|
|
function timeoutDeadline(
|
|
state: SessionTimeoutClientState,
|
|
lastActivityAt: number,
|
|
): { at: number; reason: SessionTimeoutReason } {
|
|
const absoluteAt = state.absoluteTimeoutMs > 0
|
|
? state.startedAt + state.absoluteTimeoutMs
|
|
: Number.POSITIVE_INFINITY
|
|
const idleAt = state.idleTimeoutMs > 0
|
|
? lastActivityAt + state.idleTimeoutMs
|
|
: Number.POSITIVE_INFINITY
|
|
|
|
return absoluteAt <= idleAt
|
|
? { at: absoluteAt, reason: 'absolute' }
|
|
: { at: idleAt, reason: 'idle' }
|
|
}
|
|
|
|
export function SessionTimeoutController() {
|
|
const [warning, setWarning] = useState<Warning | null>(null)
|
|
const [isExtending, setIsExtending] = useState(false)
|
|
const stateRef = useRef<SessionTimeoutClientState | null>(null)
|
|
const lastActivityAtRef = useRef(0)
|
|
const lastHeartbeatAtRef = useRef(0)
|
|
const heartbeatInFlightRef = useRef(false)
|
|
const expiringRef = useRef(false)
|
|
const warningOpenRef = useRef(false)
|
|
const channelRef = useRef<BroadcastChannel | null>(null)
|
|
|
|
const expire = useCallback(async (reason: SessionTimeoutReason) => {
|
|
if (expiringRef.current) return
|
|
expiringRef.current = true
|
|
resetAnalyticsIdentity()
|
|
|
|
try {
|
|
await createClient().auth.signOut({ scope: 'local' })
|
|
} catch {
|
|
// Middleware remains authoritative and clears the server cookies.
|
|
}
|
|
|
|
const method = stateRef.current?.method ?? 'password'
|
|
const url = new URL('/login', window.location.origin)
|
|
url.searchParams.set('reason', reason)
|
|
url.searchParams.set('method', method)
|
|
const next = window.location.pathname + window.location.search
|
|
if (next !== '/') url.searchParams.set('next', next)
|
|
window.location.assign(url.toString())
|
|
}, [])
|
|
|
|
const applyServerState = useCallback((state: SessionTimeoutClientState) => {
|
|
if (!state.enabled) {
|
|
stateRef.current = null
|
|
lastActivityAtRef.current = 0
|
|
warningOpenRef.current = false
|
|
setWarning(null)
|
|
return
|
|
}
|
|
|
|
const clockOffset = Date.now() - state.serverNow
|
|
stateRef.current = {
|
|
...state,
|
|
startedAt: state.startedAt + clockOffset,
|
|
lastActivityAt: state.lastActivityAt + clockOffset,
|
|
serverNow: Date.now(),
|
|
}
|
|
lastActivityAtRef.current = state.lastActivityAt + clockOffset
|
|
}, [])
|
|
|
|
const handleExpiredResponse = useCallback((response: Response) => {
|
|
const reason = response.headers.get('x-session-timeout-reason') === 'idle'
|
|
? 'idle'
|
|
: 'absolute'
|
|
void expire(reason)
|
|
}, [expire])
|
|
|
|
const syncFromServer = useCallback(async () => {
|
|
try {
|
|
const response = await fetch('/api/auth/heartbeat', {
|
|
method: 'GET',
|
|
cache: 'no-store',
|
|
credentials: 'same-origin',
|
|
})
|
|
if (response.status === 401) {
|
|
handleExpiredResponse(response)
|
|
return
|
|
}
|
|
if (!response.ok) return
|
|
const payload = await response.json() as { data: SessionTimeoutClientState }
|
|
applyServerState(payload.data)
|
|
} catch {
|
|
// A later resync or protected request will retry server enforcement.
|
|
}
|
|
}, [applyServerState, handleExpiredResponse])
|
|
|
|
const sendHeartbeat = useCallback(async (showProgress = false) => {
|
|
if (heartbeatInFlightRef.current || !stateRef.current) return false
|
|
heartbeatInFlightRef.current = true
|
|
if (showProgress) setIsExtending(true)
|
|
|
|
try {
|
|
const response = await fetch('/api/auth/heartbeat', {
|
|
method: 'POST',
|
|
cache: 'no-store',
|
|
credentials: 'same-origin',
|
|
})
|
|
if (response.status === 401) {
|
|
handleExpiredResponse(response)
|
|
return false
|
|
}
|
|
if (!response.ok) return false
|
|
const payload = await response.json() as { data: SessionTimeoutClientState }
|
|
applyServerState(payload.data)
|
|
lastHeartbeatAtRef.current = Date.now()
|
|
warningOpenRef.current = false
|
|
setWarning(null)
|
|
channelRef.current?.postMessage({ type: 'heartbeat', state: payload.data })
|
|
return true
|
|
} catch {
|
|
return false
|
|
} finally {
|
|
heartbeatInFlightRef.current = false
|
|
if (showProgress) setIsExtending(false)
|
|
}
|
|
}, [applyServerState, handleExpiredResponse])
|
|
|
|
const recordActivity = useCallback(() => {
|
|
const state = stateRef.current
|
|
if (!state || warningOpenRef.current || expiringRef.current) return
|
|
|
|
const now = Date.now()
|
|
lastActivityAtRef.current = now
|
|
channelRef.current?.postMessage({ type: 'activity', at: now })
|
|
|
|
if (now - lastHeartbeatAtRef.current >= ACTIVITY_HEARTBEAT_INTERVAL_MS) {
|
|
void sendHeartbeat()
|
|
}
|
|
}, [sendHeartbeat])
|
|
|
|
useEffect(() => {
|
|
void syncFromServer()
|
|
|
|
const channel = typeof BroadcastChannel === 'undefined'
|
|
? null
|
|
: new BroadcastChannel(SESSION_TIMEOUT_CHANNEL)
|
|
channelRef.current = channel
|
|
if (channel) {
|
|
channel.onmessage = (event: MessageEvent<{
|
|
type: 'activity' | 'heartbeat'
|
|
at?: number
|
|
state?: SessionTimeoutClientState
|
|
}>) => {
|
|
if (event.data.type === 'heartbeat' && event.data.state) {
|
|
applyServerState(event.data.state)
|
|
lastHeartbeatAtRef.current = Date.now()
|
|
warningOpenRef.current = false
|
|
setWarning(null)
|
|
} else if (
|
|
event.data.type === 'activity' &&
|
|
typeof event.data.at === 'number' &&
|
|
!warningOpenRef.current
|
|
) {
|
|
lastActivityAtRef.current = Math.max(
|
|
lastActivityAtRef.current,
|
|
event.data.at,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const eventName of ACTIVITY_EVENTS) {
|
|
window.addEventListener(eventName, recordActivity, { passive: true })
|
|
}
|
|
|
|
const onVisibilityChange = () => {
|
|
if (document.visibilityState === 'visible') void syncFromServer()
|
|
}
|
|
document.addEventListener('visibilitychange', onVisibilityChange)
|
|
|
|
const timer = window.setInterval(() => {
|
|
const state = stateRef.current
|
|
if (!state) return
|
|
const deadline = timeoutDeadline(state, lastActivityAtRef.current)
|
|
const remainingMs = deadline.at - Date.now()
|
|
|
|
if (remainingMs <= 0) {
|
|
void expire(deadline.reason)
|
|
return
|
|
}
|
|
|
|
if (state.warningMs > 0 && remainingMs <= state.warningMs) {
|
|
warningOpenRef.current = true
|
|
setWarning({
|
|
reason: deadline.reason,
|
|
seconds: Math.max(1, Math.ceil(remainingMs / 1000)),
|
|
})
|
|
} else if (warningOpenRef.current) {
|
|
warningOpenRef.current = false
|
|
setWarning(null)
|
|
}
|
|
}, 1000)
|
|
const resyncTimer = window.setInterval(() => {
|
|
if (document.visibilityState === 'visible') void syncFromServer()
|
|
}, SERVER_RESYNC_INTERVAL_MS)
|
|
|
|
return () => {
|
|
window.clearInterval(timer)
|
|
window.clearInterval(resyncTimer)
|
|
document.removeEventListener('visibilitychange', onVisibilityChange)
|
|
for (const eventName of ACTIVITY_EVENTS) {
|
|
window.removeEventListener(eventName, recordActivity)
|
|
}
|
|
channel?.close()
|
|
channelRef.current = null
|
|
}
|
|
}, [applyServerState, expire, recordActivity, syncFromServer])
|
|
|
|
if (!warning) return null
|
|
|
|
return (
|
|
<SessionTimeoutModal
|
|
reason={warning.reason}
|
|
seconds={warning.seconds}
|
|
isExtending={isExtending}
|
|
onContinue={() => {
|
|
if (warning.reason === 'absolute') {
|
|
void expire('absolute')
|
|
} else {
|
|
lastActivityAtRef.current = Date.now()
|
|
void sendHeartbeat(true)
|
|
}
|
|
}}
|
|
/>
|
|
)
|
|
}
|