Skip to content

Commit 0434fc1

Browse files
feat: Adding oauth authorization code flow
1 parent 5aa7bc7 commit 0434fc1

14 files changed

+810
-188
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ node_modules/
77

88
tsconfig.tsbuildinfo
99

10+
# Token cache directory - contains sensitive OAuth tokens
11+
.dt-mcp/
12+
1013
# mcp registry
1114
.mcpregistry_github_token
1215
.mcpregistry_registry_token

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
- Fixed an issue with MCP communication failing with `SyntaxError: Unexpected token 'd'` due to `dotenv`
2626
- Added Support for Google Gemini CLI
2727

28+
- **⚡ Simplified Setup**: OAuth authorization code flow is now automatically enabled when neither, OAuth credentials, nor a platform token are provided. Just provide `DT_ENVIRONMENT`, and authentication is handled automatically via an interactive OAuth authorization code flow
29+
2830
## 0.6.0
2931

3032
**Highlights**:

src/authentication/dynatrace-clients.test.ts

Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@ import { createDtHttpClient } from './dynatrace-clients';
22
import { PlatformHttpClient } from '@dynatrace-sdk/http-client';
33
import { getSSOUrl } from 'dt-app';
44
import { OAuthTokenResponse } from './types';
5+
import { performOAuthAuthorizationCodeFlow } from './dynatrace-oauth-auth-code-flow';
6+
import { globalTokenCache } from './token-cache';
57

68
// Mock external dependencies
79
jest.mock('@dynatrace-sdk/http-client');
810
jest.mock('dt-app');
11+
jest.mock('./dynatrace-oauth-auth-code-flow');
12+
jest.mock('./token-cache');
913
jest.mock('../../package.json', () => ({
1014
version: '1.0.0-test',
1115
}));
@@ -15,13 +19,26 @@ global.fetch = jest.fn();
1519

1620
const mockPlatformHttpClient = PlatformHttpClient as jest.MockedClass<typeof PlatformHttpClient>;
1721
const mockGetSSOUrl = getSSOUrl as jest.MockedFunction<typeof getSSOUrl>;
22+
const mockPerformOAuthAuthorizationCodeFlow = performOAuthAuthorizationCodeFlow as jest.MockedFunction<
23+
typeof performOAuthAuthorizationCodeFlow
24+
>;
25+
const mockGlobalTokenCache = globalTokenCache as jest.Mocked<typeof globalTokenCache>;
1826
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
1927

