Skip to content

Commit ad6ade8

Browse files
committed
Cloud: cleanup AuthService
- Use zod schemas to validate responses - Switch to fetch() from axios - Remove unused session token parsing from sign in - Clear credentials (and logout) if session token response indicates invalid/expired client token - Add AuthService unit tests
1 parent 5bac2e7 commit ad6ade8

File tree

3 files changed

+894
-57
lines changed

3 files changed

+894
-57
lines changed

packages/cloud/src/AuthService.ts

Lines changed: 90 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import crypto from "crypto"
22
import EventEmitter from "events"
33

4-
import axios from "axios"
54
import * as vscode from "vscode"
65
import { z } from "zod"
76

@@ -30,6 +29,40 @@ const AUTH_STATE_KEY = "clerk-auth-state"
3029

3130
type AuthState = "initializing" | "logged-out" | "active-session" | "inactive-session"
3231

32+
const clerkSignInResponseSchema = z.object({
33+
response: z.object({
34+
created_session_id: z.string(),
35+
}),
36+
})
37+
38+
const clerkCreateSessionTokenResponseSchema = z.object({
39+
jwt: z.string(),
40+
})
41+
42+
const clerkMeResponseSchema = z.object({
43+
response: z.object({
44+
first_name: z.string().optional(),
45+
last_name: z.string().optional(),
46+
image_url: z.string().optional(),
47+
primary_email_address_id: z.string().optional(),
48+
email_addresses: z
49+
.array(
50+
z.object({
51+
id: z.string(),
52+
email_address: z.string(),
53+
}),
54+
)
55+
.optional(),
56+
}),
57+
})
58+
59+
class InvalidClientTokenError extends Error {
60+
constructor() {
61+
super("Invalid/Expired client token")
62+
Object.setPrototypeOf(this, InvalidClientTokenError.prototype)
63+
}
64+
}
65+
3366
export class AuthService extends EventEmitter<AuthServiceEvents> {
3467
private context: vscode.ExtensionContext
3568
private timer: RefreshTimer
@@ -208,7 +241,7 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {
208241
throw new Error("Invalid state parameter. Authentication request may have been tampered with.")
209242
}
210243

211-
const { credentials } = await this.clerkSignIn(code)
244+
const credentials = await this.clerkSignIn(code)
212245

213246
await this.storeCredentials(credentials)
214247

@@ -285,7 +318,6 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {
285318
private async refreshSession(): Promise<void> {
286319
if (!this.credentials) {
287320
this.log("[auth] Cannot refresh session: missing credentials")
288-
this.state = "inactive-session"
289321
return
290322
}
291323

@@ -300,6 +332,10 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {
300332
this.fetchUserInfo()
301333
}
302334
} catch (error) {
335+
if (error instanceof InvalidClientTokenError) {
336+
this.log("[auth] Invalid/Expired client token: clearing credentials")
337+
this.clearCredentials()
338+
}
303339
this.log("[auth] Failed to refresh session", error)
304340
throw error
305341
}
@@ -323,120 +359,117 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {
323359
return this.userInfo
324360
}
325361

326-
private async clerkSignIn(ticket: string): Promise<{ credentials: AuthCredentials; sessionToken: string }> {
362+
private async clerkSignIn(ticket: string): Promise<AuthCredentials> {
327363
const formData = new URLSearchParams()
328364
formData.append("strategy", "ticket")
329365
formData.append("ticket", ticket)
330366

331-
const response = await axios.post(`${getClerkBaseUrl()}/v1/client/sign_ins`, formData, {
367+
const response = await fetch(`${getClerkBaseUrl()}/v1/client/sign_ins`, {
368+
method: "POST",
332369
headers: {
333370
"Content-Type": "application/x-www-form-urlencoded",
334371
"User-Agent": this.userAgent(),
335372
},
373+
body: formData.toString(),
374+
signal: AbortSignal.timeout(10000),
336375
})
337376

338-
// 3. Extract the client token from the Authorization header.
339-
const clientToken = response.headers.authorization
340-
341-
if (!clientToken) {
342-
throw new Error("No authorization header found in the response")
377+
if (!response.ok) {
378+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
343379
}
344380

345-
// 4. Find the session using created_session_id and extract the JWT.
346-
const sessionId = response.data?.response?.created_session_id
347-
348-
if (!sessionId) {
349-
throw new Error("No session ID found in the response")
350-
}
351-
352-
// Find the session in the client sessions array.
353-
const session = response.data?.client?.sessions?.find((s: { id: string }) => s.id === sessionId)
354-
355-
if (!session) {
356-
throw new Error("Session not found in the response")
357-
}
381+
const {
382+
response: { created_session_id: sessionId },
383+
} = clerkSignInResponseSchema.parse(await response.json())
358384

359-
// Extract the session token (JWT) and store it.
360-
const sessionToken = session.last_active_token?.jwt
385+
// 3. Extract the client token from the Authorization header.
386+
const clientToken = response.headers.get("authorization")
361387

362-
if (!sessionToken) {
363-
throw new Error("Session does not have a token")
388+
if (!clientToken) {
389+
throw new Error("No authorization header found in the response")
364390
}
365391

366-
const credentials = authCredentialsSchema.parse({ clientToken, sessionId })
367-
368-
return { credentials, sessionToken }
392+
return authCredentialsSchema.parse({ clientToken, sessionId })
369393
}
370394

371395
private async clerkCreateSessionToken(): Promise<string> {
372396
const formData = new URLSearchParams()
373397
formData.append("_is_native", "1")
374398

375-
const response = await axios.post(
376-
`${getClerkBaseUrl()}/v1/client/sessions/${this.credentials!.sessionId}/tokens`,
377-
formData,
378-
{
379-
headers: {
380-
"Content-Type": "application/x-www-form-urlencoded",
381-
Authorization: `Bearer ${this.credentials!.clientToken}`,
382-
"User-Agent": this.userAgent(),
383-
},
399+
const response = await fetch(`${getClerkBaseUrl()}/v1/client/sessions/${this.credentials!.sessionId}/tokens`, {
400+
method: "POST",
401+
headers: {
402+
"Content-Type": "application/x-www-form-urlencoded",
403+
Authorization: `Bearer ${this.credentials!.clientToken}`,
404+
"User-Agent": this.userAgent(),
384405
},
385-
)
386-
387-
const sessionToken = response.data?.jwt
406+
body: formData.toString(),
407+
signal: AbortSignal.timeout(10000),
408+
})
388409

389-
if (!sessionToken) {
390-
throw new Error("No JWT found in refresh response")
410+
if (response.status >= 400 && response.status < 500) {
411+
throw new InvalidClientTokenError()
412+
} else if (!response.ok) {
413+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
391414
}
392415

393-
return sessionToken
416+
const data = clerkCreateSessionTokenResponseSchema.parse(await response.json())
417+
418+
return data.jwt
394419
}
395420

396421
private async clerkMe(): Promise<CloudUserInfo> {
397-
const response = await axios.get(`${getClerkBaseUrl()}/v1/me`, {
422+
const response = await fetch(`${getClerkBaseUrl()}/v1/me`, {
398423
headers: {
399424
Authorization: `Bearer ${this.credentials!.clientToken}`,
400425
"User-Agent": this.userAgent(),
401426
},
427+
signal: AbortSignal.timeout(10000),
402428
})
403429

404-
const userData = response.data?.response
405-
406-
if (!userData) {
407-
throw new Error("No response user data")
430+
if (!response.ok) {
431+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
408432
}
409433

434+
const { response: userData } = clerkMeResponseSchema.parse(await response.json())
435+
410436
const userInfo: CloudUserInfo = {}
411437

412-
userInfo.name = `${userData?.first_name} ${userData?.last_name}`
413-
const primaryEmailAddressId = userData?.primary_email_address_id
414-
const emailAddresses = userData?.email_addresses
438+
userInfo.name = `${userData.first_name} ${userData.last_name}`
439+
const primaryEmailAddressId = userData.primary_email_address_id
440+
const emailAddresses = userData.email_addresses
415441

416442
if (primaryEmailAddressId && emailAddresses) {
417443
userInfo.email = emailAddresses.find(
418-
(email: { id: string }) => primaryEmailAddressId === email?.id,
444+
(email: { id: string }) => primaryEmailAddressId === email.id,
419445
)?.email_address
420446
}
421447

422-
userInfo.picture = userData?.image_url
448+
userInfo.picture = userData.image_url
423449
return userInfo
424450
}
425451

426452
private async clerkLogout(credentials: AuthCredentials): Promise<void> {
427453
const formData = new URLSearchParams()
428454
formData.append("_is_native", "1")
429455

430-
await axios.post(`${getClerkBaseUrl()}/v1/client/sessions/${credentials.sessionId}/remove`, formData, {
456+
const response = await fetch(`${getClerkBaseUrl()}/v1/client/sessions/${credentials.sessionId}/remove`, {
457+
method: "POST",
431458
headers: {
459+
"Content-Type": "application/x-www-form-urlencoded",
432460
Authorization: `Bearer ${credentials.clientToken}`,
433461
"User-Agent": this.userAgent(),
434462
},
463+
body: formData.toString(),
464+
signal: AbortSignal.timeout(10000),
435465
})
466+
467+
if (!response.ok) {
468+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
469+
}
436470
}
437471

438472
private userAgent(): string {
439473
return getUserAgent(this.context)
440474
}
441-
442475
}

packages/cloud/src/__mocks__/vscode.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,18 @@ export interface ExtensionContext {
1919
get: (key: string) => Promise<string | undefined>
2020
store: (key: string, value: string) => Promise<void>
2121
delete: (key: string) => Promise<void>
22+
onDidChange: (listener: (e: { key: string }) => void) => { dispose: () => void }
2223
}
2324
globalState: {
2425
get: <T>(key: string) => T | undefined
2526
update: (key: string, value: any) => Promise<void>
2627
}
28+
subscriptions: any[]
2729
extension?: {
2830
packageJSON?: {
2931
version?: string
32+
publisher?: string
33+
name?: string
3034
}
3135
}
3236
}
@@ -37,14 +41,18 @@ export const mockExtensionContext: ExtensionContext = {
3741
get: vi.fn().mockResolvedValue(undefined),
3842
store: vi.fn().mockResolvedValue(undefined),
3943
delete: vi.fn().mockResolvedValue(undefined),
44+
onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }),
4045
},
4146
globalState: {
4247
get: vi.fn().mockReturnValue(undefined),
4348
update: vi.fn().mockResolvedValue(undefined),
4449
},
50+
subscriptions: [],
4551
extension: {
4652
packageJSON: {
4753
version: "1.0.0",
54+
publisher: "RooVeterinaryInc",
55+
name: "roo-cline",
4856
},
4957
},
5058
}

0 commit comments

Comments
 (0)