feat: add API token authentication (closes #87)

- Add api_tokens table migration (007)
- Add APIToken model with CRUD operations
- Generate tokens with upaas_ prefix + 32 hex chars
- Store SHA-256 hash of tokens (not plaintext)
- Update APISessionAuth middleware to check Bearer tokens
- Add POST/GET/DELETE /api/v1/tokens endpoints
- Token creation returns plaintext once; list never exposes it
- Expired and revoked tokens are rejected
- Tests for creation, listing, deletion, bearer auth, revocation
This commit is contained in:
clawbot
2026-02-19 13:47:39 -08:00
committed by user
parent 3a4e999382
commit 7387ba6b5c
8 changed files with 809 additions and 12 deletions

View File

@@ -0,0 +1,293 @@
package handlers_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// tokenRouter builds a chi router with token + app API routes.
func tokenRouter(tc *testContext) http.Handler {
r := chi.NewRouter()
r.Route("/api/v1", func(apiR chi.Router) {
apiR.Post("/login", tc.handlers.HandleAPILoginPOST())
apiR.Group(func(apiR chi.Router) {
apiR.Use(tc.middleware.APISessionAuth())
apiR.Get("/whoami", tc.handlers.HandleAPIWhoAmI())
apiR.Post("/tokens", tc.handlers.HandleAPICreateToken())
apiR.Get("/tokens", tc.handlers.HandleAPIListTokens())
apiR.Delete(
"/tokens/{tokenID}",
tc.handlers.HandleAPIDeleteToken(),
)
apiR.Get("/apps", tc.handlers.HandleAPIListApps())
})
})
return r
}
func TestAPICreateToken(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
r := tokenRouter(tc)
body := `{"name":"my-ci-token"}`
req := httptest.NewRequest(
http.MethodPost, "/api/v1/tokens",
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
for _, c := range cookies {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusCreated, rr.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
assert.Equal(t, "my-ci-token", resp["name"])
assert.Contains(t, resp["token"], "upaas_")
assert.NotEmpty(t, resp["id"])
}
func TestAPICreateTokenMissingName(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
r := tokenRouter(tc)
body := `{"name":""}`
req := httptest.NewRequest(
http.MethodPost, "/api/v1/tokens",
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
for _, c := range cookies {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
}
func TestAPIListTokens(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
r := tokenRouter(tc)
// Create two tokens.
for _, name := range []string{"token-a", "token-b"} {
body := `{"name":"` + name + `"}`
req := httptest.NewRequest(
http.MethodPost, "/api/v1/tokens",
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
for _, c := range cookies {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
require.Equal(t, http.StatusCreated, rr.Code)
}
// List tokens.
req := httptest.NewRequest(http.MethodGet, "/api/v1/tokens", nil)
for _, c := range cookies {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
var tokens []map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &tokens))
assert.Len(t, tokens, 2)
// Plaintext token must NOT appear in list.
for _, tok := range tokens {
assert.Nil(t, tok["token"])
}
}
func TestAPIDeleteToken(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
r := tokenRouter(tc)
// Create a token.
body := `{"name":"delete-me"}`
req := httptest.NewRequest(
http.MethodPost, "/api/v1/tokens",
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
for _, c := range cookies {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
require.Equal(t, http.StatusCreated, rr.Code)
var created map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
tokenID, ok := created["id"].(string)
require.True(t, ok)
// Delete it.
req = httptest.NewRequest(
http.MethodDelete, "/api/v1/tokens/"+tokenID, nil,
)
for _, c := range cookies {
req.AddCookie(c)
}
rr = httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
// List should be empty.
req = httptest.NewRequest(http.MethodGet, "/api/v1/tokens", nil)
for _, c := range cookies {
req.AddCookie(c)
}
rr = httptest.NewRecorder()
r.ServeHTTP(rr, req)
var tokens []map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &tokens))
assert.Empty(t, tokens)
}
func TestAPIBearerTokenAuth(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
r := tokenRouter(tc)
// Create a token via session auth.
body := `{"name":"bearer-test"}`
req := httptest.NewRequest(
http.MethodPost, "/api/v1/tokens",
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
for _, c := range cookies {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
require.Equal(t, http.StatusCreated, rr.Code)
var created map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
plaintext, ok := created["token"].(string)
require.True(t, ok)
// Use Bearer token to access an authenticated endpoint.
req = httptest.NewRequest(http.MethodGet, "/api/v1/apps", nil)
req.Header.Set("Authorization", "Bearer "+plaintext)
rr = httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
}
func TestAPIBearerTokenInvalid(t *testing.T) {
t.Parallel()
tc := setupTestHandlers(t)
r := tokenRouter(tc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/apps", nil)
req.Header.Set("Authorization", "Bearer upaas_invalidtoken1234567890ab")
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
}
func TestAPIBearerTokenRevoked(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
r := tokenRouter(tc)
// Create then delete a token.
body := `{"name":"revoke-test"}`
req := httptest.NewRequest(
http.MethodPost, "/api/v1/tokens",
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
for _, c := range cookies {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
require.Equal(t, http.StatusCreated, rr.Code)
var created map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
plaintext, ok := created["token"].(string)
require.True(t, ok)
tokenID, ok := created["id"].(string)
require.True(t, ok)
// Delete (revoke) the token.
req = httptest.NewRequest(
http.MethodDelete, "/api/v1/tokens/"+tokenID, nil,
)
for _, c := range cookies {
req.AddCookie(c)
}
rr = httptest.NewRecorder()
r.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
// Try to use the revoked token.
req = httptest.NewRequest(http.MethodGet, "/api/v1/apps", nil)
req.Header.Set("Authorization", "Bearer "+plaintext)
rr = httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
}

View File

@@ -0,0 +1,220 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/models"
)
// apiTokenResponse is the JSON representation of an API token.
type apiTokenResponse struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"createdAt"`
ExpiresAt *string `json:"expiresAt,omitempty"`
LastUsedAt *string `json:"lastUsedAt,omitempty"`
}
// apiTokenCreateResponse includes the plaintext token (shown once).
type apiTokenCreateResponse struct {
apiTokenResponse
Token string `json:"token"`
}
func tokenToAPI(t *models.APIToken) apiTokenResponse {
resp := apiTokenResponse{
ID: t.ID,
Name: t.Name,
CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
if t.ExpiresAt.Valid {
s := t.ExpiresAt.Time.Format("2006-01-02T15:04:05Z")
resp.ExpiresAt = &s
}
if t.LastUsedAt.Valid {
s := t.LastUsedAt.Time.Format("2006-01-02T15:04:05Z")
resp.LastUsedAt = &s
}
return resp
}
// createTokenRequest is the JSON body for token creation.
type createTokenRequest struct {
Name string `json:"name"`
}
// createAndSaveToken generates a token, saves it, and returns
// the plaintext and model.
func (h *Handlers) createAndSaveToken(
ctx context.Context,
userID int64,
name string,
) (string, *models.APIToken, error) {
plaintext, err := models.GenerateToken()
if err != nil {
return "", nil, fmt.Errorf("generating: %w", err)
}
token := models.NewAPIToken(h.db)
token.UserID = userID
token.Name = name
token.TokenHash = database.HashAPIToken(plaintext)
saveErr := token.Save(ctx)
if saveErr != nil {
return "", nil, fmt.Errorf("saving: %w", saveErr)
}
return plaintext, token, nil
}
// HandleAPICreateToken returns a handler that creates an API token.
func (h *Handlers) HandleAPICreateToken() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
user, err := h.auth.GetCurrentUser(
request.Context(), request,
)
if err != nil || user == nil {
h.respondJSON(writer, request,
map[string]string{"error": "unauthorized"},
http.StatusUnauthorized)
return
}
var req createTokenRequest
decodeErr := json.NewDecoder(request.Body).Decode(&req)
if decodeErr != nil {
h.respondJSON(writer, request,
map[string]string{"error": "invalid JSON body"},
http.StatusBadRequest)
return
}
if req.Name == "" {
h.respondJSON(writer, request,
map[string]string{"error": "name is required"},
http.StatusBadRequest)
return
}
plaintext, token, createErr := h.createAndSaveToken(
request.Context(), user.ID, req.Name,
)
if createErr != nil {
h.log.Error("api: token creation failed",
"error", createErr)
h.respondJSON(writer, request,
map[string]string{"error": "internal error"},
http.StatusInternalServerError)
return
}
h.respondJSON(writer, request, apiTokenCreateResponse{
apiTokenResponse: tokenToAPI(token),
Token: plaintext,
}, http.StatusCreated)
}
}
// HandleAPIListTokens returns a handler that lists API tokens.
func (h *Handlers) HandleAPIListTokens() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
user, err := h.auth.GetCurrentUser(
request.Context(), request,
)
if err != nil || user == nil {
h.respondJSON(writer, request,
map[string]string{"error": "unauthorized"},
http.StatusUnauthorized)
return
}
tokens, listErr := models.ListAPITokensByUser(
request.Context(), h.db, user.ID,
)
if listErr != nil {
h.log.Error("api: failed to list tokens",
"error", listErr)
h.respondJSON(writer, request,
map[string]string{"error": "internal error"},
http.StatusInternalServerError)
return
}
result := make([]apiTokenResponse, 0, len(tokens))
for _, t := range tokens {
result = append(result, tokenToAPI(t))
}
h.respondJSON(writer, request, result, http.StatusOK)
}
}
// HandleAPIDeleteToken returns a handler that revokes an API token.
func (h *Handlers) HandleAPIDeleteToken() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
user, err := h.auth.GetCurrentUser(
request.Context(), request,
)
if err != nil || user == nil {
h.respondJSON(writer, request,
map[string]string{"error": "unauthorized"},
http.StatusUnauthorized)
return
}
tokenID := chi.URLParam(request, "tokenID")
token, findErr := models.FindAPIToken(
request.Context(), h.db, tokenID,
)
if findErr != nil {
h.respondJSON(writer, request,
map[string]string{"error": "internal error"},
http.StatusInternalServerError)
return
}
if token == nil || token.UserID != user.ID {
h.respondJSON(writer, request,
map[string]string{"error": "token not found"},
http.StatusNotFound)
return
}
deleteErr := token.Delete(request.Context())
if deleteErr != nil {
h.log.Error("api: failed to delete token",
"error", deleteErr)
h.respondJSON(writer, request,
map[string]string{"error": "internal error"},
http.StatusInternalServerError)
return
}
h.respondJSON(writer, request,
map[string]string{"status": "deleted"},
http.StatusOK)
}
}

View File

@@ -169,10 +169,11 @@ func setupTestHandlers(t *testing.T) *testContext {
require.NoError(t, handlerErr)
mw, mwErr := middleware.New(fx.Lifecycle(nil), middleware.Params{
Logger: logInstance,
Globals: globalInstance,
Config: cfg,
Auth: authSvc,
Logger: logInstance,
Globals: globalInstance,
Config: cfg,
Auth: authSvc,
Database: dbInstance,
})
require.NoError(t, mwErr)