Skip to content

Commit c55f5f2

Browse files
authored
fix(auth): load project-scoped OAuth projects
Generated-By: PostHog Code Task-Id: 07b377f9-4583-463c-b256-cc29621375e6
1 parent 13f7ab6 commit c55f5f2

3 files changed

Lines changed: 212 additions & 30 deletions

File tree

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ function mockTokenResponse(
8181
accessToken?: string;
8282
refreshToken?: string;
8383
scopedOrgs?: string[];
84+
scopedTeams?: number[];
8485
} = {},
8586
) {
8687
return {
@@ -91,6 +92,7 @@ function mockTokenResponse(
9192
expires_in: 3600,
9293
token_type: "Bearer",
9394
scope: "",
95+
scoped_teams: overrides.scopedTeams,
9496
scoped_organizations: overrides.scopedOrgs ?? ["org-1"],
9597
},
9698
};
@@ -1463,6 +1465,63 @@ describe("AuthService", () => {
14631465
expect(service.getState()).toMatchObject(expectedState);
14641466
},
14651467
);
1468+
1469+
it("loads project-scoped OAuth access without calling the organization endpoint", async () => {
1470+
let orgCalls = 0;
1471+
let projectCalls = 0;
1472+
vi.stubGlobal(
1473+
"fetch",
1474+
vi.fn(async (input: string | Request) => {
1475+
const url = typeof input === "string" ? input : input.url;
1476+
if (url.includes("/api/users/@me/")) {
1477+
return {
1478+
ok: true,
1479+
json: vi.fn().mockResolvedValue({
1480+
uuid: "user-1",
1481+
organization: { id: "org-1", name: "Org 1" },
1482+
}),
1483+
} as unknown as Response;
1484+
}
1485+
if (/\/api\/organizations\/[^/]+\/$/.test(url)) {
1486+
orgCalls++;
1487+
return { ok: false, status: 403 } as Response;
1488+
}
1489+
if (url.endsWith("/api/projects/42/")) {
1490+
projectCalls++;
1491+
return {
1492+
ok: true,
1493+
json: vi.fn().mockResolvedValue({
1494+
id: 42,
1495+
name: "Project 42",
1496+
organization: "org-1",
1497+
}),
1498+
} as unknown as Response;
1499+
}
1500+
return {
1501+
ok: true,
1502+
json: vi.fn().mockResolvedValue({ has_access: true }),
1503+
} as unknown as Response;
1504+
}) as unknown as typeof fetch,
1505+
);
1506+
oauthFlow.startFlow.mockResolvedValue(
1507+
mockTokenResponse({ scopedOrgs: [], scopedTeams: [42] }),
1508+
);
1509+
1510+
await service.login("us");
1511+
1512+
expect(orgCalls).toBe(0);
1513+
expect(projectCalls).toBe(1);
1514+
expect(service.getState()).toMatchObject({
1515+
currentOrgId: "org-1",
1516+
currentProjectId: 42,
1517+
orgProjectsMap: {
1518+
"org-1": {
1519+
orgName: "Org 1",
1520+
projects: [{ id: 42, name: "Project 42" }],
1521+
},
1522+
},
1523+
});
1524+
});
14661525
});
14671526

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

packages/core/src/auth/auth.ts

Lines changed: 152 additions & 30 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+
scopedProjectIds: number[];
6061
orgProjectsIncomplete: boolean;
6162
}
6263

