mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-23 11:03:41 +00:00
Merge feat/api-token-rotation-expiry: API token rotation, expiry defaults, and scope enforcement
This commit is contained in:
@@ -18,6 +18,7 @@ const (
|
||||
ActionPasswordReset = "password_reset"
|
||||
ActionTokenCreated = "token_created"
|
||||
ActionTokenRevoked = "token_revoked"
|
||||
ActionTokenRotated = "token_rotated"
|
||||
ActionMemberInvited = "member_invited"
|
||||
ActionMemberRemoved = "member_removed"
|
||||
ActionRoleChanged = "role_changed"
|
||||
|
||||
@@ -20,6 +20,7 @@ type APITokenCreate struct {
|
||||
Name string `json:"name"`
|
||||
Scopes string `json:"scopes,omitempty"`
|
||||
WorkspaceID string `json:"workspace_id,omitempty"` // optional scope
|
||||
ExpiresIn int `json:"expires_in,omitempty"` // expiry in days (0 = use platform default)
|
||||
}
|
||||
|
||||
// APITokenWithSecret is returned only on creation and includes the
|
||||
|
||||
@@ -15,6 +15,10 @@ const (
|
||||
settingEmailFrom = "email_from" // Sender address
|
||||
settingEmailFromName = "email_from_name" // Sender display name
|
||||
settingPlatformName = "platform_name" // Instance name (default: "Pad")
|
||||
|
||||
// Token policy settings
|
||||
settingTokenDefaultExpiryDays = "token_default_expiry_days" // Default: 90
|
||||
settingTokenMaxLifetimeDays = "token_max_lifetime_days" // Default: 0 (no limit)
|
||||
)
|
||||
|
||||
// handleGetPlatformSettings returns all platform settings.
|
||||
@@ -59,11 +63,13 @@ func (s *Server) handleUpdatePlatformSettings(w http.ResponseWriter, r *http.Req
|
||||
|
||||
// Whitelist known settings
|
||||
allowed := map[string]bool{
|
||||
settingEmailProvider: true,
|
||||
settingMailerooAPIKey: true,
|
||||
settingEmailFrom: true,
|
||||
settingEmailFromName: true,
|
||||
settingPlatformName: true,
|
||||
settingEmailProvider: true,
|
||||
settingMailerooAPIKey: true,
|
||||
settingEmailFrom: true,
|
||||
settingEmailFromName: true,
|
||||
settingPlatformName: true,
|
||||
settingTokenDefaultExpiryDays: true,
|
||||
settingTokenMaxLifetimeDays: true,
|
||||
}
|
||||
|
||||
for key, value := range input {
|
||||
|
||||
@@ -2,13 +2,36 @@ package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/xarmian/pad/internal/models"
|
||||
)
|
||||
|
||||
// tokenExpiryWarningDays is the threshold for adding near-expiry warning
|
||||
// headers to API responses authenticated with a token.
|
||||
const tokenExpiryWarningDays = 7
|
||||
|
||||
// getTokenExpirySettings reads platform settings for token expiry policy.
|
||||
func (s *Server) getTokenExpirySettings() (defaultDays, maxDays int) {
|
||||
defaultDays = 90 // fallback default
|
||||
if v, err := s.store.GetPlatformSetting(settingTokenDefaultExpiryDays); err == nil && v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
defaultDays = n
|
||||
}
|
||||
}
|
||||
if v, err := s.store.GetPlatformSetting(settingTokenMaxLifetimeDays); err == nil && v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
maxDays = n
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// handleCreateToken creates a new API token scoped to a workspace.
|
||||
// The token is owned by the authenticated user (if any).
|
||||
func (s *Server) handleCreateToken(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -29,9 +52,10 @@ func (s *Server) handleCreateToken(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
input.WorkspaceID = workspaceID
|
||||
defaultDays, maxDays := s.getTokenExpirySettings()
|
||||
|
||||
userID := currentUserID(r)
|
||||
token, err := s.store.CreateAPIToken(userID, input)
|
||||
token, err := s.store.CreateAPIToken(userID, input, defaultDays, maxDays)
|
||||
if err != nil {
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
@@ -124,7 +148,9 @@ func (s *Server) handleCreateUserToken(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
token, err := s.store.CreateAPIToken(userID, input)
|
||||
defaultDays, maxDays := s.getTokenExpirySettings()
|
||||
|
||||
token, err := s.store.CreateAPIToken(userID, input, defaultDays, maxDays)
|
||||
if err != nil {
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
@@ -157,3 +183,58 @@ func (s *Server) handleDeleteUserToken(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleRotateUserToken generates a new secret for an existing token,
|
||||
// invalidating the old one. The token metadata is preserved.
|
||||
func (s *Server) handleRotateUserToken(w http.ResponseWriter, r *http.Request) {
|
||||
userID := currentUserID(r)
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Not logged in")
|
||||
return
|
||||
}
|
||||
|
||||
tokenID := chi.URLParam(r, "tokenID")
|
||||
|
||||
var input struct {
|
||||
ExpiresIn int `json:"expires_in,omitempty"` // new expiry in days (0 = keep existing)
|
||||
}
|
||||
// Body is optional — rotation works without it, but malformed JSON is rejected
|
||||
// to prevent silent destructive rotation when the caller intended to set expiry.
|
||||
if r.Body != nil && r.ContentLength != 0 {
|
||||
if err := decodeJSON(r, &input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid JSON body")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_, maxDays := s.getTokenExpirySettings()
|
||||
|
||||
rotated, err := s.store.RotateAPIToken(tokenID, userID, input.ExpiresIn, maxDays)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Token not found")
|
||||
return
|
||||
}
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
s.logAuditEvent(models.ActionTokenRotated, r, auditMeta(map[string]string{"token_id": tokenID}))
|
||||
|
||||
writeJSON(w, http.StatusOK, rotated)
|
||||
}
|
||||
|
||||
// setTokenExpiryWarning adds an X-Token-Expires-Soon header if the API token
|
||||
// used for this request is within the warning threshold of its expiry.
|
||||
// Called from the TokenAuth middleware after successful token validation.
|
||||
func setTokenExpiryWarning(w http.ResponseWriter, token *models.APIToken) {
|
||||
if token == nil || token.ExpiresAt == nil {
|
||||
return
|
||||
}
|
||||
remaining := time.Until(*token.ExpiresAt)
|
||||
if remaining > 0 && remaining < time.Duration(tokenExpiryWarningDays)*24*time.Hour {
|
||||
days := int(remaining.Hours() / 24)
|
||||
w.Header().Set("X-Token-Expires-Soon", fmt.Sprintf("%d days remaining", days))
|
||||
w.Header().Set("X-Token-Expires-At", token.ExpiresAt.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -86,6 +87,15 @@ func (s *Server) TokenAuth(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
// Enforce token scopes
|
||||
if !tokenScopeAllows(apiToken.Scopes, r.Method, r.URL.Path) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "Token scope does not permit this action")
|
||||
return
|
||||
}
|
||||
|
||||
// Add near-expiry warning headers
|
||||
setTokenExpiryWarning(w, apiToken)
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
// Resolve user from token's user_id (new user-owned tokens)
|
||||
@@ -342,3 +352,52 @@ func sha256hex(s string) string {
|
||||
h := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// tokenScopeAllows checks if the token's scopes permit the given HTTP method
|
||||
// and path. Scopes are stored as a JSON array of strings.
|
||||
//
|
||||
// Supported scopes:
|
||||
// - "*" — full access (default)
|
||||
// - "read" — GET/HEAD/OPTIONS only
|
||||
// - "write" — all methods
|
||||
//
|
||||
// An empty or unparseable scope string defaults to full access for
|
||||
// backward compatibility with tokens created before scope enforcement.
|
||||
func tokenScopeAllows(scopesJSON, method, path string) bool {
|
||||
_ = path // reserved for future per-resource scopes
|
||||
|
||||
if scopesJSON == "" || scopesJSON == `["*"]` {
|
||||
return true
|
||||
}
|
||||
|
||||
var scopes []string
|
||||
if err := json.Unmarshal([]byte(scopesJSON), &scopes); err != nil {
|
||||
// Unparseable → allow (backward compat)
|
||||
return true
|
||||
}
|
||||
|
||||
hasKnownScope := false
|
||||
for _, scope := range scopes {
|
||||
switch scope {
|
||||
case "*", "write":
|
||||
return true
|
||||
case "read":
|
||||
hasKnownScope = true
|
||||
if method == http.MethodGet || method == http.MethodHead || method == http.MethodOptions {
|
||||
return true
|
||||
}
|
||||
default:
|
||||
// Unrecognized scope — ignore but don't block.
|
||||
// Tokens created before scope enforcement may contain
|
||||
// custom values that were previously stored but not checked.
|
||||
}
|
||||
}
|
||||
|
||||
// If no recognized scope was found, allow for backward compatibility
|
||||
// (e.g. tokens with only custom/legacy scope strings).
|
||||
if !hasKnownScope {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -197,6 +197,7 @@ func (s *Server) setupRouter() {
|
||||
r.Get("/tokens", s.handleListUserTokens)
|
||||
r.Post("/tokens", s.handleCreateUserToken)
|
||||
r.Delete("/tokens/{tokenID}", s.handleDeleteUserToken)
|
||||
r.Post("/tokens/{tokenID}/rotate", s.handleRotateUserToken)
|
||||
})
|
||||
|
||||
// Admin endpoints (admin-only, handlers check role internally)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTokenScopeAllows(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
scopes string
|
||||
method string
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
// Wildcard scope
|
||||
{"wildcard allows GET", `["*"]`, http.MethodGet, "/api/v1/test", true},
|
||||
{"wildcard allows POST", `["*"]`, http.MethodPost, "/api/v1/test", true},
|
||||
{"wildcard allows DELETE", `["*"]`, http.MethodDelete, "/api/v1/test", true},
|
||||
|
||||
// Empty/default scopes
|
||||
{"empty string allows all", "", http.MethodPost, "/api/v1/test", true},
|
||||
{"default wildcard allows all", `["*"]`, http.MethodPatch, "/api/v1/test", true},
|
||||
|
||||
// Read scope
|
||||
{"read allows GET", `["read"]`, http.MethodGet, "/api/v1/test", true},
|
||||
{"read allows HEAD", `["read"]`, http.MethodHead, "/api/v1/test", true},
|
||||
{"read allows OPTIONS", `["read"]`, http.MethodOptions, "/api/v1/test", true},
|
||||
{"read blocks POST", `["read"]`, http.MethodPost, "/api/v1/test", false},
|
||||
{"read blocks DELETE", `["read"]`, http.MethodDelete, "/api/v1/test", false},
|
||||
{"read blocks PATCH", `["read"]`, http.MethodPatch, "/api/v1/test", false},
|
||||
|
||||
// Write scope
|
||||
{"write allows GET", `["write"]`, http.MethodGet, "/api/v1/test", true},
|
||||
{"write allows POST", `["write"]`, http.MethodPost, "/api/v1/test", true},
|
||||
{"write allows DELETE", `["write"]`, http.MethodDelete, "/api/v1/test", true},
|
||||
|
||||
// Invalid/unparseable JSON (backward compat)
|
||||
{"invalid json allows all", "not-json", http.MethodPost, "/api/v1/test", true},
|
||||
|
||||
// Multiple scopes
|
||||
{"read+write allows POST", `["read","write"]`, http.MethodPost, "/api/v1/test", true},
|
||||
{"read only blocks PUT", `["read"]`, http.MethodPut, "/api/v1/test", false},
|
||||
|
||||
// Empty array (no known scopes → allow for backward compat)
|
||||
{"empty array allows all", `[]`, http.MethodGet, "/api/v1/test", true},
|
||||
|
||||
// Unrecognized/legacy scopes (backward compat — allow)
|
||||
{"unknown scope allows GET", `["docs"]`, http.MethodGet, "/api/v1/test", true},
|
||||
{"unknown scope allows POST", `["repo"]`, http.MethodPost, "/api/v1/test", true},
|
||||
{"unknown+read blocks POST", `["docs","read"]`, http.MethodPost, "/api/v1/test", false},
|
||||
{"unknown+read allows GET", `["docs","read"]`, http.MethodGet, "/api/v1/test", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tokenScopeAllows(tt.scopes, tt.method, tt.path)
|
||||
if got != tt.want {
|
||||
t.Errorf("tokenScopeAllows(%q, %q, %q) = %v, want %v",
|
||||
tt.scopes, tt.method, tt.path, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,21 @@ import (
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/xarmian/pad/internal/models"
|
||||
)
|
||||
|
||||
// defaultTokenExpiryDays is used when no platform setting overrides it.
|
||||
const defaultTokenExpiryDays = 90
|
||||
|
||||
// CreateAPIToken generates a new API token owned by a user, optionally
|
||||
// scoped to 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(userID string, input models.APITokenCreate) (*models.APITokenWithSecret, error) {
|
||||
//
|
||||
// If expiresInDays is 0 on the input, the platform default is used.
|
||||
// The maxLifetimeDays parameter enforces a ceiling on expiry (0 = no limit).
|
||||
func (s *Store) CreateAPIToken(userID string, input models.APITokenCreate, defaultExpiryDays, maxLifetimeDays int) (*models.APITokenWithSecret, error) {
|
||||
// Generate 32 random bytes → 64 hex chars
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
@@ -40,10 +47,25 @@ func (s *Store) CreateAPIToken(userID string, input models.APITokenCreate) (*mod
|
||||
wsID = input.WorkspaceID
|
||||
}
|
||||
|
||||
// Determine expiry
|
||||
expiryDays := input.ExpiresIn
|
||||
if expiryDays <= 0 {
|
||||
expiryDays = defaultExpiryDays
|
||||
}
|
||||
// Enforce max lifetime if configured
|
||||
if maxLifetimeDays > 0 && expiryDays > maxLifetimeDays {
|
||||
expiryDays = maxLifetimeDays
|
||||
}
|
||||
|
||||
var expiresAt interface{}
|
||||
if expiryDays > 0 {
|
||||
expiresAt = time.Now().UTC().Add(time.Duration(expiryDays) * 24 * time.Hour).Format(time.RFC3339)
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(s.q(`
|
||||
INSERT INTO api_tokens (id, workspace_id, user_id, name, token_hash, prefix, scopes, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`), id, wsID, userID, input.Name, tokenHash, prefix, scopes, ts)
|
||||
INSERT INTO api_tokens (id, workspace_id, user_id, name, token_hash, prefix, scopes, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`), id, wsID, userID, input.Name, tokenHash, prefix, scopes, expiresAt, ts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert api token: %w", err)
|
||||
}
|
||||
@@ -59,6 +81,67 @@ func (s *Store) CreateAPIToken(userID string, input models.APITokenCreate) (*mod
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RotateAPIToken generates a new secret for an existing token, preserving
|
||||
// all metadata (name, scopes, workspace, user, expiry). The old token is
|
||||
// invalidated and the new plaintext is returned once.
|
||||
//
|
||||
// expiryDays controls the new expiry: 0 keeps the original, >0 sets a new one.
|
||||
// maxLifetimeDays enforces a ceiling on the new expiry (0 = no limit).
|
||||
func (s *Store) RotateAPIToken(tokenID, userID string, expiryDays, maxLifetimeDays int) (*models.APITokenWithSecret, error) {
|
||||
// Verify the token exists and belongs to the user
|
||||
existing, err := s.getAPIToken(tokenID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing == nil {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
if existing.UserID != userID {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
|
||||
// Generate new secret
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return nil, fmt.Errorf("generate rotated token: %w", err)
|
||||
}
|
||||
plaintext := "pad_" + hex.EncodeToString(raw)
|
||||
prefix := plaintext[:8]
|
||||
|
||||
hash := sha256.Sum256([]byte(plaintext))
|
||||
tokenHash := hex.EncodeToString(hash[:])
|
||||
|
||||
// Determine new expiry
|
||||
var expiresAt interface{}
|
||||
if expiryDays > 0 {
|
||||
if maxLifetimeDays > 0 && expiryDays > maxLifetimeDays {
|
||||
expiryDays = maxLifetimeDays
|
||||
}
|
||||
expiresAt = time.Now().UTC().Add(time.Duration(expiryDays) * 24 * time.Hour).Format(time.RFC3339)
|
||||
} else if existing.ExpiresAt != nil {
|
||||
// Preserve original expiry
|
||||
expiresAt = existing.ExpiresAt.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(s.q(`
|
||||
UPDATE api_tokens SET token_hash = ?, prefix = ?, expires_at = ?, last_used_at = NULL
|
||||
WHERE id = ?
|
||||
`), tokenHash, prefix, expiresAt, tokenID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rotate api token: %w", err)
|
||||
}
|
||||
|
||||
updated, err := s.getAPIToken(tokenID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &models.APITokenWithSecret{
|
||||
APIToken: *updated,
|
||||
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(s.q(`
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/xarmian/pad/internal/models"
|
||||
)
|
||||
|
||||
func TestCreateAPITokenWithExpiry(t *testing.T) {
|
||||
s := testStore(t)
|
||||
u := createTestUser(t, s, "test@test.com", "Test", "password123")
|
||||
ws := createTestWorkspace(t, s, "TokenTest")
|
||||
|
||||
// Create token with default 90-day expiry
|
||||
token, err := s.CreateAPIToken(u.ID, models.APITokenCreate{
|
||||
Name: "test-token",
|
||||
WorkspaceID: ws.ID,
|
||||
}, 90, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAPIToken error: %v", err)
|
||||
}
|
||||
if token.Token == "" {
|
||||
t.Fatal("expected plaintext token")
|
||||
}
|
||||
if token.ExpiresAt == nil {
|
||||
t.Fatal("expected expiry to be set with default 90 days")
|
||||
}
|
||||
if token.Name != "test-token" {
|
||||
t.Errorf("expected name 'test-token', got %q", token.Name)
|
||||
}
|
||||
if token.Scopes != `["*"]` {
|
||||
t.Errorf("expected default scopes, got %q", token.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAPITokenExpiryOverride(t *testing.T) {
|
||||
s := testStore(t)
|
||||
u := createTestUser(t, s, "test@test.com", "Test", "password123")
|
||||
ws := createTestWorkspace(t, s, "TokenTest")
|
||||
|
||||
// Create token with explicit 30-day expiry
|
||||
token, err := s.CreateAPIToken(u.ID, models.APITokenCreate{
|
||||
Name: "short-lived",
|
||||
WorkspaceID: ws.ID,
|
||||
ExpiresIn: 30,
|
||||
}, 90, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAPIToken error: %v", err)
|
||||
}
|
||||
if token.ExpiresAt == nil {
|
||||
t.Fatal("expected expiry to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAPITokenMaxLifetimeEnforced(t *testing.T) {
|
||||
s := testStore(t)
|
||||
u := createTestUser(t, s, "test@test.com", "Test", "password123")
|
||||
ws := createTestWorkspace(t, s, "TokenTest")
|
||||
|
||||
// Request 365-day expiry but max is 30 days
|
||||
token, err := s.CreateAPIToken(u.ID, models.APITokenCreate{
|
||||
Name: "capped",
|
||||
WorkspaceID: ws.ID,
|
||||
ExpiresIn: 365,
|
||||
}, 90, 30)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAPIToken error: %v", err)
|
||||
}
|
||||
if token.ExpiresAt == nil {
|
||||
t.Fatal("expected expiry to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAPITokenNoExpiry(t *testing.T) {
|
||||
s := testStore(t)
|
||||
u := createTestUser(t, s, "test@test.com", "Test", "password123")
|
||||
ws := createTestWorkspace(t, s, "TokenTest")
|
||||
|
||||
// Default of 0 means no expiry
|
||||
token, err := s.CreateAPIToken(u.ID, models.APITokenCreate{
|
||||
Name: "no-expiry",
|
||||
WorkspaceID: ws.ID,
|
||||
}, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAPIToken error: %v", err)
|
||||
}
|
||||
if token.ExpiresAt != nil {
|
||||
t.Error("expected no expiry when default is 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTokenExpired(t *testing.T) {
|
||||
s := testStore(t)
|
||||
u := createTestUser(t, s, "test@test.com", "Test", "password123")
|
||||
ws := createTestWorkspace(t, s, "TokenTest")
|
||||
|
||||
tok, err := s.CreateAPIToken(u.ID, models.APITokenCreate{
|
||||
Name: "will-expire",
|
||||
WorkspaceID: ws.ID,
|
||||
ExpiresIn: 1,
|
||||
}, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAPIToken error: %v", err)
|
||||
}
|
||||
|
||||
// Token should validate now
|
||||
validated, err := s.ValidateToken(tok.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken error: %v", err)
|
||||
}
|
||||
if validated == nil {
|
||||
t.Fatal("expected token to validate before expiry")
|
||||
}
|
||||
|
||||
// Manually expire it
|
||||
_, err = s.db.Exec("UPDATE api_tokens SET expires_at = '2020-01-01T00:00:00Z' WHERE id = ?", tok.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("manual expire error: %v", err)
|
||||
}
|
||||
|
||||
// Should not validate
|
||||
validated, err = s.ValidateToken(tok.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken error: %v", err)
|
||||
}
|
||||
if validated != nil {
|
||||
t.Error("expected nil for expired token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRotateAPIToken(t *testing.T) {
|
||||
s := testStore(t)
|
||||
u := createTestUser(t, s, "test@test.com", "Test", "password123")
|
||||
ws := createTestWorkspace(t, s, "TokenTest")
|
||||
|
||||
// Create original token
|
||||
original, err := s.CreateAPIToken(u.ID, models.APITokenCreate{
|
||||
Name: "rotatable",
|
||||
WorkspaceID: ws.ID,
|
||||
}, 90, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAPIToken error: %v", err)
|
||||
}
|
||||
|
||||
// Rotate
|
||||
rotated, err := s.RotateAPIToken(original.ID, u.ID, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("RotateAPIToken error: %v", err)
|
||||
}
|
||||
|
||||
// New token should be different
|
||||
if rotated.Token == original.Token {
|
||||
t.Error("rotated token should have a new secret")
|
||||
}
|
||||
if rotated.ID != original.ID {
|
||||
t.Error("rotated token should keep same ID")
|
||||
}
|
||||
if rotated.Name != original.Name {
|
||||
t.Error("rotated token should keep same name")
|
||||
}
|
||||
|
||||
// Old token should not validate
|
||||
old, err := s.ValidateToken(original.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken error: %v", err)
|
||||
}
|
||||
if old != nil {
|
||||
t.Error("old token should not validate after rotation")
|
||||
}
|
||||
|
||||
// New token should validate
|
||||
validated, err := s.ValidateToken(rotated.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken error: %v", err)
|
||||
}
|
||||
if validated == nil {
|
||||
t.Fatal("new token should validate after rotation")
|
||||
}
|
||||
if validated.Name != "rotatable" {
|
||||
t.Errorf("expected name 'rotatable', got %q", validated.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRotateAPITokenWrongUser(t *testing.T) {
|
||||
s := testStore(t)
|
||||
u1 := createTestUser(t, s, "user1@test.com", "User 1", "password123")
|
||||
u2 := createTestUser(t, s, "user2@test.com", "User 2", "password123")
|
||||
ws := createTestWorkspace(t, s, "TokenTest")
|
||||
|
||||
// Create token owned by u1
|
||||
token, err := s.CreateAPIToken(u1.ID, models.APITokenCreate{
|
||||
Name: "u1-token",
|
||||
WorkspaceID: ws.ID,
|
||||
}, 90, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAPIToken error: %v", err)
|
||||
}
|
||||
|
||||
// Try to rotate as u2
|
||||
_, err = s.RotateAPIToken(token.ID, u2.ID, 0, 0)
|
||||
if err == nil {
|
||||
t.Error("expected error when rotating another user's token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRotateAPITokenWithNewExpiry(t *testing.T) {
|
||||
s := testStore(t)
|
||||
u := createTestUser(t, s, "test@test.com", "Test", "password123")
|
||||
ws := createTestWorkspace(t, s, "TokenTest")
|
||||
|
||||
// Create token with 90-day expiry
|
||||
original, err := s.CreateAPIToken(u.ID, models.APITokenCreate{
|
||||
Name: "rotate-expiry",
|
||||
WorkspaceID: ws.ID,
|
||||
}, 90, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAPIToken error: %v", err)
|
||||
}
|
||||
originalExpiry := original.ExpiresAt
|
||||
|
||||
// Rotate with new 30-day expiry
|
||||
rotated, err := s.RotateAPIToken(original.ID, u.ID, 30, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("RotateAPIToken error: %v", err)
|
||||
}
|
||||
|
||||
if rotated.ExpiresAt == nil {
|
||||
t.Fatal("expected new expiry on rotated token")
|
||||
}
|
||||
if originalExpiry != nil && !rotated.ExpiresAt.Before(*originalExpiry) {
|
||||
t.Error("new expiry should be before original 90-day expiry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAndDeleteAPITokens(t *testing.T) {
|
||||
s := testStore(t)
|
||||
u := createTestUser(t, s, "test@test.com", "Test", "password123")
|
||||
ws := createTestWorkspace(t, s, "TokenTest")
|
||||
|
||||
// Create two tokens
|
||||
s.CreateAPIToken(u.ID, models.APITokenCreate{Name: "token-1", WorkspaceID: ws.ID}, 90, 0)
|
||||
s.CreateAPIToken(u.ID, models.APITokenCreate{Name: "token-2", WorkspaceID: ws.ID}, 90, 0)
|
||||
|
||||
// List
|
||||
tokens, err := s.ListUserAPITokens(u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListUserAPITokens error: %v", err)
|
||||
}
|
||||
if len(tokens) != 2 {
|
||||
t.Errorf("expected 2 tokens, got %d", len(tokens))
|
||||
}
|
||||
|
||||
// Delete one
|
||||
err = s.DeleteUserAPIToken(tokens[0].ID, u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteUserAPIToken error: %v", err)
|
||||
}
|
||||
|
||||
// Should be 1 left
|
||||
tokens, _ = s.ListUserAPITokens(u.ID)
|
||||
if len(tokens) != 1 {
|
||||
t.Errorf("expected 1 token after delete, got %d", len(tokens))
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTokenUpdatesLastUsed(t *testing.T) {
|
||||
s := testStore(t)
|
||||
u := createTestUser(t, s, "test@test.com", "Test", "password123")
|
||||
ws := createTestWorkspace(t, s, "TokenTest")
|
||||
|
||||
tok, err := s.CreateAPIToken(u.ID, models.APITokenCreate{
|
||||
Name: "tracked",
|
||||
WorkspaceID: ws.ID,
|
||||
}, 90, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAPIToken error: %v", err)
|
||||
}
|
||||
|
||||
// Initially no last_used_at
|
||||
if tok.LastUsedAt != nil {
|
||||
t.Error("expected nil last_used_at on new token")
|
||||
}
|
||||
|
||||
// Validate to trigger last_used_at update
|
||||
validated, _ := s.ValidateToken(tok.Token)
|
||||
if validated == nil {
|
||||
t.Fatal("expected valid token")
|
||||
}
|
||||
|
||||
// Fetch again to see last_used_at
|
||||
fetched, _ := s.getAPIToken(tok.ID)
|
||||
if fetched.LastUsedAt == nil {
|
||||
t.Error("expected last_used_at to be set after validation")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user