Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions .github/workflows/lobu-apply-atlas.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
51 changes: 51 additions & 0 deletions packages/server/src/__tests__/integration/mcp/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
36 changes: 24 additions & 12 deletions packages/server/src/workspace/multi-tenant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading