From b8b51404a3c960868a5c29af6168768d96cf5694 Mon Sep 17 00:00:00 2001
From: Jayko001 <86390011+Jayko001@users.noreply.github.com>
Date: Tue, 22 Sep 2026 04:41:27 +0000
Subject: [PATCH 1/2] Capture OAuth signup attribution
---
.../oauth-conformance/oauth-client-fixture.ts | 1 +
src/app/register/route.test.ts | 16 ++
src/app/register/route.ts | 13 ++
src/app/select-org/actions.test.ts | 117 +++++++++++++
src/app/select-org/actions.ts | 68 ++++++++
src/app/select-org/attribution-survey.tsx | 156 ++++++++++++++++++
src/app/select-org/attribution.test.ts | 38 +++++
src/app/select-org/attribution.ts | 54 ++++++
src/app/select-org/page.tsx | 30 +++-
src/lib/oauth-client-metadata.ts | 63 +++++++
src/lib/redis.ts | 20 +++
11 files changed, 568 insertions(+), 8 deletions(-)
create mode 100644 src/app/select-org/actions.test.ts
create mode 100644 src/app/select-org/actions.ts
create mode 100644 src/app/select-org/attribution-survey.tsx
create mode 100644 src/app/select-org/attribution.test.ts
create mode 100644 src/app/select-org/attribution.ts
create mode 100644 src/lib/oauth-client-metadata.ts
diff --git a/src/app/oauth-conformance/oauth-client-fixture.ts b/src/app/oauth-conformance/oauth-client-fixture.ts
index 9395af22..d7609ba6 100644
--- a/src/app/oauth-conformance/oauth-client-fixture.ts
+++ b/src/app/oauth-conformance/oauth-client-fixture.ts
@@ -72,6 +72,7 @@ export function createFixture(contract: OAuthClientConformanceContract) {
clientSecret: null,
};
},
+ saveClientMetadata: async () => {},
};
const authorizeDependencies: AuthorizeDependencies = {
diff --git a/src/app/register/route.test.ts b/src/app/register/route.test.ts
index 0337cd00..b49ab585 100644
--- a/src/app/register/route.test.ts
+++ b/src/app/register/route.test.ts
@@ -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"],
@@ -33,6 +37,9 @@ describe("POST /register", () => {
clientSecret: null,
};
},
+ saveClientMetadata: async (value) => {
+ metadataCalls.push(value);
+ },
},
);
@@ -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"],
@@ -63,6 +78,7 @@ describe("POST /register", () => {
called = true;
return { id: "unexpected", clientId: "unexpected" };
},
+ saveClientMetadata: async () => {},
};
const contentType = await registerRequest(request({}, "text/plain"), deps);
diff --git a/src/app/register/route.ts b/src/app/register/route.ts
index df19d4fd..342793d4 100644
--- a/src/app/register/route.ts
+++ b/src/app/register/route.ts
@@ -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
@@ -28,6 +29,7 @@ export interface RegisterDependencies {
clientId: string;
clientSecret?: string | null;
}>;
+ saveClientMetadata: typeof saveOAuthClientMetadata;
}
const registerDependencies: RegisterDependencies = {
@@ -35,6 +37,7 @@ const registerDependencies: RegisterDependencies = {
const clerk = await clerkClient();
return clerk.oauthApplications.create(input);
},
+ saveClientMetadata: saveOAuthClientMetadata,
};
export async function registerRequest(
@@ -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);
diff --git a/src/app/select-org/actions.test.ts b/src/app/select-org/actions.test.ts
new file mode 100644
index 00000000..ace0e738
--- /dev/null
+++ b/src/app/select-org/actions.test.ts
@@ -0,0 +1,117 @@
+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 => ({}),
+);
+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",
+ },
+ });
+ });
+});
diff --git a/src/app/select-org/actions.ts b/src/app/select-org/actions.ts
new file mode 100644
index 00000000..bd902159
--- /dev/null
+++ b/src/app/select-org/actions.ts
@@ -0,0 +1,68 @@
+"use server";
+
+import { auth, clerkClient } from "@clerk/nextjs/server";
+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);
+ const oauthRedirectOrigin = urlOrigin(input.oauthRedirectUri);
+ let clientMetadata = null;
+ if (oauthClientId) {
+ try {
+ clientMetadata = await getOAuthClientMetadata(oauthClientId);
+ } catch (error) {
+ console.error("Failed to load OAuth client metadata:", error);
+ }
+ }
+
+ 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) : "";
+}
diff --git a/src/app/select-org/attribution-survey.tsx b/src/app/select-org/attribution-survey.tsx
new file mode 100644
index 00000000..849823bc
--- /dev/null
+++ b/src/app/select-org/attribution-survey.tsx
@@ -0,0 +1,156 @@
+"use client";
+
+import { useState } from "react";
+import { Col } from "@/components/col";
+import { saveOAuthAttribution } from "./actions";
+import type { ConnectorTrigger, DiscoverySource } from "./attribution";
+
+const discoveryChoices: Array<{ value: DiscoverySource; label: string }> = [
+ {
+ value: "ai_answer",
+ label: "chatgpt, claude, perplexity, or another ai answer",
+ },
+ { value: "search_engine", label: "google or another search engine" },
+ { value: "social_or_content", label: "social media or content" },
+ { value: "friend_or_coworker", label: "friend or coworker" },
+ { value: "existing_user", label: "already used KERNEL" },
+ { value: "other", label: "other" },
+];
+
+const triggerChoices: Array<{ value: ConnectorTrigger; label: string }> = [
+ {
+ value: "claude_suggestion",
+ label: "claude suggested KERNEL while completing a task",
+ },
+ {
+ value: "claude_connector_directory",
+ label: "found KERNEL in claude's connector directory",
+ },
+ { value: "kernel_documentation", label: "followed KERNEL documentation" },
+ {
+ value: "manual_connector_url",
+ label: "manually entered the connector url",
+ },
+ {
+ value: "product_or_template",
+ label: "another product or template configured it",
+ },
+ { value: "other", label: "other" },
+];
+
+interface AttributionSurveyProps {
+ onSaved: () => void;
+ oauthClientId?: string;
+ oauthRedirectUri?: string;
+}
+
+export function AttributionSurvey({
+ onSaved,
+ oauthClientId,
+ oauthRedirectUri,
+}: AttributionSurveyProps): React.ReactElement {
+ const [firstDiscoverySource, setFirstDiscoverySource] =
+ useState();
+ const [connectorTrigger, setConnectorTrigger] = useState();
+ const [isSaving, setIsSaving] = useState(false);
+ const [error, setError] = useState(null);
+
+ const handleSubmit = async (
+ event: React.FormEvent,
+ ): Promise => {
+ event.preventDefault();
+ if (!firstDiscoverySource || !connectorTrigger || isSaving) return;
+
+ setIsSaving(true);
+ setError(null);
+ try {
+ const result = await saveOAuthAttribution({
+ firstDiscoverySource,
+ connectorTrigger,
+ oauthClientId,
+ oauthRedirectUri,
+ });
+ if (!result.success) {
+ setError("could not save your answers. please try again.");
+ return;
+ }
+ onSaved();
+ } catch (saveError) {
+ console.error("Failed to save OAuth attribution:", saveError);
+ setError("could not save your answers. please try again.");
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ return (
+
+ );
+}
+
+function ChoiceGroup({
+ legend,
+ name,
+ choices,
+ value,
+ onChange,
+}: {
+ legend: string;
+ name: string;
+ choices: Array<{ value: T; label: string }>;
+ value?: T;
+ onChange: (value: T) => void;
+}): React.ReactElement {
+ return (
+
+ );
+}
diff --git a/src/app/select-org/attribution.test.ts b/src/app/select-org/attribution.test.ts
new file mode 100644
index 00000000..6f791dcb
--- /dev/null
+++ b/src/app/select-org/attribution.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from "bun:test";
+import {
+ CONNECTOR_TRIGGER_VALUES,
+ DISCOVERY_SOURCE_VALUES,
+ parseOAuthAttribution,
+} from "./attribution";
+
+describe("parseOAuthAttribution", () => {
+ it("accepts a valid pair of answers", () => {
+ expect(
+ parseOAuthAttribution({
+ firstDiscoverySource: DISCOVERY_SOURCE_VALUES[0],
+ connectorTrigger: CONNECTOR_TRIGGER_VALUES[0],
+ }),
+ ).toEqual({
+ firstDiscoverySource: DISCOVERY_SOURCE_VALUES[0],
+ connectorTrigger: CONNECTOR_TRIGGER_VALUES[0],
+ signupPath: "oauth_picker",
+ });
+ });
+
+ it("rejects missing answers", () => {
+ expect(
+ parseOAuthAttribution({
+ firstDiscoverySource: DISCOVERY_SOURCE_VALUES[0],
+ }),
+ ).toBeNull();
+ });
+
+ it("rejects values outside the allowed choices", () => {
+ expect(
+ parseOAuthAttribution({
+ firstDiscoverySource: "unknown",
+ connectorTrigger: CONNECTOR_TRIGGER_VALUES[0],
+ }),
+ ).toBeNull();
+ });
+});
diff --git a/src/app/select-org/attribution.ts b/src/app/select-org/attribution.ts
new file mode 100644
index 00000000..5bdaf940
--- /dev/null
+++ b/src/app/select-org/attribution.ts
@@ -0,0 +1,54 @@
+export const DISCOVERY_SOURCE_VALUES = [
+ "ai_answer",
+ "search_engine",
+ "social_or_content",
+ "friend_or_coworker",
+ "existing_user",
+ "other",
+] as const;
+
+export const CONNECTOR_TRIGGER_VALUES = [
+ "claude_suggestion",
+ "claude_connector_directory",
+ "kernel_documentation",
+ "manual_connector_url",
+ "product_or_template",
+ "other",
+] as const;
+
+export type DiscoverySource = (typeof DISCOVERY_SOURCE_VALUES)[number];
+export type ConnectorTrigger = (typeof CONNECTOR_TRIGGER_VALUES)[number];
+
+export interface OAuthAttributionInput {
+ firstDiscoverySource?: unknown;
+ connectorTrigger?: unknown;
+ oauthClientId?: unknown;
+ oauthRedirectUri?: unknown;
+}
+
+export interface OAuthAttributionMetadata {
+ firstDiscoverySource: DiscoverySource;
+ connectorTrigger: ConnectorTrigger;
+ signupPath: "oauth_picker";
+}
+
+export function parseOAuthAttribution(
+ input: OAuthAttributionInput,
+): OAuthAttributionMetadata | null {
+ if (
+ !DISCOVERY_SOURCE_VALUES.includes(
+ input.firstDiscoverySource as DiscoverySource,
+ ) ||
+ !CONNECTOR_TRIGGER_VALUES.includes(
+ input.connectorTrigger as ConnectorTrigger,
+ )
+ ) {
+ return null;
+ }
+
+ return {
+ firstDiscoverySource: input.firstDiscoverySource as DiscoverySource,
+ connectorTrigger: input.connectorTrigger as ConnectorTrigger,
+ signupPath: "oauth_picker",
+ };
+}
diff --git a/src/app/select-org/page.tsx b/src/app/select-org/page.tsx
index b9a9096a..f0776c50 100644
--- a/src/app/select-org/page.tsx
+++ b/src/app/select-org/page.tsx
@@ -19,6 +19,8 @@ import {
type SelectionScope,
type SelectionStage,
} from "./primary-action";
+import { AttributionSurvey } from "./attribution-survey";
+import { parseOAuthAttribution } from "./attribution";
interface OAuthProject {
id: string;
@@ -46,6 +48,7 @@ function SelectOrgContent(): React.ReactElement {
const [selectedScope, setSelectedScope] = useState("organization");
const [projectsError, setProjectsError] = useState(false);
const [selectionError, setSelectionError] = useState(null);
+ const [attributionSaved, setAttributionSaved] = useState(false);
const [canScrollUp, setCanScrollUp] = useState(false);
const [canScrollDown, setCanScrollDown] = useState(false);
const scrollContainerRef = useRef(null);
@@ -242,6 +245,9 @@ function SelectOrgContent(): React.ReactElement {
const memberships =
userMemberships?.data || user?.organizationMemberships || [];
+ const hasSavedAttribution = Boolean(
+ parseOAuthAttribution(user?.publicMetadata || {}),
+ );
if (!memberships.length) {
return (
@@ -264,14 +270,22 @@ function SelectOrgContent(): React.ReactElement {
you need to be a member of at least one organization to continue.
- {
- const params = new URLSearchParams(searchParams.toString());
- params.set("org_created", "true");
- return `/select-org?${params.toString()}`;
- })()}
- skipInvitationScreen={true}
- />
+ {attributionSaved || hasSavedAttribution ? (
+ {
+ const params = new URLSearchParams(searchParams.toString());
+ params.set("org_created", "true");
+ return `/select-org?${params.toString()}`;
+ })()}
+ skipInvitationScreen={true}
+ />
+ ) : (
+ setAttributionSaved(true)}
+ oauthClientId={searchParams.get("client_id") || undefined}
+ oauthRedirectUri={searchParams.get("redirect_uri") || undefined}
+ />
+ )}
);
diff --git a/src/lib/oauth-client-metadata.ts b/src/lib/oauth-client-metadata.ts
new file mode 100644
index 00000000..09b5ebb6
--- /dev/null
+++ b/src/lib/oauth-client-metadata.ts
@@ -0,0 +1,63 @@
+import {
+ getOAuthClientMetadataValue,
+ setOAuthClientMetadataValue,
+} from "./redis";
+
+const OAUTH_CLIENT_METADATA_TTL_SECONDS = 180 * 24 * 60 * 60;
+
+export interface OAuthClientMetadata {
+ clientName: string;
+ clientUri?: string;
+ redirectUris: string[];
+}
+
+function key(clientId: string): string {
+ return `oauth-client-metadata:${clientId}`;
+}
+
+export async function saveOAuthClientMetadata({
+ clientId,
+ clientName,
+ clientUri,
+ redirectUris,
+}: OAuthClientMetadata & { clientId: string }): Promise {
+ const safeClientName = clientName.trim().slice(0, 256);
+ const safeClientUri = clientUri?.trim().slice(0, 2048);
+ const safeRedirectUris = redirectUris.map((uri) => uri.trim().slice(0, 2048));
+ await setOAuthClientMetadataValue({
+ key: key(clientId),
+ ttlSeconds: OAUTH_CLIENT_METADATA_TTL_SECONDS,
+ value: JSON.stringify({
+ clientName: safeClientName,
+ ...(safeClientUri ? { clientUri: safeClientUri } : {}),
+ redirectUris: safeRedirectUris,
+ }),
+ });
+}
+
+export async function getOAuthClientMetadata(
+ clientId: string,
+): Promise {
+ const value = await getOAuthClientMetadataValue(key(clientId));
+ if (!value) return null;
+
+ try {
+ const parsed = JSON.parse(value) as Partial;
+ if (
+ typeof parsed.clientName !== "string" ||
+ !Array.isArray(parsed.redirectUris) ||
+ !parsed.redirectUris.every((uri) => typeof uri === "string")
+ ) {
+ return null;
+ }
+ return {
+ clientName: parsed.clientName,
+ ...(typeof parsed.clientUri === "string"
+ ? { clientUri: parsed.clientUri }
+ : {}),
+ redirectUris: parsed.redirectUris,
+ };
+ } catch {
+ return null;
+ }
+}
diff --git a/src/lib/redis.ts b/src/lib/redis.ts
index 59e66017..5f6abba5 100644
--- a/src/lib/redis.ts
+++ b/src/lib/redis.ts
@@ -187,6 +187,26 @@ export async function getAuthorizationContextForClientId({
export { client as redisClient };
+export async function setOAuthClientMetadataValue({
+ key,
+ value,
+ ttlSeconds,
+}: {
+ key: string;
+ value: string;
+ ttlSeconds: number;
+}): Promise {
+ await ensureConnected();
+ await withReconnect(() => client.setEx(key, ttlSeconds, value));
+}
+
+export async function getOAuthClientMetadataValue(
+ key: string,
+): Promise {
+ await ensureConnected();
+ return withReconnect(() => client.get(key));
+}
+
// MCP Apps capability markers. Streamable HTTP creates one McpServer per
// request, so initialize capability must survive in Redis. The key combines
// the authenticated subject with the server-signed MCP transport session;
From cecbc945c73ff724d0a3adc2a357f3ffc9179557 Mon Sep 17 00:00:00 2001
From: Jay Kothari
Date: Tue, 22 Sep 2026 14:18:08 -0700
Subject: [PATCH 2/2] Only record redirect origin when it matches the
registered client
The select-org query string is browser-controlled, so a mismatched
redirect_uri could pair a real client ID with an unrelated origin.
For dynamically registered clients, drop the redirect origin unless it
matches one of the client's registered redirect URIs.
Co-Authored-By: Claude Opus 5.5
---
src/app/select-org/actions.test.ts | 22 ++++++++++++++++++++++
src/app/select-org/actions.ts | 12 +++++++++++-
2 files changed, 33 insertions(+), 1 deletion(-)
diff --git a/src/app/select-org/actions.test.ts b/src/app/select-org/actions.test.ts
index ace0e738..a318cb96 100644
--- a/src/app/select-org/actions.test.ts
+++ b/src/app/select-org/actions.test.ts
@@ -114,4 +114,26 @@ describe("saveOAuthAttribution", () => {
},
});
});
+
+ 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",
+ },
+ });
+ });
});
diff --git a/src/app/select-org/actions.ts b/src/app/select-org/actions.ts
index bd902159..78a3c549 100644
--- a/src/app/select-org/actions.ts
+++ b/src/app/select-org/actions.ts
@@ -1,6 +1,7 @@
"use server";
import { auth, clerkClient } from "@clerk/nextjs/server";
+import { expandLocalhostUris } from "@/lib/auth-utils";
import { getOAuthClientMetadata } from "@/lib/oauth-client-metadata";
import {
parseOAuthAttribution,
@@ -17,7 +18,6 @@ export async function saveOAuthAttribution(
if (!userId) return { success: false };
const oauthClientId = boundedString(input.oauthClientId, 256);
- const oauthRedirectOrigin = urlOrigin(input.oauthRedirectUri);
let clientMetadata = null;
if (oauthClientId) {
try {
@@ -27,6 +27,16 @@ export async function saveOAuthAttribution(
}
}
+ // 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: {