diff --git a/DECISIONS.md b/DECISIONS.md index f1c3b382..9756c328 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1202,3 +1202,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-24] fiscal_periods.previous_period_id is adjacency-only: findNextPeriod ignores a chained period that does not start the day after the current one, and SIE import only wires predecessor/successor links between date-adjacent periods (before: nearest period across any gap). A non-adjacent link is what sent a company's opening balances two years forward (feedback seq 249297); 40 such links exist on prod across 39 companies and are neutralized by the read-side guard, not repaired in this change. A gap in the chain means a missing räkenskapsår (BFL 3 kap), which reports should show as missing rather than bridge silently. [2026-08-24] Pending-operation authorization refusals (401/403 from an executor) release the claim back to 'pending' instead of consuming the op as 'rejected': the refusal happens before any side-effect and reflects the credential, not the booking, so the same op must survive for an authorized approver (/pending UI or a scoped key). Every CommitResult now carries operation_status so agents stop inferring "consumed" from status 'failed'. Deterministic content errors (400) still consume the op: re-staging is the only fix for those. [2026-08-24] tools/call rejects unknown top-level parameters (VALIDATION_ERROR naming the valid keys) instead of ignoring them: hosts do not reliably enforce inputSchema, and a misspelled key silently widened gnubok_query_journal to the whole journal (feedback seq 261545). company_id stays tolerated on every tool because the routing layer owns it. Chose server-side enforcement over per-tool presence guards: every schema already declares additionalProperties:false, so the contract exists, it just was not enforced. +[2026-08-24] Agent-first onboarding (issue #1814) ships as shape B+ (signup inside the MCP OAuth popup, lazy auth, setup tools token-agnostic) and NOT as pre-identity provisional tenants first: bank and Skatteverket connects need a human browser anyway, so the claim-link shape buys little for a lot of TTL/abuse/RLS work; it stays a later auth swap. +[2026-08-24] Keys minted from the OAuth popup before the first company exists get company_id NULL and are bound lazily in validateApiKey (first validation after a company exists) instead of at company creation: creation happens in a Server Action that knows nothing about keys, and one chokepoint covers every creation path. +[2026-08-24] /api/mcp-oauth/authorize now forces TOTP enrollment (not just verification) for password accounts with no factor: the middleware skips enrollment for zero-company users, so a popup signup would otherwise mint an MFA-exempt key for an account with no second factor. BankID-linked accounts stay exempt. +[2026-08-24] /auth/callback honours next only when it targets /api/mcp-oauth/authorize (via safeReturnTo): consent handles the zero-company state, an arbitrary deep link would not. diff --git a/app/(auth)/auth/callback/__tests__/route.test.ts b/app/(auth)/auth/callback/__tests__/route.test.ts index 9f252324..7e77654f 100644 --- a/app/(auth)/auth/callback/__tests__/route.test.ts +++ b/app/(auth)/auth/callback/__tests__/route.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { NextRequest } from 'next/server' +import { createServerClient } from '@supabase/ssr' const verifyOtp = vi.fn() const exchangeCodeForSession = vi.fn() @@ -151,3 +152,90 @@ describe('GET /auth/callback: admin invite flow (type=invite)', () => { expect(response.headers.get('set-cookie') ?? '').not.toContain('gnubok-invite-token') }) }) + +describe('GET /auth/callback: resuming an MCP OAuth consent flow (issue #1814)', () => { + // A signup that started from an MCP client's Connect popup confirms its + // e-mail (or completes Google OAuth) here. The consent page handles the + // zero-company state itself, so it is the one `next` this callback honours + // for a fresh session; anything else still lands on the dashboard. + const CONSENT = '/api/mcp-oauth/authorize?response_type=code&state=xyz' + + function clientWithTeamMembership() { + const chain: Record> = { + select: vi.fn(() => chain), + eq: vi.fn(() => chain), + limit: vi.fn(() => chain), + maybeSingle: vi.fn().mockResolvedValue({ data: { team_id: 'team-1' }, error: null }), + } + return { + auth: { + verifyOtp, + exchangeCodeForSession, + getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } } }), + mfa: { + getAuthenticatorAssuranceLevel: vi.fn().mockResolvedValue({ data: null }), + listFactors: vi.fn().mockResolvedValue({ data: null }), + }, + }, + from: vi.fn(() => chain), + rpc: vi.fn(), + } + } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(createServerClient).mockImplementation(() => clientWithTeamMembership() as never) + }) + + it('sends a confirmed signup back to the consent page when next targets it', async () => { + verifyOtp.mockResolvedValue({ error: null }) + + const request = new NextRequest( + `http://localhost:3000/auth/callback?token_hash=abc&type=signup&next=${encodeURIComponent(CONSENT)}` + ) + const response = await GET(request) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe(`http://localhost:3000${CONSENT}`) + }) + + it('carries the consent destination through the MFA verify step', async () => { + verifyOtp.mockResolvedValue({ error: null }) + const client = clientWithTeamMembership() + client.auth.mfa.getAuthenticatorAssuranceLevel.mockResolvedValue({ + data: { currentLevel: 'aal1', nextLevel: 'aal2' }, + }) + vi.mocked(createServerClient).mockImplementation(() => client as never) + + const request = new NextRequest( + `http://localhost:3000/auth/callback?token_hash=abc&type=signup&next=${encodeURIComponent(CONSENT)}` + ) + const response = await GET(request) + + const location = new URL(response.headers.get('location')!) + expect(location.pathname).toBe('/mfa/verify') + expect(location.searchParams.get('returnTo')).toBe(CONSENT) + }) + + it('still lands on the dashboard for any other next', async () => { + verifyOtp.mockResolvedValue({ error: null }) + + const request = new NextRequest( + 'http://localhost:3000/auth/callback?token_hash=abc&type=signup&next=%2Fsettings' + ) + const response = await GET(request) + + expect(response.headers.get('location')).toBe('http://localhost:3000/') + }) + + it('ignores an off-origin next that merely contains the consent path', async () => { + verifyOtp.mockResolvedValue({ error: null }) + + const request = new NextRequest( + `http://localhost:3000/auth/callback?token_hash=abc&type=signup&next=${encodeURIComponent('https://evil.example' + CONSENT)}` + ) + const response = await GET(request) + + expect(response.headers.get('location')).toBe('http://localhost:3000/') + }) +}) diff --git a/app/(auth)/auth/callback/route.ts b/app/(auth)/auth/callback/route.ts index d34cb74a..706995ce 100644 --- a/app/(auth)/auth/callback/route.ts +++ b/app/(auth)/auth/callback/route.ts @@ -2,6 +2,21 @@ import { createServerClient } from '@supabase/ssr' import { type NextRequest, NextResponse } from 'next/server' import { hashInviteToken } from '@/lib/auth/invite-tokens' import { INVITE_COOKIE_NAME } from '@/lib/auth/consume-invite-cookie' +import { safeReturnTo } from '@/lib/auth/safe-return-to' + +/** + * The one `next` destination this callback honours for a fresh session: the + * MCP OAuth consent page. A signup that started from an MCP client's Connect + * popup (issue #1814) confirms its e-mail or completes Google OAuth here, and + * has to land back on consent instead of the dashboard. Consent handles the + * zero-company state itself, which is why this is safe where an arbitrary + * deep link would not be (a brand-new account has no membership to spend a + * deep link on). Same-origin only, via safeReturnTo. + */ +function oauthResumePath(next: string): string | null { + const safe = safeReturnTo(next, '/') + return safe.startsWith('/api/mcp-oauth/authorize?') ? safe : null +} export async function GET(request: NextRequest) { const { searchParams, origin } = new URL(request.url) @@ -9,6 +24,7 @@ export async function GET(request: NextRequest) { const token_hash = searchParams.get('token_hash') const type = searchParams.get('type') const next = searchParams.get('next') ?? '/' + const resumeOAuth = oauthResumePath(next) // Collect cookies that Supabase sets during auth so we can // explicitly forward them on the redirect response. @@ -101,7 +117,9 @@ export async function GET(request: NextRequest) { // Check MFA status: redirect to verify if factor is enrolled but session is AAL1 const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel() if (aal?.nextLevel === 'aal2' && aal?.currentLevel === 'aal1') { - const response = NextResponse.redirect(new URL('/mfa/verify', origin)) + const verifyUrl = new URL('/mfa/verify', origin) + if (resumeOAuth) verifyUrl.searchParams.set('returnTo', resumeOAuth) + const response = NextResponse.redirect(verifyUrl) for (const { name, value, options } of pendingCookies) { response.cookies.set({ name, value, ...options }) } @@ -220,8 +238,10 @@ export async function GET(request: NextRequest) { } } - // Always redirect to dashboard: it handles zero-company and incomplete states - redirectPath = '/' + // Redirect to the dashboard (it handles zero-company and incomplete + // states), unless the session was created to resume an MCP OAuth + // consent flow: that page handles the zero-company state too. + redirectPath = resumeOAuth ?? '/' } // Create redirect and explicitly set auth cookies on the response diff --git a/app/(auth)/login/login-client.tsx b/app/(auth)/login/login-client.tsx index 719f84f9..ccac4cf3 100644 --- a/app/(auth)/login/login-client.tsx +++ b/app/(auth)/login/login-client.tsx @@ -93,6 +93,10 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu // (/login?next=/api/mcp-oauth/authorize?...). Sanitized to a same-origin // relative path; '/' means no explicit destination. const nextPath = safeReturnTo(searchParams.get('next'), '/') + // A visitor who arrives here from the MCP consent page and has no account + // yet must be able to sign up without losing that destination (issue + // #1814). The register page re-sanitises it through safeReturnTo. + const registerHref = nextPath === '/' ? '/register' : `/register?next=${encodeURIComponent(nextPath)}` const supabase = createClient() const bankIdEnabled = isBankIdEnabled() const googleAuthEnabled = isGoogleAuthEnabled() @@ -548,7 +552,7 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu

{tAuth('bankid_no_account_body')}

{tAuth('bankid_no_account_create')} @@ -696,6 +700,7 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu {googleAuthEnabled && ( setFormError({ kind: 'oauth', message })} /> )} @@ -718,7 +723,7 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu

