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
@@ -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">
|
||||
|
||||
@@ -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,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)
|
||||
})
|
||||
})
|
||||
@@ -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,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>
|
||||
|
||||
Reference in New Issue
Block a user