diff --git a/packages/cloud/src/CloudService.ts b/packages/cloud/src/CloudService.ts index 12aafee9dcc..f48062a1a3c 100644 --- a/packages/cloud/src/CloudService.ts +++ b/packages/cloud/src/CloudService.ts @@ -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) diff --git a/packages/cloud/src/SettingsService.ts b/packages/cloud/src/SettingsService.ts index 68c6f2fe486..f4c36e45b59 100644 --- a/packages/cloud/src/SettingsService.ts +++ b/packages/cloud/src/SettingsService.ts @@ -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" diff --git a/packages/cloud/src/ShareService.ts b/packages/cloud/src/ShareService.ts index 07176d3e9de..5dcc7cae3f8 100644 --- a/packages/cloud/src/ShareService.ts +++ b/packages/cloud/src/ShareService.ts @@ -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" diff --git a/packages/cloud/src/TelemetryClient.ts b/packages/cloud/src/TelemetryClient.ts index ea48fcf2691..de37ea503b7 100644 --- a/packages/cloud/src/TelemetryClient.ts +++ b/packages/cloud/src/TelemetryClient.ts @@ -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 { diff --git a/packages/cloud/src/__tests__/CloudService.test.ts b/packages/cloud/src/__tests__/CloudService.test.ts index 6ed8c9741c5..5320a51864b 100644 --- a/packages/cloud/src/__tests__/CloudService.test.ts +++ b/packages/cloud/src/__tests__/CloudService.test.ts @@ -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, diff --git a/packages/cloud/src/__tests__/ShareService.test.ts b/packages/cloud/src/__tests__/ShareService.test.ts index dd2e5f1ae51..dd5b6696033 100644 --- a/packages/cloud/src/__tests__/ShareService.test.ts +++ b/packages/cloud/src/__tests__/ShareService.test.ts @@ -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 diff --git a/packages/cloud/src/__tests__/auth/StaticTokenAuthService.spec.ts b/packages/cloud/src/__tests__/auth/StaticTokenAuthService.spec.ts new file mode 100644 index 00000000000..cbf3a7b998f --- /dev/null +++ b/packages/cloud/src/__tests__/auth/StaticTokenAuthService.spec.ts @@ -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() + }) + }) +}) diff --git a/packages/cloud/src/__tests__/AuthService.spec.ts b/packages/cloud/src/__tests__/auth/WebAuthService.spec.ts similarity index 95% rename from packages/cloud/src/__tests__/AuthService.spec.ts rename to packages/cloud/src/__tests__/auth/WebAuthService.spec.ts index 944bcd2b243..0e6681c20ba 100644 --- a/packages/cloud/src/__tests__/AuthService.spec.ts +++ b/packages/cloud/src/__tests__/auth/WebAuthService.spec.ts @@ -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() diff --git a/packages/cloud/src/auth/AuthService.ts b/packages/cloud/src/auth/AuthService.ts new file mode 100644 index 00000000000..11ed5161eda --- /dev/null +++ b/packages/cloud/src/auth/AuthService.ts @@ -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 { + // Lifecycle + initialize(): Promise + + // Authentication methods + login(): Promise + logout(): Promise + handleCallback(code: string | null, state: string | null, organizationId?: string | null): Promise + + // State methods + getState(): AuthState + isAuthenticated(): boolean + hasActiveSession(): boolean + hasOrIsAcquiringActiveSession(): boolean + + // Token and user info + getSessionToken(): string | undefined + getUserInfo(): CloudUserInfo | null + getStoredOrganizationId(): string | null +} diff --git a/packages/cloud/src/auth/StaticTokenAuthService.ts b/packages/cloud/src/auth/StaticTokenAuthService.ts new file mode 100644 index 00000000000..11fc18d3fb2 --- /dev/null +++ b/packages/cloud/src/auth/StaticTokenAuthService.ts @@ -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 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 { + 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 { + throw new Error("Authentication methods are disabled in StaticTokenAuthService") + } + + public async logout(): Promise { + throw new Error("Authentication methods are disabled in StaticTokenAuthService") + } + + public async handleCallback( + _code: string | null, + _state: string | null, + _organizationId?: string | null, + ): Promise { + 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 + } +} diff --git a/packages/cloud/src/AuthService.ts b/packages/cloud/src/auth/WebAuthService.ts similarity index 96% rename from packages/cloud/src/AuthService.ts rename to packages/cloud/src/auth/WebAuthService.ts index cd8e1362c16..d14cbe67d84 100644 --- a/packages/cloud/src/AuthService.ts +++ b/packages/cloud/src/auth/WebAuthService.ts @@ -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 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 { +export class WebAuthService extends EventEmitter implements AuthService { private context: vscode.ExtensionContext private timer: RefreshTimer private state: AuthState = "initializing" diff --git a/packages/cloud/src/auth/index.ts b/packages/cloud/src/auth/index.ts new file mode 100644 index 00000000000..b04a805295a --- /dev/null +++ b/packages/cloud/src/auth/index.ts @@ -0,0 +1,3 @@ +export type { AuthService, AuthServiceEvents, AuthState } from "./AuthService" +export { WebAuthService } from "./WebAuthService" +export { StaticTokenAuthService } from "./StaticTokenAuthService"