Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions src/utils/__tests__/project-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
6 changes: 6 additions & 0 deletions src/utils/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
34 changes: 34 additions & 0 deletions src/utils/project-resolution.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
29 changes: 28 additions & 1 deletion src/utils/setup-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
detectRegionFromToken,
} from './urls';
import { performOAuthFlow } from './oauth';
import { resolveGrantedProject } from './project-resolution';
import { provisionNewAccount } from './provisioning';
import {
fetchUserData,
Expand Down Expand Up @@ -472,6 +473,7 @@ export async function getOrAskForProjectData(
email: _options.email,
region: _options.region,
programId: _options.programId,
projectId: _options.projectId,
}),
);

Expand Down Expand Up @@ -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<ProjectData & { cloudRegion: CloudRegion }> {
if (options.signup) {
return askForProvisioningSignup(options.email, options.region);
Expand All @@ -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(
Expand Down
Loading