Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
21 changes: 5 additions & 16 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions src/activate/handleUri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
63 changes: 63 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -1424,6 +1425,68 @@ export class ClineProvider
await this.upsertProviderProfile(currentApiConfigName, newConfiguration)
}

// HuggingFace

async handleHuggingFaceCallback(code: string, returnedState?: string) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider extracting common OAuth handling logic. The pattern here is duplicated across Glama, OpenRouter, and Requesty providers. A shared OAuth handler would reduce code duplication and make maintenance easier.

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.")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error messages don't follow the i18n pattern used elsewhere in the codebase. Should use the translation function for consistency.

}

// Optional state validation (if state was provided in the callback)
if (returnedState && pkceData.state && returnedState !== pkceData.state) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The state validation is incomplete. It only checks for mismatch but doesn't verify the state format or prevent replay attacks. Consider adding timestamp validation to ensure the state is fresh (e.g., not older than 10 minutes).

// 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing validation of the access token format. Consider validating the token structure and checking that it's a non-empty string before using it.


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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error handling doesn't clean up stored PKCE data on all error paths. The secrets should be deleted in a finally block to ensure cleanup happens regardless of success or failure.

this.log(
`Error exchanging code for Hugging Face access token: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
)
throw error
}
}

// Requesty

async handleRequestyCallback(code: string) {
Expand Down
13 changes: 13 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using context.secrets for temporary PKCE data isn't ideal as secrets are meant for long-term credentials. Consider using extension global state or in-memory storage for temporary OAuth flow data.

} catch (error) {
console.error("Failed to store Hugging Face PKCE data:", error)
}
}
break
}
case "checkpointDiff":
const result = checkoutDiffPayloadSchema.safeParse(message.payload)

Expand Down
1 change: 1 addition & 0 deletions src/shared/WebviewMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ export interface WebviewMessage {
| "profileThresholds"
| "setHistoryPreviewCollapsed"
| "openExternal"
| "storeHuggingFacePkce"
| "filterMarketplaceItems"
| "marketplaceButtonClicked"
| "installMarketplaceItem"
Expand Down
10 changes: 10 additions & 0 deletions src/shared/oauth-constants.ts
Original file line number Diff line number Diff line change
@@ -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"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security concern: While OAuth client IDs are public, hardcoding them makes rotation difficult and prevents environment-specific configurations. Consider using environment variables or configuration files to allow overriding for different environments (dev/staging/prod).

1 change: 1 addition & 0 deletions webview-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion webview-ui/src/components/settings/ApiOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -595,7 +595,11 @@ const ApiOptions = ({
)}

{selectedProvider === "huggingface" && (
<HuggingFace apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} />
<HuggingFace
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
uriScheme={uriScheme}
/>
)}

{selectedProvider === "cerebras" && (
Expand Down
43 changes: 43 additions & 0 deletions webview-ui/src/components/settings/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,49 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ 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<keyof ExtensionStateContextType> = useCallback((field, value) => {
setCachedState((prevState) => {
if (prevState[field] === value) {
Expand Down
34 changes: 29 additions & 5 deletions webview-ui/src/components/settings/providers/HuggingFace.tsx
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -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<HuggingFaceModel[]>([])
const [loading, setLoading] = useState(false)
Expand Down Expand Up @@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing error handling for PKCE challenge generation. The catch block only logs to console but doesn't notify the user. Consider showing a user-friendly error message.

}
}, [uriScheme])

const handleModelSelect = (modelId: string) => {
setApiConfigurationField("huggingFaceModelId", modelId)
// Reset provider selection when model changes
Expand Down Expand Up @@ -180,9 +204,9 @@ export const HuggingFace = ({ apiConfiguration, setApiConfigurationField }: Hugg
</div>

{!apiConfiguration?.huggingFaceApiKey && (
<VSCodeButtonLink href="https://huggingface.co/settings/tokens" appearance="secondary">
<VSCodeButton appearance="primary" onClick={handleStartOauth} style={{ width: "100%" }}>
{t("settings:providers.getHuggingFaceApiKey")}
</VSCodeButtonLink>
</VSCodeButton>
)}

<div className="flex flex-col gap-2">
Expand Down
19 changes: 19 additions & 0 deletions webview-ui/src/oauth/urls.ts
Original file line number Diff line number Diff line change
@@ -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}`)
Expand All @@ -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
}
Loading