backlog-mcp-server 0.20.2 → 0.20.3

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.
@@ -1,5 +1,29 @@
1
1
  import type { BacklogOAuthConfig } from './backlogOAuthConfig.js';
2
2
  import type { BacklogTokenData } from './tokenStore.js';
3
+ /**
4
+ * A failed Backlog token call, carrying what the caller needs to decide what to
5
+ * tell the client.
6
+ *
7
+ * Whether Backlog rejected the credential or could not be reached decides
8
+ * whether the client should authorize again or back off and retry. Folded into
9
+ * a message string the two are the same exception, and the caller is left
10
+ * parsing prose to tell them apart.
11
+ *
12
+ * `status` is absent when the request never produced a response — DNS failure,
13
+ * refused connection, timeout — which is unambiguously the retry case, so "no
14
+ * status" reads as "not a rejection" without having to guess.
15
+ *
16
+ * `errorCode` is the OAuth error code from the response body, which is where
17
+ * RFC 6749 §5.2 puts the reason. It is the more precise of the two: the status
18
+ * alone cannot separate `invalid_grant` from `invalid_client`, and those ask
19
+ * the caller for opposite behaviour. Absent when the body is not an OAuth error
20
+ * object, which is every response outside the token endpoint.
21
+ */
22
+ export declare class BacklogTokenError extends Error {
23
+ readonly status?: number | undefined;
24
+ readonly errorCode?: string | undefined;
25
+ constructor(message: string, status?: number | undefined, errorCode?: string | undefined);
26
+ }
3
27
  export declare function buildBacklogAuthorizationUrl(config: BacklogOAuthConfig, redirectUri: string, state: string): string;
4
28
  export declare function exchangeBacklogCode(config: BacklogOAuthConfig, code: string, redirectUri: string): Promise<BacklogTokenData>;
5
29
  export declare function refreshBacklogToken(config: BacklogOAuthConfig, refreshToken: string): Promise<BacklogTokenData>;
@@ -1,5 +1,56 @@
1
1
  // Copyright (c) 2025 Nulab inc.
2
2
  // Licensed under the MIT License.
