diff --git a/packages/core/src/auth/auth.test.ts b/packages/core/src/auth/auth.test.ts index 38be8ae05e..f96fb65234 100644 --- a/packages/core/src/auth/auth.test.ts +++ b/packages/core/src/auth/auth.test.ts @@ -81,6 +81,7 @@ function mockTokenResponse( accessToken?: string; refreshToken?: string; scopedOrgs?: string[]; + scopedTeams?: number[]; } = {}, ) { return { @@ -91,6 +92,7 @@ function mockTokenResponse( expires_in: 3600, token_type: "Bearer", scope: "", + scoped_teams: overrides.scopedTeams, scoped_organizations: overrides.scopedOrgs ?? ["org-1"], }, }; @@ -1463,6 +1465,198 @@ describe("AuthService", () => { expect(service.getState()).toMatchObject(expectedState); }, ); + + // Regression for #3279: the current-org fallback still strands the picker + // when the backend rejects team-scoped tokens on organization endpoints. + it("loads project-scoped OAuth access when organization access is forbidden", async () => { + let orgCalls = 0; + let projectCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | Request) => { + const url = typeof input === "string" ? input : input.url; + if (url.includes("/api/users/@me/")) { + return { + ok: true, + json: vi.fn().mockResolvedValue({ + uuid: "user-1", + organization: { id: "org-1", name: "Org 1" }, + }), + } as unknown as Response; + } + if (/\/api\/organizations\/[^/]+\/$/.test(url)) { + orgCalls++; + return { ok: false, status: 403 } as Response; + } + if (url.endsWith("/api/projects/42/")) { + projectCalls++; + return { + ok: true, + json: vi.fn().mockResolvedValue({ + id: 42, + name: "Project 42", + organization: "org-1", + }), + } as unknown as Response; + } + return { + ok: true, + json: vi.fn().mockResolvedValue({ has_access: true }), + } as unknown as Response; + }) as unknown as typeof fetch, + ); + oauthFlow.startFlow.mockResolvedValue( + mockTokenResponse({ scopedOrgs: [], scopedTeams: [42] }), + ); + + await service.login("us"); + + expect(orgCalls).toBe(0); + expect(projectCalls).toBe(1); + expect(service.getState()).toMatchObject({ + currentOrgId: "org-1", + currentProjectId: 42, + orgProjectsMap: { + "org-1": { + orgName: "Org 1", + projects: [{ id: 42, name: "Project 42" }], + }, + }, + }); + }); + + it("merges organization and project scopes", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | Request) => { + const url = typeof input === "string" ? input : input.url; + if (url.includes("/api/users/@me/")) { + return { + ok: true, + json: vi.fn().mockResolvedValue({ + uuid: "user-1", + organization: { id: "org-1", name: "Org 1" }, + }), + } as unknown as Response; + } + if (url.endsWith("/api/organizations/org-1/")) { + return { + ok: true, + json: vi.fn().mockResolvedValue({ + name: "Org 1", + teams: [{ id: 11, name: "Organization Project" }], + }), + } as unknown as Response; + } + if (url.endsWith("/api/projects/42/")) { + return { + ok: true, + json: vi.fn().mockResolvedValue({ + id: 42, + name: "Scoped Project", + organization: "org-1", + }), + } as unknown as Response; + } + return { + ok: true, + json: vi.fn().mockResolvedValue({ has_access: true }), + } as unknown as Response; + }) as unknown as typeof fetch, + ); + oauthFlow.startFlow.mockResolvedValue( + mockTokenResponse({ scopedOrgs: ["org-1"], scopedTeams: [42] }), + ); + + await service.login("us"); + + expect(service.getState().orgProjectsMap["org-1"].projects).toEqual([ + { id: 11, name: "Organization Project" }, + { id: 42, name: "Scoped Project" }, + ]); + }); + + it("aligns the current organization with the restored scoped project", async () => { + seedStoredSession({ + selectedProjectId: 84, + scopeVersion: OAUTH_SCOPE_VERSION - 1, + }); + await service.initialize(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | Request) => { + const url = typeof input === "string" ? input : input.url; + if (url.includes("/api/users/@me/")) { + return { + ok: true, + json: vi.fn().mockResolvedValue({ + uuid: "user-1", + organization: { id: "org-1", name: "Org 1" }, + }), + } as unknown as Response; + } + const projectMatch = url.match(/\/api\/projects\/(42|84)\/$/); + if (projectMatch) { + const projectId = Number(projectMatch[1]); + return { + ok: true, + json: vi.fn().mockResolvedValue({ + id: projectId, + name: `Project ${projectId}`, + organization: projectId === 42 ? "org-1" : "org-2", + }), + } as unknown as Response; + } + return { + ok: true, + json: vi.fn().mockResolvedValue({ has_access: true }), + } as unknown as Response; + }) as unknown as typeof fetch, + ); + oauthFlow.startFlow.mockResolvedValue( + mockTokenResponse({ scopedOrgs: [], scopedTeams: [42, 84] }), + ); + + await service.login("us"); + + expect(service.getState()).toMatchObject({ + currentOrgId: "org-2", + currentProjectId: 84, + }); + }); + + it("rejects authentication when a scoped project cannot be loaded permanently", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | Request) => { + const url = typeof input === "string" ? input : input.url; + if (url.includes("/api/users/@me/")) { + return { + ok: true, + json: vi.fn().mockResolvedValue({ + uuid: "user-1", + organization: { id: "org-1", name: "Org 1" }, + }), + } as unknown as Response; + } + if (url.endsWith("/api/projects/42/")) { + return { ok: false, status: 403 } as Response; + } + return { + ok: true, + json: vi.fn().mockResolvedValue({ has_access: true }), + } as unknown as Response; + }) as unknown as typeof fetch, + ); + oauthFlow.startFlow.mockResolvedValue( + mockTokenResponse({ scopedOrgs: [], scopedTeams: [42] }), + ); + + await expect(service.login("us")).rejects.toThrow( + "Unable to load OAuth-scoped project 42", + ); + expect(service.getState().status).toBe("anonymous"); + }); }); describe("switchOrg", () => { diff --git a/packages/core/src/auth/auth.ts b/packages/core/src/auth/auth.ts index 9f9452ec04..8fd70b2b65 100644 --- a/packages/core/src/auth/auth.ts +++ b/packages/core/src/auth/auth.ts @@ -57,6 +57,8 @@ interface InMemorySession { orgProjectsMap: OrgProjectsMap; currentOrgId: string | null; currentProjectId: number | null; + scopedOrgIds: string[]; + scopedProjectIds: number[]; orgProjectsIncomplete: boolean; } @@ -638,27 +640,23 @@ export class AuthService extends TypedEventEmitter { options: TokenResponseOptions, ): Promise { const scopedOrgIds = tokenResponse.scoped_organizations ?? []; - const { accountKey, currentOrgId } = await this.fetchUserContext( - tokenResponse.access_token, - options.cloudRegion, - ); - // Team-scoped tokens (required_access_level=project) can arrive with an - // empty scoped_organizations list — the server only populates scoped_teams. - // Fall back to the current org from /api/users/@me/ so the picker isn't - // empty; without this the user is stranded on "No projects". - const orgIdsToFetch = - scopedOrgIds.length > 0 - ? scopedOrgIds - : currentOrgId - ? [currentOrgId] - : []; - const { map: orgProjectsMap, incomplete: orgProjectsIncomplete } = - await this.buildOrgProjectsMap( + const scopedProjectIds = tokenResponse.scoped_teams ?? []; + const { accountKey, currentOrgId, currentOrgName } = + await this.fetchUserContext( tokenResponse.access_token, options.cloudRegion, - orgIdsToFetch, - this.session?.orgProjectsMap ?? {}, ); + const previousMap = this.session?.orgProjectsMap ?? {}; + const { map: orgProjectsMap, incomplete: orgProjectsIncomplete } = + await this.buildAuthorizedProjectsMap({ + accessToken: tokenResponse.access_token, + cloudRegion: options.cloudRegion, + scopedOrgIds, + scopedProjectIds, + currentOrgId, + currentOrgName, + previousMap, + }); const lastPrefs = accountKey ? this.authPreference.get(accountKey, options.cloudRegion) : null; @@ -670,6 +668,12 @@ export class AuthService extends TypedEventEmitter { lastSelectedOrgId: lastPrefs?.lastSelectedOrgId ?? null, }); + const resolvedCurrentOrgId = currentProjectId + ? (findOrgForProject(orgProjectsMap, currentProjectId, currentOrgId) ?? + currentOrgId) + : currentOrgId && orgProjectsMap[currentOrgId] + ? currentOrgId + : (Object.keys(orgProjectsMap)[0] ?? currentOrgId); const session: InMemorySession = { accountKey, accessToken: tokenResponse.access_token, @@ -677,13 +681,164 @@ export class AuthService extends TypedEventEmitter { refreshToken: tokenResponse.refresh_token, cloudRegion: options.cloudRegion, orgProjectsMap, - currentOrgId, + currentOrgId: resolvedCurrentOrgId, currentProjectId, + scopedOrgIds, + scopedProjectIds, orgProjectsIncomplete, }; return session; } + private async buildAuthorizedProjectsMap(input: { + accessToken: string; + cloudRegion: CloudRegion; + scopedOrgIds: string[]; + scopedProjectIds: number[]; + currentOrgId: string | null; + currentOrgName: string | null; + previousMap: OrgProjectsMap; + }): Promise<{ map: OrgProjectsMap; incomplete: boolean }> { + const orgIdsToFetch = + input.scopedOrgIds.length > 0 + ? input.scopedOrgIds + : input.scopedProjectIds.length === 0 && input.currentOrgId + ? [input.currentOrgId] + : []; + const [orgResult, projectResult] = await Promise.all([ + this.buildOrgProjectsMap( + input.accessToken, + input.cloudRegion, + orgIdsToFetch, + input.previousMap, + ), + input.scopedProjectIds.length > 0 + ? this.buildScopedProjectMap( + input.accessToken, + input.cloudRegion, + input.scopedProjectIds, + input.currentOrgId, + input.currentOrgName, + input.previousMap, + ) + : Promise.resolve({ map: {}, incomplete: false }), + ]); + + return { + map: this.mergeOrgProjectsMaps(orgResult.map, projectResult.map), + incomplete: orgResult.incomplete || projectResult.incomplete, + }; + } + private mergeOrgProjectsMaps(...maps: OrgProjectsMap[]): OrgProjectsMap { + const merged: OrgProjectsMap = {}; + for (const map of maps) { + for (const [orgId, org] of Object.entries(map)) { + const existing = merged[orgId]; + const projects = new Map( + existing?.projects.map((project) => [project.id, project]) ?? [], + ); + for (const project of org.projects) { + projects.set(project.id, project); + } + merged[orgId] = { + orgName: + existing?.orgName && existing.orgName !== "(unknown)" + ? existing.orgName + : org.orgName, + projects: [...projects.values()], + }; + } + } + return merged; + } + private async buildScopedProjectMap( + accessToken: string, + cloudRegion: CloudRegion, + projectIds: number[], + currentOrgId: string | null, + currentOrgName: string | null, + previousMap: OrgProjectsMap, + ): Promise<{ map: OrgProjectsMap; incomplete: boolean }> { + let incomplete = false; + const map: OrgProjectsMap = {}; + + await Promise.all( + projectIds.map(async (projectId) => { + const result = await this.fetchScopedProject( + accessToken, + cloudRegion, + projectId, + ); + if (!result.ok) { + if (!result.retryable) { + throw new Error(`Unable to load OAuth-scoped project ${projectId}`); + } + incomplete = true; + return; + } + + const { orgId, project } = result.data; + const existing = map[orgId]; + map[orgId] = { + orgName: + existing?.orgName ?? + (orgId === currentOrgId ? currentOrgName : null) ?? + previousMap[orgId]?.orgName ?? + "(unknown)", + projects: [...(existing?.projects ?? []), project], + }; + }), + ); + + return { map, incomplete }; + } + private async fetchScopedProject( + accessToken: string, + cloudRegion: CloudRegion, + projectId: number, + ): Promise< + | { + ok: true; + data: { orgId: string; project: { id: number; name: string } }; + } + | { ok: false; retryable: boolean } + > { + try { + const response = await this.executeAuthenticatedFetch( + fetch, + `${getCloudUrlFromRegion(cloudRegion)}/api/projects/${projectId}/`, + {}, + accessToken, + ); + if (!response.ok) { + return { ok: false, retryable: response.status >= 500 }; + } + const raw = (await response.json().catch(() => null)) as { + id?: unknown; + name?: unknown; + organization?: unknown; + } | null; + const responseProjectId = + typeof raw?.id === "number" ? raw.id : projectId; + const name = typeof raw?.name === "string" ? raw.name : null; + const orgId = + typeof raw?.organization === "string" + ? raw.organization + : typeof raw?.organization === "object" && raw.organization !== null + ? (raw.organization as { id?: unknown }).id + : null; + if (typeof orgId !== "string" || !name) { + return { ok: false, retryable: false }; + } + return { + ok: true, + data: { orgId, project: { id: responseProjectId, name } }, + }; + } catch (error) { + this.logger.warn("Failed to fetch scoped project", { projectId, error }); + return { ok: false, retryable: true }; + } + } private async buildOrgProjectsMap( accessToken: string, cloudRegion: CloudRegion, @@ -896,7 +1051,11 @@ export class AuthService extends TypedEventEmitter { private async fetchUserContext( accessToken: string, cloudRegion: CloudRegion, - ): Promise<{ accountKey: string | null; currentOrgId: string | null }> { + ): Promise<{ + accountKey: string | null; + currentOrgId: string | null; + currentOrgName: string | null; + }> { try { const response = await this.executeAuthenticatedFetch( fetch, @@ -906,14 +1065,18 @@ export class AuthService extends TypedEventEmitter { ); if (!response.ok) { - return { accountKey: null, currentOrgId: null }; + return { + accountKey: null, + currentOrgId: null, + currentOrgName: null, + }; } const data = (await response.json().catch(() => ({}))) as { uuid?: unknown; distinct_id?: unknown; email?: unknown; - organization?: { id?: unknown } | null; + organization?: { id?: unknown; name?: unknown } | null; }; let accountKey: string | null = null; @@ -931,11 +1094,14 @@ export class AuthService extends TypedEventEmitter { const orgId = data.organization?.id; const currentOrgId = typeof orgId === "string" && orgId.length > 0 ? orgId : null; + const orgName = data.organization?.name; + const currentOrgName = + typeof orgName === "string" && orgName.length > 0 ? orgName : null; - return { accountKey, currentOrgId }; + return { accountKey, currentOrgId, currentOrgName }; } catch (error) { this.logger.warn("Failed to resolve user context", { error }); - return { accountKey: null, currentOrgId: null }; + return { accountKey: null, currentOrgId: null, currentOrgName: null }; } } private requireSession(): InMemorySession { @@ -1166,13 +1332,16 @@ export class AuthService extends TypedEventEmitter { if (!session.orgProjectsIncomplete) return; - const orgIds = Object.keys(session.orgProjectsMap); - const { map, incomplete } = await this.buildOrgProjectsMap( - session.accessToken, - session.cloudRegion, - orgIds, - session.orgProjectsMap, - ); + const { map, incomplete } = await this.buildAuthorizedProjectsMap({ + accessToken: session.accessToken, + cloudRegion: session.cloudRegion, + scopedOrgIds: session.scopedOrgIds, + scopedProjectIds: session.scopedProjectIds, + currentOrgId: session.currentOrgId, + currentOrgName: + session.orgProjectsMap[session.currentOrgId ?? ""]?.orgName ?? null, + previousMap: session.orgProjectsMap, + }); // The session may have been replaced (logout, re-login) while the fetch // was in flight; committing the stale one would resurrect it. diff --git a/packages/core/src/auth/oauth.schemas.test.ts b/packages/core/src/auth/oauth.schemas.test.ts new file mode 100644 index 0000000000..cd3e6333fc --- /dev/null +++ b/packages/core/src/auth/oauth.schemas.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { oAuthTokenResponse } from "./oauth.schemas"; + +describe("oAuthTokenResponse", () => { + it("preserves project scopes from the token response", () => { + const token = oAuthTokenResponse.parse({ + access_token: "access-token", + expires_in: 3600, + token_type: "Bearer", + refresh_token: "refresh-token", + scoped_teams: [42, 84], + scoped_organizations: [], + }); + + expect(token.scoped_teams).toEqual([42, 84]); + }); +}); diff --git a/packages/core/src/auth/oauth.schemas.ts b/packages/core/src/auth/oauth.schemas.ts index c8d51cc73d..e909c4471b 100644 --- a/packages/core/src/auth/oauth.schemas.ts +++ b/packages/core/src/auth/oauth.schemas.ts @@ -24,6 +24,7 @@ export const oAuthTokenResponse = z.object({ token_type: z.string(), scope: z.string().optional().default(""), refresh_token: z.string(), + scoped_teams: z.array(z.number()).optional(), scoped_organizations: z.array(z.string()).optional(), }); export type OAuthTokenResponse = z.infer;