From a7e7845aa694d276c9e63ad903435855165e9e14 Mon Sep 17 00:00:00 2001 From: Antony Pegg Date: Mon, 19 Oct 2026 12:37:30 -0400 Subject: [PATCH 01/11] feat: add session claims and the Auth0 table Browser sign-in needs three building blocks below the commands: the public Auth0 settings per API (domain, audience, client ID, in one table), a reader for the tenant claims in a user's access token, and a profile marker for a browser session with its tenant. A session token's cache binding covers the API URL and the user under its own domain, so neither kind of token can pass for the other. Both client IDs are empty until the Auth0 app is applied, which makes browser sign-in report that it is not available yet. --- internal/apidefaults/apidefaults_test.go | 32 +++++++++ internal/apidefaults/auth0.go | 37 ++++++++++ internal/auth/auth.go | 57 ++++++++++++--- internal/auth/claims.go | 67 ++++++++++++++++++ internal/auth/claims_test.go | 90 ++++++++++++++++++++++++ internal/config/config.go | 15 ++++ 6 files changed, 288 insertions(+), 10 deletions(-) create mode 100644 internal/apidefaults/auth0.go create mode 100644 internal/auth/claims.go create mode 100644 internal/auth/claims_test.go diff --git a/internal/apidefaults/apidefaults_test.go b/internal/apidefaults/apidefaults_test.go index f44f4fc..7c74709 100644 --- a/internal/apidefaults/apidefaults_test.go +++ b/internal/apidefaults/apidefaults_test.go @@ -30,3 +30,35 @@ func TestResolveStarfleetAPIURL(t *testing.T) { }) } } + +func TestAuth0For(t *testing.T) { + saved := auth0Apps + t.Cleanup(func() { auth0Apps = saved }) + auth0Apps = map[string]Auth0App{ + "https://ready": {Domain: "d", Audience: "a", ClientID: "c"}, + "https://empty": {Domain: "d", Audience: "a"}, + } + for _, tc := range []struct { + url string + want bool + }{ + {"https://ready", true}, + {"https://ready/", true}, + {"https://empty", false}, + {"https://unknown", false}, + } { + if _, ok := Auth0For(tc.url); ok != tc.want { + t.Errorf("Auth0For(%q) = %v, want %v", tc.url, ok, tc.want) + } + } +} + +func TestAuth0AppsCoverBothAPIs(t *testing.T) { + for _, u := range []string{StarfleetAPIURL, + "https://devapi.pgedge.com"} { + app, ok := auth0Apps[u] + if !ok || app.Domain == "" || app.Audience != u { + t.Errorf("%s: %+v", u, app) + } + } +} diff --git a/internal/apidefaults/auth0.go b/internal/apidefaults/auth0.go new file mode 100644 index 0000000..e3dc01f --- /dev/null +++ b/internal/apidefaults/auth0.go @@ -0,0 +1,37 @@ +package apidefaults + +import "strings" + +// Auth0App is the public Auth0 configuration browser sign-in uses for +// one pgEdge Starfleet API. +type Auth0App struct { + // Domain is the custom Auth0 domain the /authorize and + // /oauth/token endpoints live on. + Domain string + Audience string + // ClientID is the native app's public client ID. Empty means + // browser sign-in is not available for this API yet. + ClientID string +} + +// auth0Apps is the one place to edit when a client ID is issued. +var auth0Apps = map[string]Auth0App{ + "https://api.pgedge.com": { + Domain: "auth.pgedge.com", + Audience: "https://api.pgedge.com", + ClientID: "", + }, + "https://devapi.pgedge.com": { + Domain: "devauth.pgedge.com", + Audience: "https://devapi.pgedge.com", + ClientID: "", + }, +} + +// Auth0For returns the Auth0 app for apiURL and whether browser sign-in +// is available for it: the URL is known and its client ID is filled in. +// A trailing slash is ignored. +func Auth0For(apiURL string) (Auth0App, bool) { + app, ok := auth0Apps[strings.TrimRight(apiURL, "/")] + return app, ok && app.ClientID != "" +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 8c8ec77..d95471c 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -49,6 +49,9 @@ const ( SourceEnv = "env" SourceKeychain = "keychain" SourceConfig = "config" + // SourceBrowser is a browser sign-in: no credentials, only the + // user's cached token. + SourceBrowser = "browser" ) // The environment pair. Empty counts as unset. @@ -68,6 +71,43 @@ type CachedToken struct { // silent re-exchange. // The name says binding because the digest covers the URL too. Fingerprint string `json:"binding_fingerprint,omitempty"` + // User is a browser session's email, else its subject. + User string `json:"user,omitempty"` +} + +// sessionFingerprintDomain keeps a browser session's binding apart from +// an API client's, so neither kind of token can pass for the other. +const sessionFingerprintDomain = "pgedge-cli/token-cache/session/v1" + +// SessionFingerprint binds a browser session's token to the API base URL +// it was signed in for and the user it belongs to. +func SessionFingerprint(apiURL, subject string) string { + return digest(sessionFingerprintDomain, apiURL, subject) +} + +// SessionFor reports whether t is a browser session token bound to +// apiURL. A token whose payload cannot be read is not. +func (t *CachedToken) SessionFor(apiURL string) bool { + if t.Fingerprint == "" { + return false + } + c, err := ParseClaims(t.AccessToken) + if err != nil { + return false + } + return t.Fingerprint == SessionFingerprint(apiURL, c.Subject) +} + +// digest is SHA-256 over domain and parts, each preceded by a NUL byte +// so ("a","bc") and ("ab","c") differ. +func digest(domain string, parts ...string) string { + h := sha256.New() + h.Write([]byte(domain)) + for _, p := range parts { + h.Write([]byte{0}) + h.Write([]byte(p)) + } + return "sha256:" + hex.EncodeToString(h.Sum(nil)) } // fingerprintDomain separates the binding fingerprint from any other hash @@ -96,15 +136,7 @@ const fingerprintDomain = "pgedge-cli/token-cache/v2" // connection" is one comparison in one place, not two mechanisms that // must agree. func Fingerprint(apiURL, clientID, clientSecret string) string { - h := sha256.New() - h.Write([]byte(fingerprintDomain)) - h.Write([]byte{0}) - h.Write([]byte(apiURL)) - h.Write([]byte{0}) - h.Write([]byte(clientID)) - h.Write([]byte{0}) - h.Write([]byte(clientSecret)) - return "sha256:" + hex.EncodeToString(h.Sum(nil)) + return digest(fingerprintDomain, apiURL, clientID, clientSecret) } // MintedBy reports whether t was minted for the given API base URL by the @@ -234,6 +266,8 @@ var ErrNoCredentials = errors.New("no credentials found — use " + // // The profile's secret comes from the config file when it holds one, // else from the keychain; login writes one or the other, never both. +// A browser-session profile resolves to SourceBrowser with nil +// credentials. func (a *Auth) ResolveCredentials(ap *config.StarfleetProfile, flagID, flagSecret string) (*Credentials, string, error) { creds, err := FlagCredentials(flagID, flagSecret) @@ -250,6 +284,9 @@ func (a *Auth) ResolveCredentials(ap *config.StarfleetProfile, if creds != nil { return creds, SourceEnv, nil } + if ap.IsBrowserSession() { + return nil, SourceBrowser, nil + } if ap == nil || ap.ClientID == "" { return nil, "", ErrNoCredentials } @@ -287,7 +324,7 @@ func (a *Auth) KeychainSecret() (string, error) { // SaveToken writes the token to the profile+module cache file with // 0600 permissions. It refuses a token with no Fingerprint, so no // caller, including a future one, can write an unbound cache file; -// conn.Login, the only caller, always stamps one first. +// conn.Login and conn.SaveSession, the only callers, stamp one first. func (a *Auth) SaveToken(tok *CachedToken) error { if tok.Fingerprint == "" { return fmt.Errorf( diff --git a/internal/auth/claims.go b/internal/auth/claims.go new file mode 100644 index 0000000..9756bec --- /dev/null +++ b/internal/auth/claims.go @@ -0,0 +1,67 @@ +package auth + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "time" +) + +// Claims are the JWT payload fields the CLI reads from a user's tokens. +type Claims struct { + Subject string `json:"sub"` + Email string `json:"email"` + Expiry int64 `json:"exp"` + TenantID string `json:"pgedge_tenant_id"` + TenantIDs []string `json:"pgedge_tenant_ids"` +} + +// ParseClaims decodes a JWT's payload. The signature is not checked: +// the API verifies every token it receives, and the CLI only reads it. +func ParseClaims(token string) (*Claims, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil, errors.New("auth: token is not a JWT") + } + raw, err := base64.RawURLEncoding.DecodeString( + strings.TrimRight(parts[1], "=")) + if err != nil { + return nil, fmt.Errorf("auth: decode token payload: %w", err) + } + var c Claims + if err := json.Unmarshal(raw, &c); err != nil { + return nil, fmt.Errorf("auth: parse token payload: %w", err) + } + return &c, nil +} + +// Tenants lists every tenant the token may act in. +func (c *Claims) Tenants() []string { + if len(c.TenantIDs) > 0 { + return c.TenantIDs + } + if c.TenantID != "" { + return []string{c.TenantID} + } + return nil +} + +// HasTenant reports whether id is one of Tenants, ignoring case. +func (c *Claims) HasTenant(id string) bool { + for _, t := range c.Tenants() { + if strings.EqualFold(t, id) { + return true + } + } + return false +} + +// ExpiresAt is the exp claim as a time, zero when absent. +func (c *Claims) ExpiresAt() time.Time { + if c.Expiry == 0 { + return time.Time{} + } + return time.Unix(c.Expiry, 0) +} diff --git a/internal/auth/claims_test.go b/internal/auth/claims_test.go new file mode 100644 index 0000000..1b99db5 --- /dev/null +++ b/internal/auth/claims_test.go @@ -0,0 +1,90 @@ +package auth + +import ( + "encoding/base64" + "testing" + "time" + + "github.com/pgEdge/pgedge-cli/internal/config" +) + +func jwt(payload string) string { + return "h." + base64.RawURLEncoding.EncodeToString([]byte(payload)) + + ".s" +} + +func TestParseClaims(t *testing.T) { + c, err := ParseClaims(jwt(`{"sub":"auth0|1","email":"a@example.com",` + + `"exp":1700000000,"pgedge_tenant_id":"t2",` + + `"pgedge_tenant_ids":["t1","t2"]}`)) + if err != nil { + t.Fatal(err) + } + if c.Subject != "auth0|1" || c.Email != "a@example.com" || + !c.ExpiresAt().Equal(time.Unix(1700000000, 0)) || + c.TenantID != "t2" || len(c.Tenants()) != 2 { + t.Errorf("claims = %+v", c) + } + if !c.HasTenant("T1") || c.HasTenant("t3") { + t.Error("HasTenant should match the list, ignoring case") + } + + for _, bad := range []string{"one-part", "a.!!!.c", jwt("not json")} { + if _, err := ParseClaims(bad); err == nil { + t.Errorf("ParseClaims(%q) should fail", bad) + } + } +} + +func TestClaimsTenantsFallsBackToTheDefault(t *testing.T) { + for _, tc := range []struct { + c Claims + want int + }{ + {Claims{TenantID: "t"}, 1}, + {Claims{}, 0}, + } { + if got := len(tc.c.Tenants()); got != tc.want { + t.Errorf("%+v: %d tenants, want %d", tc.c, got, tc.want) + } + } + if !(&Claims{}).ExpiresAt().IsZero() { + t.Error("no exp should be the zero time") + } +} + +func TestSessionForBindsTheAPIURL(t *testing.T) { + tok := &CachedToken{AccessToken: jwt(`{"sub":"u"}`), + Fingerprint: SessionFingerprint("https://a", "u")} + if !tok.SessionFor("https://a") { + t.Error("a session should match its own API URL") + } + if tok.SessionFor("https://b") { + t.Error("a session must not match another API URL") + } + // An API client's binding never passes for a session. + tok.Fingerprint = Fingerprint("https://a", "u", "") + if tok.SessionFor("https://a") { + t.Error("an API client fingerprint passed as a session") + } + if (&CachedToken{AccessToken: "x", Fingerprint: "y"}).SessionFor("a") || + (&CachedToken{}).SessionFor("a") { + t.Error("an unreadable or unbound token is not a session") + } +} + +func TestResolveCredentialsReportsABrowserSession(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv(EnvClientID, "") + t.Setenv(EnvClientSecret, "") + a := &Auth{Profile: "p", Module: "starfleet"} + ap := &config.StarfleetProfile{Auth: config.AuthBrowser} + creds, src, err := a.ResolveCredentials(ap, "", "") + if err != nil || creds != nil || src != SourceBrowser { + t.Fatalf("got %v %q %v, want the browser source", creds, src, err) + } + // Flags still outrank the profile. + if _, src, _ := a.ResolveCredentials(ap, "i", "s"); src != SourceFlags { + t.Errorf("source = %q, want flags", src) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 662058c..2c28918 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,10 +20,25 @@ import ( // product means one credential: the account-level commands, byoc and // managed all read this section, exactly as they share one token on // the wire. +// +// A profile holds one kind of credential: an API client (ClientID and +// a secret), or a browser session (Auth set to AuthBrowser, with the +// user's token in the token cache). type StarfleetProfile struct { APIURL string `yaml:"api_url,omitempty"` ClientID string `yaml:"client_id,omitempty"` ClientSecret string `yaml:"client_secret,omitempty"` + Auth string `yaml:"auth,omitempty"` + // TenantID is the tenant a browser session acts in. + TenantID string `yaml:"tenant_id,omitempty"` +} + +// AuthBrowser marks a profile signed in through the browser. +const AuthBrowser = "browser" + +// IsBrowserSession reports whether the profile is a browser sign-in. +func (p *StarfleetProfile) IsBrowserSession() bool { + return p != nil && p.Auth == AuthBrowser } // ControlplaneProfile holds Control Plane module settings within a profile. From 938ab48c102207105ca346fb942fb6ac2a2e7cf8 Mon Sep 17 00:00:00 2001 From: Antony Pegg Date: Mon, 19 Oct 2026 12:37:37 -0400 Subject: [PATCH 02/11] feat: add the browser sign-in PKCE flow internal/starfleet/signin runs the authorization-code flow with PKCE against Auth0: a loopback listener on the first free port of 8484, 8485 and 8486, a random state checked on return, an S256 challenge over a 32-byte verifier, and the code exchange at /oauth/token. The browser opener, the Auth0 base URL, the port list and the wait are injected, so the tests drive the callback with real requests and never open a browser or reach the network. --- .../starfleet/conn/requesttimeout_test.go | 2 +- internal/starfleet/signin/signin.go | 314 +++++++++++++++++ internal/starfleet/signin/signin_test.go | 318 ++++++++++++++++++ 3 files changed, 633 insertions(+), 1 deletion(-) create mode 100644 internal/starfleet/signin/signin.go create mode 100644 internal/starfleet/signin/signin_test.go diff --git a/internal/starfleet/conn/requesttimeout_test.go b/internal/starfleet/conn/requesttimeout_test.go index 90fd96b..f8a3f89 100644 --- a/internal/starfleet/conn/requesttimeout_test.go +++ b/internal/starfleet/conn/requesttimeout_test.go @@ -184,7 +184,7 @@ func TestClientTimeoutIsIndistinguishableFromAWaitExpiry(t *testing.T) { // rewritten into a shape the walk cannot see leaves the population // silently, and a "found more than zero" guard reads that as a pass. // Raise or lower it deliberately when a client is added or removed. -const starfleetHTTPClientSites = 4 +const starfleetHTTPClientSites = 5 // TestEveryStarfleetHTTPClientSetsATimeout derives its population by // walking internal/starfleet rather than naming the three known diff --git a/internal/starfleet/signin/signin.go b/internal/starfleet/signin/signin.go new file mode 100644 index 0000000..33d7364 --- /dev/null +++ b/internal/starfleet/signin/signin.go @@ -0,0 +1,314 @@ +// Package signin runs the browser half of a pgEdge Starfleet sign-in: +// an OAuth 2.0 authorization-code flow with PKCE against Auth0, with +// the redirect caught on a loopback listener. +package signin + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os/exec" + "runtime" + "strconv" + "strings" + "time" + + "github.com/pgEdge/pgedge-cli/internal/output" +) + +// DefaultPorts are the loopback ports the Auth0 app lists as callbacks; +// any other port is refused by Auth0. +var DefaultPorts = []int{8484, 8485, 8486} + +// DefaultWait bounds how long Run waits for the browser to come back. +const DefaultWait = 5 * time.Minute + +// Errors Run reports. Callers map them to exit codes. +var ( + ErrPortsBusy = errors.New("signin: no free callback port") + ErrStateMismatch = errors.New("the sign-in response did not " + + "match this request (state mismatch); start the sign-in again") + ErrTimeout = errors.New("timed out waiting for the browser sign-in") + ErrCancelled = errors.New("sign-in cancelled") +) + +// DeniedError is an error Auth0 returned on the callback, such as +// access_denied. +type DeniedError struct { + Code string + Description string +} + +func (e *DeniedError) Error() string { + if e.Description == "" { + return "sign-in refused: " + e.Code + } + return "sign-in refused: " + e.Description +} + +// Flow is one sign-in. BaseURL is https://; tests point +// it, Ports and Open at fakes. +type Flow struct { + BaseURL string + ClientID string + Audience string + Ports []int + Open func(string) error + Wait time.Duration + HTTPClient *http.Client + // Out receives the URL and progress lines. + Out io.Writer +} + +// Tokens is what the token endpoint returns. +type Tokens struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` + ExpiresIn int64 `json:"expires_in"` +} + +type callback struct { + code string + err error +} + +// Run listens for the callback, opens the browser, waits for the +// redirect and exchanges the code for tokens. +func (f *Flow) Run(ctx context.Context) (*Tokens, error) { + ln, port, err := listen(f.Ports) + if err != nil { + return nil, err + } + verifier, err := randomString() + if err != nil { + _ = ln.Close() + return nil, err + } + state, err := randomString() + if err != nil { + _ = ln.Close() + return nil, err + } + redirect := fmt.Sprintf("http://127.0.0.1:%d/callback", port) + + results := make(chan callback, 1) + srv := &http.Server{ + Handler: callbackHandler(state, results), + ReadHeaderTimeout: 10 * time.Second, + } + go func() { _ = srv.Serve(ln) }() + defer func() { + sctx, cancel := context.WithTimeout( + context.Background(), 2*time.Second) + defer cancel() + _ = srv.Shutdown(sctx) + }() + + authURL := f.authorizeURL(redirect, challengeFor(verifier), state) + fmt.Fprintf(f.Out, "Opening your browser to sign in to pgEdge "+ + "Starfleet. If it does not open, visit:\n %s\n", output.Sanitize(authURL)) + if f.Open != nil { + if err := f.Open(authURL); err != nil { + fmt.Fprintf(f.Out, "Could not open a browser (%s); open "+ + "the URL above to continue.\n", output.Sanitize(err.Error())) + } + } + fmt.Fprintln(f.Out, "Waiting for the sign-in to finish "+ + "(Ctrl-C to cancel)...") + + wait := f.Wait + if wait <= 0 { + wait = DefaultWait + } + timer := time.NewTimer(wait) + defer timer.Stop() + + var cb callback + select { + case cb = <-results: + case <-timer.C: + return nil, ErrTimeout + case <-ctx.Done(): + return nil, ErrCancelled + } + if cb.err != nil { + return nil, cb.err + } + return f.exchange(ctx, cb.code, redirect, verifier) +} + +// listen binds the first free port of ports on 127.0.0.1. +func listen(ports []int) (net.Listener, int, error) { + if len(ports) == 0 { + ports = DefaultPorts + } + for _, p := range ports { + ln, err := net.Listen("tcp", + net.JoinHostPort("127.0.0.1", strconv.Itoa(p))) + if err == nil { + return ln, ln.Addr().(*net.TCPAddr).Port, nil + } + } + return nil, 0, fmt.Errorf("%w: ports %s on 127.0.0.1 are all in "+ + "use; free one and retry", ErrPortsBusy, joinPorts(ports)) +} + +func joinPorts(ports []int) string { + s := make([]string, len(ports)) + for i, p := range ports { + s[i] = strconv.Itoa(p) + } + return strings.Join(s, ", ") +} + +func (f *Flow) authorizeURL(redirect, challenge, state string) string { + q := url.Values{ + "response_type": {"code"}, + "client_id": {f.ClientID}, + "redirect_uri": {redirect}, + "audience": {f.Audience}, + "scope": {"openid profile email"}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + "state": {state}, + } + return strings.TrimRight(f.BaseURL, "/") + "/authorize?" + q.Encode() +} + +// randomString is 32 random bytes, base64url without padding: the +// PKCE verifier length RFC 7636 recommends. +func randomString() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("signin: random: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func challengeFor(verifier string) string { + sum := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +const ( + pageDone = "Signed in to pgEdge Starfleet. You can close this tab." + pageFailed = "Sign-in to pgEdge Starfleet failed. Return to the " + + "terminal for details. You can close this tab." +) + +// callbackHandler answers /callback once; only the first result is +// kept. +func callbackHandler(state string, results chan<- callback) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + var cb callback + switch { + case q.Get("state") != state: + cb.err = ErrStateMismatch + case q.Get("error") != "": + cb.err = &DeniedError{ + Code: q.Get("error"), + Description: q.Get("error_description"), + } + case q.Get("code") == "": + cb.err = errors.New("the sign-in response carried no " + + "authorization code") + default: + cb.code = q.Get("code") + } + page := pageDone + if cb.err != nil { + page = pageFailed + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = io.WriteString(w, "pgEdge "+ + "Starfleet

