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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,28 @@ band auth switch <account-id> # change active account (no re-auth needed)
band auth status # verify auth state
```

### Verified authentication status

`band auth status --plain` now performs a fresh OAuth token exchange by default. `authenticated: true` means this verification succeeded; a cached token is not sufficient because a client secret may have been revoked after the token was issued. Verification refreshes reported roles, capabilities, Build type, and accessible accounts from the returned JWT without modifying saved configuration or the selected account. It verifies credentials, not authorization for every API operation or the selected account.

The existing JSON fields are retained, with two additions: `credentials_stored` (client ID and a non-empty secret are available from the configured keychain) and `token`:

| Situation | `authenticated` | `token.status` | `token.reason` | Exit |
|---|---|---|---|---|
| Verified | true | valid | omitted; `expires_in` gives remaining seconds | 0 |
| Token endpoint rejects credentials (401, or 400 `invalid_client`) | false | rejected | recognized OAuth code, or `token_endpoint_error` | 2 |
| Network, malformed response/JWT, environment, or server failure | false | unknown | probe_failed | 1 |
| Token endpoint rate limit | false | unknown | probe_failed | 7 |
| No client ID | false | unknown | not_logged_in | 2 |
| Keychain secret missing/unavailable/empty | false | unknown | credentials_unavailable | 2 |
| `--no-verify`, credentials available | false | unknown | not_verified | 0 |

**Migration:** previously `authenticated` only meant the keychain lookup succeeded and status was offline. For that presence check, use `band auth status --no-verify --plain` and inspect `credentials_stored`. Offline mode reports stored metadata, never calls the token endpoint or background update checker, and exits 0 even when credentials are absent (with the corresponding missing-credential reason above). Config/output errors still fail. Automation that requires usable credentials should use default verification and gate on `authenticated` or `token.status == "valid"`; an unknown result is not evidence of rejection.

Structured status results go to stdout before verification failures return nonzero; human-readable status and remediation go to stderr. Token values, client secrets, and raw OAuth error bodies are never printed. Ordinary API commands also classify token-exchange credential rejection as exit 2, with a profile-specific login command; token 429 remains exit 7 and token 5xx remains exit 1. Existing resource-API 403 mappings are unchanged. Runtime errors omit usage text; parse/argument/required-flag errors and typed `FlagError` validation errors retain it.

Token exchange honors command cancellation and its 15-second HTTP timeout. Verification uses the same environment selection as API commands (`--environment` > `BW_ENVIRONMENT` > profile; `BW_API_URL` overrides the endpoint) and does not require an account ID. SIP and 10DLC account-level availability still require their own probes below.

### Credential Profiles

Store multiple credential sets under named profiles — useful when different roles or environments require different client credentials:
Expand Down Expand Up @@ -70,20 +92,20 @@ SIP provisioning (`band sip realm ...`, `band sip credential ...`) needs **two**
| `reason` | `status` | Meaning |
|----------|----------|---------|
| `role_absent` | `unavailable` | Credential lacks the `SIP Credentials` role. |
| `role_present_not_probed` | `unknown` | Credential has the role, but `auth status` is offline and cannot confirm account-level configuration. |
| `role_present_not_probed` | `unknown` | Credential has the role, but `auth status` does not probe SIP account-level configuration. |
| `account_not_enabled` | `unavailable` | Only returned by `band sip status` — the account has the role but SIP Credentials isn't enabled on the account. Contact Bandwidth support. |
| `probe_succeeded` | `available` | Only returned by `band sip status` — the account can use SIP provisioning. |
| `probe_failed` | `unknown` | Only returned by `band sip status` — the probe itself failed (e.g. rate limited or a server error); retry later. |

`band auth status` never calls the network, so it can only ever report `role_absent` or `role_present_not_probed` for `sip`. To resolve an `unknown`, run the explicit probe:
`band auth status` verifies the credentials, but does not probe SIP account settings, so it can only report `role_absent` or `role_present_not_probed` for `sip`. To resolve an `unknown`, run the explicit probe:

```bash
band sip status --plain
```

This issues one cheap `GET /realms` call. A `200` reports `available`/`probe_succeeded` (exit 0). Hitting error code `33004` ("account isn't setup for Sip Credentials") reports `unavailable`/`account_not_enabled` — and **exits 0**, because a successful probe that confirms a negative fact is not a command failure. Auth errors (401/403) exit 2 via the normal error path; rate limiting or server errors exit non-zero with `probe_failed`.

Important: `band sip status` **does not persist** its result anywhere. Run it again any time you need a fresh answer, and don't expect `band auth status` to start reporting anything other than `unknown` for a role-holding credential — that command stays fully offline by design.
Important: `band sip status` **does not persist** its result anywhere. Run it again any time you need a fresh answer, and don't expect `band auth status` to start reporting anything other than `unknown` for a role-holding credential — credential verification does not confirm SIP account settings.

#### 10DLC capability (tri-state, not boolean)

Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,8 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f
|---------|-------------|
| `band auth login` | Log in with OAuth2 credentials (use `--profile <name>` to store under a named profile) |
| `band auth logout` | Clear stored credentials |
| `band auth status` | Show auth state, active account, and accessible accounts |
| `band auth status` | Verify credentials with a fresh token exchange; show auth state, active account, and accessible accounts |
| `band auth status --no-verify` | Inspect stored credentials offline; does not assert authentication |
| `band auth switch [id]` | Switch to a different account |
| `band auth profiles` | List all stored credential profiles |
| `band auth use <profile>` | Switch the active credential profile |
Expand Down Expand Up @@ -615,7 +616,7 @@ All five share the same filters: `--to`/`--from` (comma-separated E.164), `--dir

