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.
*/