From 73096af7d7dae473232b5a4d4ebdcd69bc7f479f Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Thu, 11 Jun 2026 15:56:22 -0700 Subject: [PATCH 1/6] working on sso, checkpoint on branch --- AGENTS.md | 10 + modules/core/auth/env.go | 2 +- modules/core/auth/login.go | 4 +- modules/core/auth/loginwizard.go | 4 +- modules/core/auth/set.go | 5 +- modules/core/auth/sso.go | 437 +++++++++++++++++++++++++++ modules/core/auth/status.go | 30 +- modules/core/auth/token.go | 2 +- modules/core/core.go | 2 + modules/har/pkg/har/configure_npm.go | 2 +- modules/har/pkg/har/firewall.go | 20 +- modules/har/pkg/har/pull.go | 3 +- modules/har/pkg/har/push_cargo.go | 4 +- modules/har/pkg/har/push_composer.go | 2 +- modules/har/pkg/har/push_conda.go | 2 +- modules/har/pkg/har/push_dart.go | 2 +- modules/har/pkg/har/push_go.go | 5 +- modules/har/pkg/har/push_maven.go | 21 +- modules/har/pkg/har/push_npm.go | 28 +- modules/har/pkg/har/push_nuget.go | 2 +- modules/har/pkg/har/push_puppet.go | 2 +- modules/har/pkg/har/push_python.go | 2 +- modules/har/pkg/har/push_rpm.go | 2 +- modules/har/pkg/har/push_swift.go | 2 +- modules/iacm/plan.go | 4 +- pkg/auth/auth.go | 84 +++-- pkg/auth/credentials.go | 65 +++- pkg/client/client.go | 12 +- pkg/console/console.go | 18 ++ pkg/execgraph/execgraph.go | 4 +- pkg/logstream/logstream.go | 14 +- pkg/spec/core.spec.yaml | 18 ++ 32 files changed, 690 insertions(+), 124 deletions(-) create mode 100644 modules/core/auth/sso.go diff --git a/AGENTS.md b/AGENTS.md index e1f19e9..8930858 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,6 +144,16 @@ The CLI reads auth from the active profile (typically `~/.harness/profiles.yaml` | `pipeline.spec.yaml` | CI/CD pipelines | | `core.spec.yaml` | Core resources | +## Security — never put real credentials in code or comments + +Do not hardcode into source files, comments, or documentation: +- Account IDs, org IDs, project IDs +- API tokens, OAuth tokens, client secrets +- User emails, UUIDs, or any other PII +- Real hostnames or URLs from live environments (unless they are published public endpoints like `id.harness.io`) + +Use placeholder text like ``, ``, `` in examples. + ## Common pitfalls - **Binary not updated**: `task build` alone isn't enough — must `cp` to `~/.local/bin/harness`. diff --git a/modules/core/auth/env.go b/modules/core/auth/env.go index c5005f2..7535191 100644 --- a/modules/core/auth/env.go +++ b/modules/core/auth/env.go @@ -27,7 +27,7 @@ func EnvHandler(ctx *cmdctx.Ctx) error { } vars := []struct{ k, v string }{ - {hbase.EnvAPIKey, resolved.Token}, + {hbase.EnvAPIKey, resolved.PATToken}, {hbase.EnvAccount, resolved.AccountID}, {hbase.EnvAPIURL, resolved.APIUrl}, } diff --git a/modules/core/auth/login.go b/modules/core/auth/login.go index bd2df6e..5257dac 100644 --- a/modules/core/auth/login.go +++ b/modules/core/auth/login.go @@ -72,7 +72,9 @@ func LoginHandler(ctx *cmdctx.Ctx) error { existingURL := existingProfile.APIUrl existingToken := "" if creds, cerr := auth.LoadCredentials(); cerr == nil { - existingToken = creds[profileName] + if c := creds[profileName]; c != nil { + existingToken = c.Token + } } existing = &WizardExisting{APIURL: existingURL, Token: existingToken} } diff --git a/modules/core/auth/loginwizard.go b/modules/core/auth/loginwizard.go index 2b983c9..075b1b3 100644 --- a/modules/core/auth/loginwizard.go +++ b/modules/core/auth/loginwizard.go @@ -660,7 +660,7 @@ func fetchOrgItems(ctx *cmdctx.Ctx, apiURL, token, accountID string) ([]orgItem, fetchCtx.Noun = "organization" fetchCtx.Auth = &pkgauth.ResolvedAuth{ APIUrl: apiURL, - Token: token, + PATToken: token, AccountID: accountID, } items, err := ctx.Resolver.FetchItems(&fetchCtx, cs.Endpoint, cmdctx.PagingFlags{All: true}) @@ -684,7 +684,7 @@ func fetchProjectItems(ctx *cmdctx.Ctx, apiURL, token, accountID, orgID string) fetchCtx.Noun = "project" fetchCtx.Auth = &pkgauth.ResolvedAuth{ APIUrl: apiURL, - Token: token, + PATToken: token, AccountID: accountID, OrgID: orgID, } diff --git a/modules/core/auth/set.go b/modules/core/auth/set.go index dfe483e..f1d9551 100644 --- a/modules/core/auth/set.go +++ b/modules/core/auth/set.go @@ -42,10 +42,11 @@ func setInteractive(ctx *cmdctx.Ctx, profileName string) error { if err != nil { return err } - token := creds[profileName] - if token == "" { + profileCreds := creds[profileName] + if profileCreds == nil || profileCreds.Token == "" { return fmt.Errorf("no token found for profile %q — run 'harness auth login' first", profileName) } + token := profileCreds.Token result, err := RunSetWizard(ctx, &SetWizardInput{ APIURL: p.APIUrl, diff --git a/modules/core/auth/sso.go b/modules/core/auth/sso.go new file mode 100644 index 0000000..a175f32 --- /dev/null +++ b/modules/core/auth/sso.go @@ -0,0 +1,437 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/harness/harness-cli/pkg/auth" + "github.com/harness/harness-cli/pkg/client" + "github.com/harness/harness-cli/pkg/cmdctx" + "github.com/harness/harness-cli/pkg/console" + "github.com/harness/harness-cli/pkg/hlog" +) + +const ( + mcpBaseURL = "https://mcp.harness.io" + ssoAuthServerBase = "https://id.harness.io" + ssoMetadataPath = "/.well-known/oauth-authorization-server" + ssoCallbackPath = "/oauth/callback" + ssoClientID = "harness-cli-client" + ssoPort = 57380 + ssoDiscoverTimeout = 10 * time.Second + ssoTokenTimeout = 30 * time.Second + ssoCallbackTimeout = 5 * time.Minute +) + +// LoginSSOHandler implements `harness auth loginsso`. +// It performs the full OAuth2 PKCE flow via browser: +// 1. Fetch authorization server metadata from id.harness.io +// 2. Launch browser with PKCE authorization URL + local callback server on port 57380 +// 3. Exchange code for token, extract account ID from JWT claims +// 4. Drop into existing org/project picker wizard, then save profile +func LoginSSOHandler(ctx *cmdctx.Ctx) error { + overwrite := cmdctx.GetBool(ctx.FlagValues, "overwrite") + noOverwrite := cmdctx.GetBool(ctx.FlagValues, "no-overwrite") + if overwrite && noOverwrite { + return fmt.Errorf("--overwrite and --no-overwrite are mutually exclusive") + } + + profileName := cmdctx.GetString(ctx.FlagValues, "profile") + if profileName == "" { + profileName = "default" + } + if !profileNameRe.MatchString(profileName) { + return fmt.Errorf("invalid profile name %q: must match ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$", profileName) + } + + cfg, err := auth.LoadConfig() + if err != nil { + return err + } + if _, exists := cfg.Profiles[profileName]; exists { + switch { + case noOverwrite: + return fmt.Errorf("profile %q already exists (use --overwrite to replace it)", profileName) + case !overwrite: + fmt.Fprintf(os.Stderr, "WARNING: profile %q already exists, continuing will overwrite it\n\n", profileName) + if !console.PromptYesNo("Overwrite?") { + return fmt.Errorf("canceled by user — config not written") + } + fmt.Fprintln(os.Stderr) + } + } + + meta, err := fetchAuthServerMeta(&http.Client{Timeout: ssoDiscoverTimeout}, ssoAuthServerBase) + if err != nil { + return fmt.Errorf("SSO discovery failed: %w", err) + } + + token, refreshToken, accountID, subdomain, err := runPKCEFlow(meta) + if err != nil { + return fmt.Errorf("SSO login failed: %w", err) + } + + apiURL, err := resolveAPIURL(token, accountID, subdomain) + if err != nil { + return err + } + + // Reuse the existing set-wizard to pick org/project. + var orgID, projectID string + if console.IsBothTTY() { + result, werr := RunSetWizard(ctx, &SetWizardInput{ + APIURL: apiURL, + Token: token, + AccountID: accountID, + }) + if werr != nil { + return werr + } + if result == nil { + return fmt.Errorf("canceled by user — config not written") + } + orgID = result.OrgID + projectID = result.Project + } + + cfg.Profiles[profileName] = &auth.Profile{ + APIUrl: apiURL, + AccountID: accountID, + OrgID: orgID, + ProjectID: projectID, + AuthType: auth.AuthTypeSSO, + } + if err := auth.SaveConfig(cfg); err != nil { + return fmt.Errorf("saving profile: %w", err) + } + if err := auth.SetSSOCredentials(profileName, token, refreshToken); err != nil { + return fmt.Errorf("saving credentials: %w", err) + } + + fmt.Printf("Logged in via SSO. Profile %q written.\n\n", profileName) + printStatus(runStatusChecks(profileName)) + return nil +} + +// resolveAPIURL determines the Harness REST API base URL for the account. +// It prefers the subdomain from the JWT (e.g. "prod2.harness.io"), falls back +// to mcp.harness.io, then verifies the URL works. If verification fails and +// we're on a TTY, it prompts the user to enter the URL manually. +func resolveAPIURL(token, accountID, subdomain string) (string, error) { + candidate := mcpBaseURL + if subdomain != "" { + candidate = "https://" + subdomain + } + hlog.Debug("resolveAPIURL", "candidate", candidate, "subdomain", subdomain) + + resolved := &auth.ResolvedAuth{ + APIUrl: candidate, + SSOToken: token, + AccountID: accountID, + AuthType: auth.AuthTypeSSO, + } + c := client.New(context.Background(), resolved) + _, _, err := c.Get("/ng/api/user/currentUser", nil) + hlog.Debug("resolveAPIURL currentUser check", "url", candidate, "err", err) + if err == nil { + return candidate, nil + } + + if console.IsBothTTY() { + fmt.Fprintf(os.Stderr, "Could not reach %s — please enter your Harness API URL\n", candidate) + apiURL, err := console.ReadPrompt("API URL", "https://app.harness.io") + if err != nil { + return "", err + } + return apiURL, nil + } + return "", fmt.Errorf("could not reach %s — re-run with --api-url to specify the URL manually", candidate) +} + +// --- discovery --- + +type authServerMeta struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + RegistrationEndpoint string `json:"registration_endpoint"` +} + +func fetchAuthServerMeta(c *http.Client, authServerBaseURL string) (*authServerMeta, error) { + metaURL := authServerBaseURL + ssoMetadataPath + resp, err := c.Get(metaURL) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, metaURL) + } + var meta authServerMeta + if err := json.Unmarshal(body, &meta); err != nil { + return nil, fmt.Errorf("parsing authorization server metadata: %w", err) + } + if meta.AuthorizationEndpoint == "" || meta.TokenEndpoint == "" { + return nil, fmt.Errorf("authorization server metadata missing required endpoints") + } + // If the issuer is a sub-path (e.g. a Keycloak realm), re-fetch metadata from + // the issuer's own discovery doc so we get the real realm endpoints. + if meta.Issuer != "" && meta.Issuer != authServerBaseURL { + return fetchAuthServerMeta(c, meta.Issuer) + } + return &meta, nil +} + +// --- PKCE flow --- + +func runPKCEFlow(meta *authServerMeta) (token, refreshToken, accountID, subdomain string, err error) { + // Generate PKCE verifier + challenge + verifier, err := generateCodeVerifier() + if err != nil { + return "", "", "", "", fmt.Errorf("generating PKCE verifier: %w", err) + } + challenge := codeChallenge(verifier) + + state, err := randomState() + if err != nil { + return "", "", "", "", err + } + + redirectURI := fmt.Sprintf("http://localhost:%d%s", ssoPort, ssoCallbackPath) + ln, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", ssoPort)) + if err != nil { + return "", "", "", "", fmt.Errorf("starting local callback server on port %d: %w", ssoPort, err) + } + + authURL := buildAuthURL(meta.AuthorizationEndpoint, ssoClientID, redirectURI, challenge, state) + fmt.Fprintf(os.Stderr, "\nOpening browser for SSO login…\n%s\n\n", authURL) + _ = console.OpenBrowser(authURL) + + code, err := waitForCallback(ln, state) + if err != nil { + return "", "", "", "", fmt.Errorf("callback failed: %w", err) + } + + rawToken, rawRefreshToken, err := exchangeCode(meta.TokenEndpoint, ssoClientID, code, verifier, redirectURI) + if err != nil { + return "", "", "", "", fmt.Errorf("token exchange failed: %w", err) + } + + claims, err := parseJWT(rawToken) + if err != nil { + return "", "", "", "", fmt.Errorf("extracting claims from token: %w", err) + } + + return rawToken, rawRefreshToken, claims.AccountID, claims.Subdomain, nil +} + +func buildAuthURL(endpoint, clientID, redirectURI, challenge, state string) string { + params := url.Values{} + params.Set("response_type", "code") + params.Set("client_id", clientID) + params.Set("redirect_uri", redirectURI) + params.Set("code_challenge", challenge) + params.Set("code_challenge_method", "S256") + params.Set("state", state) + params.Set("scope", "openid profile email") + return endpoint + "?" + params.Encode() +} + +// waitForCallback runs a one-shot HTTP server on ln, waits for the OAuth callback, +// and returns the authorization code. +func waitForCallback(ln net.Listener, expectedState string) (string, error) { + codeCh := make(chan string, 1) + errCh := make(chan error, 1) + + srv := &http.Server{ReadHeaderTimeout: 10 * time.Second} + srv.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != ssoCallbackPath { + http.NotFound(w, r) + return + } + q := r.URL.Query() + if errParam := q.Get("error"); errParam != "" { + desc := q.Get("error_description") + fmt.Fprintf(w, "

Login failed: %s

%s

You may close this tab.

", errParam, desc) + errCh <- fmt.Errorf("authorization error: %s — %s", errParam, desc) + return + } + if q.Get("state") != expectedState { + http.Error(w, "invalid state", http.StatusBadRequest) + errCh <- fmt.Errorf("state mismatch — possible CSRF") + return + } + code := q.Get("code") + if code == "" { + http.Error(w, "missing code", http.StatusBadRequest) + errCh <- fmt.Errorf("no authorization code in callback") + return + } + fmt.Fprintf(w, "

Login successful!

You may close this tab and return to your terminal.

") + codeCh <- code + }) + + go func() { + if serveErr := srv.Serve(ln); serveErr != nil && serveErr != http.ErrServerClosed { + errCh <- serveErr + } + }() + + ctx, cancel := context.WithTimeout(context.Background(), ssoCallbackTimeout) + defer cancel() + + var code string + shutdown := func() { + shutCtx, shutCancel := context.WithTimeout(context.Background(), 2*time.Second) + defer shutCancel() + srv.Shutdown(shutCtx) //nolint:errcheck + } + + select { + case code = <-codeCh: + shutdown() + return code, nil + case err := <-errCh: + shutdown() + return "", err + case <-ctx.Done(): + shutdown() + return "", fmt.Errorf("timed out waiting for browser login (%.0f min)", ssoCallbackTimeout.Minutes()) + } +} + +func exchangeCode(tokenEndpoint, clientID, code, verifier, redirectURI string) (accessToken, refreshToken string, err error) { + params := url.Values{} + params.Set("grant_type", "authorization_code") + params.Set("code", code) + params.Set("redirect_uri", redirectURI) + params.Set("client_id", clientID) + params.Set("code_verifier", verifier) + + c := &http.Client{Timeout: ssoTokenTimeout} + resp, err := c.Post(tokenEndpoint, "application/x-www-form-urlencoded", strings.NewReader(params.Encode())) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return "", "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncate(string(body), 200)) + } + + var tok struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + Error string `json:"error"` + ErrorDesc string `json:"error_description"` + } + if err := json.Unmarshal(body, &tok); err != nil { + return "", "", fmt.Errorf("parsing token response: %w", err) + } + if tok.Error != "" { + return "", "", fmt.Errorf("%s: %s", tok.Error, tok.ErrorDesc) + } + if tok.AccessToken == "" { + return "", "", fmt.Errorf("token response missing access_token") + } + + hlog.Debug("token exchange", "has_refresh_token", tok.RefreshToken != "") + return tok.AccessToken, tok.RefreshToken, nil +} + +type jwtClaims struct { + AccountID string // from account_id claim + Subdomain string // from account_metadata..subdomain (may be empty) +} + +// parseJWT extracts claims from the JWT payload (no signature verification — +// the server will reject an invalid token; this is just for local display/storage). +// +// Confirmed claims from id.harness.io/idp/realms/HarnessIDP: +// +// account_id — Harness account ID +// email — user email +// name / given_name — display name +// preferred_username — login username +// sub — Keycloak user UUID (not the Harness account ID) +// scope — includes "organization:" as well +// account_metadata..subdomain — vanity subdomain (e.g. "prod2.harness.io"), may be empty during platform transition +// account_metadata..clusterId — e.g. "prod2" +// account_metadata..accountName — human-readable account name +func parseJWT(rawToken string) (*jwtClaims, error) { + parts := strings.Split(rawToken, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("not a JWT (expected 3 segments)") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("decoding JWT payload: %w", err) + } + var raw map[string]any + if err := json.Unmarshal(payload, &raw); err != nil { + return nil, fmt.Errorf("parsing JWT claims: %w", err) + } + hlog.Debug("JWT claims", "payload", string(payload)) + + var accountID string + for _, key := range []string{"accountID", "account_id", "accountId"} { + if v, ok := raw[key]; ok { + if s, ok := v.(string); ok && s != "" { + accountID = s + break + } + } + } + if accountID == "" { + return nil, fmt.Errorf("JWT does not contain an accountID claim — contact your Harness administrator") + } + + var subdomain string + if meta, ok := raw["account_metadata"].(map[string]any); ok { + if acctMeta, ok := meta[accountID].(map[string]any); ok { + if vals, ok := acctMeta["subdomain"].([]any); ok && len(vals) > 0 { + subdomain, _ = vals[0].(string) + } + } + } + + return &jwtClaims{AccountID: accountID, Subdomain: subdomain}, nil +} + +// --- PKCE helpers --- + +func generateCodeVerifier() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func codeChallenge(verifier string) string { + h := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(h[:]) +} + +func randomState() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} diff --git a/modules/core/auth/status.go b/modules/core/auth/status.go index 71a0ff4..18f24e5 100644 --- a/modules/core/auth/status.go +++ b/modules/core/auth/status.go @@ -118,12 +118,14 @@ func runStatusChecks(profileFlag string) statusResult { } r.Status.API = checkResult{OK: true} - if err := auth.ValidatePATFormat(resolved.Token); err != nil { - r.Status.User = checkResult{OK: false, Error: err.Error()} - r.Status.Account = skip - r.Status.Org = &skip - r.Status.Project = &skip - return r + if resolved.AuthType != auth.AuthTypeSSO { + if err := auth.ValidatePATFormat(resolved.PATToken); err != nil { + r.Status.User = checkResult{OK: false, Error: err.Error()} + r.Status.Account = skip + r.Status.Org = &skip + r.Status.Project = &skip + return r + } } c := &http.Client{Timeout: 10 * time.Second} @@ -326,7 +328,7 @@ func checkAPIUrl(apiURL string) error { func fetchCurrentUser(c *http.Client, a *auth.ResolvedAuth) (any, error) { url := fmt.Sprintf("%s/ng/api/user/currentUser?accountIdentifier=%s", a.APIUrl, a.AccountID) - body, status, err := doGet(c, url, a.Token) + body, status, err := doGet(c, url, a) if err != nil { return nil, err } @@ -348,7 +350,7 @@ func fetchCurrentUser(c *http.Client, a *auth.ResolvedAuth) (any, error) { func checkAccount(c *http.Client, a *auth.ResolvedAuth) (string, error) { url := fmt.Sprintf("%s/ng/api/accounts/%s?accountIdentifier=%s", a.APIUrl, a.AccountID, a.AccountID) - body, status, err := doGet(c, url, a.Token) + body, status, err := doGet(c, url, a) if err != nil { return "", err } @@ -372,7 +374,7 @@ func checkAccount(c *http.Client, a *auth.ResolvedAuth) (string, error) { func checkOrg(c *http.Client, a *auth.ResolvedAuth) (string, error) { url := fmt.Sprintf("%s/ng/api/organizations/%s?accountIdentifier=%s", a.APIUrl, a.OrgID, a.AccountID) - body, status, err := doGet(c, url, a.Token) + body, status, err := doGet(c, url, a) if err != nil { return "", err } @@ -397,7 +399,7 @@ func checkOrg(c *http.Client, a *auth.ResolvedAuth) (string, error) { func checkProject(c *http.Client, a *auth.ResolvedAuth) (string, error) { url := fmt.Sprintf("%s/ng/api/projects/%s?accountIdentifier=%s&orgIdentifier=%s", a.APIUrl, a.ProjectID, a.AccountID, a.OrgID) - body, status, err := doGet(c, url, a.Token) + body, status, err := doGet(c, url, a) if err != nil { return "", err } @@ -419,12 +421,16 @@ func checkProject(c *http.Client, a *auth.ResolvedAuth) (string, error) { } } -func doGet(c *http.Client, url, token string) ([]byte, int, error) { +func doGet(c *http.Client, url string, a *auth.ResolvedAuth) ([]byte, int, error) { req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, 0, fmt.Errorf("building request: %w", err) } - req.Header.Set("x-api-key", token) + if a.AuthType == auth.AuthTypeSSO { + req.Header.Set("Authorization", "Bearer "+a.SSOToken) + } else { + req.Header.Set("x-api-key", a.PATToken) + } resp, err := c.Do(req) if err != nil { diff --git a/modules/core/auth/token.go b/modules/core/auth/token.go index b60a312..42d6363 100644 --- a/modules/core/auth/token.go +++ b/modules/core/auth/token.go @@ -19,6 +19,6 @@ func TokenHandler(ctx *cmdctx.Ctx) error { return err } - fmt.Fprintln(os.Stdout, resolved.Token) + fmt.Fprintln(os.Stdout, resolved.PATToken) return nil } diff --git a/modules/core/core.go b/modules/core/core.go index ad1cac8..7021c71 100644 --- a/modules/core/core.go +++ b/modules/core/core.go @@ -17,6 +17,7 @@ var helpText string const ( // auth loginHandlerID = "login" + loginSSOHandlerID = "loginsso" logoutHandlerID = "logout" statusHandlerID = "status" setHandlerID = "set" @@ -38,6 +39,7 @@ const ( func ModuleInit(reg registry.ModuleRegistrar) { reg.SetHelpText(helpText) reg.RegisterWorkflow(loginHandlerID, auth.LoginHandler) + reg.RegisterWorkflow(loginSSOHandlerID, auth.LoginSSOHandler) reg.RegisterWorkflow(logoutHandlerID, auth.LogoutHandler) reg.RegisterWorkflow(statusHandlerID, auth.StatusHandler) reg.RegisterWorkflow(setHandlerID, auth.SetHandler) diff --git a/modules/har/pkg/har/configure_npm.go b/modules/har/pkg/har/configure_npm.go index e28044f..572b3a7 100644 --- a/modules/har/pkg/har/configure_npm.go +++ b/modules/har/pkg/har/configure_npm.go @@ -51,7 +51,7 @@ func configureNpm(ctx *cmdctx.Ctx) error { return err } - if err := writeNpmrc(npmrcPath, registryURL, scope, a.Token); err != nil { + if err := writeNpmrc(npmrcPath, registryURL, scope, a.PATToken); err != nil { return fmt.Errorf("writing .npmrc: %w", err) } diff --git a/modules/har/pkg/har/firewall.go b/modules/har/pkg/har/firewall.go index edf143c..126622d 100644 --- a/modules/har/pkg/har/firewall.go +++ b/modules/har/pkg/har/firewall.go @@ -49,9 +49,9 @@ type bulkEvalAcceptedResp struct { } type bulkEvalStatusData struct { - Status *string `json:"status,omitempty"` - Error *string `json:"error,omitempty"` - Scans *[]bulkScanItem `json:"scans,omitempty"` + Status *string `json:"status,omitempty"` + Error *string `json:"error,omitempty"` + Scans *[]bulkScanItem `json:"scans,omitempty"` } type bulkEvalStatusResp struct { @@ -216,7 +216,7 @@ func executeArtifactFirewallScanHandler(cmdCtx *cmdctx.Ctx) error { // 1. Look up registry UUID. fmt.Printf("Fetching registry details for: %s\n", registryID) - registryUUID, err := getRegistryUUID(ctx, hc, a.APIUrl, a.Token, a.AccountID, a.OrgID, a.ProjectID, registryID) + registryUUID, err := getRegistryUUID(ctx, hc, a.APIUrl, a.PATToken, a.AccountID, a.OrgID, a.ProjectID, registryID) if err != nil { return fmt.Errorf("fetching registry: %w", err) } @@ -226,7 +226,7 @@ func executeArtifactFirewallScanHandler(cmdCtx *cmdctx.Ctx) error { fmt.Printf("Initiating evaluation for %s@%s\n", packageName, version) evalURL := buildEvalURL(a.APIUrl, a.AccountID, a.OrgID, a.ProjectID) var initResp bulkEvalAcceptedResp - if err := doHAR(ctx, hc, a.Token, evalURL, "POST", bulkEvalRequest{ + if err := doHAR(ctx, hc, a.PATToken, evalURL, "POST", bulkEvalRequest{ RegistryId: registryUUID, Artifacts: []artifactScanInput{{PackageName: packageName, Version: version}}, }, &initResp); err != nil { @@ -244,7 +244,7 @@ func executeArtifactFirewallScanHandler(cmdCtx *cmdctx.Ctx) error { var statusData *bulkEvalStatusData for i := 0; i < 120; i++ { var statusResp bulkEvalStatusResp - if err := doHAR(ctx, hc, a.Token, statusURL, "GET", nil, &statusResp); err != nil { + if err := doHAR(ctx, hc, a.PATToken, statusURL, "GET", nil, &statusResp); err != nil { return fmt.Errorf("polling evaluation status: %w", err) } if statusResp.Data == nil || statusResp.Data.Status == nil { @@ -306,7 +306,7 @@ done: fmt.Println("Fetching detailed scan information...") detailURL := buildScanDetailsURL(a.APIUrl, scanID, a.AccountID) var detailResp scanDetailsResp - if err := doHAR(ctx, hc, a.Token, detailURL, "GET", nil, &detailResp); err != nil { + if err := doHAR(ctx, hc, a.PATToken, detailURL, "GET", nil, &detailResp); err != nil { fmt.Printf(" (could not fetch scan details: %v)\n", err) return nil } @@ -480,7 +480,7 @@ func executeRegistryFirewallScanHandler(cmdCtx *cmdctx.Ctx) error { // 1. Registry UUID. fmt.Printf("Fetching registry details for: %s\n", registryID) - registryUUID, err := getRegistryUUID(ctx, hc, a.APIUrl, a.Token, a.AccountID, a.OrgID, a.ProjectID, registryID) + registryUUID, err := getRegistryUUID(ctx, hc, a.APIUrl, a.PATToken, a.AccountID, a.OrgID, a.ProjectID, registryID) if err != nil { return fmt.Errorf("fetching registry: %w", err) } @@ -520,7 +520,7 @@ func executeRegistryFirewallScanHandler(cmdCtx *cmdctx.Ctx) error { evalURL := buildEvalURL(a.APIUrl, a.AccountID, a.OrgID, a.ProjectID) var initResp bulkEvalAcceptedResp - if err := doHAR(ctx, hc, a.Token, evalURL, "POST", bulkEvalRequest{ + if err := doHAR(ctx, hc, a.PATToken, evalURL, "POST", bulkEvalRequest{ RegistryId: registryUUID, Artifacts: artifacts, }, &initResp); err != nil { @@ -536,7 +536,7 @@ func executeRegistryFirewallScanHandler(cmdCtx *cmdctx.Ctx) error { var statusData *bulkEvalStatusData for poll := 0; poll < 120; poll++ { var statusResp bulkEvalStatusResp - if err := doHAR(ctx, hc, a.Token, statusURL, "GET", nil, &statusResp); err != nil { + if err := doHAR(ctx, hc, a.PATToken, statusURL, "GET", nil, &statusResp); err != nil { return fmt.Errorf("batch %d: polling status: %w", i+1, err) } if statusResp.Data == nil || statusResp.Data.Status == nil { diff --git a/modules/har/pkg/har/pull.go b/modules/har/pkg/har/pull.go index 9d78d2a..1544339 100644 --- a/modules/har/pkg/har/pull.go +++ b/modules/har/pkg/har/pull.go @@ -118,7 +118,7 @@ func pullGenericArtifact(ctx *cmdctx.Ctx) error { if err != nil { return fmt.Errorf("building request: %w", err) } - setAuthHeader(req, ctx.Auth.Token) + setAuthHeader(req, ctx.Auth.PATToken) resp, err := newHTTPClient().Do(req) if err != nil { @@ -200,4 +200,3 @@ func pullHelmArtifact(_ *cmdctx.Ctx) error { func pullDockerArtifact(_ *cmdctx.Ctx) error { return fmt.Errorf("pull docker artifact: not yet implemented") } - diff --git a/modules/har/pkg/har/push_cargo.go b/modules/har/pkg/har/push_cargo.go index c0c909f..ec66ba7 100644 --- a/modules/har/pkg/har/push_cargo.go +++ b/modules/har/pkg/har/push_cargo.go @@ -92,7 +92,7 @@ func pushCargoArtifact(ctx *cmdctx.Ctx) error { if err != nil { return fmt.Errorf("push cargo: building request: %w", err) } - setAuthHeader(req, ctx.Auth.Token) + setAuthHeader(req, ctx.Auth.PATToken) req.Header.Set("Content-Type", "application/octet-stream") req.ContentLength = int64(len(payload)) @@ -192,7 +192,7 @@ func buildCargoPayload(name, version string, crateBytes []byte) ([]byte, error) return nil, fmt.Errorf("marshaling metadata: %w", err) } - metaLen := uint32(len(metaJSON)) // #nosec G115 + metaLen := uint32(len(metaJSON)) // #nosec G115 crateLen := uint32(len(crateBytes)) // #nosec G115 buf := make([]byte, 4+metaLen+4+crateLen) diff --git a/modules/har/pkg/har/push_composer.go b/modules/har/pkg/har/push_composer.go index c511f7f..b15cdde 100644 --- a/modules/har/pkg/har/push_composer.go +++ b/modules/har/pkg/har/push_composer.go @@ -60,7 +60,7 @@ func pushComposerArtifact(ctx *cmdctx.Ctx) error { if err != nil { return fmt.Errorf("building request: %w", err) } - setAuthHeader(req, ctx.Auth.Token) + setAuthHeader(req, ctx.Auth.PATToken) req.Header.Set("Content-Type", "application/octet-stream") req.ContentLength = fi.Size() diff --git a/modules/har/pkg/har/push_conda.go b/modules/har/pkg/har/push_conda.go index 3b6f0ed..da5b8e1 100644 --- a/modules/har/pkg/har/push_conda.go +++ b/modules/har/pkg/har/push_conda.go @@ -103,7 +103,7 @@ func pushCondaArtifact(ctx *cmdctx.Ctx) error { if err != nil { return fmt.Errorf("building request: %w", err) } - setAuthHeader(req, ctx.Auth.Token) + setAuthHeader(req, ctx.Auth.PATToken) req.Header.Set("Content-Type", "application/octet-stream") req.Header.Set("X-File-Name", fileName) req.Header.Set("X-Subdir", meta.Subdir) diff --git a/modules/har/pkg/har/push_dart.go b/modules/har/pkg/har/push_dart.go index 2c366f3..e330022 100644 --- a/modules/har/pkg/har/push_dart.go +++ b/modules/har/pkg/har/push_dart.go @@ -109,7 +109,7 @@ func pushDartArtifact(ctx *cmdctx.Ctx) error { if err != nil { return fmt.Errorf("building request: %w", err) } - setAuthHeader(req, ctx.Auth.Token) + setAuthHeader(req, ctx.Auth.PATToken) req.Header.Set("Content-Type", mw.FormDataContentType()) if sums, sumErr := computeFileChecksums(localFile); sumErr == nil { diff --git a/modules/har/pkg/har/push_go.go b/modules/har/pkg/har/push_go.go index ecd088e..37ed071 100644 --- a/modules/har/pkg/har/push_go.go +++ b/modules/har/pkg/har/push_go.go @@ -23,7 +23,8 @@ import ( // // ctx.Id = "/" where name is not used directly (registry identifies the target) // ctx.Args[0] = local directory path containing go.mod (option a), or ignored when all three -// --mod-file / --info-file / --zip-file flags are supplied (option b). +// +// --mod-file / --info-file / --zip-file flags are supplied (option b). // // Required flag: --version (e.g. "v1.2.3") // Optional flags: --mod-file, --info-file, --zip-file (pre-built files; skips local generation) @@ -118,7 +119,7 @@ func pushGoArtifact(ctx *cmdctx.Ctx) error { if err != nil { return fmt.Errorf("building request: %w", err) } - setAuthHeader(req, ctx.Auth.Token) + setAuthHeader(req, ctx.Auth.PATToken) req.Header.Set("Content-Type", mw.FormDataContentType()) if _, doErr := doRequest(newHTTPClient(), req); doErr != nil { diff --git a/modules/har/pkg/har/push_maven.go b/modules/har/pkg/har/push_maven.go index 08dfb37..900daf9 100644 --- a/modules/har/pkg/har/push_maven.go +++ b/modules/har/pkg/har/push_maven.go @@ -47,10 +47,10 @@ type mavenCoords struct { // mavenMetadataXML is a minimal representation of maven-metadata.xml. type mavenMetadataXML struct { - XMLName xml.Name `xml:"metadata"` - GroupID string `xml:"groupId"` - ArtifactID string `xml:"artifactId"` - Versioning mavenVersioning `xml:"versioning"` + XMLName xml.Name `xml:"metadata"` + GroupID string `xml:"groupId"` + ArtifactID string `xml:"artifactId"` + Versioning mavenVersioning `xml:"versioning"` } type mavenVersioning struct { @@ -62,8 +62,9 @@ type mavenVersioning struct { // pushMavenArtifact implements "push artifact" for Maven (.jar/.war) packages. // // Required: -// ctx.Args[0] = local .jar or .war file path -// --pom-file flag = path to the project-level pom.xml or .pom file +// +// ctx.Args[0] = local .jar or .war file path +// --pom-file flag = path to the project-level pom.xml or .pom file // // The function: // 1. Validates the jar/war and pom file paths. @@ -384,7 +385,7 @@ func mavenPutFile(ctx *cmdctx.Ctx, client *http.Client, registry, groupPath stri if err != nil { return fmt.Errorf("building request: %w", err) } - setAuthHeader(req, ctx.Auth.Token) + setAuthHeader(req, ctx.Auth.PATToken) req.Header.Set("Content-Type", contentType) req.ContentLength = fi.Size() @@ -407,7 +408,7 @@ func mavenPutBytes(ctx *cmdctx.Ctx, client *http.Client, registry, groupPath str if err != nil { return fmt.Errorf("building request: %w", err) } - setAuthHeader(req, ctx.Auth.Token) + setAuthHeader(req, ctx.Auth.PATToken) req.Header.Set("Content-Type", contentType) req.ContentLength = int64(len(content)) @@ -450,7 +451,7 @@ func updateMavenMetadata(ctx *cmdctx.Ctx, client *http.Client, registry, groupPa if err != nil { return fmt.Errorf("building metadata GET request: %w", err) } - setAuthHeader(getReq, ctx.Auth.Token) + setAuthHeader(getReq, ctx.Auth.PATToken) getResp, err := client.Do(getReq) if err != nil { @@ -523,7 +524,7 @@ func putBytesToURL(ctx *cmdctx.Ctx, client *http.Client, targetURL string, conte if err != nil { return fmt.Errorf("building request: %w", err) } - setAuthHeader(req, ctx.Auth.Token) + setAuthHeader(req, ctx.Auth.PATToken) req.Header.Set("Content-Type", contentType) req.ContentLength = int64(len(content)) diff --git a/modules/har/pkg/har/push_npm.go b/modules/har/pkg/har/push_npm.go index 57ca1c7..8832cd3 100644 --- a/modules/har/pkg/har/push_npm.go +++ b/modules/har/pkg/har/push_npm.go @@ -119,19 +119,19 @@ type npmAttachment struct { // npmUploadPayload is the full JSON body sent to the npm upload endpoint. type npmUploadPayload struct { - ID string `json:"_id"` - Name string `json:"name"` - Description interface{} `json:"description,omitempty"` - DistTags map[string]string `json:"dist-tags"` - Versions map[string]*npmVersionEntry `json:"versions"` - Readme string `json:"readme,omitempty"` - License interface{} `json:"license,omitempty"` - Homepage interface{} `json:"homepage,omitempty"` - Keywords []string `json:"keywords,omitempty"` - Repository interface{} `json:"repository,omitempty"` - Author interface{} `json:"author,omitempty"` - Bugs interface{} `json:"bugs,omitempty"` - Attachments map[string]*npmAttachment `json:"_attachments"` + ID string `json:"_id"` + Name string `json:"name"` + Description interface{} `json:"description,omitempty"` + DistTags map[string]string `json:"dist-tags"` + Versions map[string]*npmVersionEntry `json:"versions"` + Readme string `json:"readme,omitempty"` + License interface{} `json:"license,omitempty"` + Homepage interface{} `json:"homepage,omitempty"` + Keywords []string `json:"keywords,omitempty"` + Repository interface{} `json:"repository,omitempty"` + Author interface{} `json:"author,omitempty"` + Bugs interface{} `json:"bugs,omitempty"` + Attachments map[string]*npmAttachment `json:"_attachments"` } // pushNpmArtifact implements "push artifact " for npm packages. @@ -276,7 +276,7 @@ func pushNpmArtifact(ctx *cmdctx.Ctx) error { if err != nil { return fmt.Errorf("building request: %w", err) } - setAuthHeader(req, ctx.Auth.Token) + setAuthHeader(req, ctx.Auth.PATToken) req.Header.Set("Content-Type", "application/json") if sums, sumErr := computeFileChecksums(localFile); sumErr == nil { diff --git a/modules/har/pkg/har/push_nuget.go b/modules/har/pkg/har/push_nuget.go index df250dc..54b4bfe 100644 --- a/modules/har/pkg/har/push_nuget.go +++ b/modules/har/pkg/har/push_nuget.go @@ -78,7 +78,7 @@ func pushNugetArtifact(ctx *cmdctx.Ctx) error { if err != nil { return fmt.Errorf("building request: %w", err) } - setAuthHeader(req, ctx.Auth.Token) + setAuthHeader(req, ctx.Auth.PATToken) req.Header.Set("Content-Type", mw.FormDataContentType()) if sums, sumErr := computeFileChecksums(localFile); sumErr == nil { diff --git a/modules/har/pkg/har/push_puppet.go b/modules/har/pkg/har/push_puppet.go index 1986955..fe88b72 100644 --- a/modules/har/pkg/har/push_puppet.go +++ b/modules/har/pkg/har/push_puppet.go @@ -96,7 +96,7 @@ func pushPuppetArtifact(ctx *cmdctx.Ctx) error { if err != nil { return fmt.Errorf("building request: %w", err) } - setAuthHeader(req, ctx.Auth.Token) + setAuthHeader(req, ctx.Auth.PATToken) req.Header.Set("Content-Type", mw.FormDataContentType()) if sums, sumErr := computeFileChecksums(localFile); sumErr == nil { diff --git a/modules/har/pkg/har/push_python.go b/modules/har/pkg/har/push_python.go index a2b4c3f..b07fc26 100644 --- a/modules/har/pkg/har/push_python.go +++ b/modules/har/pkg/har/push_python.go @@ -139,7 +139,7 @@ func uploadPythonFile(ctx *cmdctx.Ctx, registry, filePath string) error { if err != nil { return fmt.Errorf("building request: %w", err) } - setAuthHeader(req, ctx.Auth.Token) + setAuthHeader(req, ctx.Auth.PATToken) req.Header.Set("Content-Type", mw.FormDataContentType()) if sums, sumErr := computeFileChecksums(filePath); sumErr == nil { diff --git a/modules/har/pkg/har/push_rpm.go b/modules/har/pkg/har/push_rpm.go index cddbec5..31fc14f 100644 --- a/modules/har/pkg/har/push_rpm.go +++ b/modules/har/pkg/har/push_rpm.go @@ -80,7 +80,7 @@ func pushRpmArtifact(ctx *cmdctx.Ctx) error { if err != nil { return fmt.Errorf("building request: %w", err) } - setAuthHeader(req, ctx.Auth.Token) + setAuthHeader(req, ctx.Auth.PATToken) req.Header.Set("Content-Type", mw.FormDataContentType()) if sums, sumErr := computeFileChecksums(localFile); sumErr == nil { diff --git a/modules/har/pkg/har/push_swift.go b/modules/har/pkg/har/push_swift.go index 57063c0..2d1f91b 100644 --- a/modules/har/pkg/har/push_swift.go +++ b/modules/har/pkg/har/push_swift.go @@ -113,7 +113,7 @@ func pushSwiftArtifact(ctx *cmdctx.Ctx) error { if err != nil { return fmt.Errorf("building request: %w", err) } - setAuthHeader(req, ctx.Auth.Token) + setAuthHeader(req, ctx.Auth.PATToken) req.Header.Set("Content-Type", mw.FormDataContentType()) req.Header.Set("Accept", "application/vnd.swift.registry.v1+json") diff --git a/modules/iacm/plan.go b/modules/iacm/plan.go index 69214f4..6630b6e 100644 --- a/modules/iacm/plan.go +++ b/modules/iacm/plan.go @@ -227,7 +227,7 @@ func doIACM(ctx context.Context, hc *http.Client, a *auth.ResolvedAuth, method, if err != nil { return err } - req.Header.Set("x-api-key", a.Token) + req.Header.Set("x-api-key", a.PATToken) req.Header.Set("harness-account", a.AccountID) if body != nil { req.Header.Set("Content-Type", "application/json") @@ -284,7 +284,7 @@ func uploadRemoteExecution(ctx context.Context, hc *http.Client, a *auth.Resolve if err != nil { return nil, err } - req.Header.Set("x-api-key", a.Token) + req.Header.Set("x-api-key", a.PATToken) req.Header.Set("harness-account", a.AccountID) req.Header.Set("Content-Digest", checksum) req.Header.Set("Content-Type", "application/octet-stream") diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index 2f791f7..81626c9 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -17,12 +17,22 @@ import ( "github.com/harness/harness-cli/pkg/hbase" ) +// AuthType identifies how the token in the credentials file was obtained. +// Empty string (existing profiles) is treated as AuthTypePAT. +type AuthType = string + +const ( + AuthTypePAT = "pat" // default; omitted from YAML for existing profiles + AuthTypeSSO = "sso" // OAuth2 JWT obtained via browser login +) + type Profile struct { - APIUrl string `yaml:"api_url"` - AccountID string `yaml:"account_id"` - OrgID string `yaml:"org_id,omitempty"` - ProjectID string `yaml:"project_id,omitempty"` - RegistryURL string `yaml:"registry_url,omitempty"` + APIUrl string `yaml:"api_url"` + AccountID string `yaml:"account_id"` + OrgID string `yaml:"org_id,omitempty"` + ProjectID string `yaml:"project_id,omitempty"` + RegistryURL string `yaml:"registry_url,omitempty"` + AuthType AuthType `yaml:"auth_type,omitempty"` // omitted for existing PAT profiles } type Config struct { @@ -32,15 +42,20 @@ type Config struct { const SourceEnv = "env" // ResolvedAuth is the result of auth resolution — the active credentials for a command invocation. -// Token is present but never printed; callers that display auth context must omit it. +// Credential fields are never printed; callers that display auth context must omit them. type ResolvedAuth struct { - Source string // "profile:" or SourceEnv + Source string // "profile:" or SourceEnv + AuthType AuthType // AuthTypePAT or AuthTypeSSO APIUrl string - Token string AccountID string OrgID string ProjectID string RegistryURL string + + // Exactly one of these is set depending on AuthType. + PATToken string // set when AuthType == AuthTypePAT + SSOToken string // set when AuthType == AuthTypeSSO + RefreshToken string // set when AuthType == AuthTypeSSO } func LoadConfig() (*Config, error) { @@ -98,7 +113,7 @@ func Load(profileFlag string) (*ResolvedAuth, error) { } return &ResolvedAuth{ Source: SourceEnv, - Token: key, + PATToken: key, AccountID: acct, OrgID: os.Getenv(hbase.EnvOrg), ProjectID: os.Getenv(hbase.EnvProject), @@ -116,20 +131,26 @@ func Load(profileFlag string) (*ResolvedAuth, error) { // Validate checks that a ResolvedAuth is complete enough to make API calls. func Validate(r *ResolvedAuth) error { - if r.Token == "" { - return fmt.Errorf("no token found for profile — run 'harness login' to re-authenticate") - } - if err := ValidatePATFormat(r.Token); err != nil { - if r.Source == SourceEnv { - return fmt.Errorf("%s is invalid: %w", hbase.EnvAPIKey, err) + if r.AuthType == AuthTypeSSO { + if r.SSOToken == "" { + return fmt.Errorf("no token found for profile — run 'harness auth loginsso' to re-authenticate") } - return fmt.Errorf("stored token is invalid — run 'harness login' to re-authenticate: %w", err) - } - if tokenAcct := AccountIDFromToken(r.Token); tokenAcct != "" && r.AccountID != tokenAcct { - if r.Source == SourceEnv { - return fmt.Errorf("%s %q does not match account in token %q", hbase.EnvAccount, r.AccountID, tokenAcct) + } else { + if r.PATToken == "" { + return fmt.Errorf("no token found for profile — run 'harness login' to re-authenticate") + } + if err := ValidatePATFormat(r.PATToken); err != nil { + if r.Source == SourceEnv { + return fmt.Errorf("%s is invalid: %w", hbase.EnvAPIKey, err) + } + return fmt.Errorf("stored token is invalid — run 'harness login' to re-authenticate: %w", err) + } + if tokenAcct := AccountIDFromToken(r.PATToken); tokenAcct != "" && r.AccountID != tokenAcct { + if r.Source == SourceEnv { + return fmt.Errorf("%s %q does not match account in token %q", hbase.EnvAccount, r.AccountID, tokenAcct) + } + return fmt.Errorf("stored account %q does not match token — run 'harness login' to re-authenticate", r.AccountID) } - return fmt.Errorf("stored account %q does not match token — run 'harness login' to re-authenticate", r.AccountID) } if r.OrgID == "" { if r.Source == SourceEnv { @@ -174,8 +195,8 @@ func resolveProfile(name string) (*ResolvedAuth, error) { if err != nil { return nil, fmt.Errorf("loading credentials: %w", err) } - token := creds[name] - if token == "" { + profileCreds := creds[name] + if profileCreds == nil || profileCreds.Token == "" { return nil, fmt.Errorf("no token found for profile %q — run 'harness login' to re-authenticate", name) } apiURL := p.APIUrl @@ -186,15 +207,26 @@ func resolveProfile(name string) (*ResolvedAuth, error) { if registryURL == "" { registryURL = hbase.DefaultRegistryURL } - return &ResolvedAuth{ + authType := p.AuthType + if authType == "" { + authType = AuthTypePAT + } + r := &ResolvedAuth{ Source: "profile:" + name, + AuthType: authType, APIUrl: apiURL, - Token: token, AccountID: p.AccountID, OrgID: p.OrgID, ProjectID: p.ProjectID, RegistryURL: registryURL, - }, nil + } + if authType == AuthTypeSSO { + r.SSOToken = profileCreds.Token + r.RefreshToken = profileCreds.RefreshToken + } else { + r.PATToken = profileCreds.Token + } + return r, nil } // ValidateAPIURL returns an error if apiURL is not a parseable URL with a host. diff --git a/pkg/auth/credentials.go b/pkg/auth/credentials.go index 7019cc4..81588ff 100644 --- a/pkg/auth/credentials.go +++ b/pkg/auth/credentials.go @@ -13,7 +13,7 @@ import ( "github.com/harness/harness-cli/pkg/hbase" ) -// custom "toml" parser for the single token field +// custom "toml" parser for credentials fields // if this ever gets more complicated (or requires quoting/unquoting arbitrary strings) we should switch to a real TOML parser const credentialsHeader = `# Harness credentials — contains sensitive tokens @@ -22,13 +22,18 @@ const credentialsHeader = `# Harness credentials — contains sensitive tokens ` -// LoadCredentials reads ~/.harness/credentials and returns a map of profile → token. +type ProfileCredentials struct { + Token string + RefreshToken string // only present for SSO profiles +} + +// LoadCredentials reads ~/.harness/credentials and returns a map of profile → credentials. // Returns an empty map if the file does not exist. -func LoadCredentials() (map[string]string, error) { +func LoadCredentials() (map[string]*ProfileCredentials, error) { path := hbase.GetCredentialsFilePath() data, err := os.ReadFile(path) if os.IsNotExist(err) { - return map[string]string{}, nil + return map[string]*ProfileCredentials{}, nil } if err != nil { return nil, fmt.Errorf("reading credentials: %w", err) @@ -37,8 +42,8 @@ func LoadCredentials() (map[string]string, error) { } // parseCredentials parses a minimal TOML-like file: [section] / key = "value". -func parseCredentials(content string) (map[string]string, error) { - result := map[string]string{} +func parseCredentials(content string) (map[string]*ProfileCredentials, error) { + result := map[string]*ProfileCredentials{} current := "" scanner := bufio.NewScanner(strings.NewReader(content)) for scanner.Scan() { @@ -59,32 +64,49 @@ func parseCredentials(content string) (map[string]string, error) { } k = strings.TrimSpace(k) v = strings.TrimSpace(v) - // unquote simple double-quoted strings if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' { v = v[1 : len(v)-1] } - if k == "token" { - result[current] = v + if result[current] == nil { + result[current] = &ProfileCredentials{} + } + switch k { + case "token", "pat_token": + result[current].Token = v + case "sso_token": + result[current].Token = v + case "refresh_token": + result[current].RefreshToken = v } } return result, scanner.Err() } // SaveCredentials writes the credentials map to ~/.harness/credentials with 0600 perms. -func SaveCredentials(creds map[string]string) error { +func SaveCredentials(creds map[string]*ProfileCredentials) error { path := hbase.GetCredentialsFilePath() if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { return fmt.Errorf("creating credentials dir: %w", err) } var sb strings.Builder sb.WriteString(credentialsHeader) - for name, token := range creds { + for name, c := range creds { sb.WriteString("[") sb.WriteString(name) sb.WriteString("]\n") - sb.WriteString("token = \"") - sb.WriteString(token) - sb.WriteString("\"\n\n") + if c.RefreshToken != "" { + sb.WriteString("sso_token = \"") + sb.WriteString(c.Token) + sb.WriteString("\"\n") + sb.WriteString("refresh_token = \"") + sb.WriteString(c.RefreshToken) + sb.WriteString("\"\n") + } else { + sb.WriteString("pat_token = \"") + sb.WriteString(c.Token) + sb.WriteString("\"\n") + } + sb.WriteString("\n") } return os.WriteFile(path, []byte(sb.String()), 0600) } @@ -95,7 +117,20 @@ func SetCredential(profileName, token string) error { if err != nil { return err } - creds[profileName] = token + if creds[profileName] == nil { + creds[profileName] = &ProfileCredentials{} + } + creds[profileName].Token = token + return SaveCredentials(creds) +} + +// SetSSOCredentials saves both the access token and refresh token for an SSO profile. +func SetSSOCredentials(profileName, token, refreshToken string) error { + creds, err := LoadCredentials() + if err != nil { + return err + } + creds[profileName] = &ProfileCredentials{Token: token, RefreshToken: refreshToken} return SaveCredentials(creds) } diff --git a/pkg/client/client.go b/pkg/client/client.go index e4073ed..8b91f4b 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -64,9 +64,9 @@ func APIErrorMessage(status int, body []byte) string { // Request describes a single HTTP request to the Harness API. type Request struct { - Method string - Path string - QueryParams map[string]string + Method string + Path string + QueryParams map[string]string // Body is always fully materialized: string → sent as-is with BodyContentType; // any other type → JSON-marshaled, BodyContentType defaults to "application/json". Body any @@ -166,7 +166,11 @@ func (c *Client) DoRequest(r Request) (any, http.Header, error) { if err != nil { return nil, nil, fmt.Errorf("creating API request: %w", err) } - req.Header.Set("x-api-key", c.resolved.Token) + if c.resolved.AuthType == auth.AuthTypeSSO { + req.Header.Set("Authorization", "Bearer "+c.resolved.SSOToken) + } else { + req.Header.Set("x-api-key", c.resolved.PATToken) + } if contentType != "" { req.Header.Set("Content-Type", contentType) } diff --git a/pkg/console/console.go b/pkg/console/console.go index 3177cc5..3c4ec06 100644 --- a/pkg/console/console.go +++ b/pkg/console/console.go @@ -11,7 +11,9 @@ import ( "bufio" "fmt" "os" + "os/exec" "regexp" + "runtime" "strings" "sync" "syscall" @@ -163,3 +165,19 @@ func PromptYesNo(question string) bool { } return false } + +// OpenBrowser attempts to open url in the default system browser. +// Returns an error if the browser cannot be launched; callers should fall back +// to printing the URL for the user to open manually. +func OpenBrowser(url string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("cmd", "/c", "start", url) + default: + cmd = exec.Command("xdg-open", url) + } + return cmd.Start() +} diff --git a/pkg/execgraph/execgraph.go b/pkg/execgraph/execgraph.go index 101df24..7db6895 100644 --- a/pkg/execgraph/execgraph.go +++ b/pkg/execgraph/execgraph.go @@ -115,7 +115,7 @@ func FetchExecutionGraph(hc *http.Client, a *auth.ResolvedAuth, execId string) ( if err != nil { return ExecutionGraph{}, err } - req.Header.Set("x-api-key", a.Token) + req.Header.Set("x-api-key", a.PATToken) resp, err := hc.Do(req) if err != nil { return ExecutionGraph{}, err @@ -157,7 +157,7 @@ func FetchExecutionFull(hc *http.Client, a *auth.ResolvedAuth, execId string) (E if err != nil { return ExecutionFull{}, err } - req.Header.Set("x-api-key", a.Token) + req.Header.Set("x-api-key", a.PATToken) resp, err := hc.Do(req) if err != nil { return ExecutionFull{}, err diff --git a/pkg/logstream/logstream.go b/pkg/logstream/logstream.go index 4c16d07..d82a814 100644 --- a/pkg/logstream/logstream.go +++ b/pkg/logstream/logstream.go @@ -50,9 +50,9 @@ const ( type Event struct { Kind EventKind Source string - StartTs int64 // EvStart + StartTs int64 // EvStart Node execgraph.GraphNode // EvEnd - Lines []string // EvLogLine / EvBlob + Lines []string // EvLogLine / EvBlob } type LogKeyEntry struct { @@ -122,7 +122,7 @@ func FetchAndPrintLog(hc *http.Client, a *auth.ResolvedAuth, shortKey, fmtFlag s if err != nil { return false, err } - req.Header.Set("x-api-key", a.Token) + req.Header.Set("x-api-key", a.PATToken) resp, err := hc.Do(req) if err != nil { @@ -152,9 +152,9 @@ func FetchAndPrintLog(hc *http.Client, a *auth.ResolvedAuth, shortKey, fmtFlag s // if the blob is empty and the step finished within 60s, it retries up to 3 times (2s apart). func FetchAndPrintLogWithRetry(hc *http.Client, a *auth.ResolvedAuth, shortKey, fmtFlag string, isPty bool, out io.Writer, endTs int64) (bool, error) { const ( - maxRetries = 3 - retryDelay = 2 * time.Second - retryWindow = 60 * time.Second + maxRetries = 3 + retryDelay = 2 * time.Second + retryWindow = 60 * time.Second ) age := time.Duration(-1) if endTs > 0 { @@ -308,7 +308,7 @@ func StreamSSEToChannel(ctx context.Context, hc *http.Client, a *auth.ResolvedAu if err != nil { return false, err } - req.Header.Set("x-api-key", a.Token) + req.Header.Set("x-api-key", a.PATToken) req.Header.Set("Accept", "text/event-stream") resp, err := hc.Do(req) diff --git a/pkg/spec/core.spec.yaml b/pkg/spec/core.spec.yaml index c0a2e46..6c59fc7 100644 --- a/pkg/spec/core.spec.yaml +++ b/pkg/spec/core.spec.yaml @@ -81,6 +81,24 @@ commands: is_bool: true description: Skip token validation against the API + - command: auth loginsso + verb: auth + noun: loginsso + short: Log in via browser SSO (OAuth2) + hidden: true + no_auth: true + handler_type: workflow + workflow_id: loginsso + flags: + - name: profile + description: Profile name to save credentials under (default "default") + - name: overwrite + is_bool: true + description: Overwrite existing profile without prompting + - name: no-overwrite + is_bool: true + description: Error if profile already exists + - command: auth setscope verb: auth noun: setscope From 8e65c189c14ae82cbfdeb4610d2d18e6667a08ee Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Tue, 16 Jun 2026 15:40:50 -0700 Subject: [PATCH 2/6] sso token refresh --- modules/core/auth/sso.go | 123 ++------------------- pkg/auth/sso.go | 229 +++++++++++++++++++++++++++++++++++++++ pkg/client/client.go | 4 + 3 files changed, 241 insertions(+), 115 deletions(-) create mode 100644 pkg/auth/sso.go diff --git a/modules/core/auth/sso.go b/modules/core/auth/sso.go index a175f32..e1d54bc 100644 --- a/modules/core/auth/sso.go +++ b/modules/core/auth/sso.go @@ -5,12 +5,9 @@ package auth import ( "context" - "crypto/rand" - "crypto/sha256" "encoding/base64" "encoding/json" "fmt" - "io" "net" "net/http" "net/url" @@ -27,13 +24,8 @@ import ( const ( mcpBaseURL = "https://mcp.harness.io" - ssoAuthServerBase = "https://id.harness.io" - ssoMetadataPath = "/.well-known/oauth-authorization-server" ssoCallbackPath = "/oauth/callback" - ssoClientID = "harness-cli-client" ssoPort = 57380 - ssoDiscoverTimeout = 10 * time.Second - ssoTokenTimeout = 30 * time.Second ssoCallbackTimeout = 5 * time.Minute ) @@ -75,7 +67,7 @@ func LoginSSOHandler(ctx *cmdctx.Ctx) error { } } - meta, err := fetchAuthServerMeta(&http.Client{Timeout: ssoDiscoverTimeout}, ssoAuthServerBase) + meta, err := auth.FetchAuthServerMeta(&http.Client{Timeout: 10 * time.Second}, auth.SSOAuthServerBase) if err != nil { return fmt.Errorf("SSO discovery failed: %w", err) } @@ -162,52 +154,16 @@ func resolveAPIURL(token, accountID, subdomain string) (string, error) { return "", fmt.Errorf("could not reach %s — re-run with --api-url to specify the URL manually", candidate) } -// --- discovery --- - -type authServerMeta struct { - Issuer string `json:"issuer"` - AuthorizationEndpoint string `json:"authorization_endpoint"` - TokenEndpoint string `json:"token_endpoint"` - RegistrationEndpoint string `json:"registration_endpoint"` -} - -func fetchAuthServerMeta(c *http.Client, authServerBaseURL string) (*authServerMeta, error) { - metaURL := authServerBaseURL + ssoMetadataPath - resp, err := c.Get(metaURL) - if err != nil { - return nil, err - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != 200 { - return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, metaURL) - } - var meta authServerMeta - if err := json.Unmarshal(body, &meta); err != nil { - return nil, fmt.Errorf("parsing authorization server metadata: %w", err) - } - if meta.AuthorizationEndpoint == "" || meta.TokenEndpoint == "" { - return nil, fmt.Errorf("authorization server metadata missing required endpoints") - } - // If the issuer is a sub-path (e.g. a Keycloak realm), re-fetch metadata from - // the issuer's own discovery doc so we get the real realm endpoints. - if meta.Issuer != "" && meta.Issuer != authServerBaseURL { - return fetchAuthServerMeta(c, meta.Issuer) - } - return &meta, nil -} - // --- PKCE flow --- -func runPKCEFlow(meta *authServerMeta) (token, refreshToken, accountID, subdomain string, err error) { - // Generate PKCE verifier + challenge - verifier, err := generateCodeVerifier() +func runPKCEFlow(meta *auth.AuthServerMeta) (token, refreshToken, accountID, subdomain string, err error) { + verifier, err := auth.GenerateCodeVerifier() if err != nil { return "", "", "", "", fmt.Errorf("generating PKCE verifier: %w", err) } - challenge := codeChallenge(verifier) + challenge := auth.CodeChallenge(verifier) - state, err := randomState() + state, err := auth.RandomState() if err != nil { return "", "", "", "", err } @@ -218,7 +174,7 @@ func runPKCEFlow(meta *authServerMeta) (token, refreshToken, accountID, subdomai return "", "", "", "", fmt.Errorf("starting local callback server on port %d: %w", ssoPort, err) } - authURL := buildAuthURL(meta.AuthorizationEndpoint, ssoClientID, redirectURI, challenge, state) + authURL := buildAuthURL(meta.AuthorizationEndpoint, auth.SSOClientID, redirectURI, challenge, state) fmt.Fprintf(os.Stderr, "\nOpening browser for SSO login…\n%s\n\n", authURL) _ = console.OpenBrowser(authURL) @@ -227,7 +183,7 @@ func runPKCEFlow(meta *authServerMeta) (token, refreshToken, accountID, subdomai return "", "", "", "", fmt.Errorf("callback failed: %w", err) } - rawToken, rawRefreshToken, err := exchangeCode(meta.TokenEndpoint, ssoClientID, code, verifier, redirectURI) + rawToken, rawRefreshToken, err := auth.ExchangeCode(meta.TokenEndpoint, auth.SSOClientID, code, verifier, redirectURI) if err != nil { return "", "", "", "", fmt.Errorf("token exchange failed: %w", err) } @@ -295,7 +251,6 @@ func waitForCallback(ln net.Listener, expectedState string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), ssoCallbackTimeout) defer cancel() - var code string shutdown := func() { shutCtx, shutCancel := context.WithTimeout(context.Background(), 2*time.Second) defer shutCancel() @@ -303,7 +258,7 @@ func waitForCallback(ln net.Listener, expectedState string) (string, error) { } select { - case code = <-codeCh: + case code := <-codeCh: shutdown() return code, nil case err := <-errCh: @@ -315,45 +270,6 @@ func waitForCallback(ln net.Listener, expectedState string) (string, error) { } } -func exchangeCode(tokenEndpoint, clientID, code, verifier, redirectURI string) (accessToken, refreshToken string, err error) { - params := url.Values{} - params.Set("grant_type", "authorization_code") - params.Set("code", code) - params.Set("redirect_uri", redirectURI) - params.Set("client_id", clientID) - params.Set("code_verifier", verifier) - - c := &http.Client{Timeout: ssoTokenTimeout} - resp, err := c.Post(tokenEndpoint, "application/x-www-form-urlencoded", strings.NewReader(params.Encode())) - if err != nil { - return "", "", err - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != 200 { - return "", "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncate(string(body), 200)) - } - - var tok struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - Error string `json:"error"` - ErrorDesc string `json:"error_description"` - } - if err := json.Unmarshal(body, &tok); err != nil { - return "", "", fmt.Errorf("parsing token response: %w", err) - } - if tok.Error != "" { - return "", "", fmt.Errorf("%s: %s", tok.Error, tok.ErrorDesc) - } - if tok.AccessToken == "" { - return "", "", fmt.Errorf("token response missing access_token") - } - - hlog.Debug("token exchange", "has_refresh_token", tok.RefreshToken != "") - return tok.AccessToken, tok.RefreshToken, nil -} - type jwtClaims struct { AccountID string // from account_id claim Subdomain string // from account_metadata..subdomain (may be empty) @@ -412,26 +328,3 @@ func parseJWT(rawToken string) (*jwtClaims, error) { return &jwtClaims{AccountID: accountID, Subdomain: subdomain}, nil } - -// --- PKCE helpers --- - -func generateCodeVerifier() (string, error) { - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(b), nil -} - -func codeChallenge(verifier string) string { - h := sha256.Sum256([]byte(verifier)) - return base64.RawURLEncoding.EncodeToString(h[:]) -} - -func randomState() (string, error) { - b := make([]byte, 16) - if _, err := rand.Read(b); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(b), nil -} diff --git a/pkg/auth/sso.go b/pkg/auth/sso.go new file mode 100644 index 0000000..70fd110 --- /dev/null +++ b/pkg/auth/sso.go @@ -0,0 +1,229 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/harness/harness-cli/pkg/hlog" +) + +// ErrSSOSessionExpired is returned when the refresh token is expired and the +// user must run 'harness auth loginsso' to obtain a new session. +var ErrSSOSessionExpired = fmt.Errorf("SSO session expired — run 'harness auth loginsso' to log in again") + +const ( + SSOAuthServerBase = "https://id.harness.io" + ssoMetadataPath = "/.well-known/oauth-authorization-server" + SSOClientID = "harness-cli-client" + ssoDiscoverTimeout = 10 * time.Second + ssoTokenTimeout = 30 * time.Second + AccessTokenGracePeriod = 15 * time.Second +) + +// AuthServerMeta holds the endpoints from OAuth2 authorization server discovery. +type AuthServerMeta struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + RegistrationEndpoint string `json:"registration_endpoint"` +} + +// FetchAuthServerMeta retrieves OAuth2 authorization server metadata via discovery. +func FetchAuthServerMeta(c *http.Client, authServerBaseURL string) (*AuthServerMeta, error) { + metaURL := authServerBaseURL + ssoMetadataPath + resp, err := c.Get(metaURL) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, metaURL) + } + var meta AuthServerMeta + if err := json.Unmarshal(body, &meta); err != nil { + return nil, fmt.Errorf("parsing authorization server metadata: %w", err) + } + if meta.AuthorizationEndpoint == "" || meta.TokenEndpoint == "" { + return nil, fmt.Errorf("authorization server metadata missing required endpoints") + } + // If the issuer is a sub-path (e.g. a Keycloak realm), re-fetch metadata from + // the issuer's own discovery doc so we get the real realm endpoints. + if meta.Issuer != "" && meta.Issuer != authServerBaseURL { + return FetchAuthServerMeta(c, meta.Issuer) + } + return &meta, nil +} + +// ExchangeCode exchanges an authorization code for access and refresh tokens. +func ExchangeCode(tokenEndpoint, clientID, code, verifier, redirectURI string) (accessToken, refreshToken string, err error) { + params := url.Values{} + params.Set("grant_type", "authorization_code") + params.Set("code", code) + params.Set("redirect_uri", redirectURI) + params.Set("client_id", clientID) + params.Set("code_verifier", verifier) + return doTokenRequest(tokenEndpoint, params) +} + +// RefreshSSOToken exchanges a refresh token for a new access token (and possibly +// a new refresh token). If the server does not return a new refresh token, the +// original is returned unchanged. +func RefreshSSOToken(oldRefreshToken string) (accessToken, refreshToken string, err error) { + meta, err := FetchAuthServerMeta(&http.Client{Timeout: ssoDiscoverTimeout}, SSOAuthServerBase) + if err != nil { + return "", "", fmt.Errorf("SSO discovery failed: %w", err) + } + params := url.Values{} + params.Set("grant_type", "refresh_token") + params.Set("refresh_token", oldRefreshToken) + params.Set("client_id", SSOClientID) + newAccess, newRefresh, err := doTokenRequest(meta.TokenEndpoint, params) + if err != nil { + return "", "", fmt.Errorf("token refresh failed: %w", err) + } + if newRefresh == "" { + newRefresh = oldRefreshToken + } + return newAccess, newRefresh, nil +} + +// CheckAndUpdateAccessToken checks whether the SSO access token in r is expiring +// soon and, if so, refreshes it. On a successful refresh the credentials file and +// r are both updated in place. No-ops for PAT profiles or env-sourced auth. +func CheckAndUpdateAccessToken(r *ResolvedAuth, now time.Time) error { + if r.AuthType != AuthTypeSSO { + return nil + } + if !strings.HasPrefix(r.Source, "profile:") { + return nil + } + if !IsAccessTokenExpiringSoon(r.SSOToken, now) { + return nil + } + + // Check refresh token expiry before attempting the network round-trip. + if IsAccessTokenExpiringSoon(r.RefreshToken, now) { + return ErrSSOSessionExpired + } + + newAccess, newRefresh, err := RefreshSSOToken(r.RefreshToken) + if err != nil { + return fmt.Errorf("SSO access token is expired and token refresh failed: %w", err) + } + + profileName := strings.TrimPrefix(r.Source, "profile:") + if err := SetSSOCredentials(profileName, newAccess, newRefresh); err != nil { + return fmt.Errorf("saving refreshed credentials: %w", err) + } + + r.SSOToken = newAccess + r.RefreshToken = newRefresh + return nil +} + +// AccessTokenExpiry returns the expiration time embedded in the JWT's "exp" claim. +func AccessTokenExpiry(rawToken string) (time.Time, error) { + parts := strings.Split(rawToken, ".") + if len(parts) != 3 { + return time.Time{}, fmt.Errorf("not a JWT (expected 3 segments)") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return time.Time{}, fmt.Errorf("decoding JWT payload: %w", err) + } + var raw struct { + Exp float64 `json:"exp"` + } + if err := json.Unmarshal(payload, &raw); err != nil { + return time.Time{}, fmt.Errorf("parsing JWT claims: %w", err) + } + if raw.Exp == 0 { + return time.Time{}, fmt.Errorf("JWT has no exp claim") + } + return time.Unix(int64(raw.Exp), 0), nil +} + +// IsAccessTokenExpiringSoon reports whether rawToken expires within AccessTokenGracePeriod. +// Returns true (treat as expired) if the expiry cannot be determined. +func IsAccessTokenExpiringSoon(rawToken string, now time.Time) bool { + exp, err := AccessTokenExpiry(rawToken) + if err != nil { + return true + } + return now.After(exp.Add(-AccessTokenGracePeriod)) +} + +// GenerateCodeVerifier generates a random PKCE code verifier. +func GenerateCodeVerifier() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// CodeChallenge derives the S256 PKCE challenge from a verifier. +func CodeChallenge(verifier string) string { + h := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(h[:]) +} + +// RandomState generates a random OAuth2 state parameter. +func RandomState() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func doTokenRequest(tokenEndpoint string, params url.Values) (accessToken, refreshToken string, err error) { + c := &http.Client{Timeout: ssoTokenTimeout} + resp, err := c.Post(tokenEndpoint, "application/x-www-form-urlencoded", strings.NewReader(params.Encode())) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return "", "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncate(string(body), 200)) + } + + var tok struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + Error string `json:"error"` + ErrorDesc string `json:"error_description"` + } + if err := json.Unmarshal(body, &tok); err != nil { + return "", "", fmt.Errorf("parsing token response: %w", err) + } + if tok.Error != "" { + return "", "", fmt.Errorf("%s: %s", tok.Error, tok.ErrorDesc) + } + if tok.AccessToken == "" { + return "", "", fmt.Errorf("token response missing access_token") + } + + hlog.Debug("token exchange", "grant", params.Get("grant_type"), "has_refresh_token", tok.RefreshToken != "") + return tok.AccessToken, tok.RefreshToken, nil +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/pkg/client/client.go b/pkg/client/client.go index 8b91f4b..8487728 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -128,6 +128,10 @@ func (c *Client) PutRaw(path string, queryParams map[string]string, body, conten // If Body is a string, it is sent as-is using BodyContentType. Otherwise Body is JSON-marshaled and // BodyContentType defaults to "application/json". Extra per-request headers may be set via Headers. func (c *Client) DoRequest(r Request) (any, http.Header, error) { + if err := auth.CheckAndUpdateAccessToken(c.resolved, time.Now()); err != nil { + return nil, nil, err + } + u, err := url.Parse(c.resolved.APIUrl + r.Path) if err != nil { return nil, nil, fmt.Errorf("building URL: %w", err) From 3397cc15cd0fb02a2b4bd0a377bdee98e13934b5 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Tue, 16 Jun 2026 17:24:06 -0700 Subject: [PATCH 3/6] latest sso updates --- modules/core/auth/loginwizard.go | 226 ++++++++++++++++--------------- modules/core/auth/set.go | 1 + modules/core/auth/sso.go | 38 +----- pkg/client/client.go | 1 + 4 files changed, 124 insertions(+), 142 deletions(-) diff --git a/modules/core/auth/loginwizard.go b/modules/core/auth/loginwizard.go index 075b1b3..573d295 100644 --- a/modules/core/auth/loginwizard.go +++ b/modules/core/auth/loginwizard.go @@ -4,13 +4,10 @@ package auth import ( - "encoding/json" + "context" "fmt" - "io" - "net/http" "sort" "strings" - "time" "charm.land/bubbles/v2/list" "charm.land/bubbles/v2/spinner" @@ -19,6 +16,7 @@ import ( "charm.land/lipgloss/v2" pkgauth "github.com/harness/harness-cli/pkg/auth" + hclient "github.com/harness/harness-cli/pkg/client" "github.com/harness/harness-cli/pkg/cmdctx" ) @@ -137,11 +135,13 @@ type wizardModel struct { // pre-selected values (for set-wizard mode) currentOrgID string currentProjectID string - setMode bool // started at org pick; no URL/token steps + setMode bool // started at org pick; no URL/token steps + authType pkgauth.AuthType // AuthTypePAT or AuthTypeSSO - cmdCtx *cmdctx.Ctx - err string - cancelled bool + cmdCtx *cmdctx.Ctx + err string + cancelled bool + cancelReason error // set when cancelled due to an internal error, not user action width int height int } @@ -350,6 +350,7 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.setMode { m.err = msg.err.Error() m.cancelled = true + m.cancelReason = msg.err return m, tea.Quit } m.step = stepToken @@ -630,7 +631,7 @@ func (m wizardModel) fetchOrgs() tea.Cmd { token := m.token accountID := m.accountID return func() tea.Msg { - orgs, err := fetchOrgItems(cmdCtx, apiURL, token, accountID) + orgs, err := fetchOrgItems(cmdCtx, apiURL, token, accountID, m.authType) return orgsDoneMsg{orgs: orgs, err: err} } } @@ -642,7 +643,7 @@ func (m wizardModel) fetchProjects() tea.Cmd { accountID := m.accountID orgID := m.orgID return func() tea.Msg { - projects, err := fetchProjectItems(cmdCtx, apiURL, token, accountID, orgID) + projects, err := fetchProjectItems(cmdCtx, apiURL, token, accountID, orgID, m.authType) return projectsDoneMsg{projects: projects, err: err} } } @@ -651,18 +652,14 @@ func (m wizardModel) fetchProjects() tea.Cmd { // fetchOrgItems fetches all organizations via the framework's FetchItems, falling back // to a direct HTTP call when no resolver is available (e.g. during login before auth exists). -func fetchOrgItems(ctx *cmdctx.Ctx, apiURL, token, accountID string) ([]orgItem, error) { +func fetchOrgItems(ctx *cmdctx.Ctx, apiURL, token, accountID string, authType pkgauth.AuthType) ([]orgItem, error) { if ctx.Resolver != nil { cs := ctx.Resolver.GetSpec("list", "organization") if cs != nil && cs.Endpoint != nil && cs.Endpoint.Paging != nil { fetchCtx := *ctx fetchCtx.Verb = "list" fetchCtx.Noun = "organization" - fetchCtx.Auth = &pkgauth.ResolvedAuth{ - APIUrl: apiURL, - PATToken: token, - AccountID: accountID, - } + fetchCtx.Auth = newLoginResolvedAuth(apiURL, token, accountID, "", authType) items, err := ctx.Resolver.FetchItems(&fetchCtx, cs.Endpoint, cmdctx.PagingFlags{All: true}) if err != nil { return nil, fmt.Errorf("fetching organizations: %w", err) @@ -670,24 +667,19 @@ func fetchOrgItems(ctx *cmdctx.Ctx, apiURL, token, accountID string) ([]orgItem, return orgItemsFromRaw(items, "it.organization.identifier", "it.organization.name") } } - return fetchOrgsHTTP(apiURL, token, accountID) + return fetchOrgsClient(apiURL, token, accountID, authType) } // fetchProjectItems fetches all projects for the given org via the framework's FetchItems, // falling back to direct HTTP when no resolver is available. -func fetchProjectItems(ctx *cmdctx.Ctx, apiURL, token, accountID, orgID string) ([]orgItem, error) { +func fetchProjectItems(ctx *cmdctx.Ctx, apiURL, token, accountID, orgID string, authType pkgauth.AuthType) ([]orgItem, error) { if ctx.Resolver != nil { cs := ctx.Resolver.GetSpec("list", "project") if cs != nil && cs.Endpoint != nil && cs.Endpoint.Paging != nil { fetchCtx := *ctx fetchCtx.Verb = "list" fetchCtx.Noun = "project" - fetchCtx.Auth = &pkgauth.ResolvedAuth{ - APIUrl: apiURL, - PATToken: token, - AccountID: accountID, - OrgID: orgID, - } + fetchCtx.Auth = newLoginResolvedAuth(apiURL, token, accountID, orgID, authType) items, err := ctx.Resolver.FetchItems(&fetchCtx, cs.Endpoint, cmdctx.PagingFlags{All: true}) if err != nil { return nil, fmt.Errorf("fetching projects: %w", err) @@ -695,7 +687,22 @@ func fetchProjectItems(ctx *cmdctx.Ctx, apiURL, token, accountID, orgID string) return orgItemsFromRaw(items, "it.project.identifier", "it.project.name") } } - return fetchProjectsHTTP(apiURL, token, accountID, orgID) + return fetchProjectsClient(apiURL, token, accountID, orgID, authType) +} + +func newLoginResolvedAuth(apiURL, token, accountID, orgID string, authType pkgauth.AuthType) *pkgauth.ResolvedAuth { + ra := &pkgauth.ResolvedAuth{ + APIUrl: apiURL, + AuthType: authType, + AccountID: accountID, + OrgID: orgID, + } + if authType == pkgauth.AuthTypeSSO { + ra.SSOToken = token + } else { + ra.PATToken = token + } + return ra } // orgItemsFromRaw maps raw FetchItems results to []orgItem using the completion exprs @@ -745,30 +752,15 @@ func validateAndFetch(apiURL, token string) (accountID, regURL string, err error if accountID == "" { return "", "", fmt.Errorf("token does not look like a Harness PAT (expected pat..<...>)") } - - c := &http.Client{Timeout: 10 * time.Second} - - // validate token - url := fmt.Sprintf("%s/ng/api/accounts/%s?accountIdentifier=%s", apiURL, accountID, accountID) - req, _ := http.NewRequest("GET", url, nil) - req.Header.Set("x-api-key", token) - resp, rerr := c.Do(req) - if rerr != nil { - return "", "", fmt.Errorf("cannot reach %s: %w", apiURL, rerr) - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - switch resp.StatusCode { - case 200: - case 401: - return "", "", fmt.Errorf("token rejected (401) — check your PAT") - case 403: - return "", "", fmt.Errorf("access denied (403) — check account ID or RBAC") - default: - return "", "", fmt.Errorf("validation failed (%d): %s", resp.StatusCode, truncate(string(body), 120)) + c := hclient.New(context.Background(), &pkgauth.ResolvedAuth{ + APIUrl: apiURL, + AuthType: pkgauth.AuthTypePAT, + PATToken: token, + AccountID: accountID, + }) + if _, _, err := c.Get(fmt.Sprintf("/ng/api/accounts/%s", accountID), map[string]string{"accountIdentifier": accountID}); err != nil { + return "", "", err } - - // fetch registry URL (best-effort) regURL, _ = fetchRegistryURL(apiURL, token, accountID) return accountID, regURL, nil } @@ -781,76 +773,68 @@ func accountIDFromToken(token string) string { return "" } -// fetchOrgsHTTP is the raw HTTP fallback used when no resolver is available. -type orgRespHTTP struct { - Data struct { - Content []struct { - Organization struct { - Identifier string `json:"identifier"` - Name string `json:"name"` - } `json:"organization"` - } `json:"content"` - } `json:"data"` -} - -func fetchOrgsHTTP(apiURL, token, accountID string) ([]orgItem, error) { - c := &http.Client{Timeout: 15 * time.Second} - url := fmt.Sprintf("%s/ng/api/organizations?accountIdentifier=%s&pageSize=200", apiURL, accountID) - req, _ := http.NewRequest("GET", url, nil) - req.Header.Set("x-api-key", token) - resp, err := c.Do(req) - if err != nil { - return nil, fmt.Errorf("fetching organizations: %w", err) - } - defer resp.Body.Close() - b, _ := io.ReadAll(resp.Body) - if resp.StatusCode != 200 { - return nil, fmt.Errorf("organizations API error (%d)", resp.StatusCode) - } - var parsed orgRespHTTP - if err := json.Unmarshal(b, &parsed); err != nil { - return nil, fmt.Errorf("decoding organizations: %w", err) +func newLoginClient(apiURL, token, accountID string, authType pkgauth.AuthType) *hclient.Client { + ra := &pkgauth.ResolvedAuth{ + APIUrl: apiURL, + AuthType: authType, + AccountID: accountID, } - out := make([]orgItem, 0, len(parsed.Data.Content)) - for _, row := range parsed.Data.Content { - out = append(out, orgItem{id: row.Organization.Identifier, name: row.Organization.Name}) + if authType == pkgauth.AuthTypeSSO { + ra.SSOToken = token + } else { + ra.PATToken = token } - sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].name) < strings.ToLower(out[j].name) }) - return out, nil + return hclient.New(context.Background(), ra) } -type projectRespHTTP struct { - Data struct { - Content []struct { - Project struct { - Identifier string `json:"identifier"` - Name string `json:"name"` - } `json:"project"` - } `json:"content"` - } `json:"data"` +func fetchOrgsClient(apiURL, token, accountID string, authType pkgauth.AuthType) ([]orgItem, error) { + c := newLoginClient(apiURL, token, accountID, authType) + resp, _, err := c.Get("/ng/api/organizations", map[string]string{ + "accountIdentifier": accountID, + "pageSize": "200", + }) + if err != nil { + return nil, fmt.Errorf("fetching organizations: %w", err) + } + return orgItemsFromResponse(resp, "organization") } -func fetchProjectsHTTP(apiURL, token, accountID, orgID string) ([]orgItem, error) { - c := &http.Client{Timeout: 15 * time.Second} - url := fmt.Sprintf("%s/ng/api/projects?accountIdentifier=%s&orgIdentifier=%s&pageSize=200", apiURL, accountID, orgID) - req, _ := http.NewRequest("GET", url, nil) - req.Header.Set("x-api-key", token) - resp, err := c.Do(req) +func fetchProjectsClient(apiURL, token, accountID, orgID string, authType pkgauth.AuthType) ([]orgItem, error) { + c := newLoginClient(apiURL, token, accountID, authType) + resp, _, err := c.Get("/ng/api/projects", map[string]string{ + "accountIdentifier": accountID, + "orgIdentifier": orgID, + "pageSize": "200", + }) if err != nil { return nil, fmt.Errorf("fetching projects: %w", err) } - defer resp.Body.Close() - b, _ := io.ReadAll(resp.Body) - if resp.StatusCode != 200 { - return nil, fmt.Errorf("projects API error (%d)", resp.StatusCode) - } - var parsed projectRespHTTP - if err := json.Unmarshal(b, &parsed); err != nil { - return nil, fmt.Errorf("decoding projects: %w", err) + return orgItemsFromResponse(resp, "project") +} + +func orgItemsFromResponse(resp any, key string) ([]orgItem, error) { + m, ok := resp.(map[string]any) + if !ok { + return nil, fmt.Errorf("unexpected response type") } - out := make([]orgItem, 0, len(parsed.Data.Content)) - for _, row := range parsed.Data.Content { - out = append(out, orgItem{id: row.Project.Identifier, name: row.Project.Name}) + data, _ := m["data"].(map[string]any) + content, _ := data["content"].([]any) + out := make([]orgItem, 0, len(content)) + for _, row := range content { + rm, ok := row.(map[string]any) + if !ok { + continue + } + inner, _ := rm[key].(map[string]any) + id, _ := inner["identifier"].(string) + name, _ := inner["name"].(string) + if id == "" { + continue + } + if name == "" { + name = id + } + out = append(out, orgItem{id: id, name: name}) } sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].name) < strings.ToLower(out[j].name) }) return out, nil @@ -888,6 +872,7 @@ type SetWizardInput struct { APIURL string Token string AccountID string + AuthType pkgauth.AuthType RegURL string OrgID string ProjectID string @@ -896,6 +881,11 @@ type SetWizardInput struct { // RunSetWizard starts the wizard at the org-pick step using already-validated credentials. // Pre-selects the currently saved org and project. Returns (nil, nil) if cancelled. func RunSetWizard(ctx *cmdctx.Ctx, in *SetWizardInput) (*WizardResult, error) { + orgs, err := fetchOrgItems(ctx, in.APIURL, in.Token, in.AccountID, in.AuthType) + if err != nil { + return nil, err + } + st := newWizardStyles() sp := spinner.New() sp.Spinner = spinner.Dot @@ -915,15 +905,29 @@ func RunSetWizard(ctx *cmdctx.Ctx, in *SetWizardInput) (*WizardResult, error) { return l } + orgListModel := newList("Select an organization") + orgItems := make([]list.Item, len(orgs)) + for i, o := range orgs { + orgItems[i] = o + } + orgListModel.SetItems(orgItems) + for i, o := range orgs { + if o.id == in.OrgID { + orgListModel.Select(i) + break + } + } + m := wizardModel{ st: st, - step: stepOrgLoad, + step: stepOrgPick, spin: sp, - orgList: newList("Select an organization"), + orgList: orgListModel, projList: newList("Select a project"), apiURL: in.APIURL, token: in.Token, accountID: in.AccountID, + authType: in.AuthType, regURL: in.RegURL, currentOrgID: in.OrgID, currentProjectID: in.ProjectID, @@ -940,7 +944,7 @@ func RunSetWizard(ctx *cmdctx.Ctx, in *SetWizardInput) (*WizardResult, error) { } fm := final.(wizardModel) if fm.cancelled || fm.step != stepDone { - return nil, nil + return nil, fm.cancelReason } return &WizardResult{ APIURL: fm.apiURL, diff --git a/modules/core/auth/set.go b/modules/core/auth/set.go index f1d9551..c58019e 100644 --- a/modules/core/auth/set.go +++ b/modules/core/auth/set.go @@ -52,6 +52,7 @@ func setInteractive(ctx *cmdctx.Ctx, profileName string) error { APIURL: p.APIUrl, Token: token, AccountID: p.AccountID, + AuthType: p.AuthType, RegURL: p.RegistryURL, OrgID: p.OrgID, ProjectID: p.ProjectID, diff --git a/modules/core/auth/sso.go b/modules/core/auth/sso.go index e1d54bc..af3ab94 100644 --- a/modules/core/auth/sso.go +++ b/modules/core/auth/sso.go @@ -16,7 +16,6 @@ import ( "time" "github.com/harness/harness-cli/pkg/auth" - "github.com/harness/harness-cli/pkg/client" "github.com/harness/harness-cli/pkg/cmdctx" "github.com/harness/harness-cli/pkg/console" "github.com/harness/harness-cli/pkg/hlog" @@ -89,6 +88,7 @@ func LoginSSOHandler(ctx *cmdctx.Ctx) error { APIURL: apiURL, Token: token, AccountID: accountID, + AuthType: auth.AuthTypeSSO, }) if werr != nil { return werr @@ -119,39 +119,15 @@ func LoginSSOHandler(ctx *cmdctx.Ctx) error { return nil } -// resolveAPIURL determines the Harness REST API base URL for the account. -// It prefers the subdomain from the JWT (e.g. "prod2.harness.io"), falls back -// to mcp.harness.io, then verifies the URL works. If verification fails and -// we're on a TTY, it prompts the user to enter the URL manually. +// resolveAPIURL determines the Harness API base URL from the JWT subdomain claim. +// Per-cluster JWT support is not yet available; this URL is stored in the profile +// for when it becomes available. func resolveAPIURL(token, accountID, subdomain string) (string, error) { - candidate := mcpBaseURL if subdomain != "" { - candidate = "https://" + subdomain + hlog.Debug("resolveAPIURL", "subdomain", subdomain) + return "https://" + subdomain, nil } - hlog.Debug("resolveAPIURL", "candidate", candidate, "subdomain", subdomain) - - resolved := &auth.ResolvedAuth{ - APIUrl: candidate, - SSOToken: token, - AccountID: accountID, - AuthType: auth.AuthTypeSSO, - } - c := client.New(context.Background(), resolved) - _, _, err := c.Get("/ng/api/user/currentUser", nil) - hlog.Debug("resolveAPIURL currentUser check", "url", candidate, "err", err) - if err == nil { - return candidate, nil - } - - if console.IsBothTTY() { - fmt.Fprintf(os.Stderr, "Could not reach %s — please enter your Harness API URL\n", candidate) - apiURL, err := console.ReadPrompt("API URL", "https://app.harness.io") - if err != nil { - return "", err - } - return apiURL, nil - } - return "", fmt.Errorf("could not reach %s — re-run with --api-url to specify the URL manually", candidate) + return mcpBaseURL, nil } // --- PKCE flow --- diff --git a/pkg/client/client.go b/pkg/client/client.go index 8487728..3bf4371 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -172,6 +172,7 @@ func (c *Client) DoRequest(r Request) (any, http.Header, error) { } if c.resolved.AuthType == auth.AuthTypeSSO { req.Header.Set("Authorization", "Bearer "+c.resolved.SSOToken) + return nil, nil, fmt.Errorf("SSO/JWT authentication is not yet supported by the Harness CLI — use 'harness auth login' with a Personal Access Token instead") } else { req.Header.Set("x-api-key", c.resolved.PATToken) } From a9872bfc100ed0cded518f3cdc31305aaf500907 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Wed, 17 Jun 2026 21:05:36 -0700 Subject: [PATCH 4/6] fix "harness login" => "harness auth login". update sso flow for real sso arch --- pkg/auth/auth.go | 10 +++++----- pkg/client/client.go | 1 - 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index 81626c9..e0d4672 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -137,19 +137,19 @@ func Validate(r *ResolvedAuth) error { } } else { if r.PATToken == "" { - return fmt.Errorf("no token found for profile — run 'harness login' to re-authenticate") + return fmt.Errorf("no token found for profile — run 'harness auth login' to re-authenticate") } if err := ValidatePATFormat(r.PATToken); err != nil { if r.Source == SourceEnv { return fmt.Errorf("%s is invalid: %w", hbase.EnvAPIKey, err) } - return fmt.Errorf("stored token is invalid — run 'harness login' to re-authenticate: %w", err) + return fmt.Errorf("stored token is invalid — run 'harness auth login' to re-authenticate: %w", err) } if tokenAcct := AccountIDFromToken(r.PATToken); tokenAcct != "" && r.AccountID != tokenAcct { if r.Source == SourceEnv { return fmt.Errorf("%s %q does not match account in token %q", hbase.EnvAccount, r.AccountID, tokenAcct) } - return fmt.Errorf("stored account %q does not match token — run 'harness login' to re-authenticate", r.AccountID) + return fmt.Errorf("stored account %q does not match token — run 'harness auth login' to re-authenticate", r.AccountID) } } if r.OrgID == "" { @@ -187,7 +187,7 @@ func resolveProfile(name string) (*ResolvedAuth, error) { p, ok := cfg.Profiles[name] if !ok { if name == "default" { - return nil, errors.New("not logged in — run 'harness login' to get started") + return nil, errors.New("not logged in — run 'harness auth login' to get started") } return nil, fmt.Errorf("profile %q not found", name) } @@ -197,7 +197,7 @@ func resolveProfile(name string) (*ResolvedAuth, error) { } profileCreds := creds[name] if profileCreds == nil || profileCreds.Token == "" { - return nil, fmt.Errorf("no token found for profile %q — run 'harness login' to re-authenticate", name) + return nil, fmt.Errorf("no token found for profile %q — run 'harness auth login' to re-authenticate", name) } apiURL := p.APIUrl if apiURL == "" { diff --git a/pkg/client/client.go b/pkg/client/client.go index 3bf4371..8487728 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -172,7 +172,6 @@ func (c *Client) DoRequest(r Request) (any, http.Header, error) { } if c.resolved.AuthType == auth.AuthTypeSSO { req.Header.Set("Authorization", "Bearer "+c.resolved.SSOToken) - return nil, nil, fmt.Errorf("SSO/JWT authentication is not yet supported by the Harness CLI — use 'harness auth login' with a Personal Access Token instead") } else { req.Header.Set("x-api-key", c.resolved.PATToken) } From 29363fcca0b66c82b963a9cde0edccd9f40a8ce2 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Wed, 17 Jun 2026 21:14:55 -0700 Subject: [PATCH 5/6] nicer looker sso callback page, always use mcp.harness.io --- modules/core/auth/assets/assets.go | 12 ++ modules/core/auth/assets/harness-logo.svg | 4 + modules/core/auth/assets/sso_callback.html | 131 +++++++++++++++++++++ modules/core/auth/sso.go | 46 ++++++-- 4 files changed, 184 insertions(+), 9 deletions(-) create mode 100644 modules/core/auth/assets/assets.go create mode 100644 modules/core/auth/assets/harness-logo.svg create mode 100644 modules/core/auth/assets/sso_callback.html diff --git a/modules/core/auth/assets/assets.go b/modules/core/auth/assets/assets.go new file mode 100644 index 0000000..d0314c5 --- /dev/null +++ b/modules/core/auth/assets/assets.go @@ -0,0 +1,12 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package assets + +import _ "embed" + +//go:embed sso_callback.html +var CallbackHTML string + +//go:embed harness-logo.svg +var LogoSVG string diff --git a/modules/core/auth/assets/harness-logo.svg b/modules/core/auth/assets/harness-logo.svg new file mode 100644 index 0000000..d024679 --- /dev/null +++ b/modules/core/auth/assets/harness-logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/modules/core/auth/assets/sso_callback.html b/modules/core/auth/assets/sso_callback.html new file mode 100644 index 0000000..51334ff --- /dev/null +++ b/modules/core/auth/assets/sso_callback.html @@ -0,0 +1,131 @@ + + + + + + {{.Title}} + + + +
+ + + {{if .Success}} +
+ + + +
+

Login successful

+

You're authenticated. You can close this tab and return to your terminal.

+ {{else}} +
+ + + + +
+

Login failed

+

{{.ErrorMessage}}

+ {{if .ErrorDetail}}
{{.ErrorDetail}}
{{end}} + {{end}} + +
+

Harness CLI — SSO authentication

+
+ + diff --git a/modules/core/auth/sso.go b/modules/core/auth/sso.go index af3ab94..31b5393 100644 --- a/modules/core/auth/sso.go +++ b/modules/core/auth/sso.go @@ -4,10 +4,12 @@ package auth import ( + "bytes" "context" "encoding/base64" "encoding/json" "fmt" + "html/template" "net" "net/http" "net/url" @@ -15,6 +17,7 @@ import ( "strings" "time" + "github.com/harness/harness-cli/modules/core/auth/assets" "github.com/harness/harness-cli/pkg/auth" "github.com/harness/harness-cli/pkg/cmdctx" "github.com/harness/harness-cli/pkg/console" @@ -119,14 +122,10 @@ func LoginSSOHandler(ctx *cmdctx.Ctx) error { return nil } -// resolveAPIURL determines the Harness API base URL from the JWT subdomain claim. -// Per-cluster JWT support is not yet available; this URL is stored in the profile -// for when it becomes available. +// resolveAPIURL returns the MCP gateway URL for SSO-authenticated requests. +// All SSO traffic is routed through mcp.harness.io regardless of the per-cluster +// subdomain in the JWT; the gateway handles cluster routing internally. func resolveAPIURL(token, accountID, subdomain string) (string, error) { - if subdomain != "" { - hlog.Debug("resolveAPIURL", "subdomain", subdomain) - return "https://" + subdomain, nil - } return mcpBaseURL, nil } @@ -190,6 +189,17 @@ func waitForCallback(ln net.Listener, expectedState string) (string, error) { codeCh := make(chan string, 1) errCh := make(chan error, 1) + callbackTmpl := template.Must(template.New("callback").Parse(assets.CallbackHTML)) + renderPage := func(w http.ResponseWriter, data callbackPageData) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + var buf bytes.Buffer + if err := callbackTmpl.Execute(&buf, data); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + w.Write(buf.Bytes()) //nolint:errcheck + } + srv := &http.Server{ReadHeaderTimeout: 10 * time.Second} srv.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != ssoCallbackPath { @@ -199,7 +209,13 @@ func waitForCallback(ln net.Listener, expectedState string) (string, error) { q := r.URL.Query() if errParam := q.Get("error"); errParam != "" { desc := q.Get("error_description") - fmt.Fprintf(w, "

Login failed: %s

%s

You may close this tab.

", errParam, desc) + renderPage(w, callbackPageData{ + Title: "Login failed", + LogoSVG: template.HTML(assets.LogoSVG), + Success: false, + ErrorMessage: "Authorization was denied or an error occurred.", + ErrorDetail: errParam + ": " + desc, + }) errCh <- fmt.Errorf("authorization error: %s — %s", errParam, desc) return } @@ -214,7 +230,11 @@ func waitForCallback(ln net.Listener, expectedState string) (string, error) { errCh <- fmt.Errorf("no authorization code in callback") return } - fmt.Fprintf(w, "

Login successful!

You may close this tab and return to your terminal.

") + renderPage(w, callbackPageData{ + Title: "Login successful", + LogoSVG: template.HTML(assets.LogoSVG), + Success: true, + }) codeCh <- code }) @@ -246,6 +266,14 @@ func waitForCallback(ln net.Listener, expectedState string) (string, error) { } } +type callbackPageData struct { + Title string + LogoSVG template.HTML + Success bool + ErrorMessage string + ErrorDetail string +} + type jwtClaims struct { AccountID string // from account_id claim Subdomain string // from account_metadata..subdomain (may be empty) From aef2559015352ea7070dad9235d559e5b43df871 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Wed, 17 Jun 2026 21:19:51 -0700 Subject: [PATCH 6/6] fix formatting --- modules/core/auth/loginwizard.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/core/auth/loginwizard.go b/modules/core/auth/loginwizard.go index 573d295..c87d43a 100644 --- a/modules/core/auth/loginwizard.go +++ b/modules/core/auth/loginwizard.go @@ -135,15 +135,15 @@ type wizardModel struct { // pre-selected values (for set-wizard mode) currentOrgID string currentProjectID string - setMode bool // started at org pick; no URL/token steps - authType pkgauth.AuthType // AuthTypePAT or AuthTypeSSO + setMode bool // started at org pick; no URL/token steps + authType pkgauth.AuthType // AuthTypePAT or AuthTypeSSO cmdCtx *cmdctx.Ctx err string cancelled bool cancelReason error // set when cancelled due to an internal error, not user action - width int - height int + width int + height int } func buildURLOpts(existingAPIURL string) (opts []urlOpt, defaultIdx int) {