@dynatrace-oss/dynatrace-mcp-server 1.5.0 → 1.5.2
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.
|
@@ -78,6 +78,15 @@ const createOAuthClientCredentialsHttpClient = async (environmentUrl, scopes, cl
|
|
|
78
78
|
// now that we have the access token, we can just use a plain bearer token client
|
|
79
79
|
return createBearerTokenHttpClient(environmentUrl, tokenResponse.access_token);
|
|
80
80
|
};
|
|
81
|
+
/**
|
|
82
|
+
* Shared promise for an in-progress token refresh (prevents concurrent refresh races).
|
|
83
|
+
* When multiple callers find an expired token simultaneously, only the first starts a refresh;
|
|
84
|
+
* all others await the same promise so the refresh token is only consumed once.
|
|
85
|
+
*
|
|
86
|
+
* Note: this relies on Node.js's single-threaded event loop — no concurrent synchronous
|
|
87
|
+
* access can occur between the null-check and the assignment below.
|
|
88
|
+
*/
|
|
89
|
+
let ongoingRefreshPromise = null;
|
|
81
90
|
/** Create an OAuth Client using authorization code flow (interactive authentication)
|
|
82
91
|
* This starts a local HTTP server to handle the OAuth redirect and requires user interaction.
|
|
83
92
|
* Implements an in-memory token cache (not persisted to disk). After every server restart a new
|
|
@@ -97,13 +106,22 @@ const createOAuthAuthCodeFlowHttpClient = async (environmentUrl, scopes, clientI
|
|
|
97
106
|
// just use the cached token as a bearer token
|
|
98
107
|
return createBearerTokenHttpClient(environmentUrl, cachedToken.access_token);
|
|
99
108
|
}
|
|
100
|
-
// If we have an expired token that can be refreshed, refresh it
|
|
109
|
+
// If we have an expired token that can be refreshed, refresh it.
|
|
110
|
+
// Use a single shared promise to deduplicate concurrent refresh attempts so the refresh token
|
|
111
|
+
// is only consumed once (OAuth refresh tokens are rotated/invalidated on use).
|
|
101
112
|
if (cachedToken && cachedToken.refresh_token && !isValid) {
|
|
102
113
|
const expiresIn = cachedToken.expires_at ? Math.round((cachedToken.expires_at - Date.now()) / 1000) : 'never';
|
|
103
|
-
|
|
114
|
+
if (!ongoingRefreshPromise) {
|
|
115
|
+
console.error(`🔍 Auth-Code-Flow: Found expired cached token (expires in ${expiresIn}s), attempting refresh...`);
|
|
116
|
+
ongoingRefreshPromise = (0, dynatrace_oauth_auth_code_flow_1.refreshAccessToken)(ssoBaseURL, clientId, cachedToken.refresh_token, scopes).finally(() => {
|
|
117
|
+
ongoingRefreshPromise = null;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
console.error(`🔄 Token refresh already in progress, waiting for it to complete...`);
|
|
122
|
+
}
|
|
104
123
|
try {
|
|
105
|
-
|
|
106
|
-
const tokenResponse = await (0, dynatrace_oauth_auth_code_flow_1.refreshAccessToken)(ssoBaseURL, clientId, cachedToken.refresh_token, scopes);
|
|
124
|
+
const tokenResponse = await ongoingRefreshPromise;
|
|
107
125
|
if (tokenResponse.access_token && !tokenResponse.error) {
|
|
108
126
|
console.error(`✅ Successfully refreshed access token!`);
|
|
109
127
|
// Update the cache with the new token
|
|
@@ -18,6 +18,7 @@ global.fetch = jest.fn();
|
|
|
18
18
|
const mockPlatformHttpClient = http_client_1.PlatformHttpClient;
|
|
19
19
|
const mockGetSSOUrl = get_sso_url_1.getSSOUrl;
|
|
20
20
|
const mockPerformOAuthAuthorizationCodeFlow = dynatrace_oauth_auth_code_flow_1.performOAuthAuthorizationCodeFlow;
|
|
21
|
+
const mockRefreshAccessToken = dynatrace_oauth_auth_code_flow_1.refreshAccessToken;
|
|
21
22
|
const mockGlobalTokenCache = token_cache_1.globalTokenCache;
|
|
22
23
|
const mockFetch = global.fetch;
|
|
23
24
|
describe('dynatrace-clients', () => {
|
|
@@ -144,6 +145,36 @@ describe('dynatrace-clients', () => {
|
|
|
144
145
|
expect(result).toBeInstanceOf(http_client_1.PlatformHttpClient);
|
|
145
146
|
});
|
|
146
147
|
});
|
|
148
|
+
describe('with OAuth auth code flow (clientId only)', () => {
|
|
149
|
+
const clientId = 'test-client-id';
|
|
150
|
+
it('should deduplicate concurrent token refresh attempts', async () => {
|
|
151
|
+
const expiredCachedToken = {
|
|
152
|
+
access_token: 'expired-access-token',
|
|
153
|
+
refresh_token: 'valid-refresh-token',
|
|
154
|
+
expires_at: Date.now() - 60000, // expired 1 minute ago
|
|
155
|
+
scopes,
|
|
156
|
+
};
|
|
157
|
+
const newTokenResponse = {
|
|
158
|
+
access_token: 'new-access-token',
|
|
159
|
+
token_type: 'Bearer',
|
|
160
|
+
expires_in: 3600,
|
|
161
|
+
refresh_token: 'new-refresh-token',
|
|
162
|
+
};
|
|
163
|
+
mockGlobalTokenCache.getToken.mockReturnValue(expiredCachedToken);
|
|
164
|
+
mockGlobalTokenCache.isTokenValid.mockReturnValue(false);
|
|
165
|
+
// Simulate a slow refresh (10ms) so concurrent callers have time to pile up
|
|
166
|
+
mockRefreshAccessToken.mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve(newTokenResponse), 10)));
|
|
167
|
+
// Initiate two concurrent calls – both see the same expired token
|
|
168
|
+
const [client1, client2] = await Promise.all([
|
|
169
|
+
(0, dynatrace_clients_1.createDtHttpClient)(environmentUrl, scopes, clientId),
|
|
170
|
+
(0, dynatrace_clients_1.createDtHttpClient)(environmentUrl, scopes, clientId),
|
|
171
|
+
]);
|
|
172
|
+
// The refresh must only have been attempted once (no race on the refresh token)
|
|
173
|
+
expect(mockRefreshAccessToken).toHaveBeenCalledTimes(1);
|
|
174
|
+
expect(client1).toBeInstanceOf(http_client_1.PlatformHttpClient);
|
|
175
|
+
expect(client2).toBeInstanceOf(http_client_1.PlatformHttpClient);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
147
178
|
});
|
|
148
179
|
describe('requestToken function (indirectly tested)', () => {
|
|
149
180
|
it('should handle fetch errors gracefully', async () => {
|