**"account ID not set"** — You're logged in but haven't picked an account. Run `band auth switch <id>` or pass `--account-id`.

**"credential verification failed"** — Your client ID or secret is wrong. Double-check them in the Bandwidth App.
**"credentials were rejected"** — The token endpoint rejected your client ID or secret (exit 2). Follow the profile-specific `band auth login --profile ...` instruction. Network errors and token-endpoint failures are reported separately; they do not prove that credentials are invalid.

**API error 401** — Your token expired. Run `band auth login` again.

Expand Down
4 changes: 4 additions & 0 deletions cmd/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,9 @@ func TestTenDLCWiringAgainstLiveRoles(t *testing.T) {
// does; only driving runStatus end to end (as done here) actually exercises
// the wiring and would have failed before the fix.
func TestStatusPlainTenDLCAgreesWithCapabilities(t *testing.T) {
originalPassword := statusPassword
statusPassword = func(string) (string, error) { return "test-secret", nil }
t.Cleanup(func() { statusPassword = originalPassword })
tests := []struct {
name string
roles []string
Expand Down Expand Up @@ -354,6 +357,7 @@ func TestStatusPlainTenDLCAgreesWithCapabilities(t *testing.T) {
}

wrap := &cobra.Command{Use: "status", RunE: runStatus}
wrap.Flags().Bool("no-verify", true, "")
root := testutil.NewTestRoot(wrap)
root.SetArgs([]string{"status", "--plain"})

Expand Down
3 changes: 2 additions & 1 deletion cmd/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,8 @@ func runLogin(cmd *cobra.Command, args []string) error {
spin := ui.NewSpinner("Verifying credentials...")
spin.Start()
tm := intauth.NewTokenManager(clientID, clientSecret, tokenURL)
token, err := tm.GetToken()
tm.ProfileName = profileName
token, err := tm.GetTokenContext(cmd.Context())
spin.Stop()
if err != nil {
return fmt.Errorf("credential verification failed: %w", err)
Expand Down
186 changes: 105 additions & 81 deletions cmd/auth/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ package auth

import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"

"github.com/spf13/cobra"
Expand All @@ -16,29 +16,34 @@ import (

func init() {
Cmd.AddCommand(statusCmd)
statusCmd.Flags().Bool("no-verify", false, "Inspect stored credentials without contacting the token endpoint (authenticated remains false)")
}

var statusCmd = &cobra.Command{
Use: "status",
Short: "Show current authentication status",
Long: "Shows the active profile's authentication status, including client ID, active account, environment, and (for Bandwidth Build accounts) capabilities. Use --plain for machine-readable JSON.",
Long: "Verifies the active profile's credentials with a fresh token exchange. Use --no-verify for offline inspection, or --plain for machine-readable JSON. SIP and 10DLC account-level availability still require their separate status probes.",
Example: ` band auth status
band auth status --plain`,
RunE: runStatus,
Args: cobra.NoArgs,
}

// statusJSON is the structured output shape returned when --plain is set.
// Stable contract for agents — additive changes only.
// Existing fields are retained; authenticated now requires verification.
// Offline consumers migrate to credentials_stored (see AGENTS.md).
type statusJSON struct {
Authenticated bool `json:"authenticated"`
Profile string `json:"profile,omitempty"`
ClientID string `json:"client_id,omitempty"`
AccountID string `json:"account_id,omitempty"`
Accounts []string `json:"accounts,omitempty"`
Environment string `json:"environment,omitempty"`
Build bool `json:"build,omitempty"`
Roles []string `json:"roles,omitempty"`
Capabilities map[string]bool `json:"capabilities,omitempty"`
Authenticated bool `json:"authenticated"`
CredentialsStored bool `json:"credentials_stored"`
Token tokenStatus `json:"token"`
Profile string `json:"profile,omitempty"`
ClientID string `json:"client_id,omitempty"`
AccountID string `json:"account_id,omitempty"`
Accounts []string `json:"accounts,omitempty"`
Environment string `json:"environment,omitempty"`
Build bool `json:"build,omitempty"`
Roles []string `json:"roles,omitempty"`
Capabilities map[string]bool `json:"capabilities,omitempty"`
// SIP reports SIP provisioning availability as a tri-state object
// ({"status":..., "reason":...}) rather than a bool inside Capabilities —
// see sipCapability.
Expand All @@ -49,6 +54,15 @@ type statusJSON struct {
Error string `json:"error,omitempty"`
}

type tokenStatus struct {
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
ExpiresIn *int `json:"expires_in,omitempty"`
}

// Seamed for tests so no real OS keychain is read.
var statusPassword = intauth.GetPassword

func runStatus(cmd *cobra.Command, args []string) error {
_, plain := cmdutil.OutputFlags(cmd)

Expand All @@ -64,14 +78,6 @@ func runStatus(cmd *cobra.Command, args []string) error {

p := cfg.ActiveProfileConfig()

if p.ClientID == "" {
if plain {
return emitJSON(statusJSON{Authenticated: false})
}
fmt.Fprintln(os.Stderr, ui.Warn("Not logged in."))
return nil
}

env := p.Environment
if env == "" {
env = "prod"
Expand All @@ -82,74 +88,92 @@ func runStatus(cmd *cobra.Command, args []string) error {
profileName = "default"
}

_, keychainErr := intauth.GetPassword(p.ClientID)

if plain {
caps := Capabilities(p.Roles)
out := statusJSON{
Authenticated: keychainErr == nil,
Profile: profileName,
ClientID: p.ClientID,
AccountID: p.AccountID,
Accounts: p.Accounts,
Environment: env,
Build: p.Build,
Roles: p.Roles,
Capabilities: caps,
SIP: sipCapability(hasRole(p.Roles, "sip credentials")),
TenDLC: tendlcCapability(caps["campaign_management"]),
}
if keychainErr != nil {
out.Error = "credentials not found in keychain"
}
return emitJSON(out)
}

if keychainErr != nil {
fmt.Printf("Client ID: %s\n", ui.ID(p.ClientID))
fmt.Printf("Account: %s\n", ui.ID(p.AccountID))
// Show environment only when it's informative.
if env != "prod" || cfg.HasMultipleEnvironments() {
fmt.Printf("Environment: %s\n", env)
}
fmt.Println("Status: " + ui.Error("credentials not found in keychain"))
return nil
noVerify, _ := cmd.Flags().GetBool("no-verify")
caps := Capabilities(p.Roles)
out := statusJSON{
Profile: profileName,
ClientID: p.ClientID,
AccountID: p.AccountID,
Accounts: p.Accounts,
Environment: env,
Build: p.Build,
Roles: p.Roles,
Capabilities: caps,
SIP: sipCapability(hasRole(p.Roles, "sip credentials")),
TenDLC: tendlcCapability(caps["campaign_management"]),
}

fmt.Printf("Profile: %s\n", ui.Bold(profileName))
fmt.Printf("Client ID: %s\n", ui.ID(p.ClientID))
if p.AccountID != "" {
fmt.Printf("Account: %s\n", ui.ID(p.AccountID))
out.Token = tokenStatus{Status: "unknown", Reason: "not_verified"}
var verifyErr error
var secret string
if p.ClientID == "" {
out.Token.Reason = "not_logged_in"
verifyErr = &intauth.CredentialError{Reason: out.Token.Reason, Profile: profileName}
} else {
fmt.Printf("Account: (none — pass --account-id on commands)\n")
secret, err = statusPassword(p.ClientID)
out.CredentialsStored = err == nil && secret != ""
if !out.CredentialsStored {
out.Token.Reason = "credentials_unavailable"
verifyErr = &intauth.CredentialError{Reason: out.Token.Reason, Profile: profileName}
}
}
if len(p.Accounts) > 1 {
fmt.Printf("Accounts: %s\n", strings.Join(p.Accounts, ", "))
} else if len(p.Accounts) == 0 && p.AccountID == "" {
fmt.Println("Scope: system-wide (use --account-id to target an account)")
if !noVerify && verifyErr == nil {
var tm *intauth.TokenManager
tm, out.Environment, verifyErr = cmdutil.AuthTokenManager(p, secret, profileName)
if verifyErr == nil {
var token string
var expires int
token, expires, verifyErr = tm.Verify(cmd.Context())
if verifyErr == nil {
var claims *jwtClaims
claims, verifyErr = parseJWTClaims(token)
if verifyErr == nil {
out.Authenticated = true
out.Token = tokenStatus{Status: "valid", ExpiresIn: &expires}
out.Accounts, out.Roles, out.Build = claims.Accounts, claims.Roles, claims.Build
out.Capabilities = Capabilities(claims.Roles)
out.SIP = sipCapability(hasRole(claims.Roles, "sip credentials"))
out.TenDLC = tendlcCapability(out.Capabilities["campaign_management"])
}
}
}
if verifyErr != nil {
out.Token = tokenStatus{Status: "unknown", Reason: "probe_failed"}
var tokenErr *intauth.TokenError
if errors.As(verifyErr, &tokenErr) && tokenErr.Rejected() {
out.Token = tokenStatus{Status: "rejected", Reason: tokenErr.Code}
}
}
}
caps := Capabilities(p.Roles)
if p.Build {
fmt.Printf("Type: %s (voice-only, credit-based)\n", ui.Bold("Bandwidth Build"))
fmt.Printf("Capable of: %s\n", capabilitySummary(caps))
if verifyErr != nil {
out.Error = verifyErr.Error()
}
fmt.Printf("SIP: %s\n", sipSummary(sipCapability(hasRole(p.Roles, "sip credentials"))))
fmt.Printf("10DLC: %s\n", tendlcSummary(tendlcCapability(caps["campaign_management"])))
if env != "prod" || cfg.HasMultipleEnvironments() {
fmt.Printf("Environment: %s\n", env)
if plain {
enc := json.NewEncoder(cmd.OutOrStdout())
enc.SetIndent("", " ")
if err := enc.Encode(out); err != nil {
return err
}
} else {
w := cmd.ErrOrStderr()
fmt.Fprintf(w, "Profile: %s\nClient ID: %s\nAccount: %s\nEnvironment: %s\n", out.Profile, out.ClientID, out.AccountID, out.Environment)
fmt.Fprintf(w, "Status: %s", out.Token.Status)
if out.Token.Reason != "" {
fmt.Fprintf(w, " (%s)", out.Token.Reason)
}
fmt.Fprintln(w)
fmt.Fprintf(w, "Accounts: %s\n", strings.Join(out.Accounts, ", "))
if out.Build {
fmt.Fprintln(w, "Type: Bandwidth Build (voice-only, credit-based)")
}
fmt.Fprintf(w, "Capable of: %s\nSIP: %s\n10DLC: %s\n", capabilitySummary(out.Capabilities), sipSummary(out.SIP), tendlcSummary(out.TenDLC))
if len(cfg.Profiles) > 1 {
fmt.Fprintf(w, "Profiles: %s\n", strings.Join(cfg.ProfileNames(), ", "))
}
}
fmt.Println("Status: " + ui.Success("authenticated"))

if len(cfg.Profiles) > 1 {
fmt.Printf("Profiles: %s\n", strings.Join(cfg.ProfileNames(), ", "))
if noVerify {
return nil
}
return nil
}

func emitJSON(v statusJSON) error {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(v)
return verifyErr
}

// Capabilities maps a set of JWT role strings to a stable feature map.
Expand Down
Loading
Loading