WebAuthService.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720
  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. ) {
  123. this.transitionToAttemptingSession(credentials)
  124. }
  125. } else {
  126. if (this.state !== "logged-out") {
  127. this.transitionToLoggedOut()
  128. }
  129. }
  130. } catch (error) {
  131. this.log("[auth] Error handling credentials change:", error)
  132. }
  133. }
  134. private transitionToLoggedOut(): void {
  135. this.timer.stop()
  136. this.credentials = null
  137. this.sessionToken = null
  138. this.userInfo = null
  139. this.changeState("logged-out")
  140. }
  141. private transitionToAttemptingSession(credentials: AuthCredentials): void {
  142. this.credentials = credentials
  143. this.sessionToken = null
  144. this.userInfo = null
  145. this.isFirstRefreshAttempt = true
  146. this.changeState("attempting-session")
  147. this.timer.start()
  148. }
  149. private transitionToInactiveSession(): void {
  150. this.sessionToken = null
  151. this.userInfo = null
  152. this.changeState("inactive-session")
  153. }
  154. /**
  155. * Initialize the auth state
  156. *
  157. * This method loads tokens from storage and determines the current auth state.
  158. * It also starts the refresh timer if we have an active session.
  159. */
  160. public async initialize(): Promise<void> {
  161. if (this.state !== "initializing") {
  162. this.log("[auth] initialize() called after already initialized")
  163. return
  164. }
  165. await this.handleCredentialsChange()
  166. this.context.subscriptions.push(
  167. this.context.secrets.onDidChange((e) => {
  168. if (e.key === this.authCredentialsKey) {
  169. this.handleCredentialsChange()
  170. }
  171. }),
  172. )
  173. }
  174. public broadcast(): void {}
  175. private async storeCredentials(credentials: AuthCredentials): Promise<void> {
  176. await this.context.secrets.store(this.authCredentialsKey, JSON.stringify(credentials))
  177. }
  178. private async loadCredentials(): Promise<AuthCredentials | null> {
  179. const credentialsJson = await this.context.secrets.get(this.authCredentialsKey)
  180. if (!credentialsJson) return null
  181. try {
  182. const parsedJson = JSON.parse(credentialsJson)
  183. const credentials = authCredentialsSchema.parse(parsedJson)
  184. // Migration: If no organizationId but we have userInfo, add it
  185. if (credentials.organizationId === undefined && this.userInfo?.organizationId) {
  186. credentials.organizationId = this.userInfo.organizationId
  187. await this.storeCredentials(credentials)
  188. this.log("[auth] Migrated credentials with organizationId")
  189. }
  190. return credentials
  191. } catch (error) {
  192. if (error instanceof z.ZodError) {
  193. this.log("[auth] Invalid credentials format:", error.errors)
  194. } else {
  195. this.log("[auth] Failed to parse stored credentials:", error)
  196. }
  197. return null
  198. }
  199. }
  200. private async clearCredentials(): Promise<void> {
  201. await this.context.secrets.delete(this.authCredentialsKey)
  202. }
  203. /**
  204. * Start the login process
  205. *
  206. * This method initiates the authentication flow by generating a state parameter
  207. * and opening the browser to the authorization URL.
  208. *
  209. * @param landingPageSlug Optional slug of a specific landing page (e.g., "supernova", "special-offer", etc.)
  210. */
  211. public async login(landingPageSlug?: string): Promise<void> {
  212. try {
  213. const vscode = await importVscode()
  214. if (!vscode) {
  215. throw new Error("VS Code API not available")
  216. }
  217. // Generate a cryptographically random state parameter.
  218. const state = crypto.randomBytes(16).toString("hex")
  219. await this.context.globalState.update(AUTH_STATE_KEY, state)
  220. const packageJSON = this.context.extension?.packageJSON
  221. const publisher = packageJSON?.publisher ?? "RooVeterinaryInc"
  222. const name = packageJSON?.name ?? "roo-cline"
  223. const params = new URLSearchParams({
  224. state,
  225. auth_redirect: `${vscode.env.uriScheme}://${publisher}.${name}`,
  226. })
  227. // Use landing page URL if slug is provided, otherwise use default sign-in URL
  228. const url = landingPageSlug
  229. ? `${getRooCodeApiUrl()}/l/${landingPageSlug}?${params.toString()}`
  230. : `${getRooCodeApiUrl()}/extension/sign-in?${params.toString()}`
  231. await vscode.env.openExternal(vscode.Uri.parse(url))
  232. } catch (error) {
  233. const context = landingPageSlug ? ` (landing page: ${landingPageSlug})` : ""
  234. this.log(`[auth] Error initiating Roo Code Cloud auth${context}: ${error}`)
  235. throw new Error(`Failed to initiate Roo Code Cloud authentication${context}: ${error}`)
  236. }
  237. }
  238. /**
  239. * Handle the callback from Roo Code Cloud
  240. *
  241. * This method is called when the user is redirected back to the extension
  242. * after authenticating with Roo Code Cloud.
  243. *
  244. * @param code The authorization code from the callback
  245. * @param state The state parameter from the callback
  246. * @param organizationId The organization ID from the callback (null for personal accounts)
  247. */
  248. public async handleCallback(
  249. code: string | null,
  250. state: string | null,
  251. organizationId?: string | null,
  252. ): Promise<void> {
  253. if (!code || !state) {
  254. const vscode = await importVscode()
  255. if (vscode) {
  256. vscode.window.showInformationMessage("Invalid Roo Code Cloud sign in url")
  257. }
  258. return
  259. }
  260. try {
  261. // Validate state parameter to prevent CSRF attacks.
  262. const storedState = this.context.globalState.get(AUTH_STATE_KEY)
  263. if (state !== storedState) {
  264. this.log("[auth] State mismatch in callback")
  265. throw new Error("Invalid state parameter. Authentication request may have been tampered with.")
  266. }
  267. const credentials = await this.clerkSignIn(code)
  268. // Set organizationId (null for personal accounts)
  269. credentials.organizationId = organizationId || null
  270. await this.storeCredentials(credentials)
  271. const vscode = await importVscode()
  272. if (vscode) {
  273. vscode.window.showInformationMessage("Successfully authenticated with Roo Code Cloud")
  274. }
  275. this.log("[auth] Successfully authenticated with Roo Code Cloud")
  276. } catch (error) {
  277. this.log(`[auth] Error handling Roo Code Cloud callback: ${error}`)
  278. this.changeState("logged-out")
  279. throw new Error(`Failed to handle Roo Code Cloud callback: ${error}`)
  280. }
  281. }
  282. /**
  283. * Log out
  284. *
  285. * This method removes all stored tokens and stops the refresh timer.
  286. */
  287. public async logout(): Promise<void> {
  288. const oldCredentials = this.credentials
  289. try {
  290. // Clear credentials from storage - onDidChange will handle state transitions
  291. await this.clearCredentials()
  292. await this.context.globalState.update(AUTH_STATE_KEY, undefined)
  293. if (oldCredentials) {
  294. try {
  295. await this.clerkLogout(oldCredentials)
  296. } catch (error) {
  297. this.log("[auth] Error calling clerkLogout:", error)
  298. }
  299. }
  300. const vscode = await importVscode()
  301. if (vscode) {
  302. vscode.window.showInformationMessage("Logged out from Roo Code Cloud")
  303. }
  304. this.log("[auth] Logged out from Roo Code Cloud")
  305. } catch (error) {
  306. this.log(`[auth] Error logging out from Roo Code Cloud: ${error}`)
  307. throw new Error(`Failed to log out from Roo Code Cloud: ${error}`)
  308. }
  309. }
  310. public getState(): AuthState {
  311. return this.state
  312. }
  313. public getSessionToken(): string | undefined {
  314. if (this.state === "active-session" && this.sessionToken) {
  315. return this.sessionToken
  316. }
  317. return
  318. }
  319. /**
  320. * Check if the user is authenticated
  321. *
  322. * @returns True if the user is authenticated (has an active, attempting, or inactive session)
  323. */
  324. public isAuthenticated(): boolean {
  325. return (
  326. this.state === "active-session" || this.state === "attempting-session" || this.state === "inactive-session"
  327. )
  328. }
  329. public hasActiveSession(): boolean {
  330. return this.state === "active-session"
  331. }
  332. /**
  333. * Check if the user has an active session or is currently attempting to acquire one
  334. *
  335. * @returns True if the user has an active session or is attempting to get one
  336. */
  337. public hasOrIsAcquiringActiveSession(): boolean {
  338. return this.state === "active-session" || this.state === "attempting-session"
  339. }
  340. /**
  341. * Refresh the session
  342. *
  343. * This method refreshes the session token using the client token.
  344. */
  345. private async refreshSession(): Promise<void> {
  346. if (!this.credentials) {
  347. this.log("[auth] Cannot refresh session: missing credentials")
  348. return
  349. }
  350. try {
  351. const previousState = this.state
  352. this.sessionToken = await this.clerkCreateSessionToken()
  353. if (previousState !== "active-session") {
  354. this.changeState("active-session")
  355. this.fetchUserInfo()
  356. } else {
  357. this.state = "active-session"
  358. }
  359. } catch (error) {
  360. if (error instanceof InvalidClientTokenError) {
  361. this.log("[auth] Invalid/Expired client token: clearing credentials")
  362. this.clearCredentials()
  363. } else if (this.isFirstRefreshAttempt && this.state === "attempting-session") {
  364. this.isFirstRefreshAttempt = false
  365. this.transitionToInactiveSession()
  366. }
  367. this.log("[auth] Failed to refresh session", error)
  368. throw error
  369. }
  370. }
  371. private async fetchUserInfo(): Promise<void> {
  372. if (!this.credentials) {
  373. return
  374. }
  375. this.userInfo = await this.clerkMe()
  376. this.emit("user-info", { userInfo: this.userInfo })
  377. }
  378. /**
  379. * Extract user information from the ID token
  380. *
  381. * @returns User information from ID token claims or null if no ID token available
  382. */
  383. public getUserInfo(): CloudUserInfo | null {
  384. return this.userInfo
  385. }
  386. /**
  387. * Get the stored organization ID from credentials
  388. *
  389. * @returns The stored organization ID, null for personal accounts or if no credentials exist
  390. */
  391. public getStoredOrganizationId(): string | null {
  392. return this.credentials?.organizationId || null
  393. }
  394. private async clerkSignIn(ticket: string): Promise<AuthCredentials> {
  395. const formData = new URLSearchParams()
  396. formData.append("strategy", "ticket")
  397. formData.append("ticket", ticket)
  398. const response = await fetch(`${getClerkBaseUrl()}/v1/client/sign_ins`, {
  399. method: "POST",
  400. headers: {
  401. "Content-Type": "application/x-www-form-urlencoded",
  402. "User-Agent": this.userAgent(),
  403. },
  404. body: formData.toString(),
  405. signal: AbortSignal.timeout(10000),
  406. })
  407. if (!response.ok) {
  408. throw new Error(`HTTP ${response.status}: ${response.statusText}`)
  409. }
  410. const {
  411. response: { created_session_id: sessionId },
  412. } = clerkSignInResponseSchema.parse(await response.json())
  413. // 3. Extract the client token from the Authorization header.
  414. const clientToken = response.headers.get("authorization")
  415. if (!clientToken) {
  416. throw new Error("No authorization header found in the response")
  417. }
  418. return authCredentialsSchema.parse({ clientToken, sessionId })
  419. }
  420. private async clerkCreateSessionToken(): Promise<string> {
  421. const formData = new URLSearchParams()
  422. formData.append("_is_native", "1")
  423. // Handle 3 cases for organization_id:
  424. // 1. Have an org id: organization_id=THE_ORG_ID
  425. // 2. Have a personal account: organization_id= (empty string)
  426. // 3. Don't know if you have an org id (old style credentials): don't send organization_id param at all
  427. const organizationId = this.getStoredOrganizationId()
  428. if (this.credentials?.organizationId !== undefined) {
  429. // We have organization context info (either org id or personal account)
  430. formData.append("organization_id", organizationId || "")
  431. }
  432. // If organizationId is undefined, don't send the param at all (old credentials)
  433. const response = await fetch(`${getClerkBaseUrl()}/v1/client/sessions/${this.credentials!.sessionId}/tokens`, {
  434. method: "POST",
  435. headers: {
  436. "Content-Type": "application/x-www-form-urlencoded",
  437. Authorization: `Bearer ${this.credentials!.clientToken}`,
  438. "User-Agent": this.userAgent(),
  439. },
  440. body: formData.toString(),
  441. signal: AbortSignal.timeout(10000),
  442. })
  443. if (response.status === 401 || response.status === 404) {
  444. throw new InvalidClientTokenError()
  445. } else if (!response.ok) {
  446. throw new Error(`HTTP ${response.status}: ${response.statusText}`)
  447. }
  448. const data = clerkCreateSessionTokenResponseSchema.parse(await response.json())
  449. return data.jwt
  450. }
  451. private async clerkMe(): Promise<CloudUserInfo> {
  452. const response = await fetch(`${getClerkBaseUrl()}/v1/me`, {
  453. headers: {
  454. Authorization: `Bearer ${this.credentials!.clientToken}`,
  455. "User-Agent": this.userAgent(),
  456. },
  457. signal: AbortSignal.timeout(10000),
  458. })
  459. if (!response.ok) {
  460. throw new Error(`HTTP ${response.status}: ${response.statusText}`)
  461. }
  462. const payload = await response.json()
  463. const { response: userData } = clerkMeResponseSchema.parse(payload)
  464. const userInfo: CloudUserInfo = {
  465. id: userData.id,
  466. picture: userData.image_url,
  467. }
  468. const names = [userData.first_name, userData.last_name].filter((name) => !!name)
  469. userInfo.name = names.length > 0 ? names.join(" ") : undefined
  470. const primaryEmailAddressId = userData.primary_email_address_id
  471. const emailAddresses = userData.email_addresses
  472. if (primaryEmailAddressId && emailAddresses) {
  473. userInfo.email = emailAddresses.find(
  474. (email: { id: string }) => primaryEmailAddressId === email.id,
  475. )?.email_address
  476. }
  477. let extensionBridgeEnabled = true
  478. // Fetch organization info if user is in organization context
  479. try {
  480. const storedOrgId = this.getStoredOrganizationId()
  481. if (this.credentials?.organizationId !== undefined) {
  482. // We have organization context info
  483. if (storedOrgId !== null) {
  484. // User is in organization context - fetch user's memberships and filter
  485. const orgMemberships = await this.clerkGetOrganizationMemberships()
  486. const userMembership = this.findOrganizationMembership(orgMemberships, storedOrgId)
  487. if (userMembership) {
  488. this.setUserOrganizationInfo(userInfo, userMembership)
  489. extensionBridgeEnabled = await this.isExtensionBridgeEnabledForOrganization(storedOrgId)
  490. this.log("[auth] User in organization context:", {
  491. id: userMembership.organization.id,
  492. name: userMembership.organization.name,
  493. role: userMembership.role,
  494. })
  495. } else {
  496. this.log("[auth] Warning: User not found in stored organization:", storedOrgId)
  497. }
  498. } else {
  499. this.log("[auth] User in personal account context - not setting organization info")
  500. }
  501. } else {
  502. // Old credentials without organization context - fetch organization info to determine context
  503. const orgMemberships = await this.clerkGetOrganizationMemberships()
  504. const primaryOrgMembership = this.findPrimaryOrganizationMembership(orgMemberships)
  505. if (primaryOrgMembership) {
  506. this.setUserOrganizationInfo(userInfo, primaryOrgMembership)
  507. extensionBridgeEnabled = await this.isExtensionBridgeEnabledForOrganization(
  508. primaryOrgMembership.organization.id,
  509. )
  510. this.log("[auth] Legacy credentials: Found organization membership:", {
  511. id: primaryOrgMembership.organization.id,
  512. name: primaryOrgMembership.organization.name,
  513. role: primaryOrgMembership.role,
  514. })
  515. } else {
  516. this.log("[auth] Legacy credentials: No organization memberships found")
  517. }
  518. }
  519. } catch (error) {
  520. this.log("[auth] Failed to fetch organization info:", error)
  521. // Don't throw - organization info is optional
  522. }
  523. // Set the extension bridge enabled flag
  524. userInfo.extensionBridgeEnabled = extensionBridgeEnabled
  525. return userInfo
  526. }
  527. private findOrganizationMembership(
  528. memberships: CloudOrganizationMembership[],
  529. organizationId: string,
  530. ): CloudOrganizationMembership | undefined {
  531. return memberships?.find((membership) => membership.organization.id === organizationId)
  532. }
  533. private findPrimaryOrganizationMembership(
  534. memberships: CloudOrganizationMembership[],
  535. ): CloudOrganizationMembership | undefined {
  536. return memberships && memberships.length > 0 ? memberships[0] : undefined
  537. }
  538. private setUserOrganizationInfo(userInfo: CloudUserInfo, membership: CloudOrganizationMembership): void {
  539. userInfo.organizationId = membership.organization.id
  540. userInfo.organizationName = membership.organization.name
  541. userInfo.organizationRole = membership.role
  542. userInfo.organizationImageUrl = membership.organization.image_url
  543. }
  544. private async clerkGetOrganizationMemberships(): Promise<CloudOrganizationMembership[]> {
  545. const response = await fetch(`${getClerkBaseUrl()}/v1/me/organization_memberships`, {
  546. headers: {
  547. Authorization: `Bearer ${this.credentials!.clientToken}`,
  548. "User-Agent": this.userAgent(),
  549. },
  550. signal: AbortSignal.timeout(10000),
  551. })
  552. return clerkOrganizationMembershipsSchema.parse(await response.json()).response
  553. }
  554. private async getOrganizationMetadata(
  555. organizationId: string,
  556. ): Promise<{ public_metadata?: Record<string, unknown> } | null> {
  557. try {
  558. const response = await fetch(`${getClerkBaseUrl()}/v1/organizations/${organizationId}`, {
  559. headers: {
  560. Authorization: `Bearer ${this.credentials!.clientToken}`,
  561. "User-Agent": this.userAgent(),
  562. },
  563. signal: AbortSignal.timeout(10000),
  564. })
  565. if (!response.ok) {
  566. this.log(`[auth] Failed to fetch organization metadata: ${response.status} ${response.statusText}`)
  567. return null
  568. }
  569. const data = await response.json()
  570. return data.response || data
  571. } catch (error) {
  572. this.log("[auth] Error fetching organization metadata:", error)
  573. return null
  574. }
  575. }
  576. private async isExtensionBridgeEnabledForOrganization(organizationId: string): Promise<boolean> {
  577. const orgMetadata = await this.getOrganizationMetadata(organizationId)
  578. return orgMetadata?.public_metadata?.extension_bridge_enabled === true
  579. }
  580. private async clerkLogout(credentials: AuthCredentials): Promise<void> {
  581. const formData = new URLSearchParams()
  582. formData.append("_is_native", "1")
  583. const response = await fetch(`${getClerkBaseUrl()}/v1/client/sessions/${credentials.sessionId}/remove`, {
  584. method: "POST",
  585. headers: {
  586. "Content-Type": "application/x-www-form-urlencoded",
  587. Authorization: `Bearer ${credentials.clientToken}`,
  588. "User-Agent": this.userAgent(),
  589. },
  590. body: formData.toString(),
  591. signal: AbortSignal.timeout(10000),
  592. })
  593. if (!response.ok) {
  594. throw new Error(`HTTP ${response.status}: ${response.statusText}`)
  595. }
  596. }
  597. private userAgent(): string {
  598. return getUserAgent(this.context)
  599. }
  600. }