WebAuthService.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785
  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, or flag that no model was selected
  279. if (providerModel) {
  280. await this.context.globalState.update("roo-provider-model", providerModel)
  281. await this.context.globalState.update("roo-auth-skip-model", undefined)
  282. this.log(`[auth] Stored provider model: ${providerModel}`)
  283. } else {
  284. // No model was selected during signup - flag this for the webview
  285. await this.context.globalState.update("roo-auth-skip-model", true)
  286. this.log(`[auth] No provider model selected during signup`)
  287. }
  288. const vscode = await importVscode()
  289. if (vscode) {
  290. vscode.window.showInformationMessage("Successfully authenticated with Roo Code Cloud")
  291. }
  292. this.log("[auth] Successfully authenticated with Roo Code Cloud")
  293. } catch (error) {
  294. this.log(`[auth] Error handling Roo Code Cloud callback: ${error}`)
  295. this.changeState("logged-out")
  296. throw new Error(`Failed to handle Roo Code Cloud callback: ${error}`)
  297. }
  298. }
  299. /**
  300. * Log out
  301. *
  302. * This method removes all stored tokens and stops the refresh timer.
  303. */
  304. public async logout(): Promise<void> {
  305. const oldCredentials = this.credentials
  306. try {
  307. // Clear credentials from storage - onDidChange will handle state transitions
  308. await this.clearCredentials()
  309. await this.context.globalState.update(AUTH_STATE_KEY, undefined)
  310. if (oldCredentials) {
  311. try {
  312. await this.clerkLogout(oldCredentials)
  313. } catch (error) {
  314. this.log("[auth] Error calling clerkLogout:", error)
  315. }
  316. }
  317. const vscode = await importVscode()
  318. if (vscode) {
  319. vscode.window.showInformationMessage("Logged out from Roo Code Cloud")
  320. }
  321. this.log("[auth] Logged out from Roo Code Cloud")
  322. } catch (error) {
  323. this.log(`[auth] Error logging out from Roo Code Cloud: ${error}`)
  324. throw new Error(`Failed to log out from Roo Code Cloud: ${error}`)
  325. }
  326. }
  327. public getState(): AuthState {
  328. return this.state
  329. }
  330. public getSessionToken(): string | undefined {
  331. if (this.state === "active-session" && this.sessionToken) {
  332. return this.sessionToken
  333. }
  334. return
  335. }
  336. /**
  337. * Check if the user is authenticated
  338. *
  339. * @returns True if the user is authenticated (has an active, attempting, or inactive session)
  340. */
  341. public isAuthenticated(): boolean {
  342. return (
  343. this.state === "active-session" || this.state === "attempting-session" || this.state === "inactive-session"
  344. )
  345. }
  346. public hasActiveSession(): boolean {
  347. return this.state === "active-session"
  348. }
  349. /**
  350. * Check if the user has an active session or is currently attempting to acquire one
  351. *
  352. * @returns True if the user has an active session or is attempting to get one
  353. */
  354. public hasOrIsAcquiringActiveSession(): boolean {
  355. return this.state === "active-session" || this.state === "attempting-session"
  356. }
  357. /**
  358. * Refresh the session
  359. *
  360. * This method refreshes the session token using the client token.
  361. */
  362. private async refreshSession(): Promise<void> {
  363. if (!this.credentials) {
  364. this.log("[auth] Cannot refresh session: missing credentials")
  365. return
  366. }
  367. try {
  368. const previousState = this.state
  369. this.sessionToken = await this.clerkCreateSessionToken()
  370. if (previousState !== "active-session") {
  371. this.changeState("active-session")
  372. this.fetchUserInfo()
  373. } else {
  374. this.state = "active-session"
  375. }
  376. } catch (error) {
  377. if (error instanceof InvalidClientTokenError) {
  378. this.log("[auth] Invalid/Expired client token: clearing credentials")
  379. this.clearCredentials()
  380. } else if (this.isFirstRefreshAttempt && this.state === "attempting-session") {
  381. this.isFirstRefreshAttempt = false
  382. this.transitionToInactiveSession()
  383. }
  384. this.log("[auth] Failed to refresh session", error)
  385. throw error
  386. }
  387. }
  388. private async fetchUserInfo(): Promise<void> {
  389. if (!this.credentials) {
  390. return
  391. }
  392. this.userInfo = await this.clerkMe()
  393. this.emit("user-info", { userInfo: this.userInfo })
  394. }
  395. /**
  396. * Extract user information from the ID token
  397. *
  398. * @returns User information from ID token claims or null if no ID token available
  399. */
  400. public getUserInfo(): CloudUserInfo | null {
  401. return this.userInfo
  402. }
  403. /**
  404. * Get the stored organization ID from credentials
  405. *
  406. * @returns The stored organization ID, null for personal accounts or if no credentials exist
  407. */
  408. public getStoredOrganizationId(): string | null {
  409. return this.credentials?.organizationId || null
  410. }
  411. /**
  412. * Switch to a different organization context
  413. * @param organizationId The organization ID to switch to, or null for personal account
  414. */
  415. public async switchOrganization(organizationId: string | null): Promise<void> {
  416. if (!this.credentials) {
  417. throw new Error("Cannot switch organization: not authenticated")
  418. }
  419. // Update the stored credentials with the new organization ID
  420. const updatedCredentials: AuthCredentials = {
  421. ...this.credentials,
  422. organizationId: organizationId,
  423. }
  424. // Store the updated credentials, handleCredentialsChange will handle the update
  425. await this.storeCredentials(updatedCredentials)
  426. }
  427. /**
  428. * Get all organization memberships for the current user
  429. * @returns Array of organization memberships
  430. */
  431. public async getOrganizationMemberships(): Promise<CloudOrganizationMembership[]> {
  432. if (!this.credentials) {
  433. return []
  434. }
  435. try {
  436. return await this.clerkGetOrganizationMemberships()
  437. } catch (error) {
  438. this.log(`[auth] Failed to get organization memberships: ${error}`)
  439. return []
  440. }
  441. }
  442. private async clerkSignIn(ticket: string): Promise<AuthCredentials> {
  443. const formData = new URLSearchParams()
  444. formData.append("strategy", "ticket")
  445. formData.append("ticket", ticket)
  446. const response = await fetch(`${getClerkBaseUrl()}/v1/client/sign_ins`, {
  447. method: "POST",
  448. headers: {
  449. "Content-Type": "application/x-www-form-urlencoded",
  450. "User-Agent": this.userAgent(),
  451. },
  452. body: formData.toString(),
  453. signal: AbortSignal.timeout(10000),
  454. })
  455. if (!response.ok) {
  456. throw new Error(`HTTP ${response.status}: ${response.statusText}`)
  457. }
  458. const {
  459. response: { created_session_id: sessionId },
  460. } = clerkSignInResponseSchema.parse(await response.json())
  461. // 3. Extract the client token from the Authorization header.
  462. const clientToken = response.headers.get("authorization")
  463. if (!clientToken) {
  464. throw new Error("No authorization header found in the response")
  465. }
  466. return authCredentialsSchema.parse({ clientToken, sessionId })
  467. }
  468. private async clerkCreateSessionToken(): Promise<string> {
  469. const formData = new URLSearchParams()
  470. formData.append("_is_native", "1")
  471. // Handle 3 cases for organization_id:
  472. // 1. Have an org id: organization_id=THE_ORG_ID
  473. // 2. Have a personal account: organization_id= (empty string)
  474. // 3. Don't know if you have an org id (old style credentials): don't send organization_id param at all
  475. const organizationId = this.getStoredOrganizationId()
  476. if (this.credentials?.organizationId !== undefined) {
  477. // We have organization context info (either org id or personal account)
  478. formData.append("organization_id", organizationId || "")
  479. }
  480. // If organizationId is undefined, don't send the param at all (old credentials)
  481. const response = await fetch(`${getClerkBaseUrl()}/v1/client/sessions/${this.credentials!.sessionId}/tokens`, {
  482. method: "POST",
  483. headers: {
  484. "Content-Type": "application/x-www-form-urlencoded",
  485. Authorization: `Bearer ${this.credentials!.clientToken}`,
  486. "User-Agent": this.userAgent(),
  487. },
  488. body: formData.toString(),
  489. signal: AbortSignal.timeout(10000),
  490. })
  491. if (response.status === 401 || response.status === 404) {
  492. throw new InvalidClientTokenError()
  493. } else if (!response.ok) {
  494. throw new Error(`HTTP ${response.status}: ${response.statusText}`)
  495. }
  496. const data = clerkCreateSessionTokenResponseSchema.parse(await response.json())
  497. return data.jwt
  498. }
  499. private async clerkMe(): Promise<CloudUserInfo> {
  500. const response = await fetch(`${getClerkBaseUrl()}/v1/me`, {
  501. headers: {
  502. Authorization: `Bearer ${this.credentials!.clientToken}`,
  503. "User-Agent": this.userAgent(),
  504. },
  505. signal: AbortSignal.timeout(10000),
  506. })
  507. if (!response.ok) {
  508. throw new Error(`HTTP ${response.status}: ${response.statusText}`)
  509. }
  510. const payload = await response.json()
  511. const { response: userData } = clerkMeResponseSchema.parse(payload)
  512. const userInfo: CloudUserInfo = {
  513. id: userData.id,
  514. picture: userData.image_url,
  515. }
  516. const names = [userData.first_name, userData.last_name].filter((name) => !!name)
  517. userInfo.name = names.length > 0 ? names.join(" ") : undefined
  518. const primaryEmailAddressId = userData.primary_email_address_id
  519. const emailAddresses = userData.email_addresses
  520. if (primaryEmailAddressId && emailAddresses) {
  521. userInfo.email = emailAddresses.find(
  522. (email: { id: string }) => primaryEmailAddressId === email.id,
  523. )?.email_address
  524. }
  525. let extensionBridgeEnabled = true
  526. // Fetch organization info if user is in organization context
  527. try {
  528. const storedOrgId = this.getStoredOrganizationId()
  529. if (this.credentials?.organizationId !== undefined) {
  530. // We have organization context info
  531. if (storedOrgId !== null) {
  532. // User is in organization context - fetch user's memberships and filter
  533. const orgMemberships = await this.clerkGetOrganizationMemberships()
  534. const userMembership = this.findOrganizationMembership(orgMemberships, storedOrgId)
  535. if (userMembership) {
  536. this.setUserOrganizationInfo(userInfo, userMembership)
  537. extensionBridgeEnabled = await this.isExtensionBridgeEnabledForOrganization(storedOrgId)
  538. this.log("[auth] User in organization context:", {
  539. id: userMembership.organization.id,
  540. name: userMembership.organization.name,
  541. role: userMembership.role,
  542. })
  543. } else {
  544. this.log("[auth] Warning: User not found in stored organization:", storedOrgId)
  545. }
  546. } else {
  547. this.log("[auth] User in personal account context - not setting organization info")
  548. }
  549. } else {
  550. // Old credentials without organization context - fetch organization info to determine context
  551. const orgMemberships = await this.clerkGetOrganizationMemberships()
  552. const primaryOrgMembership = this.findPrimaryOrganizationMembership(orgMemberships)
  553. if (primaryOrgMembership) {
  554. this.setUserOrganizationInfo(userInfo, primaryOrgMembership)
  555. extensionBridgeEnabled = await this.isExtensionBridgeEnabledForOrganization(
  556. primaryOrgMembership.organization.id,
  557. )
  558. this.log("[auth] Legacy credentials: Found organization membership:", {
  559. id: primaryOrgMembership.organization.id,
  560. name: primaryOrgMembership.organization.name,
  561. role: primaryOrgMembership.role,
  562. })
  563. } else {
  564. this.log("[auth] Legacy credentials: No organization memberships found")
  565. }
  566. }
  567. } catch (error) {
  568. this.log("[auth] Failed to fetch organization info:", error)
  569. // Don't throw - organization info is optional
  570. }
  571. // Set the extension bridge enabled flag
  572. userInfo.extensionBridgeEnabled = extensionBridgeEnabled
  573. return userInfo
  574. }
  575. private findOrganizationMembership(
  576. memberships: CloudOrganizationMembership[],
  577. organizationId: string,
  578. ): CloudOrganizationMembership | undefined {
  579. return memberships?.find((membership) => membership.organization.id === organizationId)
  580. }
  581. private findPrimaryOrganizationMembership(
  582. memberships: CloudOrganizationMembership[],
  583. ): CloudOrganizationMembership | undefined {
  584. return memberships && memberships.length > 0 ? memberships[0] : undefined
  585. }
  586. private setUserOrganizationInfo(userInfo: CloudUserInfo, membership: CloudOrganizationMembership): void {
  587. userInfo.organizationId = membership.organization.id
  588. userInfo.organizationName = membership.organization.name
  589. userInfo.organizationRole = membership.role
  590. userInfo.organizationImageUrl = membership.organization.image_url
  591. }
  592. private async clerkGetOrganizationMemberships(): Promise<CloudOrganizationMembership[]> {
  593. if (!this.credentials) {
  594. this.log("[auth] Cannot get organization memberships: missing credentials")
  595. return []
  596. }
  597. const response = await fetch(`${getClerkBaseUrl()}/v1/me/organization_memberships`, {
  598. headers: {
  599. Authorization: `Bearer ${this.credentials.clientToken}`,
  600. "User-Agent": this.userAgent(),
  601. },
  602. signal: AbortSignal.timeout(10000),
  603. })
  604. if (response.ok) {
  605. return clerkOrganizationMembershipsSchema.parse(await response.json()).response
  606. }
  607. const errorMessage = `Failed to get organization memberships: ${response.status} ${response.statusText}`
  608. this.log(`[auth] ${errorMessage}`)
  609. throw new Error(errorMessage)
  610. }
  611. private async getOrganizationMetadata(
  612. organizationId: string,
  613. ): Promise<{ public_metadata?: Record<string, unknown> } | null> {
  614. try {
  615. const response = await fetch(`${getClerkBaseUrl()}/v1/organizations/${organizationId}`, {
  616. headers: {
  617. Authorization: `Bearer ${this.credentials!.clientToken}`,
  618. "User-Agent": this.userAgent(),
  619. },
  620. signal: AbortSignal.timeout(10000),
  621. })
  622. if (!response.ok) {
  623. this.log(`[auth] Failed to fetch organization metadata: ${response.status} ${response.statusText}`)
  624. return null
  625. }
  626. const data = await response.json()
  627. return data.response || data
  628. } catch (error) {
  629. this.log("[auth] Error fetching organization metadata:", error)
  630. return null
  631. }
  632. }
  633. private async isExtensionBridgeEnabledForOrganization(organizationId: string): Promise<boolean> {
  634. const orgMetadata = await this.getOrganizationMetadata(organizationId)
  635. return orgMetadata?.public_metadata?.extension_bridge_enabled === true
  636. }
  637. private async clerkLogout(credentials: AuthCredentials): Promise<void> {
  638. const formData = new URLSearchParams()
  639. formData.append("_is_native", "1")
  640. const response = await fetch(`${getClerkBaseUrl()}/v1/client/sessions/${credentials.sessionId}/remove`, {
  641. method: "POST",
  642. headers: {
  643. "Content-Type": "application/x-www-form-urlencoded",
  644. Authorization: `Bearer ${credentials.clientToken}`,
  645. "User-Agent": this.userAgent(),
  646. },
  647. body: formData.toString(),
  648. signal: AbortSignal.timeout(10000),
  649. })
  650. if (!response.ok) {
  651. throw new Error(`HTTP ${response.status}: ${response.statusText}`)
  652. }
  653. }
  654. private userAgent(): string {
  655. return getUserAgent(this.context)
  656. }
  657. }