From f262449b180961773b27e5defd4f523db2e0ee85 Mon Sep 17 00:00:00 2001 From: David Barkhausen Date: Wed, 9 Sep 2026 16:09:29 -0400 Subject: [PATCH] =?UTF-8?q?feat(cli):=20pad=20token=20create/list/revoke?= =?UTF-8?q?=20=E2=80=94=20CLI=20mint=20path=20for=20API=20tokens=20(#1237)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 15 ++- cmd/pad/cmd_token.go | 187 +++++++++++++++++++++++++++ cmd/pad/cmd_token_test.go | 235 ++++++++++++++++++++++++++++++++++ cmd/pad/main.go | 1 + internal/cli/client_tokens.go | 39 ++++++ 5 files changed, 475 insertions(+), 2 deletions(-) create mode 100644 cmd/pad/cmd_token.go create mode 100644 cmd/pad/cmd_token_test.go create mode 100644 internal/cli/client_tokens.go diff --git a/README.md b/README.md index c5090e14..0f393da0 100644 --- a/README.md +++ b/README.md @@ -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 # 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 diff --git a/cmd/pad/cmd_token.go b/cmd/pad/cmd_token.go new file mode 100644 index 00000000..7f84c7f7 --- /dev/null +++ b/cmd/pad/cmd_token.go @@ -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 ", + 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 ") + 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 ", + 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") +} diff --git a/cmd/pad/cmd_token_test.go b/cmd/pad/cmd_token_test.go new file mode 100644 index 00000000..161c0c74 --- /dev/null +++ b/cmd/pad/cmd_token_test.go @@ -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()) + } +} diff --git a/cmd/pad/main.go b/cmd/pad/main.go index 52ce8541..1697a75f 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -141,6 +141,7 @@ func newRootCmd() *cobra.Command { roleCmd(), tagCmd(), webhooksCmd(), + tokenCmd(), attachmentCmd(), dbCmd(), completionCmd(), diff --git a/internal/cli/client_tokens.go b/internal/cli/client_tokens.go new file mode 100644 index 00000000..1e8cfb82 --- /dev/null +++ b/internal/cli/client_tokens.go @@ -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)) +}