From cf83a60fc26f5e1ec8a6a015666eff23052e69f8 Mon Sep 17 00:00:00 2001 From: xarmian Date: Sat, 28 Mar 2026 14:13:50 +0000 Subject: [PATCH] feat: Add API tokens system for programmatic access Add a complete API tokens system enabling CI/CD integrations, custom scripts, and third-party tools to authenticate with the Pad API. - Migration 011: api_tokens table with hash-based token storage - Model: APIToken, APITokenCreate, APITokenWithSecret types - Store: CRUD operations with crypto/rand generation and SHA-256 hashing - Middleware: Bearer token auth that sets workspace context - Handlers: POST/GET/DELETE /workspaces/{ws}/tokens endpoints - CORS: Allow Authorization header for token-based requests --- internal/models/api_token.go | 28 +++ internal/server/handlers_tokens.go | 77 ++++++++ internal/server/middleware_auth.go | 65 +++++++ internal/server/server.go | 10 +- internal/store/api_tokens.go | 184 +++++++++++++++++++ internal/store/migrations/011_api_tokens.sql | 12 ++ internal/store/store.go | 1 + 7 files changed, 376 insertions(+), 1 deletion(-) create mode 100644 internal/models/api_token.go create mode 100644 internal/server/handlers_tokens.go create mode 100644 internal/server/middleware_auth.go create mode 100644 internal/store/api_tokens.go create mode 100644 internal/store/migrations/011_api_tokens.sql diff --git a/internal/models/api_token.go b/internal/models/api_token.go new file mode 100644 index 00000000..bf91e22d --- /dev/null +++ b/internal/models/api_token.go @@ -0,0 +1,28 @@ +package models + +import "time" + +// APIToken represents a stored API token (without the secret). +type APIToken struct { + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + Name string `json:"name"` + Prefix string `json:"prefix"` + Scopes string `json:"scopes"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// APITokenCreate is the input for creating a new API token. +type APITokenCreate struct { + Name string `json:"name"` + Scopes string `json:"scopes,omitempty"` +} + +// APITokenWithSecret is returned only on creation and includes the +// plaintext token. The token is never stored and cannot be retrieved again. +type APITokenWithSecret struct { + APIToken + Token string `json:"token"` // Only returned once +} diff --git a/internal/server/handlers_tokens.go b/internal/server/handlers_tokens.go new file mode 100644 index 00000000..92769fe8 --- /dev/null +++ b/internal/server/handlers_tokens.go @@ -0,0 +1,77 @@ +package server + +import ( + "database/sql" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/xarmian/pad/internal/models" +) + +// handleCreateToken creates a new API token for a workspace. +// The plaintext token is returned only in this response. +func (s *Server) handleCreateToken(w http.ResponseWriter, r *http.Request) { + workspaceID, ok := s.getWorkspaceID(w, r) + if !ok { + return + } + + var input models.APITokenCreate + if err := decodeJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + + if input.Name == "" { + writeError(w, http.StatusBadRequest, "bad_request", "name is required") + return + } + + token, err := s.store.CreateAPIToken(workspaceID, input) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", err.Error()) + return + } + + writeJSON(w, http.StatusCreated, token) +} + +// handleListTokens returns all API tokens for a workspace (without secrets). +func (s *Server) handleListTokens(w http.ResponseWriter, r *http.Request) { + workspaceID, ok := s.getWorkspaceID(w, r) + if !ok { + return + } + + tokens, err := s.store.ListAPITokens(workspaceID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", err.Error()) + return + } + if tokens == nil { + tokens = []models.APIToken{} + } + + writeJSON(w, http.StatusOK, tokens) +} + +// handleDeleteToken revokes an API token by ID. +func (s *Server) handleDeleteToken(w http.ResponseWriter, r *http.Request) { + _, ok := s.getWorkspaceID(w, r) + if !ok { + return + } + + tokenID := chi.URLParam(r, "tokenID") + if err := s.store.DeleteAPIToken(tokenID); err != nil { + if err == sql.ErrNoRows { + writeError(w, http.StatusNotFound, "not_found", "Token not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", err.Error()) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/server/middleware_auth.go b/internal/server/middleware_auth.go new file mode 100644 index 00000000..0e3f4b18 --- /dev/null +++ b/internal/server/middleware_auth.go @@ -0,0 +1,65 @@ +package server + +import ( + "context" + "net/http" + "strings" +) + +type contextKey string + +const ( + // ctxTokenWorkspaceID is set when a valid API token is present. + ctxTokenWorkspaceID contextKey = "token_workspace_id" +) + +// TokenAuth middleware checks for an Authorization: Bearer pad_xxx header. +// If a valid token is found, the associated workspace ID is stored in the +// request context. If no token header is present the request passes through +// unchanged (existing localhost behaviour). Invalid or expired tokens +// receive a 401 response. +func (s *Server) TokenAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth == "" { + // No token provided — allow through (localhost access). + next.ServeHTTP(w, r) + return + } + + // Expect "Bearer pad_<64 hex chars>" + if !strings.HasPrefix(auth, "Bearer ") { + writeError(w, http.StatusUnauthorized, "unauthorized", "Invalid authorization header format") + return + } + + token := strings.TrimPrefix(auth, "Bearer ") + token = strings.TrimSpace(token) + + if !strings.HasPrefix(token, "pad_") || len(token) != 68 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Invalid token format") + return + } + + apiToken, err := s.store.ValidateToken(token) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Token validation failed") + return + } + if apiToken == nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "Invalid or expired token") + return + } + + // Store workspace ID from the token in request context. + ctx := context.WithValue(r.Context(), ctxTokenWorkspaceID, apiToken.WorkspaceID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// tokenWorkspaceID returns the workspace ID set by the TokenAuth middleware, +// or an empty string if no token was used. +func tokenWorkspaceID(r *http.Request) string { + v, _ := r.Context().Value(ctxTokenWorkspaceID).(string) + return v +} diff --git a/internal/server/server.go b/internal/server/server.go index 16579f7f..e5e07bcb 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -52,10 +52,11 @@ func (s *Server) setupRouter() { r.Use(cors.Handler(cors.Options{ AllowedOrigins: []string{"http://localhost:*", "http://127.0.0.1:*"}, AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"}, - AllowedHeaders: []string{"Accept", "Content-Type"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"}, AllowCredentials: true, MaxAge: 300, })) + r.Use(s.TokenAuth) r.Use(jsonContentType) // SSE endpoint (outside jsonContentType middleware) @@ -166,6 +167,13 @@ func (s *Server) setupRouter() { }) }) + // API Tokens + r.Route("/tokens", func(r chi.Router) { + r.Get("/", s.handleListTokens) + r.Post("/", s.handleCreateToken) + r.Delete("/{tokenID}", s.handleDeleteToken) + }) + // Dashboard (v2) r.Get("/dashboard", s.handleGetDashboard) }) diff --git a/internal/store/api_tokens.go b/internal/store/api_tokens.go new file mode 100644 index 00000000..1f7af617 --- /dev/null +++ b/internal/store/api_tokens.go @@ -0,0 +1,184 @@ +package store + +import ( + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/hex" + "fmt" + + "github.com/xarmian/pad/internal/models" +) + +// CreateAPIToken generates a new API token for a workspace. The plaintext +// token is returned in the response and is never stored — only its SHA-256 +// hash is persisted. +func (s *Store) CreateAPIToken(workspaceID string, input models.APITokenCreate) (*models.APITokenWithSecret, error) { + // Generate 32 random bytes → 64 hex chars + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return nil, fmt.Errorf("generate token: %w", err) + } + plaintext := "pad_" + hex.EncodeToString(raw) + prefix := plaintext[:8] + + // SHA-256 hash for storage + hash := sha256.Sum256([]byte(plaintext)) + tokenHash := hex.EncodeToString(hash[:]) + + id := newID() + ts := now() + + scopes := input.Scopes + if scopes == "" { + scopes = `["*"]` + } + + _, err := s.db.Exec(` + INSERT INTO api_tokens (id, workspace_id, name, token_hash, prefix, scopes, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, id, workspaceID, input.Name, tokenHash, prefix, scopes, ts) + if err != nil { + return nil, fmt.Errorf("insert api token: %w", err) + } + + token, err := s.getAPIToken(id) + if err != nil { + return nil, err + } + + return &models.APITokenWithSecret{ + APIToken: *token, + Token: plaintext, + }, nil +} + +// ListAPITokens returns all API tokens for a workspace (without secrets). +func (s *Store) ListAPITokens(workspaceID string) ([]models.APIToken, error) { + rows, err := s.db.Query(` + SELECT id, workspace_id, name, prefix, scopes, expires_at, last_used_at, created_at + FROM api_tokens + WHERE workspace_id = ? + ORDER BY created_at ASC + `, workspaceID) + if err != nil { + return nil, fmt.Errorf("list api tokens: %w", err) + } + defer rows.Close() + + var result []models.APIToken + for rows.Next() { + t, err := scanAPIToken(rows) + if err != nil { + return nil, err + } + result = append(result, *t) + } + return result, rows.Err() +} + +// DeleteAPIToken removes an API token by ID. +func (s *Store) DeleteAPIToken(id string) error { + result, err := s.db.Exec("DELETE FROM api_tokens WHERE id = ?", id) + if err != nil { + return fmt.Errorf("delete api token: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return sql.ErrNoRows + } + return nil +} + +// ValidateToken hashes the provided plaintext token, looks it up in the +// database, checks expiry, and updates last_used_at. Returns nil if the +// token is invalid or expired. +func (s *Store) ValidateToken(token string) (*models.APIToken, error) { + hash := sha256.Sum256([]byte(token)) + tokenHash := hex.EncodeToString(hash[:]) + + var t models.APIToken + var expiresAt, lastUsedAt *string + var createdAt string + + err := s.db.QueryRow(` + SELECT id, workspace_id, name, prefix, scopes, expires_at, last_used_at, created_at + FROM api_tokens + WHERE token_hash = ? + `, tokenHash).Scan( + &t.ID, &t.WorkspaceID, &t.Name, &t.Prefix, &t.Scopes, + &expiresAt, &lastUsedAt, &createdAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("validate token: %w", err) + } + + t.CreatedAt = parseTime(createdAt) + t.ExpiresAt = parseTimePtr(expiresAt) + t.LastUsedAt = parseTimePtr(lastUsedAt) + + // Check expiry + if t.ExpiresAt != nil && t.ExpiresAt.Before(parseTime(now())) { + return nil, nil + } + + // Update last_used_at + ts := now() + _, _ = s.db.Exec("UPDATE api_tokens SET last_used_at = ? WHERE id = ?", ts, t.ID) + + return &t, nil +} + +// getAPIToken retrieves a single API token by ID. +func (s *Store) getAPIToken(id string) (*models.APIToken, error) { + var t models.APIToken + var expiresAt, lastUsedAt *string + var createdAt string + + err := s.db.QueryRow(` + SELECT id, workspace_id, name, prefix, scopes, expires_at, last_used_at, created_at + FROM api_tokens + WHERE id = ? + `, id).Scan( + &t.ID, &t.WorkspaceID, &t.Name, &t.Prefix, &t.Scopes, + &expiresAt, &lastUsedAt, &createdAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get api token: %w", err) + } + + t.CreatedAt = parseTime(createdAt) + t.ExpiresAt = parseTimePtr(expiresAt) + t.LastUsedAt = parseTimePtr(lastUsedAt) + return &t, nil +} + +// scanner is an interface satisfied by both *sql.Row and *sql.Rows. +type scanner interface { + Scan(dest ...interface{}) error +} + +// scanAPIToken scans an API token from a row scanner. +func scanAPIToken(s scanner) (*models.APIToken, error) { + var t models.APIToken + var expiresAt, lastUsedAt *string + var createdAt string + + if err := s.Scan( + &t.ID, &t.WorkspaceID, &t.Name, &t.Prefix, &t.Scopes, + &expiresAt, &lastUsedAt, &createdAt, + ); err != nil { + return nil, fmt.Errorf("scan api token: %w", err) + } + + t.CreatedAt = parseTime(createdAt) + t.ExpiresAt = parseTimePtr(expiresAt) + t.LastUsedAt = parseTimePtr(lastUsedAt) + return &t, nil +} diff --git a/internal/store/migrations/011_api_tokens.sql b/internal/store/migrations/011_api_tokens.sql new file mode 100644 index 00000000..09031f49 --- /dev/null +++ b/internal/store/migrations/011_api_tokens.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS api_tokens ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + name TEXT NOT NULL, + token_hash TEXT NOT NULL, + prefix TEXT NOT NULL, + scopes TEXT NOT NULL DEFAULT '["*"]', + expires_at TEXT, + last_used_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) +); diff --git a/internal/store/store.go b/internal/store/store.go index 1da4028f..8544a9cb 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -72,6 +72,7 @@ func (s *Store) migrate() error { "008_tasks_phase_field.sql", "009_ideas_implemented_status.sql", "010_webhooks.sql", + "011_api_tokens.sql", } for _, name := range migrations {