Skip to content

Commit 446da28

Browse files
authored
fix(auth): handle mixed OAuth project scopes
Generated-By: PostHog Code Task-Id: 07b377f9-4583-463c-b256-cc29621375e6
1 parent 8fb7f9c commit 446da28

2 files changed

Lines changed: 223 additions & 43 deletions

File tree

packages/core/src/auth/auth.test.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1524,6 +1524,139 @@ describe("AuthService", () => {
15241524
},
15251525
});
15261526
});
1527+
1528+
it("merges organization and project scopes", async () => {
1529+
vi.stubGlobal(
1530+
"fetch",
1531+
vi.fn(async (input: string | Request) => {
1532+
const url = typeof input === "string" ? input : input.url;
1533+
if (url.includes("/api/users/@me/")) {
1534+
return {
1535+
ok: true,
1536+
json: vi.fn().mockResolvedValue({
1537+
uuid: "user-1",
1538+
organization: { id: "org-1", name: "Org 1" },
1539+
}),
1540+
} as unknown as Response;
1541+
}
1542+
if (url.endsWith("/api/organizations/org-1/")) {
1543+
return {
1544+
ok: true,
1545+
json: vi.fn().mockResolvedValue({
1546+
name: "Org 1",
1547+
teams: [{ id: 11, name: "Organization Project" }],
1548+
}),
1549+
} as unknown as Response;
1550+
}
1551+
if (url.endsWith("/api/projects/42/")) {
1552+
return {
1553+
ok: true,
1554+
json: vi.fn().mockResolvedValue({
1555+
id: 42,
1556+
name: "Scoped Project",
1557+
organization: "org-1",
1558+
}),
1559+
} as unknown as Response;
1560+
}
1561+
return {
1562+
ok: true,
1563+
json: vi.fn().mockResolvedValue({ has_access: true }),
1564+
} as unknown as Response;
1565+
}) as unknown as typeof fetch,
1566+
);
1567+
oauthFlow.startFlow.mockResolvedValue(
1568+
mockTokenResponse({ scopedOrgs: ["org-1"], scopedTeams: [42] }),
1569+
);
1570+
1571+
await service.login("us");
1572+
1573+
expect(service.getState().orgProjectsMap["org-1"].projects).toEqual([
1574+
{ id: 11, name: "Organization Project" },
1575+
{ id: 42, name: "Scoped Project" },
1576+
]);
1577+
});
1578+
1579+
it("aligns the current organization with the restored scoped project", async () => {
1580+
seedStoredSession({
1581+
selectedProjectId: 84,
1582+
scopeVersion: OAUTH_SCOPE_VERSION - 1,
1583+
});
1584+
await service.initialize();
1585+
vi.stubGlobal(
1586+
"fetch",
1587+
vi.fn(async (input: string | Request) => {
1588+
const url = typeof input === "string" ? input : input.url;
1589+
if (url.includes("/api/users/@me/")) {
1590+
return {
1591+
ok: true,
1592+
json: vi.fn().mockResolvedValue({
1593+
uuid: "user-1",
1594+
organization: { id: "org-1", name: "Org 1" },
1595+
}),
1596+
} as unknown as Response;
1597+
}
1598+
const projectMatch = url.match(/\/api\/projects\/(42|84)\/$/);
1599+
if (projectMatch) {
1600+
const projectId = Number(projectMatch[1]);
1601+
return {
1602+
ok: true,
1603+
json: vi.fn().mockResolvedValue({
1604+
id: projectId,
1605+
name: `Project ${projectId}`,
1606+
organization: projectId === 42 ? "org-1" : "org-2",
1607+
}),
1608+
} as unknown as Response;
1609+
}
1610+
return {
1611+
ok: true,
1612+
json: vi.fn().mockResolvedValue({ has_access: true }),
1613+
} as unknown as Response;
1614+
}) as unknown as typeof fetch,
1615+
);
1616+
oauthFlow.startFlow.mockResolvedValue(
1617+
mockTokenResponse({ scopedOrgs: [], scopedTeams: [42, 84] }),
1618+
);
1619+
1620+
await service.login("us");
1621+
1622+
expect(service.getState()).toMatchObject({
1623+
currentOrgId: "org-2",
1624+
currentProjectId: 84,
1625+
});
1626+
});
1627+
1628+
it("rejects authentication when a scoped project cannot be loaded permanently", async () => {
1629+
vi.stubGlobal(
1630+
"fetch",
1631+
vi.fn(async (input: string | Request) => {
1632+
const url = typeof input === "string" ? input : input.url;
1633+
if (url.includes("/api/users/@me/")) {
1634+
return {
1635+
ok: true,
1636+
json: vi.fn().mockResolvedValue({
1637+
uuid: "user-1",
1638+
organization: { id: "org-1", name: "Org 1" },
1639+
}),
1640+
} as unknown as Response;
1641+
}
1642+
if (url.endsWith("/api/projects/42/")) {
1643+
return { ok: false, status: 403 } as Response;
1644+
}
1645+
return {
1646+
ok: true,
1647+
json: vi.fn().mockResolvedValue({ has_access: true }),
1648+
} as unknown as Response;
1649+
}) as unknown as typeof fetch,
1650+
);
1651+
oauthFlow.startFlow.mockResolvedValue(
1652+
mockTokenResponse({ scopedOrgs: [], scopedTeams: [42] }),
1653+
);
1654+
1655+
await expect(service.login("us")).rejects.toThrow(
1656+
"Unable to load OAuth-scoped project 42",
1657+
);
1658+
expect(service.getState().status).toBe("anonymous");
1659+
});
15271660
});
15281661

