Skip to content

Commit b9f0e55

Browse files
BYKGPT-6 Sol
andcommitted
feat(core): Share OAuth device polling intervals
Co-Authored-By: GPT-6 Sol <agent@openai.com>
1 parent 486de4b commit b9f0e55

8 files changed

Lines changed: 104 additions & 17 deletions

File tree

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
deviceCodeRequestBody,
1010
deviceTokenRequestBody,
1111
} from "@sentry/toolkit-core/oauth-device";
12+
import { nextDevicePollInterval } from "@sentry/toolkit-core/oauth-poll";
1213
import { safeParse } from "valibot";
1314
import type { TokenResponse } from "../types/index.js";
1415
import {
@@ -372,7 +373,7 @@ function pollForToken(deviceCode: string): Promise<TokenResponse> {
372373

373374
type PollResult =
374375
| { status: "success"; token: TokenResponse }
375-
| { status: "pending" }
376+
| { status: "authorization_pending" }
376377
| { status: "slow_down" }
377378
| { status: "error"; message: string };
378379

@@ -390,7 +391,7 @@ async function attemptPoll(deviceCode: string): Promise<PollResult> {
390391

391392
switch (error.code) {
392393
case "authorization_pending":
393-
return { status: "pending" };
394+
return { status: "authorization_pending" };
394395
case "slow_down":
395396
return { status: "slow_down" };
396397
case "expired_token":
@@ -460,10 +461,9 @@ export async function performDeviceFlow(
460461
switch (result.status) {
461462
case "success":
462463
return result.token;
463-
case "pending":
464-
continue;
464+
case "authorization_pending":
465465
case "slow_down":
466-
pollInterval += 5;
466+
pollInterval = nextDevicePollInterval(pollInterval, result.status);
467467
continue;
468468
case "error":
469469
throw new DeviceFlowError("authorization_failed", result.message);

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

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,66 @@ import {
1414
property,
1515
uniqueArray,
1616
} from "fast-check";
17-
import { describe, expect, test } from "vitest";
17+
import { afterEach, describe, expect, test, vi } from "vitest";
1818
import { SENTRY_SCOPES } from "../../src/lib/api-scope.js";
1919
import { ValidationError } from "../../src/lib/errors.js";
20-
import { OAUTH_SCOPES, resolveOAuthScopeString } from "../../src/lib/oauth.js";
20+
import {
21+
OAUTH_SCOPES,
22+
performDeviceFlow,
23+
resolveOAuthScopeString,
24+
} from "../../src/lib/oauth.js";
2125
import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js";
2226

2327
const knownScopeArb = constantFrom(...SENTRY_SCOPES);
2428

29+
afterEach(() => {
30+
vi.useRealTimers();
31+
vi.unstubAllGlobals();
32+
});
33+
34+
describe("performDeviceFlow polling", () => {
35+
test("keeps the interval for pending responses and adds five seconds after slow_down", async () => {
36+
vi.useFakeTimers();
37+
vi.setSystemTime(0);
38+
39+
const token = {
40+
access_token: "test-token",
41+
token_type: "Bearer",
42+
expires_in: 3600,
43+
};
44+
const fetchMock = vi
45+
.fn()
46+
.mockResolvedValueOnce(
47+
Response.json({
48+
device_code: "test-code",
49+
user_code: "ABCD",
50+
verification_uri: "https://sentry.example/oauth/device/",
51+
interval: 1,
52+
expires_in: 30,
53+
})
54+
)
55+
.mockResolvedValueOnce(
56+
Response.json({ error: "authorization_pending" }, { status: 400 })
57+
)
58+
.mockResolvedValueOnce(
59+
Response.json({ error: "slow_down" }, { status: 400 })
60+
)
61+
.mockResolvedValueOnce(Response.json(token));
62+
vi.stubGlobal("fetch", fetchMock);
63+
64+
const result = performDeviceFlow({ onUserCode: vi.fn() });
65+
await vi.advanceTimersByTimeAsync(1000);
66+
expect(fetchMock).toHaveBeenCalledTimes(2);
67+
await vi.advanceTimersByTimeAsync(1000);
68+
expect(fetchMock).toHaveBeenCalledTimes(3);
69+
await vi.advanceTimersByTimeAsync(5999);
70+
expect(fetchMock).toHaveBeenCalledTimes(3);
71+
await vi.advanceTimersByTimeAsync(1);
72+
await expect(result).resolves.toEqual(token);
73+
expect(fetchMock).toHaveBeenCalledTimes(4);
74+
});
75+
});
76+
2577
describe("resolveOAuthScopeString", () => {
2678
test("default scopes include Team Admin for project creation", () => {
2779
expect(OAUTH_SCOPES).toContain("team:admin");

‎packages/mcp-server/src/auth/constants.ts‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,6 @@ export const DEVICE_CODE_ENDPOINT = "/oauth/device/code/";
1515
export const TOKEN_ENDPOINT = "/oauth/token/";
1616
export const DEVICE_CODE_SCOPES = Object.keys(SCOPES).join(" ");
1717

18-
/** Interval increment on slow_down response (RFC 8628). */
19-
export const SLOW_DOWN_INCREMENT_SEC = 5;
20-
2118
/**
2219
* Whether device code auth is available for this host.
2320
* Supports sentry.io and regional subdomains (us.sentry.io, eu.sentry.io).

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ import {
55
deviceCodeRequestBody,
66
deviceTokenRequestBody,
77
} from "@sentry/toolkit-core/oauth-device";
8+
import { nextDevicePollInterval } from "@sentry/toolkit-core/oauth-poll";
89
import {
910
DEVICE_CODE_ENDPOINT,
1011
DEVICE_CODE_SCOPES,
11-
SLOW_DOWN_INCREMENT_SEC,
1212
TOKEN_ENDPOINT,
1313
} from "./constants";
1414
import {
@@ -102,10 +102,8 @@ export async function pollForToken({
102102

103103
switch (errorCode) {
104104
case "authorization_pending":
105-
// Keep polling at current interval
106-
continue;
107105
case "slow_down":
108-
pollInterval += SLOW_DOWN_INCREMENT_SEC;
106+
pollInterval = nextDevicePollInterval(pollInterval, errorCode);
109107
continue;
110108
case "access_denied":
111109
throw new DeviceCodeError(

‎packages/toolkit-core/README.md‎

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

6-
The shared code validates opaque bearer tokens and constructs OAuth device-flow
7-
form bodies. Each product retains its own credential storage, host trust checks,
8-
HTTP transport, response validation, and user-facing error types.
6+
The shared code validates opaque bearer tokens, constructs OAuth device-flow
7+
form bodies, and applies the RFC 8628 polling interval rule. Each product
8+
retains its own credential storage, host trust checks, polling deadline, HTTP
9+
transport, response validation, and user-facing error types.

‎packages/toolkit-core/package.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
"./oauth-device": {
1515
"types": "./src/oauth-device.ts",
1616
"default": "./src/oauth-device.ts"
17+
},
18+
"./oauth-poll": {
19+
"types": "./src/oauth-poll.ts",
20+
"default": "./src/oauth-poll.ts"
1721
}
1822
},
1923
"scripts": {
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, expect, it } from "vitest";
2+
import { nextDevicePollInterval } from "./oauth-poll";
3+
4+
describe("nextDevicePollInterval", () => {
5+
it.each([1, 5, 30])(
6+
"keeps a %is interval while authorization is pending",
7+
(interval) => {
8+
expect(nextDevicePollInterval(interval, "authorization_pending")).toBe(
9+
interval,
10+
);
11+
},
12+
);
13+
14+
it.each([1, 5, 30])(
15+
"adds five seconds to a %is interval on slow_down",
16+
(interval) => {
17+
expect(nextDevicePollInterval(interval, "slow_down")).toBe(interval + 5);
18+
},
19+
);
20+
21+
it("accumulates repeated slow_down responses", () => {
22+
const first = nextDevicePollInterval(1, "slow_down");
23+
expect(nextDevicePollInterval(first, "slow_down")).toBe(11);
24+
});
25+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/** RFC 8628 retry responses. The caller owns the polling deadline and errors. */
2+
export type DevicePollRetry = "authorization_pending" | "slow_down";
3+
4+
/** RFC 8628 section 3.5 requires five additional seconds after slow_down. */
5+
export function nextDevicePollInterval(
6+
intervalSeconds: number,
7+
response: DevicePollRetry,
8+
): number {
9+
return response === "slow_down" ? intervalSeconds + 5 : intervalSeconds;
10+
}

0 commit comments

Comments
 (0)