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
112 changes: 94 additions & 18 deletions src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ import {
normalizeOrigin,
normalizeUserInputToOrigin,
} from "../../lib/sentry-urls.js";
import {
loadSentryCliRc,
type SentryCliRcConfig,
} from "../../lib/sentryclirc.js";
import {
isLoginTrustAnchorFor,
registerLoginTrustAnchor,
Expand Down Expand Up @@ -115,10 +119,15 @@ export function parseLoginUrl(raw: string): string {
* a trusted source (`--url` flag or boot-time env snapshot), so "no
* matching anchor" is the load-bearing signal that the host arrived via
* an untrusted channel.
*
* @param rcSource - Path of the `.sentryclirc` file that provided the URL,
* if that's where the host came from. Used to produce a more actionable
* error message pointing at the specific file.
*/
function refuseLoginToUntrustedHost(
flags: LoginFlags,
effectiveHost: string
effectiveHost: string,
rcSource?: string
): void {
if (
flags.url ||
Expand All @@ -127,11 +136,78 @@ function refuseLoginToUntrustedHost(
) {
return;
}
const tokenHint = flags.token ? " --token <token>" : "";
const tokenFlag = flags.token ? " --token <your-token>" : "";
const sourceClause = rcSource
? `this URL was read from .sentryclirc (${rcSource}) but hasn't been confirmed as trusted yet`
: "--url was not provided";
throw new HostScopeError(
`Refusing to log in against ${effectiveHost} without explicit --url.\n` +
"Pass the host explicitly to confirm you trust it:\n" +
` sentry auth login --url ${effectiveHost}${tokenHint}`
`Refusing to log in against ${effectiveHost} — ${sourceClause}.\n\n` +
"To authenticate against this self-hosted instance, confirm the host explicitly:\n" +
` sentry auth login --url ${effectiveHost}${tokenFlag}`
);
}

/**
* Resolve which `.sentryclirc` file (if any) provided the effective host, and
* return its path alongside the full rc config for downstream use.
*/
async function resolveRcContext(
flagUrl: string | undefined,
cwd: string,
effectiveHost: string
): Promise<{
rcConfig: SentryCliRcConfig;
urlFromRc: string | undefined;
}> {
const rcConfig = await loadSentryCliRc(cwd);
const rcUrlNormalized = rcConfig.url
? normalizeOrigin(normalizeUrl(rcConfig.url))
: undefined;
const urlFromRc =
!flagUrl &&
!!rcUrlNormalized &&
normalizeOrigin(effectiveHost) === rcUrlNormalized
? rcConfig.sources.url
: undefined;
return { rcConfig, urlFromRc };
}

/**
* Returns a hint string when .sentryclirc contains a token the user could
* pass directly via --token instead of going through the OAuth flow.
* Returned as a footer hint so it appears after login completes, not before.
*
* Only shown when the stored token is plausibly for the current host: either
* no URL is set in the rc file (global SaaS token) or the rc URL matches
* effectiveHost. A mismatched URL means the token is for a different instance.
*/
/** @internal exported for testing */
export function rcTokenHint(
rcConfig: SentryCliRcConfig,
effectiveHost: string
): string | undefined {
if (!rcConfig.token) {
return;
}
const rcUrl = rcConfig.url
? normalizeOrigin(normalizeUrl(rcConfig.url))
: undefined;
Comment thread
sentry[bot] marked this conversation as resolved.
// Token is for a different host — don't suggest it
if (rcUrl && rcUrl !== normalizeOrigin(effectiveHost)) {
return;
}
// No URL in rc means a bare SaaS token — don't suggest it for self-hosted
if (!(rcUrl || isSaaSTrustOrigin(effectiveHost))) {
return;
}
// Always include --url for self-hosted instances regardless of how the host
// was supplied — omitting it would point the user at SaaS instead.
const urlHint = isSaaSTrustOrigin(effectiveHost)
? ""
: ` --url ${effectiveHost}`;
return (
`Found a token in .sentryclirc (${rcConfig.sources.token}). ` +
`To skip OAuth next time: sentry auth login --token <token>${urlHint}`
);
}
Comment thread
betegon marked this conversation as resolved.

Expand Down Expand Up @@ -287,43 +363,46 @@ export const loginCommand = buildCommand({
// requested instance. Default URL persistence is deferred until login
// succeeds — see persistLoginUrlAsDefault calls below.
const effectiveHost = applyLoginUrl(flags.url);
refuseLoginToUntrustedHost(flags, effectiveHost);

// Check if already authenticated and handle re-authentication
// Check whether the effective URL came from .sentryclirc so we can name
// the source file in trust-refusal errors and show a migration tip.
const { rcConfig, urlFromRc } = await resolveRcContext(
flags.url,
this.cwd,
effectiveHost
);

refuseLoginToUntrustedHost(flags, effectiveHost, urlFromRc);

if (isAuthenticated()) {
const shouldProceed = await handleExistingAuth(flags.force);
if (!shouldProceed) {
return;
}
}

// Clear stale cached responses from a previous session
try {
await clearResponseCache();
} catch {
// Non-fatal: cache directory may not exist
}

// Token-based authentication
if (flags.token) {
// Save token first (with host scope), then validate by fetching user regions
await setAuthToken(flags.token, undefined, undefined, {
host: effectiveHost,
});

// Validate token by fetching user regions
try {
await getUserRegions();
} catch {
// Token is invalid - clear it and throw
await clearAuth();
throw new AuthError(
"invalid",
"Invalid API token. Please check your token and try again."
);
}

// Login succeeded — persist default URL for subsequent invocations.
persistLoginUrlAsDefault(flags.url, effectiveHost);

// Fetch and cache user info via /auth/ (works with all token types).
Expand Down Expand Up @@ -357,16 +436,13 @@ export const loginCommand = buildCommand({
});

if (result) {
// Login succeeded — persist default URL for subsequent invocations.
persistLoginUrlAsDefault(flags.url, effectiveHost);
// Warm the org + region cache so the first real command is fast.
// Fire-and-forget — login already succeeded, caching is best-effort.
warmOrgCache();
yield new CommandOutput(result);
} else {
// Error already displayed by runInteractiveLogin
process.exitCode = 1;
return { hint: rcTokenHint(rcConfig, effectiveHost) };
}
// Error already displayed by runInteractiveLogin
process.exitCode = 1;
},
});

Expand Down
54 changes: 53 additions & 1 deletion test/commands/auth/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ mock.module("../../../src/lib/logger.js", () => ({
}));

// Dynamic import: must run AFTER mock.module() so login.ts picks up fakeLog.
const { loginCommand } = await import("../../../src/commands/auth/login.js");
const { loginCommand, rcTokenHint } = await import(
"../../../src/commands/auth/login.js"
);

// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
import * as apiClient from "../../../src/lib/api-client.js";
Expand All @@ -88,6 +90,7 @@ import * as dbUser from "../../../src/lib/db/user.js";
import { AuthError } from "../../../src/lib/errors.js";
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
import * as interactiveLogin from "../../../src/lib/interactive-login.js";
import type { SentryCliRcConfig } from "../../../src/lib/sentryclirc.js";

type LoginFlags = {
readonly token?: string;
Expand Down Expand Up @@ -780,3 +783,52 @@ describe("applyLoginUrl (trust anchor registration)", () => {
).toBe(false);
});
});

function makeRcConfig(
token: string | undefined,
url?: string
): SentryCliRcConfig {
return {
token,
url,
sources: { token: token ? "~/.sentryclirc" : undefined },
};
}

describe("rcTokenHint", () => {
test("no token → no hint", () => {
expect(
rcTokenHint(makeRcConfig(undefined), "https://sentry.io")
).toBeUndefined();
});

test("SaaS host, no rc URL → hint without --url", () => {
const hint = rcTokenHint(makeRcConfig("sntrys_abc"), "https://sentry.io");
expect(hint).toContain("sentry auth login --token <token>");
expect(hint).not.toContain("--url");
});

test("self-hosted, rc URL matches → hint includes --url", () => {
const hint = rcTokenHint(
makeRcConfig("sntrys_abc", "https://self.example.com"),
"https://self.example.com"
);
expect(hint).toContain("--url https://self.example.com");
});

test("self-hosted, rc URL mismatches → no hint (token is for a different instance)", () => {
const hint = rcTokenHint(
makeRcConfig("sntrys_abc", "https://other.example.com"),
"https://self.example.com"
);
expect(hint).toBeUndefined();
});

test("self-hosted, no rc URL → no hint (bare SaaS token shouldn't be suggested for self-hosted)", () => {
const hint = rcTokenHint(
makeRcConfig("sntrys_abc"),
"https://self.example.com"
);
expect(hint).toBeUndefined();
});
});
Loading