Skip to content
19 changes: 17 additions & 2 deletions apps/cli-docs/src/fragments/commands/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,19 @@ that fits what they target, without changing the active account:
- **Server** — `SENTRY_HOST`/`SENTRY_URL`, `.sentryclirc`, or a pasted Sentry
URL for another account's server (for example a self-hosted issue link) uses
the account stored for that server.
- **Environment token** — `SENTRY_AUTH_TOKEN` (or `SENTRY_TOKEN`) acts as one
more account, never stored, when its server is known from a trusted source:
an organization auth token's own URL, else sentry.io (including a sentry.io
`SENTRY_HOST`/`SENTRY_URL`; never `.sentryclirc` or a pasted URL). A
self-hosted `SENTRY_HOST`/`SENTRY_URL` alone doesn't count, since CI steps
can set it without access to the token: use an organization auth token, or
`SENTRY_FORCE_ENV_TOKEN=1`. `sentry org list` lists its
organizations under `env:SENTRY_AUTH_TOKEN` and caches them; commands
targeting one of them then use the env token, even when a stored account
knows the organization too. It also serves its
server when no stored account is on it. `sentry auth list` and
`sentry auth status` show it with its user when the server reports one. It
can't be selected with `sentry auth switch`.

```bash
# Active account is personal; this uses the work account that knows acme-corp
Expand Down Expand Up @@ -204,8 +217,10 @@ By default, the CLI checks for auth tokens in the following order:
3. `SENTRY_TOKEN` environment variable (legacy alias)

The stored credential takes priority. Stored OAuth credentials support
automatic refresh; manually provided API tokens do not use a refresh token. To
override this precedence and force environment tokens to win, set
automatic refresh; manually provided API tokens do not use a refresh token.
An environment token with a known server is still used for the organizations it
can access and for its own server (see account selection above). To override
this precedence and use only the environment token, set
`SENTRY_FORCE_ENV_TOKEN=1`.

When a token comes from an environment variable, the CLI skips expiry checks and automatic refresh.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ List organizations
**Flags:**
- `-n, --limit <value> - Maximum number of organizations to list - (default: "25")`
- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data`
- `-a, --account <value> - Only list organizations of this stored account (email, username, or account id; <who>:<server> for one server)`
- `-a, --account <value> - Only list organizations of this account (email, username, or account id; <who>:<server> for one server; env:SENTRY_AUTH_TOKEN)`

### `sentry org view [<org>]`

