import crypto from "crypto" import EventEmitter from "events" import * as vscode from "vscode" import { z } from "zod" import type { CloudUserInfo, CloudOrganizationMembership } from "@roo-code/types" import { getClerkBaseUrl, getRooCodeApiUrl, PRODUCTION_CLERK_BASE_URL } from "./Config" import { RefreshTimer } from "./RefreshTimer" import { getUserAgent } from "./utils" export interface AuthServiceEvents { "attempting-session": [data: { previousState: AuthState }] "inactive-session": [data: { previousState: AuthState }] "active-session": [data: { previousState: AuthState }] "logged-out": [data: { previousState: AuthState }] "user-info": [data: { userInfo: CloudUserInfo }] } const authCredentialsSchema = z.object({ clientToken: z.string().min(1, "Client token cannot be empty"), sessionId: z.string().min(1, "Session ID cannot be empty"), }) type AuthCredentials = z.infer const AUTH_STATE_KEY = "clerk-auth-state" type AuthState = "initializing" | "logged-out" | "active-session" | "attempting-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 { private context: vscode.ExtensionContext private timer: RefreshTimer private state: AuthState = "initializing" private log: (...args: unknown[]) => void private readonly authCredentialsKey: string private credentials: AuthCredentials | null = null private sessionToken: string | null = null private userInfo: CloudUserInfo | null = null private isFirstRefreshAttempt: boolean = false constructor(context: vscode.ExtensionContext, log?: (...args: unknown[]) => void) { super() this.context = context this.log = log || console.log // Calculate auth credentials key based on Clerk base URL const clerkBaseUrl = getClerkBaseUrl() if (clerkBaseUrl !== PRODUCTION_CLERK_BASE_URL) { this.authCredentialsKey = `clerk-auth-credentials-${clerkBaseUrl}` } else { this.authCredentialsKey = "clerk-auth-credentials" } this.timer = new RefreshTimer({ callback: async () => { await this.refreshSession() return true }, successInterval: 50_000, initialBackoffMs: 1_000, maxBackoffMs: 300_000, }) } private async handleCredentialsChange(): Promise { try { const credentials = await this.loadCredentials() if (credentials) { if ( this.credentials === null || this.credentials.clientToken !== credentials.clientToken || this.credentials.sessionId !== credentials.sessionId ) { this.transitionToAttemptingSession(credentials) } } else { if (this.state !== "logged-out") { this.transitionToLoggedOut() } } } catch (error) { this.log("[auth] Error handling credentials change:", error) } } private transitionToLoggedOut(): void { this.timer.stop() const previousState = this.state this.credentials = null this.sessionToken = null this.userInfo = null this.state = "logged-out" this.emit("logged-out", { previousState }) this.log("[auth] Transitioned to logged-out state") } private transitionToAttemptingSession(credentials: AuthCredentials): void { this.credentials = credentials const previousState = this.state this.state = "attempting-session" this.sessionToken = null this.userInfo = null this.isFirstRefreshAttempt = true this.emit("attempting-session", { previousState }) this.timer.start() this.log("[auth] Transitioned to attempting-session state") } private transitionToInactiveSession(): void { const previousState = this.state this.state = "inactive-session" this.sessionToken = null this.userInfo = null this.emit("inactive-session", { previousState }) this.log("[auth] Transitioned to inactive-session state") } /** * Initialize the auth state * * This method loads tokens from storage and determines the current auth state. * It also starts the refresh timer if we have an active session. */ public async initialize(): Promise { if (this.state !== "initializing") { this.log("[auth] initialize() called after already initialized") return } await this.handleCredentialsChange() this.context.subscriptions.push( this.context.secrets.onDidChange((e) => { if (e.key === this.authCredentialsKey) { this.handleCredentialsChange() } }), ) } private async storeCredentials(credentials: AuthCredentials): Promise { await this.context.secrets.store(this.authCredentialsKey, JSON.stringify(credentials)) } private async loadCredentials(): Promise { const credentialsJson = await this.context.secrets.get(this.authCredentialsKey) if (!credentialsJson) return null try { const parsedJson = JSON.parse(credentialsJson) return authCredentialsSchema.parse(parsedJson) } catch (error) { if (error instanceof z.ZodError) { this.log("[auth] Invalid credentials format:", error.errors) } else { this.log("[auth] Failed to parse stored credentials:", error) } return null } } private async clearCredentials(): Promise { await this.context.secrets.delete(this.authCredentialsKey) } /** * Start the login process * * This method initiates the authentication flow by generating a state parameter * and opening the browser to the authorization URL. */ public async login(): Promise { try { // Generate a cryptographically random state parameter. const state = crypto.randomBytes(16).toString("hex") await this.context.globalState.update(AUTH_STATE_KEY, state) const packageJSON = this.context.extension?.packageJSON const publisher = packageJSON?.publisher ?? "RooVeterinaryInc" const name = packageJSON?.name ?? "roo-cline" const params = new URLSearchParams({ state, auth_redirect: `${vscode.env.uriScheme}://${publisher}.${name}`, }) const url = `${getRooCodeApiUrl()}/extension/sign-in?${params.toString()}` await vscode.env.openExternal(vscode.Uri.parse(url)) } catch (error) { this.log(`[auth] Error initiating Roo Code Cloud auth: ${error}`) throw new Error(`Failed to initiate Roo Code Cloud authentication: ${error}`) } } /** * Handle the callback from Roo Code Cloud * * This method is called when the user is redirected back to the extension * after authenticating with Roo Code Cloud. * * @param code The authorization code from the callback * @param state The state parameter from the callback */ public async handleCallback(code: string | null, state: string | null): Promise { if (!code || !state) { vscode.window.showInformationMessage("Invalid Roo Code Cloud sign in url") return } try { // Validate state parameter to prevent CSRF attacks. const storedState = this.context.globalState.get(AUTH_STATE_KEY) if (state !== storedState) { this.log("[auth] State mismatch in callback") throw new Error("Invalid state parameter. Authentication request may have been tampered with.") } const credentials = await this.clerkSignIn(code) await this.storeCredentials(credentials) vscode.window.showInformationMessage("Successfully authenticated with Roo Code Cloud") this.log("[auth] Successfully authenticated with Roo Code Cloud") } catch (error) { this.log(`[auth] Error handling Roo Code Cloud callback: ${error}`) const previousState = this.state this.state = "logged-out" this.emit("logged-out", { previousState }) throw new Error(`Failed to handle Roo Code Cloud callback: ${error}`) } } /** * Log out * * This method removes all stored tokens and stops the refresh timer. */ public async logout(): Promise { const oldCredentials = this.credentials try { // Clear credentials from storage - onDidChange will handle state transitions await this.clearCredentials() await this.context.globalState.update(AUTH_STATE_KEY, undefined) if (oldCredentials) { try { await this.clerkLogout(oldCredentials) } catch (error) { this.log("[auth] Error calling clerkLogout:", error) } } vscode.window.showInformationMessage("Logged out from Roo Code Cloud") this.log("[auth] Logged out from Roo Code Cloud") } catch (error) { this.log(`[auth] Error logging out from Roo Code Cloud: ${error}`) throw new Error(`Failed to log out from Roo Code Cloud: ${error}`) } } public getState(): AuthState { return this.state } public getSessionToken(): string | undefined { if (this.state === "active-session" && this.sessionToken) { return this.sessionToken } return } /** * Check if the user is authenticated * * @returns True if the user is authenticated (has an active, attempting, or inactive session) */ public isAuthenticated(): boolean { return ( this.state === "active-session" || this.state === "attempting-session" || this.state === "inactive-session" ) } public hasActiveSession(): boolean { return this.state === "active-session" } /** * Check if the user has an active session or is currently attempting to acquire one * * @returns True if the user has an active session or is attempting to get one */ public hasOrIsAcquiringActiveSession(): boolean { return this.state === "active-session" || this.state === "attempting-session" } /** * Refresh the session * * This method refreshes the session token using the client token. */ private async refreshSession(): Promise { if (!this.credentials) { this.log("[auth] Cannot refresh session: missing credentials") return } try { const previousState = this.state this.sessionToken = await this.clerkCreateSessionToken() this.state = "active-session" if (previousState !== "active-session") { this.log("[auth] Transitioned to active-session state") this.emit("active-session", { previousState }) this.fetchUserInfo() } } catch (error) { if (error instanceof InvalidClientTokenError) { this.log("[auth] Invalid/Expired client token: clearing credentials") this.clearCredentials() } else if (this.isFirstRefreshAttempt && this.state === "attempting-session") { this.isFirstRefreshAttempt = false this.transitionToInactiveSession() } this.log("[auth] Failed to refresh session", error) throw error } } private async fetchUserInfo(): Promise { if (!this.credentials) { return } this.userInfo = await this.clerkMe() this.emit("user-info", { userInfo: this.userInfo }) } /** * Extract user information from the ID token * * @returns User information from ID token claims or null if no ID token available */ public getUserInfo(): CloudUserInfo | null { return this.userInfo } private async clerkSignIn(ticket: string): Promise { const formData = new URLSearchParams() formData.append("strategy", "ticket") formData.append("ticket", ticket) 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), }) if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) } const { response: { created_session_id: sessionId }, } = clerkSignInResponseSchema.parse(await response.json()) // 3. Extract the client token from the Authorization header. const clientToken = response.headers.get("authorization") if (!clientToken) { throw new Error("No authorization header found in the response") } return authCredentialsSchema.parse({ clientToken, sessionId }) } private async clerkCreateSessionToken(): Promise { const formData = new URLSearchParams() formData.append("_is_native", "1") 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(), }, body: formData.toString(), signal: AbortSignal.timeout(10000), }) if (response.status >= 400 && response.status < 500) { throw new InvalidClientTokenError() } else if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) } const data = clerkCreateSessionTokenResponseSchema.parse(await response.json()) return data.jwt } private async clerkMe(): Promise { const response = await fetch(`${getClerkBaseUrl()}/v1/me`, { headers: { Authorization: `Bearer ${this.credentials!.clientToken}`, "User-Agent": this.userAgent(), }, signal: AbortSignal.timeout(10000), }) 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 if (primaryEmailAddressId && emailAddresses) { userInfo.email = emailAddresses.find( (email: { id: string }) => primaryEmailAddressId === email.id, )?.email_address } userInfo.picture = userData.image_url // Fetch organization memberships separately try { const orgMemberships = await this.clerkGetOrganizationMemberships() if (orgMemberships && orgMemberships.length > 0) { // Get the first (or active) organization membership const primaryOrgMembership = orgMemberships[0] const organization = primaryOrgMembership?.organization if (organization) { userInfo.organizationId = organization.id userInfo.organizationName = organization.name userInfo.organizationRole = primaryOrgMembership.role userInfo.organizationImageUrl = organization.image_url } } } catch (error) { this.log("[auth] Failed to fetch organization memberships:", error) // Don't throw - organization info is optional } return userInfo } private async clerkGetOrganizationMemberships(): Promise { const response = await fetch(`${getClerkBaseUrl()}/v1/me/organization_memberships`, { headers: { Authorization: `Bearer ${this.credentials!.clientToken}`, "User-Agent": this.userAgent(), }, signal: AbortSignal.timeout(10000), }) return clerkOrganizationMembershipsSchema.parse(await response.json()).response } private async clerkLogout(credentials: AuthCredentials): Promise { const formData = new URLSearchParams() formData.append("_is_native", "1") 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), }) if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) } } private userAgent(): string { return getUserAgent(this.context) } }