11import { HttpClient , PlatformHttpClient } from '@dynatrace-sdk/http-client' ;
22import { getSSOUrl } from 'dt-app' ;
33import { 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