2028
describe('dynatrace-clients', () => {
2129
beforeEach(() => {
2230
jest.clearAllMocks();
2331
// Reset console.error mock
2432
jest.spyOn(console, 'error').mockImplementation(() => {});
33+
34+
// Mock token cache methods
35+
mockGlobalTokenCache.getToken.mockReturnValue(null);
36+
mockGlobalTokenCache.isTokenValid.mockReturnValue(false);
37+
mockGlobalTokenCache.setToken.mockImplementation(() => {});
38+
mockGlobalTokenCache.clearToken.mockImplementation(() => {});
39+
40+
// Mock getSSOUrl
41+
mockGetSSOUrl.mockResolvedValue('https://sso.dynatrace.com');
2542
});
2643

2744
afterEach(() => {
@@ -79,18 +96,6 @@ describe('dynatrace-clients', () => {
7996
expect(result).toBeInstanceOf(PlatformHttpClient);
8097
});
8198

82-
it('should throw error when clientId, clientSecret and platformToken are missing', async () => {
83-
await expect(createDtHttpClient(environmentUrl, scopes, undefined, undefined, undefined)).rejects.toThrow(
84-
'Failed to create Dynatrace HTTP Client: Please provide either clientId and clientSecret or dtPlatformToken',
85-
);
86-
});
87-
88-
it('should throw error when environmentUrl is missing', async () => {
89-
await expect(createDtHttpClient('', scopes, clientId, clientSecret)).rejects.toThrow(
90-
'Failed to retrieve environment URL from env "DT_ENVIRONMENT"',
91-
);
92-
});
93-
9499
it('should throw error when token request fails with HTTP error', async () => {
95100
mockFetch.mockResolvedValueOnce({
96101
ok: false,
@@ -157,7 +162,7 @@ describe('dynatrace-clients', () => {
157162
await createDtHttpClient(environmentUrl, scopes, clientId, clientSecret);
158163

159164
expect(console.error).toHaveBeenCalledWith(
160-
`Trying to authenticate API Calls to ${environmentUrl} via OAuthClientId ${clientId} with the following scopes: ${scopes.join(', ')}`,
165+
`🔒 Client-Creds-Flow: Trying to authenticate API Calls to ${environmentUrl} via OAuthClientId ${clientId} with the following scopes: ${scopes.join(', ')}`,
161166
);
162167
});
163168
});
@@ -178,14 +183,6 @@ describe('dynatrace-clients', () => {
178183
expect(result).toBeInstanceOf(PlatformHttpClient);
179184
});
180185
});
181-
182-
describe('with no authentication', () => {
183-
it('should throw error when no authentication method is provided', async () => {
184-
await expect(createDtHttpClient(environmentUrl, scopes)).rejects.toThrow(
185-
'Failed to create Dynatrace HTTP Client: Please provide either clientId and clientSecret or dtPlatformToken',
186-
);
187-
});
188-
});
189186
});
190187

191188
describe('requestToken function (indirectly tested)', () => {
Lines changed: 135 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,14 @@
11
import { HttpClient, PlatformHttpClient } from '@dynatrace-sdk/http-client';
22
import { getSSOUrl } from 'dt-app';
33
import { getUserAgent } from '../utils/user-agent';
4-
import { OAuthTokenResponse } from './types';
4+
import { performOAuthAuthorizationCodeFlow, refreshAccessToken } from './dynatrace-oauth-auth-code-flow';
5+
import { globalTokenCache } from './token-cache';
6+
import { getRandomPort } from './utils';
7+
import { requestTokenForClientCredentials } from './dynatrace-oauth-client-credentials';
58

69
/**
7-
* Uses the provided oauth Client ID and Secret and requests a token via client-credentials flow
8-
* @param clientId - OAuth Client ID for Dynatrace
9-
* @param clientSecret - Oauth Client Secret for Dynatrace
10-
* @param ssoAuthUrl - SSO Authentication URL
11-
* @param scopes - List of requested scopes
12-
* @returns Response of the OAuth Endpoint (which, in the best case includes a token)
13-
*/
14-
const requestToken = async (
15-
clientId: string,
16-
clientSecret: string,
17-
ssoAuthUrl: string,
18-
scopes: string[],
19-
): Promise<OAuthTokenResponse> => {
20-
const res = await fetch(ssoAuthUrl, {
21-
method: 'POST',
22-
headers: {
23-
'Content-Type': 'application/x-www-form-urlencoded',
24-
},
25-
body: new URLSearchParams({
26-
grant_type: 'client_credentials',
27-
client_id: clientId,
28-
client_secret: clientSecret,
29-
scope: scopes.join(' '),
30-
}),
31-
});
32-
// check if the response was okay (HTTP 2xx) or not (HTTP 4xx or 5xx)
33-
if (!res.ok) {
34-
// log the error
35-
console.error(`Failed to fetch token: ${res.status} ${res.statusText}`);
36-
}
37-
// and return the JSON result, as it contains additional information
38-
return await res.json();
39-
};
40-
41-
/**
42-
* Create a Dynatrace Http Client (from the http-client SDK) based on the provided authentication credentails
10+
* Create a Dynatrace Http Client (from the http-client SDK) based on the provided authentication credentials
11+
* Supports Platform Token, Oauth Client Credentials Flow, and OAuth Authorization Code Flow (interactive)
4312
* @param environmentUrl
4413
* @param scopes
4514
* @param clientId
@@ -54,67 +23,169 @@ export const createDtHttpClient = async (
5423
clientSecret?: string,
5524
dtPlatformToken?: string,
5625
): Promise<HttpClient> => {
57-
if (clientId && clientSecret) {
58-
// create an OAuth client if clientId and clientSecret are provided
59-
return createOAuthHttpClient(environmentUrl, scopes, clientId, clientSecret);
60-
}
26+
/** Logic:
27+
* * if a platform token is provided, use it
28+
* * If no platform token is provided, but clientId and clientSecret are provided, use client credentials flow
29+
* * If no platform token is provided, and no clientSecret is provided, but a clientId is provided, use OAuth authorization code flow (interactive)
30+
* * If neither platform token nor OAuth credentials are provided, throw an error
31+
*/
6132
if (dtPlatformToken) {
6233
// create a simple HTTP client if only the platform token is provided
63-
return createBearerTokenHttpClient(environmentUrl, dtPlatformToken);
34+
return createPlatformTokenHttpClient(environmentUrl, dtPlatformToken);
35+
} else if (clientId && clientSecret) {
36+
// create an Oauth client using client credentials flow (non-interactive)
37+
return createOAuthClientCredentialsHttpClient(environmentUrl, scopes, clientId, clientSecret);
38+
} else if (clientId) {
39+
// create an OAuth client using authorization code flow (interactive)
40+
return createOAuthAuthCodeFlowHttpClient(environmentUrl, scopes, clientId);
6441
}
42+
6543
throw new Error(
66-
'Failed to create Dynatrace HTTP Client: Please provide either clientId and clientSecret or dtPlatformToken',
44+
'Failed to create Dynatrace HTTP Client: Please provide either clientId and clientSecret for client credentials flow, clientId only for interactive OAuth flow, or just a platform token.',
6745
);
6846
};
6947

70-
/** Creates an HTTP Client based on environmentUrl and a platform token */
71-
const createBearerTokenHttpClient = async (environmentUrl: string, dtPlatformToken: string): Promise<HttpClient> => {
48+
/**
49+
* Creates an HTTP Client based on environmentUrl and a bearer token, and also sets the user agent
50+
*/
51+
const createBearerTokenHttpClient = async (environmentUrl: string, bearerToken: string): Promise<HttpClient> => {
7252
return new PlatformHttpClient({
7353
baseUrl: environmentUrl,
7454
defaultHeaders: {
75-
'Authorization': `Bearer ${dtPlatformToken}`,
55+
'Authorization': `Bearer ${bearerToken}`,
7656
'User-Agent': getUserAgent(),
7757
},
7858
});
7959
};
8060

81-
/** Create an Oauth Client based on clientId, clientSecret, environmentUrl and scopes
61+
/**
62+
* Creates an HTTP Client based on environmentUrl and a platform token (as bearer token)
63+
*/
64+
const createPlatformTokenHttpClient = async (environmentUrl: string, dtPlatformToken: string): Promise<HttpClient> => {
65+
console.error(`🔒 Using Platform Token to authenticate API Calls to ${environmentUrl}`);
66+
return createBearerTokenHttpClient(environmentUrl, dtPlatformToken);
67+
};
68+
69+
/**
70+
* Create an Oauth Client based on clientId, clientSecret, environmentUrl and scopes
8271
* This uses a client-credentials flow to request a token from the SSO endpoint.
72+
* Note: We do not refresh the token here, we always request a new one on each client creation.
8373
*/
84-
const createOAuthHttpClient = async (
74+
const createOAuthClientCredentialsHttpClient = async (
8575
environmentUrl: string,
8676
scopes: string[],
8777
clientId: string,
8878
clientSecret: string,
8979
): Promise<HttpClient> => {
90-
if (!clientId) {
91-
throw new Error('Failed to retrieve OAuth client id from env "DT_APP_OAUTH_CLIENT_ID"');
92-
}
93-
if (!clientSecret) {
94-
throw new Error('Failed to retrieve OAuth client secret from env "DT_APP_OAUTH_CLIENT_SECRET"');
95-
}
96-
if (!environmentUrl) {
97-
throw new Error('Failed to retrieve environment URL from env "DT_ENVIRONMENT"');
98-
}
99-
10080
console.error(
101-
`Trying to authenticate API Calls to ${environmentUrl} via OAuthClientId ${clientId} with the following scopes: ${scopes.join(', ')}`,
81+
`🔒 Client-Creds-Flow: Trying to authenticate API Calls to ${environmentUrl} via OAuthClientId ${clientId} with the following scopes: ${scopes.join(', ')}`,
10282
);
10383

104-
const ssoBaseUrl = await getSSOUrl(environmentUrl);
105-
const ssoAuthUrl = new URL('/sso/oauth2/token', ssoBaseUrl).toString();
106-
console.error(`Using SSO auth URL: ${ssoAuthUrl}`);
84+
// Get SSO Base URL
85+
const ssoBaseURL = await getSSOUrl(environmentUrl);
10786

10887
// try to request a token, just to verify that everything is set up correctly
109-
const tokenResponse = await requestToken(clientId, clientSecret, ssoAuthUrl, scopes);
88+
const tokenResponse = await requestTokenForClientCredentials(clientId, clientSecret, ssoBaseURL, scopes);
11089

11190
// in case we didn't get a token, or error / error_description / issueId is set, we throw an error
11291
if (!tokenResponse.access_token || tokenResponse.error || tokenResponse.error_description || tokenResponse.issueId) {
11392
throw new Error(
11493
`Failed to retrieve OAuth token (IssueId: ${tokenResponse.issueId}): ${tokenResponse.error} - ${tokenResponse.error_description}. Note: Your OAuth client is most likely not configured correctly and/or is missing scopes.`,
11594
);
11695
}
117-
console.error(`Successfully retrieved token from SSO!`);
96+
console.error(
97+
`Successfully retrieved token from SSO! Token valid for ${tokenResponse.expires_in}s with scopes: ${tokenResponse.scope}`,
98+
);
99+
100+
// now that we have the access token, we can just use a plain bearer token client
101+
return createBearerTokenHttpClient(environmentUrl, tokenResponse.access_token);
102+
};
103+
104+
/** Create an OAuth Client using authorization code flow (interactive authentication)
105+
* This starts a local HTTP server to handle the OAuth redirect and requires user interaction.
106+
* Implements token caching (via .dt-mcp/token.json) to avoid repeated OAuth flows.
107+
* Note: Always requests a complete set of scopes for maximum token reusability. Else the user will end up having to approve multiple requests.
108+
*/
109+
const createOAuthAuthCodeFlowHttpClient = async (
110+
environmentUrl: string,
111+
scopes: string[],
112+
clientId: string,
113+
): Promise<HttpClient> => {
114+
// Get SSO Base URL
115+
const ssoBaseURL = await getSSOUrl(environmentUrl);
116+
117+
// Fast Track: Fetch cached token and check if it is still valid
118+
const cachedToken = globalTokenCache.getToken(scopes);
119+
const isValid = globalTokenCache.isTokenValid(scopes);
120+
121+
// If we have a valid cached token, we can use it
122+
if (isValid && cachedToken) {
123+
const expiresIn = cachedToken.expires_at ? Math.round((cachedToken.expires_at - Date.now()) / 1000) : 'never';
124+
console.error(`✅ Auth-Code-Flow: Using cached access token (expires in ${expiresIn}s)`);
125+
126+
// just use the cached token as a bearer token
127+
return createBearerTokenHttpClient(environmentUrl, cachedToken.access_token);
128+
}
129+
130+
// If we have an expired token that can be refreshed, refresh it
131+
if (cachedToken && cachedToken.refresh_token && !isValid) {
132+
const expiresIn = cachedToken.expires_at ? Math.round((cachedToken.expires_at - Date.now()) / 1000) : 'never';
133+
console.error(`🔍 Auth-Code-Flow: Found expired cached token (expires in ${expiresIn}s), attempting refresh...`);
134+
try {
135+
console.error(`🔄 Attempting to refresh expired access token...`);
136+
const tokenResponse = await refreshAccessToken(ssoBaseURL, clientId, cachedToken.refresh_token, scopes);
137+
138+
if (tokenResponse.access_token && !tokenResponse.error) {
139+
console.error(`✅ Successfully refreshed access token!`);
140+
// Update the cache with the new token
141+
globalTokenCache.setToken(scopes, tokenResponse);
142+
143+
// now use the updated token as a bearer token
144+
return createBearerTokenHttpClient(environmentUrl, tokenResponse.access_token);
145+
} else {
146+
console.error(`❌ Token refresh failed: ${tokenResponse.error} - ${tokenResponse.error_description}`);
147+
// Clear the invalid token from cache
148+
globalTokenCache.clearToken();
149+
}
150+
} catch (error) {
151+
console.error(`❌ Token refresh failed with error: ${error instanceof Error ? error.message : String(error)}`);
152+
// Clear the invalid token from cache
153+
globalTokenCache.clearToken();
154+
}
155+
}
156+
157+
// If we get here, we are currently not authenticated, and need to perform a full OAuth Authorization Code Flow
158+
console.error(`🚀 Auth-Code-Flow: No valid cached token found, initiating OAuth Authorization Code Flow...`);
159+
console.error(`Using SSO base URL ${ssoBaseURL}`);
160+
161+
// Randomly select a port for the OAuth redirect URL (e.g., 5344)
162+
const port = getRandomPort();
163+
164+
// Perform the OAuth authorization code flow with all scopes
165+
const tokenResponse = await performOAuthAuthorizationCodeFlow(
166+
ssoBaseURL,
167+
{
168+
clientId,
169+
// redirectUri will be used as a redirect/callback from the authorization code flow
170+
redirectUri: `http://localhost:${port}/auth/login`,
171+
scopes: scopes, // Request all scopes upfront
172+
},
173+
port,
174+
);
175+
176+
// Check if we got a valid token
177+
if (!tokenResponse.access_token || tokenResponse.error || tokenResponse.error_description || tokenResponse.issueId) {
178+
throw new Error(
179+
`Failed to retrieve OAuth token via authorization code flow (IssueId: ${tokenResponse.issueId}): ${tokenResponse.error} - ${tokenResponse.error_description}`,
180+
);
181+
}
182+
183+
// Cache the new token with all scopes
184+
globalTokenCache.setToken(scopes, tokenResponse);
185+
console.error(
186+
`✅ Successfully retrieved token from SSO! Token cached for future use with scopes: ${scopes.join(', ')}`,
187+
);
118188

189+
// now that we have the access token, we can just use a plain bearer token client
119190
return createBearerTokenHttpClient(environmentUrl, tokenResponse.access_token);
120191
};

0 commit comments

Comments
 (0)