mirror of
https://github.com/cline/cline.git
synced 2026-09-06 12:28:08 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a729199d3 | |||
| 9513352a6b | |||
| ca9f679b90 | |||
| d64be539c2 | |||
| d7912aa816 | |||
| 0e806127c5 | |||
| 8f1c586160 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix authentication issue where Cline accounts users would keep getting logged out or seeing 'Unexpected API response' errors
|
||||
Generated
+15
@@ -51,6 +51,7 @@
|
||||
"image-size": "^2.0.2",
|
||||
"isbinaryfile": "^5.0.2",
|
||||
"jschardet": "^3.1.4",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"mammoth": "^1.8.0",
|
||||
"monaco-vscode-textmate-theme-converter": "^0.1.7",
|
||||
"nice-grpc": "^2.1.12",
|
||||
@@ -15785,6 +15786,15 @@
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jwt-decode": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
|
||||
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.16.22",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz",
|
||||
@@ -35228,6 +35238,11 @@
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"jwt-decode": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
|
||||
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="
|
||||
},
|
||||
"katex": {
|
||||
"version": "0.16.22",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz",
|
||||
|
||||
@@ -449,6 +449,7 @@
|
||||
"image-size": "^2.0.2",
|
||||
"isbinaryfile": "^5.0.2",
|
||||
"jschardet": "^3.1.4",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"mammoth": "^1.8.0",
|
||||
"monaco-vscode-textmate-theme-converter": "^0.1.7",
|
||||
"nice-grpc": "^2.1.12",
|
||||
|
||||
@@ -74,7 +74,7 @@ export class Controller {
|
||||
)
|
||||
this.accountService = ClineAccountService.getInstance()
|
||||
this.authService = AuthService.getInstance(context)
|
||||
this.authService.restoreAuthToken()
|
||||
this.authService.restoreRefreshTokenAndRetrieveAuthInfo()
|
||||
|
||||
// Clean up legacy checkpoints
|
||||
cleanupLegacyCheckpoints(this.context.globalStorageUri.fsPath, this.outputChannel).catch((error) => {
|
||||
|
||||
+1
-1
@@ -715,7 +715,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
context.subscriptions.push(
|
||||
context.secrets.onDidChange((event) => {
|
||||
if (event.key === "clineAccountId") {
|
||||
AuthService.getInstance(context)?.restoreAuthToken()
|
||||
AuthService.getInstance(context)?.restoreRefreshTokenAndRetrieveAuthInfo()
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -274,8 +274,8 @@ export class ClineAccountService {
|
||||
console.error("Error switching account:", error)
|
||||
throw error
|
||||
} finally {
|
||||
// Request a new authentication token
|
||||
await this._authService.refreshAuth()
|
||||
// After user switches account, we will force a refresh of the id token by calling this function that restores the refresh token and retrieves new auth info
|
||||
await this._authService.restoreRefreshTokenAndRetrieveAuthInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import vscode from "vscode"
|
||||
import crypto from "crypto"
|
||||
import { EmptyRequest, String } from "../../shared/proto/common"
|
||||
import { AuthState } from "../../shared/proto/account"
|
||||
import { AuthState, UserInfo } from "../../shared/proto/account"
|
||||
import { StreamingResponseHandler, getRequestRegistry } from "@/core/controller/grpc-handler"
|
||||
import { FirebaseAuthProvider } from "./providers/FirebaseAuthProvider"
|
||||
import { Controller } from "@/core/controller"
|
||||
@@ -22,14 +22,35 @@ const availableAuthProviders = {
|
||||
// Add other providers here as needed
|
||||
}
|
||||
|
||||
export interface ClineAuthInfo {
|
||||
idToken: string
|
||||
userInfo: ClineAccountUserInfo
|
||||
}
|
||||
|
||||
export interface ClineAccountUserInfo {
|
||||
createdAt: string
|
||||
displayName: string
|
||||
email: string
|
||||
id: string
|
||||
organizations: ClineAccountOrganization[]
|
||||
}
|
||||
|
||||
export interface ClineAccountOrganization {
|
||||
active: boolean
|
||||
memberId: string
|
||||
name: string
|
||||
organizationId: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
// TODO: Add logic to handle multiple webviews getting auth updates.
|
||||
|
||||
export class AuthService {
|
||||
private static instance: AuthService | null = null
|
||||
private _config: ServiceConfig
|
||||
private _authenticated: boolean = false
|
||||
private _user: any = null
|
||||
private _provider: any = null
|
||||
private _clineAuthInfo: ClineAuthInfo | null = null
|
||||
private _provider: { provider: FirebaseAuthProvider } | null = null
|
||||
private readonly _authNonce = crypto.randomBytes(32).toString("hex")
|
||||
private _activeAuthStatusUpdateSubscriptions = new Set<[Controller, StreamingResponseHandler]>()
|
||||
private _context: vscode.ExtensionContext
|
||||
@@ -142,13 +163,19 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async getAuthToken(): Promise<string | null> {
|
||||
if (!this._user) {
|
||||
if (!this._clineAuthInfo) {
|
||||
return null
|
||||
}
|
||||
|
||||
// TODO: This may need to be dependant on the auth provider
|
||||
// Return the ID token from the user object
|
||||
return this._provider.provider.getAuthToken(this._user)
|
||||
const idToken = this._clineAuthInfo.idToken
|
||||
const shouldRefreshIdToken = await this._provider?.provider.shouldRefreshIdToken(idToken)
|
||||
if (shouldRefreshIdToken) {
|
||||
// Retrieves the stored id token and refreshes it, then updates this._clineAuthInfo
|
||||
await this.restoreRefreshTokenAndRetrieveAuthInfo()
|
||||
if (!this._clineAuthInfo) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return this._clineAuthInfo.idToken
|
||||
}
|
||||
|
||||
private _setProvider(providerName: string): void {
|
||||
@@ -161,13 +188,20 @@ export class AuthService {
|
||||
}
|
||||
|
||||
getInfo(): AuthState {
|
||||
let user = null
|
||||
if (this._user && this._authenticated) {
|
||||
user = this._provider.provider.convertUserData(this._user)
|
||||
let userInfo = null
|
||||
if (this._clineAuthInfo && this._authenticated) {
|
||||
userInfo = this._clineAuthInfo.userInfo
|
||||
}
|
||||
|
||||
// TODO: create proto for new user info type
|
||||
|
||||
return AuthState.create({
|
||||
user: user,
|
||||
user: UserInfo.create({
|
||||
uid: userInfo?.id,
|
||||
displayName: userInfo?.displayName,
|
||||
email: userInfo?.email,
|
||||
photoUrl: undefined,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -200,8 +234,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
try {
|
||||
await this._provider.provider.signOut()
|
||||
this._user = null
|
||||
this._clineAuthInfo = null
|
||||
this._authenticated = false
|
||||
this.sendAuthStatusUpdate()
|
||||
} catch (error) {
|
||||
@@ -216,12 +249,11 @@ export class AuthService {
|
||||
}
|
||||
|
||||
try {
|
||||
this._user = await this._provider.provider.signIn(this._context, token, provider)
|
||||
this._clineAuthInfo = await this._provider.provider.signIn(this._context, token, provider)
|
||||
this._authenticated = true
|
||||
|
||||
await this.sendAuthStatusUpdate()
|
||||
this.setupAutoRefreshAuth()
|
||||
return this._user
|
||||
// return this._clineAuthInfo
|
||||
} catch (error) {
|
||||
console.error("Error signing in with custom token:", error)
|
||||
throw error
|
||||
@@ -240,59 +272,29 @@ export class AuthService {
|
||||
* Restores the authentication token from the extension's storage.
|
||||
* This is typically called when the extension is activated.
|
||||
*/
|
||||
async restoreAuthToken(): Promise<void> {
|
||||
async restoreRefreshTokenAndRetrieveAuthInfo(): Promise<void> {
|
||||
if (!this._provider || !this._provider.provider) {
|
||||
throw new Error("Auth provider is not set")
|
||||
}
|
||||
|
||||
try {
|
||||
this._user = await this._provider.provider.restoreAuthCredential(this._context)
|
||||
if (this._user) {
|
||||
this._clineAuthInfo = await this._provider.provider.retrieveClineAuthInfo(this._context)
|
||||
if (this._clineAuthInfo) {
|
||||
this._authenticated = true
|
||||
await this.sendAuthStatusUpdate()
|
||||
this.setupAutoRefreshAuth()
|
||||
// Setup auto-refresh for the auth token
|
||||
} else {
|
||||
console.warn("No user found after restoring auth token")
|
||||
this._authenticated = false
|
||||
this._user = null
|
||||
this._clineAuthInfo = null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error restoring auth token:", error)
|
||||
this._authenticated = false
|
||||
this._user = null
|
||||
this._clineAuthInfo = null
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the authentication status and sends an update to all subscribers.
|
||||
*/
|
||||
async refreshAuth(): Promise<void> {
|
||||
if (!this._user) {
|
||||
console.warn("No user is authenticated, skipping auth refresh")
|
||||
return
|
||||
}
|
||||
|
||||
await this._provider.provider.refreshAuthToken()
|
||||
this.sendAuthStatusUpdate()
|
||||
}
|
||||
|
||||
private setupAutoRefreshAuth(): void {
|
||||
// Set timeoutDuration to refresh the auth token 5 minutes before it expires
|
||||
const timeoutDuration = Math.floor(this._user.stsTokenManager.expirationTime - 5 * 60000 - Date.now()) // Milliseconds until 5 minutes before expiration
|
||||
setTimeout(() => this._autoRefreshAuth(), timeoutDuration)
|
||||
}
|
||||
|
||||
private async _autoRefreshAuth(): Promise<void> {
|
||||
if (!this._user) {
|
||||
console.warn("No user is authenticated, skipping auth refresh")
|
||||
return
|
||||
}
|
||||
await this.refreshAuth()
|
||||
this.setupAutoRefreshAuth() // Reschedule the next auto-refresh
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to authStatusUpdate events
|
||||
* @param controller The controller instance
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import { getSecret, storeSecret } from "@/core/storage/state"
|
||||
import { ErrorService } from "@/services/error/ErrorService"
|
||||
import axios from "axios"
|
||||
import { initializeApp } from "firebase/app"
|
||||
import {
|
||||
AuthCredential,
|
||||
GoogleAuthProvider,
|
||||
GithubAuthProvider,
|
||||
OAuthCredential,
|
||||
User,
|
||||
UserCredential,
|
||||
getAuth,
|
||||
signInWithCredential,
|
||||
signOut,
|
||||
} from "firebase/auth"
|
||||
import { GithubAuthProvider, GoogleAuthProvider, User, getAuth, signInWithCredential } from "firebase/auth"
|
||||
import { ExtensionContext } from "vscode"
|
||||
import { ClineAccountUserInfo, ClineAuthInfo } from "../AuthService"
|
||||
import { jwtDecode } from "jwt-decode"
|
||||
|
||||
export class FirebaseAuthProvider {
|
||||
private _config: any
|
||||
@@ -29,80 +22,16 @@ export class FirebaseAuthProvider {
|
||||
this._config = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the authentication token of the current user.
|
||||
* @returns {Promise<string | null>} A promise that resolves to the authentication token of the current user, or null if no user is signed in.
|
||||
*/
|
||||
async getAuthToken(): Promise<string | null> {
|
||||
const user = getAuth().currentUser
|
||||
const idToken = user ? await user.getIdToken() : null
|
||||
return idToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the refresh token of the current user.
|
||||
* @returns {Promise<string | null>} A promise that resolves to the refresh token of the current user, or null if no user is signed in.
|
||||
*/
|
||||
async getRefreshToken(): Promise<string | null> {
|
||||
const user = getAuth().currentUser
|
||||
const refreshToken = user ? user.refreshToken : null
|
||||
return refreshToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the authentication token of the current user.
|
||||
* @returns {Promise<string | null>} A promise that resolves to the refreshed authentication token of the current user, or null if no user is signed in.
|
||||
*/
|
||||
async refreshAuthToken(): Promise<string | null> {
|
||||
const user = getAuth().currentUser
|
||||
const idToken = user ? await user.getIdToken(true) : null
|
||||
return idToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts Firebase User object to a generic user object.
|
||||
* @param user - The Firebase User object.
|
||||
* @returns {User} A generic user object.
|
||||
*/
|
||||
convertUserData(user: User) {
|
||||
return {
|
||||
uid: user.uid,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
photoUrl: user.photoURL,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs out the current user from Firebase.
|
||||
* @returns {Promise<void>} A promise that resolves when the user is signed out.
|
||||
*/
|
||||
async signOut(): Promise<void> {
|
||||
signOut(getAuth(initializeApp(Object.assign({}, this._config))))
|
||||
.then(() => {
|
||||
console.log("User signed out successfully.")
|
||||
})
|
||||
.catch((error) => {
|
||||
ErrorService.logMessage("Firebase sign-out error", "error")
|
||||
ErrorService.logException(error)
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the authentication token using a provided token.
|
||||
* @param token - The authentication token to store.
|
||||
* @returns {Promise<User>} A promise that resolves with the authenticated user.
|
||||
* @throws {Error} Throws an error if the storage fails.
|
||||
*/
|
||||
private async _storeAuthCredential(context: ExtensionContext, credential: AuthCredential): Promise<void> {
|
||||
try {
|
||||
await storeSecret(context, "clineAccountId", JSON.stringify(credential.toJSON()))
|
||||
} catch (error) {
|
||||
ErrorService.logMessage("Firebase store token error", "error")
|
||||
ErrorService.logException(error)
|
||||
throw error
|
||||
async shouldRefreshIdToken(existingIdToken: string): Promise<boolean> {
|
||||
const decodedToken = jwtDecode(existingIdToken)
|
||||
const exp = decodedToken.exp || 0 // 1752297633
|
||||
const expirationTime = exp * 1000
|
||||
const currentTime = Date.now()
|
||||
const fiveMinutesInMs = 5 * 60 * 1000
|
||||
if (currentTime > expirationTime - fiveMinutesInMs) {
|
||||
return true // id token is expired or about to be expired
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,31 +40,55 @@ export class FirebaseAuthProvider {
|
||||
* @returns {Promise<User>} A promise that resolves with the authenticated user.
|
||||
* @throws {Error} Throws an error if the restoration fails.
|
||||
*/
|
||||
async restoreAuthCredential(context: ExtensionContext): Promise<User | null> {
|
||||
const credentialJSON = await getSecret(context, "clineAccountId")
|
||||
if (!credentialJSON) {
|
||||
async retrieveClineAuthInfo(context: ExtensionContext): Promise<ClineAuthInfo | null> {
|
||||
const userRefreshToken = await getSecret(context, "clineAccountId")
|
||||
if (!userRefreshToken) {
|
||||
console.error("No stored authentication credential found.")
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const credentialData: AuthCredential = OAuthCredential.fromJSON(credentialJSON) as AuthCredential
|
||||
const userCredential = await this._signInWithCredential(credentialData)
|
||||
return userCredential.user
|
||||
} catch (error) {
|
||||
ErrorService.logMessage("Firebase restore token error", "error")
|
||||
ErrorService.logException(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
// Exchange refresh token for new access token using Firebase's secure token endpoint
|
||||
// https://stackoverflow.com/questions/38233687/how-to-use-the-firebase-refreshtoken-to-reauthenticate/57119131#57119131
|
||||
const firebaseApiKey = this._config.apiKey
|
||||
const googleAccessTokenResponse = await axios.post(
|
||||
`https://securetoken.googleapis.com/v1/token?key=${firebaseApiKey}`,
|
||||
`grant_type=refresh_token&refresh_token=${encodeURIComponent(userRefreshToken)}`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
async _signInWithCredential(credential: AuthCredential): Promise<UserCredential> {
|
||||
const firebaseConfig = Object.assign({}, this._config)
|
||||
const app = initializeApp(firebaseConfig)
|
||||
const auth = getAuth(app)
|
||||
try {
|
||||
return await signInWithCredential(auth, credential)
|
||||
// console.log("googleAccessTokenResponse", googleAccessTokenResponse)
|
||||
|
||||
// This returns an object with access_token, expires_in (3600), id_token (can be used as bearer token to authenticate requests, we'll use this in the future instead of firebase but need to be aware of how we use firebase sdk for e.g. user info like the profile image), project_id, refresh_token, token_type (always Bearer), and user_id
|
||||
const idToken = googleAccessTokenResponse.data.id_token
|
||||
// const idTokenExpirationDate = new Date(Date.now() + googleAccessTokenResponse.data.expires_in * 1000)
|
||||
|
||||
// Now retrieve the user info from the backend (this was an easy solution to keep providing user profile details like name and email, but we should move to using the fetchMe() function instead)
|
||||
// Fetch user info from Cline API
|
||||
// TODO: consolidate with fetchMe() instead of making the call directly here
|
||||
const userResponse = await axios.get("https://api.cline.bot/api/v1/users/me", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${idToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
// Store user data
|
||||
const userInfo: ClineAccountUserInfo = userResponse.data.data
|
||||
|
||||
return { idToken, userInfo }
|
||||
|
||||
// let userObject = JSON.parse(credentialJSON)
|
||||
// let user = User.
|
||||
// userObject = User.constructor._fromJSON(auth, user2);
|
||||
// const credentialData: AuthCredential = OAuthCredential.fromJSON(credentialJSON) as AuthCredential
|
||||
// const userCredential = await this._signInWithCredential(context, credentialData)
|
||||
// return userCredential.user
|
||||
} catch (error) {
|
||||
ErrorService.logMessage("Firebase sign-in with credential error", "error")
|
||||
console.error("Firebase restore token error", error)
|
||||
ErrorService.logMessage("Firebase restore token error", "error")
|
||||
ErrorService.logException(error)
|
||||
throw error
|
||||
}
|
||||
@@ -146,10 +99,9 @@ export class FirebaseAuthProvider {
|
||||
* @returns {Promise<User>} A promise that resolves with the authenticated user.
|
||||
* @throws {Error} Throws an error if the sign-in fails.
|
||||
*/
|
||||
async signIn(context: ExtensionContext, token: string, provider: string): Promise<User> {
|
||||
async signIn(context: ExtensionContext, token: string, provider: string): Promise<ClineAuthInfo | null> {
|
||||
try {
|
||||
let credential
|
||||
let userCredential
|
||||
switch (provider) {
|
||||
case "google":
|
||||
credential = GoogleAuthProvider.credential(token)
|
||||
@@ -160,9 +112,25 @@ export class FirebaseAuthProvider {
|
||||
default:
|
||||
throw new Error(`Unsupported provider: ${provider}`)
|
||||
}
|
||||
this._storeAuthCredential(context, credential)
|
||||
userCredential = await this._signInWithCredential(credential)
|
||||
return userCredential.user
|
||||
// we've received the short-lived tokens from google/github, now we need to sign in to firebase with them
|
||||
const firebaseConfig = Object.assign({}, this._config)
|
||||
const app = initializeApp(firebaseConfig)
|
||||
const auth = getAuth(app)
|
||||
// this signs the user into firebase sdk internally
|
||||
const userCredential = (await signInWithCredential(auth, credential)).user
|
||||
// const userRefreshToken = await userCredential.getIdToken()
|
||||
|
||||
// store the long-lived refresh token in secret storage
|
||||
try {
|
||||
await storeSecret(context, "clineAccountId", userCredential.refreshToken)
|
||||
} catch (error) {
|
||||
ErrorService.logMessage("Firebase store token error", "error")
|
||||
ErrorService.logException(error)
|
||||
throw error
|
||||
}
|
||||
|
||||
// userCredential = await this._signInWithCredential(context, credential)
|
||||
return await this.retrieveClineAuthInfo(context)
|
||||
} catch (error) {
|
||||
ErrorService.logMessage("Firebase sign-in error", "error")
|
||||
ErrorService.logException(error)
|
||||
|
||||
@@ -146,13 +146,13 @@ export const ClineAccountView = () => {
|
||||
<div className="flex flex-col pr-3 h-full">
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex items-center mb-6 flex-wrap gap-y-4">
|
||||
{user.photoUrl ? (
|
||||
{/* {user.photoUrl ? (
|
||||
<img src={user.photoUrl} alt="Profile" className="size-16 rounded-full mr-4" />
|
||||
) : (
|
||||
<div className="size-16 rounded-full bg-[var(--vscode-button-background)] flex items-center justify-center text-2xl text-[var(--vscode-button-foreground)] mr-4">
|
||||
{user.displayName?.[0] || user.email?.[0] || "?"}
|
||||
</div>
|
||||
)}
|
||||
) : ( */}
|
||||
<div className="size-16 rounded-full bg-[var(--vscode-button-background)] flex items-center justify-center text-2xl text-[var(--vscode-button-foreground)] mr-4">
|
||||
{user.displayName?.[0] || user.email?.[0] || "?"}
|
||||
</div>
|
||||
{/* )} */}
|
||||
|
||||
<div className="flex flex-col">
|
||||
{user.displayName && (
|
||||
|
||||
Reference in New Issue
Block a user