* 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>
242 lines
8.7 KiB
TypeScript
242 lines
8.7 KiB
TypeScript
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()
|
|
|
|
vi.mock('@supabase/ssr', () => ({
|
|
createServerClient: vi.fn(() => ({
|
|
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(),
|
|
rpc: vi.fn(),
|
|
})),
|
|
}))
|
|
|
|
vi.mock('@/lib/auth/invite-tokens', () => ({
|
|
hashInviteToken: vi.fn(),
|
|
}))
|
|
|
|
import { GET } from '../route'
|
|
|
|
describe('GET /auth/callback: recovery flow', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('redirects to /reset-password after a successful recovery OTP (token-hash flow)', async () => {
|
|
verifyOtp.mockResolvedValue({ error: null })
|
|
|
|
const request = new NextRequest(
|
|
'http://localhost:3000/auth/callback?token_hash=abc&type=recovery&next=/reset-password'
|
|
)
|
|
const response = await GET(request)
|
|
|
|
expect(response.status).toBe(307)
|
|
expect(response.headers.get('location')).toBe('http://localhost:3000/reset-password')
|
|
expect(verifyOtp).toHaveBeenCalledWith({ token_hash: 'abc', type: 'recovery' })
|
|
})
|
|
|
|
it('redirects to /reset-password after a successful PKCE exchange when next=/reset-password (no type param)', async () => {
|
|
exchangeCodeForSession.mockResolvedValue({ error: null })
|
|
|
|
const request = new NextRequest(
|
|
'http://localhost:3000/auth/callback?code=xyz&next=/reset-password'
|
|
)
|
|
const response = await GET(request)
|
|
|
|
expect(response.status).toBe(307)
|
|
expect(response.headers.get('location')).toBe('http://localhost:3000/reset-password')
|
|
expect(exchangeCodeForSession).toHaveBeenCalledWith('xyz')
|
|
})
|
|
|
|
it('tags a failed recovery link with flow=recovery so the login page shows reset copy', async () => {
|
|
verifyOtp.mockResolvedValue({ error: { message: 'Token has expired or is invalid' } })
|
|
|
|
const request = new NextRequest(
|
|
'http://localhost:3000/auth/callback?token_hash=expired&type=recovery&next=/reset-password'
|
|
)
|
|
const response = await GET(request)
|
|
|
|
expect(response.status).toBe(307)
|
|
expect(response.headers.get('location')).toBe(
|
|
'http://localhost:3000/login?error=auth_error&flow=recovery'
|
|
)
|
|
})
|
|
|
|
it('tags a failed signup confirmation (PKCE code, no type/next) with flow=signup', async () => {
|
|
exchangeCodeForSession.mockResolvedValue({
|
|
error: { message: 'code verifier missing' },
|
|
})
|
|
|
|
const request = new NextRequest('http://localhost:3000/auth/callback?code=xyz')
|
|
const response = await GET(request)
|
|
|
|
expect(response.status).toBe(307)
|
|
expect(response.headers.get('location')).toBe(
|
|
'http://localhost:3000/login?error=auth_error&flow=signup'
|
|
)
|
|
})
|
|
|
|
it('tags a failed OAuth code exchange (flow=oauth marker) with flow=oauth', async () => {
|
|
exchangeCodeForSession.mockResolvedValue({
|
|
error: { message: 'code verifier missing' },
|
|
})
|
|
|
|
const request = new NextRequest('http://localhost:3000/auth/callback?code=xyz&flow=oauth')
|
|
const response = await GET(request)
|
|
|
|
expect(response.status).toBe(307)
|
|
expect(response.headers.get('location')).toBe(
|
|
'http://localhost:3000/login?error=auth_error&flow=oauth'
|
|
)
|
|
})
|
|
|
|
it('tags a provider denial (no code, only ?error from the provider) with flow=oauth', async () => {
|
|
const request = new NextRequest(
|
|
'http://localhost:3000/auth/callback?flow=oauth&error=access_denied'
|
|
)
|
|
const response = await GET(request)
|
|
|
|
expect(response.status).toBe(307)
|
|
expect(response.headers.get('location')).toBe(
|
|
'http://localhost:3000/login?error=auth_error&flow=oauth'
|
|
)
|
|
expect(exchangeCodeForSession).not.toHaveBeenCalled()
|
|
expect(verifyOtp).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('GET /auth/callback: admin invite flow (type=invite)', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('routes a verified invite to /reset-password and preserves the invite token from next', async () => {
|
|
verifyOtp.mockResolvedValue({ error: null })
|
|
|
|
const request = new NextRequest(
|
|
'http://localhost:3000/auth/callback?token_hash=abc&type=invite&next=/invite/gnubok_inv_tok123'
|
|
)
|
|
const response = await GET(request)
|
|
|
|
expect(response.status).toBe(307)
|
|
expect(response.headers.get('location')).toBe('http://localhost:3000/reset-password')
|
|
expect(verifyOtp).toHaveBeenCalledWith({ token_hash: 'abc', type: 'invite' })
|
|
// The company invite token is persisted as the pre-auth invite cookie so
|
|
// the reset-password handoff can accept the membership after the
|
|
// password is set.
|
|
expect(response.headers.get('set-cookie') ?? '').toContain(
|
|
'gnubok-invite-token=gnubok_inv_tok123'
|
|
)
|
|
})
|
|
|
|
it('routes a verified invite without an invite path in next to /reset-password without the cookie', async () => {
|
|
verifyOtp.mockResolvedValue({ error: null })
|
|
|
|
const request = new NextRequest(
|
|
'http://localhost:3000/auth/callback?token_hash=abc&type=invite'
|
|
)
|
|
const response = await GET(request)
|
|
|
|
expect(response.status).toBe(307)
|
|
expect(response.headers.get('location')).toBe('http://localhost:3000/reset-password')
|
|
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<string, ReturnType<typeof vi.fn>> = {
|
|
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/')
|
|
})
|
|
})
|