AuthService.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. import crypto from "crypto"
  2. import EventEmitter from "events"
  3. import axios from "axios"
  4. import * as vscode from "vscode"
  5. import type { CloudUserInfo } from "@roo-code/types"
  6. import { getClerkBaseUrl, getRooCodeApiUrl } from "./Config"
  7. import { RefreshTimer } from "./RefreshTimer"
  8. export interface AuthServiceEvents {
  9. "active-session": [data: { previousState: AuthState }]
  10. "logged-out": [data: { previousState: AuthState }]
  11. "user-info": [data: { userInfo: CloudUserInfo }]
  12. }
  13. const CLIENT_TOKEN_KEY = "clerk-client-token"
  14. const SESSION_ID_KEY = "clerk-session-id"
  15. const AUTH_STATE_KEY = "clerk-auth-state"
  16. type AuthState = "initializing" | "logged-out" | "active-session" | "inactive-session"
  17. export class AuthService extends EventEmitter<AuthServiceEvents> {
  18. private context: vscode.ExtensionContext
  19. private timer: RefreshTimer
  20. private state: AuthState = "initializing"
  21. private clientToken: string | null = null
  22. private sessionToken: string | null = null
  23. private sessionId: string | null = null
  24. private userInfo: CloudUserInfo | null = null
  25. constructor(context: vscode.ExtensionContext) {
  26. super()
  27. this.context = context
  28. this.timer = new RefreshTimer({
  29. callback: async () => {
  30. await this.refreshSession()
  31. return true
  32. },
  33. successInterval: 50_000,
  34. initialBackoffMs: 1_000,
  35. maxBackoffMs: 300_000,
  36. })
  37. }
  38. /**
  39. * Initialize the auth state
  40. *
  41. * This method loads tokens from storage and determines the current auth state.
  42. * It also starts the refresh timer if we have an active session.
  43. */
  44. public async initialize(): Promise<void> {
  45. if (this.state !== "initializing") {
  46. console.log("[auth] initialize() called after already initialized")
  47. return
  48. }
  49. try {
  50. this.clientToken = (await this.context.secrets.get(CLIENT_TOKEN_KEY)) || null
  51. this.sessionId = this.context.globalState.get<string>(SESSION_ID_KEY) || null
  52. // Determine initial state.
  53. if (!this.clientToken || !this.sessionId) {
  54. // TODO: it may be possible to get a new session with the client,
  55. // but the obvious Clerk endpoints don't support that.
  56. const previousState = this.state
  57. this.state = "logged-out"
  58. this.emit("logged-out", { previousState })
  59. } else {
  60. this.state = "inactive-session"
  61. this.timer.start()
  62. }
  63. console.log(`[auth] Initialized with state: ${this.state}`)
  64. } catch (error) {
  65. console.error(`[auth] Error initializing AuthService: ${error}`)
  66. this.state = "logged-out"
  67. }
  68. }
  69. /**
  70. * Start the login process
  71. *
  72. * This method initiates the authentication flow by generating a state parameter
  73. * and opening the browser to the authorization URL.
  74. */
  75. public async login(): Promise<void> {
  76. try {
  77. // Generate a cryptographically random state parameter.
  78. const state = crypto.randomBytes(16).toString("hex")
  79. await this.context.globalState.update(AUTH_STATE_KEY, state)
  80. const uri = vscode.Uri.parse(`${getRooCodeApiUrl()}/extension/sign-in?state=${state}`)
  81. await vscode.env.openExternal(uri)
  82. } catch (error) {
  83. console.error(`[auth] Error initiating Roo Code Cloud auth: ${error}`)
  84. throw new Error(`Failed to initiate Roo Code Cloud authentication: ${error}`)
  85. }
  86. }
  87. /**
  88. * Handle the callback from Roo Code Cloud
  89. *
  90. * This method is called when the user is redirected back to the extension
  91. * after authenticating with Roo Code Cloud.
  92. *
  93. * @param code The authorization code from the callback
  94. * @param state The state parameter from the callback
  95. */
  96. public async handleCallback(code: string | null, state: string | null): Promise<void> {
  97. if (!code || !state) {
  98. vscode.window.showInformationMessage("Invalid Roo Code Cloud sign in url")
  99. return
  100. }
  101. try {
  102. // Validate state parameter to prevent CSRF attacks.
  103. const storedState = this.context.globalState.get(AUTH_STATE_KEY)
  104. if (state !== storedState) {
  105. console.log("[auth] State mismatch in callback")
  106. throw new Error("Invalid state parameter. Authentication request may have been tampered with.")
  107. }
  108. const { clientToken, sessionToken, sessionId } = await this.clerkSignIn(code)
  109. await this.context.secrets.store(CLIENT_TOKEN_KEY, clientToken)
  110. await this.context.globalState.update(SESSION_ID_KEY, sessionId)
  111. this.clientToken = clientToken
  112. this.sessionId = sessionId
  113. this.sessionToken = sessionToken
  114. const previousState = this.state
  115. this.state = "active-session"
  116. this.emit("active-session", { previousState })
  117. this.timer.start()
  118. this.fetchUserInfo()
  119. vscode.window.showInformationMessage("Successfully authenticated with Roo Code Cloud")
  120. console.log("[auth] Successfully authenticated with Roo Code Cloud")
  121. } catch (error) {
  122. console.log(`[auth] Error handling Roo Code Cloud callback: ${error}`)
  123. const previousState = this.state
  124. this.state = "logged-out"
  125. this.emit("logged-out", { previousState })
  126. throw new Error(`Failed to handle Roo Code Cloud callback: ${error}`)
  127. }
  128. }
  129. /**
  130. * Log out
  131. *
  132. * This method removes all stored tokens and stops the refresh timer.
  133. */
  134. public async logout(): Promise<void> {
  135. try {
  136. this.timer.stop()
  137. await this.context.secrets.delete(CLIENT_TOKEN_KEY)
  138. await this.context.globalState.update(SESSION_ID_KEY, undefined)
  139. await this.context.globalState.update(AUTH_STATE_KEY, undefined)
  140. const oldClientToken = this.clientToken
  141. const oldSessionId = this.sessionId
  142. this.clientToken = null
  143. this.sessionToken = null
  144. this.sessionId = null
  145. this.userInfo = null
  146. const previousState = this.state
  147. this.state = "logged-out"
  148. this.emit("logged-out", { previousState })
  149. if (oldClientToken && oldSessionId) {
  150. await this.clerkLogout(oldClientToken, oldSessionId)
  151. }
  152. this.fetchUserInfo()
  153. vscode.window.showInformationMessage("Logged out from Roo Code Cloud")
  154. console.log("[auth] Logged out from Roo Code Cloud")
  155. } catch (error) {
  156. console.log(`[auth] Error logging out from Roo Code Cloud: ${error}`)
  157. throw new Error(`Failed to log out from Roo Code Cloud: ${error}`)
  158. }
  159. }
  160. public getState(): AuthState {
  161. return this.state
  162. }
  163. public getSessionToken(): string | undefined {
  164. if (this.state === "active-session" && this.sessionToken) {
  165. return this.sessionToken
  166. }
  167. return
  168. }
  169. /**
  170. * Check if the user is authenticated
  171. *
  172. * @returns True if the user is authenticated (has an active or inactive session)
  173. */
  174. public isAuthenticated(): boolean {
  175. return this.state === "active-session" || this.state === "inactive-session"
  176. }
  177. public hasActiveSession(): boolean {
  178. return this.state === "active-session"
  179. }
  180. /**
  181. * Refresh the session
  182. *
  183. * This method refreshes the session token using the client token.
  184. */
  185. private async refreshSession(): Promise<void> {
  186. if (!this.sessionId || !this.clientToken) {
  187. console.log("[auth] Cannot refresh session: missing session ID or token")
  188. this.state = "inactive-session"
  189. return
  190. }
  191. const previousState = this.state
  192. this.sessionToken = await this.clerkCreateSessionToken()
  193. this.state = "active-session"
  194. if (previousState !== "active-session") {
  195. this.emit("active-session", { previousState })
  196. this.fetchUserInfo()
  197. }
  198. }
  199. private async fetchUserInfo(): Promise<void> {
  200. if (!this.clientToken) {
  201. return
  202. }
  203. this.userInfo = await this.clerkMe()
  204. this.emit("user-info", { userInfo: this.userInfo })
  205. }
  206. /**
  207. * Extract user information from the ID token
  208. *
  209. * @returns User information from ID token claims or null if no ID token available
  210. */
  211. public getUserInfo(): CloudUserInfo | null {
  212. return this.userInfo
  213. }
  214. private async clerkSignIn(
  215. ticket: string,
  216. ): Promise<{ clientToken: string; sessionToken: string; sessionId: string }> {
  217. const formData = new URLSearchParams()
  218. formData.append("strategy", "ticket")
  219. formData.append("ticket", ticket)
  220. const response = await axios.post(`${getClerkBaseUrl()}/v1/client/sign_ins`, formData, {
  221. headers: {
  222. "Content-Type": "application/x-www-form-urlencoded",
  223. "User-Agent": this.userAgent(),
  224. },
  225. })
  226. // 3. Extract the client token from the Authorization header.
  227. const clientToken = response.headers.authorization
  228. if (!clientToken) {
  229. throw new Error("No authorization header found in the response")
  230. }
  231. // 4. Find the session using created_session_id and extract the JWT.
  232. const createdSessionId = response.data?.response?.created_session_id
  233. if (!createdSessionId) {
  234. throw new Error("No session ID found in the response")
  235. }
  236. // Find the session in the client sessions array.
  237. const session = response.data?.client?.sessions?.find((s: { id: string }) => s.id === createdSessionId)
  238. if (!session) {
  239. throw new Error("Session not found in the response")
  240. }
  241. // Extract the session token (JWT) and store it.
  242. const sessionToken = session.last_active_token?.jwt
  243. if (!sessionToken) {
  244. throw new Error("Session does not have a token")
  245. }
  246. return { clientToken, sessionToken, sessionId: session.id }
  247. }
  248. private async clerkCreateSessionToken(): Promise<string> {
  249. const formData = new URLSearchParams()
  250. formData.append("_is_native", "1")
  251. const response = await axios.post(
  252. `${getClerkBaseUrl()}/v1/client/sessions/${this.sessionId}/tokens`,
  253. formData,
  254. {
  255. headers: {
  256. "Content-Type": "application/x-www-form-urlencoded",
  257. Authorization: `Bearer ${this.clientToken}`,
  258. "User-Agent": this.userAgent(),
  259. },
  260. },
  261. )
  262. const sessionToken = response.data?.jwt
  263. if (!sessionToken) {
  264. throw new Error("No JWT found in refresh response")
  265. }
  266. return sessionToken
  267. }
  268. private async clerkMe(): Promise<CloudUserInfo> {
  269. const response = await axios.get(`${getClerkBaseUrl()}/v1/me`, {
  270. headers: {
  271. Authorization: `Bearer ${this.clientToken}`,
  272. "User-Agent": this.userAgent(),
  273. },
  274. })
  275. const userData = response.data?.response
  276. if (!userData) {
  277. throw new Error("No response user data")
  278. }
  279. const userInfo: CloudUserInfo = {}
  280. userInfo.name = `${userData?.first_name} ${userData?.last_name}`
  281. const primaryEmailAddressId = userData?.primary_email_address_id
  282. const emailAddresses = userData?.email_addresses
  283. if (primaryEmailAddressId && emailAddresses) {
  284. userInfo.email = emailAddresses.find(
  285. (email: { id: string }) => primaryEmailAddressId === email?.id,
  286. )?.email_address
  287. }
  288. userInfo.picture = userData?.image_url
  289. return userInfo
  290. }
  291. private async clerkLogout(clientToken: string, sessionId: string): Promise<void> {
  292. const formData = new URLSearchParams()
  293. formData.append("_is_native", "1")
  294. await axios.post(`${getClerkBaseUrl()}/v1/client/sessions/${sessionId}/remove`, formData, {
  295. headers: {
  296. Authorization: `Bearer ${clientToken}`,
  297. "User-Agent": this.userAgent(),
  298. },
  299. })
  300. }
  301. private userAgent(): string {
  302. return `Roo-Code ${this.context.extension?.packageJSON?.version}`
  303. }
  304. private static _instance: AuthService | null = null
  305. static get instance() {
  306. if (!this._instance) {
  307. throw new Error("AuthService not initialized")
  308. }
  309. return this._instance
  310. }
  311. static async createInstance(context: vscode.ExtensionContext) {
  312. if (this._instance) {
  313. throw new Error("AuthService instance already created")
  314. }
  315. this._instance = new AuthService(context)
  316. await this._instance.initialize()
  317. return this._instance
  318. }
  319. }