@@ -638,27 +639,36 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
638639
options: TokenResponseOptions,
639640
): Promise<InMemorySession> {
640641
const scopedOrgIds = tokenResponse.scoped_organizations ?? [];
641-
const { accountKey, currentOrgId } = await this.fetchUserContext(
642-
tokenResponse.access_token,
643-
options.cloudRegion,
644-
);
645-
// Team-scoped tokens (required_access_level=project) can arrive with an
646-
// empty scoped_organizations list — the server only populates scoped_teams.
647-
// Fall back to the current org from /api/users/@me/ so the picker isn't
648-
// empty; without this the user is stranded on "No projects".
649-
const orgIdsToFetch =
650-
scopedOrgIds.length > 0
651-
? scopedOrgIds
652-
: currentOrgId
653-
? [currentOrgId]
654-
: [];
655-
const { map: orgProjectsMap, incomplete: orgProjectsIncomplete } =
656-
await this.buildOrgProjectsMap(
642+
const scopedProjectIds = tokenResponse.scoped_teams ?? [];
643+
const { accountKey, currentOrgId, currentOrgName } =
644+
await this.fetchUserContext(
657645
tokenResponse.access_token,
658646
options.cloudRegion,
659-
orgIdsToFetch,
660-
this.session?.orgProjectsMap ?? {},
661647
);
648+
const previousMap = this.session?.orgProjectsMap ?? {};
649+
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+
);
662672
const lastPrefs = accountKey
663673
? this.authPreference.get(accountKey, options.cloudRegion)
664674
: null;
@@ -670,20 +680,110 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
670680
lastSelectedOrgId: lastPrefs?.lastSelectedOrgId ?? null,
671681
});
672682

683+
const resolvedCurrentOrgId =
684+
currentOrgId && orgProjectsMap[currentOrgId]
685+
? currentOrgId
686+
: (Object.keys(orgProjectsMap)[0] ?? currentOrgId);
673687
const session: InMemorySession = {
674688
accountKey,
675689
accessToken: tokenResponse.access_token,
676690
accessTokenExpiresAt: Date.now() + tokenResponse.expires_in * 1000,
677691
refreshToken: tokenResponse.refresh_token,
678692
cloudRegion: options.cloudRegion,
679693
orgProjectsMap,
680-
currentOrgId,
694+
currentOrgId: resolvedCurrentOrgId,
681695
currentProjectId,
696+
scopedProjectIds,
682697
orgProjectsIncomplete,
683698
};
684699

