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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
2a33291c18
commit
a717f03898
@@ -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
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user