From 0462f639bc1e1973d30b9ffcd358ac62fa10fb46 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 17 May 2026 21:36:23 +0300 Subject: Add /api/v1/auth/tokens endpoints for API token management --- player-server/internal/api/handlers_auth.go | 115 +++++++++++++++++ player-server/internal/api/handlers_test.go | 187 ++++++++++++++++++++++++++++ player-server/internal/api/server.go | 3 + 3 files changed, 305 insertions(+) (limited to 'player-server/internal') diff --git a/player-server/internal/api/handlers_auth.go b/player-server/internal/api/handlers_auth.go index ed6b3e9..c2665a7 100644 --- a/player-server/internal/api/handlers_auth.go +++ b/player-server/internal/api/handlers_auth.go @@ -4,8 +4,10 @@ import ( "errors" "fmt" "net/http" + "strings" "time" + "codeberg.org/snonux/player/internal/model" "codeberg.org/snonux/player/internal/service" ) @@ -19,6 +21,25 @@ type loginRequest struct { Password string `json:"password"` } +type createAPITokenRequest struct { + Name string `json:"name"` + ExpiresInDays *int `json:"expires_in_days"` +} + +type createAPITokenResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + Token string `json:"token"` +} + +type apiTokenResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + LastUsedAt *time.Time `json:"last_used_at"` + ExpiresAt *time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` +} + // ------------------------------------------------------------------ // Bootstrap & Auth // ------------------------------------------------------------------ @@ -88,6 +109,75 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +func (s *Server) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) { + if !requireService(w, s.authSvc) { + return + } + + var req createAPITokenRequest + if err := readJSON(r, &req); err != nil { + badRequest(w, "invalid request body") + return + } + name := strings.TrimSpace(req.Name) + if name == "" { + badRequest(w, "name required") + return + } + + expiresAt, ok := apiTokenExpiresAt(req.ExpiresInDays) + if !ok { + badRequest(w, "expires_in_days must be greater than zero") + return + } + + result, err := s.authSvc.CreateAPIToken(r.Context(), userIDFromContext(r), name, expiresAt) + if err != nil { + handleError(w, err) + return + } + if result == nil || result.Token == nil { + handleError(w, fmt.Errorf("create api token returned nil result")) + return + } + + writeJSON(w, http.StatusOK, createAPITokenResponse{ + ID: result.Token.ID, + Name: result.Token.Name, + Token: result.Plaintext, + }) +} + +func (s *Server) handleListAPITokens(w http.ResponseWriter, r *http.Request) { + if !requireService(w, s.authSvc) { + return + } + + tokens, err := s.authSvc.ListAPITokens(r.Context(), userIDFromContext(r)) + if err != nil { + handleError(w, err) + return + } + writeJSON(w, http.StatusOK, apiTokenResponses(tokens)) +} + +func (s *Server) handleRevokeAPIToken(w http.ResponseWriter, r *http.Request) { + if !requireService(w, s.authSvc) { + return + } + + id := pathID(r, "id") + if id == 0 { + badRequest(w, "invalid token id") + return + } + if err := s.authSvc.RevokeAPIToken(r.Context(), userIDFromContext(r), id); err != nil { + handleError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } @@ -124,3 +214,28 @@ func (s *Server) clearSessionCookie(w http.ResponseWriter) { Expires: time.Unix(0, 0), }) } + +func apiTokenExpiresAt(expiresInDays *int) (*time.Time, bool) { + if expiresInDays == nil { + return nil, true + } + if *expiresInDays <= 0 { + return nil, false + } + expiresAt := time.Now().Add(time.Duration(*expiresInDays) * 24 * time.Hour) + return &expiresAt, true +} + +func apiTokenResponses(tokens []model.APIToken) []apiTokenResponse { + responses := make([]apiTokenResponse, 0, len(tokens)) + for _, token := range tokens { + responses = append(responses, apiTokenResponse{ + ID: token.ID, + Name: token.Name, + LastUsedAt: token.LastUsedAt, + ExpiresAt: token.ExpiresAt, + CreatedAt: token.CreatedAt, + }) + } + return responses +} diff --git a/player-server/internal/api/handlers_test.go b/player-server/internal/api/handlers_test.go index c200ebb..0800ba5 100644 --- a/player-server/internal/api/handlers_test.go +++ b/player-server/internal/api/handlers_test.go @@ -754,6 +754,193 @@ func TestServer_Logout(t *testing.T) { }) } +func TestServer_APITokens(t *testing.T) { + cfg := &internal.Config{SessionTimeoutHours: 24} + store := buildSessionStore(42) + sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) + cookie := addSessionCookie(t, store, sm, 42) + createdAt := time.Date(2026, 5, 17, 12, 0, 0, 0, time.UTC) + expiresAt := createdAt.Add(24 * time.Hour) + + t.Run("create", func(t *testing.T) { + authSvc := &service.MockAuthService{ + CountUsersFunc: func(context.Context) (int, error) { return 1, nil }, + CreateAPITokenFunc: func(ctx context.Context, userID int64, name string, expiresAt *time.Time) (*service.CreateAPITokenResult, error) { + if userID != 42 { + t.Fatalf("expected user 42, got %d", userID) + } + if name != "automation" { + t.Fatalf("expected trimmed token name, got %q", name) + } + if expiresAt == nil { + t.Fatal("expected expires_at") + } + return &service.CreateAPITokenResult{ + Token: &model.APIToken{ID: 7, UserID: userID, Name: name, ExpiresAt: expiresAt}, + Plaintext: "plain-token", + }, nil + }, + } + srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, nil, nil, nil, nil, nil, nil, nil, nil, authSvc, nil) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/tokens", strings.NewReader(`{"name":" automation ","expires_in_days":1}`)) + req.AddCookie(cookie) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d: %s", http.StatusOK, rr.Code, rr.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if resp["token"] != "plain-token" || resp["name"] != "automation" { + t.Fatalf("unexpected create response: %+v", resp) + } + }) + + t.Run("list metadata only", func(t *testing.T) { + authSvc := &service.MockAuthService{ + CountUsersFunc: func(context.Context) (int, error) { return 1, nil }, + ListAPITokensFunc: func(ctx context.Context, userID int64) ([]model.APIToken, error) { + if userID != 42 { + t.Fatalf("expected user 42, got %d", userID) + } + return []model.APIToken{{ + ID: 7, + UserID: userID, + TokenHash: "secret-hash", + Name: "automation", + ExpiresAt: &expiresAt, + CreatedAt: createdAt, + LastUsedAt: &createdAt, + }}, nil + }, + } + srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, nil, nil, nil, nil, nil, nil, nil, nil, authSvc, nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/tokens", nil) + req.AddCookie(cookie) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d: %s", http.StatusOK, rr.Code, rr.Body.String()) + } + if strings.Contains(rr.Body.String(), "secret-hash") || strings.Contains(rr.Body.String(), "token") { + t.Fatalf("list response leaked token material: %s", rr.Body.String()) + } + }) + + t.Run("revoke", func(t *testing.T) { + var revokedID int64 + authSvc := &service.MockAuthService{ + CountUsersFunc: func(context.Context) (int, error) { return 1, nil }, + RevokeAPITokenFunc: func(ctx context.Context, userID, tokenID int64) error { + if userID != 42 { + t.Fatalf("expected user 42, got %d", userID) + } + revokedID = tokenID + return nil + }, + } + srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, nil, nil, nil, nil, nil, nil, nil, nil, authSvc, nil) + req := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/tokens/7", nil) + req.AddCookie(cookie) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusNoContent { + t.Fatalf("expected %d, got %d: %s", http.StatusNoContent, rr.Code, rr.Body.String()) + } + if revokedID != 7 { + t.Fatalf("expected token 7 revoked, got %d", revokedID) + } + }) +} + +func TestServer_APITokenBearerFlow(t *testing.T) { + ctx := context.Background() + dbStore, err := repository.Open(":memory:") + if err != nil { + t.Fatalf("open db: %v", err) + } + defer dbStore.Close() + + now := time.Now() + userID, err := dbStore.CreateUser(ctx, &model.User{ + Username: "admin", + PasswordHash: "hashed", + IsAdmin: true, + CreatedAt: now, + }) + if err != nil { + t.Fatalf("create user: %v", err) + } + + clk := &clock.MockClock{T: now} + hasher := &staticHasher{fixed: "hashed"} + sm := auth.NewSessionManager(dbStore, clk, time.Hour) + authSvc := service.NewAuthService(dbStore, clk, hasher, sm, nil) + mediaSvc := service.NewMediaService(dbStore, clk, t.TempDir(), nil, nil) + cfg := &internal.Config{SessionTimeoutHours: 24, MaxUploadSizeMB: 10} + srv := newTestServer(t, dbStore, hasher, sm, cfg, mediaSvc, mediaSvc, mediaSvc, mediaSvc, mediaSvc, mediaSvc, nil, nil, authSvc, nil) + + sessionID, err := sm.CreateSession(ctx, userID) + if err != nil { + t.Fatalf("create session: %v", err) + } + cookie := &http.Cookie{Name: "session", Value: sessionID} + + createReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/tokens", strings.NewReader(`{"name":"cli"}`)) + createReq.AddCookie(cookie) + createRR := httptest.NewRecorder() + srv.ServeHTTP(createRR, createReq) + if createRR.Code != http.StatusOK { + t.Fatalf("expected create %d, got %d: %s", http.StatusOK, createRR.Code, createRR.Body.String()) + } + + var created createAPITokenResponse + if err := json.Unmarshal(createRR.Body.Bytes(), &created); err != nil { + t.Fatalf("unmarshal create response: %v", err) + } + if created.ID == 0 || created.Token == "" || created.Name != "cli" { + t.Fatalf("unexpected create response: %+v", created) + } + + listReq := httptest.NewRequest(http.MethodGet, "/api/v1/auth/tokens", nil) + listReq.AddCookie(cookie) + listRR := httptest.NewRecorder() + srv.ServeHTTP(listRR, listReq) + if listRR.Code != http.StatusOK { + t.Fatalf("expected list %d, got %d: %s", http.StatusOK, listRR.Code, listRR.Body.String()) + } + if strings.Contains(listRR.Body.String(), created.Token) || strings.Contains(listRR.Body.String(), `"token"`) { + t.Fatalf("list response leaked plaintext token: %s", listRR.Body.String()) + } + + bearerReq := httptest.NewRequest(http.MethodGet, "/api/v1/media", nil) + bearerReq.Header.Set("Authorization", "Bearer "+created.Token) + bearerRR := httptest.NewRecorder() + srv.ServeHTTP(bearerRR, bearerReq) + if bearerRR.Code != http.StatusOK { + t.Fatalf("expected bearer media %d, got %d: %s", http.StatusOK, bearerRR.Code, bearerRR.Body.String()) + } + + revokeReq := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/v1/auth/tokens/%d", created.ID), nil) + revokeReq.AddCookie(cookie) + revokeRR := httptest.NewRecorder() + srv.ServeHTTP(revokeRR, revokeReq) + if revokeRR.Code != http.StatusNoContent { + t.Fatalf("expected revoke %d, got %d: %s", http.StatusNoContent, revokeRR.Code, revokeRR.Body.String()) + } + + afterRevokeReq := httptest.NewRequest(http.MethodGet, "/api/v1/media", nil) + afterRevokeReq.Header.Set("Authorization", "Bearer "+created.Token) + afterRevokeRR := httptest.NewRecorder() + srv.ServeHTTP(afterRevokeRR, afterRevokeReq) + if afterRevokeRR.Code != http.StatusUnauthorized { + t.Fatalf("expected revoked bearer %d, got %d: %s", http.StatusUnauthorized, afterRevokeRR.Code, afterRevokeRR.Body.String()) + } +} + func TestServer_Healthz(t *testing.T) { srv := newTestServer(t, &repository.MockStore{}, nil, nil, &internal.Config{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/healthz", nil) diff --git a/player-server/internal/api/server.go b/player-server/internal/api/server.go index f0aaa3f..9f1a526 100644 --- a/player-server/internal/api/server.go +++ b/player-server/internal/api/server.go @@ -189,6 +189,9 @@ func (s *Server) routesHTML() { // routesAuth wires the logout route. func (s *Server) routesAuth() { s.handleBoth(http.MethodPost, "/api/logout", s.requireSession(s.handleLogout)) + s.handleBoth(http.MethodPost, "/api/auth/tokens", s.requireSession(s.handleCreateAPIToken)) + s.handleBoth(http.MethodGet, "/api/auth/tokens", s.requireSession(s.handleListAPITokens)) + s.handleBoth(http.MethodDelete, "/api/auth/tokens/{id}", s.requireSession(s.handleRevokeAPIToken)) } // routesConfig wires authenticated client configuration. -- cgit v1.2.3