Skip to content
Open
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
13 changes: 13 additions & 0 deletions apps/mesh/src/api/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,12 +301,25 @@ export async function createApp(options: CreateAppOptions = {}) {
c.set("meshContext", ctx);
}

// Require authentication (user session or API key)
const user = ctx.auth.user;
if (!user) {
return c.json({ error: "Unauthorized" }, 401);
}

// Get connection URL
const connection = await ctx.storage.connections.findById(connectionId);
if (!connection?.connection_url) {
return c.json({ error: "Connection not found" }, 404);
}

if (connection.organization_id !== ctx.organization?.id) {
return c.json(
{ error: "Connection does not belong to your organization" },
403,
);
}

// Get origin auth server - tries Protected Resource Metadata first, then falls back to origin root
const resourceRes = await fetchProtectedResourceMetadata(
connection.connection_url,
Expand Down
152 changes: 145 additions & 7 deletions apps/mesh/src/api/routes/oauth-proxy.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ function createMockEventBus(): EventBus {
};
}

const TEST_ORG_ID = "org_test";
const TEST_AUTH_HEADERS = {
Authorization: "Bearer test-api-key",
};

let database: MeshDatabase;
let app: Awaited<ReturnType<typeof createApp>>;
const connectionMap = new Map<string, string>();
Expand All @@ -103,8 +108,6 @@ describe("MCP OAuth Proxy E2E", () => {
await createTestSchema(database.db);
app = await createApp({ database, eventBus: createMockEventBus() });

const orgId = "org_test";

// Mock auth to allow authenticated requests
spyOn(auth.api, "getMcpSession").mockResolvedValue(null);
spyOn(auth.api, "verifyApiKey").mockResolvedValue({
Expand All @@ -117,7 +120,7 @@ describe("MCP OAuth Proxy E2E", () => {
permissions: { self: ["COLLECTION_CONNECTIONS_LIST"] },
metadata: {
organization: {
id: orgId,
id: TEST_ORG_ID,
slug: "test-org",
name: "Test Organization",
},
Expand All @@ -134,7 +137,7 @@ describe("MCP OAuth Proxy E2E", () => {
.insertInto("connections")
.values({
id: connectionId,
organization_id: orgId,
organization_id: TEST_ORG_ID,
created_by: "test_user",
title: server.name,
connection_type: "HTTP",
Expand All @@ -155,7 +158,7 @@ describe("MCP OAuth Proxy E2E", () => {
.insertInto("connections")
.values({
id: connectionId,
organization_id: orgId,
organization_id: TEST_ORG_ID,
created_by: "test_user",
title: server.name,
connection_type: "HTTP",
Expand All @@ -172,6 +175,135 @@ describe("MCP OAuth Proxy E2E", () => {
await closeDatabase(database);
});

// ===========================================================================
// Access Control - Auth & Organization checks (IDOR protection)
// ===========================================================================

describe("Access Control", () => {
test("returns 401 for unauthenticated requests", async () => {
const connectionId = connectionMap.get(MCP_SERVERS[0]!.url)!;

// Temporarily override the mock to simulate unauthenticated request
const verifyMock = spyOn(auth.api, "verifyApiKey").mockResolvedValue({
valid: false,
error: "Invalid key",
key: null,
} as never);

const res = await app.request(
`/oauth-proxy/${connectionId}/authorize?response_type=code&client_id=test&state=test`,
{ redirect: "manual" },
);

expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toBe("Unauthorized");

// Restore the original mock for subsequent tests
verifyMock.mockResolvedValue({
valid: true,
error: null,
key: {
id: "test-key-id",
name: "Test API Key",
userId: "test-user-id",
permissions: { self: ["COLLECTION_CONNECTIONS_LIST"] },
metadata: {
organization: {
id: TEST_ORG_ID,
slug: "test-org",
name: "Test Organization",
},
},
},
} as never);
});

test("returns 404 for non-existent connection", async () => {
const res = await app.request(
`/oauth-proxy/conn_nonexistent/authorize?response_type=code&client_id=test&state=test`,
{
redirect: "manual",
headers: TEST_AUTH_HEADERS,
},
);

expect(res.status).toBe(404);
const body = await res.json();
expect(body.error).toBe("Connection not found");
});

test("returns 403 for cross-organization connection access (IDOR protection)", async () => {
// Create a connection belonging to a different organization
const crossOrgConnectionId = "conn_cross_org";
await database.db
.insertInto("connections")
.values({
id: crossOrgConnectionId,
organization_id: "org_other", // Different from TEST_ORG_ID
created_by: "other_user",
title: "Cross Org Server",
connection_type: "HTTP",
connection_url: "https://example.com/mcp",
status: "active",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
.execute();

const res = await app.request(
`/oauth-proxy/${crossOrgConnectionId}/authorize?response_type=code&client_id=test&state=test`,
{
redirect: "manual",
headers: TEST_AUTH_HEADERS,
},
);

expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toBe(
"Connection does not belong to your organization",
);
});

test("returns 403 for cross-org token endpoint access", async () => {
const res = await app.request(`/oauth-proxy/conn_cross_org/token`, {
method: "POST",
headers: {
...TEST_AUTH_HEADERS,
"Content-Type": "application/x-www-form-urlencoded",
},
body: "grant_type=authorization_code&code=test_code",
});

expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toBe(
"Connection does not belong to your organization",
);
});

test("returns 403 for cross-org register endpoint access", async () => {
const res = await app.request(`/oauth-proxy/conn_cross_org/register`, {
method: "POST",
headers: {
...TEST_AUTH_HEADERS,
"Content-Type": "application/json",
},
body: JSON.stringify({
client_name: "malicious-client",
redirect_uris: ["https://evil.com/callback"],
}),
});

expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toBe(
"Connection does not belong to your organization",
);
});
});

// ===========================================================================
// Step 1: Protected Resource Metadata Discovery
// ===========================================================================
Expand Down Expand Up @@ -239,7 +371,10 @@ describe("MCP OAuth Proxy E2E", () => {
const connectionId = connectionMap.get(server.url)!;
const res = await app.request(
`/oauth-proxy/${connectionId}/authorize?response_type=code&client_id=test&state=test`,
{ redirect: "manual" },
{
redirect: "manual",
headers: TEST_AUTH_HEADERS,
},
);

// Must be a redirect (302)
Expand All @@ -263,7 +398,10 @@ describe("MCP OAuth Proxy E2E", () => {
const proxyResourceUrl = `http://localhost/mcp/${connectionId}`;
const res = await app.request(
`/oauth-proxy/${connectionId}/authorize?response_type=code&client_id=test&state=test&resource=${encodeURIComponent(proxyResourceUrl)}`,
{ redirect: "manual" },
{
redirect: "manual",
headers: TEST_AUTH_HEADERS,
},
);

expect(res.status).toBe(302);
Expand Down