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/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/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 edfd16f8..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, @@ -472,6 +473,7 @@ export async function getOrAskForProjectData( email: _options.email, region: _options.region, programId: _options.programId, + projectId: _options.projectId, }), ); @@ -541,6 +543,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 +554,31 @@ 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. 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 ${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: resolution.requested, + granted_project_id: resolution.granted, + }); + getUI().log.error(error.message); + await abort(); + } + + const projectId = resolution.ok ? resolution.projectId : undefined; if (projectId === undefined) { const error = new Error(