@sentry/junior-github 0.74.1 → 0.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/index.d.ts +2 -4
  2. package/index.js +142 -51
  3. package/package.json +2 -2
package/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { JuniorPluginRegistration } from "@sentry/junior-plugin-api";
1
+ import type { PluginRegistration } from "@sentry/junior-plugin-api";
2
2
 
3
3
  export type GitHubAppPermissionLevel = "read" | "write" | "admin";
4
4
 
@@ -46,6 +46,4 @@ export interface GitHubPluginOptions {
46
46
  }
47
47
 
48
48
  /** Register GitHub manifest content and runtime hooks. */
49
- export function githubPlugin(
50
- options?: GitHubPluginOptions,
51
- ): JuniorPluginRegistration;
49
+ export function githubPlugin(options?: GitHubPluginOptions): PluginRegistration;
package/index.js CHANGED
@@ -13,6 +13,7 @@ const GITHUB_AUTH_TOKEN_ENV = "GITHUB_TOKEN";
13
13
  const GITHUB_AUTH_TOKEN_PLACEHOLDER = "ghp_host_managed_credential";
14
14
  const MAX_LEASE_MS = 60 * 60 * 1000;
15
15
  const REFRESH_BUFFER_MS = 5 * 60 * 1000;
16
+ const USER_REFRESH_TIMEOUT_MS = 20_000;
16
17
  const GITHUB_GRAPHQL_RESPONSE_BODY_LIMIT_BYTES = 64 * 1024;
17
18
  const HTTP_READ_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
18
19
  const USER_TOKEN_GRANTS = new Set(["user-read", "user-write"]);
@@ -290,20 +291,27 @@ function buildOAuthTokenRequest(input) {
290
291
  };
291
292
  }
292
293
 