"+page+"

\n") + select { + case results <- cb: + default: + } + }) + return mux +} + +// exchange trades the code for tokens at /oauth/token. +func (f *Flow) exchange(ctx context.Context, + code, redirect, verifier string) (*Tokens, error) { + form := url.Values{ + "grant_type": {"authorization_code"}, + "client_id": {f.ClientID}, + "code": {code}, + "redirect_uri": {redirect}, + "code_verifier": {verifier}, + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + strings.TrimRight(f.BaseURL, "/")+"/oauth/token", + strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("signin: token request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + client := f.HTTPClient + if client == nil { + client = &http.Client{Timeout: 30 * time.Second} + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("token exchange: %w", err) + } + defer func() { _ = resp.Body.Close() }() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("token exchange: read response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + var e struct { + Error string `json:"error"` + Description string `json:"error_description"` + } + if json.Unmarshal(raw, &e) == nil && e.Error != "" { + return nil, &DeniedError{Code: e.Error, + Description: e.Description} + } + return nil, fmt.Errorf("token exchange returned %s", resp.Status) + } + var tok Tokens + if err := json.Unmarshal(raw, &tok); err != nil { + return nil, fmt.Errorf("token exchange: decode response: %w", err) + } + if tok.AccessToken == "" { + return nil, errors.New("token exchange returned no access_token") + } + return &tok, nil +} + +// browserCommand is the command that opens target on goos. +func browserCommand(goos, target string) (name string, args []string) { + switch goos { + case "darwin": + return "open", []string{target} + case "windows": + return "rundll32", []string{"url.dll,FileProtocolHandler", target} + } + return "xdg-open", []string{target} +} + +// OpenBrowser opens target in the platform's default browser. +func OpenBrowser(target string) error { + name, args := browserCommand(runtime.GOOS, target) + cmd := exec.Command(name, args...) //nolint:gosec // G204: a fixed opener; target is our own authorize URL + if err := cmd.Start(); err != nil { + return err + } + go func() { _ = cmd.Wait() }() + return nil +} diff --git a/internal/starfleet/signin/signin_test.go b/internal/starfleet/signin/signin_test.go new file mode 100644 index 0000000..73023f1 --- /dev/null +++ b/internal/starfleet/signin/signin_test.go @@ -0,0 +1,318 @@ +package signin + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +// fakeAuth0 serves /oauth/token, accepting only the verifier whose S256 +// matches the challenge the browser was sent. +type fakeAuth0 struct { + challenge string + form url.Values + status int + body string +} + +func (a *fakeAuth0) serve(t *testing.T) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/oauth/token" || r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + _ = r.ParseForm() + a.form = r.PostForm + w.Header().Set("Content-Type", "application/json") + if a.status != 0 { + w.WriteHeader(a.status) + _, _ = io.WriteString(w, a.body) + return + } + if challengeFor(r.PostForm.Get("code_verifier")) != a.challenge { + w.WriteHeader(http.StatusForbidden) + _, _ = io.WriteString(w, `{"error":"invalid_grant",`+ + `"error_description":"PKCE verification failed"}`) + return + } + _, _ = io.WriteString(w, `{"access_token":"at","id_token":`+ + `"it","expires_in":86400}`) + })) + t.Cleanup(srv.Close) + return srv +} + +// browser stands in for the user's browser: it records the authorize +// URL and follows the redirect with the query reply builds. +func browser(t *testing.T, a *fakeAuth0, got *url.Values, + reply func(q url.Values) url.Values) func(string) error { + return func(target string) error { + u, err := url.Parse(target) + if err != nil { + t.Fatalf("authorize URL: %v", err) + } + q := u.Query() + *got = q + a.challenge = q.Get("code_challenge") + resp, err := http.Get(q.Get("redirect_uri") + "?" + + reply(q).Encode()) + if err != nil { + t.Errorf("callback: %v", err) + return nil + } + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if !strings.Contains(string(body), "close this tab") { + t.Errorf("callback page = %q", body) + } + return nil + } +} + +func approve(q url.Values) url.Values { + return url.Values{"code": {"the-code"}, "state": {q.Get("state")}} +} + +func newFlow(srv *httptest.Server, open func(string) error) (*Flow, + *bytes.Buffer) { + var out bytes.Buffer + return &Flow{ + BaseURL: srv.URL, + ClientID: "cid", + Audience: "https://api.example", + Ports: []int{0}, + Open: open, + Wait: 5 * time.Second, + Out: &out, + }, &out +} + +func TestRunExchangesTheCodeWithTheVerifier(t *testing.T) { + a := &fakeAuth0{} + srv := a.serve(t) + var q url.Values + f, out := newFlow(srv, browser(t, a, &q, approve)) + + tok, err := f.Run(context.Background()) + if err != nil { + t.Fatalf("Run: %v", err) + } + if tok.AccessToken != "at" || tok.IDToken != "it" || + tok.ExpiresIn != 86400 { + t.Errorf("tokens = %+v", tok) + } + for k, want := range map[string]string{ + "response_type": "code", + "client_id": "cid", + "audience": "https://api.example", + "scope": "openid profile email", + "code_challenge_method": "S256", + } { + if q.Get(k) != want { + t.Errorf("authorize %s = %q, want %q", k, q.Get(k), want) + } + } + if strings.Contains(q.Get("scope"), "offline_access") { + t.Error("step 1 must not ask for offline_access") + } + if !strings.HasPrefix(q.Get("redirect_uri"), "http://127.0.0.1:") || + !strings.HasSuffix(q.Get("redirect_uri"), "/callback") { + t.Errorf("redirect_uri = %q", q.Get("redirect_uri")) + } + for k, want := range map[string]string{ + "grant_type": "authorization_code", + "client_id": "cid", + "code": "the-code", + "redirect_uri": q.Get("redirect_uri"), + } { + if a.form.Get(k) != want { + t.Errorf("token %s = %q, want %q", k, a.form.Get(k), want) + } + } + if len(a.form.Get("code_verifier")) != 43 { + t.Errorf("verifier %q is not 32 bytes base64url", + a.form.Get("code_verifier")) + } + if !strings.Contains(out.String(), srv.URL+"/authorize?") { + t.Errorf("the URL was not printed:\n%s", out.String()) + } +} + +func TestRunFailsWhenTheVerifierDoesNotMatch(t *testing.T) { + a := &fakeAuth0{} + srv := a.serve(t) + var q url.Values + f, _ := newFlow(srv, func(target string) error { + err := browser(t, a, &q, approve)(target) + a.challenge = "something-else" + return err + }) + _, err := f.Run(context.Background()) + var denied *DeniedError + if !errors.As(err, &denied) || + !strings.Contains(err.Error(), "PKCE") { + t.Fatalf("err = %v, want the token endpoint's refusal", err) + } +} + +func TestRunRejectsAWrongState(t *testing.T) { + a := &fakeAuth0{} + srv := a.serve(t) + var q url.Values + f, _ := newFlow(srv, browser(t, a, &q, func(url.Values) url.Values { + return url.Values{"code": {"c"}, "state": {"forged"}} + })) + if _, err := f.Run(context.Background()); !errors.Is(err, + ErrStateMismatch) { + t.Fatalf("err = %v, want ErrStateMismatch", err) + } + if a.form != nil { + t.Error("a forged state reached the token endpoint") + } +} + +func TestRunReportsAnAuth0Error(t *testing.T) { + a := &fakeAuth0{} + srv := a.serve(t) + var q url.Values + f, _ := newFlow(srv, browser(t, a, &q, func(q url.Values) url.Values { + return url.Values{"state": {q.Get("state")}, + "error": {"access_denied"}, + "error_description": {"Please verify your email"}} + })) + _, err := f.Run(context.Background()) + var denied *DeniedError + if !errors.As(err, &denied) || denied.Code != "access_denied" || + err.Error() != "sign-in refused: Please verify your email" { + t.Fatalf("err = %v", err) + } + if (&DeniedError{Code: "x"}).Error() != "sign-in refused: x" { + t.Error("a bare code should be reported") + } +} + +func TestRunRejectsACallbackWithNoCode(t *testing.T) { + a := &fakeAuth0{} + srv := a.serve(t) + var q url.Values + f, _ := newFlow(srv, browser(t, a, &q, func(q url.Values) url.Values { + return url.Values{"state": {q.Get("state")}} + })) + if _, err := f.Run(context.Background()); err == nil || + !strings.Contains(err.Error(), "no authorization code") { + t.Fatalf("err = %v", err) + } +} + +func TestRunFailsWhenEveryPortIsBusy(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = ln.Close() }() + busy := ln.Addr().(*net.TCPAddr).Port + + f, _ := newFlow(httptest.NewServer(http.NotFoundHandler()), nil) + f.Ports = []int{busy} + _, err = f.Run(context.Background()) + if !errors.Is(err, ErrPortsBusy) || + !strings.Contains(err.Error(), fmt.Sprint(busy)) { + t.Fatalf("err = %v, want ErrPortsBusy naming %d", err, busy) + } +} + +func TestRunTimesOut(t *testing.T) { + f, out := newFlow(httptest.NewServer(http.NotFoundHandler()), + func(string) error { return errors.New("no display") }) + f.Wait = 50 * time.Millisecond + if _, err := f.Run(context.Background()); !errors.Is(err, ErrTimeout) { + t.Fatalf("err = %v, want ErrTimeout", err) + } + if !strings.Contains(out.String(), "Could not open a browser (no "+ + "display)") { + t.Errorf("a failed launch was not reported:\n%s", out.String()) + } +} + +func TestRunStopsWhenCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + f, _ := newFlow(httptest.NewServer(http.NotFoundHandler()), + func(string) error { cancel(); return nil }) + if _, err := f.Run(ctx); !errors.Is(err, ErrCancelled) { + t.Fatalf("err = %v, want ErrCancelled", err) + } +} + +func TestExchangeFailures(t *testing.T) { + for _, tc := range []struct { + name string + status int + body string + want string + }{ + {"plain error", 500, "boom", "500"}, + {"no token", 200, `{"expires_in":1}`, "no access_token"}, + {"not json", 200, ``, "decode response"}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tc.status) + _, _ = io.WriteString(w, tc.body) + })) + defer srv.Close() + f := &Flow{BaseURL: srv.URL} + _, err := f.exchange(context.Background(), "c", "r", "v") + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want %q", err, tc.want) + } + }) + } + f := &Flow{BaseURL: "http://127.0.0.1:0"} + if _, err := f.exchange(context.Background(), "c", "r", + "v"); err == nil { + t.Error("an unreachable token endpoint should fail") + } + f = &Flow{BaseURL: "://bad"} + if _, err := f.exchange(context.Background(), "c", "r", + "v"); err == nil { + t.Error("a malformed base URL should fail") + } +} + +func TestBrowserCommand(t *testing.T) { + for _, tc := range []struct { + goos, name string + args []string + }{ + {"darwin", "open", []string{"u"}}, + {"linux", "xdg-open", []string{"u"}}, + {"windows", "rundll32", + []string{"url.dll,FileProtocolHandler", "u"}}, + } { + name, args := browserCommand(tc.goos, "u") + if name != tc.name || strings.Join(args, " ") != + strings.Join(tc.args, " ") { + t.Errorf("%s: %s %v", tc.goos, name, args) + } + } +} + +func TestChallengeIsS256OfTheVerifier(t *testing.T) { + // RFC 7636 appendix B. + got := challengeFor("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk") + if got != "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" { + t.Errorf("challenge = %q", got) + } +} From 9e97f6e7a391fdf45407c6c8797054372b17f12c Mon Sep 17 00:00:00 2001 From: Antony Pegg Date: Mon, 19 Oct 2026 12:37:45 -0400 Subject: [PATCH 03/11] feat: run commands as a browser session A profile marked as a browser session resolves to the user's cached token instead of a client-credentials exchange. Every request of a session carries X-pgEdge-Selected-Tenant: the profile's tenant, or the new persistent --tenant flag for one command, checked locally against the token's pgedge_tenant_ids. An API client never sends the header, and --tenant with one is a usage error. A session is never re-minted. An expired, missing or other-API token fails with exit 5 and names the login command, including when a long --wait outlives it. doctor's Auth and Tenant rows understand a session and report its selected tenant. conn.Resolve now takes conn.Flags, and conn.ReadFlags replaces the three copies of the persistent-flag reader. --- internal/starfleet/account/cmd/client_conn.go | 43 ++-- internal/starfleet/account/cmd/doctor.go | 59 ++++- .../starfleet/account/cmd/helpers_test.go | 1 + internal/starfleet/account/cmd/invite_test.go | 3 + internal/starfleet/byoc/cmd/client.go | 35 +-- internal/starfleet/cmd/api.go | 3 +- internal/starfleet/cmd/root.go | 19 +- internal/starfleet/cmd/session_test.go | 67 ++++++ internal/starfleet/conn/conn.go | 194 ++++++++++++++-- internal/starfleet/conn/conn_test.go | 34 +-- internal/starfleet/conn/envcreds_test.go | 6 +- internal/starfleet/conn/session_test.go | 211 ++++++++++++++++++ internal/starfleet/managed/cmd/client.go | 31 +-- internal/testsupport/session.go | 68 ++++++ 14 files changed, 654 insertions(+), 120 deletions(-) create mode 100644 internal/starfleet/cmd/session_test.go create mode 100644 internal/starfleet/conn/session_test.go create mode 100644 internal/testsupport/session.go diff --git a/internal/starfleet/account/cmd/client_conn.go b/internal/starfleet/account/cmd/client_conn.go index b8dcf8d..da9e182 100644 --- a/internal/starfleet/account/cmd/client_conn.go +++ b/internal/starfleet/account/cmd/client_conn.go @@ -4,7 +4,6 @@ import ( "fmt" "io" "net/http" - "time" "github.com/pgEdge/pgedge-cli/internal/module" accountapi "github.com/pgEdge/pgedge-cli/internal/starfleet/account/api" @@ -64,29 +63,21 @@ func checkEmptyBodyResponse(resp *http.Response, what string) error { func clientFromCmd( rt *module.Runtime, cmd *cobra.Command, ) (*accountapi.ClientWithResponses, error) { - id, _ := cmd.Flags().GetString("client-id") - secret, _ := cmd.Flags().GetString("client-secret") - apiURL, _ := cmd.Flags().GetString("api-url") - // Fail safe: a failed read keeps the 30-second default rather - // than the zero value, which would be an unbounded client. - timeout := conn.RequestTimeout - if v, err := cmd.Flags().GetDuration("timeout"); err == nil { - timeout = v - } - return newAccountClient(rt, id, secret, apiURL, timeout) + client, _, err := connClientFromCmd(rt, cmd) + return client, err } -// newAccountClient builds an authenticated Accounts API client from -// explicit override values. -func newAccountClient( - rt *module.Runtime, id, secret, apiURL string, - timeout time.Duration, -) (*accountapi.ClientWithResponses, error) { - c, err := conn.Resolve(rt, id, secret, apiURL, timeout) +// connClientFromCmd is clientFromCmd that also returns the resolved +// connection, for a caller that needs to know whose it is. +func connClientFromCmd( + rt *module.Runtime, cmd *cobra.Command, +) (*accountapi.ClientWithResponses, *conn.Conn, error) { + c, err := conn.Resolve(rt, conn.ReadFlags(cmd.Flags())) if err != nil { - return nil, err + return nil, nil, err } - return accountClientFor(c) + client, err := accountClientFor(c) + return client, c, err } // newEphemeralAccountClient is newAccountClient for a caller that must @@ -97,14 +88,14 @@ func newAccountClient( // token cached, which keeps a session to one token exchange. // TestOnlyDoctorResolvesEphemerally pins the caller list. func newEphemeralAccountClient( - rt *module.Runtime, id, secret, apiURL string, - timeout time.Duration, -) (*accountapi.ClientWithResponses, error) { - c, err := conn.ResolveEphemeral(rt, id, secret, apiURL, timeout) + rt *module.Runtime, f conn.Flags, +) (*accountapi.ClientWithResponses, *conn.Conn, error) { + c, err := conn.ResolveEphemeral(rt, f) if err != nil { - return nil, err + return nil, nil, err } - return accountClientFor(c) + client, err := accountClientFor(c) + return client, c, err } // accountClientFor builds the generated client over an already-resolved diff --git a/internal/starfleet/account/cmd/doctor.go b/internal/starfleet/account/cmd/doctor.go index b8e96fd..a656190 100644 --- a/internal/starfleet/account/cmd/doctor.go +++ b/internal/starfleet/account/cmd/doctor.go @@ -13,6 +13,7 @@ import ( "github.com/pgEdge/pgedge-cli/internal/auth" "github.com/pgEdge/pgedge-cli/internal/module" "github.com/pgEdge/pgedge-cli/internal/output" + api "github.com/pgEdge/pgedge-cli/internal/starfleet/account/api" "github.com/pgEdge/pgedge-cli/internal/starfleet/conn" "github.com/pgEdge/pgedge-cli/internal/useragent" "github.com/spf13/cobra" @@ -37,6 +38,8 @@ type authInfo struct { // diverged; the text wording names both. TokenBound bool `json:"token_bound"` ExpiresAt string `json:"expires_at,omitempty"` + // User is a browser session's email or subject. + User string `json:"user,omitempty"` } type apiInfo struct { @@ -75,6 +78,18 @@ type tenantInfo struct { // Count makes a credential spanning more than one tenant visible // rather than silently reported as its first. Count int `json:"count,omitempty"` + // selected: a browser session's chosen tenant, not the first. + selected bool +} + +// selectedTenant returns the tenant whose ID is id, else the first. +func selectedTenant(tenants []api.Tenant, id string) api.Tenant { + for _, t := range tenants { + if strings.EqualFold(t.Id, id) { + return t + } + } + return tenants[0] } // accountDoctorReport is the Starfleet connection's report: credential @@ -143,6 +158,9 @@ func checkAuth(rt *module.Runtime, f *conn.Flags) authInfo { if res.Env { return info } + if res.Session { + return checkSessionAuth(rt, res.APIURL, info) + } tok, err := conn.Store(rt).LoadToken() if err != nil { @@ -156,6 +174,23 @@ func checkAuth(rt *module.Runtime, f *conn.Flags) authInfo { return info } +// checkSessionAuth fills the Auth row for a browser session, whose +// token cannot be re-minted: TokenValid and TokenBound are read the way +// conn.LoadSession reads them. +func checkSessionAuth( + rt *module.Runtime, apiURL string, info authInfo, +) authInfo { + tok, err := conn.Store(rt).LoadToken() + if err != nil { + return info + } + info.User = tok.User + info.ExpiresAt = tok.ExpiresAt.Format(time.RFC3339) + info.TokenValid = time.Until(tok.ExpiresAt) > auth.RefreshWindow + info.TokenBound = tok.SessionFor(apiURL) + return info +} + func checkAPI(rt *module.Runtime, f *conn.Flags) apiInfo { apiURL := conn.ResolveAPIURL( rt.Config.StarfleetProfile(rt.Profile), f.APIURL) @@ -210,8 +245,9 @@ func checkTenant(rt *module.Runtime, f *conn.Flags) tenantInfo { if timeout <= 0 { timeout = conn.RequestTimeout } - client, err := newEphemeralAccountClient( - rt, f.ClientID, f.ClientSecret, f.APIURL, timeout) + cf := *f + cf.Timeout = timeout + client, c, err := newEphemeralAccountClient(rt, cf) if err != nil { return tenantInfo{} } @@ -230,11 +266,15 @@ func checkTenant(rt *module.Runtime, f *conn.Flags) tenantInfo { } t := tenants[0] + if c.Session != nil { + t = selectedTenant(tenants, c.Session.TenantID) + } info := tenantInfo{ Resolved: true, Name: t.Name, ID: t.Id, Count: len(tenants), + selected: c.Session != nil, } if t.Plan != nil { info.Plan = *t.Plan @@ -265,7 +305,11 @@ func tenantCheckRow(info tenantInfo) output.CheckRow { if info.PlanTrial { detail += " [trial]" } - if info.Count > 1 { + switch { + case info.Count > 1 && info.selected: + detail += fmt.Sprintf(" — %d tenants reachable, reporting the "+ + "selected one", info.Count) + case info.Count > 1: detail += fmt.Sprintf(" — %d tenants visible, reporting the first", info.Count) } @@ -310,7 +354,16 @@ func runDoctor(rt *module.Runtime, f *conn.Flags) error { if report.Auth.Problem != "" { authDetail = report.Auth.Problem } + session := report.Auth.Source == auth.SourceBrowser switch { + case session && report.Auth.TokenValid && report.Auth.TokenBound: + authStatus = "ok" + authDetail = fmt.Sprintf("signed in as %s via browser "+ + "(expires %s)", output.Sanitize(report.Auth.User), + report.Auth.ExpiresAt) + case session: + authDetail = conn.SessionExpiredError(rt, + report.API.URL).Error() case report.Auth.Authenticated && report.Auth.Source == auth.SourceEnv: authStatus = "ok" authDetail = fmt.Sprintf("authenticated via %s and %s "+ diff --git a/internal/starfleet/account/cmd/helpers_test.go b/internal/starfleet/account/cmd/helpers_test.go index e993bec..ba54cb0 100644 --- a/internal/starfleet/account/cmd/helpers_test.go +++ b/internal/starfleet/account/cmd/helpers_test.go @@ -38,6 +38,7 @@ func newStarfleetRoot(rt *module.Runtime) *cobra.Command { pf.StringVar(&f.APIURL, "api-url", "", "pgEdge Starfleet API base URL") pf.StringVar(&f.ClientID, "client-id", "", "API client ID") pf.StringVar(&f.ClientSecret, "client-secret", "", "API client secret") + pf.StringVar(&f.Tenant, "tenant", "", "browser session tenant") root.AddCommand(NewAuthCmd(rt, f)) root.AddCommand(NewDoctorCmd(rt, f)) diff --git a/internal/starfleet/account/cmd/invite_test.go b/internal/starfleet/account/cmd/invite_test.go index a276d8e..24b2843 100644 --- a/internal/starfleet/account/cmd/invite_test.go +++ b/internal/starfleet/account/cmd/invite_test.go @@ -177,8 +177,11 @@ func TestInviteGuardPremiseStillHolds(t *testing.T) { // The credentials themselves are persistent flags on the cloud // root, not local to login, so that is the surface to watch: a // device-code or browser login would add one here. + // tenant came with browser sign-in, which does sign a user in. The + // invite guard stays for now by decision; lifting it is a follow-up. wantFlags := map[string]bool{ "api-url": true, "client-id": true, "client-secret": true, + "tenant": true, } newStarfleetRoot(rt).PersistentFlags().VisitAll(func(f *pflag.Flag) { if !wantFlags[f.Name] { diff --git a/internal/starfleet/byoc/cmd/client.go b/internal/starfleet/byoc/cmd/client.go index 8714c13..d41eb8e 100644 --- a/internal/starfleet/byoc/cmd/client.go +++ b/internal/starfleet/byoc/cmd/client.go @@ -74,10 +74,9 @@ func checkEmptyBodyResponse(resp *http.Response, what string) error { // newAPIClient builds an authenticated BYOC API client over the // starfleet module's resolved connection. -func newAPIClient(rt *module.Runtime, - flagID, flagSecret, flagAPIURL string, timeout time.Duration, +func newAPIClient(rt *module.Runtime, f conn.Flags, ) (*api.ClientWithResponses, error) { - c, err := conn.Resolve(rt, flagID, flagSecret, flagAPIURL, timeout) + c, err := conn.Resolve(rt, f) if err != nil { return nil, err } @@ -93,32 +92,20 @@ func newAPIClient(rt *module.Runtime, } // clientFromCmd builds an authenticated API client for a resource -// command, reading the byoc root's persistent --client-id, -// --client-secret, and --api-url overrides off cmd's inherited flag -// set. Every resource command's RunE calls it to obtain its client. +// command from the starfleet root's persistent connection flags. Every +// resource command's RunE calls it to obtain its client. func clientFromCmd( rt *module.Runtime, cmd *cobra.Command, ) (*api.ClientWithResponses, error) { - id, secret, apiURL, timeout := connFlags(cmd) - return newAPIClient(rt, id, secret, apiURL, timeout) + return newAPIClient(rt, connFlags(cmd)) } -// connFlags reads the byoc root's persistent connection overrides off -// cmd's inherited flag set. -func connFlags(cmd *cobra.Command) ( - id, secret, apiURL string, timeout time.Duration, -) { - id, _ = cmd.Flags().GetString("client-id") - secret, _ = cmd.Flags().GetString("client-secret") - apiURL, _ = cmd.Flags().GetString("api-url") - // A failed read keeps the default: its zero value would mean an - // unbounded client. - timeout = conn.RequestTimeout - if v, err := cmd.Flags().GetDuration("timeout"); err == nil { - timeout = v - } - requestTimeoutFlag = timeout - return id, secret, apiURL, timeout +// connFlags reads the persistent connection flags off cmd's inherited +// flag set and records --timeout for the wait machinery. +func connFlags(cmd *cobra.Command) conn.Flags { + f := conn.ReadFlags(cmd.Flags()) + requestTimeoutFlag = f.Timeout + return f } // requestTimeoutFlag is the resolved --timeout, recorded by connFlags diff --git a/internal/starfleet/cmd/api.go b/internal/starfleet/cmd/api.go index 17099ab..a836e52 100644 --- a/internal/starfleet/cmd/api.go +++ b/internal/starfleet/cmd/api.go @@ -58,8 +58,7 @@ Example: return conn.NewExitError(fmt.Sprintf("--data: %v", err), conn.ExitUsage) } - c, err := conn.Resolve(rt, f.ClientID, f.ClientSecret, - f.APIURL, f.Timeout) + c, err := conn.Resolve(rt, *f) if err != nil { return err } diff --git a/internal/starfleet/cmd/root.go b/internal/starfleet/cmd/root.go index a3161ed..1296a0c 100644 --- a/internal/starfleet/cmd/root.go +++ b/internal/starfleet/cmd/root.go @@ -15,7 +15,7 @@ import ( // NewStarfleetCmd builds the `pgedge starfleet` command tree. // -// The four connection flags are declared here and nowhere else: one +// The five connection flags are declared here and nowhere else: one // product means one connection, and every byoc and managed leaf reads // them off its inherited flag set (connFlags in each sub-tree's // client.go). --timeout bounds one HTTP exchange, as controlplane's @@ -31,10 +31,12 @@ resources (API clients, tenants, invites, memberships) and the two infrastructure sub-trees — byoc for BYOC clusters and managed for Managed databases. -Authenticate once with 'pgedge starfleet auth login'. Every starfleet +Sign in once with 'pgedge starfleet auth login', which opens your +browser, or give it an API client for unattended use. Every starfleet subcommand shares that one connection, taken from the active profile in ~/.pgedge/cli/config.yaml and overridable per invocation with ---client-id, --client-secret, and --api-url. +--client-id, --client-secret, and --api-url. --tenant picks the tenant +a browser sign-in acts in for one command. Example: pgedge starfleet auth login @@ -68,6 +70,8 @@ Example: "API client secret (overrides profile config)") pf.DurationVar(&f.Timeout, "timeout", conn.RequestTimeout, "Per-request timeout (Go duration; 0 disables)") + pf.StringVar(&f.Tenant, "tenant", "", + "Tenant ID a browser sign-in acts in (overrides the profile's)") cmd.AddCommand(accountcmd.NewAuthCmd(rt, f)) cmd.AddCommand(accountcmd.NewDoctorCmd(rt, f)) @@ -77,11 +81,10 @@ Example: cmd.AddCommand(accountcmd.NewTenantCmd(rt)) cmd.AddCommand(newAPICmd(rt, f)) - // No `starfleet user` verbs, deliberately, although the generated - // client carries GetCurrentUser/UpdateCurrentUser. The API answers - // 401 when EITHER the tenant or the user is missing, and a - // client-credentials token carries a tenant but never a user. - // Revisit when a user-token auth flow exists. + // No `starfleet user` verbs yet, although the generated client + // carries GetCurrentUser/UpdateCurrentUser: the API answers 401 when + // EITHER the tenant or the user is missing, and a client-credentials + // token carries no user. A browser sign-in does, so they can follow. cmd.AddCommand(byoccmd.NewByocCmd(rt)) cmd.AddCommand(managedcmd.NewManagedCmd(rt)) diff --git a/internal/starfleet/cmd/session_test.go b/internal/starfleet/cmd/session_test.go new file mode 100644 index 0000000..328027e --- /dev/null +++ b/internal/starfleet/cmd/session_test.go @@ -0,0 +1,67 @@ +package cmd + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/pgEdge/pgedge-cli/internal/starfleet/conn" + "github.com/pgEdge/pgedge-cli/internal/testsupport" +) + +// TestTenantHeaderOnlyForASession drives a byoc and a managed leaf and +// the api passthrough through the real root, once as a browser session +// and once as an API client. +func TestTenantHeaderOnlyForASession(t *testing.T) { + var seen []http.Header + srv := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == testsupport.TokenPath { + _, _ = w.Write([]byte(testsupport.TokenBody)) + return + } + seen = append(seen, r.Header.Clone()) + _, _ = w.Write([]byte("[]")) + })) + defer srv.Close() + + leaves := [][]string{ + {"byoc", "cluster", "list"}, + {"managed", "database", "list"}, + {"api", "GET", "/account/v1/tenants"}, + } + for _, leaf := range leaves { + seen = nil + rt, _, _ := testsupport.NewRuntime(t, "", "json") + testsupport.SeedSession(t, rt, srv.URL, "u1", + time.Now().Add(time.Hour), "t-a", "t-b") + args := append(append([]string{}, leaf...), "--tenant", "t-b") + if err := runStarfleet(t, rt, &bytes.Buffer{}, + args...); err != nil { + t.Fatalf("%v as a session: %v", leaf, err) + } + if len(seen) == 0 || + seen[0].Get(conn.SelectedTenantHeader) != "t-b" { + t.Errorf("%v as a session sent %v", leaf, seen) + } + + seen = nil + rt, out, _ := testsupport.NewRuntime(t, "", "json") + if err := testsupport.RunAuthed(t, NewStarfleetCmd(rt), out, + srv.URL, leaf...); err != nil { + t.Fatalf("%v as an API client: %v", leaf, err) + } + for _, h := range seen { + if h.Get(conn.SelectedTenantHeader) != "" { + t.Errorf("%v as an API client sent the tenant "+ + "header", leaf) + } + } + if len(seen) == 0 { + t.Errorf("%v as an API client made no request", leaf) + } + } +} diff --git a/internal/starfleet/conn/conn.go b/internal/starfleet/conn/conn.go index 8d64439..b1b78cd 100644 --- a/internal/starfleet/conn/conn.go +++ b/internal/starfleet/conn/conn.go @@ -50,6 +50,25 @@ type Flags struct { // Timeout bounds one HTTP exchange, defaulting to RequestTimeout; // zero disables the bound, as controlplane's --timeout does. Timeout time.Duration + // Tenant overrides a browser session's tenant for one command. + Tenant string +} + +// ReadFlags reads the starfleet root's persistent connection flags off +// a leaf's inherited flag set. +func ReadFlags(fs *pflag.FlagSet) Flags { + var f Flags + f.ClientID, _ = fs.GetString("client-id") + f.ClientSecret, _ = fs.GetString("client-secret") + f.APIURL, _ = fs.GetString("api-url") + f.Tenant, _ = fs.GetString("tenant") + // A failed read keeps the default: the zero value would be an + // unbounded client. + f.Timeout = RequestTimeout + if v, err := fs.GetDuration("timeout"); err == nil { + f.Timeout = v + } + return f } // Exit codes reported by Starfleet commands. They are the CLI-wide @@ -317,6 +336,12 @@ func HTTPClientFor(rt *module.Runtime, timeout time.Duration) *http.Client { } } +// SignInHTTPClient is the client for browser sign-in's code exchange, +// which, like the client-credentials exchange, must survive a dry run. +func SignInHTTPClient(rt *module.Runtime, timeout time.Duration) *http.Client { + return authHTTPClientFor(rt, timeout) +} + // authHTTPClientFor is HTTPClientFor without the dry-run layer, for the // token exchange: that POST would otherwise be intercepted and every dry // run would fail to authenticate before its first read. The exemption @@ -500,6 +525,10 @@ type Resolved struct { // Env reports env-pair credentials, which never touch the config // file or the token cache. Env bool + // Session reports a browser sign-in, where Creds is nil and the + // user's token lives in the cache. TenantID is the profile's. + Session bool + TenantID string } // ProfileConflictError reports --profile given while the env pair is @@ -545,6 +574,10 @@ func ResolveCredentials(rt *module.Runtime, return r, nil } r.APIURL = ResolveAPIURL(ap, flagAPIURL) + if source == auth.SourceBrowser { + r.Session = true + r.TenantID = ap.TenantID + } return r, nil } @@ -555,17 +588,91 @@ type Conn struct { Token string HTTPClient *http.Client BearerEditor func(context.Context, *http.Request) error + // Session is set for a browser sign-in, nil for an API client. + Session *Session +} + +// SelectedTenantHeader picks a browser session's tenant; the gateway +// honours it when the tenant is in the token's pgedge_tenant_ids. +const SelectedTenantHeader = "X-pgEdge-Selected-Tenant" + +// Session describes a browser sign-in's cached token. +type Session struct { + User string + // TenantID is the tenant requests act in: --tenant, else the + // profile's, else the token's default. + TenantID string + TenantIDs []string + ExpiresAt time.Time +} + +// SessionExpiredError is the exit-5 failure for a browser session +// whose token is expired, missing, or was signed in for another API. +func SessionExpiredError(rt *module.Runtime, apiURL string) *ExitError { + login := "pgedge starfleet auth login" + if rt.Profile != "" && rt.Profile != "default" { + login += " --profile " + rt.Profile + } + return NewExitError(fmt.Sprintf("the browser sign-in for %s has "+ + "expired or is missing: run '%s' to sign in again", + apiURL, login), ExitAuth) +} + +// LoadSession reads the profile's browser session token, usable when it +// is bound to apiURL and more than auth.RefreshWindow from expiry. +// Anything else is SessionExpiredError. +func LoadSession(rt *module.Runtime, apiURL string, +) (*auth.CachedToken, *auth.Claims, error) { + tok, err := Store(rt).LoadToken() + if err != nil || !tok.SessionFor(apiURL) || + time.Until(tok.ExpiresAt) <= auth.RefreshWindow { + return nil, nil, SessionExpiredError(rt, apiURL) + } + claims, err := auth.ParseClaims(tok.AccessToken) + if err != nil { + return nil, nil, SessionExpiredError(rt, apiURL) + } + return tok, claims, nil +} + +// SaveSession writes a browser sign-in's access token to the profile's +// cache, bound to apiURL and the token's subject. +func SaveSession(rt *module.Runtime, apiURL, accessToken, user string, + expiresAt time.Time) (*auth.CachedToken, error) { + claims, err := auth.ParseClaims(accessToken) + if err != nil { + return nil, err + } + if claims.Subject == "" { + return nil, errors.New("the access token carries no subject") + } + tok := &auth.CachedToken{ + AccessToken: accessToken, + ExpiresAt: expiresAt, + Fingerprint: auth.SessionFingerprint(apiURL, claims.Subject), + User: user, + } + if err := Store(rt).SaveToken(tok); err != nil { + return nil, err + } + return tok, nil +} + +// TenantUsageError is the exit-2 failure for a --tenant the token +// cannot act in. +func TenantUsageError(tenant string, reachable []string) *ExitError { + return NewExitError(fmt.Sprintf("--tenant %s is not a tenant this "+ + "sign-in can reach; reachable tenant IDs: %s", tenant, + strings.Join(reachable, ", ")), ExitUsage) } // Resolve produces an authenticated connection for the runtime's // active profile, honouring per-invocation flag overrides. It reuses a // cached token that is more than auth.RefreshWindow from expiry and -// otherwise exchanges credentials for a fresh one. -func Resolve(rt *module.Runtime, - flagID, flagSecret, flagAPIURL string, timeout time.Duration, -) (*Conn, error) { - return resolve(rt, flagID, flagSecret, flagAPIURL, timeout, - persistToken) +// otherwise exchanges credentials for a fresh one; a browser session +// has nothing to exchange, so it fails instead. +func Resolve(rt *module.Runtime, f Flags) (*Conn, error) { + return resolve(rt, f, persistToken) } // ResolveEphemeral is Resolve for `starfleet doctor`, which must not @@ -574,11 +681,8 @@ func Resolve(rt *module.Runtime, // and leave a fresh token behind, and `doctor --api-url ` would // evict the profile's working token, since a cached token is bound to // its API URL. -func ResolveEphemeral(rt *module.Runtime, - flagID, flagSecret, flagAPIURL string, timeout time.Duration, -) (*Conn, error) { - return resolve(rt, flagID, flagSecret, flagAPIURL, timeout, - ephemeralToken) +func ResolveEphemeral(rt *module.Runtime, f Flags) (*Conn, error) { + return resolve(rt, f, ephemeralToken) } // tokenPolicy says whether a freshly minted token may reach the cache. @@ -589,17 +693,24 @@ const ( ephemeralToken tokenPolicy = false ) -func resolve(rt *module.Runtime, - flagID, flagSecret, flagAPIURL string, timeout time.Duration, - policy tokenPolicy, +func resolve(rt *module.Runtime, f Flags, policy tokenPolicy, ) (*Conn, error) { - res, err := ResolveCredentials(rt, flagID, flagSecret, flagAPIURL) + timeout := f.Timeout + res, err := ResolveCredentials(rt, f.ClientID, f.ClientSecret, + f.APIURL) if err != nil { if IsUsageError(err) { return nil, NewExitError(err.Error(), ExitUsage) } return nil, NewExitError(err.Error(), ExitAuth) } + if res.Session { + return resolveSession(rt, res, f.Tenant, timeout) + } + if f.Tenant != "" { + return nil, NewExitError("--tenant applies only to a browser "+ + "sign-in: an API client is scoped to one tenant", ExitUsage) + } store, apiURL, creds := Store(rt), res.APIURL, res.Creds if res.Env { store = nil @@ -640,6 +751,59 @@ func resolve(rt *module.Runtime, }, nil } +// resolveSession builds a browser session's connection from its cached +// token. Every request carries SelectedTenantHeader. +func resolveSession(rt *module.Runtime, res *Resolved, flagTenant string, + timeout time.Duration, +) (*Conn, error) { + tok, claims, err := LoadSession(rt, res.APIURL) + if err != nil { + return nil, err + } + tenant := res.TenantID + if flagTenant != "" { + if !claims.HasTenant(flagTenant) { + return nil, TenantUsageError(flagTenant, claims.Tenants()) + } + tenant = flagTenant + } + if tenant == "" { + tenant = claims.TenantID + } + sess := &Session{ + User: tok.User, + TenantID: tenant, + TenantIDs: claims.Tenants(), + ExpiresAt: tok.ExpiresAt, + } + // A wait that outlives the token ends with the same message, not a + // 401: there is nothing to re-mint with. + src := &tokenSource{ + value: tok.AccessToken, + expiry: tok.ExpiresAt, + mint: func(context.Context) (string, time.Time, error) { + return "", time.Time{}, SessionExpiredError(rt, res.APIURL) + }, + } + return &Conn{ + APIURL: res.APIURL, + Token: tok.AccessToken, + HTTPClient: HTTPClientFor(rt, timeout), + Session: sess, + BearerEditor: func(ctx context.Context, req *http.Request) error { + v, err := src.bearer(ctx) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+v) + if tenant != "" { + req.Header.Set(SelectedTenantHeader, tenant) + } + return nil + }, + }, nil +} + // tokenSource hands the bearer editor a token more than // auth.RefreshWindow from expiry, re-minting when it is not. The mutex // is defensive: no starfleet command is concurrent today, but the editor diff --git a/internal/starfleet/conn/conn_test.go b/internal/starfleet/conn/conn_test.go index 859948b..5a6adb9 100644 --- a/internal/starfleet/conn/conn_test.go +++ b/internal/starfleet/conn/conn_test.go @@ -478,7 +478,7 @@ func TestResolveUsesCachedToken(t *testing.T) { Stderr: io.Discard, } - c, err := Resolve(rt, "", "", "", RequestTimeout) + c, err := Resolve(rt, Flags{Timeout: RequestTimeout}) if err != nil { t.Fatal(err) } @@ -547,7 +547,7 @@ func TestResolveRefreshesStaleToken(t *testing.T) { Stderr: io.Discard, } - c, err := Resolve(rt, "", "", "", RequestTimeout) + c, err := Resolve(rt, Flags{Timeout: RequestTimeout}) if err != nil { t.Fatal(err) } @@ -613,7 +613,7 @@ func TestResolveIgnoresCorruptCache(t *testing.T) { Stderr: io.Discard, } - c, err := Resolve(rt, "", "", "", RequestTimeout) + c, err := Resolve(rt, Flags{Timeout: RequestTimeout}) if err != nil { t.Fatal(err) } @@ -792,7 +792,7 @@ func TestResolveCredentialBinding(t *testing.T) { wantURL, wantToken = other.url(), "other-host-tok" } - c, err := Resolve(rt, tc.flagID, tc.flagSecret, flagAPIURL, RequestTimeout) + c, err := Resolve(rt, Flags{ClientID: tc.flagID, ClientSecret: tc.flagSecret, APIURL: flagAPIURL, Timeout: RequestTimeout}) if err != nil { t.Fatal(err) } @@ -914,7 +914,7 @@ func TestResolveDoesNotReplayACachedTokenToADifferentHost(t *testing.T) { } // Step 0 — cold cache: one exchange against the profile's host. - c, err := Resolve(rt, "", "", "", RequestTimeout) + c, err := Resolve(rt, Flags{Timeout: RequestTimeout}) if err != nil { t.Fatal(err) } @@ -928,7 +928,7 @@ func TestResolveDoesNotReplayACachedTokenToADifferentHost(t *testing.T) { // Step 1 — the positive control. Nothing changed, so the cache // must be reused with no exchange at all. Without this, step 2 // proves nothing. - if c, err = Resolve(rt, "", "", "", RequestTimeout); err != nil { + if c, err = Resolve(rt, Flags{Timeout: RequestTimeout}); err != nil { t.Fatal(err) } if c.Token != "origin-tok" { @@ -941,7 +941,7 @@ func TestResolveDoesNotReplayACachedTokenToADifferentHost(t *testing.T) { // Step 2 — the bug. Same credentials, different host: the cached // token must NOT be handed to the other host. - c, err = Resolve(rt, "", "", other.url(), RequestTimeout) + c, err = Resolve(rt, Flags{APIURL: other.url(), Timeout: RequestTimeout}) if err != nil { t.Fatal(err) } @@ -966,7 +966,7 @@ func TestResolveDoesNotReplayACachedTokenToADifferentHost(t *testing.T) { // Step 3 — the new cache is bound to the host that minted it, so // repeating the override reuses rather than re-exchanging. This is // what keeps the fix from degenerating into "never cache". - if c, err = Resolve(rt, "", "", other.url(), RequestTimeout); err != nil { + if c, err = Resolve(rt, Flags{APIURL: other.url(), Timeout: RequestTimeout}); err != nil { t.Fatal(err) } if c.Token != "other-tok" { @@ -979,7 +979,7 @@ func TestResolveDoesNotReplayACachedTokenToADifferentHost(t *testing.T) { // Step 4 — and the binding is symmetric: dropping back to the // profile's host must not replay the other host's token either. - if c, err = Resolve(rt, "", "", "", RequestTimeout); err != nil { + if c, err = Resolve(rt, Flags{Timeout: RequestTimeout}); err != nil { t.Fatal(err) } if c.Token != "origin-tok" { @@ -1026,7 +1026,7 @@ func TestResolveFlagOverrides(t *testing.T) { Stderr: io.Discard, } - c, err := Resolve(rt, "flag-id", "flag-secret", srv.URL, RequestTimeout) + c, err := Resolve(rt, Flags{ClientID: "flag-id", ClientSecret: "flag-secret", APIURL: srv.URL, Timeout: RequestTimeout}) if err != nil { t.Fatal(err) } @@ -1051,7 +1051,7 @@ func TestResolveNoCredentialsIsAuthExit(t *testing.T) { Output: &output.Renderer{Format: "table"}, Stderr: io.Discard, } - _, err := Resolve(rt, "", "", "", RequestTimeout) + _, err := Resolve(rt, Flags{Timeout: RequestTimeout}) if err == nil { t.Fatal("expected an error with no credentials") } @@ -1095,7 +1095,7 @@ func TestResolveHalfSuppliedFlagPairDoesNotFallBack(t *testing.T) { Stderr: io.Discard, } - _, err := Resolve(rt, "only-an-id", "", srv.URL, RequestTimeout) + _, err := Resolve(rt, Flags{ClientID: "only-an-id", APIURL: srv.URL, Timeout: RequestTimeout}) if err == nil { t.Fatal("want an error for --client-id without --client-secret") } @@ -1142,7 +1142,7 @@ func TestResolveExchangeFailureIsAuthExit(t *testing.T) { Output: &output.Renderer{Format: "table"}, Stderr: io.Discard, } - _, err := Resolve(rt, "", "", srv.URL, RequestTimeout) + _, err := Resolve(rt, Flags{APIURL: srv.URL, Timeout: RequestTimeout}) if err == nil { t.Fatal("expected an error when the exchange fails") } @@ -2128,7 +2128,7 @@ func TestBearerEditorRefreshesAcrossExpiry(t *testing.T) { Stderr: io.Discard, } - c, err := Resolve(rt, "", "", "", RequestTimeout) + c, err := Resolve(rt, Flags{Timeout: RequestTimeout}) if err != nil { t.Fatal(err) } @@ -2193,7 +2193,7 @@ func TestBearerEditorReusesAHealthyToken(t *testing.T) { Stderr: io.Discard, } - c, err := Resolve(rt, "", "", "", RequestTimeout) + c, err := Resolve(rt, Flags{Timeout: RequestTimeout}) if err != nil { t.Fatal(err) } @@ -2255,7 +2255,7 @@ func TestBearerEditorRefreshSettlesEphemeral(t *testing.T) { Stderr: io.Discard, } - c, err := ResolveEphemeral(rt, "", "", "", RequestTimeout) + c, err := ResolveEphemeral(rt, Flags{Timeout: RequestTimeout}) if err != nil { t.Fatal(err) } @@ -2311,7 +2311,7 @@ func TestBearerEditorRefreshHonoursTheContext(t *testing.T) { Stderr: io.Discard, } - c, err := Resolve(rt, "", "", "", RequestTimeout) + c, err := Resolve(rt, Flags{Timeout: RequestTimeout}) if err != nil { t.Fatal(err) } diff --git a/internal/starfleet/conn/envcreds_test.go b/internal/starfleet/conn/envcreds_test.go index db06c40..58b95eb 100644 --- a/internal/starfleet/conn/envcreds_test.go +++ b/internal/starfleet/conn/envcreds_test.go @@ -75,7 +75,7 @@ func TestEnvWithExplicitProfileIsAUsageError(t *testing.T) { t.Fatalf("err = %v, want a usage-class ProfileConflictError", err) } - _, err = Resolve(rt, "", "", "", time.Second) + _, err = Resolve(rt, Flags{Timeout: time.Second}) var ee *ExitError if !errors.As(err, &ee) || ee.Code() != ExitUsage { t.Fatalf("Resolve err = %v, want exit %d", err, ExitUsage) @@ -135,7 +135,7 @@ func TestEnvCredentialsNeverTouchTheTokenCache(t *testing.T) { t.Fatal(err) } - c, err := Resolve(rt, "", "", srv.URL, time.Second) + c, err := Resolve(rt, Flags{APIURL: srv.URL, Timeout: time.Second}) if err != nil { t.Fatal(err) } @@ -153,7 +153,7 @@ func TestEnvCredentialsNeverTouchTheTokenCache(t *testing.T) { func TestEnvCredentialsWriteNoCacheFile(t *testing.T) { rt := envRuntime(t) url := testsupport.NewAuthedServer(t, testsupport.JSONHandler(200, "{}")) - if _, err := Resolve(rt, "", "", url, time.Second); err != nil { + if _, err := Resolve(rt, Flags{APIURL: url, Timeout: time.Second}); err != nil { t.Fatal(err) } current, _ := cachePaths(t) diff --git a/internal/starfleet/conn/session_test.go b/internal/starfleet/conn/session_test.go new file mode 100644 index 0000000..aa4492b --- /dev/null +++ b/internal/starfleet/conn/session_test.go @@ -0,0 +1,211 @@ +package conn + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + "time" + + "github.com/pgEdge/pgedge-cli/internal/auth" + "github.com/pgEdge/pgedge-cli/internal/config" + "github.com/pgEdge/pgedge-cli/internal/module" + "github.com/pgEdge/pgedge-cli/internal/testsupport" + "github.com/spf13/pflag" +) + +const sessionAPI = "https://api.example" + +// editedHeaders runs c's editor over a fresh request. +func editedHeaders(t *testing.T, c *Conn) (http.Header, error) { + t.Helper() + req, _ := http.NewRequest(http.MethodGet, c.APIURL+"/x", http.NoBody) + err := c.BearerEditor(context.Background(), req) + return req.Header, err +} + +func wantExit(t *testing.T, err error, code int, substr string) { + t.Helper() + var ee *ExitError + if !errors.As(err, &ee) || ee.Code() != code { + t.Fatalf("err = %v, want exit %d", err, code) + } + if !strings.Contains(err.Error(), substr) { + t.Errorf("err = %q, want it to contain %q", err, substr) + } +} + +func TestSessionSendsTheProfileTenant(t *testing.T) { + rt, _, _ := testsupport.NewRuntime(t, "", "text") + tok := testsupport.SeedSession(t, rt, sessionAPI, "u1", + time.Now().Add(time.Hour), "t-a", "t-b") + + for _, resolveFn := range []func() (*Conn, error){ + func() (*Conn, error) { return Resolve(rt, Flags{}) }, + func() (*Conn, error) { return ResolveEphemeral(rt, Flags{}) }, + } { + c, err := resolveFn() + if err != nil { + t.Fatal(err) + } + h, err := editedHeaders(t, c) + if err != nil { + t.Fatal(err) + } + if h.Get("Authorization") != "Bearer "+tok || + h.Get(SelectedTenantHeader) != "t-a" { + t.Errorf("headers = %v", h) + } + if c.Session == nil || c.Session.TenantID != "t-a" || + c.Session.User != "u1@example.com" || + len(c.Session.TenantIDs) != 2 { + t.Errorf("session = %+v", c.Session) + } + } +} + +func TestSessionTenantFlag(t *testing.T) { + rt, _, _ := testsupport.NewRuntime(t, "", "text") + testsupport.SeedSession(t, rt, sessionAPI, "u1", + time.Now().Add(time.Hour), "t-a", "t-b") + + c, err := Resolve(rt, Flags{Tenant: "t-b"}) + if err != nil { + t.Fatal(err) + } + if h, _ := editedHeaders(t, c); h.Get(SelectedTenantHeader) != "t-b" { + t.Errorf("--tenant did not override the profile: %v", h) + } + + _, err = Resolve(rt, Flags{Tenant: "t-z"}) + wantExit(t, err, ExitUsage, "t-a, t-b") +} + +func TestSessionWithNoProfileTenantUsesTheTokenDefault(t *testing.T) { + rt, _, _ := testsupport.NewRuntime(t, "", "text") + testsupport.SeedSession(t, rt, sessionAPI, "u1", + time.Now().Add(time.Hour), "t-a") + rt.Config.SetStarfleetProfile("default", &config.StarfleetProfile{ + APIURL: sessionAPI, Auth: config.AuthBrowser}) + c, err := Resolve(rt, Flags{}) + if err != nil { + t.Fatal(err) + } + if h, _ := editedHeaders(t, c); h.Get(SelectedTenantHeader) != "t-a" { + t.Errorf("headers = %v", h) + } +} + +func TestAPIClientNeverSendsTheTenantHeader(t *testing.T) { + stub := newTokenStub(t, "client-tok") + rt, _, _ := testsupport.NewRuntime(t, "", "text") + c, err := Resolve(rt, Flags{ClientID: "id", ClientSecret: "s", + APIURL: stub.url(), Timeout: RequestTimeout}) + if err != nil { + t.Fatal(err) + } + h, err := editedHeaders(t, c) + if err != nil { + t.Fatal(err) + } + if h.Get(SelectedTenantHeader) != "" || c.Session != nil { + t.Errorf("an API client sent %s: %v", SelectedTenantHeader, h) + } + + _, err = Resolve(rt, Flags{ClientID: "id", ClientSecret: "s", + APIURL: stub.url(), Tenant: "t-a"}) + wantExit(t, err, ExitUsage, "--tenant applies only") +} + +func TestSessionExpiredOrMissing(t *testing.T) { + for _, tc := range []struct { + name string + profile string + seed func(t *testing.T, rt *module.Runtime) + apiURL string + want string + }{ + {"expired", "default", seedExpiring(-time.Minute), "", + "run 'pgedge starfleet auth login' to"}, + {"inside the refresh window", "default", + seedExpiring(auth.RefreshWindow / 2), "", "has expired"}, + {"other API URL", "default", seedExpiring(time.Hour), + "https://other.example", "https://other.example"}, + {"no cached token", "dev", seedProfileOnly, "", + "auth login --profile dev"}, + } { + t.Run(tc.name, func(t *testing.T) { + rt, _, _ := testsupport.NewRuntime(t, "", "text") + rt.Profile = tc.profile + tc.seed(t, rt) + _, err := Resolve(rt, Flags{APIURL: tc.apiURL}) + wantExit(t, err, ExitAuth, tc.want) + }) + } +} + +func TestSessionWaitThatOutlivesTheTokenFailsWithTheSameMessage(t *testing.T) { + rt, _, _ := testsupport.NewRuntime(t, "", "text") + testsupport.SeedSession(t, rt, sessionAPI, "u1", + time.Now().Add(auth.RefreshWindow+150*time.Millisecond), "t-a") + c, err := Resolve(rt, Flags{}) + if err != nil { + t.Fatal(err) + } + time.Sleep(200 * time.Millisecond) + _, err = editedHeaders(t, c) + wantExit(t, err, ExitAuth, "has expired or is missing") +} + +func TestSaveSession(t *testing.T) { + rt, _, _ := testsupport.NewRuntime(t, "", "text") + exp := time.Now().Add(time.Hour).Truncate(time.Second) + at := testsupport.SessionToken(t, "u1", exp, "t-a") + if _, err := SaveSession(rt, sessionAPI, at, "me", exp); err != nil { + t.Fatal(err) + } + tok, err := Store(rt).LoadToken() + if err != nil || !tok.SessionFor(sessionAPI) || tok.User != "me" || + !tok.ExpiresAt.Equal(exp) { + t.Fatalf("cached = %+v, %v", tok, err) + } + if _, err := SaveSession(rt, sessionAPI, "not-a-jwt", "me", + exp); err == nil { + t.Error("an unreadable token was saved") + } + noSub := testsupport.FakeJWT(t, map[string]any{"exp": 1}) + if _, err := SaveSession(rt, sessionAPI, noSub, "me", exp); err == nil { + t.Error("a token with no subject was saved") + } +} + +func TestReadFlags(t *testing.T) { + fs := pflag.NewFlagSet("x", pflag.ContinueOnError) + fs.String("client-id", "i", "") + fs.String("client-secret", "s", "") + fs.String("api-url", "u", "") + fs.String("tenant", "t", "") + fs.Duration("timeout", time.Second, "") + got := ReadFlags(fs) + if got != (Flags{ClientID: "i", ClientSecret: "s", APIURL: "u", + Tenant: "t", Timeout: time.Second}) { + t.Errorf("ReadFlags = %+v", got) + } + if ReadFlags(pflag.NewFlagSet("y", pflag.ContinueOnError)).Timeout != + RequestTimeout { + t.Error("a missing --timeout should keep the default") + } +} + +func seedExpiring(in time.Duration) func(*testing.T, *module.Runtime) { + return func(t *testing.T, rt *module.Runtime) { + testsupport.SeedSession(t, rt, sessionAPI, "u1", + time.Now().Add(in), "t-a") + } +} + +func seedProfileOnly(t *testing.T, rt *module.Runtime) { + rt.Config.SetStarfleetProfile(rt.Profile, &config.StarfleetProfile{ + APIURL: sessionAPI, Auth: config.AuthBrowser}) +} diff --git a/internal/starfleet/managed/cmd/client.go b/internal/starfleet/managed/cmd/client.go index 9848455..569d248 100644 --- a/internal/starfleet/managed/cmd/client.go +++ b/internal/starfleet/managed/cmd/client.go @@ -76,10 +76,9 @@ func checkEmptyBodyResponse(resp *http.Response, what string) error { // newAPIClient builds an authenticated Managed API client over the // starfleet module's resolved connection, sharing its one credential // pair and cached token. -func newAPIClient(rt *module.Runtime, - flagID, flagSecret, flagAPIURL string, timeout time.Duration, +func newAPIClient(rt *module.Runtime, f conn.Flags, ) (*api.ClientWithResponses, error) { - c, err := conn.Resolve(rt, flagID, flagSecret, flagAPIURL, timeout) + c, err := conn.Resolve(rt, f) if err != nil { return nil, err } @@ -99,27 +98,15 @@ func newAPIClient(rt *module.Runtime, func clientFromCmd( rt *module.Runtime, cmd *cobra.Command, ) (*api.ClientWithResponses, error) { - id, secret, apiURL, timeout := connFlags(cmd) - return newAPIClient(rt, id, secret, apiURL, timeout) + return newAPIClient(rt, connFlags(cmd)) } -// connFlags reads the managed root's persistent connection overrides -// off cmd's inherited flag set. -func connFlags(cmd *cobra.Command) ( - id, secret, apiURL string, timeout time.Duration, -) { - id, _ = cmd.Flags().GetString("client-id") - secret, _ = cmd.Flags().GetString("client-secret") - apiURL, _ = cmd.Flags().GetString("api-url") - // Fail safe, as controlplane's reader does: a failed read keeps the - // 30-second default, because its zero value would be an unbounded - // client. - timeout = conn.RequestTimeout - if v, err := cmd.Flags().GetDuration("timeout"); err == nil { - timeout = v - } - requestTimeoutFlag = timeout - return id, secret, apiURL, timeout +// connFlags reads the persistent connection flags off cmd's inherited +// flag set and records --timeout for the wait machinery. +func connFlags(cmd *cobra.Command) conn.Flags { + f := conn.ReadFlags(cmd.Flags()) + requestTimeoutFlag = f.Timeout + return f } // requestTimeoutFlag is the resolved --timeout, recorded by connFlags diff --git a/internal/testsupport/session.go b/internal/testsupport/session.go new file mode 100644 index 0000000..2d57f6c --- /dev/null +++ b/internal/testsupport/session.go @@ -0,0 +1,68 @@ +package testsupport + +import ( + "encoding/base64" + "encoding/json" + "testing" + "time" + + "github.com/pgEdge/pgedge-cli/internal/auth" + "github.com/pgEdge/pgedge-cli/internal/config" + "github.com/pgEdge/pgedge-cli/internal/module" +) + +// FakeJWT builds an unsigned JWT carrying claims. The CLI never checks +// a signature, so this is all a test needs. +func FakeJWT(t testing.TB, claims map[string]any) string { + t.Helper() + enc := func(v any) string { + raw, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal jwt part: %v", err) + } + return base64.RawURLEncoding.EncodeToString(raw) + } + return enc(map[string]string{"alg": "none"}) + "." + enc(claims) + + ".sig" +} + +// SessionToken is a user access token for sub, reaching tenants, with +// the first tenant as the default and exp at expiresAt. +func SessionToken(t testing.TB, sub string, expiresAt time.Time, + tenants ...string) string { + t.Helper() + claims := map[string]any{"sub": sub, "exp": expiresAt.Unix()} + if len(tenants) > 0 { + claims["pgedge_tenant_id"] = tenants[0] + claims["pgedge_tenant_ids"] = tenants + } + return FakeJWT(t, claims) +} + +// SeedSession makes rt's profile a browser session for apiURL, with a +// cached token for sub reaching tenants (the first is selected), +// expiring at expiresAt. +func SeedSession(t *testing.T, rt *module.Runtime, apiURL, sub string, + expiresAt time.Time, tenants ...string) string { + t.Helper() + tok := SessionToken(t, sub, expiresAt, tenants...) + selected := "" + if len(tenants) > 0 { + selected = tenants[0] + } + rt.Config.SetStarfleetProfile(rt.Profile, &config.StarfleetProfile{ + APIURL: apiURL, Auth: config.AuthBrowser, TenantID: selected}) + if err := rt.Config.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + store := &auth.Auth{Profile: rt.Profile, Module: "starfleet"} + if err := store.SaveToken(&auth.CachedToken{ + AccessToken: tok, + ExpiresAt: expiresAt, + Fingerprint: auth.SessionFingerprint(apiURL, sub), + User: sub + "@example.com", + }); err != nil { + t.Fatalf("save token: %v", err) + } + return tok +} From 0e82daf36eedab799a16cbe814a77be28e7445b7 Mon Sep 17 00:00:00 2001 From: Antony Pegg Date: Mon, 19 Oct 2026 12:37:51 -0400 Subject: [PATCH 04/11] feat: sign in through the browser by default auth login with no credential flags now signs the user in through the browser and saves the profile as a session: api_url, auth and tenant_id, with any API client ID and keychain secret removed. The token goes to the existing cache, bound to the API URL and the user, with its real expiry. A user in several tenants picks one at a prompt on a terminal, or gets the account default with a note on stderr; --tenant takes an ID or a name. --api-client keeps the old prompt for an ID and secret, and --client-id/--client-secret are unchanged. status, whoami and logout understand a session. whoami reports the user and the selected tenant without listing API clients, and status exits 5 for an expired session, which nothing can renew. --- internal/clitest/stderr_sanitize_test.go | 2 +- internal/starfleet/account/cmd/auth.go | 173 ++++-- .../starfleet/account/cmd/auth_browser.go | 268 +++++++++ .../account/cmd/auth_browser_test.go | 525 ++++++++++++++++++ internal/starfleet/account/cmd/auth_test.go | 14 +- internal/starfleet/account/cmd/auth_whoami.go | 52 +- 6 files changed, 990 insertions(+), 44 deletions(-) create mode 100644 internal/starfleet/account/cmd/auth_browser.go create mode 100644 internal/starfleet/account/cmd/auth_browser_test.go diff --git a/internal/clitest/stderr_sanitize_test.go b/internal/clitest/stderr_sanitize_test.go index 2e675a9..e0a73cb 100644 --- a/internal/clitest/stderr_sanitize_test.go +++ b/internal/clitest/stderr_sanitize_test.go @@ -298,7 +298,7 @@ var stderrComposedMessages = map[string]struct { // 198 -> 199: a branch link's `Using branch` line. // 199 -> 200: `branch get` prints the display name. const ( - stderrArgSites = 200 + stderrArgSites = 211 stderrComposedSites = 22 ) diff --git a/internal/starfleet/account/cmd/auth.go b/internal/starfleet/account/cmd/auth.go index bfca1be..82f0124 100644 --- a/internal/starfleet/account/cmd/auth.go +++ b/internal/starfleet/account/cmd/auth.go @@ -50,28 +50,44 @@ Example: } func newAuthLoginCmd(rt *module.Runtime, f *conn.Flags) *cobra.Command { - var insecureStorage bool + var insecureStorage, apiClient bool cmd := &cobra.Command{ Use: "login", Short: "Authenticate with pgEdge Starfleet", - Long: `login takes a client ID and secret, exchanges them for an -access token, and saves them to the active profile. The secret goes -to the OS keychain (Keychain on macOS, the Secret Service on Linux, -Credential Manager on Windows); the client ID and API URL go to + Long: `login signs the active profile in to pgEdge Starfleet, either as +you, through the browser, or as an API client. + +With no credential flags, login opens your browser on the pgEdge +sign-in page and prints the same URL on stderr, so you can open it by +hand when no browser starts. Once you sign in, every command runs as +you, in your tenant, until the session expires; login prints when +that is, and you run login again after it. The profile stores the API +URL, the tenant and the fact that it is a browser sign-in; the token +goes to the token cache, never the config file. When you belong to +several tenants, login asks which to use when it can prompt, and uses +your account's default tenant otherwise. --tenant picks one by ID or +name without asking. Browser sign-in is available only for pgEdge +Starfleet APIs that have a sign-in app; for any other --api-url, +login fails with exit 5 and asks for an API client. + +An API client is the unattended path, for CI and scripts. Supply +--client-id and --client-secret, or --api-client to be prompted for +them; the secret is never echoed. login exchanges them for an access +token and saves them to the profile. The secret goes to the OS +keychain (Keychain on macOS, the Secret Service on Linux, Credential +Manager on Windows); the client ID and API URL go to ~/.pgedge/cli/config.yaml. Where no keychain can be used, login writes the secret to the config file instead and warns that it did. --insecure-storage writes it to the config file without trying the -keychain. - -Supply both with --client-id and --client-secret to run it -unattended, or omit them to be prompted; the secret is never echoed -either way. Supplying only one of the pair is a usage error (exit 2) +keychain. Supplying only one of the pair is a usage error (exit 2) rather than a prompt for the other, so a mistyped flag cannot sign you in under a different identity. -Run this once per profile. The credentials serve every pgEdge Starfleet -module — account, byoc and managed — which share one connection and -one token. Provide --api-url to target a non-default API endpoint. +A profile holds one kind of credential: signing in one way removes +what the other way stored. Run this once per profile. The sign-in +serves every pgEdge Starfleet module — account, byoc and managed — +which share one connection and one token. Provide --api-url to +target a non-default API endpoint. login ignores PGEDGE_CLIENT_ID and PGEDGE_CLIENT_SECRET, and warns when they are set, because every other command uses them instead of @@ -79,9 +95,9 @@ the profile until they are unset. Example: pgedge starfleet auth login - pgedge starfleet auth login --api-url https://api.pgedge.com + pgedge starfleet auth login --tenant TENANT_ID pgedge starfleet auth login --client-id ID --client-secret SECRET - pgedge starfleet auth login --insecure-storage`, + pgedge starfleet auth login --api-client --insecure-storage`, Annotations: map[string]string{ cli.AnnotationProfileExempt: "creates the named profile, " + "so a new name is the point rather than a typo", @@ -96,6 +112,24 @@ Example: return newExitError(err.Error(), ExitUsage) } + if creds == nil && !apiClient { + if insecureStorage { + return newExitError("--insecure-storage stores an "+ + "API client's secret: add --api-client or "+ + "--client-id and --client-secret", ExitUsage) + } + if err := browserLogin(rt, f); err != nil { + return err + } + warnEnvPair(rt) + return nil + } + if f.Tenant != "" { + return newExitError("--tenant applies only to a "+ + "browser sign-in: an API client is scoped to one "+ + "tenant", ExitUsage) + } + var clientID, clientSecret string if creds != nil { clientID, clientSecret = creds.ClientID, creds.ClientSecret @@ -169,20 +203,27 @@ Example: fmt.Fprintf(rt.Stderr, "Credentials saved to %s\n", path) } - if os.Getenv(auth.EnvClientID) != "" || - os.Getenv(auth.EnvClientSecret) != "" { - fmt.Fprintln(rt.Stderr, "Warning: PGEDGE_CLIENT_ID or "+ - "PGEDGE_CLIENT_SECRET is set, so other commands use "+ - "it instead of this profile until it is unset.") - } + warnEnvPair(rt) return nil }, } cmd.Flags().BoolVar(&insecureStorage, "insecure-storage", false, "store the client secret in the config file, not the OS keychain") + cmd.Flags().BoolVar(&apiClient, "api-client", false, + "sign in with an API client, prompting for its ID and secret") return cmd } +// warnEnvPair warns that the env pair outranks the profile just saved. +func warnEnvPair(rt *module.Runtime) { + if os.Getenv(auth.EnvClientID) != "" || + os.Getenv(auth.EnvClientSecret) != "" { + fmt.Fprintln(rt.Stderr, "Warning: PGEDGE_CLIENT_ID or "+ + "PGEDGE_CLIENT_SECRET is set, so other commands use "+ + "it instead of this profile until it is unset.") + } +} + // keychainDelete removes the profile's keychain entry, reporting only // a failure other than "there was none". func keychainDelete(store *auth.Auth) error { @@ -217,8 +258,11 @@ type authStatusReport struct { Source string `json:"source,omitempty"` // Problem is as in authInfo: a usage error or an unreadable // keychain, absent when there are simply no credentials. - Problem string `json:"problem,omitempty"` - ClientID string `json:"client_id,omitempty"` + Problem string `json:"problem,omitempty"` + ClientID string `json:"client_id,omitempty"` + // User and TenantID describe a browser session. + User string `json:"user,omitempty"` + TenantID string `json:"tenant_id,omitempty"` APIURL string `json:"api_url,omitempty"` TokenValid bool `json:"token_valid"` // TokenBound means what authInfo.TokenBound (doctor.go) means. @@ -235,12 +279,16 @@ current profile, the API URL in effect, and whether a cached token is present and still valid. -o json and -o yaml carry the same facts as a single object, so a script can check them without parsing prose. +For a browser sign-in it reports the user, the tenant and when the +session expires; for an API client, its client ID. + It exits 5 (the auth-failure code) when no usable credentials resolve, 2 if you supplied half a credential pair, and 0 -whenever they do — even if no token is -cached yet or the cached one has expired, since the CLI mints a new -one on demand the next time it is needed. Run it to confirm you are -signed in before using resource commands. +whenever they do. An API client's token may be missing or expired +with exit 0, since the CLI mints a new one on demand the next time it +is needed. A browser session cannot be renewed that way, so an +expired one exits 5; run 'pgedge starfleet auth login' again. Run +status to confirm you are signed in before using resource commands. Example: pgedge starfleet auth status @@ -272,6 +320,9 @@ Example: } return conn.NewExitError(err.Error(), code) } + if res.Session { + return sessionStatus(rt, res, f.Tenant) + } creds, source, apiURL := res.Creds, res.Source, res.APIURL report := authStatusReport{ @@ -326,6 +377,58 @@ Example: } } +// sessionStatus reports a browser session. Unlike an API client, an +// expired session cannot be renewed on demand, so it exits 5. +func sessionStatus( + rt *module.Runtime, res *conn.Resolved, flagTenant string, +) error { + report := authStatusReport{ + Authenticated: true, + Source: res.Source, + APIURL: res.APIURL, + TenantID: res.TenantID, + } + if flagTenant != "" { + report.TenantID = flagTenant + } + tok, err := conn.Store(rt).LoadToken() + if err == nil { + report.User = tok.User + report.ExpiresAt = tok.ExpiresAt.Format(time.RFC3339) + report.TokenValid = time.Until(tok.ExpiresAt) > auth.RefreshWindow + report.TokenBound = tok.SessionFor(res.APIURL) + } + lines := []string{ + fmt.Sprintf("User: %s", output.Sanitize(report.User)), + fmt.Sprintf("Auth source: %s", report.Source), + fmt.Sprintf("API URL: %s", report.APIURL), + fmt.Sprintf("Tenant: %s", output.Sanitize(report.TenantID)), + } + var expired error + switch { + case err != nil: + lines = append(lines, "Token: not cached") + case !report.TokenBound: + lines = append(lines, + "Token: stale (signed in for a different API URL)") + case !report.TokenValid: + lines = append(lines, "Token: expired") + default: + lines = append(lines, fmt.Sprintf( + "Token: valid (expires %s)", report.ExpiresAt)) + } + if !report.TokenValid || !report.TokenBound { + report.Authenticated = false + expired = conn.SessionExpiredError(rt, res.APIURL) + report.Problem = expired.Error() + } + if pErr := printAuthStatus( + rt, report, strings.Join(lines, "\n")+"\n"); pErr != nil { + return pErr + } + return expired +} + // printAuthStatus renders report through rt.Output for json/yaml, or // writes text verbatim otherwise. func printAuthStatus( @@ -343,10 +446,11 @@ func newAuthLogoutCmd(rt *module.Runtime) *cobra.Command { Use: "logout", Short: "Clear stored pgEdge Starfleet credentials and token", Long: `logout removes the cached access token and the stored -client credentials for the active profile, from the OS keychain and -from ~/.pgedge/cli/config.yaml. The API URL and the profile itself -are kept, so 'pgedge starfleet auth login' can sign in again without -re-entering the endpoint. +credential for the active profile: for an API client, its client ID +and secret, from the OS keychain and from ~/.pgedge/cli/config.yaml; +for a browser sign-in, the session and its tenant. The API URL and the +profile itself are kept, so 'pgedge starfleet auth login' can sign in +again without re-entering the endpoint. Example: pgedge starfleet auth logout`, @@ -361,7 +465,7 @@ Example: // cannot be reached warns rather than stopping the file side // being cleared. cp := rt.Config.StarfleetProfile(rt.Profile) - if cp.ClientSecret == "" { + if !cp.IsBrowserSession() && cp.ClientSecret == "" { if err := keychainDelete(conn.Store(rt)); err != nil { fmt.Fprintf(rt.Stderr, "Warning: could not remove "+ "the client secret from the OS keychain: %s\n", @@ -373,6 +477,11 @@ Example: if err := rt.Config.Save(); err != nil { return fmt.Errorf("clear credentials: %w", err) } + if cp.IsBrowserSession() { + fmt.Fprintln(rt.Stderr, + "Logged out. Session token removed.") + return nil + } fmt.Fprintln(rt.Stderr, "Logged out. Token and stored credentials removed.") return nil diff --git a/internal/starfleet/account/cmd/auth_browser.go b/internal/starfleet/account/cmd/auth_browser.go new file mode 100644 index 0000000..e079fa7 --- /dev/null +++ b/internal/starfleet/account/cmd/auth_browser.go @@ -0,0 +1,268 @@ +package cmd + +import ( + "bufio" + "context" + "fmt" + "net/http" + "os" + "os/signal" + "strconv" + "strings" + "time" + + "golang.org/x/term" + + "github.com/pgEdge/pgedge-cli/internal/apidefaults" + "github.com/pgEdge/pgedge-cli/internal/auth" + "github.com/pgEdge/pgedge-cli/internal/config" + "github.com/pgEdge/pgedge-cli/internal/module" + "github.com/pgEdge/pgedge-cli/internal/output" + accountapi "github.com/pgEdge/pgedge-cli/internal/starfleet/account/api" + "github.com/pgEdge/pgedge-cli/internal/starfleet/conn" + "github.com/pgEdge/pgedge-cli/internal/starfleet/signin" +) + +// auth0For finds the sign-in app for an API URL. A variable so tests +// can sign in against a stub API. +var auth0For = apidefaults.Auth0For + +// signInFlow builds the browser sign-in. A variable so tests can point +// it at a fake Auth0 and stand in for the browser. +var signInFlow = func(rt *module.Runtime, app apidefaults.Auth0App, + timeout time.Duration) *signin.Flow { + return &signin.Flow{ + BaseURL: "https://" + app.Domain, + ClientID: app.ClientID, + Audience: app.Audience, + Ports: signin.DefaultPorts, + Open: signin.OpenBrowser, + Wait: signin.DefaultWait, + HTTPClient: conn.SignInHTTPClient(rt, timeout), + Out: rt.Stderr, + } +} + +// promptIsInteractive reports whether a person can answer the tenant +// prompt. A variable so tests can stand in for one. +var promptIsInteractive = func(rt *module.Runtime) bool { + in, ok := rt.Stdin.(*os.File) + if !ok || !term.IsTerminal(int(in.Fd())) { + return false + } + out, ok := rt.Stdout.(*os.File) + return ok && term.IsTerminal(int(out.Fd())) +} + +// browserLogin signs the user in through the browser and saves the +// profile as a browser session. +func browserLogin(rt *module.Runtime, f *conn.Flags) error { + apiURL := conn.ResolveAPIURL( + rt.Config.StarfleetProfile(rt.Profile), f.APIURL) + app, ok := auth0For(apiURL) + if !ok { + // Exit 5, not 2: the command is well formed, and the same line + // works once the API has a sign-in app. + return newExitError(fmt.Sprintf("browser sign-in is not "+ + "available for %s yet: sign in with an API client instead, "+ + "with --client-id and --client-secret, or --api-client to "+ + "be prompted for them", apiURL), ExitAuth) + } + timeout := f.Timeout + if timeout <= 0 { + timeout = conn.RequestTimeout + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + toks, err := signInFlow(rt, app, timeout).Run(ctx) + if err != nil { + return newExitError(err.Error(), ExitAuth) + } + + claims, err := auth.ParseClaims(toks.AccessToken) + if err != nil { + return newExitError(fmt.Sprintf("sign-in returned an access "+ + "token the CLI cannot read: %v", err), ExitAuth) + } + expiresAt := claims.ExpiresAt() + if expiresAt.IsZero() { + expiresAt = time.Now().Add( + time.Duration(toks.ExpiresIn) * time.Second) + } + user := signedInUser(claims, toks.IDToken) + + tenantID, label, err := pickTenant(ctx, rt, apiURL, + toks.AccessToken, claims, f.Tenant, timeout) + if err != nil { + return err + } + + if _, err := conn.SaveSession(rt, apiURL, toks.AccessToken, user, + expiresAt); err != nil { + return newExitError(fmt.Sprintf("save session: %v", err), + ExitAuth) + } + + // One profile, one kind of credential: an API client's secret goes. + old := rt.Config.StarfleetProfile(rt.Profile) + if old.ClientID != "" && old.ClientSecret == "" { + if err := keychainDelete(conn.Store(rt)); err != nil { + fmt.Fprintf(rt.Stderr, "Warning: could not remove the "+ + "previous client secret from the OS keychain: %s\n", + output.Sanitize(err.Error())) + } + } + rt.Config.SetStarfleetProfile(rt.Profile, &config.StarfleetProfile{ + APIURL: apiURL, Auth: config.AuthBrowser, TenantID: tenantID}) + if err := rt.Config.Save(); err != nil { + return fmt.Errorf("save profile: %w", err) + } + + fmt.Fprintf(rt.Stderr, "Signed in as %s.\n", output.Sanitize(user)) + fmt.Fprintf(rt.Stderr, "Tenant: %s\n", output.Sanitize(label)) + fmt.Fprintf(rt.Stderr, "The session lasts until %s; run 'pgedge "+ + "starfleet auth login' again after that.\n", + expiresAt.Format(time.RFC3339)) + fmt.Fprintf(rt.Stderr, "Profile %s saved to %s\n", + output.Sanitize(rt.Profile), output.Sanitize(rt.Config.Path())) + return nil +} + +// signedInUser names the user: the ID token's email, else the access +// token's, else its subject. +func signedInUser(claims *auth.Claims, idToken string) string { + if idToken != "" { + if id, err := auth.ParseClaims(idToken); err == nil && + id.Email != "" { + return id.Email + } + } + if claims.Email != "" { + return claims.Email + } + return claims.Subject +} + +// pickTenant chooses the tenant the session acts in and a label for it: +// --tenant by ID or name, the only one, a prompt, or the token's +// default. +func pickTenant(ctx context.Context, rt *module.Runtime, apiURL, + token string, claims *auth.Claims, want string, + timeout time.Duration, +) (id, label string, err error) { + ids := claims.Tenants() + if len(ids) == 0 { + return "", "", newExitError("the signed-in user belongs to no "+ + "pgEdge Starfleet tenant; accept an invite first", ExitAuth) + } + // Names are a convenience: without them the IDs still work. + tenants := reachableTenants(ctx, rt, apiURL, token, timeout, claims) + labelFor := func(id string) string { + for _, t := range tenants { + if strings.EqualFold(t.Id, id) { + return t.Name + " (" + t.Id + ")" + } + } + return id + } + + switch { + case want != "": + id, err = matchTenant(want, ids, tenants) + if err != nil { + return "", "", err + } + case len(ids) == 1: + id = ids[0] + case promptIsInteractive(rt): + id, err = promptTenant(rt, ids, labelFor) + if err != nil { + return "", "", err + } + default: + id = claims.TenantID + if id == "" { + id = ids[0] + } + fmt.Fprintf(rt.Stderr, "This sign-in reaches %d tenants; using "+ + "%s, the account default. Pick another with --tenant.\n", + len(ids), output.Sanitize(labelFor(id))) + } + return id, labelFor(id), nil +} + +// matchTenant resolves --tenant against the token's tenant IDs, then +// against the reachable tenants' names. +func matchTenant(want string, ids []string, tenants []accountapi.Tenant, +) (string, error) { + for _, id := range ids { + if strings.EqualFold(id, want) { + return id, nil + } + } + var found []string + for _, t := range tenants { + if strings.EqualFold(t.Name, want) { + found = append(found, t.Id) + } + } + switch len(found) { + case 1: + return found[0], nil + case 0: + return "", conn.TenantUsageError(want, ids) + } + return "", newExitError(fmt.Sprintf("--tenant %s names %d tenants; "+ + "give the ID instead: %s", want, len(found), + strings.Join(found, ", ")), ExitUsage) +} + +// reachableTenants lists the tenants the new token reaches, keeping only +// those in its claims. Any failure returns nil. +func reachableTenants(ctx context.Context, rt *module.Runtime, apiURL, + token string, timeout time.Duration, claims *auth.Claims, +) []accountapi.Tenant { + client, err := accountapi.NewClientWithResponses(apiURL, + accountapi.WithHTTPClient(conn.HTTPClientFor(rt, timeout)), + accountapi.WithRequestEditorFn( + func(_ context.Context, req *http.Request) error { + req.Header.Set("Authorization", "Bearer "+token) + return nil + })) + if err != nil { + return nil + } + resp, err := client.ListTenantsWithResponse(ctx) + if err != nil || resp.JSON200 == nil { + return nil + } + var out []accountapi.Tenant + for _, t := range *resp.JSON200 { + if claims.HasTenant(t.Id) { + out = append(out, t) + } + } + return out +} + +// promptTenant asks which tenant to use. +func promptTenant(rt *module.Runtime, ids []string, + labelFor func(string) string) (string, error) { + fmt.Fprintf(rt.Stderr, "This sign-in reaches %d tenants:\n", len(ids)) + for i, id := range ids { + fmt.Fprintf(rt.Stderr, " %d) %s\n", i+1, + output.Sanitize(labelFor(id))) + } + fmt.Fprintf(rt.Stderr, "Tenant [1-%d]: ", len(ids)) + line, err := bufio.NewReader(rt.Stdin).ReadString('\n') + if err != nil && line == "" { + return "", newExitError("no tenant chosen", ExitUsage) + } + n, err := strconv.Atoi(strings.TrimSpace(line)) + if err != nil || n < 1 || n > len(ids) { + return "", newExitError(fmt.Sprintf("%q is not a number from 1 "+ + "to %d", strings.TrimSpace(line), len(ids)), ExitUsage) + } + return ids[n-1], nil +} diff --git a/internal/starfleet/account/cmd/auth_browser_test.go b/internal/starfleet/account/cmd/auth_browser_test.go new file mode 100644 index 0000000..0210061 --- /dev/null +++ b/internal/starfleet/account/cmd/auth_browser_test.go @@ -0,0 +1,525 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/pgEdge/pgedge-cli/internal/apidefaults" + "github.com/pgEdge/pgedge-cli/internal/auth" + "github.com/pgEdge/pgedge-cli/internal/config" + "github.com/pgEdge/pgedge-cli/internal/keychain" + "github.com/pgEdge/pgedge-cli/internal/module" + "github.com/pgEdge/pgedge-cli/internal/starfleet/conn" + "github.com/pgEdge/pgedge-cli/internal/starfleet/signin" + "github.com/pgEdge/pgedge-cli/internal/testsupport" +) + +// tenantsBody is the stub API's GET /account/v1/tenants answer. +const tenantsBody = `[` + + `{"id":"t-a","name":"Alpha","plan":"enterprise"},` + + `{"id":"t-b","name":"Beta","plan":"managed"}]` + +// signInStubs stands up a fake Auth0 and a fake API, and points the +// login command's seams at them. The fake Auth0 issues an access token +// reaching tenants, with the first as its default. +type signInStubs struct { + api string + tenants []string + verifier atomic.Value + reply func(q url.Values) url.Values + apiHits map[string]int + headers []http.Header +} + +func newSignInStubs(t *testing.T, tenants ...string) *signInStubs { + t.Helper() + s := &signInStubs{tenants: tenants, apiHits: map[string]int{}} + s.reply = func(q url.Values) url.Values { + return url.Values{"code": {"c"}, "state": {q.Get("state")}} + } + auth0 := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + s.verifier.Store(r.PostForm.Get("code_verifier")) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": testsupport.SessionToken(t, "auth0|u1", + time.Now().Add(24*time.Hour), s.tenants...), + "id_token": testsupport.FakeJWT(t, + map[string]any{"email": "user@example.com"}), + "expires_in": 86400, + }) + })) + t.Cleanup(auth0.Close) + api := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + s.apiHits[r.URL.Path]++ + s.headers = append(s.headers, r.Header.Clone()) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/account/v1/tenants": + _, _ = io.WriteString(w, tenantsBody) + case testsupport.TokenPath: + _, _ = io.WriteString(w, testsupport.TokenBody) + default: + _, _ = io.WriteString(w, "[]") + } + })) + t.Cleanup(api.Close) + s.api = api.URL + + savedFor, savedFlow, savedTTY := auth0For, signInFlow, + promptIsInteractive + t.Cleanup(func() { + auth0For, signInFlow, promptIsInteractive = savedFor, savedFlow, + savedTTY + }) + auth0For = func(string) (apidefaults.Auth0App, bool) { + return apidefaults.Auth0App{Domain: "unused", + Audience: "aud", ClientID: "cid"}, true + } + promptIsInteractive = func(*module.Runtime) bool { return false } + signInFlow = func(rt *module.Runtime, app apidefaults.Auth0App, + _ time.Duration) *signin.Flow { + return &signin.Flow{ + BaseURL: auth0.URL, ClientID: app.ClientID, + Audience: app.Audience, Ports: []int{0}, + Wait: 5 * time.Second, Out: rt.Stderr, + Open: func(target string) error { + u, _ := url.Parse(target) + q := u.Query() + resp, err := http.Get(q.Get("redirect_uri") + "?" + + s.reply(q).Encode()) + if err == nil { + _ = resp.Body.Close() + } + return err + }, + } + } + return s +} + +func login(t *testing.T, rt *module.Runtime, s *signInStubs, + args ...string) error { + t.Helper() + full := append([]string{"auth", "login", "--api-url", s.api}, args...) + return runAccount(t, rt, &bytes.Buffer{}, full...) +} + +func exitCode(err error) int { + var ee *ExitError + if errors.As(err, &ee) { + return ee.Code() + } + return -1 +} + +func TestSignedInUser(t *testing.T) { + id := testsupport.FakeJWT(t, map[string]any{"email": "id@example.com"}) + for _, tc := range []struct { + claims auth.Claims + id string + want string + }{ + {auth.Claims{Subject: "s", Email: "at@example.com"}, id, + "id@example.com"}, + {auth.Claims{Subject: "s", Email: "at@example.com"}, "", + "at@example.com"}, + {auth.Claims{Subject: "s"}, "opaque", "s"}, + } { + if got := signedInUser(&tc.claims, tc.id); got != tc.want { + t.Errorf("signedInUser(%+v, %q) = %q, want %q", + tc.claims, tc.id, got, tc.want) + } + } +} + +func TestDefaultSignInFlowTargetsAuth0(t *testing.T) { + rt, _, errb := testsupport.NewRuntime(t, "", "text") + f := signInFlow(rt, apidefaults.Auth0App{Domain: "auth.example", + Audience: "aud", ClientID: "cid"}, time.Second) + if f.BaseURL != "https://auth.example" || f.ClientID != "cid" || + f.Audience != "aud" || f.Open == nil || f.Out != errb || + len(f.Ports) != 3 || f.Ports[0] != 8484 || + f.Wait != signin.DefaultWait || f.HTTPClient == nil { + t.Errorf("default flow = %+v", f) + } +} + +func TestBrowserLoginSavesASession(t *testing.T) { + s := newSignInStubs(t, "t-a") + rt, _, errb := testsupport.NewRuntime(t, "", "text") + if err := login(t, rt, s); err != nil { + t.Fatalf("login: %v\n%s", err, errb.String()) + } + cfg, err := config.Load("") + if err != nil { + t.Fatal(err) + } + p := cfg.StarfleetProfile("default") + if *p != (config.StarfleetProfile{APIURL: s.api, + Auth: config.AuthBrowser, TenantID: "t-a"}) { + t.Errorf("profile = %+v", p) + } + tok, err := conn.Store(rt).LoadToken() + if err != nil || !tok.SessionFor(s.api) || + tok.User != "user@example.com" || + time.Until(tok.ExpiresAt) < 23*time.Hour { + t.Fatalf("cached token = %+v, %v", tok, err) + } + if v, _ := s.verifier.Load().(string); len(v) != 43 { + t.Errorf("token exchange carried verifier %q", v) + } + for _, want := range []string{ + "/authorize?", + "Signed in as user@example.com.", + "Tenant: Alpha (t-a)", + "The session lasts until " + tok.ExpiresAt.Format(time.RFC3339), + "Profile default saved to", + } { + if !strings.Contains(errb.String(), want) { + t.Errorf("stderr lacks %q:\n%s", want, errb.String()) + } + } +} + +func TestBrowserLoginTenantChoice(t *testing.T) { + for _, tc := range []struct { + name string + args []string + tty bool + stdin string + want string + code int + stderr string + tenants []string + }{ + {name: "only one", tenants: []string{"t-b"}, want: "t-b"}, + {name: "flag by id", args: []string{"--tenant", "T-B"}, + want: "t-b"}, + {name: "flag by name", args: []string{"--tenant", "beta"}, + want: "t-b"}, + {name: "flag unknown", args: []string{"--tenant", "Gamma"}, + code: ExitUsage, stderr: "t-a, t-b"}, + {name: "several, no terminal", want: "t-a", + stderr: "using Alpha (t-a), the account default"}, + {name: "several, prompt", tty: true, stdin: "2\n", want: "t-b", + stderr: "2) Beta (t-b)"}, + {name: "prompt, bad answer", tty: true, stdin: "9\n", + code: ExitUsage}, + {name: "prompt, no answer", tty: true, code: ExitUsage}, + {name: "no tenant", tenants: []string{}, code: ExitAuth}, + } { + t.Run(tc.name, func(t *testing.T) { + tenants := tc.tenants + if tenants == nil { + tenants = []string{"t-a", "t-b"} + } + s := newSignInStubs(t, tenants...) + promptIsInteractive = func(*module.Runtime) bool { + return tc.tty + } + rt, _, errb := testsupport.NewRuntime(t, tc.stdin, "text") + err := login(t, rt, s, tc.args...) + if tc.code != 0 { + if exitCode(err) != tc.code { + t.Fatalf("err = %v, want exit %d", err, tc.code) + } + if tc.stderr != "" && + !strings.Contains(err.Error(), tc.stderr) { + t.Errorf("err = %v, want %q", err, tc.stderr) + } + return + } + if err != nil { + t.Fatalf("login: %v", err) + } + if got := rt.Config.StarfleetProfile("default").TenantID; got != tc.want { + t.Errorf("tenant = %q, want %q", got, tc.want) + } + if !strings.Contains(errb.String(), tc.stderr) { + t.Errorf("stderr lacks %q:\n%s", tc.stderr, errb.String()) + } + }) + } +} + +func TestBrowserLoginWithoutTenantNamesUsesIDs(t *testing.T) { + s := newSignInStubs(t, "t-x", "t-y") + rt, _, errb := testsupport.NewRuntime(t, "", "text") + if err := login(t, rt, s, "--api-url", "http://127.0.0.1:1"); err != nil { + t.Fatalf("login: %v", err) + } + if !strings.Contains(errb.String(), "Tenant: t-x\n") { + t.Errorf("stderr:\n%s", errb.String()) + } +} + +func TestBrowserLoginFailures(t *testing.T) { + t.Run("not available", func(t *testing.T) { + s := newSignInStubs(t, "t-a") + auth0For = apidefaults.Auth0For + rt, _, _ := testsupport.NewRuntime(t, "", "text") + err := login(t, rt, s) + if exitCode(err) != ExitAuth || + !strings.Contains(err.Error(), s.api) || + !strings.Contains(err.Error(), "--client-id") { + t.Fatalf("err = %v", err) + } + }) + t.Run("auth0 refuses", func(t *testing.T) { + s := newSignInStubs(t, "t-a") + s.reply = func(q url.Values) url.Values { + return url.Values{"state": {q.Get("state")}, + "error": {"access_denied"}, + "error_description": {"Service not available in your region"}} + } + rt, _, _ := testsupport.NewRuntime(t, "", "text") + err := login(t, rt, s) + if exitCode(err) != ExitAuth || + !strings.Contains(err.Error(), "not available in your region") { + t.Fatalf("err = %v", err) + } + if rt.Config.StarfleetProfile("default").IsBrowserSession() { + t.Error("a refused sign-in saved a session") + } + }) + t.Run("unreadable token", func(t *testing.T) { + s := newSignInStubs(t, "t-a") + flow := signInFlow + signInFlow = func(rt *module.Runtime, app apidefaults.Auth0App, + d time.Duration) *signin.Flow { + f := flow(rt, app, d) + srv := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{"access_token":"opaque"}`) + })) + t.Cleanup(srv.Close) + f.BaseURL = srv.URL + return f + } + rt, _, _ := testsupport.NewRuntime(t, "", "text") + if err := login(t, rt, s); exitCode(err) != ExitAuth { + t.Fatalf("err = %v", err) + } + }) + for _, tc := range []struct { + name string + args []string + }{ + {"tenant with an API client", + []string{"--client-id", "i", "--client-secret", "s", + "--tenant", "t"}}, + {"insecure storage alone", []string{"--insecure-storage"}}, + } { + t.Run(tc.name, func(t *testing.T) { + s := newSignInStubs(t, "t-a") + rt, _, _ := testsupport.NewRuntime(t, "", "text") + if err := login(t, rt, s, tc.args...); exitCode(err) != + ExitUsage { + t.Fatalf("err = %v, want exit 2", err) + } + }) + } +} + +func TestLoginSwitchesProfileKind(t *testing.T) { + s := newSignInStubs(t, "t-a") + rt, _, errb := testsupport.NewRuntime(t, "", "text") + kc := &keychain.Fake{} + rt.Keychain = kc + + if err := login(t, rt, s, "--client-id", "kid", + "--client-secret", "ks"); err != nil { + t.Fatalf("client login: %v", err) + } + if _, err := kc.Get(conn.Store(rt).KeychainAccount()); err != nil { + t.Fatalf("client secret not in the keychain: %v", err) + } + + if err := login(t, rt, s); err != nil { + t.Fatalf("browser login: %v\n%s", err, errb.String()) + } + p := rt.Config.StarfleetProfile("default") + if p.ClientID != "" || p.ClientSecret != "" || !p.IsBrowserSession() { + t.Errorf("after browser login, profile = %+v", p) + } + if _, err := kc.Get(conn.Store(rt).KeychainAccount()); !errors.Is(err, + keychain.ErrNotFound) { + t.Errorf("the API client's secret survived: %v", err) + } + + if err := login(t, rt, s, "--client-id", "kid2", + "--client-secret", "ks2"); err != nil { + t.Fatalf("client login again: %v", err) + } + p = rt.Config.StarfleetProfile("default") + if p.IsBrowserSession() || p.TenantID != "" || p.ClientID != "kid2" { + t.Errorf("after client login, profile = %+v", p) + } + tok, err := conn.Store(rt).LoadToken() + if err != nil || tok.SessionFor(s.api) { + t.Errorf("the session token survived the switch: %+v", tok) + } +} + +func TestStatusWhoamiLogoutForASession(t *testing.T) { + s := newSignInStubs(t, "t-a", "t-b") + rt, out, errb := testsupport.NewRuntime(t, "", "text") + testsupport.SeedSession(t, rt, s.api, "u1", + time.Now().Add(time.Hour), "t-b", "t-a") + + if err := runAccount(t, rt, &bytes.Buffer{}, "auth", "status"); err != nil { + t.Fatalf("status: %v", err) + } + for _, want := range []string{"User: u1@example.com", + "Auth source: browser", "Tenant: t-b", "Token: valid"} { + if !strings.Contains(out.String(), want) { + t.Errorf("status lacks %q:\n%s", want, out.String()) + } + } + if strings.Contains(out.String(), "eyJ") { + t.Error("status printed the token") + } + + rt.Output.Format = "json" + var got whoamiReport + out.Reset() + if err := runAccount(t, rt, &bytes.Buffer{}, "auth", "whoami"); err != nil { + t.Fatalf("whoami: %v", err) + } + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("whoami json: %v\n%s", err, out.String()) + } + if s.apiHits["/account/v1/clients"] != 0 { + t.Error("whoami listed API clients for a browser session") + } + if got.User != "u1@example.com" || got.TenantID != "t-b" || + got.TenantName != "Beta" || got.Plan != "managed" || + got.TenantCount != 2 || got.ClientID != "" { + t.Errorf("whoami = %+v", got) + } + if h := s.headers[len(s.headers)-1]; h.Get(conn.SelectedTenantHeader) != "t-b" { + t.Errorf("whoami headers = %v", h) + } + rt.Output.Format = "text" + + out.Reset() + if err := runAccount(t, rt, &bytes.Buffer{}, "auth", "whoami"); err != nil { + t.Fatalf("whoami text: %v", err) + } + if !strings.Contains(out.String(), "User: u1@example.com") || + strings.Contains(out.String(), "Client ID") { + t.Errorf("whoami text:\n%s", out.String()) + } + + errb.Reset() + if err := runAccount(t, rt, &bytes.Buffer{}, "auth", "logout"); err != nil { + t.Fatalf("logout: %v", err) + } + if !strings.Contains(errb.String(), "Session token removed") { + t.Errorf("logout said %q", errb.String()) + } + cfg, _ := config.Load("") + if p := cfg.StarfleetProfile("default"); p.IsBrowserSession() || + p.TenantID != "" || p.APIURL != s.api { + t.Errorf("after logout, profile = %+v", p) + } + if _, err := conn.Store(rt).LoadToken(); err == nil { + t.Error("logout left the session token") + } +} + +func TestStatusForAnExpiredSession(t *testing.T) { + for _, tc := range []struct { + name string + seed func(t *testing.T, rt *module.Runtime, api string) + token string + }{ + {"expired", func(t *testing.T, rt *module.Runtime, api string) { + testsupport.SeedSession(t, rt, api, "u1", + time.Now().Add(-time.Minute), "t-a") + }, "Token: expired"}, + {"other API", func(t *testing.T, rt *module.Runtime, _ string) { + testsupport.SeedSession(t, rt, "https://elsewhere", "u1", + time.Now().Add(time.Hour), "t-a") + }, "Token: stale"}, + {"missing", func(t *testing.T, rt *module.Runtime, api string) { + rt.Config.SetStarfleetProfile("default", + &config.StarfleetProfile{APIURL: api, + Auth: config.AuthBrowser}) + }, "Token: not cached"}, + } { + t.Run(tc.name, func(t *testing.T) { + rt, out, _ := testsupport.NewRuntime(t, "", "text") + api := "https://api.example" + tc.seed(t, rt, api) + err := runAccount(t, rt, &bytes.Buffer{}, "auth", "status", + "--api-url", api) + if exitCode(err) != ExitAuth || + !strings.Contains(err.Error(), "auth login") { + t.Fatalf("err = %v, want exit 5", err) + } + if !strings.Contains(out.String(), tc.token) { + t.Errorf("status:\n%s", out.String()) + } + }) + } +} + +func TestDoctorForASession(t *testing.T) { + s := newSignInStubs(t, "t-a", "t-b") + rt, out, _ := testsupport.NewRuntime(t, "", "text") + testsupport.SeedSession(t, rt, s.api, "u1", + time.Now().Add(time.Hour), "t-b", "t-a") + if err := runAccount(t, rt, &bytes.Buffer{}, "doctor"); err != nil { + t.Fatalf("doctor: %v", err) + } + for _, want := range []string{"signed in as u1@example.com via browser", + "Beta (t-b, plan: managed)", "reporting the selected one"} { + if !strings.Contains(out.String(), want) { + t.Errorf("doctor lacks %q:\n%s", want, out.String()) + } + } + + rt2, out2, _ := testsupport.NewRuntime(t, "", "text") + rt2.Profile = "dev" + testsupport.SeedSession(t, rt2, s.api, "u1", + time.Now().Add(-time.Hour), "t-a") + if err := runAccount(t, rt2, &bytes.Buffer{}, "doctor"); err != nil { + t.Fatalf("doctor: %v", err) + } + if !strings.Contains(out2.String(), + "auth login --profile dev") { + t.Errorf("doctor did not point at login:\n%s", out2.String()) + } +} + +func TestSessionTenantFlagOnResourceCommands(t *testing.T) { + s := newSignInStubs(t, "t-a", "t-b") + rt, _, _ := testsupport.NewRuntime(t, "", "text") + testsupport.SeedSession(t, rt, s.api, "u1", + time.Now().Add(time.Hour), "t-a", "t-b") + if err := runAccount(t, rt, &bytes.Buffer{}, "tenant", "list", + "--tenant", "t-b"); err != nil { + t.Fatalf("tenant list: %v", err) + } + if h := s.headers[len(s.headers)-1]; h.Get(conn.SelectedTenantHeader) != "t-b" { + t.Errorf("headers = %v", h) + } + err := runAccount(t, rt, &bytes.Buffer{}, "tenant", "list", "--tenant", "t-z") + if exitCode(err) != ExitUsage || !strings.Contains(err.Error(), + "t-a, t-b") { + t.Errorf("err = %v, want a usage error listing the tenants", err) + } +} diff --git a/internal/starfleet/account/cmd/auth_test.go b/internal/starfleet/account/cmd/auth_test.go index 6569fde..ae26053 100644 --- a/internal/starfleet/account/cmd/auth_test.go +++ b/internal/starfleet/account/cmd/auth_test.go @@ -452,7 +452,7 @@ func TestAuthLoginThenStatus(t *testing.T) { defer srv.Close() rt, out, _ := testsupport.NewRuntime(t, "myid\nmysecret\n", "text") - if err := runAccount(t, rt, out, "auth", "login", + if err := runAccount(t, rt, out, "auth", "login", "--api-client", "--api-url", srv.URL); err != nil { t.Fatalf("auth login: %v", err) } @@ -610,14 +610,14 @@ func TestAuthLoginHalfFlagPairIsUsageError(t *testing.T) { } } -// TestAuthLoginStillPromptsWithoutFlags is the other direction of the +// TestAuthLoginAPIClientPrompts is the other direction of the // same change: honouring the flags must not cost the interactive path. // It also pins that `login` does NOT fall through to config the way // conn.Resolve does — a complete pair is stored in the profile config // here, and login must still ask, because someone running `login` is // signing in as somebody, and answering that from ambient state would // silently re-save whoever is already configured. -func TestAuthLoginStillPromptsWithoutFlags(t *testing.T) { +func TestAuthLoginAPIClientPrompts(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc( func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -632,12 +632,12 @@ func TestAuthLoginStillPromptsWithoutFlags(t *testing.T) { ClientSecret: "cfg-secret", }) - if err := runAccount(t, rt, out, "auth", "login", + if err := runAccount(t, rt, out, "auth", "login", "--api-client", "--api-url", srv.URL); err != nil { t.Fatalf("auth login: %v", err) } if !strings.Contains(errb.String(), "Client ID:") { - t.Errorf("login should still prompt with no flags:\n%s", + t.Errorf("login --api-client should prompt:\n%s", errb.String()) } cp := rt.Config.StarfleetProfile("default") @@ -658,7 +658,7 @@ func TestAuthLogout(t *testing.T) { defer srv.Close() rt, out, errb := testsupport.NewRuntime(t, "myid\nmysecret\n", "text") - if err := runAccount(t, rt, out, "auth", "login", + if err := runAccount(t, rt, out, "auth", "login", "--api-client", "--api-url", srv.URL); err != nil { t.Fatalf("auth login: %v", err) } @@ -712,7 +712,7 @@ func TestAuthLoginCachesUnderStarfleetModule(t *testing.T) { defer srv.Close() rt, out, _ := testsupport.NewRuntime(t, "myid\nmysecret\n", "text") - if err := runAccount(t, rt, out, "auth", "login", + if err := runAccount(t, rt, out, "auth", "login", "--api-client", "--api-url", srv.URL); err != nil { t.Fatalf("auth login: %v", err) } diff --git a/internal/starfleet/account/cmd/auth_whoami.go b/internal/starfleet/account/cmd/auth_whoami.go index 3dc9544..4be9675 100644 --- a/internal/starfleet/account/cmd/auth_whoami.go +++ b/internal/starfleet/account/cmd/auth_whoami.go @@ -16,7 +16,10 @@ import ( // the API client record's UUID, which `client get` takes, is // ClientRecordID. type whoamiReport struct { - ClientID string `json:"client_id"` + // User is a browser session's email or subject; ClientID is empty + // then. + User string `json:"user,omitempty"` + ClientID string `json:"client_id,omitempty"` ClientName string `json:"client_name,omitempty"` ClientRecordID string `json:"client_record_id,omitempty"` // Not bare `description`: this object flattens two resources. @@ -38,7 +41,9 @@ func newAuthWhoamiCmd( Short: "Show the identity the API accepts you as", Long: `whoami reports the identity the pgEdge Starfleet API answers your credential as: the named API client, and the tenant and -plan it is scoped to. +plan it is scoped to. For a browser sign-in it reports you, the +tenant you are working in and its plan, and how many tenants you can +reach. Use it to tell one profile's credential from another's, and to read the plan behind an entitlement refusal. This is the question @@ -71,6 +76,9 @@ Example: } return newExitError(err.Error(), code) } + if res.Session { + return whoamiSession(rt, cmd) + } creds := res.Creds report.APIURL = res.APIURL report.ClientID = creds.ClientID @@ -116,6 +124,37 @@ Example: } } +// whoamiSession reports a browser session: the user and the selected +// tenant. It lists no API clients, since a user is not one. +func whoamiSession(rt *module.Runtime, cmd *cobra.Command) error { + client, c, err := connClientFromCmd(rt, cmd) + if err != nil { + return err + } + report := whoamiReport{ + User: c.Session.User, + TenantID: c.Session.TenantID, + APIURL: c.APIURL, + } + tenants, err := whoamiTenants(context.Background(), client) + if err != nil { + return err + } + if len(tenants) > 0 { + t := selectedTenant(tenants, c.Session.TenantID) + report.TenantName = t.Name + report.TenantID = t.Id + report.Plan = output.DerefString(t.Plan) + if t.PlanTrial != nil { + report.PlanTrial = *t.PlanTrial + } + } + if len(tenants) > 1 { + report.TenantCount = len(tenants) + } + return printWhoami(rt, report, false, len(tenants) == 0) +} + // whoamiClientRecord returns the API client record whose auth0_id is // the credential now in use, or nil when the tenant's client list // carries no such record. @@ -188,8 +227,13 @@ func printWhoami( return rt.Output.Print(report, nil) } - fmt.Fprintf(rt.Stdout, "Client ID: %s\n", - output.Sanitize(report.ClientID)) + if report.User != "" { + fmt.Fprintf(rt.Stdout, "User: %s\n", + output.Sanitize(report.User)) + } else { + fmt.Fprintf(rt.Stdout, "Client ID: %s\n", + output.Sanitize(report.ClientID)) + } if report.ClientName != "" { fmt.Fprintf(rt.Stdout, "Client name: %s\n", output.Sanitize(report.ClientName)) From 95455a1388c0ca3445a5b18112e43fc9b606c3fb Mon Sep 17 00:00:00 2001 From: Antony Pegg Date: Mon, 19 Oct 2026 12:37:55 -0400 Subject: [PATCH 05/11] docs: describe browser sign-in and API clients The guides, the starfleet reference and the pgedge-starfleet skill now present browser sign-in as the default and an API client as the path for CI, scripts and agents, with the session's tenant, --tenant, and the exit 5 an expired session gives. The command reference is regenerated for the new flags. --- docs/auth-and-profiles.md | 48 +++++++- docs/ci.md | 4 + docs/error-catalog.md | 3 + docs/getting-started.md | 18 +-- docs/reference/starfleet.md | 6 +- internal/starfleet/llms.txt | 33 +++++- internal/starfleet/llms/auth.txt | 110 +++++++++++------- llms.txt | 4 +- .../pgedge/skills/pgedge-starfleet/SKILL.md | 25 ++-- 9 files changed, 182 insertions(+), 69 deletions(-) diff --git a/docs/auth-and-profiles.md b/docs/auth-and-profiles.md index e7d460c..bc44dba 100644 --- a/docs/auth-and-profiles.md +++ b/docs/auth-and-profiles.md @@ -1,13 +1,42 @@ # Authentication and profiles Two questions decide every call the CLI makes: who you are, and where -it points. One credential pair serves the whole `starfleet` tree: sign -in once with `pgedge starfleet auth login` and the account-level +it points. One sign-in serves the whole `starfleet` tree: sign in +once with `pgedge starfleet auth login` and the account-level commands, `byoc` and `managed` all share that connection and its cached token. The `controlplane` module has no login at all. It targets `--base-url` directly, with optional mTLS flags for a Control Plane that requires them. +## Signing in + +A profile signs in one of two ways: + +- **As you, through the browser.** Run `pgedge starfleet auth login` + with no credential flags. It opens your browser on the pgEdge + sign-in page and prints the same URL, so you can open it by hand + when no browser starts. The CLI then calls pgEdge Starfleet as you, + with the same access as the console, and creates nothing on the + server. +- **As an API client.** Pass `--client-id` and `--client-secret`, or + `--api-client` to be prompted for them. This is the path for CI and + scripts, because it needs no browser and the token renews itself. + +A browser session lasts until the time login prints. After that, +commands fail with exit status 5 and name the login command to run +again. A profile holds one kind of credential, so signing in one way +removes what the other way stored. + +When you belong to several tenants, login asks which one to use, or +uses your account's default tenant when it cannot ask. Pass +`--tenant` with a tenant ID or name to choose without the question. +To work in another of your tenants for one command, pass `--tenant` +with its ID to that command. + +Browser sign-in works with the pgEdge Starfleet API endpoints that +have a sign-in app. For any other `--api-url`, login reports that the +browser path is not available there; sign in with an API client. + ## How credentials resolve Three sources, in priority order: @@ -16,7 +45,8 @@ Three sources, in priority order: - the `PGEDGE_CLIENT_ID` and `PGEDGE_CLIENT_SECRET` environment variables. - the active profile: its `starfleet` section in - `~/.pgedge/cli/config.yaml`, with the secret in the OS keychain. + `~/.pgedge/cli/config.yaml`, which holds either a browser session or + an API client with its secret in the OS keychain. The two flags must be supplied together. One without the other is a usage error at exit 2, not a partial override that falls back to @@ -32,7 +62,8 @@ that change something else. ## Storing the Client Secret -`pgedge starfleet auth login` saves the client secret in the OS +`pgedge starfleet auth login` with an API client saves the client +secret in the OS keychain: Keychain on macOS, the Secret Service on Linux, and Credential Manager on Windows. The client ID and the API URL go in `~/.pgedge/cli/config.yaml`. Other commands read the secret back from @@ -112,7 +143,9 @@ the file and safe to mix binary versions. ## A profile is a tenant A profile carries one Starfleet credential, and an API client belongs -to one tenant, so selecting a profile selects a tenant. The `byoc` +to one tenant, so selecting a profile selects a tenant. A browser +session stores the tenant it works in, so it selects one too, and +`--tenant` changes it for one command. The `byoc` and `managed` sub-trees borrow that same credential rather than holding one of their own, which has two consequences: @@ -135,7 +168,10 @@ credential's own ID, the API client's name, description and record UUID, then the tenant's name and UUID, the plan, a tenant count on the rare credential that reaches more than one, and the API URL in effect. A trial plan reads as the plan name followed by `(trial)`. `-o json` -and `-o yaml` carry the same facts under the same keys. +and `-o yaml` carry the same facts under the same keys. For a browser +session it opens with your email in place of the client lines, and +reports the tenant you are working in and how many tenants you can +reach. pgedge starfleet auth whoami pgedge starfleet auth whoami -o json diff --git a/docs/ci.md b/docs/ci.md index a15d5b0..6ddee33 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -15,6 +15,10 @@ The page leans on these terms: ## Supplying Credentials +An unattended run signs in with an API client, never through the +browser: a browser sign-in needs a person at the sign-in page, and its +session ends without renewing itself. + Set `PGEDGE_CLIENT_ID` and `PGEDGE_CLIENT_SECRET` from your secret store. The CLI treats the pair as a profile that lasts one invocation. Each run authenticates from the secrets alone and leaves the runner's diff --git a/docs/error-catalog.md b/docs/error-catalog.md index 4607b46..81f6da5 100644 --- a/docs/error-catalog.md +++ b/docs/error-catalog.md @@ -29,6 +29,9 @@ describes the messages both steps print: | `no credentials found — use --client-id/--client-secret, or run 'pgedge starfleet auth login'` | 5 | No profile carries a client ID and secret, and neither flag was passed. Log in, or supply both flags. | [Authentication and profiles](auth-and-profiles.md) | | `--client-id given without --client-secret: supply both or neither` | 2 | Half a credential pair reached the command. Pass both flags, or drop both and let the profile answer. | [Authentication and profiles](auth-and-profiles.md) | | `authentication failed: ` | 5 | The token exchange did not complete. The reason names the cause, most often a refused credential or a token endpoint that could not be reached. | [Authentication and profiles](auth-and-profiles.md) | +| `the browser sign-in for has expired or is missing: run 'pgedge starfleet auth login' to sign in again` | 5 | The profile's browser session ended, or its token was signed in for another API URL. Sign in again; for unattended runs, use an API client. | [Authentication and profiles](auth-and-profiles.md) | +| `browser sign-in is not available for yet` | 5 | This API endpoint has no browser sign-in. Sign in with an API client: `--client-id` and `--client-secret`, or `--api-client`. | [Authentication and profiles](auth-and-profiles.md) | +| `--tenant is not a tenant this sign-in can reach; reachable tenant IDs: ` | 2 | The browser session's user does not belong to that tenant. Pick one of the IDs listed. | [Authentication and profiles](auth-and-profiles.md) | | `authentication error (401): ` | 5 | The API rejected the bearer token. Run `pgedge starfleet auth logout` and log in again to rebuild the cache. | [Authentication and profiles](auth-and-profiles.md) | | `authentication error (403): ` | 5 | The credential authenticates and the tenant is not permitted to do this. Confirm which tenant is active before changing the credential. | [Health checks](doctor.md) | | `this tenant's plan does not allow this resource: the active credential authenticates fine, but its tenant's plan doesn't include this capability.` | 5 | Plan entitlement rather than a bad credential, so no login fixes it. Stop any retry loop and ask about the tenant's plan. | [Exit codes](exit-codes.md) | diff --git a/docs/getting-started.md b/docs/getting-started.md index 2cf37cc..8353eb1 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -44,10 +44,10 @@ describes each one and what it needs. ## Running Your First Commands -Sign in once per profile. Login prompts for the client ID and secret. -For unattended use, pass `--client-id` and `--client-secret` instead. -Login caches a token and saves the secret in the OS keychain. It saves -the rest of the profile in `~/.pgedge/cli/config.yaml`: +Sign in once per profile. Login opens your browser on the pgEdge +sign-in page, and from then on the CLI works as you, in your tenant, +until the session expires. Login prints when that is; run it again +after that. It saves the profile in `~/.pgedge/cli/config.yaml`: pgedge starfleet auth login --profile dev @@ -55,9 +55,13 @@ This login creates the `dev` profile. Login is the one command that accepts a new `--profile` name. Every other command accepts only a configured profile name, or the built-in `default`. -One credential pair serves the whole `starfleet` module. That covers -its account-level commands, byoc and managed, because all three share -one connection and one token. +For unattended use, such as CI, sign in with an API client instead: +pass `--client-id` and `--client-secret`. Login then saves the secret +in the OS keychain. [CI and automation](ci.md) covers that path. + +One sign-in serves the whole `starfleet` module. That covers its +account-level commands, byoc and managed, because all three share one +connection and one token. To check the installation and the connection, run: diff --git a/docs/reference/starfleet.md b/docs/reference/starfleet.md index 913fdda..32de094 100644 --- a/docs/reference/starfleet.md +++ b/docs/reference/starfleet.md @@ -31,6 +31,7 @@ Manage pgEdge Starfleet | `--api-url string` | No | | pgEdge Starfleet API base URL (default https://api.pgedge.com) | | `--client-id string` | No | | API client ID (overrides profile config) | | `--client-secret string` | No | | API client secret (overrides profile config) | +| `--tenant string` | No | | Tenant ID a browser sign-in acts in (overrides the profile's) | | `--timeout duration` | No | `30s` | Per-request timeout (Go duration; 0 disables) | **Example:** @@ -89,15 +90,16 @@ Authenticate with pgEdge Starfleet | Flag | Required | Default | Description | |------|----------|---------|-------------| +| `--api-client` | No | | sign in with an API client, prompting for its ID and secret | | `--insecure-storage` | No | | store the client secret in the config file, not the OS keychain | **Example:** ``` pgedge starfleet auth login -pgedge starfleet auth login --api-url https://api.pgedge.com +pgedge starfleet auth login --tenant TENANT_ID pgedge starfleet auth login --client-id ID --client-secret SECRET -pgedge starfleet auth login --insecure-storage +pgedge starfleet auth login --api-client --insecure-storage ``` #### pgedge starfleet auth logout diff --git a/internal/starfleet/llms.txt b/internal/starfleet/llms.txt index 1d61749..69e4924 100644 --- a/internal/starfleet/llms.txt +++ b/internal/starfleet/llms.txt @@ -29,15 +29,32 @@ belongs to byoc and managed writes only. ### Authentication +A profile signs in one of two ways: + +- **As a user, through the browser.** `pgedge starfleet auth login` + with no credential flags opens the browser, and every command then + runs as that user, in the tenant the profile names, until the + session expires. Nothing renews it: once it expires, a command + fails at exit 5 and tells the user to run `auth login` again. An + agent cannot complete a browser sign-in, so ask the user to run it. +- **As an API client,** for CI, scripts and agents. Supply a client + ID and secret through one of the sources below. + Three credential sources, in priority order: 1. CLI flags: `--client-id` / `--client-secret` (persistent flags on `pgedge starfleet`) 2. Environment: `PGEDGE_CLIENT_ID` / `PGEDGE_CLIENT_SECRET` 3. Profile: the active profile's `starfleet` section in - `~/.pgedge/cli/config.yaml`, with the secret in the OS keychain - (or in that section's `client_secret` when `auth login` could not - use a keychain) + `~/.pgedge/cli/config.yaml`: a browser session, or an API client + with the secret in the OS keychain (or in that section's + `client_secret` when `auth login` could not use a keychain) + +A browser session acts in the profile's `tenant_id`. `--tenant ` +picks another tenant the user belongs to for one command; an ID the +session cannot reach is exit 2, and the message lists the reachable +IDs. An API client is scoped to one tenant, so `--tenant` alongside +one is exit 2. The two flags must be supplied together. One without the other is a usage error (exit 2, `--client-id given without --client-secret`). The @@ -56,13 +73,16 @@ file still fails the command. The token is cached at `~/.pgedge/cli/cache/-starfleet.json`, bound to the credential and API base URL that minted it. Change either, in the profile or with a flag, and the next command exchanges a fresh -token, so a token only ever goes to the endpoint that minted it. +token, so a token only ever goes to the endpoint that minted it. A +browser session's token is bound to its API URL and user, and a +command with another `--api-url` fails at exit 5 rather than sending +it there. `pgedge starfleet auth logout` removes the active profile's cache file and keychain entry, and leaves other profiles' tokens alone. ### Starfleet Module Flags -The four connection flags are declared on `pgedge starfleet` and +The five connection flags are declared on `pgedge starfleet` and inherited by every subcommand. The `pgedge starfleet` command reference below has the flag table. Combine with the global `-o`/`--output` flag (text, json, yaml) for machine-readable output. @@ -102,6 +122,7 @@ Manage pgEdge Starfleet | `--api-url string` | No | | pgEdge Starfleet API base URL (default https://api.pgedge.com) | | `--client-id string` | No | | API client ID (overrides profile config) | | `--client-secret string` | No | | API client secret (overrides profile config) | +| `--tenant string` | No | | Tenant ID a browser sign-in acts in (overrides the profile's) | | `--timeout duration` | No | `30s` | Per-request timeout (Go duration; 0 disables) | @@ -118,7 +139,7 @@ pgedge starfleet pgedge starfleet --help ``` -Flags: --api-url, --client-id, --client-secret, --timeout +Flags: --api-url, --client-id, --client-secret, --tenant, --timeout Expected: the module description, usage and flag list on stdout, exit 0. diff --git a/internal/starfleet/llms/auth.txt b/internal/starfleet/llms/auth.txt index b21acc7..7db6b5a 100644 --- a/internal/starfleet/llms/auth.txt +++ b/internal/starfleet/llms/auth.txt @@ -28,21 +28,43 @@ Authenticate with pgEdge Starfleet | Flag | Required | Default | Description | |------|----------|---------|-------------| +| `--api-client` | No | | sign in with an API client, prompting for its ID and secret | | `--insecure-storage` | No | | store the client secret in the config file, not the OS keychain | -Takes a client ID and secret, exchanges them for an access token, and -saves them to the active profile. The secret goes to the OS keychain, -and the client ID and API URL go to `~/.pgedge/cli/config.yaml`. Run -this once per profile: the credentials -serve the whole starfleet tree, and the account-level commands, byoc and -managed all share that one connection and one token. - -**This command is scriptable.** Pass both `--client-id` and -`--client-secret` and it never reads stdin, so it works with no -terminal attached. Omit both and it prompts for each in turn. The -secret is never echoed in either case. +Signs the active profile in, as a user through the browser or as an +API client. Run this once per profile: the sign-in serves the whole +starfleet tree, and the account-level commands, byoc and managed all +share that one connection and one token. A profile holds one kind of +credential, so signing in one way removes what the other way stored. + +**With no credential flags, login signs you in through the browser.** +It opens the pgEdge sign-in page and prints the same URL on stderr, +then waits for the browser to return. A person has to complete the +page, so an agent asks the user to run this command. The profile +stores `api_url`, `auth: browser` and `tenant_id`; the user's access +token goes to the token cache. Every later command runs as that user +until the session expires. Nothing renews it, so after that a command +fails at exit 5 and names the `auth login` command to run. + +A user in several tenants is asked which to use when stdin and stdout +are a terminal. Otherwise login uses the account's default tenant and +says so on stderr. `--tenant` takes a tenant ID or name and skips the +question; one the user cannot reach is exit 2. Every sign-in failure +is exit 5: a refusal from the sign-in page (its reason is printed), +no answer from the browser, all three callback ports +(8484-8486) in use, or an API URL that has no browser sign-in. For +that last case, sign in with an API client instead. + +**With an API client, this command is scriptable.** Pass both +`--client-id` and `--client-secret` and it never reads stdin, so it +works with no terminal attached. `--api-client` prompts for each in +turn instead. The secret is never echoed in either case. login +exchanges them for an access token and saves them to the active +profile: the secret goes to the OS keychain, and the client ID and API +URL go to `~/.pgedge/cli/config.yaml`. `--tenant` with an API client +is exit 2, because a client is scoped to one tenant. Passing only one of the pair is a usage error (exit 2), not a prompt for the other: completing a half pair from a prompt would let a @@ -51,8 +73,8 @@ different identity. Supply both or neither. Unlike every other command, `login` does **not** fall back to the stored profile when the flags are absent — it is the command that -writes that config, so it asks rather than answering "who are you -signing in as?" from state that is already there. It ignores +writes that config, so it signs in afresh rather than answering "who +are you signing in as?" from state that is already there. It ignores `PGEDGE_CLIENT_ID` and `PGEDGE_CLIENT_SECRET` too, and warns on stderr when either is set, because every other command then uses the variables instead of the profile it just saved. @@ -60,29 +82,24 @@ variables instead of the profile it just saved. **Where the secret goes.** When no keychain can be used, `login` writes the secret to the config file instead, prints a warning on stderr naming the cause, and still ends with exit status 0. Pass `--insecure-storage` -to write it to the config file without trying the keychain. A later +to write it to the config file without trying the keychain; it +applies only to an API client, so without one it is exit 2. A later `login` to the keychain removes a secret the file held. **Example:** ```bash pgedge starfleet auth login -pgedge starfleet auth login --api-url https://api.pgedge.com +pgedge starfleet auth login --tenant TENANT_ID pgedge starfleet auth login --client-id ID --client-secret SECRET -pgedge starfleet auth login --insecure-storage +pgedge starfleet auth login --api-client --insecure-storage ``` -**Example output:** - -``` -Client ID: my-client-id -Client Secret: -Authenticated. Token expires 2026-12-31T23:59:59Z. -Client secret saved to the OS keychain; client ID and API URL saved to /Users/alice/.pgedge/cli/config.yaml -``` - -With both flags supplied there is no prompt, and the first two lines -are absent. +A browser sign-in ends with four lines on stderr: who signed in +(`Signed in as .`), the tenant, when the session ends, and the +file the profile was saved to. An API-client sign-in ends with +`Authenticated. Token expires