Login successful
You can close this tab now.
package session import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "html" "io" "net" "net/http" "net/url" "os" "strings" "sync" "time" "github.com/pkg/browser" ) const ( // Production environment prodAuthURL = "https://sso.dynatrace.com/oauth2/authorize" prodTokenURL = "https://token.dynatrace.com/sso/oauth2/token" prodUserInfoURL = "https://sso.dynatrace.com/sso/oauth2/userinfo" prodClientID = "dt0s12.dtctl-prod" // Development environment devAuthURL = "https://sso-dev.dynatracelabs.com/oauth2/authorize" devTokenURL = "https://dev.token.dynatracelabs.com/sso/oauth2/token" devUserInfoURL = "https://sso-dev.dynatracelabs.com/sso/oauth2/userinfo" devClientID = "dt0s12.dtctl-dev" // Hardening/Sprint environment hardAuthURL = "https://sso-sprint.dynatracelabs.com/oauth2/authorize" hardTokenURL = "https://hard.token.dynatracelabs.com/sso/oauth2/token" hardUserInfoURL = "https://sso-sprint.dynatracelabs.com/sso/oauth2/userinfo" hardClientID = "dt0s12.dtctl-sprint" callbackPort = 3232 // Must match the registered redirect URI for the OAuth client callbackPath = "/auth/login" ) // Environment represents a Dynatrace environment type type Environment string const ( EnvironmentProd Environment = "prod" EnvironmentDev Environment = "dev" EnvironmentHard Environment = "hard" ) func defaultOAuthOpenURL(targetURL string) error { return browser.OpenURL(targetURL) } func defaultOAuthHTTPDo(req *http.Request) (*http.Response, error) { client := &http.Client{Timeout: 30 * time.Second} return client.Do(req) } type OAuthConfig struct { AuthURL string TokenURL string UserInfoURL string ClientID string // Scopes are requested during the interactive login flow only; token // refresh re-issues the original grant's scopes, so refresh-only // consumers (the 401-retry path, TokenManager auto-refresh) may leave // them empty. The scope-composition tables live in dtctl's pkg/auth — // callers running a login flow pass the composed set in. Scopes []string Port int Environment Environment SafetyLevel SafetyLevel EnvironmentURL string } // DetectEnvironment determines the environment type from a Dynatrace URL func DetectEnvironment(environmentURL string) Environment { switch { case strings.Contains(environmentURL, "apps.dynatrace.com"): return EnvironmentProd case strings.Contains(environmentURL, "dev.apps.dynatracelabs.com"): return EnvironmentDev case strings.Contains(environmentURL, "sprint.apps.dynatracelabs.com"): return EnvironmentHard default: // Default to prod if unable to detect return EnvironmentProd } } // DefaultOAuthConfig returns the default OAuth configuration for production. // No scopes are set — sufficient for refresh-only use (see OAuthConfig.Scopes). func DefaultOAuthConfig() *OAuthConfig { return OAuthConfigForEnvironment(EnvironmentProd, DefaultSafetyLevel, nil) } // OAuthConfigForEnvironment creates an OAuth configuration for the specified // environment. scopes may be nil for refresh-only use; login flows pass the // scope set composed for the safety level (dtctl's pkg/auth owns that // composition). func OAuthConfigForEnvironment(env Environment, safetyLevel SafetyLevel, scopes []string) *OAuthConfig { var authURL, tokenURL, userInfoURL, clientID string // Normalize empty safety level to default if safetyLevel == "" { safetyLevel = DefaultSafetyLevel } switch env { case EnvironmentDev: authURL = devAuthURL tokenURL = devTokenURL userInfoURL = devUserInfoURL clientID = devClientID case EnvironmentHard: authURL = hardAuthURL tokenURL = hardTokenURL userInfoURL = hardUserInfoURL clientID = hardClientID default: // EnvironmentProd authURL = prodAuthURL tokenURL = prodTokenURL userInfoURL = prodUserInfoURL clientID = prodClientID } return &OAuthConfig{ AuthURL: authURL, TokenURL: tokenURL, UserInfoURL: userInfoURL, ClientID: clientID, Scopes: scopes, Port: callbackPort, Environment: env, SafetyLevel: safetyLevel, } } // OAuthConfigFromEnvironmentURL creates an OAuth configuration by detecting // the environment from a URL. scopes may be nil for refresh-only use. func OAuthConfigFromEnvironmentURL(environmentURL string, safetyLevel SafetyLevel, scopes []string) *OAuthConfig { env := DetectEnvironment(environmentURL) cfg := OAuthConfigForEnvironment(env, safetyLevel, scopes) cfg.EnvironmentURL = environmentURL return cfg } type TokenSet struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` IDToken string `json:"id_token"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` Scope string `json:"scope"` ExpiresAt time.Time `json:"expires_at,omitempty"` } // OAuthUserInfo is the SSO userinfo-endpoint response (distinct from the // platform metadata UserInfo returned by Client.CurrentUser). type OAuthUserInfo struct { Sub string `json:"sub"` Email string `json:"email"` EmailVerified bool `json:"email_verified"` Name string `json:"name"` } type OAuthFlow struct { config *OAuthConfig codeVerifier string codeChallenge string state string server *http.Server resultChan chan *authResult resultOnce sync.Once openURL func(string) error httpDo func(*http.Request) (*http.Response, error) } type authResult struct { tokens *TokenSet err error } func NewOAuthFlow(config *OAuthConfig) (*OAuthFlow, error) { if config == nil { config = DefaultOAuthConfig() } verifier, challenge, err := generatePKCE() if err != nil { return nil, fmt.Errorf("failed to generate PKCE: %w", err) } state, err := generateRandomString(32) if err != nil { return nil, fmt.Errorf("failed to generate state: %w", err) } return &OAuthFlow{ config: config, codeVerifier: verifier, codeChallenge: challenge, state: state, resultChan: make(chan *authResult, 1), openURL: defaultOAuthOpenURL, httpDo: defaultOAuthHTTPDo, }, nil } func (f *OAuthFlow) Start(ctx context.Context) (*TokenSet, error) { if err := f.startCallbackServer(); err != nil { return nil, fmt.Errorf("failed to start callback server: %w", err) } defer f.stopCallbackServer() authURL := f.buildAuthURL() fmt.Fprintln(os.Stderr, "Opening browser for authentication...") fmt.Fprintln(os.Stderr, "If the browser doesn't open automatically, please visit:") fmt.Fprintln(os.Stderr, authURL) openURL := f.openURL if openURL == nil { openURL = defaultOAuthOpenURL } if err := openURL(authURL); err != nil { fmt.Fprintf(os.Stderr, "Failed to open browser automatically: %v\n", err) fmt.Fprintln(os.Stderr, "Please open the URL above manually.") } select { case result := <-f.resultChan: if result.err != nil { return nil, result.err } return result.tokens, nil case <-ctx.Done(): return nil, fmt.Errorf("authentication cancelled: %w", ctx.Err()) } } func (f *OAuthFlow) RefreshToken(refreshToken string) (*TokenSet, error) { data := url.Values{ "grant_type": {"refresh_token"}, "refresh_token": {refreshToken}, "client_id": {f.config.ClientID}, } req, err := http.NewRequest("POST", f.config.TokenURL, strings.NewReader(data.Encode())) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") httpDo := f.httpDo if httpDo == nil { httpDo = defaultOAuthHTTPDo } resp, err := httpDo(req) if err != nil { return nil, fmt.Errorf("token refresh request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("token refresh failed: %s - %s", resp.Status, string(body)) } var tokens TokenSet if err := json.NewDecoder(resp.Body).Decode(&tokens); err != nil { return nil, fmt.Errorf("failed to decode token response: %w", err) } tokens.ExpiresAt = time.Now().Add(time.Duration(tokens.ExpiresIn) * time.Second) return &tokens, nil } func (f *OAuthFlow) GetUserInfo(accessToken string) (*OAuthUserInfo, error) { req, err := http.NewRequest("GET", f.config.UserInfoURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", "Bearer "+accessToken) httpDo := f.httpDo if httpDo == nil { httpDo = defaultOAuthHTTPDo } resp, err := httpDo(req) if err != nil { return nil, fmt.Errorf("user info request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("failed to get user info: %s - %s", resp.Status, string(body)) } var userInfo OAuthUserInfo if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil { return nil, fmt.Errorf("failed to decode user info: %w", err) } return &userInfo, nil } func (f *OAuthFlow) buildAuthURL() string { params := url.Values{ "response_type": {"code"}, "client_id": {f.config.ClientID}, "redirect_uri": {f.getRedirectURI()}, "scope": {strings.Join(f.config.Scopes, " ")}, "state": {f.state}, "code_challenge": {f.codeChallenge}, "code_challenge_method": {"S256"}, } // Add resource parameter with environment URL if available if f.config.EnvironmentURL != "" { params.Set("resource", f.config.EnvironmentURL) } return f.config.AuthURL + "?" + params.Encode() } func (f *OAuthFlow) getRedirectURI() string { return fmt.Sprintf("http://localhost:%d%s", f.config.Port, callbackPath) } func (f *OAuthFlow) startCallbackServer() error { mux := http.NewServeMux() mux.HandleFunc(callbackPath, f.handleCallback) // Create a listener first so we can verify it's bound before proceeding listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", f.config.Port)) if err != nil { return fmt.Errorf("failed to bind to port %d: %w", f.config.Port, err) } f.server = &http.Server{ Handler: mux, } // Channel to signal when server is ready or encounters an error serverReady := make(chan error, 1) go func() { // Signal that we're ready to accept connections serverReady <- nil // Start serving if err := f.server.Serve(listener); err != nil && err != http.ErrServerClosed { f.publishResult(&authResult{err: fmt.Errorf("callback server error: %w", err)}) } }() // Wait for server to be ready (or error) if err := <-serverReady; err != nil { listener.Close() return err } return nil } func (f *OAuthFlow) stopCallbackServer() { if f.server != nil { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = f.server.Shutdown(ctx) } } func (f *OAuthFlow) handleCallback(w http.ResponseWriter, r *http.Request) { if errMsg := r.URL.Query().Get("error"); errMsg != "" { errDesc := r.URL.Query().Get("error_description") f.sendError(w, fmt.Errorf("authentication failed: %s - %s", errMsg, errDesc)) return } state := r.URL.Query().Get("state") if state != f.state { f.sendError(w, fmt.Errorf("invalid state parameter")) return } code := r.URL.Query().Get("code") if code == "" { f.sendError(w, fmt.Errorf("no authorization code received")) return } tokens, err := f.exchangeCode(code) if err != nil { f.sendError(w, err) return } f.sendSuccess(w) f.publishResult(&authResult{tokens: tokens}) } func (f *OAuthFlow) exchangeCode(code string) (*TokenSet, error) { data := url.Values{ "grant_type": {"authorization_code"}, "code": {code}, "client_id": {f.config.ClientID}, "redirect_uri": {f.getRedirectURI()}, "code_verifier": {f.codeVerifier}, } req, err := http.NewRequest("POST", f.config.TokenURL, strings.NewReader(data.Encode())) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") httpDo := f.httpDo if httpDo == nil { httpDo = defaultOAuthHTTPDo } resp, err := httpDo(req) if err != nil { return nil, fmt.Errorf("token exchange request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("token exchange failed: %s - %s", resp.Status, string(body)) } var tokens TokenSet if err := json.NewDecoder(resp.Body).Decode(&tokens); err != nil { return nil, fmt.Errorf("failed to decode token response: %w", err) } tokens.ExpiresAt = time.Now().Add(time.Duration(tokens.ExpiresIn) * time.Second) return &tokens, nil } func (f *OAuthFlow) sendSuccess(w http.ResponseWriter) { w.Header().Set("Content-Type", "text/html") w.WriteHeader(http.StatusOK) w.Write([]byte(successHTML)) } func (f *OAuthFlow) sendError(w http.ResponseWriter, err error) { w.Header().Set("Content-Type", "text/html") w.WriteHeader(http.StatusBadRequest) htmlContent := strings.ReplaceAll(errorHTML, "{{ERROR}}", html.EscapeString(err.Error())) w.Write([]byte(htmlContent)) f.publishResult(&authResult{err: err}) } func (f *OAuthFlow) publishResult(result *authResult) { f.resultOnce.Do(func() { select { case f.resultChan <- result: default: } }) } func generatePKCE() (verifier, challenge string, err error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", "", err } verifier = base64.RawURLEncoding.EncodeToString(b) h := sha256.New() h.Write([]byte(verifier)) challenge = base64.RawURLEncoding.EncodeToString(h.Sum(nil)) return verifier, challenge, nil } func generateRandomString(length int) (string, error) { b := make([]byte, length) if _, err := rand.Read(b); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b)[:length], nil } const successHTML = `
You can close this tab now.
Please close this window and try again.