293
- function parseOAuthError(text) {
294
- if (!text.trim()) {
294
+ function parseOAuthResponseJson(responseText) {
295
+ if (!responseText.trim()) {
295
296
  return undefined;
296
297
  }
297
298
  try {
298
- const parsed = JSON.parse(text);
299
- return isRecord(parsed) && typeof parsed.error === "string"
300
- ? parsed.error
301
- : undefined;
299
+ return JSON.parse(responseText);
302
300
  } catch {
303
301
  return undefined;
304
302
  }
305
303
  }
306
304
 
305
+ function oauthErrorCode(data) {
306
+ return isRecord(data) && typeof data.error === "string"
307
+ ? data.error
308
+ : undefined;
309
+ }
310
+
311
+ function isRejectedRefreshError(errorCode) {
312
+ return errorCode === "bad_refresh_token" || errorCode === "invalid_grant";
313
+ }
314
+
307
315
  function parseOAuthTokenResponse(data, requestedScope) {
308
316
  if (!isRecord(data)) {
309
317
  throw new Error("OAuth token response is invalid");
@@ -326,20 +334,30 @@ function parseOAuthTokenResponse(data, requestedScope) {
326
334
  refreshToken: data.refresh_token,
327
335
  ...(scope ? { scope } : {}),
328
336
  };
329
- if (data.expires_in === undefined) {
330
- return result;
337
+ if (data.expires_in !== undefined) {
338
+ if (
339
+ typeof data.expires_in !== "number" ||
340
+ !Number.isFinite(data.expires_in) ||
341
+ data.expires_in <= 0
342
+ ) {
343
+ throw new Error("OAuth token response returned invalid expires_in");
344
+ }
345
+ result.expiresAt = Date.now() + data.expires_in * 1000;
331
346
  }
332
- if (
333
- typeof data.expires_in !== "number" ||
334
- !Number.isFinite(data.expires_in) ||
335
- data.expires_in <= 0
336
- ) {
337
- throw new Error("OAuth token response returned invalid expires_in");
347
+ if (data.refresh_token_expires_in !== undefined) {
348
+ if (
349
+ typeof data.refresh_token_expires_in !== "number" ||
350
+ !Number.isFinite(data.refresh_token_expires_in) ||
351
+ data.refresh_token_expires_in <= 0
352
+ ) {
353
+ throw new Error(
354
+ "OAuth token response returned invalid refresh_token_expires_in",
355
+ );
356
+ }
357
+ result.refreshTokenExpiresAt =
358
+ Date.now() + data.refresh_token_expires_in * 1000;
338
359
  }
339
- return {
340
- ...result,
341
- expiresAt: Date.now() + data.expires_in * 1000,
342
- };
360
+ return result;
343
361
  }
344
362
 
345
363
  async function refreshUserAccessToken(input) {
@@ -357,19 +375,32 @@ async function refreshUserAccessToken(input) {
357
375
  method: "POST",
358
376
  headers: request.headers,
359
377
  body: request.body,
378
+ signal: AbortSignal.timeout(USER_REFRESH_TIMEOUT_MS),
360
379
  });
361
- if (!response.ok) {
362
- const errorCode = parseOAuthError(await response.text());
363
- if (errorCode === "bad_refresh_token" || errorCode === "invalid_grant") {
364
- throw new GitHubUserRefreshRejectedError(
365
- `GitHub user token refresh rejected: ${errorCode}`,
366
- );
367
- }
380
+ const responseText = await response.text();
381
+ const responseData = parseOAuthResponseJson(responseText);
382
+ const errorCode = oauthErrorCode(responseData);
383
+ if (isRejectedRefreshError(errorCode)) {
384
+ throw new GitHubUserRefreshRejectedError(
385
+ `GitHub user token refresh rejected: ${errorCode}`,
386
+ );
387
+ }
388
+ if (!response.ok || errorCode) {
368
389
  throw new Error(
369
390
  `GitHub user token refresh failed: ${response.status}${errorCode ? ` ${errorCode}` : ""}`,
370
391
  );
371
392
  }
372
- return parseOAuthTokenResponse(await response.json(), input.requestedScope);
393
+ try {
394
+ return parseOAuthTokenResponse(responseData, input.requestedScope);
395
+ } catch (error) {
396
+ if (
397
+ error instanceof Error &&
398
+ error.message === "OAuth token response missing access_token"
399
+ ) {
400
+ throw new GitHubUserRefreshRejectedError(error.message);
401
+ }
402
+ throw error;
403
+ }
373
404
  }
374
405
 
375
406
  function leaseExpiry(expiresAt) {
@@ -509,6 +540,83 @@ async function tokensWithAccount(tokenSlot, stored, scope) {
509
540
  return { ok: true, tokens: updated };
510
541
  }
511
542
 
543
+ function shouldRefreshUserToken(stored, now = Date.now()) {
544
+ return (
545
+ stored.expiresAt !== undefined && stored.expiresAt - now < REFRESH_BUFFER_MS
546
+ );
547
+ }
548
+
549
+ function canUseStoredUserToken(stored) {
550
+ return (
551
+ stored.expiresAt === undefined ||
552
+ (stored.expiresAt > Date.now() && !shouldRefreshUserToken(stored))
553
+ );
554
+ }
555
+
556
+ /** Re-read under the token-slot refresh gate so concurrent callers reuse the winner's rotated tokens. */
557
+ async function refreshUserTokensWithLock(tokenSlot, scope, options) {
558
+ return await tokenSlot.withRefresh(async () => {
559
+ const latest = await tokenSlot.get();
560
+ if (!latest) {
561
+ return {
562
+ ok: false,
563
+ result: credentialNeeded("Connect your GitHub account.", scope),
564
+ };
565
+ }
566
+ if (!hasRequiredOAuthScope(latest.scope, scope)) {
567
+ return {
568
+ ok: false,
569
+ result: credentialNeeded(
570
+ "Your GitHub authorization needs to be refreshed.",
571
+ scope,
572
+ ),
573
+ };
574
+ }
575
+ if (canUseStoredUserToken(latest)) {
576
+ return { ok: true, tokens: latest };
577
+ }
578
+
579
+ let refreshed;
580
+ try {
581
+ refreshed = await refreshUserAccessToken({
582
+ clientIdEnv: options.clientIdEnv,
583
+ clientSecretEnv: options.clientSecretEnv,
584
+ refreshToken: latest.refreshToken,
585
+ requestedScope: latest.scope ?? scope,
586
+ });
587
+ } catch (error) {
588
+ if (!(error instanceof GitHubUserRefreshRejectedError)) {
589
+ throw error;
590
+ }
591
+ return {
592
+ ok: false,
593
+ result: credentialNeeded(
594
+ "Your GitHub authorization has expired.",
595
+ scope,
596
+ ),
597
+ };
598
+ }
599
+ if (!hasRequiredOAuthScope(refreshed.scope, scope)) {
600
+ return {
601
+ ok: false,
602
+ result: credentialNeeded(
603
+ "Your GitHub authorization needs to be refreshed.",
604
+ scope,
605
+ ),
606
+ };
607
+ }
608
+ const refreshedTokens = {
609
+ ...(latest.refreshTokenExpiresAt
610
+ ? { refreshTokenExpiresAt: latest.refreshTokenExpiresAt }
611
+ : {}),
612
+ ...refreshed,
613
+ ...(latest.account ? { account: latest.account } : {}),
614
+ };
615
+ await tokenSlot.set(refreshedTokens);
616
+ return { ok: true, tokens: refreshedTokens };
617
+ });
618
+ }
619
+
512
620
  async function issueUserCredential(ctx, options) {
513
621
  const scope = options.userScope;
514
622
  const tokenSlot = ctx.tokens.currentUser ?? ctx.tokens.credentialSubject;
@@ -539,34 +647,17 @@ async function issueUserCredential(ctx, options) {
539
647
  stored.expiresAt !== undefined &&
540
648
  stored.expiresAt - now < REFRESH_BUFFER_MS
541
649
  ) {
542
- let refreshed;
543
- try {
544
- refreshed = await refreshUserAccessToken({
545
- clientIdEnv: options.clientIdEnv,
546
- clientSecretEnv: options.clientSecretEnv,
547
- refreshToken: stored.refreshToken,
548
- requestedScope: stored.scope ?? scope,
549
- });
550
- } catch (error) {
551
- if (!(error instanceof GitHubUserRefreshRejectedError)) {
552
- throw error;
553
- }
554
- return credentialNeeded("Your GitHub authorization has expired.", scope);
555
- }
556
- if (!hasRequiredOAuthScope(refreshed.scope, scope)) {
557
- return credentialNeeded(
558
- "Your GitHub authorization needs to be refreshed.",
559
- scope,
560
- );
650
+ const refreshResult = await refreshUserTokensWithLock(
651
+ tokenSlot,
652
+ scope,
653
+ options,
654
+ );
655
+ if (!refreshResult.ok) {
656
+ return refreshResult.result;
561
657
  }
562
- const refreshedTokens = {
563
- ...refreshed,
564
- ...(stored.account ? { account: stored.account } : {}),
565
- };
566
- await tokenSlot.set(refreshedTokens);
567
658
  const withAccount = await tokensWithAccount(
568
659
  tokenSlot,
569
- refreshedTokens,
660
+ refreshResult.tokens,
570
661
  scope,
571
662
  );
572
663
  if (!withAccount.ok) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentry/junior-github",
3
- "version": "0.74.1",
3
+ "version": "0.76.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -25,6 +25,6 @@
25
25
  "SETUP.md"
26
26
  ],
27
27
  "dependencies": {
28
- "@sentry/junior-plugin-api": "0.74.1"
28
+ "@sentry/junior-plugin-api": "0.76.0"
29
29
  }
30
30
  }