3
+ /**
4
+ * A failed Backlog token call, carrying what the caller needs to decide what to
5
+ * tell the client.
6
+ *
7
+ * Whether Backlog rejected the credential or could not be reached decides
8
+ * whether the client should authorize again or back off and retry. Folded into
9
+ * a message string the two are the same exception, and the caller is left
10
+ * parsing prose to tell them apart.
11
+ *
12
+ * `status` is absent when the request never produced a response — DNS failure,
13
+ * refused connection, timeout — which is unambiguously the retry case, so "no
14
+ * status" reads as "not a rejection" without having to guess.
15
+ *
16
+ * `errorCode` is the OAuth error code from the response body, which is where
17
+ * RFC 6749 §5.2 puts the reason. It is the more precise of the two: the status
18
+ * alone cannot separate `invalid_grant` from `invalid_client`, and those ask
19
+ * the caller for opposite behaviour. Absent when the body is not an OAuth error
20
+ * object, which is every response outside the token endpoint.
21
+ */
22
+ export class BacklogTokenError extends Error {
23
+ status;
24
+ errorCode;
25
+ constructor(message, status, errorCode) {
26
+ super(message);
27
+ this.status = status;
28
+ this.errorCode = errorCode;
29
+ this.name = 'BacklogTokenError';
30
+ }
31
+ }
32
+ /**
33
+ * The `error` field of an RFC 6749 §5.2 error response, if the body is one.
34
+ *
35
+ * Best effort by design: a proxy or a WAF can answer the token endpoint with
36
+ * HTML, and a body that is not an OAuth error object simply carries no code.
37
+ */
38
+ function readOAuthErrorCode(body) {
39
+ try {
40
+ const parsed = JSON.parse(body);
41
+ if (typeof parsed === 'object' &&
42
+ parsed !== null &&
43
+ 'error' in parsed &&
44
+ typeof parsed.error === 'string') {
45
+ return parsed.error;
46
+ }
47
+ }
48
+ catch {
49
+ // Not JSON. Nothing to read, and nothing worth reporting either: the
50
+ // status and the raw text are already in the message.
51
+ }
52
+ return undefined;
53
+ }
3
54
  export function buildBacklogAuthorizationUrl(config, redirectUri, state) {
4
55
  const params = new URLSearchParams({
5
56
  response_type: 'code',
@@ -35,23 +86,35 @@ export async function refreshBacklogToken(config, refreshToken) {
35
86
  client_secret: config.clientSecret,
36
87
  refresh_token: refreshToken,
37
88
  });
38
- const response = await fetch(`https://${config.backlogDomain}/api/v2/oauth2/token`, {
39
- method: 'POST',
40
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
41
- body: params.toString(),
42
- });
89
+ let response;
90
+ try {
91
+ response = await fetch(`https://${config.backlogDomain}/api/v2/oauth2/token`, {
92
+ method: 'POST',
93
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
94
+ body: params.toString(),
95
+ });
96
+ }
97
+ catch (err) {
98
+ throw new BacklogTokenError(`Could not reach Backlog to refresh the token: ${String(err)}`);
99
+ }
43
100
  if (!response.ok) {
44
101
  const text = await response.text();
45
- throw new Error(`Backlog token refresh failed (${response.status}): ${text}`);
102
+ throw new BacklogTokenError(`Backlog token refresh failed (${response.status}): ${text}`, response.status, readOAuthErrorCode(text));
46
103
  }
47
104
  return await response.json();
48
105
  }
49
106
  export async function verifyBacklogToken(domain, accessToken) {
50
- const response = await fetch(`https://${domain}/api/v2/users/myself`, {
51
- headers: { Authorization: `Bearer ${accessToken}` },
52
- });
107
+ let response;
108
+ try {
109
+ response = await fetch(`https://${domain}/api/v2/users/myself`, {
110
+ headers: { Authorization: `Bearer ${accessToken}` },
111
+ });
112
+ }
113
+ catch (err) {
114
+ throw new BacklogTokenError(`Could not reach Backlog to verify the token: ${String(err)}`);
115
+ }
53
116
  if (!response.ok) {
54
- throw new Error(`Backlog token verification failed (${response.status})`);
117
+ throw new BacklogTokenError(`Backlog token verification failed (${response.status})`, response.status);
55
118
  }
56
119
  return await response.json();
57
120
  }
@@ -1,6 +1,6 @@
1
1
  // Copyright (c) 2025 Nulab inc.
2
2
  // Licensed under the MIT License.
3
- import { verifyBacklogToken } from './backlogOAuthClient.js';
3
+ import { BacklogTokenError, verifyBacklogToken } from './backlogOAuthClient.js';
4
4
  import { hasBacklogAuthErrorBeenReported, runWithAccessToken, } from './backlogAuthContext.js';
5
5
  import { logger } from '../utils/logger.js';
6
6
  const CACHE_TTL_MS = 5 * 60 * 1000;
@@ -44,7 +44,27 @@ export function createBearerAuthMiddleware(store, config, mcpPath) {
44
44
  store.cacheVerification(mcpToken, authInfo, CACHE_TTL_MS);
45
45
  }
46
46
  catch (err) {
47
- logger.warn({ err }, 'Bearer token verification failed');
47
+ // Only Backlog rejecting the token means this client should
48
+ // authenticate again. A Backlog outage answered with 401 would send
49
+ // every connected client through the whole authorization flow, and the
50
+ // token that flow produced would fail verification just the same.
51
+ const rejected = err instanceof BacklogTokenError &&
52
+ (err.status === 401 || err.status === 403);
53
+ if (!rejected) {
54
+ logger.error({ err }, 'Could not verify the bearer token with Backlog');
55
+ c.header('Retry-After', '30');
56
+ return c.json({
57
+ error: 'temporarily_unavailable',
58
+ error_description: 'Could not verify the token with Backlog',
59
+ }, 503);
60
+ }
61
+ // A rejection here is the same fact `onAuthError` below acts on — the
62
+ // stored Backlog token is spent — so it gets the same treatment.
63
+ // Dropping the entry is what makes this recoverable: the client's next
64
+ // request fails the `getMcpToken` check above, and it reaches for its
65
+ // refresh token instead of replaying a credential that cannot work.
66
+ logger.warn({ err, clientId: tokenEntry.clientId }, 'Backlog rejected the stored access token during verification; revoking the MCP token');
67
+ store.revokeMcpToken(mcpToken);
48
68
  return unauthorized('Token verification failed');
49
69
  }
50
70
  }