Expand Down
23 changes: 18 additions & 5 deletions packages/cli/src/commands/auth/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
import type { SentryContext } from "../../context.js";
import { buildCommand } from "../../lib/command.js";
import { listAccounts } from "../../lib/db/accounts.js";
import { getActiveEnvVarName, getAuthConfig } from "../../lib/db/auth.js";
import {
getActiveEnvVarName,
getAuthConfig,
getEnvAccount,
} from "../../lib/db/auth.js";
import { describeEnvAccount } from "../../lib/env-account.js";
import {
type AccountListResult,
formatAccountList,
Expand All @@ -22,7 +27,11 @@ export const listCommand = buildCommand({
fullDescription:
"List every Sentry account stored by 'sentry auth login', across servers.\n\n" +
"The active account (marked with *) is used for commands. Log in again to add\n" +
"another account, and use 'sentry auth switch' to change the active one.",
"another account, and use 'sentry auth switch' to change the active one.\n\n" +
"SENTRY_AUTH_TOKEN (or SENTRY_TOKEN) is listed too when its server is known\n" +
"(an organization auth token's own URL, or sentry.io), with the user\n" +
"it belongs to when the server says. It is never stored and can't be\n" +
"switched to: commands targeting an organization it can access use it.",
},
output: {
human: formatAccountList,
Expand All @@ -32,18 +41,22 @@ export const listCommand = buildCommand({
flags: {},
},
async *func(this: SentryContext) {
const accounts = listAccounts();
const stored = listAccounts();
const envAccount = getEnvAccount();
const accounts = envAccount
? [...stored, await describeEnvAccount(envAccount)]
: stored;
yield new CommandOutput<AccountListResult>({ accounts });

if (getAuthConfig()?.source.startsWith("env:")) {
return {
hint: `${getActiveEnvVarName()} is in use and overrides stored accounts.`,
};
}
if (accounts.length > 1) {
if (stored.length > 1) {
return { hint: "Switch accounts with 'sentry auth switch <email>'." };
}
if (accounts.length === 0) {
if (stored.length === 0) {
return { hint: "Log in with 'sentry auth login'." };
}
return { hint: "Add another account with 'sentry auth login'." };
Expand Down
41 changes: 40 additions & 1 deletion packages/cli/src/commands/auth/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ import {
getActiveEnvVarName,
getAuthConfig,
getAuthToken,
getEnvAccount,
getRawEnvToken,
isAuthenticated,
} from "../../lib/db/auth.js";
import { fetchEnvAccountUser } from "../../lib/env-account.js";
import { listAccounts } from "../../lib/db/accounts.js";
import {
getDefaultOrganization,
Expand Down Expand Up @@ -93,6 +95,14 @@ export type AuthStatusData = {
envVar: string;
/** Whether the env token is the effective auth source (vs bypassed for OAuth) */
active: boolean;
/**
* Server the token is scoped to, when known from a trusted source. Then
* the token also acts as an account of its own: commands targeting an
* organization it can access use it even when stored OAuth is in effect.
*/
host?: string;
/** The token's user, from `/auth/` (not for organization auth tokens) */
user?: { name?: string; email?: string; username?: string };
};
};

Expand Down Expand Up @@ -154,6 +164,35 @@ async function verifyCredentials(): Promise<AuthStatusData["verification"]> {
}
}

/**
* Collect the env token's status, server, and user. The user is fetched
* from its own server only when that server is known from a trusted source.
*/
async function collectEnvToken(
active: boolean,
): Promise<NonNullable<AuthStatusData["envToken"]>> {
const envVar = getActiveEnvVarName();
const envAccount = getEnvAccount();
if (!envAccount) {
return { envVar, active };
}
const user = await fetchEnvAccountUser(envAccount);
return {
envVar,
active,
host: envAccount.host,
...(user
? {
user: {
name: user.name ?? undefined,
email: user.email ?? undefined,
username: user.username ?? undefined,
},
}
: {}),
};
}

export const statusCommand = buildCommand({
auth: false,
docs: {
Expand Down Expand Up @@ -207,7 +246,7 @@ export const statusCommand = buildCommand({
// (it may be set but bypassed because stored OAuth takes priority)
const rawEnv = getRawEnvToken();
const envTokenData: AuthStatusData["envToken"] = rawEnv
? { envVar: getActiveEnvVarName(), active: fromEnv }
? await collectEnvToken(fromEnv)
: undefined;

const data: AuthStatusData = {
Expand Down
94 changes: 79 additions & 15 deletions packages/cli/src/commands/org/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,16 @@ import {
accountServerOf,
serverKey,
toAccountSummary,
toEnvAccountSummary,
} from "../../lib/db/accounts.js";
import {
getEnvAccount,
getEnvAccountInScope,
listUsableAccountsInScope,
withEnvAccount,
withStoredAccount,
} from "../../lib/db/auth.js";
import { fetchEnvAccountUser } from "../../lib/env-account.js";
import { getAllOrgRegions } from "../../lib/db/regions.js";
import { ApiError, AuthError } from "../../lib/errors.js";
import { formatAccountLabel } from "../../lib/formatters/accounts.js";
Expand All @@ -47,11 +52,15 @@ type ListFlags = {
readonly fields?: string[];
};

/** The stored account an organization was listed under (no secrets). */
/**
* The account an organization was listed under (no secrets): a stored
* account, or the env token's virtual account.
*/
type OrgListAccount = {
/**
* What `--account` and `sentry auth login` accept: `<who>:<server>`, e.g.
* `jane@acme.com:sentry.io` (just `<who>` when no server is recorded).
* What `--account` accepts: `<who>:<server>`, e.g.
* `jane@acme.com:sentry.io` (just `<who>` when no server is recorded), as
* `sentry auth login` does too; `env:SENTRY_AUTH_TOKEN` for the env token.
*/
name: string;
id: string;
Expand All @@ -66,6 +75,8 @@ type OrgListAccount = {
host?: string;
/** Human label, e.g. `Jane <jane@acme.com> on sentry.io`. */
label: string;
/** The variable providing the token, for the env token's account. */
envVar?: string;
};

/**
Expand Down Expand Up @@ -186,6 +197,7 @@ function toOrgListAccount(account: AccountSummary): OrgListAccount {
username: account.username,
server: accountServerOf(account),
host: account.host,
envVar: account.envVar,
};
const entry: OrgListAccount = {
name: accountSelectorOf(account),
Expand All @@ -206,8 +218,36 @@ async function fetchOrgs(account?: AccountSummary): Promise<AccountOrgs> {
return { account, orgs, regions: getAllOrgRegions() };
}

/**
* Fetch the env token's orgs with only the env token, on its own server,
* and ask the server whose token it is (best-effort) to label it.
*/
async function fetchEnvAccountOrgs(
account: AccountSummary,
): Promise<AccountOrgs> {
const envAccount = getEnvAccount();
if (!envAccount) {
throw new AuthError(
"not_authenticated",
`${account.envVar ?? "The env token"} is no longer usable.`,
{ skipAutoAuth: true },
);
}
const [fetched, user] = await Promise.all([
withEnvAccount(envAccount, () => fetchOrgs()),
fetchEnvAccountUser(envAccount),
]);
return {
...fetched,
account: toEnvAccountSummary(envAccount.envVar, envAccount.host, user),
};
}

/** Fetch `account`'s orgs with its own credentials, on its own server. */
async function fetchAccountOrgs(account: AccountSummary): Promise<AccountOrgs> {
if (account.envVar) {
return await fetchEnvAccountOrgs(account);
}
const host = account.host;
if (!host) {
throw new AuthError(
Expand Down Expand Up @@ -256,7 +296,8 @@ async function fetchOrgsAcrossAccounts(
/**
* Flatten per-account results into list entries. An org reachable from
* several accounts on the same server is listed once, under the first of
* them (the active account, else the most recently used).
* them (the env token, which org routing prefers, then the active
* account, then the most recently used).
*/
function mergeAccountOrgs(results: AccountOrgs[]): {
orgs: SentryOrganization[];
Expand Down Expand Up @@ -300,15 +341,27 @@ function reloginCommand(account: AccountSummary): string {
}

/**
* Which stored accounts to list: the one named by `--account`, else every
* usable account in scope. `undefined` means "the credentials in effect"
* (no usable stored account, only the active one, or a forced env token),
* which keeps the single-account output unchanged. A single usable account
* that isn't the active one (say, the active login was logged out) is
* listed with its own credentials.
* Which accounts to list: the one named by `--account`, else the env
* token's virtual account (when its server is trusted, see
* `getEnvAccount`) and every usable stored account in scope. `undefined`
* means "the credentials in effect" (no usable account, only the active
* one or only the env token, or a forced env token), which keeps the
* single-account output unchanged. A single usable account that isn't the
* active one (say, the active login was logged out) is listed with its own
* credentials.
*/
function resolveAccounts(selector?: string): AccountSummary[] | undefined {
if (selector !== undefined) {
const envAccount = getEnvAccount();
const envSummary = envAccount
? toEnvAccountSummary(envAccount.envVar, envAccount.host)
: undefined;
if (
envSummary &&
selector.trim().toLowerCase() === envSummary.id.toLowerCase()
) {
return [envSummary];
}
const account = resolveAccountSelector(
selector,
"sentry org list --account",
Expand All @@ -325,13 +378,19 @@ function resolveAccounts(selector?: string): AccountSummary[] | undefined {
}
return [account];
}
// The active account first, then most recently used: its orgs lead the
// list, and an org it shares with another account is listed under it.
const accounts = listUsableAccountsInScope()
// The env token, then the active account, then most recently used: an
// org several of them share is listed under the one commands use for it.
const stored = listUsableAccountsInScope()
.map(toAccountSummary)
.sort((a, b) => Number(b.active) - Number(a.active));
const envAccount = getEnvAccountInScope();
const accounts = envAccount
? [toEnvAccountSummary(envAccount.envVar, envAccount.host), ...stored]
: stored;
const [only] = accounts;
return accounts.length > 1 || (only && !only.active) ? accounts : undefined;
return accounts.length > 1 || (only && !only.active && !only.envVar)
? accounts
: undefined;
}

export const listCommand = buildCommand({
Expand All @@ -348,6 +407,11 @@ export const listCommand = buildCommand({
"list to accounts on that server; --account limits it to one account\n" +
"(an email, username, or account id; add :<server> to pick one server's\n" +
"login, e.g. jane@example.com:sentry.example.com, as shown in ACCOUNT).\n\n" +
"SENTRY_AUTH_TOKEN (or SENTRY_TOKEN) is listed as one more account,\n" +
"env:SENTRY_AUTH_TOKEN, when its server is known: an organization auth\n" +
"token's own URL, or sentry.io. Organizations it can access are listed\n" +
"under it, and commands targeting them use it.\n" +
"With SENTRY_FORCE_ENV_TOKEN=1, only the env token is used.\n\n" +
"Pass a filter to only list organizations whose slug or name matches it,\n" +
"ignoring case: exactly, as a prefix or substring, or with a small typo\n" +
"(best matches first), or as a glob like 'acme-*'.\n\n" +
Expand All @@ -370,7 +434,7 @@ export const listCommand = buildCommand({
kind: "parsed",
parse: String,
brief:
"Only list organizations of this stored account (email, username, or account id; <who>:<server> for one server)",
"Only list organizations of this account (email, username, or account id; <who>:<server> for one server; env:SENTRY_AUTH_TOKEN)",
optional: true,
},
},
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/lib/auth-hint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ import {
getActiveEnvVarName,
getRawEnvToken,
hasStoredAuthCredentials,
isForceEnvTokenSet,
} from "./db/auth.js";
import { getUserInfo } from "./db/user.js";
import { getEnv } from "./env.js";
import { logger } from "./logger.js";
import { getRcInjectedTokenSource } from "./sentryclirc.js";

Expand Down Expand Up @@ -60,7 +60,7 @@ export function maybeWarnEnvTokenIgnored(): void {
}

// Fast path: user opted into the env token explicitly.
if (getEnv().SENTRY_FORCE_ENV_TOKEN?.trim()) {
if (isForceEnvTokenSet()) {
return;
}

Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/lib/db/account-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ import { getEnv } from "../env.js";
type AccountScope = {
/** Default account for the rest of this invocation, chosen from its target. */
accountId?: string;
/**
* The env token (`SENTRY_AUTH_TOKEN`/`SENTRY_TOKEN`) was chosen for the
* rest of this invocation instead of a stored account, because it knows
* the targeted organization. Exclusive with `accountId`.
*/
envToken?: boolean;
};

/** One scope per invocation env; see the module comment for why. */
Expand Down
Loading
Loading