Cloud: support alternate auth token from environment (#5323)

This commit is contained in:
John Richmond 2025-07-01 19:23:23 -07:00 committed by GitHub
parent 70726bcd21
commit 05040414c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 323 additions and 46 deletions

View file

@ -10,7 +10,8 @@ import type {
import { TelemetryService } from "@roo-code/telemetry"
import { CloudServiceCallbacks } from "./types"
import { AuthService } from "./AuthService"
import type { AuthService } from "./auth"
import { WebAuthService, StaticTokenAuthService } from "./auth"
import { SettingsService } from "./SettingsService"
import { TelemetryClient } from "./TelemetryClient"
import { ShareService, TaskNotFoundError } from "./ShareService"
@ -43,7 +44,13 @@ export class CloudService {
}
try {
this.authService = new AuthService(this.context, this.log)
const cloudToken = process.env.ROO_CODE_CLOUD_TOKEN
if (cloudToken && cloudToken.length > 0) {
this.authService = new StaticTokenAuthService(this.context, cloudToken, this.log)
} else {
this.authService = new WebAuthService(this.context, this.log)
}
await this.authService.initialize()
this.authService.on("attempting-session", this.authListener)

View file

@ -8,7 +8,7 @@ import {
} from "@roo-code/types"
import { getRooCodeApiUrl } from "./Config"
import { AuthService } from "./AuthService"
import type { AuthService } from "./auth"
import { RefreshTimer } from "./RefreshTimer"
const ORGANIZATION_SETTINGS_CACHE_KEY = "organization-settings"

View file

@ -2,7 +2,7 @@ import * as vscode from "vscode"
import { shareResponseSchema } from "@roo-code/types"
import { getRooCodeApiUrl } from "./Config"
import type { AuthService } from "./AuthService"
import type { AuthService } from "./auth"
import type { SettingsService } from "./SettingsService"
import { getUserAgent } from "./utils"

View file

@ -7,7 +7,7 @@ import {
import { BaseTelemetryClient } from "@roo-code/telemetry"
import { getRooCodeApiUrl } from "./Config"
import { AuthService } from "./AuthService"
import type { AuthService } from "./auth"
import { SettingsService } from "./SettingsService"
export class TelemetryClient extends BaseTelemetryClient {

View file

@ -4,7 +4,7 @@ import * as vscode from "vscode"
import type { ClineMessage } from "@roo-code/types"
import { CloudService } from "../CloudService"
import { AuthService } from "../AuthService"
import { WebAuthService } from "../auth/WebAuthService"
import { SettingsService } from "../SettingsService"
import { ShareService, TaskNotFoundError } from "../ShareService"
import { TelemetryClient } from "../TelemetryClient"
@ -27,7 +27,7 @@ vi.mock("vscode", () => ({
vi.mock("@roo-code/telemetry")
vi.mock("../AuthService")
vi.mock("../auth/WebAuthService")
vi.mock("../SettingsService")
@ -149,7 +149,7 @@ describe("CloudService", () => {
},
}
vi.mocked(AuthService).mockImplementation(() => mockAuthService as unknown as AuthService)
vi.mocked(WebAuthService).mockImplementation(() => mockAuthService as unknown as WebAuthService)
vi.mocked(SettingsService).mockImplementation(() => mockSettingsService as unknown as SettingsService)
vi.mocked(ShareService).mockImplementation(() => mockShareService as unknown as ShareService)
vi.mocked(TelemetryClient).mockImplementation(() => mockTelemetryClient as unknown as TelemetryClient)
@ -175,7 +175,7 @@ describe("CloudService", () => {
const cloudService = await CloudService.createInstance(mockContext, callbacks)
expect(cloudService).toBeInstanceOf(CloudService)
expect(AuthService).toHaveBeenCalledWith(mockContext, expect.any(Function))
expect(WebAuthService).toHaveBeenCalledWith(mockContext, expect.any(Function))
expect(SettingsService).toHaveBeenCalledWith(
mockContext,
mockAuthService,

View file

@ -4,7 +4,7 @@ import type { MockedFunction } from "vitest"
import * as vscode from "vscode"
import { ShareService, TaskNotFoundError } from "../ShareService"
import type { AuthService } from "../AuthService"
import type { AuthService } from "../auth"
import type { SettingsService } from "../SettingsService"
// Mock fetch

View file

@ -0,0 +1,174 @@
import { describe, it, expect, beforeEach, vi } from "vitest"
import * as vscode from "vscode"
import { StaticTokenAuthService } from "../../auth/StaticTokenAuthService"
// Mock vscode
vi.mock("vscode", () => ({
window: {
showInformationMessage: vi.fn(),
},
env: {
openExternal: vi.fn(),
uriScheme: "vscode",
},
Uri: {
parse: vi.fn(),
},
}))
describe("StaticTokenAuthService", () => {
let authService: StaticTokenAuthService
let mockContext: vscode.ExtensionContext
let mockLog: (...args: unknown[]) => void
const testToken = "test-static-token"
beforeEach(() => {
mockLog = vi.fn()
// Create a minimal mock that satisfies the constructor requirements
const mockContextPartial = {
extension: {
packageJSON: {
publisher: "TestPublisher",
name: "test-extension",
},
},
globalState: {
get: vi.fn(),
update: vi.fn(),
},
secrets: {
get: vi.fn(),
store: vi.fn(),
delete: vi.fn(),
onDidChange: vi.fn(),
},
subscriptions: [],
}
// Use type assertion for test mocking
mockContext = mockContextPartial as unknown as vscode.ExtensionContext
authService = new StaticTokenAuthService(mockContext, testToken, mockLog)
})
afterEach(() => {
vi.clearAllMocks()
})
describe("constructor", () => {
it("should create instance and log static token mode", () => {
expect(authService).toBeInstanceOf(StaticTokenAuthService)
expect(mockLog).toHaveBeenCalledWith("[auth] Using static token authentication mode")
})
it("should use console.log as default logger", () => {
const serviceWithoutLog = new StaticTokenAuthService(
mockContext as unknown as vscode.ExtensionContext,
testToken,
)
// Can't directly test console.log usage, but constructor should not throw
expect(serviceWithoutLog).toBeInstanceOf(StaticTokenAuthService)
})
})
describe("initialize", () => {
it("should start in active-session state", async () => {
await authService.initialize()
expect(authService.getState()).toBe("active-session")
})
it("should emit active-session event on initialize", async () => {
const spy = vi.fn()
authService.on("active-session", spy)
await authService.initialize()
expect(spy).toHaveBeenCalledWith({ previousState: "initializing" })
})
it("should log successful initialization", async () => {
await authService.initialize()
expect(mockLog).toHaveBeenCalledWith("[auth] Static token auth service initialized in active-session state")
})
})
describe("getSessionToken", () => {
it("should return the provided token", () => {
expect(authService.getSessionToken()).toBe(testToken)
})
it("should return different token when constructed with different token", () => {
const differentToken = "different-token"
const differentService = new StaticTokenAuthService(mockContext, differentToken, mockLog)
expect(differentService.getSessionToken()).toBe(differentToken)
})
})
describe("getUserInfo", () => {
it("should return empty object", () => {
expect(authService.getUserInfo()).toEqual({})
})
})
describe("getStoredOrganizationId", () => {
it("should return null", () => {
expect(authService.getStoredOrganizationId()).toBeNull()
})
})
describe("authentication state methods", () => {
it("should always return true for isAuthenticated", () => {
expect(authService.isAuthenticated()).toBe(true)
})
it("should always return true for hasActiveSession", () => {
expect(authService.hasActiveSession()).toBe(true)
})
it("should always return true for hasOrIsAcquiringActiveSession", () => {
expect(authService.hasOrIsAcquiringActiveSession()).toBe(true)
})
it("should return active-session for getState", () => {
expect(authService.getState()).toBe("active-session")
})
})
describe("disabled authentication methods", () => {
const expectedErrorMessage = "Authentication methods are disabled in StaticTokenAuthService"
it("should throw error for login", async () => {
await expect(authService.login()).rejects.toThrow(expectedErrorMessage)
})
it("should throw error for logout", async () => {
await expect(authService.logout()).rejects.toThrow(expectedErrorMessage)
})
it("should throw error for handleCallback", async () => {
await expect(authService.handleCallback("code", "state")).rejects.toThrow(expectedErrorMessage)
})
it("should throw error for handleCallback with organization", async () => {
await expect(authService.handleCallback("code", "state", "org_123")).rejects.toThrow(expectedErrorMessage)
})
})
describe("event emission", () => {
it("should be able to register and emit events", async () => {
const activeSessionSpy = vi.fn()
const userInfoSpy = vi.fn()
authService.on("active-session", activeSessionSpy)
authService.on("user-info", userInfoSpy)
await authService.initialize()
expect(activeSessionSpy).toHaveBeenCalledWith({ previousState: "initializing" })
// user-info event is not emitted in static token mode
expect(userInfoSpy).not.toHaveBeenCalled()
})
})
})

View file

@ -4,15 +4,15 @@ import { vi, Mock, beforeEach, afterEach, describe, it, expect } from "vitest"
import crypto from "crypto"
import * as vscode from "vscode"
import { AuthService } from "../AuthService"
import { RefreshTimer } from "../RefreshTimer"
import * as Config from "../Config"
import * as utils from "../utils"
import { WebAuthService } from "../../auth/WebAuthService"
import { RefreshTimer } from "../../RefreshTimer"
import * as Config from "../../Config"
import * as utils from "../../utils"
// Mock external dependencies
vi.mock("../RefreshTimer")
vi.mock("../Config")
vi.mock("../utils")
vi.mock("../../RefreshTimer")
vi.mock("../../Config")
vi.mock("../../utils")
vi.mock("crypto")
// Mock fetch globally
@ -34,8 +34,8 @@ vi.mock("vscode", () => ({
},
}))
describe("AuthService", () => {
let authService: AuthService
describe("WebAuthService", () => {
let authService: WebAuthService
let mockTimer: {
start: Mock
stop: Mock
@ -97,7 +97,8 @@ describe("AuthService", () => {
stop: vi.fn(),
reset: vi.fn(),
}
vi.mocked(RefreshTimer).mockImplementation(() => mockTimer as unknown as RefreshTimer)
const MockedRefreshTimer = vi.mocked(RefreshTimer)
MockedRefreshTimer.mockImplementation(() => mockTimer as unknown as RefreshTimer)
// Setup config mocks - use production URL by default to maintain existing test behavior
vi.mocked(Config.getClerkBaseUrl).mockReturnValue("https://clerk.roocode.com")
@ -112,7 +113,7 @@ describe("AuthService", () => {
// Setup log mock
mockLog = vi.fn()
authService = new AuthService(mockContext as unknown as vscode.ExtensionContext, mockLog)
authService = new WebAuthService(mockContext as unknown as vscode.ExtensionContext, mockLog)
})
afterEach(() => {
@ -138,9 +139,9 @@ describe("AuthService", () => {
})
it("should use console.log as default logger", () => {
const serviceWithoutLog = new AuthService(mockContext as unknown as vscode.ExtensionContext)
const serviceWithoutLog = new WebAuthService(mockContext as unknown as vscode.ExtensionContext)
// Can't directly test console.log usage, but constructor should not throw
expect(serviceWithoutLog).toBeInstanceOf(AuthService)
expect(serviceWithoutLog).toBeInstanceOf(WebAuthService)
})
})
@ -434,7 +435,7 @@ describe("AuthService", () => {
const credentials = { clientToken: "test-token", sessionId: "test-session" }
mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
const authenticatedService = new AuthService(mockContext as unknown as vscode.ExtensionContext, mockLog)
const authenticatedService = new WebAuthService(mockContext as unknown as vscode.ExtensionContext, mockLog)
await authenticatedService.initialize()
expect(authenticatedService.isAuthenticated()).toBe(true)
@ -460,7 +461,7 @@ describe("AuthService", () => {
const credentials = { clientToken: "test-token", sessionId: "test-session" }
mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
const attemptingService = new AuthService(mockContext as unknown as vscode.ExtensionContext, mockLog)
const attemptingService = new WebAuthService(mockContext as unknown as vscode.ExtensionContext, mockLog)
await attemptingService.initialize()
expect(attemptingService.hasOrIsAcquiringActiveSession()).toBe(true)
@ -960,7 +961,7 @@ describe("AuthService", () => {
// Mock getClerkBaseUrl to return production URL
vi.mocked(Config.getClerkBaseUrl).mockReturnValue("https://clerk.roocode.com")
const service = new AuthService(mockContext as unknown as vscode.ExtensionContext, mockLog)
const service = new WebAuthService(mockContext as unknown as vscode.ExtensionContext, mockLog)
const credentials = { clientToken: "test-token", sessionId: "test-session" }
await service.initialize()
@ -977,7 +978,7 @@ describe("AuthService", () => {
// Mock getClerkBaseUrl to return custom URL
vi.mocked(Config.getClerkBaseUrl).mockReturnValue(customUrl)
const service = new AuthService(mockContext as unknown as vscode.ExtensionContext, mockLog)
const service = new WebAuthService(mockContext as unknown as vscode.ExtensionContext, mockLog)
const credentials = { clientToken: "test-token", sessionId: "test-session" }
await service.initialize()
@ -993,7 +994,7 @@ describe("AuthService", () => {
const customUrl = "https://custom.clerk.com"
vi.mocked(Config.getClerkBaseUrl).mockReturnValue(customUrl)
const service = new AuthService(mockContext as unknown as vscode.ExtensionContext, mockLog)
const service = new WebAuthService(mockContext as unknown as vscode.ExtensionContext, mockLog)
const credentials = { clientToken: "test-token", sessionId: "test-session" }
mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
@ -1008,7 +1009,7 @@ describe("AuthService", () => {
const customUrl = "https://custom.clerk.com"
vi.mocked(Config.getClerkBaseUrl).mockReturnValue(customUrl)
const service = new AuthService(mockContext as unknown as vscode.ExtensionContext, mockLog)
const service = new WebAuthService(mockContext as unknown as vscode.ExtensionContext, mockLog)
await service.initialize()
await service["clearCredentials"]()
@ -1027,7 +1028,7 @@ describe("AuthService", () => {
return { dispose: vi.fn() }
})
const service = new AuthService(mockContext as unknown as vscode.ExtensionContext, mockLog)
const service = new WebAuthService(mockContext as unknown as vscode.ExtensionContext, mockLog)
await service.initialize()
// Simulate credentials change event with scoped key
@ -1054,7 +1055,7 @@ describe("AuthService", () => {
return { dispose: vi.fn() }
})
const service = new AuthService(mockContext as unknown as vscode.ExtensionContext, mockLog)
const service = new WebAuthService(mockContext as unknown as vscode.ExtensionContext, mockLog)
await service.initialize()
const inactiveSessionSpy = vi.fn()
@ -1078,7 +1079,7 @@ describe("AuthService", () => {
return { dispose: vi.fn() }
})
const service = new AuthService(mockContext as unknown as vscode.ExtensionContext, mockLog)
const service = new WebAuthService(mockContext as unknown as vscode.ExtensionContext, mockLog)
await service.initialize()
const inactiveSessionSpy = vi.fn()

View file

@ -0,0 +1,33 @@
import EventEmitter from "events"
import type { CloudUserInfo } from "@roo-code/types"
export interface AuthServiceEvents {
"attempting-session": [data: { previousState: AuthState }]
"inactive-session": [data: { previousState: AuthState }]
"active-session": [data: { previousState: AuthState }]
"logged-out": [data: { previousState: AuthState }]
"user-info": [data: { userInfo: CloudUserInfo }]
}
export type AuthState = "initializing" | "logged-out" | "active-session" | "attempting-session" | "inactive-session"
export interface AuthService extends EventEmitter<AuthServiceEvents> {
// Lifecycle
initialize(): Promise<void>
// Authentication methods
login(): Promise<void>
logout(): Promise<void>
handleCallback(code: string | null, state: string | null, organizationId?: string | null): Promise<void>
// State methods
getState(): AuthState
isAuthenticated(): boolean
hasActiveSession(): boolean
hasOrIsAcquiringActiveSession(): boolean
// Token and user info
getSessionToken(): string | undefined
getUserInfo(): CloudUserInfo | null
getStoredOrganizationId(): string | null
}

View file

@ -0,0 +1,68 @@
import EventEmitter from "events"
import * as vscode from "vscode"
import type { CloudUserInfo } from "@roo-code/types"
import type { AuthService, AuthServiceEvents, AuthState } from "./AuthService"
export class StaticTokenAuthService extends EventEmitter<AuthServiceEvents> implements AuthService {
private state: AuthState = "active-session"
private token: string
private log: (...args: unknown[]) => void
constructor(context: vscode.ExtensionContext, token: string, log?: (...args: unknown[]) => void) {
super()
this.token = token
this.log = log || console.log
this.log("[auth] Using static token authentication mode")
}
public async initialize(): Promise<void> {
const previousState: AuthState = "initializing"
this.state = "active-session"
this.emit("active-session", { previousState })
this.log("[auth] Static token auth service initialized in active-session state")
}
public async login(): Promise<void> {
throw new Error("Authentication methods are disabled in StaticTokenAuthService")
}
public async logout(): Promise<void> {
throw new Error("Authentication methods are disabled in StaticTokenAuthService")
}
public async handleCallback(
_code: string | null,
_state: string | null,
_organizationId?: string | null,
): Promise<void> {
throw new Error("Authentication methods are disabled in StaticTokenAuthService")
}
public getState(): AuthState {
return this.state
}
public getSessionToken(): string | undefined {
return this.token
}
public isAuthenticated(): boolean {
return true
}
public hasActiveSession(): boolean {
return true
}
public hasOrIsAcquiringActiveSession(): boolean {
return true
}
public getUserInfo(): CloudUserInfo | null {
return {}
}
public getStoredOrganizationId(): string | null {
return null
}
}

View file

@ -6,17 +6,10 @@ import { z } from "zod"
import type { CloudUserInfo, CloudOrganizationMembership } from "@roo-code/types"
import { getClerkBaseUrl, getRooCodeApiUrl, PRODUCTION_CLERK_BASE_URL } from "./Config"
import { RefreshTimer } from "./RefreshTimer"
import { getUserAgent } from "./utils"
export interface AuthServiceEvents {
"attempting-session": [data: { previousState: AuthState }]
"inactive-session": [data: { previousState: AuthState }]
"active-session": [data: { previousState: AuthState }]
"logged-out": [data: { previousState: AuthState }]
"user-info": [data: { userInfo: CloudUserInfo }]
}
import { getClerkBaseUrl, getRooCodeApiUrl, PRODUCTION_CLERK_BASE_URL } from "../Config"
import { RefreshTimer } from "../RefreshTimer"
import { getUserAgent } from "../utils"
import type { AuthService, AuthServiceEvents, AuthState } from "./AuthService"
const authCredentialsSchema = z.object({
clientToken: z.string().min(1, "Client token cannot be empty"),
@ -28,8 +21,6 @@ type AuthCredentials = z.infer<typeof authCredentialsSchema>
const AUTH_STATE_KEY = "clerk-auth-state"
type AuthState = "initializing" | "logged-out" | "active-session" | "attempting-session" | "inactive-session"
const clerkSignInResponseSchema = z.object({
response: z.object({
created_session_id: z.string(),
@ -85,7 +76,7 @@ class InvalidClientTokenError extends Error {
}
}
export class AuthService extends EventEmitter<AuthServiceEvents> {
export class WebAuthService extends EventEmitter<AuthServiceEvents> implements AuthService {
private context: vscode.ExtensionContext
private timer: RefreshTimer
private state: AuthState = "initializing"

View file

@ -0,0 +1,3 @@
export type { AuthService, AuthServiceEvents, AuthState } from "./AuthService"
export { WebAuthService } from "./WebAuthService"
export { StaticTokenAuthService } from "./StaticTokenAuthService"