Skip to content

Commit a879f24

Browse files
authored
cloud service cleanup (auth mostly) (#4539)
1 parent ea7749d commit a879f24

File tree

6 files changed

+948
-116
lines changed

6 files changed

+948
-116
lines changed

packages/cloud/src/AuthService.ts

Lines changed: 114 additions & 80 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,61 @@ 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+
const clerkOrganizationMembershipsSchema = z.object({
60+
response: z.array(
61+
z.object({
62+
id: z.string(),
63+
role: z.string(),
64+
permissions: z.array(z.string()).optional(),
65+
created_at: z.number().optional(),
66+
updated_at: z.number().optional(),
67+
organization: z.object({
68+
id: z.string(),
69+
name: z.string(),
70+
slug: z.string().optional(),
71+
image_url: z.string().optional(),
72+
has_image: z.boolean().optional(),
73+
created_at: z.number().optional(),
74+
updated_at: z.number().optional(),
75+
}),
76+
}),
77+
),
78+
})
79+
80+
class InvalidClientTokenError extends Error {
81+
constructor() {
82+
super("Invalid/Expired client token")
83+
Object.setPrototypeOf(this, InvalidClientTokenError.prototype)
84+
}
85+
}
86+
3387
export class AuthService extends EventEmitter<AuthServiceEvents> {
3488
private context: vscode.ExtensionContext
3589
private timer: RefreshTimer
@@ -208,7 +262,7 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {
208262
throw new Error("Invalid state parameter. Authentication request may have been tampered with.")
209263
}
210264

211-
const { credentials } = await this.clerkSignIn(code)
265+
const credentials = await this.clerkSignIn(code)
212266

213267
await this.storeCredentials(credentials)
214268

@@ -285,7 +339,6 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {
285339
private async refreshSession(): Promise<void> {
286340
if (!this.credentials) {
287341
this.log("[auth] Cannot refresh session: missing credentials")
288-
this.state = "inactive-session"
289342
return
290343
}
291344

@@ -300,6 +353,10 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {
300353
this.fetchUserInfo()
301354
}
302355
} catch (error) {
356+
if (error instanceof InvalidClientTokenError) {
357+
this.log("[auth] Invalid/Expired client token: clearing credentials")
358+
this.clearCredentials()
359+
}
303360
this.log("[auth] Failed to refresh session", error)
304361
throw error
305362
}
@@ -323,103 +380,93 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {
323380
return this.userInfo
324381
}
325382

326-
private async clerkSignIn(ticket: string): Promise<{ credentials: AuthCredentials; sessionToken: string }> {
383+
private async clerkSignIn(ticket: string): Promise<AuthCredentials> {
327384
const formData = new URLSearchParams()
328385
formData.append("strategy", "ticket")
329386
formData.append("ticket", ticket)
330387

331-
const response = await axios.post(`${getClerkBaseUrl()}/v1/client/sign_ins`, formData, {
388+
const response = await fetch(`${getClerkBaseUrl()}/v1/client/sign_ins`, {
389+
method: "POST",
332390
headers: {
333391
"Content-Type": "application/x-www-form-urlencoded",
334392
"User-Agent": this.userAgent(),
335393
},
394+
body: formData.toString(),
395+
signal: AbortSignal.timeout(10000),
336396
})
337397

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")
343-
}
344-
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")
398+
if (!response.ok) {
399+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
350400
}
351401

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-
}
402+
const {
403+
response: { created_session_id: sessionId },
404+
} = clerkSignInResponseSchema.parse(await response.json())
358405

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

362-
if (!sessionToken) {
363-
throw new Error("Session does not have a token")
409+
if (!clientToken) {
410+
throw new Error("No authorization header found in the response")
364411
}
365412

366-
const credentials = authCredentialsSchema.parse({ clientToken, sessionId })
367-
368-
return { credentials, sessionToken }
413+
return authCredentialsSchema.parse({ clientToken, sessionId })
369414
}
370415

371416
private async clerkCreateSessionToken(): Promise<string> {
372417
const formData = new URLSearchParams()
373418
formData.append("_is_native", "1")
374419

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-
},
420+
const response = await fetch(`${getClerkBaseUrl()}/v1/client/sessions/${this.credentials!.sessionId}/tokens`, {
421+
method: "POST",
422+
headers: {
423+
"Content-Type": "application/x-www-form-urlencoded",
424+
Authorization: `Bearer ${this.credentials!.clientToken}`,
425+
"User-Agent": this.userAgent(),
384426
},
385-
)
386-
387-
const sessionToken = response.data?.jwt
427+
body: formData.toString(),
428+
signal: AbortSignal.timeout(10000),
429+
})
388430

389-
if (!sessionToken) {
390-
throw new Error("No JWT found in refresh response")
431+
if (response.status >= 400 && response.status < 500) {
432+
throw new InvalidClientTokenError()
433+
} else if (!response.ok) {
434+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
391435
}
392436

393-
return sessionToken
437+
const data = clerkCreateSessionTokenResponseSchema.parse(await response.json())
438+
439+
return data.jwt
394440
}
395441

396442
private async clerkMe(): Promise<CloudUserInfo> {
397-
const response = await axios.get(`${getClerkBaseUrl()}/v1/me`, {
443+
const response = await fetch(`${getClerkBaseUrl()}/v1/me`, {
398444
headers: {
399445
Authorization: `Bearer ${this.credentials!.clientToken}`,
400446
"User-Agent": this.userAgent(),
401447
},
448+
signal: AbortSignal.timeout(10000),
402449
})
403450

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

455+
const { response: userData } = clerkMeResponseSchema.parse(await response.json())
456+
410457
const userInfo: CloudUserInfo = {}
411458

412-
userInfo.name = `${userData?.first_name} ${userData?.last_name}`
413-
const primaryEmailAddressId = userData?.primary_email_address_id
414-
const emailAddresses = userData?.email_addresses
459+
userInfo.name = `${userData.first_name} ${userData.last_name}`
460+
const primaryEmailAddressId = userData.primary_email_address_id
461+
const emailAddresses = userData.email_addresses
415462

416463
if (primaryEmailAddressId && emailAddresses) {
417464
userInfo.email = emailAddresses.find(
418-
(email: { id: string }) => primaryEmailAddressId === email?.id,
465+
(email: { id: string }) => primaryEmailAddressId === email.id,
419466
)?.email_address
420467
}
421468

422-
userInfo.picture = userData?.image_url
469+
userInfo.picture = userData.image_url
423470

424471
// Fetch organization memberships separately
425472
try {
@@ -444,51 +491,38 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {
444491
}
445492

446493
private async clerkGetOrganizationMemberships(): Promise<CloudOrganizationMembership[]> {
447-
const response = await axios.get(`${getClerkBaseUrl()}/v1/me/organization_memberships`, {
494+
const response = await fetch(`${getClerkBaseUrl()}/v1/me/organization_memberships`, {
448495
headers: {
449496
Authorization: `Bearer ${this.credentials!.clientToken}`,
450497
"User-Agent": this.userAgent(),
451498
},
499+
signal: AbortSignal.timeout(10000),
452500
})
453501

454-
// The response structure is: { response: [...] }
455-
// Extract the organization memberships from the response.response array
456-
return response.data?.response || []
502+
return clerkOrganizationMembershipsSchema.parse(await response.json()).response
457503
}
458504

459505
private async clerkLogout(credentials: AuthCredentials): Promise<void> {
460506
const formData = new URLSearchParams()
461507
formData.append("_is_native", "1")
462508

463-
await axios.post(`${getClerkBaseUrl()}/v1/client/sessions/${credentials.sessionId}/remove`, formData, {
509+
const response = await fetch(`${getClerkBaseUrl()}/v1/client/sessions/${credentials.sessionId}/remove`, {
510+
method: "POST",
464511
headers: {
512+
"Content-Type": "application/x-www-form-urlencoded",
465513
Authorization: `Bearer ${credentials.clientToken}`,
466514
"User-Agent": this.userAgent(),
467515
},
516+
body: formData.toString(),
517+
signal: AbortSignal.timeout(10000),
468518
})
469-
}
470-
471-
private userAgent(): string {
472-
return getUserAgent(this.context)
473-
}
474519

475-
private static _instance: AuthService | null = null
476-
477-
static get instance() {
478-
if (!this._instance) {
479-
throw new Error("AuthService not initialized")
520+
if (!response.ok) {
521+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
480522
}
481-
482-
return this._instance
483523
}
484524

485-
static async createInstance(context: vscode.ExtensionContext, log?: (...args: unknown[]) => void) {
486-
if (this._instance) {
487-
throw new Error("AuthService instance already created")
488-
}
489-
490-
this._instance = new AuthService(context, log)
491-
await this._instance.initialize()
492-
return this._instance
525+
private userAgent(): string {
526+
return getUserAgent(this.context)
493527
}
494528
}

packages/cloud/src/CloudService.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,16 +37,18 @@ export class CloudService {
3737
}
3838

3939
try {
40-
this.authService = await AuthService.createInstance(this.context, this.log)
40+
this.authService = new AuthService(this.context, this.log)
41+
await this.authService.initialize()
4142

4243
this.authService.on("inactive-session", this.authListener)
4344
this.authService.on("active-session", this.authListener)
4445
this.authService.on("logged-out", this.authListener)
4546
this.authService.on("user-info", this.authListener)
4647

47-
this.settingsService = await SettingsService.createInstance(this.context, () =>
48+
this.settingsService = new SettingsService(this.context, this.authService, () =>
4849
this.callbacks.stateChanged?.(),
4950
)
51+
this.settingsService.initialize()
5052

5153
this.telemetryClient = new TelemetryClient(this.authService, this.settingsService)
5254

@@ -162,13 +164,7 @@ export class CloudService {
162164
}
163165

164166
private ensureInitialized(): void {
165-
if (
166-
!this.isInitialized ||
167-
!this.authService ||
168-
!this.settingsService ||
169-
!this.telemetryClient ||
170-
!this.shareService
171-
) {
167+
if (!this.isInitialized) {
172168
throw new Error("CloudService not initialized.")
173169
}
174170
}

packages/cloud/src/SettingsService.ts

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,13 @@ import { RefreshTimer } from "./RefreshTimer"
1414
const ORGANIZATION_SETTINGS_CACHE_KEY = "organization-settings"
1515

1616
export class SettingsService {
17-
private static _instance: SettingsService | null = null
1817

1918
private context: vscode.ExtensionContext
2019
private authService: AuthService
2120
private settings: OrganizationSettings | undefined = undefined
2221
private timer: RefreshTimer
2322

24-
private constructor(context: vscode.ExtensionContext, authService: AuthService, callback: () => void) {
23+
constructor(context: vscode.ExtensionContext, authService: AuthService, callback: () => void) {
2524
this.context = context
2625
this.authService = authService
2726

@@ -122,21 +121,4 @@ export class SettingsService {
122121
this.timer.stop()
123122
}
124123

125-
static get instance() {
126-
if (!this._instance) {
127-
throw new Error("SettingsService not initialized")
128-
}
129-
130-
return this._instance
131-
}
132-
133-
static async createInstance(context: vscode.ExtensionContext, callback: () => void) {
134-
if (this._instance) {
135-
throw new Error("SettingsService instance already created")
136-
}
137-
138-
this._instance = new SettingsService(context, AuthService.instance, callback)
139-
this._instance.initialize()
140-
return this._instance
141-
}
142124
}

0 commit comments

Comments
 (0)