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
1 change: 1 addition & 0 deletions src/app/oauth-conformance/oauth-client-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export function createFixture(contract: OAuthClientConformanceContract) {
clientSecret: null,
};
},
saveClientMetadata: async () => {},
};

const authorizeDependencies: AuthorizeDependencies = {
Expand Down
16 changes: 16 additions & 0 deletions src/app/register/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@ describe("POST /register", () => {
const createCalls: Parameters<
RegisterDependencies["createOAuthApplication"]
>[0][] = [];
const metadataCalls: Parameters<
RegisterDependencies["saveClientMetadata"]
>[0][] = [];
const response = await registerRequest(
request({
client_name: "Test Client",
client_uri: "https://client.example.com",
redirect_uris: ["http://localhost:58432/callback"],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"],
Expand All @@ -33,6 +37,9 @@ describe("POST /register", () => {
clientSecret: null,
};
},
saveClientMetadata: async (value) => {
metadataCalls.push(value);
},
},
);

Expand All @@ -48,6 +55,14 @@ describe("POST /register", () => {
public: true,
},
]);
expect(metadataCalls).toEqual([
{
clientId: "client_1",
clientName: "Test Client",
clientUri: "https://client.example.com",
redirectUris: ["http://localhost:58432/callback"],
},
]);
expect(await response.json()).toMatchObject({
client_id: "client_1",
redirect_uris: ["http://localhost:58432/callback"],
Expand All @@ -63,6 +78,7 @@ describe("POST /register", () => {
called = true;
return { id: "unexpected", clientId: "unexpected" };
},
saveClientMetadata: async () => {},
};

const contentType = await registerRequest(request({}, "text/plain"), deps);
Expand Down
13 changes: 13 additions & 0 deletions src/app/register/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { clerkClient } from "@clerk/nextjs/server";
import { expandLocalhostUris } from "@/lib/auth-utils";
import { saveOAuthClientMetadata } from "@/lib/oauth-client-metadata";

// Custom registration endpoint needed because Clerk doesn't support custom scopes
// We only want "openid" scope instead of Clerk's default email/profile scopes
Expand Down Expand Up @@ -28,13 +29,15 @@ export interface RegisterDependencies {
clientId: string;
clientSecret?: string | null;
}>;
saveClientMetadata: typeof saveOAuthClientMetadata;
}

const registerDependencies: RegisterDependencies = {
createOAuthApplication: async (input) => {
const clerk = await clerkClient();
return clerk.oauthApplications.create(input);
},
saveClientMetadata: saveOAuthClientMetadata,
};

export async function registerRequest(
Expand Down Expand Up @@ -143,6 +146,16 @@ export async function registerRequest(
scopes: scope ? scope : "openid",
public: true,
});
try {
await dependencies.saveClientMetadata({
clientId: oauthApp.clientId,
clientName: client_name || "MCP Client",
...(typeof client_uri === "string" ? { clientUri: client_uri } : {}),
redirectUris: redirect_uris,
});
} catch (error) {
console.error("Failed to save OAuth client metadata:", error);
}

// Create response in OAuth Dynamic Client Registration format
const now = Math.floor(Date.now() / 1000);
Expand Down
139 changes: 139 additions & 0 deletions src/app/select-org/actions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { beforeEach, describe, expect, it, mock } from "bun:test";

const auth = mock(
async (): Promise<{ userId: string | null }> => ({
userId: "user_1",
}),
);
const updateUserMetadata = mock(
async (_userId: string, _params: unknown): Promise<unknown> => ({}),
);
const getOAuthClientMetadata = mock(
async (): Promise<{
clientName: string;
clientUri: string;
redirectUris: string[];
} | null> => ({
clientName: "Claude",
clientUri: "https://claude.ai",
redirectUris: ["https://claude.ai/api/mcp/auth_callback"],
}),
);

mock.module("@/lib/oauth-client-metadata", () => ({
getOAuthClientMetadata,
saveOAuthClientMetadata: async () => {},
}));

mock.module("@clerk/nextjs/server", () => ({
auth,
clerkClient: async () => ({ users: { updateUserMetadata } }),
verifyToken: async () => ({ sub: "user_1" }),
}));

const { saveOAuthAttribution } = await import("./actions");

beforeEach(() => {
auth.mockReset();
auth.mockImplementation(async () => ({ userId: "user_1" }));
updateUserMetadata.mockReset();
updateUserMetadata.mockImplementation(async () => ({}));
getOAuthClientMetadata.mockReset();
getOAuthClientMetadata.mockImplementation(async () => ({
clientName: "Claude",
clientUri: "https://claude.ai",
redirectUris: ["https://claude.ai/api/mcp/auth_callback"],
}));
});

describe("saveOAuthAttribution", () => {
it("persists both answers and the signup path in one metadata update", async () => {
const result = await saveOAuthAttribution({
firstDiscoverySource: "ai_answer",
connectorTrigger: "claude_suggestion",
oauthClientId: "client_1",
oauthRedirectUri: "https://claude.ai/api/mcp/auth_callback",
});

expect(result).toEqual({ success: true });
expect(updateUserMetadata).toHaveBeenCalledWith("user_1", {
publicMetadata: {
firstDiscoverySource: "ai_answer",
connectorTrigger: "claude_suggestion",
signupPath: "oauth_picker",
oauthClientId: "client_1",
oauthClientName: "Claude",
oauthClientUri: "https://claude.ai",
oauthRedirectOrigin: "https://claude.ai",
oauthClientType: "dynamically_registered",
},
});
});

it("does not mutate metadata for an invalid answer", async () => {
const result = await saveOAuthAttribution({
firstDiscoverySource: "ai_answer",
connectorTrigger: "not-a-choice",
});

expect(result).toEqual({ success: false });
expect(updateUserMetadata).not.toHaveBeenCalled();
});

it("does not mutate metadata for a signed-out request", async () => {
auth.mockImplementation(async () => ({ userId: null }));

const result = await saveOAuthAttribution({
firstDiscoverySource: "ai_answer",
connectorTrigger: "claude_suggestion",
});

expect(result).toEqual({ success: false });
expect(updateUserMetadata).not.toHaveBeenCalled();
});

it("keeps survey capture working when registered client metadata is missing", async () => {
getOAuthClientMetadata.mockImplementation(async () => null);

const result = await saveOAuthAttribution({
firstDiscoverySource: "ai_answer",
connectorTrigger: "manual_connector_url",
oauthClientId: "client_unknown",
oauthRedirectUri: "cursor://callback/oauth",
});

expect(result).toEqual({ success: true });
expect(updateUserMetadata).toHaveBeenCalledWith("user_1", {
publicMetadata: {
firstDiscoverySource: "ai_answer",
connectorTrigger: "manual_connector_url",
signupPath: "oauth_picker",
oauthClientId: "client_unknown",
oauthRedirectOrigin: "cursor:",
oauthClientType: "pre_registered_or_unknown",
},
});
});

it("omits the redirect origin when it does not match the registered client", async () => {
const result = await saveOAuthAttribution({
firstDiscoverySource: "ai_answer",
connectorTrigger: "claude_suggestion",
oauthClientId: "client_1",
oauthRedirectUri: "https://attacker.example/callback",
});

expect(result).toEqual({ success: true });
expect(updateUserMetadata).toHaveBeenCalledWith("user_1", {
publicMetadata: {
firstDiscoverySource: "ai_answer",
connectorTrigger: "claude_suggestion",
signupPath: "oauth_picker",
oauthClientId: "client_1",
oauthClientName: "Claude",
oauthClientUri: "https://claude.ai",
oauthClientType: "dynamically_registered",
},
});
});
});
78 changes: 78 additions & 0 deletions src/app/select-org/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"use server";

import { auth, clerkClient } from "@clerk/nextjs/server";
import { expandLocalhostUris } from "@/lib/auth-utils";
import { getOAuthClientMetadata } from "@/lib/oauth-client-metadata";
import {
parseOAuthAttribution,
type OAuthAttributionInput,
} from "./attribution";

export async function saveOAuthAttribution(
input: OAuthAttributionInput,
): Promise<{ success: boolean }> {
const attribution = parseOAuthAttribution(input);
if (!attribution) return { success: false };

const { userId } = await auth();
if (!userId) return { success: false };

const oauthClientId = boundedString(input.oauthClientId, 256);
let clientMetadata = null;
if (oauthClientId) {
try {
clientMetadata = await getOAuthClientMetadata(oauthClientId);
} catch (error) {
console.error("Failed to load OAuth client metadata:", error);
}
}

// For dynamically registered clients, only trust the query-string redirect
// URI when it matches one registered for that client.
const redirectUri = boundedString(input.oauthRedirectUri, 2048);
const redirectUriMatchesClient =
!clientMetadata ||
expandLocalhostUris(clientMetadata.redirectUris).includes(redirectUri);
const oauthRedirectOrigin = redirectUriMatchesClient
? urlOrigin(redirectUri)
: undefined;

const clerk = await clerkClient();
await clerk.users.updateUserMetadata(userId, {
publicMetadata: {
...attribution,
...(oauthClientId ? { oauthClientId } : {}),
...(clientMetadata?.clientName
? { oauthClientName: boundedString(clientMetadata.clientName, 256) }
: {}),
...(clientMetadata?.clientUri
? { oauthClientUri: urlOrigin(clientMetadata.clientUri) }
: {}),
...(oauthRedirectOrigin ? { oauthRedirectOrigin } : {}),
oauthClientType: clientMetadata
? "dynamically_registered"
: "pre_registered_or_unknown",
},
});

return { success: true };
}

function urlOrigin(value: unknown): string | undefined {
const bounded = boundedString(value, 2048);
if (!bounded) return undefined;
try {
const url = new URL(bounded);
const origin =
url.protocol === "http:" || url.protocol === "https:"
? url.origin
: url.protocol;
return boundedString(origin, 512) || undefined;
} catch {
return undefined;
}
}

function boundedString(value: unknown, maxLength: number): string {
return typeof value === "string" ? value.trim().slice(0, maxLength) : "";
}
Loading
Loading