@@ -2,7 +2,7 @@
2
2
  // Licensed under the MIT License.
3
3
  import { randomUUID, randomBytes, createHash } from 'node:crypto';
4
4
  import { Hono } from 'hono';
5
- import { buildBacklogAuthorizationUrl, exchangeBacklogCode, refreshBacklogToken, } from './backlogOAuthClient.js';
5
+ import { BacklogTokenError, buildBacklogAuthorizationUrl, exchangeBacklogCode, refreshBacklogToken, } from './backlogOAuthClient.js';
6
6
  import { logger } from '../utils/logger.js';
7
7
  const AUTH_CODE_TTL_MS = 10 * 60 * 1000;
8
8
  const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
@@ -392,6 +392,34 @@ export function createOAuthRoutes(config, store, mcpPath) {
392
392
  });
393
393
  }
394
394
  catch (err) {
395
+ // `invalid_grant` is Backlog stating the grant is gone: revoked from
396
+ // the user's settings, or a refresh token it no longer recognises.
397
+ // That is the one answer that moves the client to a fresh
398
+ // authorization. Reported as 503 the same failure asks it to back off
399
+ // and retry a grant that can never come back, so it retries on a
400
+ // schedule forever. The consumed entry stays consumed in this branch:
401
+ // what it holds is a refresh token Backlog has already disowned, and
402
+ // keeping it until its TTL lapses only hands the next attempt the same
403
+ // dead credential.
404
+ //
405
+ // The code is read from the body rather than inferred from the status,
406
+ // because the status cannot separate the two rejections the token
407
+ // endpoint makes: `invalid_client` rejects *this server's* credentials,
408
+ // which is the operator's misconfiguration and not the client's grant,
409
+ // and re-authorizing would fail at the same wall. A bare 400 with no
410
+ // readable code is still treated as a dead grant — that is what the
411
+ // status means on this endpoint when nothing more specific is said.
412
+ //
413
+ // Everything else leaves the grant's fate unknown — unreachable, a
414
+ // timeout, a 5xx, a rejected client secret — so the entry goes back and
415
+ // the client is told to retry.
416
+ const grantIsGone = err instanceof BacklogTokenError &&
417
+ (err.errorCode === 'invalid_grant' ||
418
+ (err.status === 400 && err.errorCode === undefined));
419
+ if (grantIsGone) {
420
+ logger.warn({ err, clientId }, 'Backlog no longer recognizes the refresh grant; the client must authorize again');
421
+ return c.json(oauthError('invalid_grant', 'Backlog no longer recognizes this grant. A new authorization is required.'), 400);
422
+ }
395
423
  logger.error({ err }, 'Failed to refresh Backlog token');
396
424
  store.storeMcpRefreshToken(refreshToken, refreshEntry);
397
425
  return c.json(oauthError('server_error', 'Failed to refresh upstream token'), 503);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backlog-mcp-server",
3
- "version": "0.20.2",
3
+ "version": "0.20.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "backlog-mcp-server": "./build/index.js"