Skip to content
48 changes: 42 additions & 6 deletions docs/auth-and-profiles.md
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:

Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion docs/error-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <reason>` | 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 <api-url> 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 <api-url> 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 <id> is not a tenant this sign-in can reach; reachable tenant IDs: <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): <body>` | 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): <body>` | 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) |
| `<command> 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) |
| `<command> 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
Expand Down
18 changes: 11 additions & 7 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,20 +44,24 @@ 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

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:

Expand Down
6 changes: 4 additions & 2 deletions docs/reference/starfleet.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions docs/starfleet/team-onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ refuses outright:
| `membership list` | Yes |
| `membership delete <membership_id>` | 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
Expand Down
10 changes: 10 additions & 0 deletions internal/apidefaults/apidefaults_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
37 changes: 37 additions & 0 deletions internal/apidefaults/auth0.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package apidefaults

import "strings"

// Auth0App is the public Auth0 configuration browser sign-in uses for
// one pgEdge Starfleet API.
type Auth0App struct {
// Domain is the custom Auth0 domain the /authorize and
// /oauth/token endpoints live on.
Domain string
Audience string
// ClientID is the native app's public client ID. Empty means
// browser sign-in is not available for this API yet.
ClientID string
}

// auth0Apps is the one place to edit when a client ID is issued.
var auth0Apps = map[string]Auth0App{
"https://api.pgedge.com": {
Domain: "auth.pgedge.com",
Audience: "https://api.pgedge.com",
ClientID: "",
},
"https://devapi.pgedge.com": {
Domain: "devauth.pgedge.com",
Audience: "https://devapi.pgedge.com",
ClientID: "",
},
}

// Auth0For returns the Auth0 app for apiURL and whether browser sign-in
// is available for it: the URL is known and its client ID is filled in.
// A trailing slash is ignored.
func Auth0For(apiURL string) (Auth0App, bool) {
app, ok := auth0Apps[strings.TrimRight(apiURL, "/")]
return app, ok && app.ClientID != ""
}
57 changes: 47 additions & 10 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading