Skip to content

Commit d768af3

Browse files
committed
feat(auth): add --scope/-s and --read-only flags to login and refresh
Add explicit OAuth scope selection following `gh` CLI conventions: - `auth login --scope/-s`: variadic, comma-separated scope flag - `auth login --read-only`: sugar for the read-only subset - `auth refresh --scope/-s` and `--read-only`: re-run device flow with new scopes (like `gh auth refresh -s`), bypassing the 'already authenticated' gate - 403 enrichment: when specific missing scopes are detected, suggest the exact `sentry auth refresh --scope X` command - Interactive 403 recovery middleware: in TTYs, prompt to re-authenticate with missing scopes (merged with default set) and retry the command Scopes are validated against the canonical SENTRY_SCOPES set from getsentry/sentry. The plumbing passes a resolved scope string (not a boolean) through performDeviceFlow, avoiding positional-boolean creep. Refs #1031.
1 parent aa1ef81 commit d768af3

14 files changed

Lines changed: 939 additions & 57 deletions

File tree

.lore.md

Lines changed: 60 additions & 40 deletions
Large diffs are not rendered by default.

plugins/sentry-cli/skills/sentry-cli/references/auth.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ Authenticate with Sentry
2020
- `--timeout <value> - Timeout for OAuth flow in seconds (default: 900) - (default: "900")`
2121
- `--force - Re-authenticate without prompting`
2222
- `--url <value> - Sentry instance URL to authenticate against (e.g. https://sentry.example.com). Required for self-hosted; defaults to SaaS (https://sentry.io).`
23+
- `--read-only - Request only read-only OAuth scopes (project:read, org:read, event:read, member:read, team:read). Useful for handing tokens to AI agents or CI jobs that should not be able to mutate Sentry state.`
24+
- `-s, --scope <value>... - Request specific OAuth scopes (repeatable, comma-separated). E.g. --scope project:read --scope org:read. Overrides the default scope set.`
2325

2426
**Examples:**
2527

@@ -49,6 +51,8 @@ Refresh your authentication token
4951

5052
**Flags:**
5153
- `--force - Force refresh even if token is still valid`
54+
- `--read-only - Re-authenticate with read-only OAuth scopes (project:read, org:read, event:read, member:read, team:read)`
55+
- `-s, --scope <value>... - Re-authenticate with specific OAuth scopes (repeatable, comma-separated). E.g. --scope project:read --scope org:read`
5256

5357
**Examples:**
5458

src/cli.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,93 @@ export async function runCli(cliArgs: string[]): Promise<void> {
357357
}
358358
};
359359

