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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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
|
-
|
|
51
|
-
|
|
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
|
|
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
|
-
|
|
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);
|