Skip to content

Commit 3fc05ad

Browse files
Better handling of cloud login/out with multiple workspaces (#4196)
* Move client token + session id to shared secret * Add secret event handling * Update packages/cloud/src/AuthService.ts Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --------- Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
1 parent 5e50c55 commit 3fc05ad

File tree

3 files changed

+123
-71
lines changed

3 files changed

+123
-71
lines changed

packages/cloud/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
"dependencies": {
1414
"@roo-code/telemetry": "workspace:^",
1515
"@roo-code/types": "workspace:^",
16-
"axios": "^1.7.4"
16+
"axios": "^1.7.4",
17+
"zod": "^3.24.2"
1718
},
1819
"devDependencies": {
1920
"@roo-code/config-eslint": "workspace:^",

packages/cloud/src/AuthService.ts

Lines changed: 118 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import EventEmitter from "events"
33

44
import axios from "axios"
55
import * as vscode from "vscode"
6+
import { z } from "zod"
67

78
import type { CloudUserInfo } from "@roo-code/types"
89

@@ -15,8 +16,14 @@ export interface AuthServiceEvents {
1516
"user-info": [data: { userInfo: CloudUserInfo }]
1617
}
1718

18-
const CLIENT_TOKEN_KEY = "clerk-client-token"
19-
const SESSION_ID_KEY = "clerk-session-id"
19+
const authCredentialsSchema = z.object({
20+
clientToken: z.string().min(1, "Client token cannot be empty"),
21+
sessionId: z.string().min(1, "Session ID cannot be empty"),
22+
})
23+
24+
type AuthCredentials = z.infer<typeof authCredentialsSchema>
25+
26+
const AUTH_CREDENTIALS_KEY = "clerk-auth-credentials"
2027
const AUTH_STATE_KEY = "clerk-auth-state"
2128

2229
type AuthState = "initializing" | "logged-out" | "active-session" | "inactive-session"
@@ -26,9 +33,8 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {
2633
private timer: RefreshTimer
2734
private state: AuthState = "initializing"
2835

29-
private clientToken: string | null = null
36+
private credentials: AuthCredentials | null = null
3037
private sessionToken: string | null = null
31-
private sessionId: string | null = null
3238
private userInfo: CloudUserInfo | null = null
3339

3440
constructor(context: vscode.ExtensionContext) {
@@ -47,6 +53,55 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {
4753
})
4854
}
4955

56+
private async handleCredentialsChange(): Promise<void> {
57+
try {
58+
const credentials = await this.loadCredentials()
59+
60+
if (credentials) {
61+
if (
62+
this.credentials === null ||
63+
this.credentials.clientToken !== credentials.clientToken ||
64+
this.credentials.sessionId !== credentials.sessionId
65+
) {
66+
this.transitionToInactiveSession(credentials)
67+
}
68+
} else {
69+
if (this.state !== "logged-out") {
70+
this.transitionToLoggedOut()
71+
}
72+
}
73+
} catch (error) {
74+
console.error("[auth] Error handling credentials change:", error)
75+
}
76+
}
77+
78+
private transitionToLoggedOut(): void {
79+
this.timer.stop()
80+
81+
const previousState = this.state
82+
83+
this.credentials = null
84+
this.sessionToken = null
85+
this.userInfo = null
86+
this.state = "logged-out"
87+
88+
this.emit("logged-out", { previousState })
89+
90+
console.log("[auth] Transitioned to logged-out state")
91+
}
92+
93+
private transitionToInactiveSession(credentials: AuthCredentials): void {
94+
this.credentials = credentials
95+
this.state = "inactive-session"
96+
97+
this.sessionToken = null
98+
this.userInfo = null
99+
100+
this.timer.start()
101+
102+
console.log("[auth] Transitioned to inactive-session state")
103+
}
104+
50105
/**
51106
* Initialize the auth state
52107
*
@@ -59,29 +114,42 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {
59114
return
60115
}
61116

117+
await this.handleCredentialsChange()
118+
119+
this.context.subscriptions.push(
120+
this.context.secrets.onDidChange((e) => {
121+
if (e.key === AUTH_CREDENTIALS_KEY) {
122+
this.handleCredentialsChange()
123+
}
124+
}),
125+
)
126+
}
127+
128+
private async storeCredentials(credentials: AuthCredentials): Promise<void> {
129+
await this.context.secrets.store(AUTH_CREDENTIALS_KEY, JSON.stringify(credentials))
130+
}
131+
132+
private async loadCredentials(): Promise<AuthCredentials | null> {
133+
const credentialsJson = await this.context.secrets.get(AUTH_CREDENTIALS_KEY)
134+
if (!credentialsJson) return null
135+
62136
try {
63-
this.clientToken = (await this.context.secrets.get(CLIENT_TOKEN_KEY)) || null
64-
this.sessionId = this.context.globalState.get<string>(SESSION_ID_KEY) || null
65-
66-
// Determine initial state.
67-
if (!this.clientToken || !this.sessionId) {
68-
// TODO: it may be possible to get a new session with the client,
69-
// but the obvious Clerk endpoints don't support that.
70-
const previousState = this.state
71-
this.state = "logged-out"
72-
this.emit("logged-out", { previousState })
137+
const parsedJson = JSON.parse(credentialsJson)
138+
return authCredentialsSchema.parse(parsedJson)
139+
} catch (error) {
140+
if (error instanceof z.ZodError) {
141+
console.error("[auth] Invalid credentials format:", error.errors)
73142
} else {
74-
this.state = "inactive-session"
75-
this.timer.start()
143+
console.error("[auth] Failed to parse stored credentials:", error)
76144
}
77-
78-
console.log(`[auth] Initialized with state: ${this.state}`)
79-
} catch (error) {
80-
console.error(`[auth] Error initializing AuthService: ${error}`)
81-
this.state = "logged-out"
145+
return null
82146
}
83147
}
84148

149+
private async clearCredentials(): Promise<void> {
150+
await this.context.secrets.delete(AUTH_CREDENTIALS_KEY)
151+
}
152+
85153
/**
86154
* Start the login process
87155
*
@@ -132,21 +200,9 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {
132200
throw new Error("Invalid state parameter. Authentication request may have been tampered with.")
133201
}
134202

135-
const { clientToken, sessionToken, sessionId } = await this.clerkSignIn(code)
136-
137-
await this.context.secrets.store(CLIENT_TOKEN_KEY, clientToken)
138-
await this.context.globalState.update(SESSION_ID_KEY, sessionId)
203+
const { credentials } = await this.clerkSignIn(code)
139204

140-
this.clientToken = clientToken
141-
this.sessionId = sessionId
142-
this.sessionToken = sessionToken
143-
144-
const previousState = this.state
145-
this.state = "active-session"
146-
this.emit("active-session", { previousState })
147-
this.timer.start()
148-
149-
this.fetchUserInfo()
205+
await this.storeCredentials(credentials)
150206

151207
vscode.window.showInformationMessage("Successfully authenticated with Roo Code Cloud")
152208
console.log("[auth] Successfully authenticated with Roo Code Cloud")
@@ -165,30 +221,21 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {
165221
* This method removes all stored tokens and stops the refresh timer.
166222
*/
167223
public async logout(): Promise<void> {
168-
try {
169-
this.timer.stop()
224+
const oldCredentials = this.credentials
170225

171-
await this.context.secrets.delete(CLIENT_TOKEN_KEY)
172-
await this.context.globalState.update(SESSION_ID_KEY, undefined)
226+
try {
227+
// Clear credentials from storage - onDidChange will handle state transitions
228+
await this.clearCredentials()
173229
await this.context.globalState.update(AUTH_STATE_KEY, undefined)
174230

175-
const oldClientToken = this.clientToken
176-
const oldSessionId = this.sessionId
177-
178-
this.clientToken = null
179-
this.sessionToken = null
180-
this.sessionId = null
181-
this.userInfo = null
182-
const previousState = this.state
183-
this.state = "logged-out"
184-
this.emit("logged-out", { previousState })
185-
186-
if (oldClientToken && oldSessionId) {
187-
await this.clerkLogout(oldClientToken, oldSessionId)
231+
if (oldCredentials) {
232+
try {
233+
await this.clerkLogout(oldCredentials)
234+
} catch (error) {
235+
console.error("[auth] Error calling clerkLogout:", error)
236+
}
188237
}
189238

190-
this.fetchUserInfo()
191-
192239
vscode.window.showInformationMessage("Logged out from Roo Code Cloud")
193240
console.log("[auth] Logged out from Roo Code Cloud")
194241
} catch (error) {
@@ -228,8 +275,8 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {
228275
* This method refreshes the session token using the client token.
229276
*/
230277
private async refreshSession(): Promise<void> {
231-
if (!this.sessionId || !this.clientToken) {
232-
console.log("[auth] Cannot refresh session: missing session ID or token")
278+
if (!this.credentials) {
279+
console.log("[auth] Cannot refresh session: missing credentials")
233280
this.state = "inactive-session"
234281
return
235282
}
@@ -239,13 +286,14 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {
239286
this.state = "active-session"
240287

241288
if (previousState !== "active-session") {
289+
console.log("[auth] Transitioned to active-session state")
242290
this.emit("active-session", { previousState })
243291
this.fetchUserInfo()
244292
}
245293
}
246294

247295
private async fetchUserInfo(): Promise<void> {
248-
if (!this.clientToken) {
296+
if (!this.credentials) {
249297
return
250298
}
251299

@@ -262,9 +310,7 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {
262310
return this.userInfo
263311
}
264312

265-
private async clerkSignIn(
266-
ticket: string,
267-
): Promise<{ clientToken: string; sessionToken: string; sessionId: string }> {
313+
private async clerkSignIn(ticket: string): Promise<{ credentials: AuthCredentials; sessionToken: string }> {
268314
const formData = new URLSearchParams()
269315
formData.append("strategy", "ticket")
270316
formData.append("ticket", ticket)
@@ -284,14 +330,14 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {
284330
}
285331

286332
// 4. Find the session using created_session_id and extract the JWT.
287-
const createdSessionId = response.data?.response?.created_session_id
333+
const sessionId = response.data?.response?.created_session_id
288334

289-
if (!createdSessionId) {
335+
if (!sessionId) {
290336
throw new Error("No session ID found in the response")
291337
}
292338

293339
// Find the session in the client sessions array.
294-
const session = response.data?.client?.sessions?.find((s: { id: string }) => s.id === createdSessionId)
340+
const session = response.data?.client?.sessions?.find((s: { id: string }) => s.id === sessionId)
295341

296342
if (!session) {
297343
throw new Error("Session not found in the response")
@@ -304,20 +350,22 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {
304350
throw new Error("Session does not have a token")
305351
}
306352

307-
return { clientToken, sessionToken, sessionId: session.id }
353+
const credentials = authCredentialsSchema.parse({ clientToken, sessionId })
354+
355+
return { credentials, sessionToken }
308356
}
309357

310358
private async clerkCreateSessionToken(): Promise<string> {
311359
const formData = new URLSearchParams()
312360
formData.append("_is_native", "1")
313361

314362
const response = await axios.post(
315-
`${getClerkBaseUrl()}/v1/client/sessions/${this.sessionId}/tokens`,
363+
`${getClerkBaseUrl()}/v1/client/sessions/${this.credentials!.sessionId}/tokens`,
316364
formData,
317365
{
318366
headers: {
319367
"Content-Type": "application/x-www-form-urlencoded",
320-
Authorization: `Bearer ${this.clientToken}`,
368+
Authorization: `Bearer ${this.credentials!.clientToken}`,
321369
"User-Agent": this.userAgent(),
322370
},
323371
},
@@ -335,7 +383,7 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {
335383
private async clerkMe(): Promise<CloudUserInfo> {
336384
const response = await axios.get(`${getClerkBaseUrl()}/v1/me`, {
337385
headers: {
338-
Authorization: `Bearer ${this.clientToken}`,
386+
Authorization: `Bearer ${this.credentials!.clientToken}`,
339387
"User-Agent": this.userAgent(),
340388
},
341389
})
@@ -362,13 +410,13 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {
362410
return userInfo
363411
}
364412

365-
private async clerkLogout(clientToken: string, sessionId: string): Promise<void> {
413+
private async clerkLogout(credentials: AuthCredentials): Promise<void> {
366414
const formData = new URLSearchParams()
367415
formData.append("_is_native", "1")
368416

369-
await axios.post(`${getClerkBaseUrl()}/v1/client/sessions/${sessionId}/remove`, formData, {
417+
await axios.post(`${getClerkBaseUrl()}/v1/client/sessions/${credentials.sessionId}/remove`, formData, {
370418
headers: {
371-
Authorization: `Bearer ${clientToken}`,
419+
Authorization: `Bearer ${credentials.clientToken}`,
372420
"User-Agent": this.userAgent(),
373421
},
374422
})

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)