15291662
describe("switchOrg", () => {

packages/core/src/auth/auth.ts

Lines changed: 90 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ interface InMemorySession {
5757
orgProjectsMap: OrgProjectsMap;
5858
currentOrgId: string | null;
5959
currentProjectId: number | null;
60+
scopedOrgIds: string[];
6061
scopedProjectIds: number[];
6162
orgProjectsIncomplete: boolean;
6263
}
@@ -647,28 +648,15 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
647648
);
648649
const previousMap = this.session?.orgProjectsMap ?? {};
649650
const { map: orgProjectsMap, incomplete: orgProjectsIncomplete } =
650-
scopedOrgIds.length > 0
651-
? await this.buildOrgProjectsMap(
652-
tokenResponse.access_token,
653-
options.cloudRegion,
654-
scopedOrgIds,
655-
previousMap,
656-
)
657-
: scopedProjectIds.length > 0
658-
? await this.buildScopedProjectMap(
659-
tokenResponse.access_token,
660-
options.cloudRegion,
661-
scopedProjectIds,
662-
currentOrgId,
663-
currentOrgName,
664-
previousMap,
665-
)
666-
: await this.buildOrgProjectsMap(
667-
tokenResponse.access_token,
668-
options.cloudRegion,
669-
currentOrgId ? [currentOrgId] : [],
670-
previousMap,
671-
);
651+
await this.buildAuthorizedProjectsMap({
652+
accessToken: tokenResponse.access_token,
653+
cloudRegion: options.cloudRegion,
654+
scopedOrgIds,
655+
scopedProjectIds,
656+
currentOrgId,
657+
currentOrgName,
658+
previousMap,
659+
});
672660
const lastPrefs = accountKey
673661
? this.authPreference.get(accountKey, options.cloudRegion)
674662
: null;
@@ -680,8 +668,10 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
680668
lastSelectedOrgId: lastPrefs?.lastSelectedOrgId ?? null,
681669
});
682670

683-
const resolvedCurrentOrgId =
684-
currentOrgId && orgProjectsMap[currentOrgId]
671+
const resolvedCurrentOrgId = currentProjectId
672+
? (findOrgForProject(orgProjectsMap, currentProjectId, currentOrgId) ??
673+
currentOrgId)
674+
: currentOrgId && orgProjectsMap[currentOrgId]
685675
? currentOrgId
686676
: (Object.keys(orgProjectsMap)[0] ?? currentOrgId);
687677
const session: InMemorySession = {
@@ -693,12 +683,74 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
693683
orgProjectsMap,
694684
currentOrgId: resolvedCurrentOrgId,
695685
currentProjectId,
686+
scopedOrgIds,
696687
scopedProjectIds,
697688
orgProjectsIncomplete,
698689
};
699690

