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..0a77da5 100644 --- a/docs/error-catalog.md +++ b/docs/error-catalog.md @@ -29,10 +29,13 @@ 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) | -| ` needs a signed-in user. The CLI authenticates with a client ID and secret, which identifies an application rather than a person` | 5 | Team invites need a person behind the token, which a client credential never supplies. Invite from the Team page in the pgEdge Starfleet UI, as the message says. | [Team onboarding](starfleet/team-onboarding.md) | +| ` needs a signed-in user in the pgEdge Starfleet UI` | 5 | Team invites need a person behind the token. An API client never supplies one, and the CLI does not send these requests for a browser sign-in either. Invite from the Team page in the pgEdge Starfleet UI, as the message says. | [Team onboarding](starfleet/team-onboarding.md) | | `client created but the API returned no body — the client secret is unrecoverable; delete the client and retry` | 1 | The API client exists and its secret was never printed. Delete the client and create it again. | [Account, tenant and API clients](starfleet/account-and-clients.md) | ## Profiles and the config file 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/docs/starfleet/team-onboarding.md b/docs/starfleet/team-onboarding.md index 48dd2cf..8cc2699 100644 --- a/docs/starfleet/team-onboarding.md +++ b/docs/starfleet/team-onboarding.md @@ -19,12 +19,12 @@ refuses outright: | `membership list` | Yes | | `membership delete ` | Yes | -`invite create` and `invite accept` both need a signed-in user. The -CLI authenticates with a client ID and secret, which names an -application rather than a person, so Starfleet refuses those two -operations whatever the credentials are. Both commands recognize that -before sending anything and exit 5 with a message pointing at the UI. -No `-o` value changes it, and a retry cannot help, because the 5 is a +`invite create` and `invite accept` both need a signed-in user in the +pgEdge Starfleet UI. An API client names an application rather than a +person, so Starfleet refuses it those two operations, and the CLI +does not send them for a browser sign-in either. Both commands exit 5 +before sending anything, with a message pointing at the UI. No `-o` +value changes it, and a retry cannot help, because the 5 is a property of the operation rather than of your credentials. ## Invite a teammate diff --git a/internal/apidefaults/apidefaults_test.go b/internal/apidefaults/apidefaults_test.go index f44f4fc..7b68bed 100644 --- a/internal/apidefaults/apidefaults_test.go +++ b/internal/apidefaults/apidefaults_test.go @@ -30,3 +30,13 @@ func TestResolveStarfleetAPIURL(t *testing.T) { }) } } + +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..90cca54 --- /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: "SCexy9LhkGBmBGrBeH2K9jeapuKb6kQb", + }, + "https://devapi.pgedge.com": { + Domain: "devauth.pgedge.com", + Audience: "https://devapi.pgedge.com", + ClientID: "ZHg4rTGZCCZf1DpaWdj2agAm7aDRvCUj", + }, +} + +// 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..166a7a0 --- /dev/null +++ b/internal/auth/claims_test.go @@ -0,0 +1,55 @@ +package auth + +import ( + "encoding/base64" + "testing" + "time" +) + +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 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") + } +} diff --git a/internal/clitest/stderr_sanitize_test.go b/internal/clitest/stderr_sanitize_test.go index 2e675a9..224671a 100644 --- a/internal/clitest/stderr_sanitize_test.go +++ b/internal/clitest/stderr_sanitize_test.go @@ -297,8 +297,10 @@ var stderrComposedMessages = map[string]struct { // 195 -> 198: byoc `node logs` prints time, level and message. // 198 -> 199: a branch link's `Using branch` line. // 199 -> 200: `branch get` prints the display name. +// 200 -> 211: browser sign-in's URL, tenant prompt and summary lines, +// and whoami's `User:` line. const ( - stderrArgSites = 200 + stderrArgSites = 211 stderrComposedSites = 22 ) 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. diff --git a/internal/httplog/form_test.go b/internal/httplog/form_test.go new file mode 100644 index 0000000..496e962 --- /dev/null +++ b/internal/httplog/form_test.go @@ -0,0 +1,34 @@ +package httplog + +import ( + "strings" + "testing" +) + +func TestRedactFormMasksTheCodeExchange(t *testing.T) { + got := RedactForm([]byte("grant_type=authorization_code&client_id=cid"+ + "&code=SECRETCODE&redirect_uri=http%3A%2F%2F127.0.0.1&"+ + "code_verifier=SECRETVERIFIER&client_secret=SECRETSECRET"), 4096) + for _, leak := range []string{"SECRETCODE", "SECRETVERIFIER", + "SECRETSECRET"} { + if strings.Contains(got, leak) { + t.Errorf("form dump leaks %s: %s", leak, got) + } + } + if !strings.HasPrefix(got, "grant_type=authorization_code&"+ + "client_id=cid&code="+mask) { + t.Errorf("form dump = %s", got) + } + if got := RedactForm([]byte("%zz=1"), 100); !strings.Contains(got, + "redacted") { + t.Errorf("an unreadable form should be redacted whole: %s", got) + } +} + +func TestRedactBodyMasksAnIDToken(t *testing.T) { + got := RedactBody([]byte(`{"access_token":"AT","id_token":"IDT",`+ + `"expires_in":1}`), 4096) + if strings.Contains(got, "IDT") || strings.Contains(got, `"AT"`) { + t.Errorf("token response dump leaks: %s", got) + } +} diff --git a/internal/httplog/httplog.go b/internal/httplog/httplog.go index 0b8dcbc..8f793ca 100644 --- a/internal/httplog/httplog.go +++ b/internal/httplog/httplog.go @@ -13,6 +13,8 @@ import ( "io" "net/http" "net/textproto" + "net/url" + "slices" "sort" "strings" "time" @@ -101,7 +103,8 @@ var maskedHeaders = map[string]bool{ // must be added here or listed in notSecrets with a reason. var secretFieldNames = []string{ // Auth: the token endpoint and stored client credentials. - "access_token", "refresh_token", "client_secret", "auth0_secret", + "access_token", "refresh_token", "id_token", "client_secret", + "auth0_secret", // Database and role credentials. "password", // Cloud-provider credentials, byoc and controlplane. @@ -150,7 +153,12 @@ func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { var raw []byte raw, send = requestBody(req) if len(raw) > 0 { - fmt.Fprintf(t.Out, "> %s\n", RedactBody(raw, MaxDumpBytes)) + body := RedactBody(raw, MaxDumpBytes) + if strings.HasPrefix(req.Header.Get("Content-Type"), + "application/x-www-form-urlencoded") { + body = RedactForm(raw, MaxDumpBytes) + } + fmt.Fprintf(t.Out, "> %s\n", body) } } else if req.Header.Get("Authorization") != "" { fmt.Fprintf(t.Out, "> Authorization: %s\n", mask) @@ -300,6 +308,28 @@ func RedactBody(raw []byte, limit int) string { return wholeBodySummary(doc) } +// formSecretNames are the form fields of an OAuth code exchange that +// would let a reader of the log finish it. +var formSecretNames = []string{"code", "code_verifier"} + +// RedactForm is RedactBody for a form-encoded body: the value of every +// secret field, JSON names included, is masked, and the field order is +// kept. +func RedactForm(raw []byte, limit int) string { + parts := strings.Split(string(raw), "&") + for i, p := range parts { + k, _, _ := strings.Cut(p, "=") + name, err := url.QueryUnescape(k) + if err != nil { + return "" + } + if isSecretField(name) || slices.Contains(formSecretNames, name) { + parts[i] = k + "=" + mask + } + } + return truncate([]byte(strings.Join(parts, "&")), limit) +} + // wholeBodySummary is the fail-closed fallback: it says what shape // arrived without saying what was in it. func wholeBodySummary(doc any) string { diff --git a/internal/starfleet/account/cmd/auth.go b/internal/starfleet/account/cmd/auth.go index bfca1be..6ea43c4 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,12 @@ Example: } return conn.NewExitError(err.Error(), code) } + if res.Session { + return sessionStatus(rt, res, f.Tenant) + } + if f.Tenant != "" { + return conn.ErrTenantWithAPIClient() + } creds, source, apiURL := res.Creds, res.Source, res.APIURL report := authStatusReport{ @@ -326,6 +380,88 @@ Example: } } +// sessionState is what status and doctor read from a browser session's +// cache, and the failure a command would hit with it, if any. +type sessionState struct { + tok *auth.CachedToken + valid, bound bool + tenant string + problem error +} + +// readSession checks a session the way conn.Resolve would, without +// calling the API. +func readSession(rt *module.Runtime, res *conn.Resolved, + flagTenant string) sessionState { + var s sessionState + expired := conn.SessionExpiredError(rt, res.APIURL) + tok, err := conn.Store(rt).LoadToken() + if err != nil { + s.problem = expired + return s + } + s.tok = tok + s.valid = time.Until(tok.ExpiresAt) > auth.RefreshWindow + s.bound = tok.SessionFor(res.APIURL) + claims, err := auth.ParseClaims(tok.AccessToken) + if !s.valid || !s.bound || err != nil { + s.problem = expired + return s + } + s.tenant, err = conn.SessionTenant(rt, res.TenantID, flagTenant, claims) + if err != nil { + s.problem = err + } + return s +} + +// sessionStatus reports a browser session. Unlike an API client, an +// expired session cannot be renewed on demand, so it exits 5; a tenant +// the token cannot reach exits 2. +func sessionStatus( + rt *module.Runtime, res *conn.Resolved, flagTenant string, +) error { + s := readSession(rt, res, flagTenant) + report := authStatusReport{ + Authenticated: s.problem == nil, + Source: res.Source, + APIURL: res.APIURL, + TenantID: s.tenant, + TokenValid: s.valid, + TokenBound: s.bound, + } + if s.tok != nil { + report.User = s.tok.User + report.ExpiresAt = s.tok.ExpiresAt.Format(time.RFC3339) + } + if s.problem != nil { + report.Problem = s.problem.Error() + } + 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)), + } + switch { + case s.tok == nil: + lines = append(lines, "Token: not cached") + case !s.bound: + lines = append(lines, + "Token: stale (signed in for a different API URL)") + case !s.valid: + lines = append(lines, "Token: expired") + default: + lines = append(lines, fmt.Sprintf( + "Token: valid (expires %s)", report.ExpiresAt)) + } + if pErr := printAuthStatus( + rt, report, strings.Join(lines, "\n")+"\n"); pErr != nil { + return pErr + } + return s.problem +} + // printAuthStatus renders report through rt.Output for json/yaml, or // writes text verbatim otherwise. func printAuthStatus( @@ -343,10 +479,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 +498,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 +510,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..f3f70dc --- /dev/null +++ b/internal/starfleet/account/cmd/auth_browser.go @@ -0,0 +1,270 @@ +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. + // Saved before the secret goes, so a failed save keeps a working + // API client. + old := rt.Config.StarfleetProfile(rt.Profile) + 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) + } + 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())) + } + } + + 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..5f473dc --- /dev/null +++ b/internal/starfleet/account/cmd/auth_browser_test.go @@ -0,0 +1,408 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/pgEdge/pgedge-cli/internal/apidefaults" + "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 + issued atomic.Value // the access and ID tokens, space-joined + reply func(q url.Values) url.Values + apiHits map[string]int + headers []http.Header +} + +// stubCode is the authorization code the fake browser returns. +const stubCode = "auth-code-7f3a9c" + +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": {stubCode}, "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")) + at := testsupport.SessionToken(t, "auth0|u1", + time.Now().Add(24*time.Hour), s.tenants...) + it := testsupport.FakeJWT(t, + map[string]any{"email": "user@example.com"}) + s.issued.Store(at + " " + it) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": at, "id_token": it, "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, + HTTPClient: conn.SignInHTTPClient(rt, 5*time.Second), + 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 TestBrowserLoginDebugOutputCarriesNoSecrets(t *testing.T) { + s := newSignInStubs(t, "t-a") + rt, _, errb := testsupport.NewRuntime(t, "", "text") + rt.Debug = true + if err := login(t, rt, s); err != nil { + t.Fatalf("login: %v", err) + } + if !strings.Contains(errb.String(), "/oauth/token") { + t.Fatalf("--debug did not log the exchange:\n%s", errb.String()) + } + issued, _ := s.issued.Load().(string) + verifier, _ := s.verifier.Load().(string) + secrets := append(strings.Fields(issued), verifier, stubCode) + for _, secret := range secrets { + if secret == "" || strings.Contains(errb.String(), secret) { + t.Errorf("--debug output carries %q", secret) + } + } +} + +func TestBrowserLoginTenantChoice(t *testing.T) { + for _, tc := range []struct { + name string + args []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: "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...) + rt, _, errb := testsupport.NewRuntime(t, "", "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 TestBrowserLoginFailures(t *testing.T) { + 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("tenant with an API client", func(t *testing.T) { + s := newSignInStubs(t, "t-a") + rt, _, _ := testsupport.NewRuntime(t, "", "text") + if err := login(t, rt, s, "--client-id", "i", "--client-secret", + "s", "--tenant", "t"); exitCode(err) != ExitUsage { + t.Fatalf("err = %v, want exit 2", err) + } + }) +} + +func TestBrowserLoginKeepsTheSecretWhenTheSaveFails(t *testing.T) { + s := newSignInStubs(t, "t-a") + rt, _, _ := 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) + } + dir := filepath.Dir(rt.Config.Path()) + if err := os.Chmod(dir, 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(dir, 0o700) }) + if err := login(t, rt, s); err == nil { + t.Fatal("login succeeded with an unwritable config") + } + if _, err := kc.Get(conn.Store(rt).KeychainAccount()); err != nil { + t.Errorf("a failed save removed the API client's secret: %v", 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 TestWhoamiLogoutForASession(t *testing.T) { + s := newSignInStubs(t, "t-a", "t-b") + rt, out, _ := testsupport.NewRuntime(t, "", "json") + testsupport.SeedSession(t, rt, s.api, "u1", + time.Now().Add(time.Hour), "t-b", "t-a") + + var got whoamiReport + 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.TenantID != "t-b" || 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) + } + + if err := runAccount(t, rt, &bytes.Buffer{}, "auth", "logout"); err != nil { + t.Fatalf("logout: %v", err) + } + 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 TestTenantFlagIsValidatedByStatusAndDoctor(t *testing.T) { + s := newSignInStubs(t, "t-a", "t-b") + for _, tc := range []struct { + name string + session bool + want string + }{ + {"session, unreachable tenant", true, "t-a, t-b"}, + {"API client", false, "--tenant applies only"}, + } { + t.Run(tc.name, func(t *testing.T) { + rt, out, _ := testsupport.NewRuntime(t, "", "json") + args := []string{"--tenant", "t-z"} + if tc.session { + testsupport.SeedSession(t, rt, s.api, "u1", + time.Now().Add(time.Hour), "t-a", "t-b") + } else { + args = append(args, "--client-id", "i", + "--client-secret", "x", "--api-url", s.api) + } + err := runAccount(t, rt, &bytes.Buffer{}, + append([]string{"auth", "status"}, args...)...) + if exitCode(err) != ExitUsage || + !strings.Contains(err.Error(), tc.want) { + t.Errorf("status err = %v, want exit 2", err) + } + + out.Reset() + if err := runAccount(t, rt, &bytes.Buffer{}, + append([]string{"doctor"}, args...)...); err != nil { + t.Fatalf("doctor: %v", err) + } + var got accountDoctorReport + _ = json.Unmarshal(out.Bytes(), &got) + if got.Auth.Authenticated || + !strings.Contains(got.Auth.Problem, tc.want) { + t.Errorf("doctor auth = %+v", got.Auth) + } + }) + } +} 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)) 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..9188074 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,19 @@ 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 listed tenant whose ID is id, else one +// carrying only the ID, so a report names the tenant requests use. +func selectedTenant(tenants []api.Tenant, id string) api.Tenant { + for _, t := range tenants { + if strings.EqualFold(t.Id, id) { + return t + } + } + return api.Tenant{Id: id} } // accountDoctorReport is the Starfleet connection's report: credential @@ -143,6 +159,13 @@ func checkAuth(rt *module.Runtime, f *conn.Flags) authInfo { if res.Env { return info } + if res.Session { + return checkSessionAuth(rt, res, f.Tenant, info) + } + if f.Tenant != "" { + return authInfo{Source: res.Source, + Problem: conn.ErrTenantWithAPIClient().Error()} + } tok, err := conn.Store(rt).LoadToken() if err != nil { @@ -156,6 +179,25 @@ 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, res *conn.Resolved, + flagTenant string, info authInfo, +) authInfo { + s := readSession(rt, res, flagTenant) + if s.tok != nil { + info.User = s.tok.User + info.ExpiresAt = s.tok.ExpiresAt.Format(time.RFC3339) + } + info.TokenValid, info.TokenBound = s.valid, s.bound + if s.problem != nil { + info.Authenticated = false + info.Problem = s.problem.Error() + } + return info +} + func checkAPI(rt *module.Runtime, f *conn.Flags) apiInfo { apiURL := conn.ResolveAPIURL( rt.Config.StarfleetProfile(rt.Profile), f.APIURL) @@ -210,8 +252,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 +273,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 @@ -261,11 +308,19 @@ func tenantCheckRow(info tenantInfo) output.CheckRow { if plan == "" { plan = "unknown" } - detail := fmt.Sprintf("%s (%s, plan: %s)", info.Name, info.ID, plan) + name := info.Name + if name == "" { + name = "a tenant missing from the tenant list" + } + detail := fmt.Sprintf("%s (%s, plan: %s)", name, info.ID, plan) 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 +365,13 @@ 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.Authenticated: + authStatus = "ok" + authDetail = fmt.Sprintf("signed in as %s via browser "+ + "(expires %s)", output.Sanitize(report.Auth.User), + report.Auth.ExpiresAt) 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.go b/internal/starfleet/account/cmd/invite.go index c3d9fef..d43df6e 100644 --- a/internal/starfleet/account/cmd/invite.go +++ b/internal/starfleet/account/cmd/invite.go @@ -24,15 +24,15 @@ var inviteColumns = []string{ // the empty user ID. The same reason is why `pgedge starfleet user` // does not exist. // -// If the CLI gains an interactive user login, delete this guard. -// TestInviteGuardPremiseStillHolds fails if the auth surface grows in -// a way that suggests it has. +// A browser sign-in does carry a user, but the guard stays for both +// kinds of sign-in until invites are verified with one (a follow-up). +// TestInviteGuardPremiseStillHolds pins that state. func errUserSessionRequired(verb, alternative string) error { return newExitError(fmt.Sprintf( - "%s needs a signed-in user. The CLI authenticates with a "+ - "client ID and secret, which identifies an application "+ - "rather than a person, so pgEdge Starfleet rejects this "+ - "operation whatever the credentials. %s", + "%s needs a signed-in user in the pgEdge Starfleet UI. An "+ + "API client identifies an application rather than a "+ + "person, so pgEdge Starfleet refuses it, and the CLI does "+ + "not send this request for a browser sign-in either. %s", verb, alternative), ExitAuth) } @@ -182,10 +182,11 @@ func newInviteCreateCmd(_ *module.Runtime) *cobra.Command { Short: "Create an invite (needs the web UI)", Long: `create sends a team invite to an email address. -This needs a signed-in user, so it cannot run with a CLI client ID -and secret: pgEdge Starfleet identifies that as an application rather -than a person and refuses the request. Invite teammates from the -Team page in the pgEdge Starfleet UI instead. Exits 5. +This needs a signed-in user in the pgEdge Starfleet UI. An API client +identifies an application rather than a person, so pgEdge Starfleet +refuses it, and the CLI does not send the request for a browser +sign-in either. Invite teammates from the Team page in the pgEdge +Starfleet UI instead. Exits 5. 'invite list', 'invite get' and 'invite delete' do work here. @@ -272,10 +273,10 @@ func newInviteAcceptCmd(_ *module.Runtime) *cobra.Command { Short: "Accept an invite (needs the web UI)", Long: `accept joins the team an invite was issued for. -This needs a signed-in user, so it cannot run with a CLI client ID -and secret: accepting an invite records which person joined, and a -client credential names an application rather than a person. Accept -the invite from the link in your invitation email, or in the pgEdge +This needs a signed-in user in the pgEdge Starfleet UI: accepting an +invite records which person joined. An API client names an +application rather than a person, and the CLI does not send the +request for a browser sign-in either. Accept the invite from the link in your invitation email, or in the pgEdge Starfleet UI. Exits 5. Example: diff --git a/internal/starfleet/account/cmd/invite_test.go b/internal/starfleet/account/cmd/invite_test.go index a276d8e..20a497b 100644 --- a/internal/starfleet/account/cmd/invite_test.go +++ b/internal/starfleet/account/cmd/invite_test.go @@ -4,8 +4,10 @@ import ( "bytes" "errors" "net/http" + "net/http/httptest" "strings" "testing" + "time" "github.com/pgEdge/pgedge-cli/internal/testsupport" "github.com/spf13/pflag" @@ -131,17 +133,11 @@ func TestInviteGuardedVerbsIssueNoRequest(t *testing.T) { } } -// TestInviteGuardPremiseStillHolds is the tripwire. The guard exists -// only because every credential the CLI accepts is a client ID and -// secret, which the API treats as a client and never a user. If an -// interactive or device login ever lands, the premise is false and both -// verbs should be restored rather than left refusing. -// -// This is weaker than the spec-divergence pattern used for -// TestDatabaseLogsSpecShapeDivergesFromAPI, because there is no -// vendored artefact to key off — it watches the auth surface instead. -// It fails on a new auth verb or a new login flag, which is the -// cheapest available signal that the premise moved. +// TestInviteGuardPremiseStillHolds is the tripwire. Browser sign-in +// gives the CLI a user, yet both verbs still refuse it until invites +// are verified with a session. It fails on a new auth verb, on a new +// persistent flag, and on the guard no longer refusing a session, so +// that lifting the guard is a decision the test has to be told about. func TestInviteGuardPremiseStillHolds(t *testing.T) { rt, _, _ := testsupport.NewRuntime(t, "", "table") // Reached through the real tree rather than newAuthCmd directly, so @@ -174,11 +170,37 @@ func TestInviteGuardPremiseStillHolds(t *testing.T) { "watching the wrong surface", name) } - // 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. + // A browser session carries a user, and the guard must still refuse + // it before any request. + hits := 0 + srv := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { hits++ })) + defer srv.Close() + for _, args := range [][]string{ + {"invite", "create", "--email", "teammate@example.com"}, + {"invite", "accept", testInviteID, "--token", "tok"}, + } { + srt, _, _ := testsupport.NewRuntime(t, "", "text") + testsupport.SeedSession(t, srt, srv.URL, "u1", + time.Now().Add(time.Hour), "t-a") + err := runAccount(t, srt, &bytes.Buffer{}, args...) + if err == nil || !strings.Contains(err.Error(), + "needs a signed-in user") { + t.Errorf("%v with a browser session: err = %v. If invites "+ + "now work with a session, that is the follow-up "+ + "landing: update this tripwire, the invite help and "+ + "invite.txt together", args, err) + } + } + if hits != 0 { + t.Errorf("a guarded invite verb reached the API %d time(s)", hits) + } + + // The credentials themselves are persistent flags on the starfleet + // root, so that is the surface to watch for another sign-in kind. 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..0b9c337 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,127 @@ 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 { + return NewExitError(fmt.Sprintf("the browser sign-in for %s has "+ + "expired or is missing: run '%s' to sign in again", + apiURL, loginCommand(rt)), ExitAuth) +} + +// loginCommand is the auth login line for rt's profile. +func loginCommand(rt *module.Runtime) string { + login := "pgedge starfleet auth login" + if rt.Profile != "" && rt.Profile != "default" { + login += " --profile " + rt.Profile + } + return login +} + +// ErrTenantWithAPIClient is the exit-2 failure for --tenant alongside an +// API client. +func ErrTenantWithAPIClient() *ExitError { + return NewExitError("--tenant applies only to a browser sign-in: "+ + "an API client is scoped to one tenant", ExitUsage) +} + +// SessionTenant picks the tenant a session's requests act in: --tenant, +// else the profile's, else the token's default. A named tenant the +// token cannot reach is exit 2, since the gateway would refuse it. +func SessionTenant(rt *module.Runtime, profileTenant, flagTenant string, + claims *auth.Claims) (string, error) { + switch { + case flagTenant != "": + if !claims.HasTenant(flagTenant) { + return "", TenantUsageError(flagTenant, claims.Tenants()) + } + return flagTenant, nil + case profileTenant != "": + if !claims.HasTenant(profileTenant) { + return "", NewExitError(fmt.Sprintf("the profile's tenant "+ + "%s is not one this sign-in can reach (%s): run '%s "+ + "--tenant ', or pass --tenant", profileTenant, + strings.Join(claims.Tenants(), ", "), loginCommand(rt)), + ExitUsage) + } + return profileTenant, nil + } + return claims.TenantID, nil +} + +// 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 +717,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,19 +729,26 @@ 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, ErrTenantWithAPIClient() + } store, apiURL, creds := Store(rt), res.APIURL, res.Creds - if res.Env { + // Flags over a browser session must not overwrite its cached token. + if res.Env || rt.Config.StarfleetProfile(rt.Profile).IsBrowserSession() { store = nil policy = ephemeralToken } @@ -640,6 +787,56 @@ 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, err := SessionTenant(rt, res.TenantID, flagTenant, claims) + if err != nil { + return nil, err + } + 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 +} + +// now is tokenSource's clock, so a test can move past a token's expiry. +var now = time.Now + // 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 @@ -655,7 +852,7 @@ type tokenSource struct { func (ts *tokenSource) bearer(ctx context.Context) (string, error) { ts.mu.Lock() defer ts.mu.Unlock() - if time.Until(ts.expiry) > auth.RefreshWindow { + if ts.expiry.Sub(now()) > auth.RefreshWindow { return ts.value, nil } v, expiry, err := ts.mint(ctx) 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..b01b6c9 --- /dev/null +++ b/internal/starfleet/conn/session_test.go @@ -0,0 +1,235 @@ +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 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(time.Hour), "t-a") + c, err := Resolve(rt, Flags{}) + if err != nil { + t.Fatal(err) + } + if _, err := editedHeaders(t, c); err != nil { + t.Fatalf("a live session failed: %v", err) + } + t.Cleanup(func() { now = time.Now }) + now = func() time.Time { return time.Now().Add(2 * time.Hour) } + _, err = editedHeaders(t, c) + wantExit(t, err, ExitAuth, "has expired or is missing") +} + +func TestFlagsOverASessionLeaveItsCacheAlone(t *testing.T) { + stub := newTokenStub(t, "client-tok") + rt, _, _ := testsupport.NewRuntime(t, "", "text") + sess := testsupport.SeedSession(t, rt, sessionAPI, "u1", + time.Now().Add(time.Hour), "t-a") + c, err := Resolve(rt, Flags{ClientID: "id", ClientSecret: "s", + APIURL: stub.url(), Timeout: RequestTimeout}) + if err != nil { + t.Fatal(err) + } + if c.Token != "client-tok" || c.Session != nil { + t.Fatalf("flags did not win over the session: %+v", c) + } + tok, err := Store(rt).LoadToken() + if err != nil || tok.AccessToken != sess || !tok.SessionFor(sessionAPI) { + t.Fatalf("the session cache was overwritten: %+v, %v", tok, err) + } + if _, err := Resolve(rt, Flags{}); err != nil { + t.Errorf("the session no longer resolves: %v", err) + } +} + +func TestStaleProfileTenantIsAUsageError(t *testing.T) { + rt, _, _ := testsupport.NewRuntime(t, "", "text") + testsupport.SeedSession(t, rt, sessionAPI, "u1", + time.Now().Add(time.Hour), "t-a", "t-b") + rt.Config.SetStarfleetProfile("default", &config.StarfleetProfile{ + APIURL: sessionAPI, Auth: config.AuthBrowser, TenantID: "t-gone"}) + _, err := Resolve(rt, Flags{}) + wantExit(t, err, ExitUsage, "auth login --tenant") + if _, err := Resolve(rt, Flags{Tenant: "t-b"}); err != nil { + t.Errorf("--tenant should override a stale profile tenant: %v", err) + } +} + +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/llms.txt b/internal/starfleet/llms.txt index 1d61749..0265937 100644 --- a/internal/starfleet/llms.txt +++ b/internal/starfleet/llms.txt @@ -29,15 +29,33 @@ 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. A profile `tenant_id` the session no longer reaches is exit 2 +too; sign in again with `--tenant`. 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 +74,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 +123,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 +140,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..2066a15 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