Compare commits

...

7 Commits

Author SHA1 Message Date
Saoud Rizwan 8a729199d3 Name function better and add comment; fix org switch logic 2025-07-12 01:49:44 -07:00
Saoud Rizwan 9513352a6b Ensure that refresh token is passed properly in params 2025-07-12 01:38:55 -07:00
Saoud Rizwan ca9f679b90 Remove signOut 2025-07-12 00:32:24 -07:00
Saoud Rizwan d64be539c2 Create green-drinks-rest.md 2025-07-12 00:27:17 -07:00
Saoud Rizwan d7912aa816 Update src/services/account/ClineAccountService.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-12 00:24:42 -07:00
Saoud Rizwan 0e806127c5 Add comments 2025-07-12 00:18:38 -07:00
Saoud Rizwan 8f1c586160 Replace firebase user object session logic with google endpoint to get id token and custom jwt validation 2025-07-12 00:11:29 -07:00
9 changed files with 161 additions and 170 deletions
+5
View File
@@ -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
+15
View File
@@ -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",
+1
View File
@@ -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",
+1 -1
View File
@@ -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
View File
@@ -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()
}
}),
)
+2 -2
View File
@@ -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()
}
}
}
+54 -52
View File
@@ -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 && (