Skip to content

Commit 290d03e

Browse files
feat(auth): switch to /auth/ endpoint and add whoami command (#266)
## Summary - Switch `getCurrentUser()` from `/users/me/` to `/auth/`, which works with all token types (OAuth, API tokens, OAuth App tokens) and avoids 403 errors - Remove the try-catch fallback in `--token` login — user info is now always fetched and stored unconditionally - Add `sentry auth whoami` (and `sentry whoami` alias): fetches live identity from `/auth/`, supports `--json` --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 233fa6c commit 290d03e

11 files changed

Lines changed: 595 additions & 8 deletions

File tree

plugins/sentry-cli/skills/sentry-cli/SKILL.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,13 @@ sentry auth status
9494

9595
Print the stored authentication token
9696

97+
#### `sentry auth whoami`
98+
99+
Show the currently authenticated user
100+
101+
**Flags:**
102+
- `--json - Output as JSON`
103+
97104
### Org
98105

99106
Work with Sentry organizations
@@ -669,6 +676,17 @@ List recent traces in a project
669676
- `-s, --sort <value> - Sort by: date, duration - (default: "date")`
670677
- `--json - Output as JSON`
671678

679+
### Whoami
680+
681+
Show the currently authenticated user
682+
683+
#### `sentry whoami`
684+
685+
Show the currently authenticated user
686+
687+
**Flags:**
688+
- `--json - Output as JSON`
689+
672690
## Output Formats
673691

674692
### JSON Output

src/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from "@stricli/core";
99
import { apiCommand } from "./commands/api.js";
1010
import { authRoute } from "./commands/auth/index.js";
11+
import { whoamiCommand } from "./commands/auth/whoami.js";
1112
import { cliRoute } from "./commands/cli/index.js";
1213
import { eventRoute } from "./commands/event/index.js";
1314
import { helpCommand } from "./commands/help.js";
@@ -56,6 +57,7 @@ export const routes = buildRouteMap({
5657
teams: teamListCommand,
5758
logs: logListCommand,
5859
traces: traceListCommand,
60+
whoami: whoamiCommand,
5961
},
6062
defaultCommand: "help",
6163
docs: {

src/commands/auth/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { logoutCommand } from "./logout.js";
44
import { refreshCommand } from "./refresh.js";
55
import { statusCommand } from "./status.js";
66
import { tokenCommand } from "./token.js";
7+
import { whoamiCommand } from "./whoami.js";
78

89
export const authRoute = buildRouteMap({
910
routes: {
@@ -12,13 +13,15 @@ export const authRoute = buildRouteMap({
1213
refresh: refreshCommand,
1314
status: statusCommand,
1415
token: tokenCommand,
16+
whoami: whoamiCommand,
1517
},
1618
docs: {
1719
brief: "Authenticate with Sentry",
1820
fullDescription:
1921
"Manage authentication with Sentry. Use 'sentry auth login' to authenticate, " +
2022
"'sentry auth logout' to remove credentials, 'sentry auth refresh' to manually refresh your token, " +
2123
"'sentry auth status' to check your authentication status, " +
24+
"'sentry auth whoami' to show your current user identity, " +
2225
"and 'sentry auth token' to print your token for use in scripts.",
2326
},
2427
});

src/commands/auth/login.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ export const loginCommand = buildCommand({
6767
);
6868
}
6969

70-
// Try to get user info (works with API tokens, may not work with OAuth App tokens)
70+
// Fetch and cache user info via /auth/ (works with all token types).
71+
// A transient failure here must not block login — the token is already valid.
7172
let user: Awaited<ReturnType<typeof getCurrentUser>> | undefined;
7273
try {
7374
user = await getCurrentUser();
@@ -78,7 +79,7 @@ export const loginCommand = buildCommand({
7879
name: user.name,
7980
});
8081
} catch {
81-
// Ignore - user info is optional, token may not have permission
82+
// Non-fatal: user info is supplementary. Token remains stored and valid.
8283
}
8384

8485
stdout.write(`${success("✓")} Authenticated with API token\n`);

src/commands/auth/whoami.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* sentry auth whoami
3+
*
4+
* Display the currently authenticated user's identity by fetching live from
5+
* the /auth/ endpoint. Unlike `sentry auth status`, this command only shows
6+
* who you are — no token details, no defaults, no org verification.
7+
*/
8+
9+
import type { SentryContext } from "../../context.js";
10+
import { getCurrentUser } from "../../lib/api-client.js";
11+
import { buildCommand } from "../../lib/command.js";
12+
import { isAuthenticated } from "../../lib/db/auth.js";
13+
import { setUserInfo } from "../../lib/db/user.js";
14+
import { AuthError } from "../../lib/errors.js";
15+
import { formatUserIdentity, writeJson } from "../../lib/formatters/index.js";
16+
17+
type WhoamiFlags = {
18+
readonly json: boolean;
19+
};
20+
21+
export const whoamiCommand = buildCommand({
22+
docs: {
23+
brief: "Show the currently authenticated user",
24+
fullDescription:
25+
"Fetch and display the identity of the currently authenticated user.\n\n" +
26+
"This calls the Sentry API live (not cached) so the result always reflects " +
27+
"the current token. Works with all token types: OAuth, API tokens, and OAuth App tokens.",
28+
},
29+
parameters: {
30+
flags: {
31+
json: {
32+
kind: "boolean",
33+
brief: "Output as JSON",
34+
default: false,
35+
},
36+
},
37+
},
38+
async func(this: SentryContext, flags: WhoamiFlags): Promise<void> {
39+
const { stdout } = this;
40+
41+
if (!(await isAuthenticated())) {
42+
throw new AuthError("not_authenticated");
43+
}
44+
45+
const user = await getCurrentUser();
46+
47+
// Keep cached user info up to date. Non-fatal: display must succeed even
48+
// if the DB write fails (read-only filesystem, corrupted database, etc.).
49+
try {
50+
setUserInfo({
51+
userId: user.id,
52+
email: user.email,
53+
username: user.username,
54+
name: user.name,
55+
});
56+
} catch {
57+
// Cache update failure is non-essential — user identity was already fetched.
58+
}
59+
60+
if (flags.json) {
61+
writeJson(stdout, {
62+
id: user.id,
63+
name: user.name ?? null,
64+
username: user.username ?? null,
65+
email: user.email ?? null,
66+
});
67+
return;
68+
}
69+
70+
stdout.write(`${formatUserIdentity(user)}\n`);
71+
},
72+
});

src/lib/api-client.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -305,9 +305,23 @@ export async function apiRequestToRegion<T>(
305305
}
306306

307307
const data = await response.json();
308-
const validated = schema ? schema.parse(data) : (data as T);
309308

310-
return { data: validated, headers: response.headers };
309+
if (schema) {
310+
const result = schema.safeParse(data);
311+
if (!result.success) {
312+
// Treat schema validation failures as API errors so they surface cleanly
313+
// through the central error handler rather than showing a raw ZodError
314+
// stack trace. This guards against unexpected API response format changes.
315+
throw new ApiError(
316+
`Unexpected response format from ${endpoint}`,
317+
response.status,
318+
result.error.message
319+
);
320+
}
321+
return { data: result.data, headers: response.headers };
322+
}
323+
324+
return { data: data as T, headers: response.headers };
311325
}
312326

313327
/**
@@ -1357,12 +1371,15 @@ export async function triggerSolutionPlanning(
13571371

13581372
/**
13591373
* Get the currently authenticated user's information.
1360-
* Uses the /users/me/ endpoint on the control silo.
1374+
*
1375+
* Uses the `/auth/` endpoint on the control silo, which works with all token
1376+
* types (OAuth, API tokens, OAuth App tokens). Unlike `/users/me/`, this
1377+
* endpoint does not return 403 for OAuth tokens.
13611378
*/
13621379
export async function getCurrentUser(): Promise<SentryUser> {
13631380
const { data } = await apiRequestToRegion<SentryUser>(
13641381
getControlSiloUrl(),
1365-
"/users/me/",
1382+
"/auth/",
13661383
{ schema: SentryUserSchema }
13671384
);
13681385
return data;

test/commands/auth/login.test.ts

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
/**
2+
* Login Command Tests
3+
*
4+
* Unit tests for the --token authentication path in src/commands/auth/login.ts.
5+
* Uses spyOn to mock api-client, db/auth, db/user, and interactive-login
6+
* to cover all branches without real HTTP calls or database access.
7+
*/
8+
9+
import {
10+
afterEach,
11+
beforeEach,
12+
describe,
13+
expect,
14+
mock,
15+
spyOn,
16+
test,
17+
} from "bun:test";
18+
import { loginCommand } from "../../../src/commands/auth/login.js";
19+
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
20+
import * as apiClient from "../../../src/lib/api-client.js";
21+
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
22+
import * as dbAuth from "../../../src/lib/db/auth.js";
23+
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
24+
import * as dbUser from "../../../src/lib/db/user.js";
25+
import { AuthError } from "../../../src/lib/errors.js";
26+
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
27+
import * as interactiveLogin from "../../../src/lib/interactive-login.js";
28+
29+
type LoginFlags = { readonly token?: string; readonly timeout: number };
30+
31+
/** Command function type extracted from loader result */
32+
type LoginFunc = (this: unknown, flags: LoginFlags) => Promise<void>;
33+
34+
const SAMPLE_USER = {
35+
id: "42",
36+
name: "Jane Doe",
37+
username: "janedoe",
38+
email: "jane@example.com",
39+
};
40+
41+
function createContext() {
42+
const stdoutLines: string[] = [];
43+
const stderrLines: string[] = [];
44+
const context = {
45+
stdout: {
46+
write: mock((s: string) => {
47+
stdoutLines.push(s);
48+
}),
49+
},
50+
stderr: {
51+
write: mock((s: string) => {
52+
stderrLines.push(s);
53+
}),
54+
},
55+
cwd: "/tmp",
56+
setContext: mock((_k: string, _v: unknown) => {
57+
/* no-op */
58+
}),
59+
};
60+
const getStdout = () => stdoutLines.join("");
61+
return { context, getStdout };
62+
}
63+
64+
describe("loginCommand.func --token path", () => {
65+
let isAuthenticatedSpy: ReturnType<typeof spyOn>;
66+
let setAuthTokenSpy: ReturnType<typeof spyOn>;
67+
let getUserRegionsSpy: ReturnType<typeof spyOn>;
68+
let clearAuthSpy: ReturnType<typeof spyOn>;
69+
let getCurrentUserSpy: ReturnType<typeof spyOn>;
70+
let setUserInfoSpy: ReturnType<typeof spyOn>;
71+
let runInteractiveLoginSpy: ReturnType<typeof spyOn>;
72+
let func: LoginFunc;
73+
74+
beforeEach(async () => {
75+
isAuthenticatedSpy = spyOn(dbAuth, "isAuthenticated");
76+
setAuthTokenSpy = spyOn(dbAuth, "setAuthToken");
77+
getUserRegionsSpy = spyOn(apiClient, "getUserRegions");
78+
clearAuthSpy = spyOn(dbAuth, "clearAuth");
79+
getCurrentUserSpy = spyOn(apiClient, "getCurrentUser");
80+
setUserInfoSpy = spyOn(dbUser, "setUserInfo");
81+
runInteractiveLoginSpy = spyOn(interactiveLogin, "runInteractiveLogin");
82+
func = (await loginCommand.loader()) as unknown as LoginFunc;
83+
});
84+
85+
afterEach(() => {
86+
isAuthenticatedSpy.mockRestore();
87+
setAuthTokenSpy.mockRestore();
88+
getUserRegionsSpy.mockRestore();
89+
clearAuthSpy.mockRestore();
90+
getCurrentUserSpy.mockRestore();
91+
setUserInfoSpy.mockRestore();
92+
runInteractiveLoginSpy.mockRestore();
93+
});
94+
95+
test("already authenticated: prints message and returns early", async () => {
96+
isAuthenticatedSpy.mockResolvedValue(true);
97+
98+
const { context, getStdout } = createContext();
99+
await func.call(context, { timeout: 900 });
100+
101+
expect(getStdout()).toContain("already authenticated");
102+
expect(setAuthTokenSpy).not.toHaveBeenCalled();
103+
expect(getCurrentUserSpy).not.toHaveBeenCalled();
104+
});
105+
106+
test("--token: stores token, fetches user, writes success", async () => {
107+
isAuthenticatedSpy.mockResolvedValue(false);
108+
setAuthTokenSpy.mockResolvedValue(undefined);
109+
getUserRegionsSpy.mockResolvedValue([]);
110+
getCurrentUserSpy.mockResolvedValue(SAMPLE_USER);
111+
setUserInfoSpy.mockReturnValue(undefined);
112+
113+
const { context, getStdout } = createContext();
114+
await func.call(context, { token: "my-token", timeout: 900 });
115+
116+
expect(setAuthTokenSpy).toHaveBeenCalledWith("my-token");
117+
expect(getCurrentUserSpy).toHaveBeenCalled();
118+
expect(setUserInfoSpy).toHaveBeenCalledWith({
119+
userId: "42",
120+
name: "Jane Doe",
121+
username: "janedoe",
122+
email: "jane@example.com",
123+
});
124+
const out = getStdout();
125+
expect(out).toContain("Authenticated");
126+
expect(out).toContain("Jane Doe");
127+
});
128+
129+
test("--token: invalid token clears auth and throws AuthError", async () => {
130+
isAuthenticatedSpy.mockResolvedValue(false);
131+
setAuthTokenSpy.mockResolvedValue(undefined);
132+
getUserRegionsSpy.mockRejectedValue(new Error("401 Unauthorized"));
133+
clearAuthSpy.mockResolvedValue(undefined);
134+
135+
const { context } = createContext();
136+
137+
await expect(
138+
func.call(context, { token: "bad-token", timeout: 900 })
139+
).rejects.toBeInstanceOf(AuthError);
140+
141+
expect(clearAuthSpy).toHaveBeenCalled();
142+
expect(getCurrentUserSpy).not.toHaveBeenCalled();
143+
});
144+
145+
test("--token: shows 'Logged in as' when user info fetch succeeds", async () => {
146+
isAuthenticatedSpy.mockResolvedValue(false);
147+
setAuthTokenSpy.mockResolvedValue(undefined);
148+
getUserRegionsSpy.mockResolvedValue([]);
149+
getCurrentUserSpy.mockResolvedValue({ id: "5", email: "only@email.com" });
150+
setUserInfoSpy.mockReturnValue(undefined);
151+
152+
const { context, getStdout } = createContext();
153+
await func.call(context, { token: "valid-token", timeout: 900 });
154+
155+
expect(getStdout()).toContain("Logged in as");
156+
expect(getStdout()).toContain("only@email.com");
157+
});
158+
159+
test("--token: login succeeds even when getCurrentUser() fails transiently", async () => {
160+
isAuthenticatedSpy.mockResolvedValue(false);
161+
setAuthTokenSpy.mockResolvedValue(undefined);
162+
getUserRegionsSpy.mockResolvedValue([]);
163+
getCurrentUserSpy.mockRejectedValue(new Error("Network error"));
164+
165+
const { context, getStdout } = createContext();
166+
167+
// Must not throw — login should succeed with the stored token
168+
await func.call(context, { token: "valid-token", timeout: 900 });
169+
170+
const out = getStdout();
171+
expect(out).toContain("Authenticated");
172+
// 'Logged in as' is omitted when user info is unavailable
173+
expect(out).not.toContain("Logged in as");
174+
// Token was stored and not cleared
175+
expect(clearAuthSpy).not.toHaveBeenCalled();
176+
expect(setUserInfoSpy).not.toHaveBeenCalled();
177+
});
178+
179+
test("no token: falls through to interactive login", async () => {
180+
isAuthenticatedSpy.mockResolvedValue(false);
181+
runInteractiveLoginSpy.mockResolvedValue(true);
182+
183+
const { context } = createContext();
184+
await func.call(context, { timeout: 900 });
185+
186+
expect(runInteractiveLoginSpy).toHaveBeenCalled();
187+
expect(setAuthTokenSpy).not.toHaveBeenCalled();
188+
});
189+
});

0 commit comments

Comments
 (0)