-
-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathoauth.ts
More file actions
380 lines (341 loc) · 10.3 KB
/
oauth.ts
File metadata and controls
380 lines (341 loc) · 10.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
/**
* OAuth Authentication
*
* Implements RFC 8628 Device Authorization Grant for Sentry OAuth.
* https://datatracker.ietf.org/doc/html/rfc8628
*/
import type { TokenResponse } from "../types/index.js";
import {
DeviceCodeResponseSchema,
TokenErrorResponseSchema,
TokenResponseSchema,
} from "../types/index.js";
import { setAuthToken } from "./db/auth.js";
import { ApiError, AuthError, ConfigError, DeviceFlowError } from "./errors.js";
import { withHttpSpan } from "./telemetry.js";
// Sentry instance URL (supports self-hosted via env override)
const SENTRY_URL = process.env.SENTRY_URL ?? "https://sentry.io";
/**
* OAuth client ID
*
* Build-time: Injected via Bun.build({ define: { SENTRY_CLIENT_ID: "..." } })
* Runtime: Can be overridden via SENTRY_CLIENT_ID env var (for self-hosted)
*
* Read at call time (not module load time) so tests can set process.env.SENTRY_CLIENT_ID
* after module initialization.
*
* @see script/build.ts
*/
declare const SENTRY_CLIENT_ID_BUILD: string | undefined;
function getClientId(): string {
return (
process.env.SENTRY_CLIENT_ID ??
(typeof SENTRY_CLIENT_ID_BUILD !== "undefined"
? SENTRY_CLIENT_ID_BUILD
: "")
);
}
// OAuth scopes requested for the CLI
const SCOPES = [
"project:read",
"project:write",
"org:read",
"event:read",
"event:write",
"member:read",
"team:read",
].join(" ");
type DeviceFlowCallbacks = {
onUserCode: (
userCode: string,
verificationUri: string,
verificationUriComplete: string
) => void | Promise<void>;
onPolling?: () => void;
};
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Wrap a fetch call with connection error handling.
* Converts network errors into user-friendly ApiError messages.
*/
async function fetchWithConnectionError(
url: string,
init: RequestInit
): Promise<Response> {
try {
return await fetch(url, init);
} catch (error) {
const isConnectionError =
error instanceof Error &&
(error.message.includes("ECONNREFUSED") ||
error.message.includes("fetch failed") ||
error.message.includes("network"));
if (isConnectionError) {
throw new ApiError(
`Cannot connect to Sentry at ${SENTRY_URL}`,
0,
"Check your network connection and SENTRY_URL configuration"
);
}
throw error;
}
}
/** Request a device code from Sentry's device authorization endpoint */
function requestDeviceCode() {
const clientId = getClientId();
if (!clientId) {
throw new ConfigError(
"SENTRY_CLIENT_ID is required for authentication",
"Set SENTRY_CLIENT_ID environment variable or use a pre-built binary"
);
}
return withHttpSpan("POST", "/oauth/device/code/", async () => {
const response = await fetchWithConnectionError(
`${SENTRY_URL}/oauth/device/code/`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: clientId,
scope: SCOPES,
}),
}
);
if (!response.ok) {
const errorText = await response.text();
throw new ApiError(
"Failed to initiate device flow",
response.status,
errorText,
"/oauth/device/code/"
);
}
const data = await response.json();
const result = DeviceCodeResponseSchema.safeParse(data);
if (!result.success) {
throw new ApiError(
"Invalid response from device authorization endpoint",
response.status,
result.error.errors.map((e) => e.message).join(", "),
"/oauth/device/code/"
);
}
return result.data;
});
}
/**
* Poll Sentry's token endpoint for the access token
*/
function pollForToken(deviceCode: string): Promise<TokenResponse> {
return withHttpSpan("POST", "/oauth/token/", async () => {
const response = await fetchWithConnectionError(
`${SENTRY_URL}/oauth/token/`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: getClientId(),
device_code: deviceCode,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}),
}
);
const data = await response.json();
// Try to parse as success response first
const tokenResult = TokenResponseSchema.safeParse(data);
if (tokenResult.success) {
return tokenResult.data;
}
// Try to parse as error response
const errorResult = TokenErrorResponseSchema.safeParse(data);
if (errorResult.success) {
throw new DeviceFlowError(
errorResult.data.error,
errorResult.data.error_description
);
}
// If neither schema matches, throw a generic error
throw new ApiError(
"Unexpected response from token endpoint",
response.status,
JSON.stringify(data),
"/oauth/token/"
);
});
}
type PollResult =
| { status: "success"; token: TokenResponse }
| { status: "pending" }
| { status: "slow_down" }
| { status: "error"; message: string };
/**
* Handle a single poll attempt, returning a result object
*/
async function attemptPoll(deviceCode: string): Promise<PollResult> {
try {
const token = await pollForToken(deviceCode);
return { status: "success", token };
} catch (error) {
if (!(error instanceof DeviceFlowError)) {
throw error;
}
switch (error.code) {
case "authorization_pending":
return { status: "pending" };
case "slow_down":
return { status: "slow_down" };
case "expired_token":
return {
status: "error",
message: "Device code expired. Please run 'sentry auth login' again.",
};
case "access_denied":
return {
status: "error",
message: "Authorization was denied. Please try again.",
};
default:
return { status: "error", message: error.message };
}
}
}
/**
* Perform the Device Flow for OAuth authentication (RFC 8628).
*
* Initiates the device authorization flow by requesting a device code,
* then polls for the access token until the user completes authorization.
*
* @param callbacks - Callbacks for UI updates during the flow
* @param timeout - Maximum time to wait for authorization in ms (default: 10 minutes)
* @returns The token response containing access_token and metadata
* @throws {ConfigError} When SENTRY_CLIENT_ID is not configured
* @throws {ApiError} When unable to connect to Sentry or API returns an error
* @throws {DeviceFlowError} When authorization fails, is denied, or times out
*/
export async function performDeviceFlow(
callbacks: DeviceFlowCallbacks,
timeout = 600_000 // 10 minutes default (matches Sentry's expires_in)
): Promise<TokenResponse> {
// Step 1: Request device code
const {
device_code,
user_code,
verification_uri,
verification_uri_complete,
interval,
expires_in,
} = await requestDeviceCode();
// Notify caller of the user code
await callbacks.onUserCode(
user_code,
verification_uri,
verification_uri_complete ?? `${verification_uri}?user_code=${user_code}`
);
// Calculate absolute timeout
const timeoutAt = Date.now() + Math.min(timeout, expires_in * 1000);
// Track polling interval (may increase on slow_down)
let pollInterval = interval;
// Step 2: Poll for token
while (Date.now() < timeoutAt) {
await sleep(pollInterval * 1000);
callbacks.onPolling?.();
const result = await attemptPoll(device_code);
switch (result.status) {
case "success":
return result.token;
case "pending":
continue;
case "slow_down":
pollInterval += 5;
continue;
case "error":
throw new DeviceFlowError("authorization_failed", result.message);
default:
throw new DeviceFlowError("unexpected_error", "Unexpected poll result");
}
}
throw new DeviceFlowError(
"expired_token",
"Authentication timed out. Please try again."
);
}
/**
* Complete the OAuth flow by storing the token in the config file.
*
* @param tokenResponse - The token response from performDeviceFlow
*/
export async function completeOAuthFlow(
tokenResponse: TokenResponse
): Promise<void> {
await setAuthToken(
tokenResponse.access_token,
tokenResponse.expires_in,
tokenResponse.refresh_token
);
}
/**
* Store an API token directly (alternative to OAuth device flow).
*
* Use this for users who have an existing API token from Sentry settings.
*
* @param token - The API token to store
*/
export async function setApiToken(token: string): Promise<void> {
await setAuthToken(token);
}
/** Refresh an access token using a refresh token. */
export function refreshAccessToken(
refreshToken: string
): Promise<TokenResponse> {
const clientId = getClientId();
if (!clientId) {
throw new ConfigError(
"SENTRY_CLIENT_ID is required for token refresh",
"Set SENTRY_CLIENT_ID environment variable or use a pre-built binary"
);
}
return withHttpSpan("POST", "/oauth/token/", async () => {
const response = await fetchWithConnectionError(
`${SENTRY_URL}/oauth/token/`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: clientId,
grant_type: "refresh_token",
refresh_token: refreshToken,
}),
}
);
if (!response.ok) {
let errorDetail = "Token refresh failed";
try {
const errorData = await response.json();
const errorResult = TokenErrorResponseSchema.safeParse(errorData);
if (errorResult.success) {
errorDetail =
errorResult.data.error_description ?? errorResult.data.error;
}
} catch {
// Ignore JSON parse errors
}
throw new AuthError(
"expired",
`Session expired: ${errorDetail}. Run 'sentry auth login' to re-authenticate.`
);
}
const data = await response.json();
const result = TokenResponseSchema.safeParse(data);
if (!result.success) {
throw new ApiError(
"Invalid response from token refresh endpoint",
response.status,
result.error.errors.map((e) => e.message).join(", "),
"/oauth/token/"
);
}
return result.data;
});
}