360+
/**
361+
* Check whether a caught error is a recoverable 403 missing-scope error.
362+
*
363+
* Returns the extracted scope names when all conditions are met:
364+
* - Interactive TTY (stdin)
365+
* - Error is an `ApiError` with status 403
366+
* - Token is an OAuth token (not env-var — those can't be re-scoped via CLI)
367+
* - The 403 detail mentions specific missing scopes
368+
*
369+
* Returns `null` when recovery is not possible, signaling the caller to
370+
* re-throw.
371+
*/
372+
async function extractRecoverableScopes(
373+
err: unknown
374+
): Promise<string[] | null> {
375+
if (!isatty(0)) {
376+
return null;
377+
}
378+
const { ApiError } = await import("./lib/errors.js");
379+
if (!(err instanceof ApiError) || err.status !== 403) {
380+
return null;
381+
}
382+
const { isEnvTokenActive } = await import("./lib/db/auth.js");
383+
if (isEnvTokenActive()) {
384+
return null;
385+
}
386+
const { extractRequiredScopes } = await import("./lib/api-scope.js");
387+
const scopes = extractRequiredScopes(err.detail);
388+
return scopes.length > 0 ? scopes : null;
389+
}
390+
391+
/**
392+
* Scope recovery middleware.
393+
*
394+
* Catches 403 Forbidden errors for OAuth tokens (not env-var tokens) in
395+
* interactive TTYs. When specific missing scopes are detected in the API
396+
* response, offers to re-authenticate with those scopes and retries the
397+
* command — mirroring `gh auth refresh -s <scope>`.
398+
*
399+
* Env-var tokens are excluded: the user must regenerate those manually
400+
* via the Sentry web UI (the 403 enrichment already directs them there).
401+
*/
402+
const scopeRecoveryMiddleware: ErrorMiddleware = async (next, argv) => {
403+
try {
404+
await next(argv);
405+
} catch (err) {
406+
const scopes = await extractRecoverableScopes(err);
407+
if (!scopes) {
408+
throw err;
409+
}
410+
411+
const scopeList = scopes.map((s) => `'${s}'`).join(", ");
412+
const { logger: logModule } = await import("./lib/logger.js");
413+
const confirmed = await logModule
414+
.withTag("auth")
415+
.prompt(
416+
`Missing scope(s): ${scopeList}. Re-authenticate with default scopes?`,
417+
{ type: "confirm", initial: true }
418+
);
419+
420+
// Symbol(clack:cancel) is truthy — strict equality check
421+
if (confirmed !== true) {
422+
throw err;
423+
}
424+
425+
process.stderr.write("\n");
426+
// Merge missing scopes with the default set so the new token retains
427+
// all previously-held scopes plus the ones the API requested.
428+
const { OAUTH_SCOPES, resolveOAuthScopeString } = await import(
429+
"./lib/oauth.js"
430+
);
431+
const merged = [...new Set([...OAUTH_SCOPES, ...scopes])];
432+
const scope = resolveOAuthScopeString({ scopes: merged });
433+
const loginSuccess = await runInteractiveLogin({ scope });
434+
435+
if (loginSuccess) {
436+
process.stderr.write("\nRetrying command...\n\n");
437+
await next(argv);
438+
return;
439+
}
440+
441+
// Login failed or was cancelled — re-throw so the user sees the
442+
// original 403 message with the scope hint.
443+
throw err;
444+
}
445+
};
446+
360447
/**
361448
* Auto-authentication middleware.
362449
*
@@ -409,6 +496,7 @@ export async function runCli(cliArgs: string[]): Promise<void> {
409496
const errorMiddlewares: ErrorMiddleware[] = [
410497
seerTrialMiddleware,
411498
rcImportMiddleware,
499+
scopeRecoveryMiddleware,
412500
autoAuthMiddleware,
413501
];
414502

src/commands/auth/login.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
toLoginUser,
3737
} from "../../lib/interactive-login.js";
3838
import { logger } from "../../lib/logger.js";
39+
import { resolveOAuthScopeString } from "../../lib/oauth.js";
3940
import { clearResponseCache } from "../../lib/response-cache.js";
4041
import {
4142
isSaaSTrustOrigin,
@@ -77,8 +78,61 @@ type LoginFlags = {
7778
readonly timeout: number;
7879
readonly force: boolean;
7980
readonly url?: string;
81+
readonly "read-only": boolean;
82+
readonly scope?: readonly string[];
8083
};
8184

85+
/**
86+
* Resolve the OAuth scope string from the login flags, enforcing mutual
87+
* exclusivity between `--token`, `--read-only`, and `--scope`.
88+
*
89+
* OAuth scope is fixed when a token is issued, so `--read-only` / `--scope`
90+
* cannot narrow an existing `--token`. `--read-only` and `--scope` are also
91+
* mutually exclusive — `--read-only` is a shorthand for a specific subset.
92+
*
93+
* @returns The resolved scope string for the device flow, or `undefined` to
94+
* use the device flow's full default scope set.
95+
* @throws {ValidationError} on any conflicting flag combination or invalid
96+
* scope value.
97+
*/
98+
function resolveLoginScope(flags: LoginFlags): string | undefined {
99+
const hasScope = flags.scope !== undefined && flags.scope.length > 0;
100+
const readOnly = flags["read-only"];
101+
102+
if (flags.token && (readOnly || hasScope)) {
103+
const conflicting = readOnly ? "--read-only" : "--scope";
104+
throw new ValidationError(
105+
[
106+
`${conflicting} cannot be used with --token — OAuth scope is fixed when the token is issued, so the CLI cannot narrow an existing token's permissions.`,
107+
"",
108+
"To restrict OAuth scopes, drop --token and use the device flow:",
109+
" sentry auth login --read-only",
110+
" sentry auth login --scope project:read --scope org:read",
111+
"",
112+
"Or create a scoped User Auth Token in Sentry (Account → Auth Tokens) and pass it without --read-only/--scope.",
113+
].join("\n"),
114+
readOnly ? "read-only" : "scope"
115+
);
116+
}
117+
118+
if (readOnly && hasScope) {
119+
throw new ValidationError(
120+
"--read-only and --scope cannot be used together. Use --read-only for the read-only subset, or --scope to list exact scopes.",
121+
"scope"
122+
);
123+
}
124+
125+
if (hasScope) {
126+
// Flatten comma-separated values so `-s a,b` and `-s a -s b` both work.
127+
const scopes = (flags.scope ?? []).flatMap((value) => value.split(","));
128+
return resolveOAuthScopeString({ scopes });
129+
}
130+
if (readOnly) {
131+
return resolveOAuthScopeString({ readOnly: true });
132+
}
133+
return;
134+
}
135+
82136
/**
83137
* Normalize and validate the `--url` flag value. Accepts bare hostnames
84138
* and full URLs; returns the normalized origin.
@@ -356,10 +410,32 @@ export const loginCommand = buildCommand({
356410
"Required for self-hosted; defaults to SaaS (https://sentry.io).",
357411
optional: true,
358412
},
413+
"read-only": {
414+
kind: "boolean",
415+
brief:
416+
"Request only read-only OAuth scopes (project:read, org:read, event:read, member:read, team:read). " +
417+
"Useful for handing tokens to AI agents or CI jobs that should not be able to mutate Sentry state.",
418+
default: false,
419+
},
420+
scope: {
421+
kind: "parsed",
422+
parse: String,
423+
brief:
424+
"Request specific OAuth scopes (repeatable, comma-separated). " +
425+
"E.g. --scope project:read --scope org:read. Overrides the default scope set.",
426+
variadic: true,
427+
optional: true,
428+
},
359429
},
430+
aliases: { s: "scope" },
360431
},
361432
output: { human: formatLoginResult },
362433
async *func(this: SentryContext, flags: LoginFlags) {
434+
// Resolve OAuth scopes up front so conflicting flag combinations
435+
// (--token + --read-only/--scope, --read-only + --scope) and invalid
436+
// scope values fail fast before any network or DB work.
437+
const oauthScope = resolveLoginScope(flags);
438+
363439
// Apply --url first so the device flow / token refresh target the
364440
// requested instance. Default URL persistence is deferred until login
365441
// succeeds — see persistLoginUrlAsDefault calls below.
@@ -435,6 +511,7 @@ export const loginCommand = buildCommand({
435511
// OAuth device flow (host scope recorded via completeOAuthFlow → setAuthToken)
436512
const result = await runInteractiveLogin({
437513
timeout: flags.timeout * 1000,
514+
scope: oauthScope,
438515
});
439516

440517
if (result) {

src/commands/auth/refresh.ts

Lines changed: 91 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
/**
22
* sentry auth refresh
33
*
4-
* Manually refresh the authentication token.
4+
* Manually refresh the authentication token, or re-authenticate with
5+
* different scopes via the OAuth device flow (like `gh auth refresh -s`).
6+
*
7+
* When `--scope` or `--read-only` is passed, the command runs a new device
8+
* flow with the requested scopes instead of refreshing the existing token.
9+
* This bypasses the "already authenticated" gate in `auth login`, making
10+
* scope changes frictionless.
511
*/
612

