diff --git a/docs/auth.md b/docs/auth.md index e8daec6..0b624a2 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -110,6 +110,8 @@ harness auth setscope --org myorg --project myproject `auth status` handles SATs differently: instead of calling `GET /ng/api/user/currentUser`, it calls `POST /ng/api/token/validate` and shows the service account identity. 403 responses on the Account/Org/Project checks are shown as warnings (not errors) since the SA may have resource-level access without enumeration permissions. +`auth login` validates SATs the same way. PATs are verified with `GET /ng/api/accounts/{accountID}`, but that endpoint needs account-view permission, which service accounts are rarely granted — so for SATs login calls `POST /ng/api/token/validate` instead. A 403 from that endpoint means the service account is disabled or has no access to the account at all; `--no-validate` writes the profile without the check. + --- ## Commands diff --git a/modules/core/auth/login.go b/modules/core/auth/login.go index 4f3e636..7137fc2 100644 --- a/modules/core/auth/login.go +++ b/modules/core/auth/login.go @@ -10,6 +10,7 @@ import ( "net/http" "os" "regexp" + "strings" "time" "github.com/harness/cli/pkg/auth" @@ -230,11 +231,25 @@ func fetchRegistryURL(apiURL, token, accountID string) (string, error) { return parsed.Data.RegistryURL, nil } -// validateToken calls GET /ng/api/accounts/{accountID} to verify the token. +// validateToken verifies the token against the API. PATs are checked by reading the account +// resource; SATs use the token introspection endpoint instead, since service accounts are +// rarely granted account-view permission and would otherwise fail with a 403. func validateToken(apiURL, token, accountID string) error { c := &http.Client{Timeout: 10 * time.Second} - url := fmt.Sprintf("%s/ng/api/accounts/%s?accountIdentifier=%s", apiURL, accountID, accountID) - req, err := http.NewRequest("GET", url, nil) + isSAT := auth.TokenType(token) == auth.TokenKindSAT + + var req *http.Request + var err error + if isSAT { + url := fmt.Sprintf("%s/ng/api/token/validate?accountIdentifier=%s", apiURL, accountID) + req, err = http.NewRequest("POST", url, strings.NewReader(token)) + if err == nil { + req.Header.Set("Content-Type", "text/plain") + } + } else { + url := fmt.Sprintf("%s/ng/api/accounts/%s?accountIdentifier=%s", apiURL, accountID, accountID) + req, err = http.NewRequest("GET", url, nil) + } if err != nil { return fmt.Errorf("building validation request: %w", err) } @@ -253,6 +268,9 @@ func validateToken(apiURL, token, accountID string) error { case 401: return fmt.Errorf("token rejected (401) — check that your API token is valid\n\nTip: run 'harness auth profiles' to see available profiles, then retry with --profile ") case 403: + if isSAT { + return fmt.Errorf("service account token rejected (403) — the service account may be disabled or lack access to this account\n\nTip: pass --no-validate to write the profile anyway, then run 'harness auth status' to see which scopes are reachable") + } return fmt.Errorf("token valid but access denied (403) — check account ID or RBAC permissions") default: // Try to extract a message from JSON diff --git a/modules/core/auth/login_test.go b/modules/core/auth/login_test.go index a8a2d42..ee82a52 100644 --- a/modules/core/auth/login_test.go +++ b/modules/core/auth/login_test.go @@ -5,6 +5,7 @@ package auth import ( "encoding/json" + "io" "net/http" "net/http/httptest" "os" @@ -370,6 +371,77 @@ func TestValidateToken(t *testing.T) { } } +// satToken is a syntactically valid SAT whose account segment is "acctid". +const satToken = "sat.acctid.tokenid.secret123" + +func TestValidateToken_endpointByTokenKind(t *testing.T) { + tests := []struct { + name string + token string + wantMethod string + wantPath string + wantBody string + }{ + { + name: "SAT uses token introspection", + token: satToken, + wantMethod: "POST", + wantPath: "/ng/api/token/validate", + wantBody: satToken, + }, + { + name: "PAT uses account lookup", + token: validToken, + wantMethod: "GET", + wantPath: "/ng/api/accounts/acctid", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var gotMethod, gotPath, gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + w.Write([]byte(`{"data":{}}`)) + })) + defer srv.Close() + + if err := validateToken(srv.URL, tc.token, "acctid"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotMethod != tc.wantMethod { + t.Errorf("method = %q, want %q", gotMethod, tc.wantMethod) + } + if gotPath != tc.wantPath { + t.Errorf("path = %q, want %q", gotPath, tc.wantPath) + } + if gotBody != tc.wantBody { + t.Errorf("body = %q, want %q", gotBody, tc.wantBody) + } + }) + } +} + +func TestValidateToken_SAT403MentionsServiceAccount(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(403) + })) + defer srv.Close() + + err := validateToken(srv.URL, satToken, "acctid") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "service account token rejected (403)") { + t.Fatalf("error = %q, want the service-account-specific 403 message", err) + } + if !strings.Contains(err.Error(), "--no-validate") { + t.Fatalf("error = %q, want a --no-validate escape hatch in the hint", err) + } +} + func TestValidateToken_unreachableServer(t *testing.T) { // Use a server that is immediately closed so the HTTP request fails. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))