From 87d6683e984301060f47458b62f489c86e057694 Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Fri, 26 Jun 2026 12:59:19 -0300 Subject: [PATCH 1/2] feat(oauth): honor --project-id on the OAuth login path On the OAuth path the project was taken solely from the consent screen (scoped_teams[0]), silently ignoring --project-id. Now the flag is authoritative when the user granted access to it, and we fail loudly if they authorized a different project instead of capturing into the wrong one. Also forward it to the authorize URL as team_id so the consent screen can pre-select that project. Generated-By: PostHog Code Task-Id: db71c482-9c12-4f75-af93-fa23836280e5 --- src/utils/oauth.ts | 6 ++++++ src/utils/setup-utils.ts | 31 ++++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/utils/oauth.ts b/src/utils/oauth.ts index 90ea8a5e..b896336f 100644 --- a/src/utils/oauth.ts +++ b/src/utils/oauth.ts @@ -66,6 +66,8 @@ export function isAuthorizationTimeout(error: Error): boolean { interface OAuthConfig { scopes: string[]; signup?: boolean; + /** Project to pre-select on the consent screen (the `--project-id` flag). */ + projectId?: number; } function getLocalOAuthOrigin(port: number): string { @@ -365,6 +367,10 @@ export async function performOAuthFlow( authUrl.searchParams.set('code_challenge_method', 'S256'); authUrl.searchParams.set('scope', config.scopes.join(' ')); authUrl.searchParams.set('required_access_level', 'project'); + if (config.projectId !== undefined) { + // Pre-select this project on the consent screen so the user just clicks Authorize. + authUrl.searchParams.set('team_id', String(config.projectId)); + } // UTM-tag both kickoff URLs so the journey into the app is // attributable to the wizard command that started it. diff --git a/src/utils/setup-utils.ts b/src/utils/setup-utils.ts index edfd16f8..9830163f 100644 --- a/src/utils/setup-utils.ts +++ b/src/utils/setup-utils.ts @@ -472,6 +472,7 @@ export async function getOrAskForProjectData( email: _options.email, region: _options.region, programId: _options.programId, + projectId: _options.projectId, }), ); @@ -541,6 +542,9 @@ async function askForWizardLogin(options: { /** Used to pick the right scope set via `getOAuthScopesForProgram`. * Omitted → default `WIZARD_OAUTH_SCOPES`. */ programId?: ProgramId | null; + /** `--project-id`, if passed. When the user granted access to it on the consent + * screen we use it directly; otherwise we fall back to the first granted team. */ + projectId?: number; }): Promise { if (options.signup) { return askForProvisioningSignup(options.email, options.region); @@ -549,9 +553,34 @@ async function askForWizardLogin(options: { const tokenResponse = await performOAuthFlow({ scopes: [...getOAuthScopesForProgram(options.programId)], signup: false, + projectId: options.projectId, }); - const projectId = tokenResponse.scoped_teams?.[0]; + // `--project-id`, when provided, is authoritative — but only if the user actually + // granted access to it on the consent screen. If they authorized a different + // project, fail loudly instead of silently capturing into the wrong one. + const grantedTeams = tokenResponse.scoped_teams; + if ( + options.projectId !== undefined && + grantedTeams?.length && + !grantedTeams.includes(options.projectId) + ) { + const error = new Error( + `You authorized project ${grantedTeams[0]}, but setup is targeting project ${options.projectId}. Re-run and grant access to project ${options.projectId} on the authorization screen.`, + ); + analytics.captureException(error, { + step: 'wizard_login', + requested_project_id: options.projectId, + granted_project_id: grantedTeams[0], + }); + getUI().log.error(error.message); + await abort(); + } + + const projectId = + options.projectId !== undefined && grantedTeams?.includes(options.projectId) + ? options.projectId + : grantedTeams?.[0]; if (projectId === undefined) { const error = new Error( From a6961a3f1379c11a129d09c8cde03608e35e585c Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Fri, 26 Jun 2026 14:19:47 -0300 Subject: [PATCH 2/2] test(oauth): cover project resolution incl. unchanged no-flag default Extract the post-OAuth project selection into a pure resolveGrantedProject helper and unit-test it: no --project-id falls back to scoped_teams[0] (unchanged for the main integration flow and every other program), --project-id is honored when granted, and a different grant is flagged as a mismatch. Generated-By: PostHog Code Task-Id: db71c482-9c12-4f75-af93-fa23836280e5 --- .../__tests__/project-resolution.test.ts | 49 +++++++++++++++++++ src/utils/project-resolution.ts | 34 +++++++++++++ src/utils/setup-utils.ts | 26 +++++----- 3 files changed, 95 insertions(+), 14 deletions(-) create mode 100644 src/utils/__tests__/project-resolution.test.ts create mode 100644 src/utils/project-resolution.ts diff --git a/src/utils/__tests__/project-resolution.test.ts b/src/utils/__tests__/project-resolution.test.ts new file mode 100644 index 00000000..2b99f827 --- /dev/null +++ b/src/utils/__tests__/project-resolution.test.ts @@ -0,0 +1,49 @@ +import { resolveGrantedProject } from '@utils/project-resolution'; + +describe('resolveGrantedProject', () => { + // The main integration flow (and every other program) runs without --project-id; + // this case must stay identical to the pre-flag behavior: use scoped_teams[0]. + it('uses the granted project when no --project-id is passed', () => { + expect(resolveGrantedProject(undefined, [123, 456])).toEqual({ + ok: true, + projectId: 123, + }); + }); + + it('returns no project when nothing is granted and no --project-id is passed', () => { + expect(resolveGrantedProject(undefined, [])).toEqual({ + ok: true, + projectId: undefined, + }); + expect(resolveGrantedProject(undefined, undefined)).toEqual({ + ok: true, + projectId: undefined, + }); + }); + + it('honors --project-id when the user granted access to it', () => { + expect(resolveGrantedProject(456, [123, 456])).toEqual({ + ok: true, + projectId: 456, + }); + }); + + it('flags a mismatch when a different project was authorized', () => { + expect(resolveGrantedProject(456, [123])).toEqual({ + ok: false, + requested: 456, + granted: 123, + }); + }); + + it('defers to the no-access guard when --project-id is passed but nothing was granted', () => { + expect(resolveGrantedProject(456, [])).toEqual({ + ok: true, + projectId: undefined, + }); + expect(resolveGrantedProject(456, undefined)).toEqual({ + ok: true, + projectId: undefined, + }); + }); +}); diff --git a/src/utils/project-resolution.ts b/src/utils/project-resolution.ts new file mode 100644 index 00000000..0d508311 --- /dev/null +++ b/src/utils/project-resolution.ts @@ -0,0 +1,34 @@ +export type GrantedProjectResolution = + | { ok: true; projectId: number | undefined } + | { ok: false; requested: number; granted: number }; + +/** + * Decide which project to use after OAuth. `--project-id` (when passed) is authoritative, + * but only when the user granted access to it on the consent screen; a different grant is a + * mismatch the caller must surface. With no `--project-id` this returns the granted project + * (`scopedTeams[0]`), preserving the pre-flag behavior for every wizard program (the main + * integration flow included). + */ +export function resolveGrantedProject( + requestedProjectId: number | undefined, + scopedTeams: number[] | undefined, +): GrantedProjectResolution { + // No --project-id: use whatever the user granted, exactly as before the flag existed. + if (requestedProjectId === undefined) { + return { ok: true, projectId: scopedTeams?.[0] }; + } + // --project-id was among the granted teams: honor it. + if (scopedTeams?.includes(requestedProjectId)) { + return { ok: true, projectId: requestedProjectId }; + } + // --project-id requested but a different project was authorized: mismatch. + if (scopedTeams && scopedTeams.length > 0) { + return { + ok: false, + requested: requestedProjectId, + granted: scopedTeams[0], + }; + } + // Nothing granted at all — let the caller's "no project access" guard handle it. + return { ok: true, projectId: undefined }; +} diff --git a/src/utils/setup-utils.ts b/src/utils/setup-utils.ts index 9830163f..55a3f7b5 100644 --- a/src/utils/setup-utils.ts +++ b/src/utils/setup-utils.ts @@ -29,6 +29,7 @@ import { detectRegionFromToken, } from './urls'; import { performOAuthFlow } from './oauth'; +import { resolveGrantedProject } from './project-resolution'; import { provisionNewAccount } from './provisioning'; import { fetchUserData, @@ -558,29 +559,26 @@ async function askForWizardLogin(options: { // `--project-id`, when provided, is authoritative — but only if the user actually // granted access to it on the consent screen. If they authorized a different - // project, fail loudly instead of silently capturing into the wrong one. - const grantedTeams = tokenResponse.scoped_teams; - if ( - options.projectId !== undefined && - grantedTeams?.length && - !grantedTeams.includes(options.projectId) - ) { + // project, fail loudly instead of silently capturing into the wrong one. With no + // `--project-id` this falls back to the granted project, unchanged for every program. + const resolution = resolveGrantedProject( + options.projectId, + tokenResponse.scoped_teams, + ); + if (!resolution.ok) { const error = new Error( - `You authorized project ${grantedTeams[0]}, but setup is targeting project ${options.projectId}. Re-run and grant access to project ${options.projectId} on the authorization screen.`, + `You authorized project ${resolution.granted}, but setup is targeting project ${resolution.requested}. Re-run and grant access to project ${resolution.requested} on the authorization screen.`, ); analytics.captureException(error, { step: 'wizard_login', - requested_project_id: options.projectId, - granted_project_id: grantedTeams[0], + requested_project_id: resolution.requested, + granted_project_id: resolution.granted, }); getUI().log.error(error.message); await abort(); } - const projectId = - options.projectId !== undefined && grantedTeams?.includes(options.projectId) - ? options.projectId - : grantedTeams?.[0]; + const projectId = resolution.ok ? resolution.projectId : undefined; if (projectId === undefined) { const error = new Error(