700691
return session;
701692
}
693+
private async buildAuthorizedProjectsMap(input: {
694+
accessToken: string;
695+
cloudRegion: CloudRegion;
696+
scopedOrgIds: string[];
697+
scopedProjectIds: number[];
698+
currentOrgId: string | null;
699+
currentOrgName: string | null;
700+
previousMap: OrgProjectsMap;
701+
}): Promise<{ map: OrgProjectsMap; incomplete: boolean }> {
702+
const orgIdsToFetch =
703+
input.scopedOrgIds.length > 0
704+
? input.scopedOrgIds
705+
: input.scopedProjectIds.length === 0 && input.currentOrgId
706+
? [input.currentOrgId]
707+
: [];
708+
const [orgResult, projectResult] = await Promise.all([
709+
this.buildOrgProjectsMap(
710+
input.accessToken,
711+
input.cloudRegion,
712+
orgIdsToFetch,
713+
input.previousMap,
714+
),
715+
input.scopedProjectIds.length > 0
716+
? this.buildScopedProjectMap(
717+
input.accessToken,
718+
input.cloudRegion,
719+
input.scopedProjectIds,
720+
input.currentOrgId,
721+
input.currentOrgName,
722+
input.previousMap,
723+
)
724+
: Promise.resolve({ map: {}, incomplete: false }),
725+
]);
726+
727+
return {
728+
map: this.mergeOrgProjectsMaps(orgResult.map, projectResult.map),
729+
incomplete: orgResult.incomplete || projectResult.incomplete,
730+
};
731+
}
732+
private mergeOrgProjectsMaps(...maps: OrgProjectsMap[]): OrgProjectsMap {
733+
const merged: OrgProjectsMap = {};
734+
for (const map of maps) {
735+
for (const [orgId, org] of Object.entries(map)) {
736+
const existing = merged[orgId];
737+
const projects = new Map(
738+
existing?.projects.map((project) => [project.id, project]) ?? [],
739+
);
740+
for (const project of org.projects) {
741+
projects.set(project.id, project);
742+
}
743+
merged[orgId] = {
744+
orgName:
745+
existing?.orgName && existing.orgName !== "(unknown)"
746+
? existing.orgName
747+
: org.orgName,
748+
projects: [...projects.values()],
749+
};
750+
}
751+
}
752+
return merged;
753+
}
702754
private async buildScopedProjectMap(
703755
accessToken: string,
704756
cloudRegion: CloudRegion,
@@ -718,7 +770,10 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
718770
projectId,
719771
);
720772
if (!result.ok) {
721-
incomplete ||= result.retryable;
773+
if (!result.retryable) {
774+
throw new Error(`Unable to load OAuth-scoped project ${projectId}`);
775+
}
776+
incomplete = true;
722777
return;
723778
}
724779

@@ -1277,24 +1332,16 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
12771332

12781333
if (!session.orgProjectsIncomplete) return;
12791334

1280-
const orgIds = Object.keys(session.orgProjectsMap);
1281-
const { map, incomplete } =
1282-
session.scopedProjectIds.length > 0
1283-
? await this.buildScopedProjectMap(
1284-
session.accessToken,
1285-
session.cloudRegion,
1286-
session.scopedProjectIds,
1287-
session.currentOrgId,
1288-
session.orgProjectsMap[session.currentOrgId ?? ""]?.orgName ??
1289-
null,
1290-
session.orgProjectsMap,
1291-
)
1292-
: await this.buildOrgProjectsMap(
1293-
session.accessToken,
1294-
session.cloudRegion,
1295-
orgIds,
1296-
session.orgProjectsMap,
1297-
);
1335+
const { map, incomplete } = await this.buildAuthorizedProjectsMap({
1336+
accessToken: session.accessToken,
1337+
cloudRegion: session.cloudRegion,
1338+
scopedOrgIds: session.scopedOrgIds,
1339+
scopedProjectIds: session.scopedProjectIds,
1340+
currentOrgId: session.currentOrgId,
1341+
currentOrgName:
1342+
session.orgProjectsMap[session.currentOrgId ?? ""]?.orgName ?? null,
1343+
previousMap: session.orgProjectsMap,
1344+
});
12981345

12991346
// The session may have been replaced (logout, re-login) while the fetch
13001347
// was in flight; committing the stale one would resurrect it.

0 commit comments

Comments
 (0)