feat(auth): enforce session idle and absolute timeouts (#1387)
* 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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
f55bfc478b
commit
1c9d378df8
@@ -0,0 +1,250 @@
|
||||
'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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import { Loader2, ShieldAlert } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import type { SessionTimeoutReason } from '@/lib/auth/session-timeout-shared'
|
||||
|
||||
export function SessionTimeoutModal({
|
||||
reason,
|
||||
seconds,
|
||||
isExtending,
|
||||
onContinue,
|
||||
}: {
|
||||
reason: SessionTimeoutReason
|
||||
seconds: number
|
||||
isExtending: boolean
|
||||
onContinue: () => void
|
||||
}) {
|
||||
const t = useTranslations('session_timeout')
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={() => {}}>
|
||||
<DialogContent
|
||||
className="max-w-md [&>button]:hidden"
|
||||
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||
onPointerDownOutside={(event) => event.preventDefault()}
|
||||
onInteractOutside={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<div className="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-300">
|
||||
<ShieldAlert className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
<DialogTitle>{t('warning_title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{reason === 'idle'
|
||||
? t('idle_warning', { seconds })
|
||||
: t('absolute_warning', { seconds })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={onContinue} disabled={isExtending} autoFocus>
|
||||
{isExtending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{reason === 'idle' ? t('continue') : t('sign_in_again')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user