From 912d57c69232400be7f18ffabf6b029748115add Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Wed, 10 Sep 2025 12:27:19 -0400 Subject: [PATCH] Add PKCE integration for Hugging Face --- pnpm-lock.yaml | 21 ++----- src/activate/handleUri.ts | 8 +++ src/core/webview/ClineProvider.ts | 63 +++++++++++++++++++ src/core/webview/webviewMessageHandler.ts | 13 ++++ src/shared/WebviewMessage.ts | 1 + src/shared/oauth-constants.ts | 10 +++ webview-ui/package.json | 1 + .../src/components/settings/ApiOptions.tsx | 6 +- .../src/components/settings/SettingsView.tsx | 43 +++++++++++++ .../settings/providers/HuggingFace.tsx | 34 ++++++++-- webview-ui/src/oauth/urls.ts | 19 ++++++ 11 files changed, 197 insertions(+), 22 deletions(-) create mode 100644 src/shared/oauth-constants.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fdbaa993b1..eef5098061 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1029,6 +1029,9 @@ importers: mermaid: specifier: ^11.4.1 version: 11.10.0 + pkce-challenge: + specifier: ^5.0.0 + version: 5.0.0 posthog-js: specifier: ^1.227.2 version: 1.242.1 @@ -4049,9 +4052,6 @@ packages: '@types/node@20.17.57': resolution: {integrity: sha512-f3T4y6VU4fVQDKVqJV4Uppy8c1p/sVvS3peyqxyWnzkqXFJLRU7Y1Bl7rMS1Qe9z0v4M6McY0Fp9yBsgHJUsWQ==} - '@types/node@20.19.13': - resolution: {integrity: sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==} - '@types/node@24.2.1': resolution: {integrity: sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==} @@ -9450,9 +9450,6 @@ packages: undici-types@6.19.8: resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici-types@7.10.0: resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==} @@ -13533,11 +13530,6 @@ snapshots: dependencies: undici-types: 6.19.8 - '@types/node@20.19.13': - dependencies: - undici-types: 6.21.0 - optional: true - '@types/node@24.2.1': dependencies: undici-types: 7.10.0 @@ -13603,7 +13595,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 20.19.13 + '@types/node': 24.2.1 optional: true '@types/yargs-parser@21.0.3': {} @@ -13776,7 +13768,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.57)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) '@vitest/utils@3.2.4': dependencies: @@ -19851,9 +19843,6 @@ snapshots: undici-types@6.19.8: {} - undici-types@6.21.0: - optional: true - undici-types@7.10.0: {} undici@6.21.3: {} diff --git a/src/activate/handleUri.ts b/src/activate/handleUri.ts index 7f0b4c64cc..106b3074d2 100644 --- a/src/activate/handleUri.ts +++ b/src/activate/handleUri.ts @@ -28,6 +28,14 @@ export const handleUri = async (uri: vscode.Uri) => { } break } + case "/huggingface": { + const code = query.get("code") + const state = query.get("state") + if (code) { + await visibleProvider.handleHuggingFaceCallback(code, state || undefined) + } + break + } case "/requesty": { const code = query.get("code") if (code) { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index dbd6283bee..8ea8e8867a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -55,6 +55,7 @@ import { formatLanguage } from "../../shared/language" import { WebviewMessage } from "../../shared/WebviewMessage" import { EMBEDDING_MODEL_PROFILES } from "../../shared/embeddingModels" import { ProfileValidator } from "../../shared/ProfileValidator" +import { HUGGING_FACE_OAUTH_CLIENT_ID } from "../../shared/oauth-constants" import { Terminal } from "../../integrations/terminal/Terminal" import { downloadTask } from "../../integrations/misc/export-markdown" @@ -1424,6 +1425,68 @@ export class ClineProvider await this.upsertProviderProfile(currentApiConfigName, newConfiguration) } + // HuggingFace + + async handleHuggingFaceCallback(code: string, returnedState?: string) { + let { apiConfiguration, currentApiConfigName = "default" } = await this.getState() + + try { + // Retrieve stored PKCE verifier and state from extension state + const pkceSecret = await this.context.secrets.get("huggingFacePkce") + const pkceData = pkceSecret ? JSON.parse(pkceSecret) : undefined + + if (!pkceData || !pkceData.verifier || !pkceData.state) { + throw new Error("PKCE verifier or state not found in extension state.") + } + + // Optional state validation (if state was provided in the callback) + if (returnedState && pkceData.state && returnedState !== pkceData.state) { + // Clear stored data before throwing to avoid reuse + await this.context.secrets.delete("huggingFacePkce") + throw new Error("OAuth state mismatch.") + } + + const verifier: string = pkceData.verifier + + // Clear PKCE data to prevent reuse + await this.context.secrets.delete("huggingFacePkce") + + const redirectUri = `${vscode.env.uriScheme}://${Package.publisher}.${Package.name}/huggingface` + + const params = new URLSearchParams() + params.append("grant_type", "authorization_code") + params.append("code", code) + params.append("client_id", HUGGING_FACE_OAUTH_CLIENT_ID) + params.append("code_verifier", verifier) + params.append("redirect_uri", redirectUri) + + const response = await axios.post("https://huggingface.co/oauth/token", params, { + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + }) + + const accessToken: string | undefined = response.data?.access_token + + if (!accessToken) { + throw new Error("Invalid response from Hugging Face token endpoint") + } + + const newConfiguration: ProviderSettings = { + ...apiConfiguration, + apiProvider: "huggingface", + huggingFaceApiKey: accessToken, + huggingFaceModelId: apiConfiguration?.huggingFaceModelId || "meta-llama/Llama-3.3-70B-Instruct", + huggingFaceInferenceProvider: apiConfiguration?.huggingFaceInferenceProvider || "auto", + } + + await this.upsertProviderProfile(currentApiConfigName, newConfiguration) + } catch (error) { + this.log( + `Error exchanging code for Hugging Face access token: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, + ) + throw error + } + } + // Requesty async handleRequestyCallback(code: string) { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index d88d10d22a..4b8b074e6f 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -978,6 +978,19 @@ export const webviewMessageHandler = async ( vscode.env.openExternal(vscode.Uri.parse(message.url)) } break + case "storeHuggingFacePkce": { + // Store PKCE verifier/state as a secret to avoid typing constraints on ContextProxy keys + const verifier = message.values?.verifier + const state = message.values?.state + if (typeof verifier === "string" && typeof state === "string" && verifier.length > 0 && state.length > 0) { + try { + await provider.context.secrets.store("huggingFacePkce", JSON.stringify({ verifier, state })) + } catch (error) { + console.error("Failed to store Hugging Face PKCE data:", error) + } + } + break + } case "checkpointDiff": const result = checkoutDiffPayloadSchema.safeParse(message.payload) diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index ae8c72dd04..ffa471d3a9 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -191,6 +191,7 @@ export interface WebviewMessage { | "profileThresholds" | "setHistoryPreviewCollapsed" | "openExternal" + | "storeHuggingFacePkce" | "filterMarketplaceItems" | "marketplaceButtonClicked" | "installMarketplaceItem" diff --git a/src/shared/oauth-constants.ts b/src/shared/oauth-constants.ts new file mode 100644 index 0000000000..507bf18e0e --- /dev/null +++ b/src/shared/oauth-constants.ts @@ -0,0 +1,10 @@ +/** + * OAuth Client IDs and Constants + * + * These are public OAuth client identifiers used in OAuth flows. + * They are safe to be exposed as they identify the application to OAuth providers. + * Unlike client secrets, these are designed to be public in OAuth 2.0 public clients. + */ + +// Hugging Face OAuth client ID for PKCE flow +export const HUGGING_FACE_OAUTH_CLIENT_ID = "aba045f7-aceb-4e53-9247-5c85d7c2b7cb" diff --git a/webview-ui/package.json b/webview-ui/package.json index 681aca126d..9476316afc 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -50,6 +50,7 @@ "lru-cache": "^11.1.0", "lucide-react": "^0.518.0", "mermaid": "^11.4.1", + "pkce-challenge": "^5.0.0", "posthog-js": "^1.227.2", "pretty-bytes": "^7.0.0", "react": "^18.3.1", diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 7f2ac4ed7a..da75079656 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -595,7 +595,11 @@ const ApiOptions = ({ )} {selectedProvider === "huggingface" && ( - + )} {selectedProvider === "cerebras" && ( diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 632873308e..cd80440ced 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -212,6 +212,49 @@ const SettingsView = forwardRef(({ onDone, t } }, [settingsImportedAt, extensionState]) + // Sync OAuth-driven key updates from extension without requiring a profile name change + // This should only happen when the extension updates the key (e.g., after OAuth callback) + // not when the user is manually editing it + const [hasHuggingFaceSynced, setHasHuggingFaceSynced] = useState(false) + + useEffect(() => { + const extApi = extensionState.apiConfiguration ?? {} + const cachedApi = cachedState.apiConfiguration ?? {} + + // Only sync if: + // 1. Extension has a key + // 2. It's different from what we started with + // 3. We haven't synced this key yet + if ( + extApi.huggingFaceApiKey && + extApi.huggingFaceApiKey !== cachedApi.huggingFaceApiKey && + !hasHuggingFaceSynced + ) { + setCachedState((prev) => ({ + ...prev, + apiConfiguration: { + ...prev.apiConfiguration, + // Keep provider in sync if extension switched it during callback + apiProvider: extApi.apiProvider ?? prev.apiConfiguration?.apiProvider, + huggingFaceApiKey: extApi.huggingFaceApiKey, + // Preserve/merge model fields from extension if present + huggingFaceModelId: extApi.huggingFaceModelId ?? prev.apiConfiguration?.huggingFaceModelId, + huggingFaceInferenceProvider: + extApi.huggingFaceInferenceProvider ?? prev.apiConfiguration?.huggingFaceInferenceProvider, + }, + })) + // Mark that we've synced this key + setHasHuggingFaceSynced(true) + // Receiving fresh state from the extension should not mark the form dirty + setChangeDetected(false) + } + }, [extensionState.apiConfiguration, cachedState.apiConfiguration, hasHuggingFaceSynced]) + + // Reset the sync flag when the profile changes + useEffect(() => { + setHasHuggingFaceSynced(false) + }, [currentApiConfigName]) + const setCachedStateField: SetCachedStateField = useCallback((field, value) => { setCachedState((prevState) => { if (prevState[field] === value) { diff --git a/webview-ui/src/components/settings/providers/HuggingFace.tsx b/webview-ui/src/components/settings/providers/HuggingFace.tsx index 94f60cc4c8..7be6b9cf3d 100644 --- a/webview-ui/src/components/settings/providers/HuggingFace.tsx +++ b/webview-ui/src/components/settings/providers/HuggingFace.tsx @@ -1,16 +1,17 @@ import { useCallback, useState, useEffect, useMemo } from "react" import { useEvent } from "react-use" -import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" +import { VSCodeTextField, VSCodeButton } from "@vscode/webview-ui-toolkit/react" +import pkceChallenge from "pkce-challenge" import type { ProviderSettings } from "@roo-code/types" import { ExtensionMessage } from "@roo/ExtensionMessage" import { vscode } from "@src/utils/vscode" import { useAppTranslation } from "@src/i18n/TranslationContext" -import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" import { SearchableSelect, type SearchableSelectOption } from "@src/components/ui" import { cn } from "@src/lib/utils" import { formatPrice } from "@/utils/formatPrice" +import { getHuggingFaceAuthUrl } from "@src/oauth/urls" import { inputEventTransform } from "../transforms" @@ -39,9 +40,10 @@ type HuggingFaceProps = { value: ProviderSettings[keyof ProviderSettings], isUserAction?: boolean, ) => void + uriScheme?: string } -export const HuggingFace = ({ apiConfiguration, setApiConfigurationField }: HuggingFaceProps) => { +export const HuggingFace = ({ apiConfiguration, setApiConfigurationField, uriScheme }: HuggingFaceProps) => { const { t } = useAppTranslation() const [models, setModels] = useState([]) const [loading, setLoading] = useState(false) @@ -109,6 +111,28 @@ export const HuggingFace = ({ apiConfiguration, setApiConfigurationField }: Hugg setApiConfigurationField, ]) + // Start OAuth with PKCE + const handleStartOauth = useCallback(async () => { + try { + // Generate PKCE challenge using the library + const pkce = await pkceChallenge() + const state = crypto.randomUUID() // Use built-in UUID for state + + // Store verifier/state in extension (secrets) + vscode.postMessage({ + type: "storeHuggingFacePkce", + values: { verifier: pkce.code_verifier, state }, + }) + + const authUrl = getHuggingFaceAuthUrl(uriScheme, pkce.code_challenge, state) + + // Open externally via extension + vscode.postMessage({ type: "openExternal", url: authUrl }) + } catch (e) { + console.error("Failed to start Hugging Face OAuth:", e) + } + }, [uriScheme]) + const handleModelSelect = (modelId: string) => { setApiConfigurationField("huggingFaceModelId", modelId) // Reset provider selection when model changes @@ -180,9 +204,9 @@ export const HuggingFace = ({ apiConfiguration, setApiConfigurationField }: Hugg {!apiConfiguration?.huggingFaceApiKey && ( - + {t("settings:providers.getHuggingFaceApiKey")} - + )}
diff --git a/webview-ui/src/oauth/urls.ts b/webview-ui/src/oauth/urls.ts index 8abf0ca434..a3e4f6cfb9 100644 --- a/webview-ui/src/oauth/urls.ts +++ b/webview-ui/src/oauth/urls.ts @@ -1,4 +1,5 @@ import { Package } from "@roo/package" +import { HUGGING_FACE_OAUTH_CLIENT_ID } from "../../../src/shared/oauth-constants" export function getCallbackUrl(provider: string, uriScheme?: string) { return encodeURIComponent(`${uriScheme || "vscode"}://${Package.publisher}.${Package.name}/${provider}`) @@ -15,3 +16,21 @@ export function getOpenRouterAuthUrl(uriScheme?: string) { export function getRequestyAuthUrl(uriScheme?: string) { return `https://app.requesty.ai/oauth/authorize?callback_url=${getCallbackUrl("requesty", uriScheme)}` } + +export function getHuggingFaceAuthUrl(uriScheme?: string, codeChallenge?: string, state?: string) { + const callback = getCallbackUrl("huggingface", uriScheme) + const scope = encodeURIComponent("openid profile inference-api") + + let url = `https://huggingface.co/oauth/authorize?client_id=${HUGGING_FACE_OAUTH_CLIENT_ID}&redirect_uri=${callback}&response_type=code&scope=${scope}` + + // Add PKCE parameters if provided + if (codeChallenge) { + url += `&code_challenge=${codeChallenge}&code_challenge_method=S256` + } + + if (state) { + url += `&state=${encodeURIComponent(state)}` + } + + return url +}