Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 114 additions & 80 deletions packages/cloud/src/AuthService.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import crypto from "crypto"
import EventEmitter from "events"

import axios from "axios"
import * as vscode from "vscode"
import { z } from "zod"

Expand Down Expand Up @@ -30,6 +29,61 @@ const AUTH_STATE_KEY = "clerk-auth-state"

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

const clerkSignInResponseSchema = z.object({
response: z.object({
created_session_id: z.string(),
}),
})

const clerkCreateSessionTokenResponseSchema = z.object({
jwt: z.string(),
})

const clerkMeResponseSchema = z.object({
response: z.object({
first_name: z.string().optional(),
last_name: z.string().optional(),
image_url: z.string().optional(),
primary_email_address_id: z.string().optional(),
email_addresses: z
.array(
z.object({
id: z.string(),
email_address: z.string(),
}),
)
.optional(),
}),
})

const clerkOrganizationMembershipsSchema = z.object({
response: z.array(
z.object({
id: z.string(),
role: z.string(),
permissions: z.array(z.string()).optional(),
created_at: z.number().optional(),
updated_at: z.number().optional(),
organization: z.object({
id: z.string(),
name: z.string(),
slug: z.string().optional(),
image_url: z.string().optional(),
has_image: z.boolean().optional(),
created_at: z.number().optional(),
updated_at: z.number().optional(),
}),
}),
),
})

class InvalidClientTokenError extends Error {
constructor() {
super("Invalid/Expired client token")
Object.setPrototypeOf(this, InvalidClientTokenError.prototype)
}
}

export class AuthService extends EventEmitter<AuthServiceEvents> {
private context: vscode.ExtensionContext
private timer: RefreshTimer
Expand Down Expand Up @@ -208,7 +262,7 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {
throw new Error("Invalid state parameter. Authentication request may have been tampered with.")
}

const { credentials } = await this.clerkSignIn(code)
const credentials = await this.clerkSignIn(code)

await this.storeCredentials(credentials)

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

Expand All @@ -300,6 +353,10 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {
this.fetchUserInfo()
}
} catch (error) {
if (error instanceof InvalidClientTokenError) {
this.log("[auth] Invalid/Expired client token: clearing credentials")
this.clearCredentials()
}
this.log("[auth] Failed to refresh session", error)
throw error
}
Expand All @@ -323,103 +380,93 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {
return this.userInfo
}

private async clerkSignIn(ticket: string): Promise<{ credentials: AuthCredentials; sessionToken: string }> {
private async clerkSignIn(ticket: string): Promise<AuthCredentials> {
const formData = new URLSearchParams()
formData.append("strategy", "ticket")
formData.append("ticket", ticket)

const response = await axios.post(`${getClerkBaseUrl()}/v1/client/sign_ins`, formData, {
const response = await fetch(`${getClerkBaseUrl()}/v1/client/sign_ins`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": this.userAgent(),
},
body: formData.toString(),
signal: AbortSignal.timeout(10000),
})

// 3. Extract the client token from the Authorization header.
const clientToken = response.headers.authorization

if (!clientToken) {
throw new Error("No authorization header found in the response")
}

// 4. Find the session using created_session_id and extract the JWT.
const sessionId = response.data?.response?.created_session_id

if (!sessionId) {
throw new Error("No session ID found in the response")
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}

// Find the session in the client sessions array.
const session = response.data?.client?.sessions?.find((s: { id: string }) => s.id === sessionId)

if (!session) {
throw new Error("Session not found in the response")
}
const {
response: { created_session_id: sessionId },
} = clerkSignInResponseSchema.parse(await response.json())

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

if (!sessionToken) {
throw new Error("Session does not have a token")
if (!clientToken) {
throw new Error("No authorization header found in the response")
}

const credentials = authCredentialsSchema.parse({ clientToken, sessionId })

return { credentials, sessionToken }
return authCredentialsSchema.parse({ clientToken, sessionId })
}

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

const response = await axios.post(
`${getClerkBaseUrl()}/v1/client/sessions/${this.credentials!.sessionId}/tokens`,
formData,
{
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Bearer ${this.credentials!.clientToken}`,
"User-Agent": this.userAgent(),
},
const response = await fetch(`${getClerkBaseUrl()}/v1/client/sessions/${this.credentials!.sessionId}/tokens`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Bearer ${this.credentials!.clientToken}`,
"User-Agent": this.userAgent(),
},
)

const sessionToken = response.data?.jwt
body: formData.toString(),
signal: AbortSignal.timeout(10000),
})

if (!sessionToken) {
throw new Error("No JWT found in refresh response")
if (response.status >= 400 && response.status < 500) {
throw new InvalidClientTokenError()
} else if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}

