mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 15:05:40 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,7 @@ func newRootCmd() *cobra.Command {
|
||||
roleCmd(),
|
||||
tagCmd(),
|
||||
webhooksCmd(),
|
||||
tokenCmd(),
|
||||
attachmentCmd(),
|
||||
dbCmd(),
|
||||
completionCmd(),
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user