Skip to content
Merged
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
32 changes: 16 additions & 16 deletions packages/cli/src/lib/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
deviceCodeRequestBody,
deviceTokenRequestBody,
} from "@sentry/toolkit-core/oauth-device";
import { nextDevicePollInterval } from "@sentry/toolkit-core/oauth-poll";
import { advanceDevicePoll } from "@sentry/toolkit-core/oauth-poll";
import { safeParse } from "valibot";
import type { TokenResponse } from "../types/index.js";
import {
Expand Down Expand Up @@ -373,14 +373,16 @@ function pollForToken(deviceCode: string): Promise<TokenResponse> {

type PollResult =
| { status: "success"; token: TokenResponse }
| { status: "authorization_pending" }
| { status: "slow_down" }
| { status: "retry"; intervalSeconds: number }
| { status: "error"; message: string };

/**
* Handle a single poll attempt, returning a result object
*/
async function attemptPoll(deviceCode: string): Promise<PollResult> {
async function attemptPoll(
deviceCode: string,
intervalSeconds: number,
): Promise<PollResult> {
try {
const token = await pollForToken(deviceCode);
return { status: "success", token };
Expand All @@ -389,22 +391,21 @@ async function attemptPoll(deviceCode: string): Promise<PollResult> {
throw error;
}

switch (error.code) {
case "authorization_pending":
return { status: "authorization_pending" };
case "slow_down":
return { status: "slow_down" };
case "expired_token":
const outcome = advanceDevicePoll(intervalSeconds, error.code);
switch (outcome.status) {
case "retry":
return outcome;
case "expired":
return {
status: "error",
message: "Device code expired. Please run 'sentry auth login' again.",
};
case "access_denied":
case "denied":
return {
status: "error",
message: "Authorization was denied. Please try again.",
};
default:
case "unexpected":
return { status: "error", message: error.message };
}
}
Expand Down Expand Up @@ -456,14 +457,13 @@ export async function performDeviceFlow(
while (Date.now() < timeoutAt) {
await sleep(pollInterval * 1000);

const result = await attemptPoll(device_code);
const result = await attemptPoll(device_code, pollInterval);

switch (result.status) {
case "success":
return result.token;
case "authorization_pending":
case "slow_down":
pollInterval = nextDevicePollInterval(pollInterval, result.status);
case "retry":
pollInterval = result.intervalSeconds;
continue;
case "error":
throw new DeviceFlowError("authorization_failed", result.message);
Expand Down
40 changes: 40 additions & 0 deletions packages/cli/test/lib/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,46 @@ describe("performDeviceFlow polling", () => {
await expect(result).resolves.toEqual(token);
expect(fetchMock).toHaveBeenCalledTimes(4);
});

test.each([
["access_denied", "Authorization was denied. Please try again."],
[
"expired_token",
"Device code expired. Please run 'sentry auth login' again.",
],
["invalid_grant", "Token was rejected"],
])("reports %s with the CLI's error message", async (code, message) => {
vi.useFakeTimers();
vi.setSystemTime(0);
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
Response.json({
device_code: "test-code",
user_code: "ABCD",
verification_uri: "https://sentry.example/oauth/device/",
interval: 1,
expires_in: 30,
}),
)
.mockResolvedValueOnce(
Response.json(
{ error: code, error_description: "Token was rejected" },
{ status: 400 },
),
);
vi.stubGlobal("fetch", fetchMock);

const result = performDeviceFlow({ onUserCode: vi.fn() });
const rejection = expect(result).rejects.toMatchObject({
name: "DeviceFlowError",
code: "authorization_failed",
message,
});
await vi.advanceTimersByTimeAsync(1000);
await rejection;
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});

describe("resolveOAuthScopeString", () => {
Expand Down
23 changes: 23 additions & 0 deletions packages/mcp-server/src/auth/device-code-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,29 @@ describe("pollForToken", () => {
).rejects.toThrow(/expired/);
});

it("reports an unexpected token error without retrying", async () => {
const fetchMock = vi.mocked(fetch).mockResolvedValueOnce(
new Response(JSON.stringify({ error: "invalid_grant" }), {
status: 400,
}),
);

await expect(
pollForToken({
deviceCode: "test-device-code",
clientId: "test-client-id",
host: "sentry.io",
interval: 0.01,
expiresIn: 10,
}),
).rejects.toMatchObject({
name: "DeviceCodeError",
code: "invalid_grant",
message: "Unexpected error during device code polling: invalid_grant",
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("increases interval on slow_down", async () => {
vi.useFakeTimers();
const fetchMock = vi.mocked(fetch);
Expand Down
16 changes: 8 additions & 8 deletions packages/mcp-server/src/auth/device-code-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
deviceCodeRequestBody,
deviceTokenRequestBody,
} from "@sentry/toolkit-core/oauth-device";
import { nextDevicePollInterval } from "@sentry/toolkit-core/oauth-poll";
import { advanceDevicePoll } from "@sentry/toolkit-core/oauth-poll";
import {
DEVICE_CODE_ENDPOINT,
DEVICE_CODE_SCOPES,
Expand Down Expand Up @@ -100,22 +100,22 @@ export async function pollForToken({
const parsed = DeviceCodeErrorSchema.safeParse(errorBody);
const errorCode = parsed.success ? parsed.data.error : undefined;

switch (errorCode) {
case "authorization_pending":
case "slow_down":
pollInterval = nextDevicePollInterval(pollInterval, errorCode);
const outcome = advanceDevicePoll(pollInterval, errorCode);
switch (outcome.status) {
case "retry":
pollInterval = outcome.intervalSeconds;
continue;
case "access_denied":
case "denied":
throw new DeviceCodeError(
"Authorization was denied. Please try again or provide --access-token.",
errorCode,
);
case "expired_token":
case "expired":
throw new DeviceCodeError(
"Device code expired before authorization was completed.",
errorCode,
);
default:
case "unexpected":
throw new DeviceCodeError(
`Unexpected error during device code polling: ${errorCode ?? resp.statusText}`,
errorCode,
Expand Down
8 changes: 4 additions & 4 deletions packages/toolkit-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Pure protocol and hostname helpers shared by the CLI and MCP. Both product
builds bundle this private workspace package into their artifacts.

The shared code validates opaque bearer tokens, constructs OAuth device-flow
form bodies, applies the RFC 8628 polling interval rule, and recognizes Sentry
hostnames. Each product retains its own credential storage, URL and host trust
checks, regional routing, polling deadline, HTTP transport, response validation,
and user-facing error types.
form bodies, classifies RFC 8628 polling responses, advances retry intervals,
and recognizes Sentry hostnames. Each product retains its own credential
storage, URL and host trust checks, regional routing, polling deadline, HTTP
transport, response validation, and user-facing error types.
28 changes: 27 additions & 1 deletion packages/toolkit-core/src/oauth-poll.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { nextDevicePollInterval } from "./oauth-poll";
import { advanceDevicePoll, nextDevicePollInterval } from "./oauth-poll";

describe("nextDevicePollInterval", () => {
it.each([1, 5, 30])(
Expand All @@ -23,3 +23,29 @@ describe("nextDevicePollInterval", () => {
expect(nextDevicePollInterval(first, "slow_down")).toBe(11);
});
});

describe("advanceDevicePoll", () => {
it("retains the interval for pending and increases it for repeated slow_down", () => {
const pending = advanceDevicePoll(1, "authorization_pending");
expect(pending).toEqual({ status: "retry", intervalSeconds: 1 });

const first = advanceDevicePoll(1, "slow_down");
expect(first).toEqual({ status: "retry", intervalSeconds: 6 });
if (first.status !== "retry") {
throw new Error("slow_down must retry");
}
expect(advanceDevicePoll(first.intervalSeconds, "slow_down")).toEqual({
status: "retry",
intervalSeconds: 11,
});
});

it.each([
["access_denied", "denied"],
["expired_token", "expired"],
["invalid_grant", "unexpected"],
[undefined, "unexpected"],
] as const)("classifies %s without retrying", (errorCode, status) => {
expect(advanceDevicePoll(5, errorCode)).toEqual({ status });
});
});
26 changes: 26 additions & 0 deletions packages/toolkit-core/src/oauth-poll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,29 @@ export function nextDevicePollInterval(
): number {
return response === "slow_down" ? intervalSeconds + 5 : intervalSeconds;
}

/** RFC 8628 token-poll outcome; callers own deadlines and user-facing errors. */
export type DevicePollOutcome =
| { status: "retry"; intervalSeconds: number }
| { status: "denied" | "expired" | "unexpected" };

/** Apply the device-flow retry rules without deciding how to report failure. */
export function advanceDevicePoll(
intervalSeconds: number,
errorCode: string | undefined,
): DevicePollOutcome {
switch (errorCode) {
case "authorization_pending":
case "slow_down":
return {
status: "retry",
intervalSeconds: nextDevicePollInterval(intervalSeconds, errorCode),
};
case "access_denied":
return { status: "denied" };
case "expired_token":
return { status: "expired" };
default:
return { status: "unexpected" };
}
}
Loading