Skip to content

Commit e630bf9

Browse files
betegonclaude
andauthored
fix(auth): surface .sentryclirc source in self-hosted login errors (#976)
When `sentry auth login` rejects a self-hosted URL that came from `.sentryclirc`, the old error just said `--url was not provided` — which doesn't tell you *why* it was blocked or where the URL came from. Now it names the exact file and gives you the fix in one line: ``` Refusing to log in against https://sentry.example.com — this URL was read from .sentryclirc (/Users/you/.sentryclirc) but hasn't been confirmed as trusted yet. To authenticate against this self-hosted instance, confirm the host explicitly: sentry auth login --url https://sentry.example.com ``` The rc context logic is extracted into small helpers (`resolveRcContext`, `maybeWarnRcToken`) to keep `func`'s cyclomatic complexity in check. Relates to #975. --------- Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 4d24755 commit e630bf9

2 files changed

Lines changed: 147 additions & 19 deletions

File tree

src/commands/auth/login.ts

Lines changed: 94 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ import {
4242
normalizeOrigin,
4343
normalizeUserInputToOrigin,
4444
} from "../../lib/sentry-urls.js";
45+
import {
46+
loadSentryCliRc,
47+
type SentryCliRcConfig,
48+
} from "../../lib/sentryclirc.js";
4549
import {
4650
isLoginTrustAnchorFor,
4751
registerLoginTrustAnchor,
@@ -115,10 +119,15 @@ export function parseLoginUrl(raw: string): string {
115119
* a trusted source (`--url` flag or boot-time env snapshot), so "no
116120
* matching anchor" is the load-bearing signal that the host arrived via
117121
* an untrusted channel.
122+
*
123+
* @param rcSource - Path of the `.sentryclirc` file that provided the URL,
124+
* if that's where the host came from. Used to produce a more actionable
125+
* error message pointing at the specific file.
118126
*/
119127
function refuseLoginToUntrustedHost(
120128
flags: LoginFlags,
121-
effectiveHost: string
129+
effectiveHost: string,
130+
rcSource?: string
122131
): void {
123132
if (
124133
flags.url ||
@@ -127,11 +136,78 @@ function refuseLoginToUntrustedHost(
127136
) {
128137
return;
129138
}
130-
const tokenHint = flags.token ? " --token <token>" : "";
139+
const tokenFlag = flags.token ? " --token <your-token>" : "";
140+
const sourceClause = rcSource
141+
? `this URL was read from .sentryclirc (${rcSource}) but hasn't been confirmed as trusted yet`
142+
: "--url was not provided";
131143
throw new HostScopeError(
132-
`Refusing to log in against ${effectiveHost} without explicit --url.\n` +
133-
"Pass the host explicitly to confirm you trust it:\n" +
134-
` sentry auth login --url ${effectiveHost}${tokenHint}`
144+
`Refusing to log in against ${effectiveHost}${sourceClause}.\n\n` +
145+
"To authenticate against this self-hosted instance, confirm the host explicitly:\n" +
146+
` sentry auth login --url ${effectiveHost}${tokenFlag}`
147+
);
148+
}
149+
150+
/**
151+
* Resolve which `.sentryclirc` file (if any) provided the effective host, and
152+
* return its path alongside the full rc config for downstream use.
153+
*/
154+
async function resolveRcContext(
155+
flagUrl: string | undefined,
156+
cwd: string,
157+
effectiveHost: string
158+
): Promise<{
159+
rcConfig: SentryCliRcConfig;
160+
urlFromRc: string | undefined;
161+
}> {
162+
const rcConfig = await loadSentryCliRc(cwd);
163+
const rcUrlNormalized = rcConfig.url
164+
? normalizeOrigin(normalizeUrl(rcConfig.url))
165+
: undefined;
166+
const urlFromRc =
167+
!flagUrl &&
168+
!!rcUrlNormalized &&
169+
normalizeOrigin(effectiveHost) === rcUrlNormalized
170+
? rcConfig.sources.url
171+
: undefined;
172+
return { rcConfig, urlFromRc };
173+
}
174+
175+
/**
176+
* Returns a hint string when .sentryclirc contains a token the user could
177+
* pass directly via --token instead of going through the OAuth flow.
178+
* Returned as a footer hint so it appears after login completes, not before.
179+
*
180+
* Only shown when the stored token is plausibly for the current host: either
181+
* no URL is set in the rc file (global SaaS token) or the rc URL matches
182+
* effectiveHost. A mismatched URL means the token is for a different instance.
183+
*/
184+
/** @internal exported for testing */
185+
export function rcTokenHint(
186+
rcConfig: SentryCliRcConfig,
187+
effectiveHost: string
188+
): string | undefined {
189+
if (!rcConfig.token) {
190+
return;
191+
}
192+
const rcUrl = rcConfig.url
193+
? normalizeOrigin(normalizeUrl(rcConfig.url))
194+
: undefined;
195+
// Token is for a different host — don't suggest it
196+
if (rcUrl && rcUrl !== normalizeOrigin(effectiveHost)) {
197+
return;
198+
}
199+
// No URL in rc means a bare SaaS token — don't suggest it for self-hosted
200+
if (!(rcUrl || isSaaSTrustOrigin(effectiveHost))) {
201+
return;
202+
}
203+
// Always include --url for self-hosted instances regardless of how the host
204+
// was supplied — omitting it would point the user at SaaS instead.
205+
const urlHint = isSaaSTrustOrigin(effectiveHost)
206+
? ""
207+
: ` --url ${effectiveHost}`;
208+
return (
209+
`Found a token in .sentryclirc (${rcConfig.sources.token}). ` +
210+
`To skip OAuth next time: sentry auth login --token <token>${urlHint}`
135211
);
136212
}
137213

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

292-
// Check if already authenticated and handle re-authentication
367+
// Check whether the effective URL came from .sentryclirc so we can name
368+
// the source file in trust-refusal errors and show a migration tip.
369+
const { rcConfig, urlFromRc } = await resolveRcContext(
370+
flags.url,
371+
this.cwd,
372+
effectiveHost
373+
);
374+
375+
refuseLoginToUntrustedHost(flags, effectiveHost, urlFromRc);
376+
293377
if (isAuthenticated()) {
294378
const shouldProceed = await handleExistingAuth(flags.force);
295379
if (!shouldProceed) {
296380
return;
297381
}
298382
}
299383

300-
// Clear stale cached responses from a previous session
301384
try {
302385
await clearResponseCache();
303386
} catch {
304387
// Non-fatal: cache directory may not exist
305388
}
306389

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

314-
// Validate token by fetching user regions
315396
try {
316397
await getUserRegions();
317398
} catch {
318-
// Token is invalid - clear it and throw
319399
await clearAuth();
320400
throw new AuthError(
321401
"invalid",
322402
"Invalid API token. Please check your token and try again."
323403
);
324404
}
325405

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

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

359438
if (result) {
360-
// Login succeeded — persist default URL for subsequent invocations.
361439
persistLoginUrlAsDefault(flags.url, effectiveHost);
362-
// Warm the org + region cache so the first real command is fast.
363-
// Fire-and-forget — login already succeeded, caching is best-effort.
364440
warmOrgCache();
365441
yield new CommandOutput(result);
366-
} else {
367-
// Error already displayed by runInteractiveLogin
368-
process.exitCode = 1;
442+
return { hint: rcTokenHint(rcConfig, effectiveHost) };
369443
}
444+
// Error already displayed by runInteractiveLogin
445+
process.exitCode = 1;
370446
},
371447
});
372448

test/commands/auth/login.test.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@ mock.module("../../../src/lib/logger.js", () => ({
7777
}));
7878

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

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

9295
type LoginFlags = {
9396
readonly token?: string;
@@ -780,3 +783,52 @@ describe("applyLoginUrl (trust anchor registration)", () => {
780783
).toBe(false);
781784
});
782785
});
786+
787+
function makeRcConfig(
788+
token: string | undefined,
789+
url?: string
790+
): SentryCliRcConfig {
791+
return {
792+
token,
793+
url,
794+
sources: { token: token ? "~/.sentryclirc" : undefined },
795+
};
796+
}
797+
798+
describe("rcTokenHint", () => {
799+
test("no token → no hint", () => {
800+
expect(
801+
rcTokenHint(makeRcConfig(undefined), "https://sentry.io")
802+
).toBeUndefined();
803+
});
804+
805+
test("SaaS host, no rc URL → hint without --url", () => {
806+
const hint = rcTokenHint(makeRcConfig("sntrys_abc"), "https://sentry.io");
807+
expect(hint).toContain("sentry auth login --token <token>");
808+
expect(hint).not.toContain("--url");
809+
});
810+
811+
test("self-hosted, rc URL matches → hint includes --url", () => {
812+
const hint = rcTokenHint(
813+
makeRcConfig("sntrys_abc", "https://self.example.com"),
814+
"https://self.example.com"
815+
);
816+
expect(hint).toContain("--url https://self.example.com");
817+
});
818+
819+
test("self-hosted, rc URL mismatches → no hint (token is for a different instance)", () => {
820+
const hint = rcTokenHint(
821+
makeRcConfig("sntrys_abc", "https://other.example.com"),
822+
"https://self.example.com"
823+
);
824+
expect(hint).toBeUndefined();
825+
});
826+
827+
test("self-hosted, no rc URL → no hint (bare SaaS token shouldn't be suggested for self-hosted)", () => {
828+
const hint = rcTokenHint(
829+
makeRcConfig("sntrys_abc"),
830+
"https://self.example.com"
831+
);
832+
expect(hint).toBeUndefined();
833+
});
834+
});

0 commit comments

Comments
 (0)