feat: add JSON API with token auth (closes #69)

- Add API token model with SHA-256 hashed tokens
- Add migration 006_add_api_tokens.sql
- Add Bearer token auth middleware
- Add API endpoints under /api/v1/:
  - GET /whoami
  - POST /tokens (create new API token)
  - GET /apps (list all apps)
  - POST /apps (create app)
  - GET /apps/{id} (get app)
  - DELETE /apps/{id} (delete app)
  - POST /apps/{id}/deploy (trigger deployment)
  - GET /apps/{id}/deployments (list deployments)
- Add comprehensive tests for all API endpoints
- All tests pass, zero lint issues
This commit is contained in:
user
2026-02-16 00:20:41 -08:00
parent e31666ab5c
commit 0536f57ec2
7 changed files with 938 additions and 12 deletions

View File

@@ -2,6 +2,7 @@
package middleware
import (
"context"
"log/slog"
"math"
"net"
@@ -19,22 +20,28 @@ import (
"golang.org/x/time/rate"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/auth"
)
// corsMaxAge is the maximum age for CORS preflight responses in seconds.
const corsMaxAge = 300
// apiUserContextKey is the context key for the authenticated API user.
type apiUserContextKey struct{}
// Params contains dependencies for Middleware.
type Params struct {
fx.In
Logger *logger.Logger
Globals *globals.Globals
Config *config.Config
Auth *auth.Service
Logger *logger.Logger
Globals *globals.Globals
Config *config.Config
Auth *auth.Service
Database *database.Database
}
// Middleware provides HTTP middleware.
@@ -339,6 +346,74 @@ func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
}
}
// APITokenAuth returns middleware that authenticates requests via Bearer token.
// It looks up the token hash in the database and stores the user in context.
func (m *Middleware) APITokenAuth() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(
writer http.ResponseWriter,
request *http.Request,
) {
authHeader := request.Header.Get("Authorization")
if authHeader == "" {
http.Error(writer, `{"error":"missing Authorization header"}`, http.StatusUnauthorized)
return
}
const bearerPrefix = "Bearer "
if !strings.HasPrefix(authHeader, bearerPrefix) {
http.Error(writer, `{"error":"invalid Authorization header"}`, http.StatusUnauthorized)
return
}
rawToken := strings.TrimPrefix(authHeader, bearerPrefix)
if rawToken == "" {
http.Error(writer, `{"error":"empty token"}`, http.StatusUnauthorized)
return
}
hash := models.HashAPIToken(rawToken)
apiToken, err := models.FindAPITokenByHash(request.Context(), m.params.Database, hash)
if err != nil {
m.log.Error("api token lookup error", "error", err)
http.Error(writer, `{"error":"internal server error"}`, http.StatusInternalServerError)
return
}
if apiToken == nil {
http.Error(writer, `{"error":"invalid token"}`, http.StatusUnauthorized)
return
}
// Touch last used (best-effort, don't block on error)
_ = apiToken.TouchLastUsed(request.Context())
user, userErr := models.FindUser(request.Context(), m.params.Database, apiToken.UserID)
if userErr != nil || user == nil {
http.Error(writer, `{"error":"token user not found"}`, http.StatusUnauthorized)
return
}
ctx := context.WithValue(request.Context(), apiUserContextKey{}, user)
next.ServeHTTP(writer, request.WithContext(ctx))
})
}
}
// APIUserFromContext extracts the authenticated API user from the context.
func APIUserFromContext(ctx context.Context) *models.User {
user, _ := ctx.Value(apiUserContextKey{}).(*models.User)
return user
}
// SetupRequired returns middleware that redirects to setup if no user exists.
func (m *Middleware) SetupRequired() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {