4ac7b45c8a
The dashboard layout runs on every hard load, hard refresh, company switch and the 16 router.refresh() sites, and loading.tsx cannot paint until it resolves. It cost ~20 network calls in 4 sequential waves: a third getUser() round trip to Supabase Auth (after the proxy's and the route guard's), the company resolution, then 16 reads including four limit-1 probes whose only job is to decide whether to render the Webshop and Körjournal nav rows, and an entitlements read that itself ran two waves. - lib/auth/claims.ts: claimsPinned/userFromClaims extracted from require-auth.ts (unchanged) so the dashboard request context shares the exact pinning + mapping. getDashboardAuthContext verifies the JWT locally and falls back to getUser() only when claims are missing, unpinned or unverifiable: the proxy already performed the per-request revocation check before the layout runs (same semantics approved for routes on 2026-07-23). - Wave 1 (user-keyed, parallel with the company resolution): team membership, profile, user preferences and the memberships join, which now also supplies the active company's row and role, so the separate companies and company_members reads are gone. - Wave 2 (company-keyed): settings, agent profile, the switcher's settings names, entitlements in ONE wave (getCompanyEntitlements takes the team_id the join already carries and runs the grants read alongside config + subscription), and get_dashboard_nav_flags(). - supabase/migrations/20260826120000_get_dashboard_nav_flags.sql: SECURITY INVOKER, STABLE, EXECUTE for authenticated only; RLS applies inside. lib/dashboard/nav-flags.ts wraps it with the pre-RPC four-probe fallback on PGRST202/42883/42501 (self-hosted not yet migrated, deploy ordering) and degrades to hidden rows on any other error. - tests/pg/dashboard-nav-flags-rpc.pg.test.ts (6): fresh company, active vs pending WooCommerce, active Shopify, mileage trips, RLS for a member of another company, EXECUTE grants. Unit tests for the wrapper (RPC row, single-object payload, each fallback code, other errors) and for the entitlements teamId option. ~20 calls / 4 waves -> ~12 calls / 2 waves, 0 auth network calls. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
84 lines
3.1 KiB
TypeScript
84 lines
3.1 KiB
TypeScript
import { createClient } from '@/lib/supabase/server'
|
|
import { NextResponse } from 'next/server'
|
|
import { shouldEnforceMfa } from './mfa'
|
|
import type { User, SupabaseClient } from '@supabase/supabase-js'
|
|
import { claimsPinned, userFromClaims } from './claims'
|
|
|
|
type AuthResult =
|
|
| { user: User; supabase: SupabaseClient; error: null }
|
|
| { user: null; supabase: SupabaseClient; error: NextResponse }
|
|
|
|
// claimsPinned / userFromClaims live in ./claims so the dashboard request
|
|
// context (and later the auth proxy) share the exact same pinning + mapping.
|
|
|
|
/**
|
|
* Auth + MFA guard for API routes.
|
|
*
|
|
* Returns the authenticated user and Supabase client, or a JSON error response.
|
|
* When MFA is required (hosted deployment), verifies AAL2 assurance level.
|
|
*
|
|
* Fast path: getClaims() performs local WebCrypto verification against the
|
|
* shared 10-minute JWKS cache instead of a per-request network getUser()
|
|
* round trip. HS256/self-hosted projects fall back to a server call inside
|
|
* getClaims itself (identical semantics; NEXT_PUBLIC_SELF_HOSTED needs no
|
|
* special-casing). Revocation is still checked on every request by proxy.ts
|
|
* middleware getUser() before any route runs. Claims-sourced metadata
|
|
* (email, app_metadata, is_anonymous) can be up to one access-token TTL
|
|
* stale, which is acceptable for all current consumers: bankid_linked
|
|
* staleness is covered because the middleware MFA gate
|
|
* (lib/supabase/middleware.ts) uses the FRESH getUser result.
|
|
*/
|
|
export async function requireAuth(): Promise<AuthResult> {
|
|
const supabase = await createClient()
|
|
|
|
let user: User | null = null
|
|
try {
|
|
// The typeof guard keeps legacy test mocks (auth object with only
|
|
// getUser) on the old path.
|
|
if (typeof supabase.auth.getClaims === 'function') {
|
|
const { data } = await supabase.auth.getClaims()
|
|
const claims = data?.claims
|
|
if (claims?.sub) {
|
|
if (claimsPinned(claims)) {
|
|
user = userFromClaims(claims)
|
|
} else {
|
|
console.error('requireAuth: getClaims iss/aud pinning failed; falling back to getUser', {
|
|
iss: claims.iss,
|
|
aud: claims.aud,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
// JWKS outage or malformed token: fall through to the server-side check.
|
|
// Logged because every hit here degrades the request to the slower
|
|
// getUser round trip; a spike must be visible in production.
|
|
console.error('requireAuth: getClaims failed; falling back to getUser', err)
|
|
}
|
|
if (!user) {
|
|
const { data } = await supabase.auth.getUser()
|
|
user = data?.user ?? null
|
|
}
|
|
|
|
if (!user) {
|
|
return {
|
|
user: null,
|
|
supabase,
|
|
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
|
}
|
|
}
|
|
|
|
if (shouldEnforceMfa(user)) {
|
|
const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
|
|
if (aal?.nextLevel === 'aal2' && aal?.currentLevel !== 'aal2') {
|
|
return {
|
|
user: null,
|
|
supabase,
|
|
error: NextResponse.json({ error: 'MFA verification required' }, { status: 403 }),
|
|
}
|
|
}
|
|
}
|
|
|
|
return { user, supabase, error: null }
|
|
}
|