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
@@ -117,6 +117,22 @@ describe('MCP company routing', () => {
expect(chain.eq).toHaveBeenCalledWith('company_id', DEFAULT_COMPANY_ID)
})
it('refuses company-dependent calls on a key whose user has no company yet', async () => {
// A key minted from the OAuth popup before onboarding (issue #1814) has
// no default company. The refusal is a distinct, actionable code and
// never reaches the membership lookup.
const { client, chain } = membershipClient({ data: null, error: null })
await expect(
resolveMcpCompanyContext({
supabase: client as never,
userId: 'user-1',
defaultCompanyId: null,
})
).rejects.toMatchObject({ code: 'NO_COMPANY_YET' })
expect(chain.maybeSingle).not.toHaveBeenCalled()
})
it('rejects companies without a current non-archived membership', async () => {
const { client } = membershipClient({ data: null, error: null })
@@ -29,12 +29,24 @@ interface ToolSchemaSource {
}
export function codedError(
code: 'VALIDATION_ERROR' | 'NOT_FOUND' | 'FORBIDDEN' | 'INTERNAL_ERROR',
code: 'VALIDATION_ERROR' | 'NOT_FOUND' | 'FORBIDDEN' | 'INTERNAL_ERROR' | 'NO_COMPANY_YET',
message: string
) {
return Object.assign(new Error(message), { code })
}
/**
* Thrown when a company-scoped operation runs on a key whose user has no
* company at all (minted from the OAuth popup before onboarding, issue
* #1814). Maps to the NO_COMPANY_YET structured error with its remediation.
*/
export function noCompanyYetError(): Error {
return codedError(
'NO_COMPANY_YET',
'This account has no company yet. Create the company in the web app, then retry.'
)
}
function isCompanyRole(value: unknown): value is CompanyRole {
return value === 'owner' || value === 'admin' || value === 'member' || value === 'viewer'
}
@@ -86,10 +98,14 @@ export function extractRequestedCompany(
export async function resolveMcpCompanyContext(args: {
supabase: SupabaseClient
userId: string
defaultCompanyId: string
/** null while the key's user has no company (see validateApiKey). */
defaultCompanyId: string | null
requestedCompanyId?: string
}): Promise<McpCompanyContext> {
const companyId = args.requestedCompanyId ?? args.defaultCompanyId
if (!companyId) {
throw noCompanyYetError()
}
const { data: membership, error } = await args.supabase
.from('company_members')
+49 -20
View File
@@ -156,6 +156,7 @@ import {
codedError,
extractRequestedCompany,
isCompanyDependentTool,
noCompanyYetError,
projectToolInputSchema,
resolveMcpCompanyContext,
} from './company-routing'
@@ -17901,8 +17902,12 @@ function emitToolCallTelemetry(payload: {
errorMessage: string | null
requestId: string | number | null
userId: string
companyId: string
// null/empty while the key's user has no company yet (issue #1814): the
// event-log persister requires a company scope, so nothing is emitted.
companyId: string | null
}): void {
if (!payload.companyId) return
const companyId = payload.companyId
emitAfterResponse(() => eventBus
.emit({
type: 'mcp.tool_called',
@@ -17923,7 +17928,7 @@ function emitToolCallTelemetry(payload: {
errorMessage: payload.errorMessage ? payload.errorMessage.slice(0, 500) : null,
requestId: payload.requestId,
userId: payload.userId,
companyId: payload.companyId,
companyId,
sessionId: payload.actor.sessionId ?? null,
client: payload.actor.client ?? null,
},
@@ -17942,8 +17947,10 @@ function emitToolsListTelemetry(payload: {
latencyMs: number
requestId: string | number | null
userId: string
companyId: string
companyId: string | null
}): void {
if (!payload.companyId) return
const companyId = payload.companyId
emitAfterResponse(() => eventBus
.emit({
type: 'mcp.tools_list_called',
@@ -17955,7 +17962,7 @@ function emitToolsListTelemetry(payload: {
latencyMs: payload.latencyMs,
requestId: payload.requestId,
userId: payload.userId,
companyId: payload.companyId,
companyId,
sessionId: payload.actor.sessionId ?? null,
client: payload.actor.client ?? null,
},
@@ -17975,8 +17982,10 @@ function emitResourceReadTelemetry(payload: {
latencyMs: number
requestId: string | number | null
userId: string
companyId: string
companyId: string | null
}): void {
if (!payload.companyId) return
const companyId = payload.companyId
emitAfterResponse(() => eventBus
.emit({
type: 'mcp.resource_read',
@@ -17991,7 +18000,7 @@ function emitResourceReadTelemetry(payload: {
actorLabel: payload.actor.label ?? null,
requestId: payload.requestId,
userId: payload.userId,
companyId: payload.companyId,
companyId,
sessionId: payload.actor.sessionId ?? null,
client: payload.actor.client ?? null,
},
@@ -18038,9 +18047,9 @@ function checkAndEmitNextHintFollowed(
toolName: string,
actor: ActorContext,
userId: string,
companyId: string,
companyId: string | null,
): void {
if (!sessionId) return
if (!sessionId || !companyId) return
const prev = lastResponseHintBySession.get(sessionId)
if (!prev || prev.expiresAt < Date.now() || prev.suggestedTool !== toolName) return
// Consume the hint so we don't double-count if the agent calls the same
@@ -18074,8 +18083,10 @@ function emitSkillLoaded(payload: {
tier: 'workflow' | 'horizontal' | 'vertical' | 'modifier'
actor: ActorContext
userId: string
companyId: string
companyId: string | null
}): void {
if (!payload.companyId) return
const companyId = payload.companyId
emitAfterResponse(() => eventBus
.emit({
type: 'mcp.skill_loaded',
@@ -18087,7 +18098,7 @@ function emitSkillLoaded(payload: {
actorId: payload.actor.id ?? null,
actorLabel: payload.actor.label ?? null,
userId: payload.userId,
companyId: payload.companyId,
companyId,
},
})
.catch((err) => console.error('[mcp] skill_loaded emit failed:', err)))
@@ -18098,8 +18109,10 @@ function emitWorkflowStarted(payload: {
slug: string
actor: ActorContext
userId: string
companyId: string
companyId: string | null
}): void {
if (!payload.companyId) return
const companyId = payload.companyId
emitAfterResponse(() => eventBus
.emit({
type: 'mcp.workflow_started',
@@ -18110,7 +18123,7 @@ function emitWorkflowStarted(payload: {
actorId: payload.actor.id ?? null,
actorLabel: payload.actor.label ?? null,
userId: payload.userId,
companyId: payload.companyId,
companyId,
},
})
.catch((err) => console.error('[mcp] workflow_started emit failed:', err)))
@@ -18318,7 +18331,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
'Discovery:',
'• tools/list returns common tool schemas. Call gnubok_search_tools(query="…") for specialized tools: it ranks all capabilities; pass detail="name"|"summary"|"full" to control payload size.',
'• gnubok_get_agent_briefing returns recommended_tools: ordered per-workflow tool loadouts (categorize_month, close_period, invoice_run, vat_declaration, payroll_month). If your harness defers tool loading, batch-load a whole workflow in one call (e.g. Claude Code ToolSearch select:a,b,c) instead of searching cluster by cluster.',
`• This connection can work with every non-archived company the API-key user belongs to. Call gnubok_list_companies to discover company_id values. Omit company_id to use the API key default (${companyId}); when selecting another company, repeat company_id on every company-data call, including approval.`,
`• This connection can work with every non-archived company the API-key user belongs to. Call gnubok_list_companies to discover company_id values. Omit company_id to use the API key default (${companyId ?? 'none yet: this account has no company; it must be created in the web app before company-data tools work'}); when selecting another company, repeat company_id on every company-data call, including approval.`,
'• MCP resources use the API key default company. For a selected non-default company, call gnubok_get_agent_briefing with company_id instead of relying on Accounted://company/current or other company-data resources.',
'• When the user asks "how do I do X" or you\'re unsure of the correct sequence (month-end close, VAT review, year-end, invoicing, payroll), call gnubok_list_skills first: domain workflows are documented as loadable skills with tool references.',
'• When a tool is missing, a description misled you, a result looks wrong, or something worked unusually well, call gnubok_feedback (context + suggestion, optional tool_name). It is read by the product team and has fixed real bugs; include ids and what you expected. Rate-limited 1/min/key, so batch a session\'s findings into one call.',
@@ -18492,7 +18505,10 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
}
let toolArgs: Record<string, unknown>
let effectiveCompanyId = companyId
// null only for the company-independent tools on a key whose user has
// no company yet (issue #1814): resolveMcpCompanyContext refuses every
// company-dependent tool with NO_COMPANY_YET before it gets here.
let effectiveCompanyId: string | null = companyId
const companyRoutingStartedAt = Date.now()
try {
const extracted = extractRequestedCompany(rawToolArgs)
@@ -18548,12 +18564,18 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
)
}
// The McpTool.execute contract takes a string company id. Only the
// company-independent tools (search, skills, list_companies) can reach
// this point unbound, and none of them dereferences the id as a tenant:
// the empty string is a placeholder for their signature, never a scope.
const tenantId: string = effectiveCompanyId ?? ''
// Enforce the capability paywall: the MCP/agent path is a paid chokepoint
// just like the HTTP routes (send_invoice → email_send, the two SKV
// submissions → skatteverket). Fail-closed; self-hosted short-circuits to
// all-on inside hasCapability. Blocks before any pending op is staged.
const requiredCapability = MCP_TOOL_CAPABILITY_MAP[toolName]
if (requiredCapability && !(await hasCapability(supabase, effectiveCompanyId, requiredCapability))) {
if (requiredCapability && !(await hasCapability(supabase, tenantId, requiredCapability))) {
const capError = { error: capabilityBlockedError(requiredCapability) }
const publicCapError = projectMcpPayload(capError, toolNamespace)
emitToolCallTelemetry({
@@ -18633,7 +18655,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
// so nothing is ever started for a call that would have been refused.
if (taskCapable && tool.shouldRunAsTask?.(toolArgs)) {
const task = await createMcpTask(supabase, {
companyId: effectiveCompanyId,
companyId: tenantId,
userId,
apiKeyId,
toolName,
@@ -18641,8 +18663,10 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
const taskStartedAt = Date.now()
emitAfterResponse(async () => {
try {
const rawResult = await tool.execute(toolArgs, effectiveCompanyId, userId, supabase, actor)
const canonicalResult = addCompanyToTopLevelNext(rawResult, effectiveCompanyId)
const rawResult = await tool.execute(toolArgs, tenantId, userId, supabase, actor)
const canonicalResult = effectiveCompanyId
? addCompanyToTopLevelNext(rawResult, effectiveCompanyId)
: rawResult
const result = projectMcpPayload(canonicalResult, toolNamespace)
const stored: Record<string, unknown> = {
resultType: 'complete',
@@ -18717,8 +18741,10 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
(toolArgs as Record<string, unknown>).__keyScopes = keyScopes
;(toolArgs as Record<string, unknown>).__toolNamespace = toolNamespace
}
const rawResult = await tool.execute(toolArgs, effectiveCompanyId, userId, supabase, actor)
const canonicalResult = addCompanyToTopLevelNext(rawResult, effectiveCompanyId)
const rawResult = await tool.execute(toolArgs, tenantId, userId, supabase, actor)
const canonicalResult = effectiveCompanyId
? addCompanyToTopLevelNext(rawResult, effectiveCompanyId)
: rawResult
const result = projectMcpPayload(canonicalResult, toolNamespace)
const latencyMs = Date.now() - callStartedAt
const response: Record<string, unknown> = {
@@ -18898,6 +18924,9 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
const dataResource = findResource(uri)
if (dataResource) {
try {
// Data resources are company-scoped; an unbound key (no company yet,
// issue #1814) gets the same NO_COMPANY_YET answer as tools/call.
if (!companyId) throw noCompanyYetError()
const result = await dataResource.read({
supabase,
companyId,