{tAuth('login_new_here')}{' '} {tAuth('no_account')} diff --git a/app/(auth)/mfa/enroll/page.tsx b/app/(auth)/mfa/enroll/page.tsx index fb19bea0..c7b8a038 100644 --- a/app/(auth)/mfa/enroll/page.tsx +++ b/app/(auth)/mfa/enroll/page.tsx @@ -37,6 +37,23 @@ function MfaEnrollContent() { const returnTo = safeReturnTo(searchParams.get('returnTo'), '/') + // Route-handler destinations (the MCP OAuth consent page sends new + // password accounts here with returnTo=/api/mcp-oauth/authorize…) return + // raw HTML the client router cannot render: hard-navigate, like /mfa/verify. + const leave = () => { + if (returnTo.startsWith('/api/')) { + window.location.assign(returnTo) + return + } + router.push(returnTo) + router.refresh() + } + // Back must not bounce into the consent page: with no factor enrolled it + // redirects straight back here. Abort the connect flow to the app instead. + const abort = () => { + router.push(returnTo.startsWith('/api/') ? '/' : returnTo) + } + // UX defense: middleware already blocks this route for BankID-only users // without a password, but a stale tab might land here too. Bounce them to // the set-password flow before they enroll a factor they cannot later @@ -152,8 +169,7 @@ function MfaEnrollContent() { description: 'Ditt konto är nu skyddat med 2FA.', }) - router.push(returnTo) - router.refresh() + leave() } catch { toast({ title: 'Verifiering misslyckades', @@ -217,7 +233,7 @@ function MfaEnrollContent() { @@ -515,7 +533,7 @@ function RegisterPageContent() { action={ formError.kind === 'email_exists' ? ( {t('sign_in')} @@ -785,6 +803,7 @@ function RegisterPageContent() { {googleAuthEnabled && ( setFormError({ kind: 'oauth', message })} /> )} @@ -807,7 +826,7 @@ function RegisterPageContent() {

{t('already_have_account')}{' '} {t('sign_in')} diff --git a/app/api/events/route.ts b/app/api/events/route.ts index ad4684d0..b4220e94 100644 --- a/app/api/events/route.ts +++ b/app/api/events/route.ts @@ -9,7 +9,7 @@ import { } from '@/lib/auth/api-keys' import { validateQuery } from '@/lib/api/validate' import { EventsQuerySchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' +import { getActiveCompanyId } from '@/lib/company/context' import type { SupabaseClient } from '@supabase/supabase-js' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' @@ -126,10 +126,13 @@ export async function GET(request: Request) { } // Session auth resolves the active company; API-key auth uses the key's bound company. - const companyId = keyCompanyId ?? await requireCompanyId(supabase, userId) + // A key minted before the user's first company exists (companyless OAuth, + // issue #1814) has no bound company either; both cases resolve to null and + // fail closed below rather than throwing. + const companyId = keyCompanyId ?? await getActiveCompanyId(supabase, userId) // Defense in depth: never run the event_log query with an empty/undefined - // scope. requireCompanyId throws when there is no company, but guard the - // key-bound path too so a malformed binding can't widen the query scope. + // scope. A user with no company resolves to null, and the guard also covers + // a malformed key binding so it can't widen the query scope. if (!companyId) { return errorResponseFromCode('FORBIDDEN', log) } diff --git a/app/api/mcp-oauth/authorize/__tests__/route.test.ts b/app/api/mcp-oauth/authorize/__tests__/route.test.ts index df74616c..81319cc9 100644 --- a/app/api/mcp-oauth/authorize/__tests__/route.test.ts +++ b/app/api/mcp-oauth/authorize/__tests__/route.test.ts @@ -4,7 +4,7 @@ import crypto from 'crypto' const mocks = vi.hoisted(() => ({ createClient: vi.fn(), isAllowedRedirectUri: vi.fn(), - requireCompanyId: vi.fn(), + getActiveCompanyId: vi.fn(), getBranding: vi.fn(), })) @@ -21,7 +21,7 @@ vi.mock('@/lib/auth/oauth-allowlist', () => ({ })) vi.mock('@/lib/company/context', () => ({ - requireCompanyId: (...args: unknown[]) => mocks.requireCompanyId(...args), + getActiveCompanyId: (...args: unknown[]) => mocks.getActiveCompanyId(...args), })) vi.mock('@/lib/branding/service', () => ({ @@ -37,15 +37,25 @@ function buildAuthorizeUrl(params: Record): string { } function buildSupabase( - user: { id: string } | null, + user: { id: string; email?: string } | null, companyName = 'Test AB', aal: { currentLevel: string; nextLevel: string } = { currentLevel: 'aal2', nextLevel: 'aal2' }, + verifiedFactors: number = aal.nextLevel === 'aal2' ? 1 : 0, ) { return { auth: { getUser: vi.fn().mockResolvedValue({ data: { user }, error: null }), mfa: { getAuthenticatorAssuranceLevel: vi.fn().mockResolvedValue({ data: aal, error: null }), + listFactors: vi.fn().mockResolvedValue({ + data: { + totp: Array.from({ length: verifiedFactors }, (_, i) => ({ + id: `factor-${i}`, + status: 'verified', + })), + }, + error: null, + }), }, }, from: vi.fn().mockReturnValue({ @@ -67,7 +77,7 @@ describe('GET /api/mcp-oauth/authorize: CSP', () => { process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-key' mocks.createClient.mockResolvedValue(buildSupabase({ id: 'user-1' })) mocks.isAllowedRedirectUri.mockResolvedValue(true) - mocks.requireCompanyId.mockResolvedValue('company-1') + mocks.getActiveCompanyId.mockResolvedValue('company-1') mocks.getBranding.mockReturnValue({ appName: 'gnubok' }) }) @@ -258,7 +268,7 @@ describe('MFA step-up on /api/mcp-oauth/authorize', () => { vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true') vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false') mocks.isAllowedRedirectUri.mockResolvedValue(true) - mocks.requireCompanyId.mockResolvedValue('company-1') + mocks.getActiveCompanyId.mockResolvedValue('company-1') mocks.getBranding.mockReturnValue({ appName: 'gnubok' }) }) @@ -310,6 +320,63 @@ describe('MFA step-up on /api/mcp-oauth/authorize', () => { expect(response.status).toBe(200) }) + it('GET fails closed to /mfa/verify when the assurance lookup returns nothing', async () => { + // A transient auth error must never read as "no MFA needed": consent + // here mints a key that bypasses MFA on every later call. + const supabase = buildSupabase({ id: 'user-1' }, 'Test AB', { currentLevel: 'aal1', nextLevel: 'aal1' }, 1) + ;(supabase.auth.mfa.getAuthenticatorAssuranceLevel as ReturnType).mockResolvedValue({ + data: null, + error: { message: 'boom' }, + }) + mocks.createClient.mockResolvedValue(supabase) + + const response = await GET(new Request(buildAuthorizeUrl(authorizeParams))) + expect(new URL(response.headers.get('location')!).pathname).toBe('/mfa/verify') + }) + + it('GET steps up (not enroll) when a verified factor exists despite an AAL1 answer', async () => { + mocks.createClient.mockResolvedValue( + buildSupabase({ id: 'user-1' }, 'Test AB', { currentLevel: 'aal1', nextLevel: 'aal1' }, 1), + ) + + const response = await GET(new Request(buildAuthorizeUrl(authorizeParams))) + expect(new URL(response.headers.get('location')!).pathname).toBe('/mfa/verify') + }) + + it('GET sends a password account with no factor to /mfa/enroll with returnTo', async () => { + // A brand-new account created inside the OAuth popup (issue #1814) has no + // company, so the middleware never forced enrollment. Without this leg the + // consent would mint an MFA-exempt key for an account with no second factor. + mocks.createClient.mockResolvedValue( + buildSupabase({ id: 'user-1' }, 'Test AB', { currentLevel: 'aal1', nextLevel: 'aal1' }, 0), + ) + + const response = await GET(new Request(buildAuthorizeUrl(authorizeParams))) + + expect(response.status).toBeGreaterThanOrEqual(300) + expect(response.status).toBeLessThan(400) + const location = new URL(response.headers.get('location')!) + expect(location.pathname).toBe('/mfa/enroll') + const returnTo = new URL(location.searchParams.get('returnTo')!, location.origin) + expect(returnTo.pathname).toBe('/api/mcp-oauth/authorize') + expect(returnTo.searchParams.get('state')).toBe('xyz') + }) + + it('POST refuses consent from a password account with no factor', async () => { + mocks.createClient.mockResolvedValue( + buildSupabase({ id: 'user-1' }, 'Test AB', { currentLevel: 'aal1', nextLevel: 'aal1' }, 0), + ) + + const formData = new FormData() + formData.set('consent', 'allow') + const response = await POST( + new Request(buildAuthorizeUrl(authorizeParams), { method: 'POST', body: formData }), + ) + + expect(new URL(response.headers.get('location')!).pathname).toBe('/mfa/enroll') + expect(response.headers.get('location')).not.toContain('code=') + }) + it('GET skips step-up for BankID-linked users (inherently 2FA)', async () => { const supabase = buildSupabase( { id: 'user-1' }, @@ -327,6 +394,66 @@ describe('MFA step-up on /api/mcp-oauth/authorize', () => { }) }) +describe('account with no company yet (issue #1814)', () => { + // Someone who signed up inside the MCP client's OAuth popup has an account + // but no company. Consent must still complete: the key is minted unbound + // and binds itself once the company exists. + const authorizeParams = { + response_type: 'code', + redirect_uri: 'https://claude.ai/api/mcp/auth_callback', + code_challenge: 'abc', + code_challenge_method: 'S256', + scope: 'mcp', + state: 'xyz', + } + + function signScope(scopeParam: string): string { + const key = crypto.createHash('sha256').update('oauth-scope:test-service-key').digest() + return crypto.createHmac('sha256', key).update(scopeParam).digest('base64url') + } + + beforeEach(() => { + vi.clearAllMocks() + process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-key' + mocks.isAllowedRedirectUri.mockResolvedValue(true) + mocks.getActiveCompanyId.mockResolvedValue(null) + mocks.getBranding.mockReturnValue({ appName: 'gnubok' }) + }) + + it('GET renders consent labelled with the account instead of a company', async () => { + const supabase = buildSupabase({ id: 'user-1', email: 'ny@example.se' }) + mocks.createClient.mockResolvedValue(supabase) + + const response = await GET(new Request(buildAuthorizeUrl(authorizeParams))) + expect(response.status).toBe(200) + + const html = await response.text() + expect(html).toContain('ny@example.se') + expect(html).toContain('inget företag') + expect(html).not.toContain('Test AB') + // No company to look up: company_settings is never queried. + expect(supabase.from).not.toHaveBeenCalled() + }) + + it('POST still issues an authorization code', async () => { + mocks.createClient.mockResolvedValue(buildSupabase({ id: 'user-1', email: 'ny@example.se' })) + + const formData = new FormData() + formData.set('consent', 'allow') + formData.set('scope_binding', 'mcp') + formData.set('scope_binding_sig', signScope('mcp')) + + const response = await POST( + new Request(buildAuthorizeUrl(authorizeParams), { method: 'POST', body: formData }), + ) + + expect(response.status).toBe(303) + const location = new URL(response.headers.get('location')!) + expect(location.searchParams.get('code')).toBe('test-auth-code') + expect(location.searchParams.get('state')).toBe('xyz') + }) +}) + describe('RFC 9207 iss parameter on authorization responses', () => { const authorizeParams = { response_type: 'code', @@ -350,7 +477,7 @@ describe('RFC 9207 iss parameter on authorization responses', () => { vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.test.example') mocks.createClient.mockResolvedValue(buildSupabase({ id: 'user-1' })) mocks.isAllowedRedirectUri.mockResolvedValue(true) - mocks.requireCompanyId.mockResolvedValue('company-1') + mocks.getActiveCompanyId.mockResolvedValue('company-1') mocks.getBranding.mockReturnValue({ appName: 'gnubok' }) }) diff --git a/app/api/mcp-oauth/authorize/route.ts b/app/api/mcp-oauth/authorize/route.ts index 10c62295..ebb9473f 100644 --- a/app/api/mcp-oauth/authorize/route.ts +++ b/app/api/mcp-oauth/authorize/route.ts @@ -4,7 +4,7 @@ import { NextResponse } from 'next/server' import type { SupabaseClient, User } from '@supabase/supabase-js' import { createAuthCode } from '@/lib/auth/oauth-codes' import { shouldEnforceMfa } from '@/lib/auth/mfa' -import { requireCompanyId } from '@/lib/company/context' +import { getActiveCompanyId } from '@/lib/company/context' import { getBranding } from '@/lib/branding/service' import { isAllowedRedirectUri } from '@/lib/auth/oauth-allowlist' import { resolveDiscoveryBaseUrl } from '@/lib/api/v1/base-url' @@ -114,7 +114,14 @@ function buildLoginRedirect(request: Request): Response { * The middleware MFA gate deliberately exempts /api/mcp-oauth/* (the token * endpoint is Bearer-only), which makes this route responsible for its own * step-up. Returns null when the session is AAL2 (or MFA isn't required), - * otherwise a redirect to /mfa/verify that returns to this authorize URL. + * otherwise a redirect to /mfa/verify (factor enrolled, session still AAL1) + * or /mfa/enroll (no factor at all) that returns to this authorize URL. + * + * The enrollment leg matters for accounts created inside the OAuth popup + * (issue #1814): the middleware only forces enrollment once a company exists, + * so a brand-new password account would otherwise consent at AAL1 and mint an + * MFA-exempt key for an account with no second factor. BankID-linked accounts + * are exempt via shouldEnforceMfa, same as everywhere else. */ async function requireAal2( supabase: SupabaseClient, @@ -122,15 +129,28 @@ async function requireAal2( request: Request, ): Promise { if (!shouldEnforceMfa(user)) return null - const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel() - if (aal?.nextLevel === 'aal2' && aal?.currentLevel !== 'aal2') { - const url = new URL(request.url) - const returnTo = `${url.pathname}${url.search}` - return NextResponse.redirect( - new URL(`/mfa/verify?returnTo=${encodeURIComponent(returnTo)}`, url.origin), - ) - } - return null + const url = new URL(request.url) + const returnTo = `${url.pathname}${url.search}` + const stepUp = (page: '/mfa/verify' | '/mfa/enroll') => + NextResponse.redirect(new URL(`${page}?returnTo=${encodeURIComponent(returnTo)}`, url.origin)) + + // Only a positive "this session is AAL2" answer lets consent through. A + // failed or empty assurance lookup is treated as AAL1 (verify page), never + // as "no MFA needed": the alternative would mint an MFA-exempt key on a + // transient auth error. + const { data: aal, error: aalError } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel() + if (aalError || !aal) return stepUp('/mfa/verify') + if (aal.currentLevel === 'aal2') return null + if (aal.nextLevel === 'aal2') return stepUp('/mfa/verify') + + // nextLevel below aal2 should mean no verified factor exists. If one does + // exist anyway (inconsistent answer), step up rather than enroll a second + // factor. Otherwise enroll: mirrors the middleware gate (lib/supabase/ + // middleware.ts), which skips zero-company users and so never ran for an + // account created inside the popup. + const { data: factors } = await supabase.auth.mfa.listFactors() + const hasVerifiedFactor = factors?.totp?.some((f) => f.status === 'verified') ?? false + return stepUp(hasVerifiedFactor ? '/mfa/verify' : '/mfa/enroll') } function errorRedirect(request: Request, redirectUri: string, state: string | null, error: string, desc: string): Response { @@ -209,19 +229,33 @@ export async function GET(request: Request) { ) } - const companyId = await requireCompanyId(supabase, user.id) + // null for an account with no company yet (signed up from the OAuth popup, + // issue #1814): consent still goes through, the key is minted unbound and + // binds itself once the company exists. The page says so instead of + // showing a company name. + const companyId = await getActiveCompanyId(supabase, user.id) - // Get company name for the consent page - const { data: settings } = await supabase - .from('company_settings') - .select('company_name') - .eq('company_id', companyId) - .single() - - const companyName = settings?.company_name || user.email + let companyName: string | null = null + if (companyId) { + const { data: settings } = await supabase + .from('company_settings') + .select('company_name') + .eq('company_id', companyId) + .single() + companyName = settings?.company_name || user.email || null + } const appNameLower = escapeHtml(getBranding().appName.toLowerCase()) + const accountRowHtml = companyName + ? ` + ` + : ` + ` + const noCompanyNoteHtml = companyId + ? '' + : `

Du har inget företag i ${appNameLower} ännu. Du kan ansluta ändå: skapa företaget i appen så använder anslutningen det automatiskt, utan att du behöver ansluta på nytt.

` + // CSP nonce for the inline consent UI controls. A nonce-bound script-src // makes the inline block executable while keeping the rest of the page // immune to script injection: without this the consent page is @@ -374,6 +408,12 @@ export async function GET(request: Request) { text-align: right; word-break: break-word; } + .note { + font-size: 0.8125rem; + color: var(--fg-muted); + line-height: 1.55; + margin: -1rem 0 1.75rem; + } .scopes-header { display: flex; justify-content: space-between; @@ -557,9 +597,9 @@ export async function GET(request: Request) {

En extern applikation begär åtkomst till ditt ${appNameLower}-konto. Välj vilka behörigheter du vill bevilja.

+ ${noCompanyNoteHtml}