Files
accounted/app/(onboarding)/layout.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

63 lines
2.4 KiB
TypeScript

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,
}: {
children: React.ReactNode
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
// Only show the settings escape hatch for users who have completed
// onboarding at least once: i.e. they have a company_members row, even if
// it points to an archived company. Absolute first-time users don't need
// it and it clutters the welcome screen.
//
// Must use the service client: RLS on company_members goes through
// user_company_ids(), which filters out archived companies, so an
// authenticated query would return nothing for a user who archived their
// last company and make the escape hatch disappear exactly when it's
// needed most. Scoped to user_id = user.id, so no cross-user exposure.
let hasCompletedOnboarding = false
if (user) {
const service = createServiceClient()
const { data } = await service
.from('company_members')
.select('company_id')
.eq('user_id', user.id)
.limit(1)
.maybeSingle()
hasCompletedOnboarding = !!data
}
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">
{children}
</div>
{/* Escape hatch: a user who archived their last company can still
reach account settings (and the delete-account flow) from here.
Hidden for absolute first-time users (no memberships ever). */}
{user && hasCompletedOnboarding && (
<Link
href="/settings/account"
aria-label="Kontoinställningar"
title="Kontoinställningar"
className="fixed bottom-6 right-6 z-50 flex h-10 w-10 items-center justify-center rounded-full border border-border bg-background/80 text-muted-foreground shadow-sm backdrop-blur transition-colors hover:border-foreground/40 hover:text-foreground"
>
<Settings className="h-4 w-4" />
</Link>
)}
</div>
)
}