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
111 changes: 111 additions & 0 deletions src/lib/auth-hint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* One-shot env-token-ignored hint.
*
* When a user sets SENTRY_AUTH_TOKEN (or SENTRY_TOKEN) but also has a
* stored OAuth login from `sentry auth login`, the CLI silently prefers
* the stored login. Users then wonder why their valid provisioned
* token isn't being used — this was the most painful item in the CLI
* UX feedback issue (getsentry/cli#785 #4). The hint surfaces the
* collision on stderr the first time the CLI reaches for auth in a
* given process.
*
* Design notes:
* - Fires at most once per process (module-local latch). CLI invocations
* are short-lived, so "once per invocation" is the intended scope —
* persisting across invocations would require a DB key and add noise
* without a clear benefit.
* - Gated behind `!SENTRY_FORCE_ENV_TOKEN`: when the user has already
* opted in to the env-var path, the hint is moot.
* - Gated behind `hasStoredAuthCredentials()`: without a stored OAuth
* login there's no collision to surface.
* - Uses `log.info` to stay clearly advisory (not a warning — nothing
* is wrong, just a helpful breadcrumb).
*/

import {
getActiveEnvVarName,
getRawEnvToken,
hasStoredAuthCredentials,
} from "./db/auth.js";
import { getUserInfo } from "./db/user.js";
import { getEnv } from "./env.js";
import { logger } from "./logger.js";

const log = logger.withTag("auth");

/** Per-process latch — flipped the first time we emit the hint. */
let hintEmitted = false;

/**
* Emit the env-token-ignored hint if the current process has the
* collision and hasn't yet notified the user.
*
* Safe to call on every auth'd request — the per-process latch and
* quick environment / DB checks mean the cost is negligible after the
* first call.
*/
export function maybeWarnEnvTokenIgnored(): void {
if (hintEmitted) {
return;
}

// Fast path: no env token to ignore.
const envToken = getRawEnvToken();
if (!envToken) {
return;
}

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

// No stored OAuth → no collision.
let hasStored = false;
try {
hasStored = hasStoredAuthCredentials();
} catch {
// DB access failed — silently skip; this is a best-effort hint.
return;
}
if (!hasStored) {
return;
}

hintEmitted = true;

const envVar = getActiveEnvVarName();
const userLabel = resolveStoredUserLabel();

log.info(
`Detected ${envVar} env var but using stored login for ${userLabel}.\n` +
" Set SENTRY_FORCE_ENV_TOKEN=1 to prefer the env var."
);
}

/**
* Resolve a user-friendly label for the stored OAuth user.
*
* Prefers `username`, then `email`, then `name` — matching what
* `sentry auth whoami` surfaces. Falls back to a neutral "stored
* OAuth user" when the cached `user_info` row is missing (fresh DB,
* never-ran-whoami, or read error).
*/
function resolveStoredUserLabel(): string {
try {
const user = getUserInfo();
return user?.username ?? user?.email ?? user?.name ?? "stored OAuth user";
} catch {
return "stored OAuth user";
}
}

/**
* Reset the one-shot latch. Tests call this between scenarios so each
* case starts with a fresh "has this process already notified" state.
*
* @internal
*/
export function resetAuthHintState(): void {
hintEmitted = false;
}
8 changes: 8 additions & 0 deletions src/lib/sentry-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/

import { getTraceData } from "@sentry/node-core/light";
import { maybeWarnEnvTokenIgnored } from "./auth-hint.js";
import {
DEFAULT_SENTRY_URL,
getConfiguredSentryUrl,
Expand Down Expand Up @@ -361,6 +362,13 @@ function createAuthenticatedFetch(): (
input: Request | string | URL,
init?: RequestInit
): Promise<Response> {
// Once-per-process hint when env-var auth token is shadowed by a
// stored OAuth login. Runs here (rather than at command entry) so
// the hint only fires for commands that actually exercise auth —
// `sentry help` and similar local-only commands stay quiet.
// Internally rate-limited and cheap on repeat calls.
maybeWarnEnvTokenIgnored();

const method =
init?.method ?? (input instanceof Request ? input.method : "GET");
const urlPath = extractUrlPath(input);
Expand Down
213 changes: 213 additions & 0 deletions test/lib/auth-hint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
/**
* Tests for the one-shot env-token-ignored hint
* (`maybeWarnEnvTokenIgnored`).
*
* Covers:
* - Hint fires when env token + stored OAuth coexist.
* - Hint does NOT fire without an env token, without a stored login,
* or when SENTRY_FORCE_ENV_TOKEN is set.
* - Repeat calls in the same process are silent.
* - User label preference order (username > email > name > fallback).
*/

import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
import {
maybeWarnEnvTokenIgnored,
resetAuthHintState,
} from "../../src/lib/auth-hint.js";
import { setAuthToken } from "../../src/lib/db/auth.js";
import { setUserInfo } from "../../src/lib/db/user.js";
import { useTestConfigDir } from "../helpers.js";

useTestConfigDir("auth-hint-");

let savedAuthToken: string | undefined;
let savedSentryToken: string | undefined;
let savedForceEnv: string | undefined;

beforeEach(() => {
savedAuthToken = process.env.SENTRY_AUTH_TOKEN;
savedSentryToken = process.env.SENTRY_TOKEN;
savedForceEnv = process.env.SENTRY_FORCE_ENV_TOKEN;
delete process.env.SENTRY_AUTH_TOKEN;
delete process.env.SENTRY_TOKEN;
delete process.env.SENTRY_FORCE_ENV_TOKEN;
resetAuthHintState();
});

afterEach(() => {
if (savedAuthToken !== undefined) {
process.env.SENTRY_AUTH_TOKEN = savedAuthToken;
} else {
delete process.env.SENTRY_AUTH_TOKEN;
}
if (savedSentryToken !== undefined) {
process.env.SENTRY_TOKEN = savedSentryToken;
} else {
delete process.env.SENTRY_TOKEN;
}
if (savedForceEnv !== undefined) {
process.env.SENTRY_FORCE_ENV_TOKEN = savedForceEnv;
} else {
delete process.env.SENTRY_FORCE_ENV_TOKEN;
}
});

/**
* Capture stderr output from consola's default reporter.
*
* We can't reliably spy on `logger.withTag("auth").info` from a test
* because each `withTag()` call returns a fresh consola instance — the
* spy target and the instance used inside `auth-hint.ts` are
* different objects. Hooking `process.stderr.write` captures the final
* rendered output regardless of which instance emitted it.
*/
function captureStderr() {
const stderrSpy = spyOn(process.stderr, "write").mockImplementation(
() => true
);
return {
/** Number of calls whose first argument contained the env-hint body. */
hintCalls: () =>
stderrSpy.mock.calls.filter((call) =>
String(call[0] ?? "").includes("SENTRY_FORCE_ENV_TOKEN=1")
).length,
/** Flattened first-arg text across all calls (for substring assertions). */
text: () =>
stderrSpy.mock.calls.map((call) => String(call[0] ?? "")).join(""),
restore: () => stderrSpy.mockRestore(),
};
}

describe("maybeWarnEnvTokenIgnored", () => {
test("does nothing when no env token is set", () => {
setAuthToken("stored_oauth", 3600, "refresh_a");
const cap = captureStderr();
try {
maybeWarnEnvTokenIgnored();
expect(cap.hintCalls()).toBe(0);
} finally {
cap.restore();
}
});

test("does nothing when no stored OAuth login exists", () => {
process.env.SENTRY_AUTH_TOKEN = "env_token";
const cap = captureStderr();
try {
maybeWarnEnvTokenIgnored();
expect(cap.hintCalls()).toBe(0);
} finally {
cap.restore();
}
});

test("does nothing when SENTRY_FORCE_ENV_TOKEN is set", () => {
setAuthToken("stored_oauth", 3600, "refresh_a");
process.env.SENTRY_AUTH_TOKEN = "env_token";
process.env.SENTRY_FORCE_ENV_TOKEN = "1";
const cap = captureStderr();
try {
maybeWarnEnvTokenIgnored();
expect(cap.hintCalls()).toBe(0);
} finally {
cap.restore();
}
});

test("fires once when env token collides with stored OAuth", () => {
setAuthToken("stored_oauth", 3600, "refresh_a");
setUserInfo({
userId: "u-1",
username: "alice",
email: "alice@example.com",
});
process.env.SENTRY_AUTH_TOKEN = "env_token";

const cap = captureStderr();
try {
maybeWarnEnvTokenIgnored();
expect(cap.hintCalls()).toBe(1);
const text = cap.text();
expect(text).toContain("SENTRY_AUTH_TOKEN env var");
expect(text).toContain("stored login for alice");
expect(text).toContain("SENTRY_FORCE_ENV_TOKEN=1");
} finally {
cap.restore();
}
});

test("is silent on repeat calls within the same process", () => {
setAuthToken("stored_oauth", 3600, "refresh_a");
process.env.SENTRY_AUTH_TOKEN = "env_token";

const cap = captureStderr();
try {
maybeWarnEnvTokenIgnored();
maybeWarnEnvTokenIgnored();
maybeWarnEnvTokenIgnored();
expect(cap.hintCalls()).toBe(1);
} finally {
cap.restore();
}
});

test("uses SENTRY_TOKEN when only that legacy var is set", () => {
setAuthToken("stored_oauth", 3600, "refresh_a");
process.env.SENTRY_TOKEN = "legacy_token";

const cap = captureStderr();
try {
maybeWarnEnvTokenIgnored();
expect(cap.hintCalls()).toBe(1);
expect(cap.text()).toContain("SENTRY_TOKEN env var");
} finally {
cap.restore();
}
});

test("falls back to email when username is unavailable", () => {
setAuthToken("stored_oauth", 3600, "refresh_a");
setUserInfo({
userId: "u-1",
email: "alice@example.com",
});
process.env.SENTRY_AUTH_TOKEN = "env_token";

const cap = captureStderr();
try {
maybeWarnEnvTokenIgnored();
expect(cap.text()).toContain("stored login for alice@example.com");
} finally {
cap.restore();
}
});

test("falls back to name when username and email are unavailable", () => {
setAuthToken("stored_oauth", 3600, "refresh_a");
setUserInfo({ userId: "u-1", name: "Alice Wonderland" });
process.env.SENTRY_AUTH_TOKEN = "env_token";

const cap = captureStderr();
try {
maybeWarnEnvTokenIgnored();
expect(cap.text()).toContain("stored login for Alice Wonderland");
} finally {
cap.restore();
}
});

test("uses a neutral label when no user info is cached", () => {
setAuthToken("stored_oauth", 3600, "refresh_a");
// No setUserInfo — user_info table is empty.
process.env.SENTRY_AUTH_TOKEN = "env_token";

const cap = captureStderr();
try {
maybeWarnEnvTokenIgnored();
expect(cap.text()).toContain("stored login for stored OAuth user");
} finally {
cap.restore();
}
});
});
Loading