feat(cli): pad token create/list/revoke — CLI mint path for API tokens (#1237)

Contributed by b4rk13 (#879 follow-up). Reviewed under DECIS-212-style read: sits on the existing user-scoped /auth/tokens endpoints, no server changes; create requires a login session per #1267 and answers 403 session_required under PAD_TOKEN, list/revoke stay PAT-reachable.

Claude-Session: https://claude.ai/code/session_01W71Y4K5hGbbqqAhbVFnjB4
This commit is contained in:
David Barkhausen
2026-09-09 16:09:29 -04:00
committed by GitHub
parent 0b16be492b
commit f262449b18
5 changed files with 475 additions and 2 deletions
+13 -2
View File
@@ -559,13 +559,24 @@ Once a user exists, all API requests and web UI access require authentication. C
#### Authenticating with an environment token
Set `PAD_TOKEN` to a Pad API token (minted under **Settings → API tokens** in the web UI) to authenticate without `pad auth login`:
Set `PAD_TOKEN` to a Pad API token (minted with `pad token create`, or under **Settings → API tokens** in the web UI) to authenticate without `pad auth login`:
```bash
PAD_TOKEN=pad_xxxxxxxx pad item list
```
`PAD_TOKEN` takes precedence over credentials saved by `pad auth login` — the same convention as `gh`'s `GH_TOKEN`. This is useful for CI, scripts, and machines where several AI agents share one CLI install but should act as different Pad users: give each agent its own token in its process environment, and the credential store is never touched. `pad auth whoami` reports the token's identity (with an `Auth: PAD_TOKEN environment override` line), and `pad auth login`/`logout` warn when the override is active — they manage the stored credentials, which the override bypasses. Deliberately, `pad auth logout` never invalidates the `PAD_TOKEN` session itself: it signs out the *stored* session only, and the env token's lifecycle belongs to wherever it was minted (revoke it under **Settings → API tokens**).
`PAD_TOKEN` takes precedence over credentials saved by `pad auth login` — the same convention as `gh`'s `GH_TOKEN`. This is useful for CI, scripts, and machines where several AI agents share one CLI install but should act as different Pad users: give each agent its own token in its process environment, and the credential store is never touched. `pad auth whoami` reports the token's identity (with an `Auth: PAD_TOKEN environment override` line), and `pad auth login`/`logout` warn when the override is active — they manage the stored credentials, which the override bypasses. Deliberately, `pad auth logout` never invalidates the `PAD_TOKEN` session itself: it signs out the *stored* session only, and the env token's lifecycle belongs to wherever it was minted (revoke it with `pad token revoke` or under **Settings → API tokens**).
#### Managing API tokens from the CLI
```bash
pad token create --name ci-agent # Mint a token (secret shown once)
pad token create --name cursor --expires-in 30
pad token list # Metadata only — never secrets
pad token revoke <token-id> # Immediate; the id must be exact
```
Tokens are user-scoped and act as the user who minted them. `create` prints the secret exactly once — the server stores only a hash and cannot show it again — so pair each mint with wherever the token will live (CI secret store, an agent's `PAD_TOKEN`). `revoke` takes the exact id from `pad token list`; revocation is immediate, and anything still authenticating with that token fails on its next call.
```bash
pad workspace members # List workspace members
+187
View File
@@ -0,0 +1,187 @@
package main
import (
"fmt"
"os"
"text/tabwriter"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/PerpetualSoftware/pad/internal/cli"
"github.com/PerpetualSoftware/pad/internal/models"
)
// `pad token` — mint, list, and revoke user-scoped API tokens from the
// CLI (#879 follow-up). Tokens are the identity PAD_TOKEN carries, and
// minting was web-only before this group, so a headless agent setup
// couldn't get its own identity without a browser.
func tokenCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "token",
Short: "Manage API tokens for automation and agents",
RunE: unknownSubcommandRun,
Long: `Manage user-scoped API tokens (the pad_ tokens PAD_TOKEN carries).
Tokens act as the user who minted them. The secret is shown exactly once,
at mint time the server stores only a hash and cannot show it again.
Examples:
pad token create --name ci-agent
pad token create --name cursor --expires-in 30
pad token list
pad token revoke 7fde5e41-...`,
}
cmd.AddCommand(
tokenCreateCmd(),
tokenListCmd(),
tokenRevokeCmd(),
)
return cmd
}
func tokenCreateCmd() *cobra.Command {
var (
nameFlag string
expiresInFlag int
scopesFlag string
)
cmd := &cobra.Command{
Use: "create --name <name>",
Short: "Mint a new API token (secret shown once)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
client, _ := getClient()
input := models.APITokenCreate{
Name: nameFlag,
Scopes: scopesFlag,
ExpiresIn: expiresInFlag,
}
token, err := client.CreateUserToken(input)
if err != nil {
// Same structured marker as webhook create (TASK-788) so
// callers can tell a plan limit from a server fault.
if apiErr, ok := err.(*cli.APIError); ok {
if apiErr.AsPlanLimit() != nil {
cli.WritePlanLimitError(os.Stderr, apiErr)
return fmt.Errorf("token creation blocked: plan limit reached")
}
}
return err
}
if formatFlag == "json" {
return cli.PrintJSON(token)
}
green := color.New(color.FgGreen)
fmt.Printf("%s Token created: %s\n", green.Sprint("✓"), token.Name)
fmt.Printf(" ID: %s\n", token.ID)
fmt.Printf(" Prefix: %s\n", token.Prefix)
fmt.Printf(" Expires: %s\n", formatTokenExpiry(token.ExpiresAt))
fmt.Println()
fmt.Printf(" %s\n", color.New(color.Bold).Sprint(token.Token))
fmt.Println()
yellow := color.New(color.FgYellow)
fmt.Printf("%s This token is shown only this once — store it now (e.g. in the agent's PAD_TOKEN).\n", yellow.Sprint("!"))
return nil
},
}
cmd.Flags().StringVar(&nameFlag, "name", "", "token name (required; shown in list and audit log)")
cmd.Flags().IntVar(&expiresInFlag, "expires-in", 0, "expiry in days (0 = platform default)")
cmd.Flags().StringVar(&scopesFlag, "scopes", "", "optional scopes string")
_ = cmd.MarkFlagRequired("name")
return cmd
}
func tokenListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List your API tokens (metadata only, never secrets)",
Aliases: []string{"ls"},
RunE: func(cmd *cobra.Command, args []string) error {
client, _ := getClient()
tokens, err := client.ListUserTokens()
if err != nil {
return err
}
if formatFlag == "json" {
return cli.PrintJSON(tokens)
}
if len(tokens) == 0 {
fmt.Println("No API tokens. Mint one with: pad token create --name <name>")
return nil
}
dim := color.New(color.Faint)
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
dim.Sprint("ID"),
dim.Sprint("NAME"),
dim.Sprint("PREFIX"),
dim.Sprint("CREATED"),
dim.Sprint("LAST USED"),
dim.Sprint("EXPIRES"),
)
for _, tok := range tokens {
lastUsed := "never"
if tok.LastUsedAt != nil {
lastUsed = cli.RelativeTime(*tok.LastUsedAt)
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
tok.ID,
tok.Name,
tok.Prefix,
cli.RelativeTime(tok.CreatedAt),
lastUsed,
formatTokenExpiry(tok.ExpiresAt),
)
}
w.Flush()
return nil
},
}
}
func tokenRevokeCmd() *cobra.Command {
return &cobra.Command{
Use: "revoke <token-id>",
Short: "Revoke an API token by id (immediate; cannot be undone)",
Long: `Revoke an API token by its exact id (from 'pad token list').
Revocation is immediate: anything authenticating with the token fails on
its next call. The id must be exact there is no prefix matching, so a
typo is a not-found error rather than a wrong token revoked.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, _ := getClient()
if err := client.RevokeUserToken(args[0]); err != nil {
return err
}
green := color.New(color.FgGreen)
fmt.Printf("%s Token %s revoked\n", green.Sprint("✓"), args[0])
return nil
},
}
}
// formatTokenExpiry renders an expiry timestamp for display; a nil
// expiry means the token never expires.
func formatTokenExpiry(t *time.Time) string {
if t == nil {
return "never"
}
return t.Format("2006-01-02")
}
+235
View File
@@ -0,0 +1,235 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// Tests for the `pad token` command group (#879 follow-up): CLI mint /
// list / revoke against the existing user-scoped /api/v1/auth/tokens
// endpoints, so the PAD_TOKEN story works end-to-end without the web UI.
//
// HOME and USERPROFILE are both set (via setTempHomeMain) because
// CredentialsPath resolves via os.UserHomeDir (HOME on Unix, USERPROFILE
// on Windows).
// stubTokenServer serves the user-scoped token endpoints for exactly one
// bearer token and records what it was asked to do.
type stubTokenServer struct {
*httptest.Server
wantToken string
createBodies []map[string]any
deletePaths []string
listHits int
}
func newStubTokenServer(t *testing.T, wantToken string) *stubTokenServer {
t.Helper()
s := &stubTokenServer{wantToken: wantToken}
mux := http.NewServeMux()
authorized := func(w http.ResponseWriter, r *http.Request) bool {
if r.Header.Get("Authorization") != "Bearer "+s.wantToken {
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{"code": "unauthorized", "message": "Not logged in"},
})
return false
}
return true
}
mux.HandleFunc("/api/v1/auth/tokens", func(w http.ResponseWriter, r *http.Request) {
if !authorized(w, r) {
return
}
switch r.Method {
case http.MethodGet:
s.listHits++
_ = json.NewEncoder(w).Encode([]map[string]any{
{
"id": "tok-1111", "name": "ci-agent", "prefix": "pad_abc1",
"created_at": "2026-08-01T10:00:00Z",
"last_used_at": "2026-09-01T09:00:00Z",
"expires_at": "2026-11-01T10:00:00Z",
},
{
"id": "tok-2222", "name": "sweep", "prefix": "pad_def2",
"created_at": "2026-08-15T10:00:00Z",
},
})
case http.MethodPost:
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
s.createBodies = append(s.createBodies, body)
if name, _ := body["name"].(string); name == "" {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{"code": "bad_request", "message": "name is required"},
})
return
}
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "tok-new", "name": body["name"], "prefix": "pad_new1",
"created_at": "2026-09-02T10:00:00Z",
"expires_at": "2026-12-01T10:00:00Z",
"token": "pad_new1secretsecretsecret",
})
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
})
mux.HandleFunc("/api/v1/auth/tokens/", func(w http.ResponseWriter, r *http.Request) {
if !authorized(w, r) {
return
}
if r.Method != http.MethodDelete {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
s.deletePaths = append(s.deletePaths, r.URL.Path)
if strings.HasSuffix(r.URL.Path, "/tok-missing") {
w.WriteHeader(http.StatusNotFound)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{"code": "not_found", "message": "Token not found"},
})
return
}
w.WriteHeader(http.StatusNoContent)
})
s.Server = httptest.NewServer(mux)
t.Cleanup(s.Close)
return s
}
func setupTokenEnv(t *testing.T) *stubTokenServer {
t.Helper()
setTempHomeMain(t) // empty credential store; PAD_TOKEN below is the auth
srv := newStubTokenServer(t, "pad_envtoken")
t.Setenv("PAD_URL", srv.URL)
t.Setenv("PAD_TOKEN", "pad_envtoken")
return srv
}
// create mints via POST /auth/tokens and prints the secret exactly once,
// with a store-it-now notice — the server never returns it again.
func TestTokenCreate_PrintsSecretOnceWithNotice(t *testing.T) {
srv := setupTokenEnv(t)
cmd := tokenCreateCmd()
cmd.SetArgs([]string{"--name", "ci-agent", "--expires-in", "30"})
var runErr error
out := captureStdout(t, func() {
runErr = cmd.Execute()
})
if runErr != nil {
t.Fatalf("token create: %v", runErr)
}
if len(srv.createBodies) != 1 {
t.Fatalf("expected exactly one POST /auth/tokens, got %d", len(srv.createBodies))
}
body := srv.createBodies[0]
if body["name"] != "ci-agent" {
t.Errorf("posted name = %v, want ci-agent", body["name"])
}
if n, _ := body["expires_in"].(float64); int(n) != 30 {
t.Errorf("posted expires_in = %v, want 30", body["expires_in"])
}
if !strings.Contains(out, "pad_new1secretsecretsecret") {
t.Errorf("output must contain the minted token once:\n%s", out)
}
if strings.Count(out, "pad_new1secretsecretsecret") != 1 {
t.Errorf("the secret must appear exactly once:\n%s", out)
}
lower := strings.ToLower(out)
if !strings.Contains(lower, "store") && !strings.Contains(lower, "shown") {
t.Errorf("output must warn the token is shown only now:\n%s", out)
}
}
// create without --name refuses locally — the flag is required, so the
// server is never asked to reject it.
func TestTokenCreate_RequiresName(t *testing.T) {
srv := setupTokenEnv(t)
cmd := tokenCreateCmd()
cmd.SetArgs([]string{})
cmd.SetOut(nil)
cmd.SetErr(nil)
var runErr error
captureStdout(t, func() {
runErr = cmd.Execute()
})
if runErr == nil {
t.Fatal("expected an error when --name is missing")
}
if len(srv.createBodies) != 0 {
t.Errorf("no POST should reach the server on a missing name, got %d", len(srv.createBodies))
}
}
// list renders metadata for every token and never a secret — the server
// doesn't return secrets on list, and the renderer must not invent one.
func TestTokenList_RendersMetadataWithoutSecret(t *testing.T) {
srv := setupTokenEnv(t)
cmd := tokenListCmd()
var runErr error
out := captureStdout(t, func() {
runErr = cmd.RunE(cmd, nil)
})
if runErr != nil {
t.Fatalf("token list: %v", runErr)
}
if srv.listHits != 1 {
t.Fatalf("expected one GET /auth/tokens, got %d", srv.listHits)
}
for _, want := range []string{"ci-agent", "sweep", "pad_abc1", "pad_def2", "tok-1111", "tok-2222"} {
if !strings.Contains(out, want) {
t.Errorf("list output missing %q:\n%s", want, out)
}
}
if strings.Contains(out, "secret") {
t.Errorf("list output must never carry secret material:\n%s", out)
}
}
// revoke DELETEs the exact id it was given.
func TestTokenRevoke_DeletesById(t *testing.T) {
srv := setupTokenEnv(t)
cmd := tokenRevokeCmd()
var runErr error
out := captureStdout(t, func() {
runErr = cmd.RunE(cmd, []string{"tok-1111"})
})
if runErr != nil {
t.Fatalf("token revoke: %v", runErr)
}
if len(srv.deletePaths) != 1 || !strings.HasSuffix(srv.deletePaths[0], "/auth/tokens/tok-1111") {
t.Errorf("delete paths = %v, want exactly one ending in /auth/tokens/tok-1111", srv.deletePaths)
}
if !strings.Contains(strings.ToLower(out), "revoked") {
t.Errorf("output should confirm the revoke:\n%s", out)
}
}
// revoke surfaces the server's 404 instead of claiming success — an id
// typo must not read as a revoked token.
func TestTokenRevoke_NotFoundSurfacesError(t *testing.T) {
setupTokenEnv(t)
cmd := tokenRevokeCmd()
var runErr error
captureStdout(t, func() {
runErr = cmd.RunE(cmd, []string{"tok-missing"})
})
if runErr == nil {
t.Fatal("expected an error for an unknown token id")
}
if !strings.Contains(runErr.Error(), "not found") && !strings.Contains(runErr.Error(), "Token not found") {
t.Errorf("error should say the token was not found, got %q", runErr.Error())
}
}
+1
View File
@@ -141,6 +141,7 @@ func newRootCmd() *cobra.Command {
roleCmd(),
tagCmd(),
webhooksCmd(),
tokenCmd(),
attachmentCmd(),
dbCmd(),
completionCmd(),
+39
View File
@@ -0,0 +1,39 @@
package cli
import (
"net/url"
"github.com/PerpetualSoftware/pad/internal/models"
)
// User-scoped API-token endpoints (GET/POST /auth/tokens,
// DELETE /auth/tokens/{id}). These are the mint/list/revoke calls behind
// `pad token` — the CLI counterpart to the web settings page, so the
// PAD_TOKEN override (#879) is usable end-to-end without a browser.
// ListUserTokens returns the caller's API tokens. Metadata only — the
// server never returns secret material on list.
func (c *Client) ListUserTokens() ([]models.APIToken, error) {
var out []models.APIToken
if err := c.get("/auth/tokens", &out); err != nil {
return nil, err
}
return out, nil
}
// CreateUserToken mints a new API token owned by the authenticated user.
// The response carries the plaintext secret exactly once; it is never
// retrievable again.
func (c *Client) CreateUserToken(input models.APITokenCreate) (*models.APITokenWithSecret, error) {
var out models.APITokenWithSecret
if err := c.post("/auth/tokens", input, &out); err != nil {
return nil, err
}
return &out, nil
}
// RevokeUserToken deletes an API token by id. The server verifies the
// token belongs to the caller; an unknown or foreign id is a 404.
func (c *Client) RevokeUserToken(id string) error {
return c.delete("/auth/tokens/" + url.PathEscape(id))
}