Skip to content

Store the organization id in credentials #5002

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 62 additions & 15 deletions packages/cloud/src/AuthService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface AuthServiceEvents {
const authCredentialsSchema = z.object({
clientToken: z.string().min(1, "Client token cannot be empty"),
sessionId: z.string().min(1, "Session ID cannot be empty"),
organizationId: z.string().nullable().optional(),
})

type AuthCredentials = z.infer<typeof authCredentialsSchema>
Expand Down Expand Up @@ -220,7 +221,16 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {

try {
const parsedJson = JSON.parse(credentialsJson)
return authCredentialsSchema.parse(parsedJson)
const credentials = authCredentialsSchema.parse(parsedJson)

// Migration: If no organizationId but we have userInfo, add it
if (credentials.organizationId === undefined && this.userInfo?.organizationId) {
credentials.organizationId = this.userInfo.organizationId
await this.storeCredentials(credentials)
this.log("[auth] Migrated credentials with organizationId")
}

return credentials
} catch (error) {
if (error instanceof z.ZodError) {
this.log("[auth] Invalid credentials format:", error.errors)
Expand Down Expand Up @@ -269,8 +279,13 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {
*
* @param code The authorization code from the callback
* @param state The state parameter from the callback
* @param organizationId The organization ID from the callback (null for personal accounts)
*/
public async handleCallback(code: string | null, state: string | null): Promise<void> {
public async handleCallback(
code: string | null,
state: string | null,
organizationId?: string | null,
): Promise<void> {
if (!code || !state) {
vscode.window.showInformationMessage("Invalid Roo Code Cloud sign in url")
return
Expand All @@ -287,6 +302,9 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {

const credentials = await this.clerkSignIn(code)

// Set organizationId (null for personal accounts)
credentials.organizationId = organizationId || null

await this.storeCredentials(credentials)

vscode.window.showInformationMessage("Successfully authenticated with Roo Code Cloud")
Expand Down Expand Up @@ -417,6 +435,15 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {
return this.userInfo
}

/**
* Get the stored organization ID from credentials
*
* @returns The stored organization ID, null for personal accounts or if no credentials exist
*/
public getStoredOrganizationId(): string | null {
return this.credentials?.organizationId || null
}

private async clerkSignIn(ticket: string): Promise<AuthCredentials> {
const formData = new URLSearchParams()
formData.append("strategy", "ticket")
Expand Down Expand Up @@ -454,6 +481,12 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {
const formData = new URLSearchParams()
formData.append("_is_native", "1")

// Only add organization_id if not null (personal accounts)
const organizationId = this.getStoredOrganizationId()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I am reading https://clerk.com/docs/reference/frontend-api/tag/Sessions#operation/createSessionToken!path=organization_id&t=request correctly (I have not poked around and actually tested this) there are actually 3 cases to consider:

  1. Have an org id: organization_id=THE_ORG_ID.
  2. Have a personal account: organization_id=.
  3. Don't know if you have an org id (old style credentials): don't send an organization_id param at all.

if (organizationId !== null) {
formData.append("organization_id", organizationId)
}

const response = await fetch(`${getClerkBaseUrl()}/v1/client/sessions/${this.credentials!.sessionId}/tokens`, {
method: "POST",
headers: {
Expand Down Expand Up @@ -505,23 +538,37 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {

userInfo.picture = userData.image_url

// Fetch organization memberships separately
// Fetch organization info separately - but only populate if user is in organization context
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is: Fetch organization info if user is in organization context? (That is: we don't fetch if we're not in org context, which is great).

try {
const orgMemberships = await this.clerkGetOrganizationMemberships()
if (orgMemberships && orgMemberships.length > 0) {
// Get the first (or active) organization membership
const primaryOrgMembership = orgMemberships[0]
const organization = primaryOrgMembership?.organization

if (organization) {
userInfo.organizationId = organization.id
userInfo.organizationName = organization.name
userInfo.organizationRole = primaryOrgMembership.role
userInfo.organizationImageUrl = organization.image_url
const storedOrgId = this.getStoredOrganizationId()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar comment to the fetch, but for different reasons: 3 cases again:

  1. Already have an org id; makes sense to refresh org info.
  2. Definitely a personal account: obviously don't fetch anything more.
  3. Old credential / don't know yet: need to fetch organization info.

(Actually, there is another option here: we could parse the session JWT and look at the o claim and get the org id that way without another call. https://clerk.com/docs/backend-requests/resources/session-tokens#default-claims But that would mean adding all that code. (Maybe worth thinking about in the medium-long term as an alternative to fetching org memberships: I believe most of what we're likely to be interested is in there.)


if (storedOrgId !== null) {
// User is in organization context - fetch user's memberships and filter
const orgMemberships = await this.clerkGetOrganizationMemberships()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could more simply just fetch https://clerk.com/docs/reference/frontend-api/tag/Organization#operation/getOrganization but I assume we plan to have a feature that would benefit from the role "soon enough" that it's worth continuing to fetch the memberships.


// Find the user's membership in this organization
const userMembership = orgMemberships?.find(
(membership: CloudOrganizationMembership) => membership.organization.id === storedOrgId,
)

if (userMembership) {
userInfo.organizationId = userMembership.organization.id
userInfo.organizationName = userMembership.organization.name
userInfo.organizationRole = userMembership.role
userInfo.organizationImageUrl = userMembership.organization.image_url
this.log("[auth] User in organization context:", {
id: userMembership.organization.id,
name: userMembership.organization.name,
role: userMembership.role,
})
} else {
this.log("[auth] Warning: User not found in stored organization:", storedOrgId)
}
} else {
this.log("[auth] User in personal account context - not setting organization info")
}
} catch (error) {
this.log("[auth] Failed to fetch organization memberships:", error)
this.log("[auth] Failed to fetch organization info:", error)
// Don't throw - organization info is optional
}

Expand Down
18 changes: 16 additions & 2 deletions packages/cloud/src/CloudService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,28 @@ export class CloudService {
return userInfo?.organizationRole || null
}

public hasStoredOrganizationId(): boolean {
this.ensureInitialized()
return this.authService!.getStoredOrganizationId() !== null
}

public getStoredOrganizationId(): string | null {
this.ensureInitialized()
return this.authService!.getStoredOrganizationId()
}

public getAuthState(): string {
this.ensureInitialized()
return this.authService!.getState()
}

public async handleAuthCallback(code: string | null, state: string | null): Promise<void> {
public async handleAuthCallback(
code: string | null,
state: string | null,
organizationId?: string | null,
): Promise<void> {
this.ensureInitialized()
return this.authService!.handleCallback(code, state)
return this.authService!.handleCallback(code, state, organizationId)
}

// SettingsService
Expand Down
58 changes: 52 additions & 6 deletions packages/cloud/src/__tests__/AuthService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ describe("AuthService", () => {

expect(mockContext.secrets.store).toHaveBeenCalledWith(
"clerk-auth-credentials",
JSON.stringify({ clientToken: "Bearer token-123", sessionId: "session-123" }),
JSON.stringify({ clientToken: "Bearer token-123", sessionId: "session-123", organizationId: null }),
)
expect(mockShowInfo).toHaveBeenCalledWith("Successfully authenticated with Roo Code Cloud")
})
Expand Down Expand Up @@ -633,9 +633,55 @@ describe("AuthService", () => {
expect(authService.getUserInfo()).toBeNull()
})

it("should parse user info correctly", async () => {
// Set up with credentials
const credentials = { clientToken: "test-token", sessionId: "test-session" }
it("should parse user info correctly for personal accounts", async () => {
// Set up with credentials for personal account (no organizationId)
const credentials = { clientToken: "test-token", sessionId: "test-session", organizationId: null }
mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
await authService.initialize()

// Clear previous mock calls
mockFetch.mockClear()

// Mock successful responses
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ jwt: "jwt-token" }),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
response: {
first_name: "Jane",
last_name: "Smith",
image_url: "https://example.com/jane.jpg",
primary_email_address_id: "email-2",
email_addresses: [
{ id: "email-1", email_address: "jane.old@example.com" },
{ id: "email-2", email_address: "jane@example.com" },
],
},
}),
})

const timerCallback = vi.mocked(RefreshTimer).mock.calls[0][0].callback
await timerCallback()

// Wait for async operations to complete
await new Promise((resolve) => setTimeout(resolve, 0))

const userInfo = authService.getUserInfo()
expect(userInfo).toEqual({
name: "Jane Smith",
email: "jane@example.com",
picture: "https://example.com/jane.jpg",
})
})

it("should parse user info correctly for organization accounts", async () => {
// Set up with credentials for organization account
const credentials = { clientToken: "test-token", sessionId: "test-session", organizationId: "org_1" }
mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
await authService.initialize()

Expand Down Expand Up @@ -699,8 +745,8 @@ describe("AuthService", () => {
})

it("should handle missing user info fields", async () => {
// Set up with credentials
const credentials = { clientToken: "test-token", sessionId: "test-session" }
// Set up with credentials for personal account (no organizationId)
const credentials = { clientToken: "test-token", sessionId: "test-session", organizationId: null }
mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
await authService.initialize()

Expand Down
38 changes: 37 additions & 1 deletion packages/cloud/src/__tests__/CloudService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ describe("CloudService", () => {
getState: ReturnType<typeof vi.fn>
getSessionToken: ReturnType<typeof vi.fn>
handleCallback: ReturnType<typeof vi.fn>
getStoredOrganizationId: ReturnType<typeof vi.fn>
on: ReturnType<typeof vi.fn>
off: ReturnType<typeof vi.fn>
once: ReturnType<typeof vi.fn>
Expand Down Expand Up @@ -88,6 +89,7 @@ describe("CloudService", () => {
getState: vi.fn().mockReturnValue("logged-out"),
getSessionToken: vi.fn(),
handleCallback: vi.fn(),
getStoredOrganizationId: vi.fn().mockReturnValue(null),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
Expand Down Expand Up @@ -255,7 +257,41 @@ describe("CloudService", () => {

it("should delegate handleAuthCallback to AuthService", async () => {
await cloudService.handleAuthCallback("code", "state")
expect(mockAuthService.handleCallback).toHaveBeenCalledWith("code", "state")
expect(mockAuthService.handleCallback).toHaveBeenCalledWith("code", "state", undefined)
})

it("should delegate handleAuthCallback with organizationId to AuthService", async () => {
await cloudService.handleAuthCallback("code", "state", "org_123")
expect(mockAuthService.handleCallback).toHaveBeenCalledWith("code", "state", "org_123")
})

it("should return stored organization ID from AuthService", () => {
mockAuthService.getStoredOrganizationId.mockReturnValue("org_456")

const result = cloudService.getStoredOrganizationId()
expect(mockAuthService.getStoredOrganizationId).toHaveBeenCalled()
expect(result).toBe("org_456")
})

it("should return null when no stored organization ID available", () => {
mockAuthService.getStoredOrganizationId.mockReturnValue(null)

const result = cloudService.getStoredOrganizationId()
expect(result).toBe(null)
})

it("should return true when stored organization ID exists", () => {
mockAuthService.getStoredOrganizationId.mockReturnValue("org_789")

const result = cloudService.hasStoredOrganizationId()
expect(result).toBe(true)
})

it("should return false when no stored organization ID exists", () => {
mockAuthService.getStoredOrganizationId.mockReturnValue(null)

const result = cloudService.hasStoredOrganizationId()
expect(result).toBe(false)
})
})

Expand Down
1 change: 1 addition & 0 deletions packages/types/src/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export const ORGANIZATION_ALLOW_ALL: OrganizationAllowList = {
export const ORGANIZATION_DEFAULT: OrganizationSettings = {
version: 0,
cloudSettings: {
recordTaskMessages: true,
enableTaskSharing: true,
taskShareExpirationDays: 30,
},
Expand Down
8 changes: 7 additions & 1 deletion src/activate/handleUri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,13 @@ export const handleUri = async (uri: vscode.Uri) => {
case "/auth/clerk/callback": {
const code = query.get("code")
const state = query.get("state")
await CloudService.instance.handleAuthCallback(code, state)
const organizationId = query.get("organizationId")

await CloudService.instance.handleAuthCallback(
code,
state,
organizationId === "null" ? null : organizationId,
)
break
}
default:
Expand Down
26 changes: 22 additions & 4 deletions src/services/mdm/MdmService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { z } from "zod"

import { CloudService, getClerkBaseUrl, PRODUCTION_CLERK_BASE_URL } from "@roo-code/cloud"
import { Package } from "../../shared/package"
import { t } from "../../i18n"

// MDM Configuration Schema
const mdmConfigSchema = z.object({
Expand Down Expand Up @@ -89,26 +90,43 @@ export class MdmService {
if (!CloudService.hasInstance() || !CloudService.instance.hasOrIsAcquiringActiveSession()) {
return {
compliant: false,
reason: "Your organization requires Roo Code Cloud authentication. Please sign in to continue.",
reason: t("mdm.errors.cloud_auth_required"),
}
}

// Check organization match if specified
const requiredOrgId = this.getRequiredOrganizationId()
if (requiredOrgId) {
try {
const currentOrgId = CloudService.instance.getOrganizationId()
// First try to get from active session
let currentOrgId = CloudService.instance.getOrganizationId()

// If no active session, check stored credentials
if (!currentOrgId) {
const storedOrgId = CloudService.instance.getStoredOrganizationId()

// null means personal account, which is not compliant for org requirements
if (storedOrgId === null || storedOrgId !== requiredOrgId) {
return {
compliant: false,
reason: t("mdm.errors.organization_mismatch"),
}
}

currentOrgId = storedOrgId
}

if (currentOrgId !== requiredOrgId) {
return {
compliant: false,
reason: "You must be authenticated with your organization's Roo Code Cloud account.",
reason: t("mdm.errors.organization_mismatch"),
}
}
} catch (error) {
this.log("[MDM] Error checking organization ID:", error)
return {
compliant: false,
reason: "Unable to verify organization authentication.",
reason: t("mdm.errors.verification_failed"),
}
}
}
Expand Down
Loading