Skip to content

Commit 080abd0

Browse files
committed
fix: accept nullable user fields in OAuth token response (#468)
The Sentry API can return `null` for `user.name` in the OAuth token response. The Zod schema had `name: z.string()` which rejected null, causing `safeParse()` to fail and login to throw 'Unexpected response from token endpoint'. - Make `name` and `email` nullable in `TokenResponseSchema` - Make `name` nullish in `SentryUserSchema` for consistency - Convert null → undefined at the UserInfo/LoginResult boundary - Add focused schema tests for nullable user fields Closes #468
1 parent 77603fc commit 080abd0

File tree

7 files changed

+146
-44
lines changed

7 files changed

+146
-44
lines changed

AGENTS.md

Lines changed: 46 additions & 35 deletions
Large diffs are not rendered by default.

src/commands/auth/login.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,9 +175,9 @@ export const loginCommand = buildCommand({
175175
userId: user.id,
176176
email: user.email,
177177
username: user.username,
178-
name: user.name,
178+
name: user.name ?? undefined,
179179
});
180-
result.user = user;
180+
result.user = { ...user, name: user.name ?? undefined };
181181
} catch {
182182
// Non-fatal: user info is supplementary. Token remains stored and valid.
183183
}

src/commands/auth/whoami.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export const whoamiCommand = buildCommand({
5959
userId: user.id,
6060
email: user.email,
6161
username: user.username,
62-
name: user.name,
62+
name: user.name ?? undefined,
6363
});
6464
} catch {
6565
// Cache update failure is non-essential — user identity was already fetched.

src/lib/interactive-login.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,8 @@ export async function runInteractiveLogin(
163163
try {
164164
setUserInfo({
165165
userId: user.id,
166-
email: user.email,
167-
name: user.name,
166+
email: user.email ?? undefined,
167+
name: user.name ?? undefined,
168168
});
169169
} catch (error) {
170170
// Report to Sentry but don't block auth - user info is not critical
@@ -178,7 +178,11 @@ export async function runInteractiveLogin(
178178
expiresIn: tokenResponse.expires_in,
179179
};
180180
if (user) {
181-
result.user = user;
181+
result.user = {
182+
id: user.id,
183+
name: user.name ?? undefined,
184+
email: user.email ?? undefined,
185+
};
182186
}
183187
return result;
184188
} catch (err) {

src/types/oauth.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ export const TokenResponseSchema = z
3434
user: z
3535
.object({
3636
id: z.string(),
37-
name: z.string(),
38-
email: z.string(),
37+
name: z.string().nullable(),
38+
email: z.string().nullable(),
3939
})
4040
.passthrough()
4141
.optional(),

src/types/sentry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ export const SentryUserSchema = z
215215
id: z.string(),
216216
email: z.string().optional(),
217217
username: z.string().optional(),
218-
name: z.string().optional(),
218+
name: z.string().nullish(),
219219
})
220220
.passthrough();
221221

test/types/oauth.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* OAuth Schema Tests
3+
*
4+
* Validates that Zod schemas correctly handle real-world API responses,
5+
* including nullable fields that the Sentry API may return.
6+
*/
7+
8+
import { describe, expect, test } from "bun:test";
9+
import { TokenResponseSchema } from "../../src/types/oauth.js";
10+
11+
describe("TokenResponseSchema", () => {
12+
const baseTokenResponse = {
13+
access_token: "sntrys_abc123",
14+
refresh_token: "sntryr_def456",
15+
expires_in: 2_591_999,
16+
token_type: "Bearer",
17+
scope: "event:read event:write member:read org:read project:admin",
18+
};
19+
20+
test("accepts response with user.name as null (GH-468)", () => {
21+
const response = {
22+
...baseTokenResponse,
23+
expires_at: "2026-04-18T09:03:59.747189Z",
24+
user: { id: "48168", name: null, email: "user@example.com" },
25+
};
26+
27+
const result = TokenResponseSchema.safeParse(response);
28+
expect(result.success).toBe(true);
29+
if (result.success) {
30+
expect(result.data.user?.name).toBeNull();
31+
expect(result.data.user?.email).toBe("user@example.com");
32+
}
33+
});
34+
35+
test("accepts response with user.email as null", () => {
36+
const response = {
37+
...baseTokenResponse,
38+
user: { id: "48168", name: "Jane Doe", email: null },
39+
};
40+
41+
const result = TokenResponseSchema.safeParse(response);
42+
expect(result.success).toBe(true);
43+
if (result.success) {
44+
expect(result.data.user?.name).toBe("Jane Doe");
45+
expect(result.data.user?.email).toBeNull();
46+
}
47+
});
48+
49+
test("accepts response with both name and email as null", () => {
50+
const response = {
51+
...baseTokenResponse,
52+
user: { id: "48168", name: null, email: null },
53+
};
54+
55+
const result = TokenResponseSchema.safeParse(response);
56+
expect(result.success).toBe(true);
57+
});
58+
59+
test("accepts response without user field", () => {
60+
const result = TokenResponseSchema.safeParse(baseTokenResponse);
61+
expect(result.success).toBe(true);
62+
if (result.success) {
63+
expect(result.data.user).toBeUndefined();
64+
}
65+
});
66+
67+
test("accepts response with extra fields in user (passthrough)", () => {
68+
const response = {
69+
...baseTokenResponse,
70+
user: {
71+
id: "48168",
72+
name: "Jane",
73+
email: "jane@example.com",
74+
avatar_url: "https://example.com/avatar.png",
75+
},
76+
};
77+
78+
const result = TokenResponseSchema.safeParse(response);
79+
expect(result.success).toBe(true);
80+
});
81+
82+
test("rejects response missing access_token", () => {
83+
const { access_token: _, ...noToken } = baseTokenResponse;
84+
const result = TokenResponseSchema.safeParse(noToken);
85+
expect(result.success).toBe(false);
86+
});
87+
});

0 commit comments

Comments
 (0)