a717f03898
* 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>
422 lines
14 KiB
TypeScript
422 lines
14 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
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,
|
|
extractBearerToken,
|
|
validateScopes,
|
|
hasScope,
|
|
validateApiKey,
|
|
findStageApproveConflict,
|
|
DEFAULT_SCOPES,
|
|
DEFAULT_OAUTH_SCOPES,
|
|
STAGING_SCOPES,
|
|
TOOL_SCOPE_MAP,
|
|
API_KEY_SCOPES,
|
|
} from '../api-keys'
|
|
import { createClient } from '@supabase/supabase-js'
|
|
|
|
const mockCreateClient = vi.mocked(createClient)
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
// ============================================================
|
|
// generateApiKey
|
|
// ============================================================
|
|
|
|
describe('generateApiKey', () => {
|
|
it('returns key starting with "gnubok_sk_"', () => {
|
|
const { key } = generateApiKey()
|
|
expect(key.startsWith('gnubok_sk_')).toBe(true)
|
|
})
|
|
|
|
it('returns 64-char hex SHA-256 hash', () => {
|
|
const { hash } = generateApiKey()
|
|
expect(hash).toMatch(/^[0-9a-f]{64}$/)
|
|
})
|
|
|
|
it('returns prefix of KEY_PREFIX + 8 chars', () => {
|
|
const { key, prefix } = generateApiKey()
|
|
expect(prefix).toBe(key.slice(0, 'gnubok_sk_'.length + 8))
|
|
})
|
|
|
|
it('generates unique keys on successive calls', () => {
|
|
const a = generateApiKey()
|
|
const b = generateApiKey()
|
|
expect(a.key).not.toBe(b.key)
|
|
expect(a.hash).not.toBe(b.hash)
|
|
})
|
|
|
|
it('hash matches hashApiKey(key)', () => {
|
|
const { key, hash } = generateApiKey()
|
|
expect(hashApiKey(key)).toBe(hash)
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// hashApiKey
|
|
// ============================================================
|
|
|
|
describe('hashApiKey', () => {
|
|
it('returns 64-char hex string', () => {
|
|
const hash = hashApiKey('gnubok_sk_test-key')
|
|
expect(hash).toMatch(/^[0-9a-f]{64}$/)
|
|
})
|
|
|
|
it('is deterministic for same input', () => {
|
|
const hash1 = hashApiKey('gnubok_sk_deterministic')
|
|
const hash2 = hashApiKey('gnubok_sk_deterministic')
|
|
expect(hash1).toBe(hash2)
|
|
})
|
|
|
|
it('produces different hashes for different inputs', () => {
|
|
const hash1 = hashApiKey('gnubok_sk_key-a')
|
|
const hash2 = hashApiKey('gnubok_sk_key-b')
|
|
expect(hash1).not.toBe(hash2)
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// extractBearerToken
|
|
// ============================================================
|
|
|
|
describe('extractBearerToken', () => {
|
|
it('extracts token from valid Bearer header', () => {
|
|
const request = new Request('http://localhost', {
|
|
headers: { authorization: 'Bearer my-secret-token' },
|
|
})
|
|
expect(extractBearerToken(request)).toBe('my-secret-token')
|
|
})
|
|
|
|
it('returns null when no authorization header', () => {
|
|
const request = new Request('http://localhost')
|
|
expect(extractBearerToken(request)).toBeNull()
|
|
})
|
|
|
|
it('returns null when header is not Bearer scheme', () => {
|
|
const request = new Request('http://localhost', {
|
|
headers: { authorization: 'Basic dXNlcjpwYXNz' },
|
|
})
|
|
expect(extractBearerToken(request)).toBeNull()
|
|
})
|
|
|
|
it('handles token with special characters', () => {
|
|
const request = new Request('http://localhost', {
|
|
headers: { authorization: 'Bearer gnubok_sk_abc+def/ghi=jkl' },
|
|
})
|
|
expect(extractBearerToken(request)).toBe('gnubok_sk_abc+def/ghi=jkl')
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// validateScopes
|
|
// ============================================================
|
|
|
|
describe('validateScopes', () => {
|
|
it('returns null for null input', () => {
|
|
expect(validateScopes(null)).toBeNull()
|
|
})
|
|
|
|
it('returns null for undefined input', () => {
|
|
expect(validateScopes(undefined)).toBeNull()
|
|
})
|
|
|
|
it('returns null for non-array input', () => {
|
|
expect(validateScopes('transactions:read')).toBeNull()
|
|
expect(validateScopes(42)).toBeNull()
|
|
expect(validateScopes({ scope: 'transactions:read' })).toBeNull()
|
|
})
|
|
|
|
it('filters to only valid API_KEY_SCOPES', () => {
|
|
const result = validateScopes(['transactions:read', 'invalid:scope', 'reports:read'])
|
|
expect(result).toEqual(['transactions:read', 'reports:read'])
|
|
})
|
|
|
|
it('returns null when no valid scopes remain after filter', () => {
|
|
expect(validateScopes(['invalid:scope', 'also:invalid'])).toBeNull()
|
|
})
|
|
|
|
it('preserves valid scopes from mixed input', () => {
|
|
const result = validateScopes(['customers:write', 'bogus', 'invoices:read'])
|
|
expect(result).toEqual(['customers:write', 'invoices:read'])
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// hasScope
|
|
// ============================================================
|
|
|
|
describe('hasScope', () => {
|
|
it('returns true when scope present in array', () => {
|
|
expect(hasScope(['transactions:read', 'reports:read'], 'transactions:read')).toBe(true)
|
|
})
|
|
|
|
it('returns false when scope absent', () => {
|
|
expect(hasScope(['transactions:read', 'reports:read'], 'invoices:write')).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// findStageApproveConflict
|
|
// ============================================================
|
|
|
|
describe('findStageApproveConflict', () => {
|
|
it('returns null when approve scope is absent', () => {
|
|
expect(findStageApproveConflict(['invoices:write', 'reports:read'])).toBeNull()
|
|
})
|
|
|
|
it('returns null when approve scope present but no staging scope', () => {
|
|
expect(
|
|
findStageApproveConflict(['pending_operations:approve', 'reports:read']),
|
|
).toBeNull()
|
|
})
|
|
|
|
it('returns the offending staging scope when both are present', () => {
|
|
expect(
|
|
findStageApproveConflict(['invoices:write', 'pending_operations:approve']),
|
|
).toBe('invoices:write')
|
|
})
|
|
|
|
it('treats every STAGING_SCOPES member as a conflict alongside approve', () => {
|
|
for (const staging of STAGING_SCOPES) {
|
|
expect(findStageApproveConflict([staging, 'pending_operations:approve'])).toBe(staging)
|
|
}
|
|
})
|
|
|
|
it('does NOT treat agent:write as a staging scope', () => {
|
|
expect(STAGING_SCOPES).not.toContain('agent:write')
|
|
// agent:write + approve is not a SoD conflict: memory writes don't stage
|
|
// bookkeeping that approve would commit.
|
|
expect(
|
|
findStageApproveConflict(['agent:write', 'pending_operations:approve']),
|
|
).toBeNull()
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// agent:write scope wiring
|
|
// ============================================================
|
|
|
|
describe('agent:write scope', () => {
|
|
it('is a registered scope with a Swedish label and description', () => {
|
|
expect(API_KEY_SCOPES['agent:write']).toBeDefined()
|
|
expect(API_KEY_SCOPES['agent:write'].label).toBe('Agent: skriv')
|
|
expect(typeof API_KEY_SCOPES['agent:write'].description).toBe('string')
|
|
})
|
|
|
|
it('maps the memory write tools to agent:write', () => {
|
|
expect(TOOL_SCOPE_MAP.gnubok_remember_fact).toBe('agent:write')
|
|
expect(TOOL_SCOPE_MAP.gnubok_forget_fact).toBe('agent:write')
|
|
})
|
|
|
|
it('keeps gnubok_get_agent_briefing on agent:read', () => {
|
|
expect(TOOL_SCOPE_MAP.gnubok_get_agent_briefing).toBe('agent:read')
|
|
})
|
|
|
|
it('is excluded from the default scope grants', () => {
|
|
expect(DEFAULT_SCOPES).not.toContain('agent:write')
|
|
expect(DEFAULT_OAUTH_SCOPES).not.toContain('agent:write')
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// validateApiKey
|
|
// ============================================================
|
|
|
|
describe('validateApiKey', () => {
|
|
function setupMockRpc(response: { data: unknown; error: unknown }) {
|
|
const mockRpc = vi.fn().mockResolvedValue(response)
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
mockCreateClient.mockReturnValue({ rpc: mockRpc } as any)
|
|
}
|
|
|
|
it('rejects keys not starting with "gnubok_sk_"', async () => {
|
|
const result = await validateApiKey('invalid-key-format')
|
|
expect(result).toEqual({ error: 'Invalid API key format', status: 401 })
|
|
})
|
|
|
|
it('rejects a refresh token presented as Bearer with a specific message', async () => {
|
|
const result = await validateApiKey('gnubok_rt_some_refresh_token')
|
|
expect('status' in result && result.status).toBe(401)
|
|
expect('error' in result && result.error).toContain('Refresh token')
|
|
})
|
|
|
|
it('rejects when RPC returns error', async () => {
|
|
setupMockRpc({ data: null, error: { message: 'db error' } })
|
|
|
|
const result = await validateApiKey('gnubok_sk_test-key-value')
|
|
expect(result).toEqual({ error: 'Invalid API key', status: 401 })
|
|
})
|
|
|
|
it('rejects when RPC returns empty data array', async () => {
|
|
setupMockRpc({ data: [], error: null })
|
|
|
|
const result = await validateApiKey('gnubok_sk_test-key-value')
|
|
expect(result).toEqual({ error: 'Invalid API key', status: 401 })
|
|
})
|
|
|
|
it('returns rate limit error when rate_limited is true', async () => {
|
|
setupMockRpc({
|
|
data: [{ user_id: 'u1', company_id: 'c1', scopes: null, rate_limited: true }],
|
|
error: null,
|
|
})
|
|
|
|
const result = await validateApiKey('gnubok_sk_test-key-value')
|
|
expect(result).toEqual({ error: 'Rate limit exceeded', status: 429 })
|
|
})
|
|
|
|
it('returns userId, companyId, scopes on success', async () => {
|
|
setupMockRpc({
|
|
data: [{
|
|
user_id: 'user-123',
|
|
company_id: 'company-456',
|
|
scopes: ['transactions:read', 'reports:read'],
|
|
rate_limited: false,
|
|
}],
|
|
error: null,
|
|
})
|
|
|
|
const result = await validateApiKey('gnubok_sk_test-key-value')
|
|
expect(result).toEqual({
|
|
userId: 'user-123',
|
|
companyId: 'company-456',
|
|
apiKeyId: undefined,
|
|
apiKeyName: undefined,
|
|
scopes: ['transactions:read', 'reports:read'],
|
|
mode: 'live',
|
|
})
|
|
})
|
|
|
|
it('falls back to DEFAULT_SCOPES when row.scopes is null', async () => {
|
|
setupMockRpc({
|
|
data: [{
|
|
user_id: 'user-123',
|
|
company_id: 'company-456',
|
|
scopes: null,
|
|
rate_limited: false,
|
|
}],
|
|
error: null,
|
|
})
|
|
|
|
const result = await validateApiKey('gnubok_sk_test-key-value')
|
|
expect(result).toEqual({
|
|
userId: 'user-123',
|
|
companyId: 'company-456',
|
|
apiKeyId: undefined,
|
|
apiKeyName: undefined,
|
|
scopes: DEFAULT_SCOPES,
|
|
mode: 'live',
|
|
})
|
|
})
|
|
|
|
it('surfaces mode from the RPC row', async () => {
|
|
setupMockRpc({
|
|
data: [{
|
|
user_id: 'user-123',
|
|
company_id: 'company-456',
|
|
api_key_id: 'ak_1',
|
|
api_key_name: 'CI test key',
|
|
scopes: ['transactions:read'],
|
|
rate_limited: false,
|
|
mode: 'test',
|
|
}],
|
|
error: null,
|
|
})
|
|
|
|
const result = await validateApiKey('gnubok_sk_test-key-value')
|
|
expect(result).toEqual({
|
|
userId: 'user-123',
|
|
companyId: 'company-456',
|
|
apiKeyId: 'ak_1',
|
|
apiKeyName: 'CI test key',
|
|
scopes: ['transactions:read'],
|
|
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()
|
|
})
|
|
})
|
|
})
|