Files
accounted/lib/supabase/middleware.ts
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

715 lines
27 KiB
TypeScript

import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
import { shouldEnforceMfa } from '@/lib/auth/mfa'
import { apiPathSkipsMfaGate } from '@/lib/auth/api-mfa-gate'
import { DEFAULT_LOCALE, LOCALE_COOKIE, isLocale } from '@/i18n/config'
import { userHasPassword } from '@/lib/auth/has-password'
import { safeReturnTo } from '@/lib/auth/safe-return-to'
import {
apiRequestSkipsSessionTimeout,
createSessionTimeoutState,
evaluateSessionTimeout,
getSessionTimeoutConfig,
sessionStateMatchesUser,
sessionTimeoutClearCookieOptions,
sessionTimeoutCookieOptions,
signSessionTimeoutState,
verifySessionTimeoutState,
} from '@/lib/auth/session-timeout'
import {
isSessionAuthMethod,
SESSION_AUTH_METHOD_HINT_COOKIE,
SESSION_TIMEOUT_COOKIE,
type SessionAuthMethod,
type SessionTimeoutReason,
} from '@/lib/auth/session-timeout-shared'
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({
request,
})
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
)
supabaseResponse = NextResponse.next({
request,
})
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
// IMPORTANT: Avoid writing any logic between createServerClient and
// supabase.auth.getUser(). A simple mistake could make it very hard to debug
// issues with users being randomly logged out.
const {
data: { user },
error: authError,
} = await supabase.auth.getUser()
// Get the pathname
const pathname = request.nextUrl.pathname
// If the refresh token is stale/invalid, clear the session cookies so the
// browser stops sending them on every request, INCLUDING /api requests,
// which previously returned before this cleanup and replayed the dead
// token forever. Skip on auth routes, the callback needs PKCE cookies
// intact. scope: 'local' only clears cookies: the refresh token is already
// dead server-side, and the default global-revoke round-trip re-triggers
// the failed refresh, the exact AuthApiError this cleans up after.
if (authError && !user && !pathname.startsWith('/auth')) {
try {
await supabase.auth.signOut({ scope: 'local' })
} catch (signOutError) {
// Expected session expiry, not a runtime error.
console.warn('[middleware] session cleanup after stale refresh token failed', signOutError)
}
}
const timeoutConfig = getSessionTimeoutConfig()
const hasAuthorizationHeader = request.headers.get('authorization') !== null
if (!user) {
clearSessionTimeoutCookies(request, supabaseResponse)
} else if (
timeoutConfig.enabled &&
!apiRequestSkipsSessionTimeout(pathname, hasAuthorizationHeader)
) {
const encodedState = request.cookies.get(SESSION_TIMEOUT_COOKIE)?.value
const sessionId = await getSupabaseSessionId(supabase)
const verifiedState = await verifySessionTimeoutState(encodedState)
if (encodedState && !verifiedState) {
await signOutTimedOutSession(supabase)
return sessionTimeoutResponse(
request,
supabaseResponse,
'absolute',
'password',
)
}
if (
!verifiedState ||
!sessionStateMatchesUser(verifiedState, user.id, sessionId)
) {
const hintedMethod = request.cookies.get(
SESSION_AUTH_METHOD_HINT_COOKIE,
)?.value
const method = isSessionAuthMethod(hintedMethod)
? hintedMethod
: 'password'
const state = createSessionTimeoutState({
userId: user.id,
sessionId,
method,
})
const signedState = await signSessionTimeoutState(state)
if (signedState) {
request.cookies.set(SESSION_TIMEOUT_COOKIE, signedState)
supabaseResponse.cookies.set(
SESSION_TIMEOUT_COOKIE,
signedState,
sessionTimeoutCookieOptions(),
)
clearAuthMethodHint(request, supabaseResponse)
}
} else {
const timeoutReason = evaluateSessionTimeout(
verifiedState,
timeoutConfig,
)
if (timeoutReason) {
await signOutTimedOutSession(supabase)
return sessionTimeoutResponse(
request,
supabaseResponse,
timeoutReason,
verifiedState.method,
)
}
}
}
// ── API routes ──────────────────────────────────────────────────────────
// API routes authenticate themselves (requireAuth, API-key Bearer, cron
// secret, webhook signatures). Middleware runs on them for ONE reason: to
// close the MFA gap. Many legacy routes hand-roll supabase.auth.getUser()
// instead of requireAuth(), so without this an authenticated-but-not-MFA-
// verified (AAL1) cookie session could reach them on the hosted product.
// Gate ONLY cookie sessions. Bearer-auth SURFACES (/api/v1, the MCP
// endpoint) and the AAL1 escape-hatch / OAuth routes pass straight through
// (see apiPathSkipsMfaGate): header presence alone never skips the gate,
// since the header is attacker-controlled and cookie-authenticated routes
// ignore it. Pure Bearer callers (cron, webhooks) carry no cookie session,
// so the `user` guard below already excludes them. Everything else about
// /api auth stays the route's own responsibility.
if (pathname.startsWith('/api')) {
const skipMfaGate = apiPathSkipsMfaGate(
pathname,
hasAuthorizationHeader,
)
if (!skipMfaGate && user && shouldEnforceMfa(user)) {
const { data: aal } =
await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
if (aal?.nextLevel === 'aal2' && aal?.currentLevel === 'aal1') {
return NextResponse.json({ error: 'MFA-verifiering krävs.' }, { status: 403 })
}
}
return supabaseResponse
}
// Invite pages: accessible to everyone, signed in or not. A user who
// already has an account and is signed in should still be able to land on
// /invite/[token] to accept the invite with one click (see
// app/invite/[token]/page.tsx). If we bounce them to '/', they never see
// the invite at all.
if (pathname.startsWith('/invite')) {
return supabaseResponse
}
// Public payslip pages, the token in the URL is the authentication
// (resolved server-side against salary_payslip_links). Employees have no
// account; bouncing them to /login would make every emailed payslip link
// dead. See app/payslip/[token]/page.tsx.
if (pathname.startsWith('/payslip')) {
return supabaseResponse
}
// Reset-password is reachable in both auth states. The recovery flow lands
// here with a fresh session (created by the OTP exchange in /auth/callback)
// precisely so the user can call supabase.auth.updateUser({ password }). If
// we bounce authenticated users to '/', the recovery email link silently
// fails. An already-logged-in user typing /reset-password directly just gets
// the same "change password" experience as in settings: no security loss.
if (pathname.startsWith('/reset-password')) {
return supabaseResponse
}
// Public auth routes: allow access
if (
pathname.startsWith('/login') ||
pathname.startsWith('/register') ||
pathname.startsWith('/auth') ||
pathname.startsWith('/sandbox')
) {
// If user is logged in and trying to access auth pages, redirect to the
// destination the auth page would have sent them to, dashboard otherwise.
// /login?next=… is set by callers like the MCP OAuth authorize endpoint
// and by the bounce below; discarding the whole query string here
// stranded an already-signed-in user on the dashboard instead of the
// deep link they clicked. Only /login and /register carry `next`;
// /auth (the PKCE callback) and /sandbox bounce to '/' exactly as before.
if (user) {
const carriesDestination =
pathname.startsWith('/login') || pathname.startsWith('/register')
const destination = carriesDestination
? safeReturnTo(request.nextUrl.searchParams.get('next'), '/')
: '/'
return NextResponse.redirect(new URL(destination, request.url))
}
return supabaseResponse
}
// Protected routes - require authentication
if (!user) {
return bounceToAuth(request, '/login')
}
// /mfa/enroll: gate behind has-password. BankID-only users who reach this
// page can lock themselves out: Supabase requires AAL2 to change password
// or unenroll MFA, and AAL2 needs a prior password sign-in. Force them to
// set a password first. The /account/set-password page does that and routes
// back here via ?returnTo. Thread the inner returnTo through so the user
// ends up on their original destination after the full chain completes.
if (pathname.startsWith('/mfa/enroll')) {
if (!userHasPassword(user)) {
const innerReturnTo = request.nextUrl.searchParams.get('returnTo')
const mfaTarget = `/mfa/enroll${
innerReturnTo ? `?returnTo=${encodeURIComponent(innerReturnTo)}` : ''
}`
return NextResponse.redirect(
new URL(
`/account/set-password?returnTo=${encodeURIComponent(mfaTarget)}`,
request.url,
),
)
}
return supabaseResponse
}
// Other MFA pages: accessible to authenticated users (AAL1+), skip MFA enforcement
if (pathname.startsWith('/mfa/')) {
return supabaseResponse
}
// /account/set-password is the escape hatch from the BankID/MFA lockout
// and must be reachable even when the user has no company yet (e.g. mid-
// onboarding) and is at AAL1.
if (pathname.startsWith('/account/set-password')) {
return supabaseResponse
}
// Resolve the active company at most once per request: both the MFA
// enrollment gate and the company-context block below need it, and the
// resolution costs DB round trips.
let resolvedCompany: {
companyId: string | null
locale: string | null
degraded: boolean
} | null = null
const resolveCompanyOnce = async () =>
(resolvedCompany ??= await resolveCompanyForMiddleware(supabase, user.id, request))
// MFA enforcement (application-side only, not RLS)
if (shouldEnforceMfa(user)) {
const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
// User has MFA enrolled but hasn't verified this session → redirect to verify
if (aal?.nextLevel === 'aal2' && aal?.currentLevel === 'aal1') {
return bounceToAuth(request, '/mfa/verify')
}
// MFA required but user has no factor enrolled yet → force enrollment
// Skip for users with no companies (still setting up)
const { companyId: companyIdForMfa } = await resolveCompanyOnce()
if (companyIdForMfa) {
const { data: factors } = await supabase.auth.mfa.listFactors()
const hasVerifiedFactor = factors?.totp?.some(f => f.status === 'verified')
if (!hasVerifiedFactor) {
return bounceToAuth(request, '/mfa/enroll')
}
}
}
// Forward the pathname so server layouts can branch on it (e.g. render a
// no-company shell for /settings/account).
supabaseResponse.headers.set('x-pathname', pathname)
// Company context resolution
const cookieCompanyId = request.cookies.get('gnubok-company-id')?.value
const { companyId, locale: dbLocale, degraded } = await resolveCompanyOnce()
// If the cookie pointed at a company we can no longer resolve (e.g.
// archived), clear it so the browser stops sending it. Never on degraded
// resolution: a transient query failure must not wipe a valid cookie.
if (!degraded && cookieCompanyId && cookieCompanyId !== companyId) {
supabaseResponse.cookies.set('gnubok-company-id', '', { path: '/', maxAge: 0 })
}
// Sync the locale cookie from user_preferences. This keeps next-intl's
// request config (which reads the cookie) consistent with the DB value
// without forcing every RSC render to query the database itself.
const cookieLocale = request.cookies.get(LOCALE_COOKIE)?.value
const effectiveLocale = isLocale(dbLocale) ? dbLocale : DEFAULT_LOCALE
if (!degraded && cookieLocale !== effectiveLocale) {
supabaseResponse.cookies.set(LOCALE_COOKIE, effectiveLocale, {
path: '/',
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 365,
})
}
// Routes that stay accessible when the user has no active company.
// Needed so a user who archived their last company can still delete
// their account without being trapped on /onboarding forever.
const isNoCompanyAllowed =
pathname.startsWith('/onboarding') ||
pathname.startsWith('/select-company') ||
pathname.startsWith('/settings/account') ||
pathname.startsWith('/api/account/') ||
pathname.startsWith('/api/company')
// No companies: redirect to the picker if we have BankID enrichment for
// this user, otherwise the manual wizard. Either way, allow the escape-hatch
// routes to pass through.
if (!companyId) {
if (isNoCompanyAllowed) {
return supabaseResponse
}
// Degraded resolution (a query FAILED, as opposed to returning no rows)
// means the user's companies are unknown, not absent. Fail open: pass
// the request through and let the layout's own resolution retry or
// surface an error. Redirecting here showed fully onboarded users the
// onboarding wizard again on a transient failure (issue #1053).
if (degraded) {
return supabaseResponse
}
// Enrichment lives in the user-keyed `bankid_enrichment` table (migration
// 20260506160000), it cannot live in extension_data, which is
// company-scoped, and the user has no company yet on this path.
const { data: enrichmentRow } = await supabase
.from('bankid_enrichment')
.select('user_id')
.eq('user_id', user.id)
.maybeSingle()
const destination = enrichmentRow ? '/select-company' : '/onboarding'
return NextResponse.redirect(new URL(destination, request.url))
}
// Set company cookie on the response so downstream requests have it
supabaseResponse.cookies.set('gnubok-company-id', companyId, {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 365,
})
// Allow access to onboarding (for adding new companies), select-company, and companies/new
if (pathname.startsWith('/select-company') || pathname.startsWith('/companies/new') || pathname.startsWith('/onboarding')) {
return supabaseResponse
}
return supabaseResponse
}
async function getSupabaseSessionId(
supabase: ReturnType<typeof createServerClient>,
): 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 (error) {
console.warn('[middleware] could not resolve Supabase session id', error)
return null
}
}
async function signOutTimedOutSession(
supabase: ReturnType<typeof createServerClient>,
): Promise<void> {
try {
await supabase.auth.signOut({ scope: 'local' })
} catch (error) {
console.warn('[middleware] timed-out session revocation failed', error)
}
}
function clearAuthMethodHint(
request: NextRequest,
response: NextResponse,
): void {
if (!request.cookies.has(SESSION_AUTH_METHOD_HINT_COOKIE)) return
request.cookies.delete(SESSION_AUTH_METHOD_HINT_COOKIE)
response.cookies.set(SESSION_AUTH_METHOD_HINT_COOKIE, '', {
path: '/',
maxAge: 0,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
})
}
function clearSessionTimeoutCookies(
request: NextRequest,
response: NextResponse,
): void {
if (request.cookies.has(SESSION_TIMEOUT_COOKIE)) {
request.cookies.delete(SESSION_TIMEOUT_COOKIE)
response.cookies.set(
SESSION_TIMEOUT_COOKIE,
'',
sessionTimeoutClearCookieOptions(),
)
}
clearAuthMethodHint(request, response)
}
function copyResponseCookies(from: NextResponse, to: NextResponse): void {
for (const cookie of from.cookies.getAll()) {
to.cookies.set(cookie)
}
}
function sessionTimeoutResponse(
request: NextRequest,
authResponse: NextResponse,
reason: SessionTimeoutReason,
method: SessionAuthMethod,
): NextResponse {
clearSessionTimeoutCookies(request, authResponse)
if (request.nextUrl.pathname.startsWith('/api')) {
const response = NextResponse.json(
{
error: {
code: 'SESSION_EXPIRED',
message: reason === 'idle'
? 'Sessionen har upphört på grund av inaktivitet.'
: 'Sessionen har upphört av säkerhetsskäl.',
message_en: reason === 'idle'
? 'The session expired due to inactivity.'
: 'The session expired for security reasons.',
reason,
},
},
{ status: 401 },
)
response.headers.set('X-Session-Timeout-Reason', reason)
response.headers.set('Cache-Control', 'no-store')
copyResponseCookies(authResponse, response)
return response
}
const url = new URL('/login', request.url)
url.searchParams.set('reason', reason)
url.searchParams.set('method', method)
const destination = safeReturnTo(
request.nextUrl.pathname + request.nextUrl.search,
'/',
)
if (destination !== '/') url.searchParams.set('next', destination)
const response = NextResponse.redirect(url)
response.headers.set('Cache-Control', 'no-store')
copyResponseCookies(authResponse, response)
return response
}
/**
* Which query parameter each auth page reads its post-auth destination from.
* /login reads `next` (app/(auth)/login/page.tsx), the MFA pages read
* `returnTo` (app/(auth)/mfa/verify/page.tsx, app/(auth)/mfa/enroll/page.tsx).
* Sending the wrong name is a silent no-op, so the mapping is explicit
* rather than guessed per call site.
*/
const AUTH_DESTINATION_PARAM = {
'/login': 'next',
'/mfa/verify': 'returnTo',
'/mfa/enroll': 'returnTo',
} as const
/**
* Bounce to an auth page, remembering where the user was heading.
*
* Fixes two things the hand-rolled redirects did wrong. (1) Cloning
* `request.nextUrl` and overwriting only `pathname` carried the ORIGINAL
* query string onto the auth page: /settings/billing?success=1 arrived as
* /login?success=1, a stray parameter the login page never asked for. The
* URL here is built fresh from the request origin, so it holds nothing but
* the one parameter we set. (2) The destination itself was dropped, so
* emailed deep links and payment returns landed on the dashboard after
* sign-in instead of where the user was going.
*
* Open-redirect guard: the destination is the CURRENT request's path plus
* query, run through `safeReturnTo`, which admits same-origin relative paths
* only. Absolute URLs, protocol-relative `//evil.com`, and the encoded forms
* that normalise into one are rejected, and a rejected (or absent, or
* root) destination degrades to a bare bounce with no parameter at all.
* Nothing attacker-supplied is reflected unvalidated.
*
* MFA semantics are untouched: this only decorates the URL of a redirect
* that was going to happen anyway, on exactly the same conditions. The auth
* pages navigate to the destination only after the step-up succeeds, and the
* next request re-runs this same gate regardless.
*/
function bounceToAuth(
request: NextRequest,
target: keyof typeof AUTH_DESTINATION_PARAM,
) {
// Absolute-path reference: replaces path AND clears query/fragment.
const url = new URL(target, request.url)
const destination = safeReturnTo(
request.nextUrl.pathname + request.nextUrl.search,
'/',
)
if (destination !== '/') {
url.search = `${AUTH_DESTINATION_PARAM[target]}=${encodeURIComponent(destination)}`
}
return NextResponse.redirect(url)
}
/**
* Resolve the active company for the authenticated user.
*
* Resolution: user_preferences → first non-archived membership.
*
* `user_preferences.active_company_id` is the authoritative source for
* the active company on both the Next.js and Postgres RLS side. The
* `gnubok-company-id` cookie is still refreshed for legacy read paths
* but it is no longer READ here, because RLS (via
* `current_active_company_id()`) cannot see cookies: so letting the
* cookie override the database would re-introduce the divergence this
* entire migration exists to fix.
*
* When we fall back to "first membership" (no user_preferences row yet),
* we also upsert user_preferences so subsequent RLS lookups agree with
* us without needing the fallback scan.
*
* RPC-first: `resolve_active_company()` collapses the whole resolution into
* one round trip and is semantically identical to both the query path below
* and `current_active_company_id()` (what RLS reads). `used_fallback` is
* true exactly when the preference was missing, null, or stale, which is
* exactly the condition under which the query path writes the resolved
* company back to user_preferences: the write-back behavior is preserved.
* Falls back to the query path on PGRST202 (self-hosted instance not
* migrated yet, or a deploy racing the branch merge).
*
* Cannot use lib/company/context.ts because middleware runs on Edge.
*/
async function resolveCompanyForMiddleware(
supabase: ReturnType<typeof createServerClient>,
userId: string,
_request: NextRequest
): Promise<{ companyId: string | null; locale: string | null; degraded: boolean }> {
const { data, error } = await supabase.rpc('resolve_active_company')
if (error) {
if (error.code === 'PGRST202') {
// Function not deployed here: use the query path.
return resolveCompanyForMiddlewareViaQueries(supabase, userId, _request)
}
// Issue #1053: a FAILED call degrades (fail open), never reads as "no
// companies". locale null is fine because the degraded flag already
// suppresses the locale-cookie sync at the call site.
console.error('[middleware] resolve_active_company rpc failed', error)
return { companyId: null, locale: null, degraded: true }
}
const row = Array.isArray(data) ? data[0] : data
if (!row) {
// Zero rows = NULL auth.uid(); impossible for the cookie-auth middleware
// client, so treat as degraded rather than redirecting to onboarding.
console.error('[middleware] resolve_active_company returned no row for authenticated user')
return { companyId: null, locale: null, degraded: true }
}
if (row.company_id && row.used_fallback) {
// Write the fallback back to user_preferences so future RLS lookups see
// the same active company without needing the fallback scan. Non-fatal
// on failure: resolution already succeeded, but log it so silent
// persistence failures (#701) are observable.
const { error: writeBackError } = await supabase
.from('user_preferences')
.upsert(
{ user_id: userId, active_company_id: row.company_id },
{ onConflict: 'user_id' }
)
if (writeBackError) {
console.error('[middleware] active company write-back failed', writeBackError)
}
}
return {
companyId: row.company_id ?? null,
locale: row.locale ?? null,
degraded: false,
}
}
/**
* Query-path resolution: the pre-RPC implementation, kept verbatim as the
* fallback for resolveCompanyForMiddleware (see the fallback conditions
* there).
*/
async function resolveCompanyForMiddlewareViaQueries(
supabase: ReturnType<typeof createServerClient>,
userId: string,
_request: NextRequest
): Promise<{ companyId: string | null; locale: string | null; degraded: boolean }> {
// 1. user_preferences (authoritative) + first membership, fetched in
// parallel: the fallback query result doubles as validation when the
// preferred company happens to be the first membership, which is the
// common single-company case, so most requests pay one round trip
// instead of two sequential ones.
const [prefsRes, firstRes] = await Promise.all([
supabase
.from('user_preferences')
.select('active_company_id, locale')
.eq('user_id', userId)
.maybeSingle(),
supabase
.from('company_members')
.select('company_id, companies!inner(archived_at)')
.eq('user_id', userId)
.is('companies.archived_at', null)
.order('created_at', { ascending: true })
.limit(1)
.maybeSingle(),
])
const prefs = prefsRes.data
const firstCompany = firstRes.data
const locale = (prefs?.locale as string | undefined) ?? null
// A FAILED query (as opposed to one returning no rows) means the user's
// companies are unknown right now, not absent: flag it so the caller
// fails open instead of redirecting to onboarding or clearing cookies
// (issue #1053). Middleware cannot throw usefully, hence a flag.
if (prefsRes.error || firstRes.error) {
console.error(
'[middleware] company resolution query failed',
prefsRes.error ?? firstRes.error
)
return { companyId: null, locale, degraded: true }
}
if (prefs?.active_company_id) {
if (prefs.active_company_id === firstCompany?.company_id) {
return { companyId: firstCompany.company_id, locale, degraded: false }
}
const { data: membership, error: membershipError } = await supabase
.from('company_members')
.select('company_id, companies!inner(archived_at)')
.eq('company_id', prefs.active_company_id)
.eq('user_id', userId)
.is('companies.archived_at', null)
.maybeSingle()
// A failed validation must not silently switch the user onto their
// first membership (wrong company for consultants): degrade instead.
if (membershipError) {
console.error('[middleware] company preference validation failed', membershipError)
return { companyId: null, locale, degraded: true }
}
if (membership) return { companyId: membership.company_id, locale, degraded: false }
}
// 2. Fallback: first non-archived membership (already fetched above)
if (!firstCompany) return { companyId: null, locale, degraded: false }
// Write the fallback back to user_preferences so future RLS lookups
// see the same active company without needing this fallback scan.
// Non-fatal on failure: resolution for this request already succeeded,
// the write-back is an optimization, but log it so silent persistence
// failures (#701) are observable.
const { error: writeBackError } = await supabase
.from('user_preferences')
.upsert(
{ user_id: userId, active_company_id: firstCompany.company_id },
{ onConflict: 'user_id' }
)
if (writeBackError) {
console.error('[middleware] active company write-back failed', writeBackError)
}
return { companyId: firstCompany.company_id, locale, degraded: false }
}