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
22 changes: 22 additions & 0 deletions packages/cli/src/lib/oauth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";

import { buildAuthorizeUrl } from "./oauth";

function getRequestedScopes(): string[] {
const url = new URL(
buildAuthorizeUrl({
redirectUri: "http://127.0.0.1:1234/callback",
state: "state",
codeChallenge: "challenge",
}),
);
return url.searchParams.get("scope")?.split(" ") ?? [];
}

describe("buildAuthorizeUrl", () => {
// Every `/accounts/{accountSlug}/...` endpoint requires it: without it,
// `argos account *` answers 403 with no way to grant it from the CLI.
it("requests the scope the `account` commands need", () => {
expect(getRequestedScopes()).toContain("account:admin");
});
});
21 changes: 20 additions & 1 deletion packages/cli/src/lib/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ export const OAUTH_CLIENT_ID = "argos-cli";

/**
* Scopes requested by `argos login`, covering the CLI's user actions (identity,
* projects, reviews, comments, media). Build upload keeps using a project token.
* projects, reviews, comments, media, team administration). Build upload keeps
* using a project token.
*
* Every scope a CLI command needs has to be listed here: the consent screen
* only offers what the client asks for, so a missing entry is not something the
Expand All @@ -30,8 +31,26 @@ export const OAUTH_SCOPES = [
"comments:write",
"media:read",
"media:write",
"account:admin",
] as const;

export type OAuthScope = (typeof OAUTH_SCOPES)[number];

/**
* Requested scopes that the consent screen grants only with "Admin" access
* (`level: "admin"` in the Argos backend's `apps/backend/src/oauth/scopes.ts`).
* The default, "Read and write", leaves them out, so a session can lack them
* even when it was granted after the CLI started asking for them.
*/
export const OAUTH_ADMIN_SCOPES: ReadonlySet<OAuthScope> = new Set([
"projects:write",
"account:admin",
]);

export function isRequestedOAuthScope(value: string): value is OAuthScope {
return (OAUTH_SCOPES as readonly string[]).includes(value);
}

/** Refresh a bit early so a token never expires mid-request. */
const EXPIRY_SKEW_MS = 60 * 1000;

Expand Down
69 changes: 68 additions & 1 deletion packages/cli/src/lib/run.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { APIError } from "@argos-ci/api-client";
import { afterEach, describe, expect, it, vi } from "vitest";

import { handleCliError } from "./run";
import { getScopeHint, handleCliError } from "./run";

/**
* `handleCliError` ends the process, so both effects have to be intercepted:
Expand Down Expand Up @@ -38,6 +38,19 @@ describe("handleCliError", () => {
expect(message).not.toContain("personal access token");
});

it("names the missing scope and the access level that grants it", () => {
const message = runAndCapture(
new APIError(
"Insufficient scope. This endpoint requires: account:admin.",
{ status: 403 },
),
"user",
);
expect(message).toContain("does not include `account:admin`");
expect(message).toContain('choose "Admin" access');
expect(message).not.toContain("personal access token");
});

it("keeps the token-type hint for other permission failures", () => {
const message = runAndCapture(
new APIError("Forbidden.", { status: 403 }),
Expand All @@ -52,3 +65,57 @@ describe("handleCliError", () => {
expect(message).toBe("Error: Boom.");
});
});

describe("getScopeHint", () => {
it("asks for a new login when a write scope is missing", () => {
const hint = getScopeHint(
"Insufficient scope. This endpoint requires: media:write.",
);
expect(hint).toBe(
"Your `argos login` session does not include `media:write`. Run `argos login` again to grant it.",
);
});

it.each(["projects:write", "account:admin"])(
"points at the Admin access level for %s",
(scope) => {
const hint = getScopeHint(
`Insufficient scope. This endpoint requires: ${scope}.`,
);
expect(hint).toContain(`does not include \`${scope}\``);
expect(hint).toContain('choose "Admin" access on the consent screen');
},
);

it("points at the Admin access level when any required scope is an admin one", () => {
const hint = getScopeHint(
"Insufficient scope. This endpoint requires: projects:read, account:admin.",
);
expect(hint).toContain("`projects:read`, `account:admin`");
expect(hint).toContain('choose "Admin" access');
});

it("suggests a personal access token for a scope the CLI never requests", () => {
const hint = getScopeHint(
"Insufficient scope. This endpoint requires: builds:write.",
);
expect(hint).toBe(
"`argos login` cannot grant `builds:write`. Use a personal access token instead (`--token` or `ARGOS_TOKEN`).",
);
});

it("falls back to a generic hint when the scope is not named", () => {
expect(getScopeHint("Insufficient scope.")).toBe(
"Run `argos login` again to grant the permission this command needs.",
);
});

it("never echoes text that is not shaped like a scope", () => {
const hint = getScopeHint(
"Insufficient scope. This endpoint requires: \u001b[2Jevil, media:write.",
);
expect(hint).not.toContain("\u001b");
expect(hint).not.toContain("evil");
expect(hint).toContain("`media:write`");
});
});
61 changes: 56 additions & 5 deletions packages/cli/src/lib/run.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { APIError } from "@argos-ci/api-client";

import { CliError } from "./cli-error";
import { isRequestedOAuthScope, OAUTH_ADMIN_SCOPES } from "./oauth";
import {
resolveBuildTarget,
type AuthMode,
Expand All @@ -14,10 +15,6 @@ const USER_AUTH_HINT =
"Ensure your token is a personal access token with access to this project. " +
"Project tokens (ARGOS_TOKEN in CI) can read build data but cannot perform review or comment actions.";

const SCOPE_HINT =
"Your `argos login` session was granted before the CLI asked for this permission. " +
"Run `argos login` again to grant it.";

/**
* A scope rejection, as opposed to any other 403: the token is valid and the
* user is allowed, it was simply issued without the permission this endpoint
Expand All @@ -28,6 +25,60 @@ function isInsufficientScope(error: APIError): boolean {
return error.status === 403 && /insufficient scope/i.test(error.message);
}

/**
* The scopes named by a scope rejection ("Insufficient scope. This endpoint
* requires: account:admin."). Anything not shaped like a scope is dropped, so
* the hint never echoes arbitrary server text.
*/
function parseRequiredScopes(message: string): string[] {
const match = /requires:\s*([^.]*)/i.exec(message);
if (!match?.[1]) {
return [];
}
return match[1]
.split(",")
.map((scope) => scope.trim())
.filter((scope) => /^[a-z]+(?::[a-z]+)?$/.test(scope));
}

