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 ( +
+ + + + {error ? ( +

+ {error} +

+ ) : null} + + + + ); +} + +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 ( +
+ {legend} + + {choices.map((choice) => ( + + ))} + +
+ ); +} 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: {