Skip to content
Open
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
1 change: 1 addition & 0 deletions plugins/sentry-cli/skills/sentry-cli/references/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Authenticate with Sentry
- `--timeout <value> - Timeout for OAuth flow in seconds (default: 900) - (default: "900")`
- `--force - Re-authenticate without prompting`
- `--url <value> - Sentry instance URL to authenticate against (e.g. https://sentry.example.com). Required for self-hosted; defaults to SaaS (https://sentry.io).`
- `--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.`

**Examples:**

Expand Down
26 changes: 26 additions & 0 deletions src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ type LoginFlags = {
readonly timeout: number;
readonly force: boolean;
readonly url?: string;
readonly "read-only": boolean;
};

/**
Expand Down Expand Up @@ -356,10 +357,34 @@ export const loginCommand = buildCommand({
"Required for self-hosted; defaults to SaaS (https://sentry.io).",
optional: true,
},
"read-only": {
Comment thread
sentry[bot] marked this conversation as resolved.
kind: "boolean",
brief:
"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.",
default: false,
},
},
},
output: { human: formatLoginResult },
async *func(this: SentryContext, flags: LoginFlags) {
// --read-only only applies to the OAuth device flow — OAuth scope is fixed
// when the token is issued, so the CLI cannot narrow an existing token's
// permissions. Refuse the combination rather than silently dropping the flag.
if (flags.token && flags["read-only"]) {
throw new ValidationError(
[
"--read-only cannot be used with --token — OAuth scope is fixed when the token is issued, so the CLI cannot narrow an existing token's permissions.",
"",
"To use OAuth read-only:",
" sentry auth login --read-only",
"",
"Or create a read-only User Auth Token in Sentry (Account → Auth Tokens) and pass it without --read-only.",
].join("\n"),
"read-only"
);
}

// Apply --url first so the device flow / token refresh target the
// requested instance. Default URL persistence is deferred until login
// succeeds — see persistLoginUrlAsDefault calls below.
Expand Down Expand Up @@ -435,6 +460,7 @@ export const loginCommand = buildCommand({
// OAuth device flow (host scope recorded via completeOAuthFlow → setAuthToken)
const result = await runInteractiveLogin({
timeout: flags.timeout * 1000,
readOnly: flags["read-only"],
Comment thread
cursor[bot] marked this conversation as resolved.
});

if (result) {
Expand Down
6 changes: 5 additions & 1 deletion src/lib/interactive-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export function toLoginUser(user: {
export type InteractiveLoginOptions = {
/** Timeout for OAuth flow in milliseconds (default: 900000 = 15 minutes) */
timeout?: number;
/** Request only read-only OAuth scopes (default: false) */
readOnly?: boolean;
};

/**
Expand Down Expand Up @@ -113,6 +115,7 @@ export async function runInteractiveLogin(
options?: InteractiveLoginOptions
): Promise<LoginResult | null> {
const timeout = options?.timeout ?? 900_000; // 15 minutes default
const readOnly = options?.readOnly ?? false;

log.info("Starting authentication...");

Expand Down Expand Up @@ -165,7 +168,8 @@ export async function runInteractiveLogin(
}
},
},
timeout
timeout,
readOnly
);

// Stop the spinner
Expand Down
13 changes: 9 additions & 4 deletions src/lib/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ export const OAUTH_SCOPES: readonly string[] = [

/** Space-joined scope string for OAuth requests */
const SCOPES = OAUTH_SCOPES.join(" ");
const SCOPES_READ_ONLY = OAUTH_SCOPES.filter((s) => s.endsWith(":read")).join(
" "
);

type DeviceFlowCallbacks = {
onUserCode: (
Expand Down Expand Up @@ -171,8 +174,9 @@ function assertRefreshHostTrusted(): void {
}

/** Request a device code from Sentry's device authorization endpoint */
function requestDeviceCode() {
function requestDeviceCode(readOnly = false) {
const clientId = getClientId();
const scope = readOnly ? SCOPES_READ_ONLY : SCOPES;
return withHttpSpan("POST", "/oauth/device/code/", async () => {
const response = await fetchWithConnectionError(
`${getSentryUrl()}/oauth/device/code/`,
Expand All @@ -181,7 +185,7 @@ function requestDeviceCode() {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: clientId,
scope: SCOPES,
scope,
}),
}
);
Expand Down Expand Up @@ -310,7 +314,8 @@ async function attemptPoll(deviceCode: string): Promise<PollResult> {
*/
export async function performDeviceFlow(
callbacks: DeviceFlowCallbacks,
timeout = 600_000 // 10 minutes default (matches Sentry's expires_in)
timeout = 600_000, // 10 minutes default (matches Sentry's expires_in)
readOnly = false
): Promise<TokenResponse> {
// Step 1: Request device code
const {
Expand All @@ -320,7 +325,7 @@ export async function performDeviceFlow(
verification_uri_complete,
interval,
expires_in,
} = await requestDeviceCode();
} = await requestDeviceCode(readOnly);

// Notify caller of the user code
await callbacks.onUserCode(
Expand Down