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:
Mattsson
2026-08-04 09:59:42 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent f55bfc478b
commit 1c9d378df8
21 changed files with 1610 additions and 13 deletions
+63 -7
View File
@@ -22,6 +22,12 @@ import {
INVITE_PROBLEM_MESSAGE_KEYS,
} from '@/lib/auth/consume-invite-cookie'
import { AuthPageSkeleton } from '@/components/auth/AuthPageSkeleton'
import { resetAnalyticsIdentity } from '@/lib/analytics/reset'
import {
isSessionAuthMethod,
setSessionAuthMethodHint,
type SessionTimeoutReason,
} from '@/lib/auth/session-timeout-shared'
const branding = getBranding()
import type { BankIdResult } from '@/components/auth/BankIdAuth'
@@ -47,6 +53,7 @@ function LoginPageContent() {
const [isLoading, setIsLoading] = useState(false)
const [isEmailSent, setIsEmailSent] = useState(false)
const [showResetPassword, setShowResetPassword] = useState(false)
const [showPasswordFallback, setShowPasswordFallback] = useState(false)
const [resetCooldownUntil, setResetCooldownUntil] = useState<number | null>(null)
const [resetCooldownRemaining, setResetCooldownRemaining] = useState(0)
const [bankIdNoAccount, setBankIdNoAccount] = useState<{ givenName?: string; surname?: string } | null>(null)
@@ -55,6 +62,11 @@ function LoginPageContent() {
const searchParams = useSearchParams()
const callbackError = searchParams.get('error')
const callbackFlow = searchParams.get('flow')
const reasonParam = searchParams.get('reason')
const timeoutReason: SessionTimeoutReason | null =
reasonParam === 'idle' || reasonParam === 'absolute' ? reasonParam : null
const methodParam = searchParams.get('method')
const requestedMethod = isSessionAuthMethod(methodParam) ? methodParam : 'password'
// Post-login destination, set e.g. by the MCP OAuth authorize endpoint
// (/login?next=/api/mcp-oauth/authorize?...). Sanitized to a same-origin
// relative path; '/' means no explicit destination.
@@ -66,6 +78,10 @@ function LoginPageContent() {
const tInvite = useTranslations('invite')
const errorLocale = useLocale() as ErrorLocale
useEffect(() => {
if (timeoutReason) resetAnalyticsIdentity()
}, [timeoutReason])
// Accept a pending invite, if any, and report a non-definitive failure.
// Returns true when the caller should land the user in the app directly.
// The invite cookie survives anything that is not a settled outcome, so
@@ -107,6 +123,7 @@ function LoginPageContent() {
if (result.error === 'service_unavailable') {
setBankIdUnavailable(true)
setShowPasswordFallback(true)
return
}
@@ -136,6 +153,8 @@ function LoginPageContent() {
return
}
setSessionAuthMethodHint('bankid')
// Check for pending invite token
if (await acceptPendingInvite()) {
window.location.href = '/'
@@ -189,6 +208,8 @@ function LoginPageContent() {
return
}
setSessionAuthMethodHint('password')
// Check MFA status
const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
@@ -267,6 +288,14 @@ function LoginPageContent() {
}
}
const isBankIdReauth = timeoutReason !== null &&
requestedMethod === 'bankid' &&
bankIdEnabled
const showPasswordLogin = !isBankIdReauth ||
showPasswordFallback ||
bankIdUnavailable ||
bankIdNoAccount !== null
// Email sent confirmation screen
if (isEmailSent) {
const webmailHint = detectWebmailHint(email, branding.authEmailFrom)
@@ -402,6 +431,18 @@ function LoginPageContent() {
</div>
<div className="rounded-lg border bg-card p-6">
{timeoutReason && (
<div
className="mb-5 rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950/30"
role="alert"
>
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
{timeoutReason === 'idle'
? tAuth('session_idle')
: tAuth('session_absolute')}
</p>
</div>
)}
{callbackError === 'auth_error' && (
<div className="mb-5 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
{callbackFlow === 'recovery' ? (
@@ -457,14 +498,25 @@ function LoginPageContent() {
<BankIdAuth mode="login" onComplete={handleBankIdComplete} />
</div>
)}
<div className="relative mb-5">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t" />
{isBankIdReauth && !showPasswordLogin ? (
<Button
type="button"
variant="ghost"
className="mb-5 w-full text-muted-foreground"
onClick={() => setShowPasswordFallback(true)}
>
{tAuth('use_password_instead')}
</Button>
) : (
<div className="relative mb-5">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">{tAuth('or_email_divider')}</span>
</div>
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">{tAuth('or_email_divider')}</span>
</div>
</div>
)}
</>
)}
{bankIdUnavailable && (
@@ -477,6 +529,8 @@ function LoginPageContent() {
</p>
</div>
)}
{showPasswordLogin && (
<>
<form onSubmit={handlePasswordLogin} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="email">{tAuth('email_label')}</Label>
@@ -547,6 +601,8 @@ function LoginPageContent() {
{tAuth('no_account')}
</Link>
</Button>
</>
)}
</div>
<p className="mt-4 text-center text-xs text-muted-foreground leading-relaxed">
+4
View File
@@ -9,6 +9,7 @@ import { AgentSheetProvider } from '@/components/agent/AgentSheetProvider'
import AgentTrigger from '@/components/agent/AgentTrigger'
import LazyCommandPalette from '@/components/common/LazyCommandPalette'
import { SettingsHotkey } from '@/components/settings/SettingsHotkey'
import { SessionTimeoutController } from '@/components/auth/SessionTimeoutController'
import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
import { getExtensionNavItems } from '@/lib/extensions/sectors'
import { CompanyProvider } from '@/contexts/CompanyContext'
@@ -108,6 +109,7 @@ export default async function DashboardLayout({
trialEndsAt: null,
}}
>
<SessionTimeoutController />
<AgentSheetProvider>
<CompanyTabSync />
<div className="min-h-screen bg-frame md:flex md:flex-col">
@@ -205,6 +207,7 @@ export default async function DashboardLayout({
return (
<CompanyProvider value={companyContextValue}>
<SessionTimeoutController />
<AgentSheetProvider>
<CompanyTabSync />
<div className="min-h-screen bg-frame md:flex md:flex-col">
@@ -284,6 +287,7 @@ export default async function DashboardLayout({
return (
<CompanyProvider value={companyContextValue}>
<SessionTimeoutController />
<AgentSheetProvider
identity={{
displayName: agentProfileIdentity?.display_name ?? null,
+2
View File
@@ -2,6 +2,7 @@ import Link from 'next/link'
import { Settings } from 'lucide-react'
import { createClient, createServiceClient } from '@/lib/supabase/server'
import OnboardingBackdrop from '@/components/onboarding/OnboardingBackdrop'
import { SessionTimeoutController } from '@/components/auth/SessionTimeoutController'
export default async function OnboardingLayout({
children,
@@ -35,6 +36,7 @@ export default async function OnboardingLayout({
return (
<div className="min-h-screen bg-background flex items-center justify-center">
{user && <SessionTimeoutController />}
<OnboardingBackdrop />
<div className="relative z-10 w-full max-w-lg px-5">
@@ -0,0 +1,139 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NextResponse } from 'next/server'
import {
createSessionTimeoutState,
signSessionTimeoutState,
verifySessionTimeoutState,
} from '@/lib/auth/session-timeout'
import { SESSION_TIMEOUT_COOKIE } from '@/lib/auth/session-timeout-shared'
const mocks = vi.hoisted(() => ({
requireAuth: vi.fn(),
cookieValue: undefined as string | undefined,
}))
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: mocks.requireAuth,
}))
vi.mock('next/headers', () => ({
cookies: vi.fn(async () => ({
get: (name: string) => name === SESSION_TIMEOUT_COOKIE && mocks.cookieValue
? { name, value: mocks.cookieValue }
: undefined,
})),
}))
import { GET, POST } from '../route'
const supabase = {
auth: {
getClaims: vi.fn(async () => ({
data: { claims: { session_id: 'session-1' } },
})),
},
}
describe('session heartbeat route', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.SESSION_TIMEOUT_SECRET = 'heartbeat-test-secret'
process.env.NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS = '30000'
process.env.NEXT_PUBLIC_SESSION_ABSOLUTE_TIMEOUT_MS = '60000'
process.env.NEXT_PUBLIC_SESSION_WARNING_MS = '10000'
mocks.requireAuth.mockResolvedValue({
user: { id: 'user-1' },
supabase,
error: null,
})
mocks.cookieValue = undefined
})
async function setState(args?: {
startedAt?: number
lastActivityAt?: number
sessionId?: string
}) {
const state = {
...createSessionTimeoutState({
userId: 'user-1',
sessionId: args?.sessionId ?? 'session-1',
method: 'password',
now: args?.startedAt ?? Date.now(),
}),
...(args?.lastActivityAt === undefined
? {}
: { lastActivityAt: args.lastActivityAt }),
}
mocks.cookieValue = (await signSessionTimeoutState(state)) ?? undefined
return state
}
it('returns the server-authoritative timeout state without extending it on GET', async () => {
const state = await setState()
const response = await GET()
expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({
data: {
enabled: true,
startedAt: state.startedAt,
lastActivityAt: state.lastActivityAt,
},
})
expect(response.cookies.get(SESSION_TIMEOUT_COOKIE)).toBeUndefined()
})
it('advances activity and rotates the signed cookie on POST', async () => {
const state = await setState({ startedAt: Date.now() - 1000 })
const response = await POST()
expect(response.status).toBe(200)
const rotated = response.cookies.get(SESSION_TIMEOUT_COOKIE)?.value
expect(rotated).toBeTruthy()
await expect(verifySessionTimeoutState(rotated)).resolves.toMatchObject({
startedAt: state.startedAt,
lastActivityAt: expect.any(Number),
})
})
it('rejects an expired state', async () => {
const now = Date.now()
await setState({ startedAt: now - 60_000, lastActivityAt: now - 1 })
const expired = await GET()
expect(expired.status).toBe(401)
expect(expired.headers.get('x-session-timeout-reason')).toBe('absolute')
})
it('initializes fresh state for a missing or mismatched cookie like middleware', async () => {
const missing = await GET()
expect(missing.status).toBe(200)
const initialized = missing.cookies.get(SESSION_TIMEOUT_COOKIE)?.value
expect(initialized).toBeTruthy()
await expect(verifySessionTimeoutState(initialized)).resolves.toMatchObject({
userId: 'user-1',
sessionId: 'session-1',
})
await setState({ sessionId: 'another-session' })
const mismatch = await GET()
expect(mismatch.status).toBe(200)
const reminted = mismatch.cookies.get(SESSION_TIMEOUT_COOKIE)?.value
expect(reminted).toBeTruthy()
await expect(verifySessionTimeoutState(reminted)).resolves.toMatchObject({
sessionId: 'session-1',
})
})
it('passes through the existing authentication error', async () => {
mocks.requireAuth.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
expect((await GET()).status).toBe(401)
})
})
+131
View File
@@ -0,0 +1,131 @@
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
import type { SupabaseClient } from '@supabase/supabase-js'
import { requireAuth } from '@/lib/auth/require-auth'
import {
createSessionTimeoutState,
evaluateSessionTimeout,
getSessionTimeoutConfig,
sessionStateMatchesUser,
sessionTimeoutCookieOptions,
signSessionTimeoutState,
toSessionTimeoutClientState,
verifySessionTimeoutState,
} from '@/lib/auth/session-timeout'
import {
SESSION_AUTH_METHOD_HINT_COOKIE,
SESSION_TIMEOUT_COOKIE,
isSessionAuthMethod,
type SessionTimeoutReason,
} from '@/lib/auth/session-timeout-shared'
export const dynamic = 'force-dynamic'
async function getSessionId(supabase: SupabaseClient): Promise<string | null> {
if (typeof supabase.auth.getClaims !== 'function') return null
try {
const { data } = await supabase.auth.getClaims()
return typeof data?.claims?.session_id === 'string'
? data.claims.session_id
: null
} catch {
return null
}
}
function expiredResponse(reason: SessionTimeoutReason): NextResponse {
const response = NextResponse.json(
{ error: { code: 'SESSION_EXPIRED', reason } },
{ status: 401 },
)
response.headers.set('X-Session-Timeout-Reason', reason)
response.headers.set('Cache-Control', 'no-store')
return response
}
async function heartbeat(updateActivity: boolean): Promise<NextResponse> {
const auth = await requireAuth()
if (auth.error) return auth.error
const config = getSessionTimeoutConfig()
const now = Date.now()
if (!config.enabled) {
return NextResponse.json({
data: {
enabled: false,
idleTimeoutMs: 0,
absoluteTimeoutMs: 0,
warningMs: 0,
serverNow: now,
startedAt: now,
lastActivityAt: now,
method: 'password',
},
})
}
const cookieStore = await cookies()
const encodedState = cookieStore.get(SESSION_TIMEOUT_COOKIE)?.value
const state = await verifySessionTimeoutState(encodedState)
const sessionId = await getSessionId(auth.supabase)
if (!state || !sessionStateMatchesUser(state, auth.user.id, sessionId)) {
// Mirror middleware initialization: a missing or session-mismatched
// cookie means the timeout state has not been established for this
// session yet, not that the session expired.
const hintedMethod = cookieStore.get(SESSION_AUTH_METHOD_HINT_COOKIE)?.value
const freshState = createSessionTimeoutState({
userId: auth.user.id,
sessionId,
method: isSessionAuthMethod(hintedMethod) ? hintedMethod : 'password',
now,
})
const response = NextResponse.json({
data: toSessionTimeoutClientState(freshState, config, now),
})
response.headers.set('Cache-Control', 'no-store')
const signedFresh = await signSessionTimeoutState(freshState)
if (signedFresh) {
response.cookies.set(
SESSION_TIMEOUT_COOKIE,
signedFresh,
sessionTimeoutCookieOptions(),
)
}
return response
}
const reason = evaluateSessionTimeout(state, config, now)
if (reason) return expiredResponse(reason)
const nextState = updateActivity
? { ...state, lastActivityAt: now }
: state
const response = NextResponse.json({
data: toSessionTimeoutClientState(nextState, config, now),
})
response.headers.set('Cache-Control', 'no-store')
if (updateActivity) {
const signedNext = await signSessionTimeoutState(nextState)
if (signedNext) {
response.cookies.set(
SESSION_TIMEOUT_COOKIE,
signedNext,
sessionTimeoutCookieOptions(),
)
}
}
return response
}
export async function GET(): Promise<NextResponse> {
return heartbeat(false)
}
export async function POST(): Promise<NextResponse> {
return heartbeat(true)
}
+2
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import OnboardingBackdrop from '@/components/onboarding/OnboardingBackdrop'
import OnboardingJourney from '@/components/onboarding/journey/OnboardingJourney'
import { SessionTimeoutController } from '@/components/auth/SessionTimeoutController'
export const dynamic = 'force-dynamic'
@@ -36,6 +37,7 @@ export default async function NewCompanyPage() {
return (
<div className="min-h-screen bg-background">
<SessionTimeoutController />
<OnboardingBackdrop />
<OnboardingJourney teamId={teamId} mode="add" />
</div>