function formatScopes(scopes: readonly string[]): string {
return scopes.map((scope) => `\`${scope}\``).join(", ");
}

/**
* How to get the scopes an `argos login` session is missing. A session lacks a
* scope for one of three reasons, each with its own fix: the CLI never asks for
* it (only a personal access token helps), it is an admin scope left out by the
* consent screen's default access level, or the session was granted before the
* CLI asked for it.
*/
export function getScopeHint(message: string): string {
const required = parseRequiredScopes(message);
if (required.length === 0) {
return "Run `argos login` again to grant the permission this command needs.";
}
const ungrantable = required.filter((scope) => !isRequestedOAuthScope(scope));
if (ungrantable.length > 0) {
return (
`\`argos login\` cannot grant ${formatScopes(ungrantable)}. ` +
"Use a personal access token instead (`--token` or `ARGOS_TOKEN`)."
);
}
const needsAdmin = required.some(
(scope) => isRequestedOAuthScope(scope) && OAUTH_ADMIN_SCOPES.has(scope),
);
if (needsAdmin) {
return (
`Your \`argos login\` session does not include ${formatScopes(required)}. ` +
'Run `argos login` again and choose "Admin" access on the consent screen to grant it.'
);
}
return (
`Your \`argos login\` session does not include ${formatScopes(required)}. ` +
"Run `argos login` again to grant it."
);
}

/**
* Print an error and exit. {@link CliError} and {@link APIError} messages are
* shown as-is; permission failures get an extra hint — how to grant a missing
Expand All @@ -40,7 +91,7 @@ export function handleCliError(error: unknown, auth?: AuthMode): never {
if (error instanceof APIError && isInsufficientScope(error)) {
// A missing scope is never fixed by switching token type, so this hint
// replaces the one about tokens rather than piling on next to it.
message += `\n${SCOPE_HINT}`;
message += `\n${getScopeHint(error.message)}`;
} else if (
auth === "user" &&
error instanceof APIError &&
Expand Down
8 changes: 8 additions & 0 deletions skills/argos-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@ Two token types — pick by command:
`baseline download` takes only a project token: the baseline lookup it relies on
rejects a PAT, including the one from `argos login`.

`argos login` asks the user which access to grant. Its default, "Read and
write", covers reviews, comments and media but not administration: `account *`,
`create-project`, `project update`, `project transfer`, `project contributor
set|remove`, `project domain set` and `automation create|update|deactivate`
need "Admin" access. An `Insufficient scope` error names the missing
permission: ask the user to run `argos login` again and choose "Admin", or use
a PAT.

Project tokens read build data but **cannot** review, comment, ignore changes, or
administer a project or team — those need a PAT. If no suitable token is
available, ask the user. For these actions, if no PAT exists, report the
Expand Down
Loading