Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 21 additions & 3 deletions modules/core/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"net/http"
"os"
"regexp"
"strings"
"time"

"github.com/harness/cli/pkg/auth"
Expand Down Expand Up @@ -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)
}
Expand All @@ -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 <name>")
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
Expand Down
72 changes: 72 additions & 0 deletions modules/core/auth/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package auth

import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
Expand Down Expand Up @@ -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) {}))
Expand Down
Loading