685700
return session;
686701
}
702+
private async buildScopedProjectMap(
703+
accessToken: string,
704+
cloudRegion: CloudRegion,
705+
projectIds: number[],
706+
currentOrgId: string | null,
707+
currentOrgName: string | null,
708+
previousMap: OrgProjectsMap,
709+
): Promise<{ map: OrgProjectsMap; incomplete: boolean }> {
710+
let incomplete = false;
711+
const map: OrgProjectsMap = {};
712+
713+
await Promise.all(
714+
projectIds.map(async (projectId) => {
715+
const result = await this.fetchScopedProject(
716+
accessToken,
717+
cloudRegion,
718+
projectId,
719+
);
720+
if (!result.ok) {
721+
incomplete ||= result.retryable;
722+
return;
723+
}
724+
725+
const { orgId, project } = result.data;
726+
const existing = map[orgId];
727+
map[orgId] = {
728+
orgName:
729+
existing?.orgName ??
730+
(orgId === currentOrgId ? currentOrgName : null) ??
731+
previousMap[orgId]?.orgName ??
732+
"(unknown)",
733+
projects: [...(existing?.projects ?? []), project],
734+
};
735+
}),
736+
);
737+
738+
return { map, incomplete };
739+
}
740+
private async fetchScopedProject(
741+
accessToken: string,
742+
cloudRegion: CloudRegion,
743+
projectId: number,
744+
): Promise<
745+
| {
746+
ok: true;
747+
data: { orgId: string; project: { id: number; name: string } };
748+
}
749+
| { ok: false; retryable: boolean }
750+
> {
751+
try {
752+
const response = await this.executeAuthenticatedFetch(
753+
fetch,
754+
`${getCloudUrlFromRegion(cloudRegion)}/api/projects/${projectId}/`,
755+
{},
756+
accessToken,
757+
);
758+
if (!response.ok) {
759+
return { ok: false, retryable: response.status >= 500 };
760+
}
761+
const raw = (await response.json().catch(() => null)) as {
762+
id?: unknown;
763+
name?: unknown;
764+
organization?: unknown;
765+
} | null;
766+
const responseProjectId =
767+
typeof raw?.id === "number" ? raw.id : projectId;
768+
const name = typeof raw?.name === "string" ? raw.name : null;
769+
const orgId =
770+
typeof raw?.organization === "string"
771+
? raw.organization
772+
: typeof raw?.organization === "object" && raw.organization !== null
773+
? (raw.organization as { id?: unknown }).id
774+
: null;
775+
if (typeof orgId !== "string" || !name) {
776+
return { ok: false, retryable: false };
777+
}
778+
return {
779+
ok: true,
780+
data: { orgId, project: { id: responseProjectId, name } },
781+
};
782+
} catch (error) {
783+
this.logger.warn("Failed to fetch scoped project", { projectId, error });
784+
return { ok: false, retryable: true };
785+
}
786+
}
687787
private async buildOrgProjectsMap(
688788
accessToken: string,
689789
cloudRegion: CloudRegion,
@@ -896,7 +996,11 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
896996
private async fetchUserContext(
897997
accessToken: string,
898998
cloudRegion: CloudRegion,
899-
): Promise<{ accountKey: string | null; currentOrgId: string | null }> {
999+
): Promise<{
1000+
accountKey: string | null;
1001+
currentOrgId: string | null;
1002+
currentOrgName: string | null;
1003+
}> {
9001004
try {
9011005
const response = await this.executeAuthenticatedFetch(
9021006
fetch,
@@ -906,14 +1010,18 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
9061010
);
9071011

9081012
if (!response.ok) {
909-
return { accountKey: null, currentOrgId: null };
1013+
return {
1014+
accountKey: null,
1015+
currentOrgId: null,
1016+
currentOrgName: null,
1017+
};
9101018
}
9111019

9121020
const data = (await response.json().catch(() => ({}))) as {
9131021
uuid?: unknown;
9141022
distinct_id?: unknown;
9151023
email?: unknown;
916-
organization?: { id?: unknown } | null;
1024+
organization?: { id?: unknown; name?: unknown } | null;
9171025
};
9181026

9191027
let accountKey: string | null = null;
@@ -931,11 +1039,14 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
9311039
const orgId = data.organization?.id;
9321040
const currentOrgId =
9331041
typeof orgId === "string" && orgId.length > 0 ? orgId : null;
1042+
const orgName = data.organization?.name;
1043+
const currentOrgName =
1044+
typeof orgName === "string" && orgName.length > 0 ? orgName : null;
9341045

935-
return { accountKey, currentOrgId };
1046+
return { accountKey, currentOrgId, currentOrgName };
9361047
} catch (error) {
9371048
this.logger.warn("Failed to resolve user context", { error });
938-
return { accountKey: null, currentOrgId: null };
1049+
return { accountKey: null, currentOrgId: null, currentOrgName: null };
9391050
}
9401051
}
9411052
private requireSession(): InMemorySession {
@@ -1167,12 +1278,23 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
11671278
if (!session.orgProjectsIncomplete) return;
11681279

11691280
const orgIds = Object.keys(session.orgProjectsMap);
1170-
const { map, incomplete } = await this.buildOrgProjectsMap(
1171-
session.accessToken,
1172-
session.cloudRegion,
1173-
orgIds,
1174-
session.orgProjectsMap,
1175-
);
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+
);
11761298

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

packages/core/src/auth/oauth.schemas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export const oAuthTokenResponse = z.object({
2424
token_type: z.string(),
2525
scope: z.string().optional().default(""),
2626
refresh_token: z.string(),
27+
scoped_teams: z.array(z.number()).optional(),
2728
scoped_organizations: z.array(z.string()).optional(),
2829
});
2930
export type OAuthTokenResponse = z.infer<typeof oAuthTokenResponse>;

0 commit comments

Comments
 (0)