713
import type { SentryContext } from "../../context.js";
@@ -12,14 +18,18 @@ import {
1218
getAuthConfig,
1319
refreshToken,
1420
} from "../../lib/db/auth.js";
15-
import { AuthError } from "../../lib/errors.js";
21+
import { AuthError, ValidationError } from "../../lib/errors.js";
1622
import { success } from "../../lib/formatters/colors.js";
1723
import { formatDuration } from "../../lib/formatters/human.js";
1824
import { CommandOutput } from "../../lib/formatters/output.js";
25+
import { runInteractiveLogin } from "../../lib/interactive-login.js";
26+
import { resolveOAuthScopeString } from "../../lib/oauth.js";
1927

2028
type RefreshFlags = {
2129
readonly json: boolean;
2230
readonly force: boolean;
31+
readonly "read-only": boolean;
32+
readonly scope?: readonly string[];
2333
readonly fields?: string[];
2434
};
2535

@@ -31,10 +41,37 @@ type RefreshOutput = {
3141
expiresAt?: string;
3242
};
3343

44+
/** Whether the user asked for a scope change (--scope or --read-only). */
45+
function hasScopeRequest(flags: RefreshFlags): boolean {
46+
return (
47+
flags["read-only"] || (flags.scope !== undefined && flags.scope.length > 0)
48+
);
49+
}
50+
51+
/**
52+
* Resolve the OAuth scope string from the refresh flags.
53+
*
54+
* @throws {ValidationError} when `--read-only` and `--scope` are both set,
55+
* or when `--scope` contains an invalid value.
56+
*/
57+
function resolveRefreshScope(flags: RefreshFlags): string {
58+
if (flags["read-only"] && flags.scope?.length) {
59+
throw new ValidationError(
60+
"--read-only and --scope cannot be used together. Use --read-only for the read-only subset, or --scope to list exact scopes.",
61+
"scope"
62+
);
63+
}
64+
if (flags.scope?.length) {
65+
const scopes = [...flags.scope].flatMap((v) => v.split(","));
66+
return resolveOAuthScopeString({ scopes });
67+
}
68+
return resolveOAuthScopeString({ readOnly: true });
69+
}
70+
3471
/** Format refresh result for terminal output */
3572
function formatRefreshResult(data: RefreshOutput): string {
3673
return data.refreshed
37-
? `${success("✓")} Token refreshed successfully. Expires in ${formatDuration(data.expiresIn ?? 0)}.`
74+
? `${success("✓")} ${data.message}. Expires in ${formatDuration(data.expiresIn ?? 0)}.`
3875
: `Token still valid (expires in ${formatDuration(data.expiresIn ?? 0)}).\nUse --force to refresh anyway.`;
3976
}
4077

