feat(mcp-oauth): let an account with no company connect and sign up from the OAuth popup (#1814 PR 1) (#1855)

* feat(mcp-oauth): let an account with no company connect and sign up from the OAuth popup

Identity unlock for agent-first onboarding (#1814, shape B+). A person
with no Accounted account can now connect from an MCP client, create the
account inside the Connect popup and finish the OAuth dance.

- authorize/token no longer require a company: consent renders a
  companyless variant and the key is minted with company_id NULL.
- validateApiKey returns companyId string|null and binds an unbound key
  to the user's first company on the first validation after it exists.
- MCP server: company-dependent tools and data resources answer with a
  structured NO_COMPANY_YET error; the company-independent tools still
  run; telemetry skips when there is no company scope.
- /api/events fails closed instead of throwing for an unbound key.
- authorize forces TOTP enrollment (not just verification) for password
  accounts with no factor, since the middleware skips enrollment for
  zero-company users; BankID-linked accounts stay exempt.
- /login forwards next to /register; register, GoogleAuthButton and
  /auth/callback carry it back to the consent page (callback honours
  only /api/mcp-oauth/authorize, via safeReturnTo); /mfa/enroll
  hard-navigates to /api/* destinations like /mfa/verify.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* refactor(company): move getActiveCompanyId out of the next/headers module

lib/auth/api-keys.ts needs the resolver for unbound-key binding, but
lib/company/context.ts imports next/headers for the legacy company cookie
and Turbopack refuses that import on some of api-keys' import paths (the
preview build failed). The resolver and CompanyContextError now live in
lib/company/active-company.ts; context.ts re-exports them so every caller
and test mock is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* fix(mcp-oauth): fail closed on a failed assurance lookup; enroll Back aborts instead of looping

Review findings on #1855: requireAal2 let consent through at AAL1 when
getAuthenticatorAssuranceLevel() returned nothing and a verified factor
existed. Only a positive AAL2 answer passes now; a failed lookup and the
inconsistent verified-factor-at-AAL1 case both step up to /mfa/verify.
Back on /mfa/enroll with the consent page as returnTo went straight back
into the redirect loop; it now aborts to the app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-25 11:25:42 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 2a33291c18
commit a717f03898
21 changed files with 858 additions and 247 deletions
+79
View File
@@ -4,6 +4,14 @@ vi.mock('@supabase/supabase-js', () => ({
createClient: vi.fn(),
}))
const contextMocks = vi.hoisted(() => ({
getActiveCompanyId: vi.fn(),
}))
vi.mock('@/lib/company/active-company', () => ({
getActiveCompanyId: (...args: unknown[]) => contextMocks.getActiveCompanyId(...args),
}))
import {
generateApiKey,
hashApiKey,
@@ -339,4 +347,75 @@ describe('validateApiKey', () => {
mode: 'test',
})
})
describe('unbound keys (minted before the first company existed, issue #1814)', () => {
function setupUnboundKeyClient(rpcRow: Record<string, unknown>) {
const chain = {
update: vi.fn(),
eq: vi.fn(),
is: vi.fn().mockResolvedValue({ data: null, error: null }),
}
chain.update.mockReturnValue(chain)
chain.eq.mockReturnValue(chain)
const from = vi.fn().mockReturnValue(chain)
const rpc = vi.fn().mockResolvedValue({ data: [rpcRow], error: null })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockCreateClient.mockReturnValue({ rpc, from } as any)
return { from, chain }
}
const unboundRow = {
user_id: 'user-123',
company_id: null,
api_key_id: 'ak_1',
api_key_name: 'MCP-klient (OAuth)',
scopes: ['transactions:read'],
rate_limited: false,
mode: 'live',
}
it('binds the key to the user\'s company once one exists and heals the row', async () => {
const { from, chain } = setupUnboundKeyClient(unboundRow)
contextMocks.getActiveCompanyId.mockResolvedValue('company-789')
const result = await validateApiKey('gnubok_sk_test-key-value')
expect('companyId' in result && result.companyId).toBe('company-789')
expect(from).toHaveBeenCalledWith('api_keys')
expect(chain.update).toHaveBeenCalledWith({ company_id: 'company-789' })
expect(chain.eq).toHaveBeenCalledWith('id', 'ak_1')
// Only an unbound row is ever rewritten: a concurrent bind must not be clobbered.
expect(chain.is).toHaveBeenCalledWith('company_id', null)
})
it('returns companyId null while the user still has no company', async () => {
const { from } = setupUnboundKeyClient(unboundRow)
contextMocks.getActiveCompanyId.mockResolvedValue(null)
const result = await validateApiKey('gnubok_sk_test-key-value')
expect('companyId' in result && result.companyId).toBeNull()
expect(from).not.toHaveBeenCalled()
})
it('treats a failed company resolution as still unbound rather than failing the key', async () => {
setupUnboundKeyClient(unboundRow)
contextMocks.getActiveCompanyId.mockRejectedValue(new Error('db blip'))
const result = await validateApiKey('gnubok_sk_test-key-value')
expect('companyId' in result && result.companyId).toBeNull()
expect('userId' in result && result.userId).toBe('user-123')
})
it('skips the heal when the RPC predates api_key_id in its return shape', async () => {
const { from } = setupUnboundKeyClient({ ...unboundRow, api_key_id: undefined })
contextMocks.getActiveCompanyId.mockResolvedValue('company-789')
const result = await validateApiKey('gnubok_sk_test-key-value')
expect('companyId' in result && result.companyId).toBe('company-789')
expect(from).not.toHaveBeenCalled()
})
})
})
+47 -2
View File
@@ -1,5 +1,10 @@
import crypto from 'crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createServiceRoleClient } from '@/lib/supabase/service-client'
// Not lib/company/context: that module imports next/headers for the legacy
// company cookie, and this file is reachable from bundles where that import
// is a build error.
import { getActiveCompanyId } from '@/lib/company/active-company'
const KEY_PREFIX = 'gnubok_sk_'
const REFRESH_TOKEN_PREFIX = 'gnubok_rt_'
@@ -473,7 +478,12 @@ export async function validateApiKey(
): Promise<
| {
userId: string
companyId: string
/**
* The key's default company. null only while the key's user has no
* company at all (minted from the OAuth popup before onboarding, issue
* #1814): the first validation after a company exists binds the key.
*/
companyId: string | null
apiKeyId?: string
apiKeyName?: string
scopes: ApiKeyScope[]
@@ -509,9 +519,12 @@ export async function validateApiKey(
return { error: 'Rate limit exceeded', status: 429 }
}
const companyId: string | null =
row.company_id ?? (await bindUnboundKey(supabase, row.user_id, row.api_key_id))
return {
userId: row.user_id,
companyId: row.company_id,
companyId,
apiKeyId: row.api_key_id,
apiKeyName: row.api_key_name,
scopes: validateScopes(row.scopes) ?? DEFAULT_SCOPES,
@@ -522,6 +535,38 @@ export async function validateApiKey(
}
}
/**
* Late binding for keys minted before the user's first company existed.
*
* The OAuth token endpoint stores company_id NULL for such keys. Company
* creation happens in the web app (a Server Action) which knows nothing about
* the user's keys, so the binding is healed here, on the first validation after
* a company exists: one place, regardless of how the company was created.
* Returns null while the user still has no company. The UPDATE is best-effort:
* a failed write only means the next call resolves again.
*/
async function bindUnboundKey(
supabase: SupabaseClient,
userId: string,
apiKeyId: string | undefined
): Promise<string | null> {
let companyId: string | null
try {
companyId = await getActiveCompanyId(supabase, userId)
} catch {
return null
}
if (!companyId) return null
if (apiKeyId) {
await supabase
.from('api_keys')
.update({ company_id: companyId })
.eq('id', apiKeyId)
.is('company_id', null)
}
return companyId
}
/**
* Check if a given scope is allowed by the key's scopes.
*/
+166
View File
@@ -0,0 +1,166 @@
import type { SupabaseClient } from '@supabase/supabase-js'
/**
* Active-company resolution with no Next.js request-scope dependency.
*
* Split out of lib/company/context.ts (which imports `next/headers` for the
* legacy company cookie) so that modules on the API-key path, notably
* lib/auth/api-keys.ts, can resolve a user's company without dragging
* `next/headers` into every bundle that validates a key. context.ts
* re-exports everything here; import from there unless the cookie import is
* the problem.
*/
/**
* Thrown by setActiveCompany so callers can tell a permissions problem
* ('not_member') apart from a failed/unverified database write
* ('persist_failed'), and by getActiveCompanyId when a resolution query
* fails ('resolution_failed': the active company is unknown right now,
* which is NOT the same as the user having no companies).
*/
export class CompanyContextError extends Error {
constructor(
message: string,
readonly code: 'not_member' | 'persist_failed' | 'resolution_failed'
) {
super(message)
this.name = 'CompanyContextError'
}
}
/**
* Get the active company ID for the authenticated user.
*
* Resolution order: user_preferences → first non-archived membership.
*
* `user_preferences.active_company_id` is the authoritative source. The
* cookie `gnubok-company-id` is written as a hint for backwards-compat but
* is no longer READ as a source of truth, because Postgres RLS (via
* `current_active_company_id()`) can only read the database, not cookies.
* Having Next.js and RLS both read from `user_preferences` keeps them
* perfectly in sync.
*
* RPC-first: tries `resolve_active_company()` (one round trip, semantically
* identical to the query path and to `current_active_company_id()`), falling
* back to the original query path when the function is not deployed
* (PGRST202), the caller lacks EXECUTE (42501: service-role clients), or the
* RPC returns zero rows (NULL auth.uid(), also service-role clients).
*
* Returns null only when the user positively has no non-archived companies.
* Throws CompanyContextError('resolution_failed') when a query fails: a
* transient failure must never read as "no companies", because callers
* redirect that state to the onboarding wizard (issue #1053).
*/
export async function getActiveCompanyId(
supabase: SupabaseClient,
userId: string
): Promise<string | null> {
const { data, error } = await supabase.rpc('resolve_active_company')
if (error) {
// PGRST202: function not in the schema cache (self-hosted instance not
// migrated yet, or a deploy racing the branch merge).
// 42501: EXECUTE is granted to `authenticated` only, so a service-role
// client is refused. These fallbacks are LOAD-BEARING, not defensive:
// app/api/mcp-oauth/token/route.ts, app/api/events/route.ts (API-key
// branch) and lib/auth/api-keys.ts call this with
// createServiceClientNoCookies(), and must silently resolve via the query
// path or the OAuth token flow breaks.
if (error.code === 'PGRST202' || error.code === '42501') {
return getActiveCompanyIdViaQueries(supabase, userId)
}
throw new CompanyContextError(
`Active company resolution failed: ${error.message}`,
'resolution_failed'
)
}
const row = (Array.isArray(data) ? data[0] : data) as
| { company_id: string | null; locale: string | null; used_fallback: boolean }
| undefined
| null
if (!row) {
// Zero rows = NULL auth.uid() inside the RPC, i.e. a service-role client
// (same call sites as the 42501 branch above). The query path filters by
// the explicit userId param and still resolves correctly.
return getActiveCompanyIdViaQueries(supabase, userId)
}
return row.company_id ?? null
}
/**
* Query-path resolution: the pre-RPC implementation, kept verbatim as the
* fallback for getActiveCompanyId (see the fallback conditions there).
*/
async function getActiveCompanyIdViaQueries(
supabase: SupabaseClient,
userId: string
): Promise<string | null> {
// 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. Most requests pay one round trip instead of two
// sequential ones. This runs on every withRouteContext API request and
// every dashboard layout render, so the sequential version was pure
// wall-clock cost. Mirrors resolveCompanyForMiddleware, minus the
// write-back (read paths shouldn't write).
const [prefsRes, firstRes] = await Promise.all([
supabase
.from('user_preferences')
.select('active_company_id')
.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 resolutionError = prefsRes.error ?? firstRes.error
if (resolutionError) {
throw new CompanyContextError(
`Active company resolution failed: ${resolutionError.message}`,
'resolution_failed'
)
}
const prefs = prefsRes.data
const firstCompany = firstRes.data
if (prefs?.active_company_id) {
if (firstCompany && prefs.active_company_id === firstCompany.company_id) {
return firstCompany.company_id
}
// Preference points at a different company than the first membership:
// validate it still resolves to a non-archived company the user is a
// member of before trusting it.
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()
// Falling back to the first membership on a FAILED validation would
// silently switch a multi-company user's active company: fail loudly.
if (membershipError) {
throw new CompanyContextError(
`Active company validation failed: ${membershipError.message}`,
'resolution_failed'
)
}
if (membership) return membership.company_id
}
// Fallback: first non-archived membership by created_at (already fetched)
return firstCompany?.company_id ?? null
}
+6 -153
View File
@@ -2,162 +2,15 @@ import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { cookies } from 'next/headers'
import type { EntityType } from '@/types'
import { CompanyContextError, getActiveCompanyId } from '@/lib/company/active-company'
// The resolver and its error class live in active-company.ts (no
// `next/headers` there) so the API-key path can use them; re-exported here so
// every existing import site and test mock keeps working.
export { CompanyContextError, getActiveCompanyId }
const COMPANY_COOKIE = 'gnubok-company-id'
/**
* Thrown by setActiveCompany so callers can tell a permissions problem
* ('not_member') apart from a failed/unverified database write
* ('persist_failed'), and by getActiveCompanyId when a resolution query
* fails ('resolution_failed': the active company is unknown right now,
* which is NOT the same as the user having no companies).
*/
export class CompanyContextError extends Error {
constructor(
message: string,
readonly code: 'not_member' | 'persist_failed' | 'resolution_failed'
) {
super(message)
this.name = 'CompanyContextError'
}
}
/**
* Get the active company ID for the authenticated user.
*
* Resolution order: user_preferences → first non-archived membership.
*
* `user_preferences.active_company_id` is the authoritative source. The
* cookie `gnubok-company-id` is written as a hint for backwards-compat but
* is no longer READ as a source of truth, because Postgres RLS (via
* `current_active_company_id()`) can only read the database, not cookies.
* Having Next.js and RLS both read from `user_preferences` keeps them
* perfectly in sync.
*
* RPC-first: tries `resolve_active_company()` (one round trip, semantically
* identical to the query path and to `current_active_company_id()`), falling
* back to the original query path when the function is not deployed
* (PGRST202), the caller lacks EXECUTE (42501: service-role clients), or the
* RPC returns zero rows (NULL auth.uid(), also service-role clients).
*
* Returns null only when the user positively has no non-archived companies.
* Throws CompanyContextError('resolution_failed') when a query fails: a
* transient failure must never read as "no companies", because callers
* redirect that state to the onboarding wizard (issue #1053).
*/
export async function getActiveCompanyId(
supabase: SupabaseClient,
userId: string
): Promise<string | null> {
const { data, error } = await supabase.rpc('resolve_active_company')
if (error) {
// PGRST202: function not in the schema cache (self-hosted instance not
// migrated yet, or a deploy racing the branch merge).
// 42501: EXECUTE is granted to `authenticated` only, so a service-role
// client is refused. These fallbacks are LOAD-BEARING, not defensive:
// app/api/mcp-oauth/token/route.ts and app/api/events/route.ts (API-key
// branch) call requireCompanyId with createServiceClientNoCookies(), and
// must silently resolve via the query path or the OAuth token flow breaks.
if (error.code === 'PGRST202' || error.code === '42501') {
return getActiveCompanyIdViaQueries(supabase, userId)
}
throw new CompanyContextError(
`Active company resolution failed: ${error.message}`,
'resolution_failed'
)
}
const row = (Array.isArray(data) ? data[0] : data) as
| { company_id: string | null; locale: string | null; used_fallback: boolean }
| undefined
| null
if (!row) {
// Zero rows = NULL auth.uid() inside the RPC, i.e. a service-role client
// (same call sites as the 42501 branch above). The query path filters by
// the explicit userId param and still resolves correctly.
return getActiveCompanyIdViaQueries(supabase, userId)
}
return row.company_id ?? null
}
/**
* Query-path resolution: the pre-RPC implementation, kept verbatim as the
* fallback for getActiveCompanyId (see the fallback conditions there).
*/
async function getActiveCompanyIdViaQueries(
supabase: SupabaseClient,
userId: string
): Promise<string | null> {
// 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. Most requests pay one round trip instead of two
// sequential ones. This runs on every withRouteContext API request and
// every dashboard layout render, so the sequential version was pure
// wall-clock cost. Mirrors resolveCompanyForMiddleware, minus the
// write-back (read paths shouldn't write).
const [prefsRes, firstRes] = await Promise.all([
supabase
.from('user_preferences')
.select('active_company_id')
.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 resolutionError = prefsRes.error ?? firstRes.error
if (resolutionError) {
throw new CompanyContextError(
`Active company resolution failed: ${resolutionError.message}`,
'resolution_failed'
)
}
const prefs = prefsRes.data
const firstCompany = firstRes.data
if (prefs?.active_company_id) {
if (firstCompany && prefs.active_company_id === firstCompany.company_id) {
return firstCompany.company_id
}
// Preference points at a different company than the first membership:
// validate it still resolves to a non-archived company the user is a
// member of before trusting it.
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()
// Falling back to the first membership on a FAILED validation would
// silently switch a multi-company user's active company: fail loudly.
if (membershipError) {
throw new CompanyContextError(
`Active company validation failed: ${membershipError.message}`,
'resolution_failed'
)
}
if (membership) return membership.company_id
}
// Fallback: first non-archived membership by created_at (already fetched)
return firstCompany?.company_id ?? null
}
/**
* Resolve a company's effective entity type.
*
+13
View File
@@ -2858,6 +2858,19 @@ const SALARY: Record<string, StructuredErrorEntry> = {
message_sv: 'Företaget kunde inte hittas.',
message_en: 'Company not found.',
},
// An API key minted for an account that has not created its first company
// yet (signup from the MCP OAuth popup, issue #1814). Not a lookup miss:
// there is nothing to look up until the company exists.
NO_COMPANY_YET: {
httpStatus: 409,
message_sv: 'Kontot har inget företag ännu. Skapa företaget i appen och försök igen.',
message_en: 'This account has no company yet. Create the company in the web app, then retry; the connection picks it up automatically.',
remediation: {
description:
'Ask the user to finish company setup in the Accounted web app (/onboarding). No re-authentication is needed afterwards: the same connection binds to the new company on its next call.',
tool: 'gnubok_list_companies',
},
},
// Phase 5 PR-1 carry-over: distinct error code for the salary-run DELETE
// FK-null guard so an operator seeing this in logs knows a journal entry
// is at risk, not just a status race.