WebAuthService.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780
  1. import crypto from "crypto"
  2. import EventEmitter from "events"
  3. import type { ExtensionContext } from "vscode"
  4. import { z } from "zod"
  5. import type {
  6. CloudUserInfo,
  7. CloudOrganizationMembership,
  8. AuthService,
  9. AuthServiceEvents,
  10. AuthState,
  11. } from "@roo-code/types"
  12. import { getClerkBaseUrl, getRooCodeApiUrl, PRODUCTION_CLERK_BASE_URL } from "./config.js"
  13. import { getUserAgent } from "./utils.js"
  14. import { importVscode } from "./importVscode.js"
  15. import { InvalidClientTokenError } from "./errors.js"
  16. import { RefreshTimer } from "./RefreshTimer.js"
  17. const AUTH_STATE_KEY = "clerk-auth-state"
  18. /**
  19. * AuthCredentials
  20. */
  21. const authCredentialsSchema = z.object({
  22. clientToken: z.string().min(1, "Client token cannot be empty"),
  23. sessionId: z.string().min(1, "Session ID cannot be empty"),
  24. organizationId: z.string().nullable().optional(),
  25. })
  26. type AuthCredentials = z.infer<typeof authCredentialsSchema>
  27. /**
  28. * Clerk Schemas
  29. */
  30. const clerkSignInResponseSchema = z.object({
  31. response: z.object({
  32. created_session_id: z.string(),
  33. }),
  34. })
  35. const clerkCreateSessionTokenResponseSchema = z.object({
  36. jwt: z.string(),
  37. })
  38. const clerkMeResponseSchema = z.object({
  39. response: z.object({
  40. id: z.string().optional(),
  41. first_name: z.string().nullish(),
  42. last_name: z.string().nullish(),
  43. image_url: z.string().optional(),
  44. primary_email_address_id: z.string().optional(),
  45. email_addresses: z
  46. .array(
  47. z.object({
  48. id: z.string(),
  49. email_address: z.string(),
  50. }),
  51. )
  52. .optional(),
  53. public_metadata: z.record(z.any()).optional(),
  54. }),
  55. })
  56. const clerkOrganizationMembershipsSchema = z.object({
  57. response: z.array(
  58. z.object({
  59. id: z.string(),
  60. role: z.string(),
  61. permissions: z.array(z.string()).optional(),
  62. created_at: z.number().optional(),
  63. updated_at: z.number().optional(),
  64. organization: z.object({
  65. id: z.string(),
  66. name: z.string(),
  67. slug: z.string().optional(),
  68. image_url: z.string().optional(),
  69. has_image: z.boolean().optional(),
  70. created_at: z.number().optional(),
  71. updated_at: z.number().optional(),
  72. }),
  73. }),
  74. ),
  75. })
  76. export class WebAuthService extends EventEmitter<AuthServiceEvents> implements AuthService {
  77. private context: ExtensionContext
  78. private timer: RefreshTimer
  79. private state: AuthState = "initializing"
  80. private log: (...args: unknown[]) => void
  81. private readonly authCredentialsKey: string
  82. private credentials: AuthCredentials | null = null
  83. private sessionToken: string | null = null
  84. private userInfo: CloudUserInfo | null = null
  85. private isFirstRefreshAttempt: boolean = false
  86. constructor(context: ExtensionContext, log?: (...args: unknown[]) => void) {
  87. super()
  88. this.context = context
  89. this.log = log || console.log
  90. this.log("[auth] Using WebAuthService")
  91. // Calculate auth credentials key based on Clerk base URL.
  92. const clerkBaseUrl = getClerkBaseUrl()
  93. if (clerkBaseUrl !== PRODUCTION_CLERK_BASE_URL) {
  94. this.authCredentialsKey = `clerk-auth-credentials-${clerkBaseUrl}`
  95. } else {
  96. this.authCredentialsKey = "clerk-auth-credentials"
  97. }
  98. this.timer = new RefreshTimer({
  99. callback: async () => {
  100. await this.refreshSession()
  101. return true
  102. },
  103. successInterval: 50_000,
  104. initialBackoffMs: 1_000,
  105. maxBackoffMs: 300_000,
  106. })
  107. }
  108. private changeState(newState: AuthState): void {
  109. const previousState = this.state
  110. this.state = newState
  111. this.log(`[auth] changeState: ${previousState} -> ${newState}`)
  112. this.emit("auth-state-changed", { state: newState, previousState })
  113. }
  114. private async handleCredentialsChange(): Promise<void> {
  115. try {
  116. const credentials = await this.loadCredentials()
  117. if (credentials) {
  118. if (
  119. this.credentials === null ||
  120. this.credentials.clientToken !== credentials.clientToken ||
  121. this.credentials.sessionId !== credentials.sessionId ||
  122. this.credentials.organizationId !== credentials.organizationId
  123. ) {
  124. this.transitionToAttemptingSession(credentials)
  125. }
  126. } else {
  127. if (this.state !== "logged-out") {
  128. this.transitionToLoggedOut()
  129. }
  130. }
  131. } catch (error) {
  132. this.log("[auth] Error handling credentials change:", error)
  133. }
  134. }
  135. private transitionToLoggedOut(): void {
  136. this.timer.stop()
  137. this.credentials = null
  138. this.sessionToken = null
  139. this.userInfo = null
  140. this.changeState("logged-out")
  141. }
  142. private transitionToAttemptingSession(credentials: AuthCredentials): void {
  143. this.credentials = credentials
  144. this.sessionToken = null
  145. this.userInfo = null
  146. this.isFirstRefreshAttempt = true
  147. this.changeState("attempting-session")
  148. this.timer.stop()
  149. this.timer.start()
  150. }
  151. private transitionToInactiveSession(): void {
  152. this.sessionToken = null
  153. this.userInfo = null
  154. this.changeState("inactive-session")
  155. }
  156. /**
  157. * Initialize the auth state
  158. *
  159. * This method loads tokens from storage and determines the current auth state.
  160. * It also starts the refresh timer if we have an active session.
  161. */
  162. public async initialize(): Promise<void> {
  163. if (this.state !== "initializing") {
  164. this.log("[auth] initialize() called after already initialized")
  165. return
  166. }
  167. await this.handleCredentialsChange()
  168. this.context.subscriptions.push(
  169. this.context.secrets.onDidChange((e) => {
  170. if (e.key === this.authCredentialsKey) {
  171. this.handleCredentialsChange()
  172. }
  173. }),
  174. )
  175. }
  176. public broadcast(): void {}
  177. private async storeCredentials(credentials: AuthCredentials): Promise<void> {
  178. await this.context.secrets.store(this.authCredentialsKey, JSON.stringify(credentials))
  179. }
  180. private async loadCredentials(): Promise<AuthCredentials | null> {
  181. const credentialsJson = await this.context.secrets.get(this.authCredentialsKey)
  182. if (!credentialsJson) return null
  183. try {
  184. const parsedJson = JSON.parse(credentialsJson)
  185. const credentials = authCredentialsSchema.parse(parsedJson)
  186. // Migration: If no organizationId but we have userInfo, add it
  187. if (credentials.organizationId === undefined && this.userInfo?.organizationId) {
  188. credentials.organizationId = this.userInfo.organizationId
  189. await this.storeCredentials(credentials)
  190. this.log("[auth] Migrated credentials with organizationId")
  191. }
  192. return credentials
  193. } catch (error) {
  194. if (error instanceof z.ZodError) {
  195. this.log("[auth] Invalid credentials format:", error.errors)
  196. } else {
  197. this.log("[auth] Failed to parse stored credentials:", error)
  198. }
  199. return null
  200. }
  201. }
  202. private async clearCredentials(): Promise<void> {
  203. await this.context.secrets.delete(this.authCredentialsKey)
  204. }
  205. /**
  206. * Start the login process
  207. *
  208. * This method initiates the authentication flow by generating a state parameter
  209. * and opening the browser to the authorization URL.
  210. *
  211. * @param landingPageSlug Optional slug of a specific landing page (e.g., "supernova", "special-offer", etc.)
  212. * @param useProviderSignup If true, uses provider signup flow (/extension/provider-sign-up). If false, uses standard sign-in (/extension/sign-in). Defaults to false.
  213. */
  214. public async login(landingPageSlug?: string, useProviderSignup: boolean = false): Promise<void> {
  215. try {
  216. const vscode = await importVscode()
  217. if (!vscode) {
  218. throw new Error("VS Code API not available")
  219. }
  220. // Generate a cryptographically random state parameter.
  221. const state = crypto.randomBytes(16).toString("hex")
  222. await this.context.globalState.update(AUTH_STATE_KEY, state)
  223. const packageJSON = this.context.extension?.packageJSON
  224. const publisher = packageJSON?.publisher ?? "RooVeterinaryInc"
  225. const name = packageJSON?.name ?? "roo-cline"
  226. const params = new URLSearchParams({
  227. state,
  228. auth_redirect: `${vscode.env.uriScheme}://${publisher}.${name}`,
  229. })
  230. // Use landing page URL if slug is provided, otherwise use provider sign-up or sign-in URL based on parameter
  231. const url = landingPageSlug
  232. ? `${getRooCodeApiUrl()}/l/${landingPageSlug}?${params.toString()}`
  233. : useProviderSignup
  234. ? `${getRooCodeApiUrl()}/extension/provider-sign-up?${params.toString()}`
  235. : `${getRooCodeApiUrl()}/extension/sign-in?${params.toString()}`
  236. await vscode.env.openExternal(vscode.Uri.parse(url))
  237. } catch (error) {
  238. const context = landingPageSlug ? ` (landing page: ${landingPageSlug})` : ""
  239. this.log(`[auth] Error initiating Roo Code Cloud auth${context}: ${error}`)
  240. throw new Error(`Failed to initiate Roo Code Cloud authentication${context}: ${error}`)
  241. }
  242. }
  243. /**
  244. * Handle the callback from Roo Code Cloud
  245. *
  246. * This method is called when the user is redirected back to the extension
  247. * after authenticating with Roo Code Cloud.
  248. *
  249. * @param code The authorization code from the callback
  250. * @param state The state parameter from the callback
  251. * @param organizationId The organization ID from the callback (null for personal accounts)
  252. * @param providerModel The model ID selected during signup (optional)
  253. */
  254. public async handleCallback(
  255. code: string | null,
  256. state: string | null,
  257. organizationId?: string | null,
  258. providerModel?: string | null,
  259. ): Promise<void> {
  260. if (!code || !state) {
  261. const vscode = await importVscode()
  262. if (vscode) {
  263. vscode.window.showInformationMessage("Invalid Roo Code Cloud sign in url")
  264. }
  265. return
  266. }
  267. try {
  268. // Validate state parameter to prevent CSRF attacks.
  269. const storedState = this.context.globalState.get(AUTH_STATE_KEY)
  270. if (state !== storedState) {
  271. this.log("[auth] State mismatch in callback")
  272. throw new Error("Invalid state parameter. Authentication request may have been tampered with.")
  273. }
  274. const credentials = await this.clerkSignIn(code)
  275. // Set organizationId (null for personal accounts)
  276. credentials.organizationId = organizationId || null
  277. await this.storeCredentials(credentials)
  278. // Store the provider model if provided
  279. if (providerModel) {
  280. await this.context.globalState.update("roo-provider-model", providerModel)
  281. this.log(`[auth] Stored provider model: ${providerModel}`)
  282. }
  283. const vscode = await importVscode()
  284. if (vscode) {
  285. vscode.window.showInformationMessage("Successfully authenticated with Roo Code Cloud")
  286. }
  287. this.log("[auth] Successfully authenticated with Roo Code Cloud")
  288. } catch (error) {
  289. this.log(`[auth] Error handling Roo Code Cloud callback: ${error}`)
  290. this.changeState("logged-out")
  291. throw new Error(`Failed to handle Roo Code Cloud callback: ${error}`)
  292. }
  293. }
  294. /**
  295. * Log out
  296. *
  297. * This method removes all stored tokens and stops the refresh timer.
  298. */
  299. public async logout(): Promise<void> {
  300. const oldCredentials = this.credentials
  301. try {
  302. // Clear credentials from storage - onDidChange will handle state transitions
  303. await this.clearCredentials()
  304. await this.context.globalState.update(AUTH_STATE_KEY, undefined)
  305. if (oldCredentials) {
  306. try {
  307. await this.clerkLogout(oldCredentials)
  308. } catch (error) {
  309. this.log("[auth] Error calling clerkLogout:", error)
  310. }
  311. }
  312. const vscode = await importVscode()
  313. if (vscode) {
  314. vscode.window.showInformationMessage("Logged out from Roo Code Cloud")
  315. }
  316. this.log("[auth] Logged out from Roo Code Cloud")
  317. } catch (error) {
  318. this.log(`[auth] Error logging out from Roo Code Cloud: ${error}`)
  319. throw new Error(`Failed to log out from Roo Code Cloud: ${error}`)
  320. }
  321. }
  322. public getState(): AuthState {
  323. return this.state
  324. }
  325. public getSessionToken(): string | undefined {
  326. if (this.state === "active-session" && this.sessionToken) {
  327. return this.sessionToken
  328. }
  329. return
  330. }
  331. /**
  332. * Check if the user is authenticated
  333. *
  334. * @returns True if the user is authenticated (has an active, attempting, or inactive session)
  335. */
  336. public isAuthenticated(): boolean {
  337. return (
  338. this.state === "active-session" || this.state === "attempting-session" || this.state === "inactive-session"
  339. )
  340. }
  341. public hasActiveSession(): boolean {
  342. return this.state === "active-session"
  343. }
  344. /**
  345. * Check if the user has an active session or is currently attempting to acquire one
  346. *
  347. * @returns True if the user has an active session or is attempting to get one
  348. */
  349. public hasOrIsAcquiringActiveSession(): boolean {
  350. return this.state === "active-session" || this.state === "attempting-session"
  351. }
  352. /**
  353. * Refresh the session
  354. *
  355. * This method refreshes the session token using the client token.
  356. */
  357. private async refreshSession(): Promise<void> {
  358. if (!this.credentials) {
  359. this.log("[auth] Cannot refresh session: missing credentials")
  360. return
  361. }
  362. try {
  363. const previousState = this.state
  364. this.sessionToken = await this.clerkCreateSessionToken()
  365. if (previousState !== "active-session") {
  366. this.changeState("active-session")
  367. this.fetchUserInfo()
  368. } else {
  369. this.state = "active-session"
  370. }
  371. } catch (error) {
  372. if (error instanceof InvalidClientTokenError) {
  373. this.log("[auth] Invalid/Expired client token: clearing credentials")
  374. this.clearCredentials()
  375. } else if (this.isFirstRefreshAttempt && this.state === "attempting-session") {
  376. this.isFirstRefreshAttempt = false
  377. this.transitionToInactiveSession()
  378. }
  379. this.log("[auth] Failed to refresh session", error)
  380. throw error
  381. }
  382. }
  383. private async fetchUserInfo(): Promise<void> {
  384. if (!this.credentials) {
  385. return
  386. }
  387. this.userInfo = await this.clerkMe()
  388. this.emit("user-info", { userInfo: this.userInfo })
  389. }
  390. /**
  391. * Extract user information from the ID token
  392. *
  393. * @returns User information from ID token claims or null if no ID token available
  394. */
  395. public getUserInfo(): CloudUserInfo | null {
  396. return this.userInfo
  397. }
  398. /**
  399. * Get the stored organization ID from credentials
  400. *
  401. * @returns The stored organization ID, null for personal accounts or if no credentials exist
  402. */
  403. public getStoredOrganizationId(): string | null {
  404. return this.credentials?.organizationId || null
  405. }
  406. /**
  407. * Switch to a different organization context
  408. * @param organizationId The organization ID to switch to, or null for personal account
  409. */
  410. public async switchOrganization(organizationId: string | null): Promise<void> {
  411. if (!this.credentials) {
  412. throw new Error("Cannot switch organization: not authenticated")
  413. }
  414. // Update the stored credentials with the new organization ID
  415. const updatedCredentials: AuthCredentials = {
  416. ...this.credentials,
  417. organizationId: organizationId,
  418. }
  419. // Store the updated credentials, handleCredentialsChange will handle the update
  420. await this.storeCredentials(updatedCredentials)
  421. }
  422. /**
  423. * Get all organization memberships for the current user
  424. * @returns Array of organization memberships
  425. */
  426. public async getOrganizationMemberships(): Promise<CloudOrganizationMembership[]> {
  427. if (!this.credentials) {
  428. return []
  429. }
  430. try {
  431. return await this.clerkGetOrganizationMemberships()
  432. } catch (error) {
  433. this.log(`[auth] Failed to get organization memberships: ${error}`)
  434. return []
  435. }
  436. }
  437. private async clerkSignIn(ticket: string): Promise<AuthCredentials> {
  438. const formData = new URLSearchParams()
  439. formData.append("strategy", "ticket")
  440. formData.append("ticket", ticket)
  441. const response = await fetch(`${getClerkBaseUrl()}/v1/client/sign_ins`, {
  442. method: "POST",
  443. headers: {
  444. "Content-Type": "application/x-www-form-urlencoded",
  445. "User-Agent": this.userAgent(),
  446. },
  447. body: formData.toString(),
  448. signal: AbortSignal.timeout(10000),
  449. })
  450. if (!response.ok) {
  451. throw new Error(`HTTP ${response.status}: ${response.statusText}`)
  452. }
  453. const {
  454. response: { created_session_id: sessionId },
  455. } = clerkSignInResponseSchema.parse(await response.json())
  456. // 3. Extract the client token from the Authorization header.
  457. const clientToken = response.headers.get("authorization")
  458. if (!clientToken) {
  459. throw new Error("No authorization header found in the response")
  460. }
  461. return authCredentialsSchema.parse({ clientToken, sessionId })
  462. }
  463. private async clerkCreateSessionToken(): Promise<string> {
  464. const formData = new URLSearchParams()
  465. formData.append("_is_native", "1")
  466. // Handle 3 cases for organization_id:
  467. // 1. Have an org id: organization_id=THE_ORG_ID
  468. // 2. Have a personal account: organization_id= (empty string)
  469. // 3. Don't know if you have an org id (old style credentials): don't send organization_id param at all
  470. const organizationId = this.getStoredOrganizationId()
  471. if (this.credentials?.organizationId !== undefined) {
  472. // We have organization context info (either org id or personal account)
  473. formData.append("organization_id", organizationId || "")
  474. }
  475. // If organizationId is undefined, don't send the param at all (old credentials)
  476. const response = await fetch(`${getClerkBaseUrl()}/v1/client/sessions/${this.credentials!.sessionId}/tokens`, {
  477. method: "POST",
  478. headers: {
  479. "Content-Type": "application/x-www-form-urlencoded",
  480. Authorization: `Bearer ${this.credentials!.clientToken}`,
  481. "User-Agent": this.userAgent(),
  482. },
  483. body: formData.toString(),
  484. signal: AbortSignal.timeout(10000),
  485. })
  486. if (response.status === 401 || response.status === 404) {
  487. throw new InvalidClientTokenError()
  488. } else if (!response.ok) {
  489. throw new Error(`HTTP ${response.status}: ${response.statusText}`)
  490. }
  491. const data = clerkCreateSessionTokenResponseSchema.parse(await response.json())
  492. return data.jwt
  493. }
  494. private async clerkMe(): Promise<CloudUserInfo> {
  495. const response = await fetch(`${getClerkBaseUrl()}/v1/me`, {
  496. headers: {
  497. Authorization: `Bearer ${this.credentials!.clientToken}`,
  498. "User-Agent": this.userAgent(),
  499. },
  500. signal: AbortSignal.timeout(10000),
  501. })
  502. if (!response.ok) {
  503. throw new Error(`HTTP ${response.status}: ${response.statusText}`)
  504. }
  505. const payload = await response.json()
  506. const { response: userData } = clerkMeResponseSchema.parse(payload)
  507. const userInfo: CloudUserInfo = {
  508. id: userData.id,
  509. picture: userData.image_url,
  510. }
  511. const names = [userData.first_name, userData.last_name].filter((name) => !!name)
  512. userInfo.name = names.length > 0 ? names.join(" ") : undefined
  513. const primaryEmailAddressId = userData.primary_email_address_id
  514. const emailAddresses = userData.email_addresses
  515. if (primaryEmailAddressId && emailAddresses) {
  516. userInfo.email = emailAddresses.find(
  517. (email: { id: string }) => primaryEmailAddressId === email.id,
  518. )?.email_address
  519. }
  520. let extensionBridgeEnabled = true
  521. // Fetch organization info if user is in organization context
  522. try {
  523. const storedOrgId = this.getStoredOrganizationId()
  524. if (this.credentials?.organizationId !== undefined) {
  525. // We have organization context info
  526. if (storedOrgId !== null) {
  527. // User is in organization context - fetch user's memberships and filter
  528. const orgMemberships = await this.clerkGetOrganizationMemberships()
  529. const userMembership = this.findOrganizationMembership(orgMemberships, storedOrgId)
  530. if (userMembership) {
  531. this.setUserOrganizationInfo(userInfo, userMembership)
  532. extensionBridgeEnabled = await this.isExtensionBridgeEnabledForOrganization(storedOrgId)
  533. this.log("[auth] User in organization context:", {
  534. id: userMembership.organization.id,
  535. name: userMembership.organization.name,
  536. role: userMembership.role,
  537. })
  538. } else {
  539. this.log("[auth] Warning: User not found in stored organization:", storedOrgId)
  540. }
  541. } else {
  542. this.log("[auth] User in personal account context - not setting organization info")
  543. }
  544. } else {
  545. // Old credentials without organization context - fetch organization info to determine context
  546. const orgMemberships = await this.clerkGetOrganizationMemberships()
  547. const primaryOrgMembership = this.findPrimaryOrganizationMembership(orgMemberships)
  548. if (primaryOrgMembership) {
  549. this.setUserOrganizationInfo(userInfo, primaryOrgMembership)
  550. extensionBridgeEnabled = await this.isExtensionBridgeEnabledForOrganization(
  551. primaryOrgMembership.organization.id,
  552. )
  553. this.log("[auth] Legacy credentials: Found organization membership:", {
  554. id: primaryOrgMembership.organization.id,
  555. name: primaryOrgMembership.organization.name,
  556. role: primaryOrgMembership.role,
  557. })
  558. } else {
  559. this.log("[auth] Legacy credentials: No organization memberships found")
  560. }
  561. }
  562. } catch (error) {
  563. this.log("[auth] Failed to fetch organization info:", error)
  564. // Don't throw - organization info is optional
  565. }
  566. // Set the extension bridge enabled flag
  567. userInfo.extensionBridgeEnabled = extensionBridgeEnabled
  568. return userInfo
  569. }
  570. private findOrganizationMembership(
  571. memberships: CloudOrganizationMembership[],
  572. organizationId: string,
  573. ): CloudOrganizationMembership | undefined {
  574. return memberships?.find((membership) => membership.organization.id === organizationId)
  575. }
  576. private findPrimaryOrganizationMembership(
  577. memberships: CloudOrganizationMembership[],
  578. ): CloudOrganizationMembership | undefined {
  579. return memberships && memberships.length > 0 ? memberships[0] : undefined
  580. }
  581. private setUserOrganizationInfo(userInfo: CloudUserInfo, membership: CloudOrganizationMembership): void {
  582. userInfo.organizationId = membership.organization.id
  583. userInfo.organizationName = membership.organization.name
  584. userInfo.organizationRole = membership.role
  585. userInfo.organizationImageUrl = membership.organization.image_url
  586. }
  587. private async clerkGetOrganizationMemberships(): Promise<CloudOrganizationMembership[]> {
  588. if (!this.credentials) {
  589. this.log("[auth] Cannot get organization memberships: missing credentials")
  590. return []
  591. }
  592. const response = await fetch(`${getClerkBaseUrl()}/v1/me/organization_memberships`, {
  593. headers: {
  594. Authorization: `Bearer ${this.credentials.clientToken}`,
  595. "User-Agent": this.userAgent(),
  596. },
  597. signal: AbortSignal.timeout(10000),
  598. })
  599. if (response.ok) {
  600. return clerkOrganizationMembershipsSchema.parse(await response.json()).response
  601. }
  602. const errorMessage = `Failed to get organization memberships: ${response.status} ${response.statusText}`
  603. this.log(`[auth] ${errorMessage}`)
  604. throw new Error(errorMessage)
  605. }
  606. private async getOrganizationMetadata(
  607. organizationId: string,
  608. ): Promise<{ public_metadata?: Record<string, unknown> } | null> {
  609. try {
  610. const response = await fetch(`${getClerkBaseUrl()}/v1/organizations/${organizationId}`, {
  611. headers: {
  612. Authorization: `Bearer ${this.credentials!.clientToken}`,
  613. "User-Agent": this.userAgent(),
  614. },
  615. signal: AbortSignal.timeout(10000),
  616. })
  617. if (!response.ok) {
  618. this.log(`[auth] Failed to fetch organization metadata: ${response.status} ${response.statusText}`)
  619. return null
  620. }
  621. const data = await response.json()
  622. return data.response || data
  623. } catch (error) {
  624. this.log("[auth] Error fetching organization metadata:", error)
  625. return null
  626. }
  627. }
  628. private async isExtensionBridgeEnabledForOrganization(organizationId: string): Promise<boolean> {
  629. const orgMetadata = await this.getOrganizationMetadata(organizationId)
  630. return orgMetadata?.public_metadata?.extension_bridge_enabled === true
  631. }
  632. private async clerkLogout(credentials: AuthCredentials): Promise<void> {
  633. const formData = new URLSearchParams()
  634. formData.append("_is_native", "1")
  635. const response = await fetch(`${getClerkBaseUrl()}/v1/client/sessions/${credentials.sessionId}/remove`, {
  636. method: "POST",
  637. headers: {
  638. "Content-Type": "application/x-www-form-urlencoded",
  639. Authorization: `Bearer ${credentials.clientToken}`,
  640. "User-Agent": this.userAgent(),
  641. },
  642. body: formData.toString(),
  643. signal: AbortSignal.timeout(10000),
  644. })
  645. if (!response.ok) {
  646. throw new Error(`HTTP ${response.status}: ${response.statusText}`)
  647. }
  648. }
  649. private userAgent(): string {
  650. return getUserAgent(this.context)
  651. }
  652. }