Files
accounted/components/auth/SessionTimeoutModal.tsx
T
Mattsson 1c9d378df8 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>
2026-08-04 09:59:42 +02:00

58 lines
1.8 KiB
TypeScript

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