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
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user