Skip to content

Commit 77279e6

Browse files
BYKGPT-6 Sol
andcommitted
feat(core): Share OAuth polling outcomes
Classify device-flow polling responses in toolkit-core while CLI and MCP retain their own deadlines and error messages. Co-Authored-By: GPT-6 Sol <agent@openai.com>
1 parent 626f869 commit 77279e6

7 files changed

Lines changed: 144 additions & 29 deletions

File tree

‎packages/cli/src/lib/oauth.ts‎

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
deviceCodeRequestBody,
1010
deviceTokenRequestBody,
1111
} from "@sentry/toolkit-core/oauth-device";
12-
import { nextDevicePollInterval } from "@sentry/toolkit-core/oauth-poll";
12+
import { advanceDevicePoll } from "@sentry/toolkit-core/oauth-poll";
1313
import { safeParse } from "valibot";
1414
import type { TokenResponse } from "../types/index.js";
1515
import {
@@ -373,14 +373,16 @@ function pollForToken(deviceCode: string): Promise<TokenResponse> {
373373

374374
type PollResult =
375375
| { status: "success"; token: TokenResponse }
376-
| { status: "authorization_pending" }
377-
| { status: "slow_down" }
376+
| { status: "retry"; intervalSeconds: number }
378377
| { status: "error"; message: string };
379378

380379
/**
381380
* Handle a single poll attempt, returning a result object
382381
*/
383-
async function attemptPoll(deviceCode: string): Promise<PollResult> {
382+
async function attemptPoll(
383+
deviceCode: string,
384+
intervalSeconds: number,
385+
): Promise<PollResult> {
384386
try {
385387
const token = await pollForToken(deviceCode);
386388
return { status: "success", token };
@@ -389,22 +391,21 @@ async function attemptPoll(deviceCode: string): Promise<PollResult> {
389391
throw error;
390392
}
391393

392-
switch (error.code) {
393-
case "authorization_pending":
394-
return { status: "authorization_pending" };
395-
case "slow_down":
396-
return { status: "slow_down" };
397-
case "expired_token":
394+
const outcome = advanceDevicePoll(intervalSeconds, error.code);
395+
switch (outcome.status) {
396+
case "retry":
397+
return outcome;
398+
case "expired":
398399
return {
399400
status: "error",
400401
message: "Device code expired. Please run 'sentry auth login' again.",
401402
};
402-
case "access_denied":
403+
case "denied":
403404
return {
404405
status: "error",
405406
message: "Authorization was denied. Please try again.",
406407
};
407-
default:
408+
case "unexpected":
408409
return { status: "error", message: error.message };
409410
}
410411
}
@@ -456,14 +457,13 @@ export async function performDeviceFlow(
456457
while (Date.now() < timeoutAt) {
457458
await sleep(pollInterval * 1000);
458459

459-
const result = await attemptPoll(device_code);
460+
const result = await attemptPoll(device_code, pollInterval);
460461

461462
switch (result.status) {
462463
case "success":
463464
return result.token;
464-
case "authorization_pending":
465-
case "slow_down":
466-
pollInterval = nextDevicePollInterval(pollInterval, result.status);
465+
case "retry":
466+
pollInterval = result.intervalSeconds;
467467
continue;
468468
case "error":
469469
throw new DeviceFlowError("authorization_failed", result.message);

‎packages/cli/test/lib/oauth.test.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,46 @@ describe("performDeviceFlow polling", () => {
7272
await expect(result).resolves.toEqual(token);
7373
expect(fetchMock).toHaveBeenCalledTimes(4);
7474
});
75+
76+
test.each([
77+
["access_denied", "Authorization was denied. Please try again."],
78+
[
79+
"expired_token",
80+
"Device code expired. Please run 'sentry auth login' again.",
81+
],
82+
["invalid_grant", "Token was rejected"],
83+
])("reports %s with the CLI's error message", async (code, message) => {
84+
vi.useFakeTimers();
85+
vi.setSystemTime(0);
86+
const fetchMock = vi
87+
.fn()
88+
.mockResolvedValueOnce(
89+
Response.json({
90+
device_code: "test-code",
91+
user_code: "ABCD",
92+
verification_uri: "https://sentry.example/oauth/device/",
93+
interval: 1,
94+
expires_in: 30,
95+
}),
96+
)
97+
.mockResolvedValueOnce(
98+
Response.json(
99+
{ error: code, error_description: "Token was rejected" },
100+
{ status: 400 },
101+
),
102+
);
103+
vi.stubGlobal("fetch", fetchMock);
104+
105+
const result = performDeviceFlow({ onUserCode: vi.fn() });
106+
const rejection = expect(result).rejects.toMatchObject({
107+
name: "DeviceFlowError",
108+
code: "authorization_failed",
109+
message,
110+
});
111+
await vi.advanceTimersByTimeAsync(1000);
112+
await rejection;
113+
expect(fetchMock).toHaveBeenCalledTimes(2);
114+
});
75115
});
76116

77117
describe("resolveOAuthScopeString", () => {

‎packages/mcp-server/src/auth/device-code-flow.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,29 @@ describe("pollForToken", () => {
154154
).rejects.toThrow(/expired/);
155155
});
156156

157+
it("reports an unexpected token error without retrying", async () => {
158+
const fetchMock = vi.mocked(fetch).mockResolvedValueOnce(
159+
new Response(JSON.stringify({ error: "invalid_grant" }), {
160+
status: 400,
161+
}),
162+
);
163+
164+
await expect(
165+
pollForToken({
166+
deviceCode: "test-device-code",
167+
clientId: "test-client-id",
168+
host: "sentry.io",
169+
interval: 0.01,
170+
expiresIn: 10,
171+
}),
172+
).rejects.toMatchObject({
173+
name: "DeviceCodeError",
174+
code: "invalid_grant",
175+
message: "Unexpected error during device code polling: invalid_grant",
176+
});
177+
expect(fetchMock).toHaveBeenCalledTimes(1);
178+
});
179+
157180
it("increases interval on slow_down", async () => {
158181
vi.useFakeTimers();
159182
const fetchMock = vi.mocked(fetch);

‎packages/mcp-server/src/auth/device-code-flow.ts‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
deviceCodeRequestBody,
66
deviceTokenRequestBody,
77
} from "@sentry/toolkit-core/oauth-device";
8-
import { nextDevicePollInterval } from "@sentry/toolkit-core/oauth-poll";
8+
import { advanceDevicePoll } from "@sentry/toolkit-core/oauth-poll";
99
import {
1010
DEVICE_CODE_ENDPOINT,
1111
DEVICE_CODE_SCOPES,
@@ -100,22 +100,22 @@ export async function pollForToken({
100100
const parsed = DeviceCodeErrorSchema.safeParse(errorBody);
101101
const errorCode = parsed.success ? parsed.data.error : undefined;
102102

103-
switch (errorCode) {
104-
case "authorization_pending":
105-
case "slow_down":
106-
pollInterval = nextDevicePollInterval(pollInterval, errorCode);
103+
const outcome = advanceDevicePoll(pollInterval, errorCode);
104+
switch (outcome.status) {
105+
case "retry":
106+
pollInterval = outcome.intervalSeconds;
107107
continue;
108-
case "access_denied":
108+
case "denied":
109109
throw new DeviceCodeError(
110110
"Authorization was denied. Please try again or provide --access-token.",
111111
errorCode,
112112
);
113-
case "expired_token":
113+
case "expired":
114114
throw new DeviceCodeError(
115115
"Device code expired before authorization was completed.",
116116
errorCode,
117117
);
118-
default:
118+
case "unexpected":
119119
throw new DeviceCodeError(
120120
`Unexpected error during device code polling: ${errorCode ?? resp.statusText}`,
121121
errorCode,

‎packages/toolkit-core/README.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Pure protocol and hostname helpers shared by the CLI and MCP. Both product
44
builds bundle this private workspace package into their artifacts.
55

66
The shared code validates opaque bearer tokens, constructs OAuth device-flow
7-
form bodies, applies the RFC 8628 polling interval rule, and recognizes Sentry
8-
hostnames. Each product retains its own credential storage, URL and host trust
9-
checks, regional routing, polling deadline, HTTP transport, response validation,
10-
and user-facing error types.
7+
form bodies, classifies RFC 8628 polling responses, advances retry intervals,
8+
and recognizes Sentry hostnames. Each product retains its own credential
9+
storage, URL and host trust checks, regional routing, polling deadline, HTTP
10+
transport, response validation, and user-facing error types.

‎packages/toolkit-core/src/oauth-poll.test.ts‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import { nextDevicePollInterval } from "./oauth-poll";
2+
import { advanceDevicePoll, nextDevicePollInterval } from "./oauth-poll";
33

44
describe("nextDevicePollInterval", () => {
55
it.each([1, 5, 30])(
@@ -23,3 +23,29 @@ describe("nextDevicePollInterval", () => {
2323
expect(nextDevicePollInterval(first, "slow_down")).toBe(11);
2424
});
2525
});
26+
27+
describe("advanceDevicePoll", () => {
28+
it("retains the interval for pending and increases it for repeated slow_down", () => {
29+
const pending = advanceDevicePoll(1, "authorization_pending");
30+
expect(pending).toEqual({ status: "retry", intervalSeconds: 1 });
31+
32+
const first = advanceDevicePoll(1, "slow_down");
33+
expect(first).toEqual({ status: "retry", intervalSeconds: 6 });
34+
if (first.status !== "retry") {
35+
throw new Error("slow_down must retry");
36+
}
37+
expect(advanceDevicePoll(first.intervalSeconds, "slow_down")).toEqual({
38+
status: "retry",
39+
intervalSeconds: 11,
40+
});
41+
});
42+
43+
it.each([
44+
["access_denied", "denied"],
45+
["expired_token", "expired"],
46+
["invalid_grant", "unexpected"],
47+
[undefined, "unexpected"],
48+
] as const)("classifies %s without retrying", (errorCode, status) => {
49+
expect(advanceDevicePoll(5, errorCode)).toEqual({ status });
50+
});
51+
});

‎packages/toolkit-core/src/oauth-poll.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,29 @@ export function nextDevicePollInterval(
88
): number {
99
return response === "slow_down" ? intervalSeconds + 5 : intervalSeconds;
1010
}
11+
12+
/** RFC 8628 token-poll outcome; callers own deadlines and user-facing errors. */
13+
export type DevicePollOutcome =
14+
| { status: "retry"; intervalSeconds: number }
15+
| { status: "denied" | "expired" | "unexpected" };
16+
17+
/** Apply the device-flow retry rules without deciding how to report failure. */
18+
export function advanceDevicePoll(
19+
intervalSeconds: number,
20+
errorCode: string | undefined,
21+
): DevicePollOutcome {
22+
switch (errorCode) {
23+
case "authorization_pending":
24+
case "slow_down":
25+
return {
26+
status: "retry",
27+
intervalSeconds: nextDevicePollInterval(intervalSeconds, errorCode),
28+
};
29+
case "access_denied":
30+
return { status: "denied" };
31+
case "expired_token":
32+
return { status: "expired" };
33+
default:
34+
return { status: "unexpected" };
35+
}
36+
}

0 commit comments

Comments
 (0)