return sessionToken
const data = clerkCreateSessionTokenResponseSchema.parse(await response.json())

return data.jwt
}

private async clerkMe(): Promise<CloudUserInfo> {
const response = await axios.get(`${getClerkBaseUrl()}/v1/me`, {
const response = await fetch(`${getClerkBaseUrl()}/v1/me`, {
headers: {
Authorization: `Bearer ${this.credentials!.clientToken}`,
"User-Agent": this.userAgent(),
},
signal: AbortSignal.timeout(10000),
})

const userData = response.data?.response

if (!userData) {
throw new Error("No response user data")
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}

const { response: userData } = clerkMeResponseSchema.parse(await response.json())

const userInfo: CloudUserInfo = {}

userInfo.name = `${userData?.first_name} ${userData?.last_name}`
const primaryEmailAddressId = userData?.primary_email_address_id
const emailAddresses = userData?.email_addresses
userInfo.name = `${userData.first_name} ${userData.last_name}`
const primaryEmailAddressId = userData.primary_email_address_id
const emailAddresses = userData.email_addresses

if (primaryEmailAddressId && emailAddresses) {
userInfo.email = emailAddresses.find(
(email: { id: string }) => primaryEmailAddressId === email?.id,
(email: { id: string }) => primaryEmailAddressId === email.id,
)?.email_address
}

userInfo.picture = userData?.image_url
userInfo.picture = userData.image_url

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

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

// The response structure is: { response: [...] }
// Extract the organization memberships from the response.response array
return response.data?.response || []
return clerkOrganizationMembershipsSchema.parse(await response.json()).response
}

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

await axios.post(`${getClerkBaseUrl()}/v1/client/sessions/${credentials.sessionId}/remove`, formData, {
const response = await fetch(`${getClerkBaseUrl()}/v1/client/sessions/${credentials.sessionId}/remove`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Bearer ${credentials.clientToken}`,
"User-Agent": this.userAgent(),
},
body: formData.toString(),
signal: AbortSignal.timeout(10000),
})
}

private userAgent(): string {
return getUserAgent(this.context)
}

private static _instance: AuthService | null = null

static get instance() {
if (!this._instance) {
throw new Error("AuthService not initialized")
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}

return this._instance
}

static async createInstance(context: vscode.ExtensionContext, log?: (...args: unknown[]) => void) {
if (this._instance) {
throw new Error("AuthService instance already created")
}

this._instance = new AuthService(context, log)
await this._instance.initialize()
return this._instance
private userAgent(): string {
return getUserAgent(this.context)
}
}
14 changes: 5 additions & 9 deletions packages/cloud/src/CloudService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,18 @@ export class CloudService {
}

try {
this.authService = await AuthService.createInstance(this.context, this.log)
this.authService = new AuthService(this.context, this.log)
await this.authService.initialize()

this.authService.on("inactive-session", this.authListener)
this.authService.on("active-session", this.authListener)
this.authService.on("logged-out", this.authListener)
this.authService.on("user-info", this.authListener)

this.settingsService = await SettingsService.createInstance(this.context, () =>
this.settingsService = new SettingsService(this.context, this.authService, () =>
this.callbacks.stateChanged?.(),
)
this.settingsService.initialize()

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

Expand Down Expand Up @@ -162,13 +164,7 @@ export class CloudService {
}

private ensureInitialized(): void {
if (
!this.isInitialized ||
!this.authService ||
!this.settingsService ||
!this.telemetryClient ||
!this.shareService
) {
if (!this.isInitialized) {
throw new Error("CloudService not initialized.")
}
}
Expand Down
20 changes: 1 addition & 19 deletions packages/cloud/src/SettingsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,13 @@ import { RefreshTimer } from "./RefreshTimer"
const ORGANIZATION_SETTINGS_CACHE_KEY = "organization-settings"

export class SettingsService {
private static _instance: SettingsService | null = null

private context: vscode.ExtensionContext
private authService: AuthService
private settings: OrganizationSettings | undefined = undefined
private timer: RefreshTimer

private constructor(context: vscode.ExtensionContext, authService: AuthService, callback: () => void) {
constructor(context: vscode.ExtensionContext, authService: AuthService, callback: () => void) {
this.context = context
this.authService = authService

Expand Down Expand Up @@ -122,21 +121,4 @@ export class SettingsService {
this.timer.stop()
}

static get instance() {
if (!this._instance) {
throw new Error("SettingsService not initialized")
}

return this._instance
}

static async createInstance(context: vscode.ExtensionContext, callback: () => void) {
if (this._instance) {
throw new Error("SettingsService instance already created")
}

this._instance = new SettingsService(context, AuthService.instance, callback)
this._instance.initialize()
return this._instance
}
}
Loading
Loading