diff --git a/.github/workflows/lobu-apply-atlas.yml b/.github/workflows/lobu-apply-atlas.yml index f59db2dff..d2c1ed398 100644 --- a/.github/workflows/lobu-apply-atlas.yml +++ b/.github/workflows/lobu-apply-atlas.yml @@ -35,14 +35,18 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Skip if no token + - name: Skip if secrets not configured id: gate env: TOKEN: ${{ secrets.LOBU_TOKEN }} + ANTHROPIC: ${{ secrets.ANTHROPIC_API_KEY }} run: | if [ -z "${TOKEN:-}" ]; then echo "LOBU_TOKEN not set — skipping (expected for fork PRs)." echo "skip=true" >> "$GITHUB_OUTPUT" + elif [ -z "${ANTHROPIC:-}" ]; then + echo "ANTHROPIC_API_KEY not set — skipping until repo secret is configured." + echo "skip=true" >> "$GITHUB_OUTPUT" else echo "skip=false" >> "$GITHUB_OUTPUT" fi @@ -57,10 +61,10 @@ jobs: working-directory: examples/atlas env: LOBU_TOKEN: ${{ secrets.LOBU_TOKEN }} - ANTHROPIC_API_KEY: ${{ secrets.LOBU_DOGFOOD_ANTHROPIC_API_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} run: | if [ "${{ github.event_name }}" = "pull_request" ]; then - bunx --bun @lobu/cli apply --dry-run + bunx --bun @lobu/cli apply --org atlas --dry-run else - bunx --bun @lobu/cli apply --yes + bunx --bun @lobu/cli apply --org atlas --yes fi diff --git a/packages/server/src/__tests__/integration/mcp/auth.test.ts b/packages/server/src/__tests__/integration/mcp/auth.test.ts index d3a7d7fca..75aad4b47 100644 --- a/packages/server/src/__tests__/integration/mcp/auth.test.ts +++ b/packages/server/src/__tests__/integration/mcp/auth.test.ts @@ -785,6 +785,57 @@ describe('MCP Authentication', () => { ); }); + it('allows OAuth access token to cross-org when user has membership', async () => { + // PATs are intentionally org-scoped (above), but OAuth tokens bind to + // whichever org the user picked at consent time and the membership + // check is the real authorization gate. Without this, `lobu login` + // (which OAuths into one org) would lock the user out of every other + // org they're admin in — including from minting a PAT for that org. + // + // We assert the auth gate passes (not 403 with the cross-org message), + // not full MCP-handshake success — that needs initialize + notify and + // is covered elsewhere. + const org2 = await createTestOrganization({ name: 'OAuth Cross-Org Target' }); + await addUserToOrganization(user.id, org2.id); + const { token } = await createTestAccessToken(user.id, org.id, client.client_id); + + const response = await post(`/mcp/${org2.slug}`, { + body: { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: {}, + }, + token, + }); + + // Anything but the cross-org auth rejection is fine — 400 (missing + // MCP session) is the expected next failure since the test skips + // initialize, but 200 is also OK if the route gets that far. + expect(response.status).not.toBe(403); + }); + + it('rejects OAuth cross-org call when user is not a member', async () => { + const org2 = await createTestOrganization({ name: 'OAuth Stranger Org' }); + // Deliberately not adding user to org2. + const { token } = await createTestAccessToken(user.id, org.id, client.client_id); + + const response = await post(`/mcp/${org2.slug}`, { + body: { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: {}, + }, + token, + }); + + expect(response.status).toBe(403); + const body = await response.json(); + expect(body.error).toBe('forbidden'); + expect(body.error_description).toContain('not a member'); + }); + it('should reject PAT without owl_pat_ prefix', async () => { const response = await post('/mcp', { body: { diff --git a/packages/server/src/workspace/multi-tenant.ts b/packages/server/src/workspace/multi-tenant.ts index ac848a7d9..8f823966f 100644 --- a/packages/server/src/workspace/multi-tenant.ts +++ b/packages/server/src/workspace/multi-tenant.ts @@ -339,21 +339,33 @@ export class MultiTenantProvider implements WorkspaceProvider { let effectiveOrgId = requestedOrgId; - // Token's bound org is the default. On scoped routes the URL slug must - // match the token's binding (already enforced); on unscoped /mcp we now - // resolve the default to the bound org instead of leaving it null. This - // matches the contract documented in `mcp-query-run-split.md`. + // Token's bound org is the default. PATs are intentionally org-scoped: + // a PAT minted for org A must never be usable against org B even if the + // owner has membership in both, so the URL slug must match the bound + // org strictly. OAuth tokens bind to whichever org the user picked at + // consent time but the user often has memberships in many orgs; the + // membership check below is the real authorization gate, so for OAuth + // we trust the URL slug and let membership decide. Without this, a + // user logged in via `lobu login` (which OAuths into one org) cannot + // hit cross-org admin routes like POST /api/:slug/tokens — the very + // call needed to bootstrap a PAT for the second org. On unscoped /mcp + // we still resolve the default to the bound org instead of leaving it + // null, matching the contract in `mcp-query-run-split.md`. if (authInfo.organizationId) { if (requestedOrgId && requestedOrgId !== authInfo.organizationId) { - return c.json( - { - error: 'forbidden', - error_description: 'Token organization does not match URL organization', - }, - 403 - ); + if (isPat) { + return c.json( + { + error: 'forbidden', + error_description: 'Token organization does not match URL organization', + }, + 403 + ); + } + effectiveOrgId = requestedOrgId; + } else { + effectiveOrgId = authInfo.organizationId; } - effectiveOrgId = authInfo.organizationId; } if (!effectiveOrgId) {