@@ -49,13 +86,24 @@ Token refresh normally happens automatically when making API requests.
4986
Use this command to force an immediate refresh or to verify the refresh
5087
mechanism is working correctly.
5188
89+
When --scope or --read-only is passed, re-authenticates via the OAuth
90+
device flow with the requested scopes instead of refreshing the existing
91+
token. This is the preferred way to add or change scopes on an existing
92+
session (similar to \`gh auth refresh -s <scope>\`).
93+
5294
Examples:
5395
$ sentry auth refresh
5496
Token refreshed successfully. Expires in 59 minutes.
5597
5698
$ sentry auth refresh --force
5799
Token refreshed successfully. Expires in 60 minutes.
58100
101+
$ sentry auth refresh --scope event:read --scope org:read
102+
Re-authenticating with scopes: event:read, org:read...
103+
104+
$ sentry auth refresh --read-only
105+
Re-authenticating with read-only scopes...
106+
59107
$ sentry auth refresh --json
60108
{"success":true,"refreshed":true,"expiresIn":3600,"expiresAt":"..."}
61109
`.trim(),
@@ -68,10 +116,26 @@ Examples:
68116
brief: "Force refresh even if token is still valid",
69117
default: false,
70118
},
119+
"read-only": {
120+
kind: "boolean",
121+
brief:
122+
"Re-authenticate with read-only OAuth scopes (project:read, org:read, event:read, member:read, team:read)",
123+
default: false,
124+
},
125+
scope: {
126+
kind: "parsed",
127+
parse: String,
128+
brief:
129+
"Re-authenticate with specific OAuth scopes (repeatable, comma-separated). " +
130+
"E.g. --scope project:read --scope org:read",
131+
variadic: true,
132+
optional: true,
133+
},
71134
},
135+
aliases: { s: "scope" },
72136
},
73137
async *func(this: SentryContext, flags: RefreshFlags) {
74-
// Env var tokens can't be refreshed — only block if env is the effective source
138+
// Env var tokens can't be refreshed or re-scoped via the CLI.
75139
const currentAuth = getAuthConfig();
76140
if (currentAuth?.source.startsWith(ENV_SOURCE_PREFIX)) {
77141
const envVar = getActiveEnvVarName();
@@ -83,7 +147,29 @@ Examples:
83147
);
84148
}
85149

86-
// Pre-check for refresh token availability (better error message)
150+
// --scope / --read-only: re-run the device flow with new scopes.
151+
if (hasScopeRequest(flags)) {
152+
const scope = resolveRefreshScope(flags);
153+
154+
const result = await runInteractiveLogin({ scope });
155+
if (!result) {
156+
process.exitCode = 1;
157+
return;
158+
}
159+
160+
const payload: RefreshOutput = {
161+
success: true,
162+
refreshed: true,
163+
message: "Re-authenticated with updated scopes",
164+
expiresIn: result.expiresIn,
165+
expiresAt: result.expiresIn
166+
? new Date(Date.now() + result.expiresIn * 1000).toISOString()
167+
: undefined,
168+
};
169+
return yield new CommandOutput(payload);
170+
}
171+
172+
// Standard token refresh path (no scope change).
87173
const auth = await getAuthConfig();
88174
if (!auth?.refreshToken && auth?.token) {
89175
throw new AuthError(

0 commit comments

Comments
 (0)