summaryrefslogtreecommitdiff
path: root/player-server/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-17 21:31:20 +0300
committerPaul Buetow <paul@buetow.org>2026-05-17 21:31:20 +0300
commite8bdc1d09837d2bb0d1daec11af63da9d27fab43 (patch)
treecc34ad1f3bb538a93e318e18968d63fdc2857b40 /player-server/internal
parenta44aab26ae83d35d7bf83a4531c6c7fa77702b0d (diff)
Add Bearer token authentication alongside session cookies
Diffstat (limited to 'player-server/internal')
-rw-r--r--player-server/internal/api/handlers_podcast_test.go2
-rw-r--r--player-server/internal/api/handlers_test.go117
-rw-r--r--player-server/internal/api/middleware.go42
-rw-r--r--player-server/internal/repository/repository.go1
-rw-r--r--player-server/internal/service/auth.go94
-rw-r--r--player-server/internal/service/auth_test.go162
-rw-r--r--player-server/internal/service/mock.go44
-rw-r--r--player-server/internal/service/service.go14
8 files changed, 453 insertions, 23 deletions
diff --git a/player-server/internal/api/handlers_podcast_test.go b/player-server/internal/api/handlers_podcast_test.go
index 5f1c73d..a5d15bf 100644
--- a/player-server/internal/api/handlers_podcast_test.go
+++ b/player-server/internal/api/handlers_podcast_test.go
@@ -95,7 +95,7 @@ func setupPodcastE2E(t *testing.T) (srv *Server, store repository.Store, sm auth
hasher := &staticHasher{fixed: "hashed"}
sm = auth.NewSessionManager(dbStore, clk, time.Hour)
- authSvc := service.NewAuthService(dbStore, clk, hasher, sm)
+ authSvc := service.NewAuthService(dbStore, clk, hasher, sm, nil)
mediaRoot := t.TempDir()
helper := service.NewAccessHelper(dbStore)
diff --git a/player-server/internal/api/handlers_test.go b/player-server/internal/api/handlers_test.go
index 6cde80b..c200ebb 100644
--- a/player-server/internal/api/handlers_test.go
+++ b/player-server/internal/api/handlers_test.go
@@ -224,6 +224,109 @@ func TestMiddleware_RequireSession(t *testing.T) {
}
}
+func TestMiddleware_RequireSession_BearerOrCookie(t *testing.T) {
+ now := time.Now()
+ cookieSession := &model.Session{ID: "cookie-session", UserID: 1, ExpiresAt: now.Add(time.Hour)}
+ bearerSession := &model.Session{ID: "api-token:9", UserID: 2, ExpiresAt: now.Add(time.Hour)}
+
+ tests := []struct {
+ name string
+ authHeader string
+ cookie *http.Cookie
+ authSvc service.AuthService
+ sm auth.SessionManager
+ wantCode int
+ wantSession string
+ }{
+ {
+ name: "valid Bearer",
+ authHeader: "Bearer good-token",
+ authSvc: bearerAuthService(t, "good-token", bearerSession, nil),
+ wantCode: http.StatusOK,
+ wantSession: "api-token:9",
+ },
+ {
+ name: "revoked Bearer",
+ authHeader: "Bearer revoked-token",
+ authSvc: bearerAuthService(t, "revoked-token", nil, service.ErrInvalidCredentials),
+ wantCode: http.StatusUnauthorized,
+ },
+ {
+ name: "expired Bearer",
+ authHeader: "Bearer expired-token",
+ authSvc: bearerAuthService(t, "expired-token", nil, service.ErrInvalidCredentials),
+ wantCode: http.StatusUnauthorized,
+ },
+ {
+ name: "valid cookie",
+ cookie: &http.Cookie{Name: "session", Value: "cookie-session"},
+ sm: sessionManagerForMiddleware(t, now, cookieSession, nil),
+ wantCode: http.StatusOK,
+ wantSession: "cookie-session",
+ },
+ {
+ name: "neither",
+ sm: sessionManagerForMiddleware(t, now, nil, nil),
+ wantCode: http.StatusUnauthorized,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mw := NewMiddleware(tt.authSvc, tt.sm)
+ inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ sess, ok := r.Context().Value(sessionCtxKey).(*model.Session)
+ if !ok || sess == nil {
+ t.Fatal("expected session in context")
+ }
+ if sess.ID != tt.wantSession {
+ t.Fatalf("expected session %q, got %q", tt.wantSession, sess.ID)
+ }
+ w.WriteHeader(http.StatusOK)
+ })
+ handler := mw.RequireSession(inner)
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ if tt.authHeader != "" {
+ req.Header.Set("Authorization", tt.authHeader)
+ }
+ if tt.cookie != nil {
+ req.AddCookie(tt.cookie)
+ }
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+ if rr.Code != tt.wantCode {
+ t.Fatalf("expected %d, got %d", tt.wantCode, rr.Code)
+ }
+ })
+ }
+}
+
+func bearerAuthService(t *testing.T, wantToken string, sess *model.Session, err error) service.AuthService {
+ t.Helper()
+ return &service.MockAuthService{
+ AuthenticateBearerFunc: func(ctx context.Context, plaintext string) (*model.Session, error) {
+ if plaintext != wantToken {
+ t.Fatalf("expected bearer token %q, got %q", wantToken, plaintext)
+ }
+ return sess, err
+ },
+ }
+}
+
+func sessionManagerForMiddleware(t *testing.T, now time.Time, sess *model.Session, err error) auth.SessionManager {
+ t.Helper()
+ repo := repository.MockSessionRepo{
+ GetSessionByIDFunc: func(ctx context.Context, id string) (*model.Session, error) {
+ return sess, err
+ },
+ DeleteSessionFunc: func(ctx context.Context, id string) error {
+ return nil
+ },
+ }
+ return auth.NewSessionManager(&repo, &clock.MockClock{T: now}, time.Hour)
+}
+
func TestMiddleware_RequireAdmin(t *testing.T) {
tests := []struct {
name string
@@ -360,7 +463,7 @@ func TestServer_Bootstrap(t *testing.T) {
CreateSessionFunc: func(ctx context.Context, session *model.Session) error { return nil },
}
sm := auth.NewSessionManager(&repo, clk, time.Hour)
- authSvc := service.NewAuthService(store, clk, hasher, sm)
+ authSvc := service.NewAuthService(store, clk, hasher, sm, nil)
srv := newTestServer(t, store, hasher, sm, cfg, nil, nil, nil, nil, nil, nil, nil, nil, authSvc, nil)
body := `{"username":"admin","password":"secret"}`
@@ -388,7 +491,7 @@ func TestServer_Bootstrap(t *testing.T) {
CountUsersFunc: func(ctx context.Context) (int, error) { return 1, nil },
},
}
- authSvc := service.NewAuthService(store, clk, hasher, nil)
+ authSvc := service.NewAuthService(store, clk, hasher, nil, nil)
srv := newTestServer(t, store, hasher, nil, cfg, nil, nil, nil, nil, nil, nil, nil, nil, authSvc, nil)
body := `{"username":"admin","password":"secret"}`
req := httptest.NewRequest(http.MethodPost, "/api/bootstrap", bytes.NewReader([]byte(body)))
@@ -405,7 +508,7 @@ func TestServer_Bootstrap(t *testing.T) {
for _, path := range paths {
t.Run(path, func(t *testing.T) {
store := &repository.MockStore{UserRepo: repository.MockUserRepo{CountUsersFunc: func(ctx context.Context) (int, error) { return 0, nil }}}
- authSvc := service.NewAuthService(store, clk, hasher, nil)
+ authSvc := service.NewAuthService(store, clk, hasher, nil, nil)
srv := newTestServer(t, store, hasher, nil, cfg, nil, nil, nil, nil, nil, nil, nil, nil, authSvc, nil)
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader([]byte(`{"username":""}`)))
rr := httptest.NewRecorder()
@@ -446,7 +549,7 @@ func TestServer_Login(t *testing.T) {
CreateSessionFunc: func(ctx context.Context, session *model.Session) error { return nil },
}
sm := auth.NewSessionManager(&repo, clk, time.Hour)
- authSvc := service.NewAuthService(store, clk, hasher, sm)
+ authSvc := service.NewAuthService(store, clk, hasher, sm, nil)
srv := newTestServer(t, store, hasher, sm, cfg, nil, nil, nil, nil, nil, nil, nil, nil, authSvc, nil)
body := `{"username":"alice","password":"correct"}`
req := httptest.NewRequest(http.MethodPost, "/api/login", bytes.NewReader([]byte(body)))
@@ -475,7 +578,7 @@ func TestServer_Login(t *testing.T) {
},
},
}
- authSvc := service.NewAuthService(store, clk, hasher, nil)
+ authSvc := service.NewAuthService(store, clk, hasher, nil, nil)
srv := newTestServer(t, store, hasher, nil, cfg, nil, nil, nil, nil, nil, nil, nil, nil, authSvc, nil)
body := `{"username":"alice","password":"wrong"}`
req := httptest.NewRequest(http.MethodPost, "/api/login", bytes.NewReader([]byte(body)))
@@ -496,7 +599,7 @@ func TestServer_Login(t *testing.T) {
},
},
}
- authSvc := service.NewAuthService(store, clk, hasher, nil)
+ authSvc := service.NewAuthService(store, clk, hasher, nil, nil)
srv := newTestServer(t, store, hasher, nil, cfg, nil, nil, nil, nil, nil, nil, nil, nil, authSvc, nil)
body := `{"username":"nobody","password":"pass"}`
req := httptest.NewRequest(http.MethodPost, "/api/login", bytes.NewReader([]byte(body)))
@@ -523,7 +626,7 @@ func TestServer_SessionCookieSecure(t *testing.T) {
}
clk := &clock.MockClock{T: time.Now()}
sm := auth.NewSessionManager(&repo, clk, time.Hour)
- authSvc := service.NewAuthService(store, clk, hasher, sm)
+ authSvc := service.NewAuthService(store, clk, hasher, sm, nil)
t.Run("Secure=true by default", func(t *testing.T) {
cfg := &internal.Config{SessionTimeoutHours: 24, SecureCookies: true}
diff --git a/player-server/internal/api/middleware.go b/player-server/internal/api/middleware.go
index 5585be9..80128fd 100644
--- a/player-server/internal/api/middleware.go
+++ b/player-server/internal/api/middleware.go
@@ -2,6 +2,7 @@ package api
import (
"context"
+ "errors"
"net/http"
"strings"
@@ -12,6 +13,8 @@ import (
type ctxKey int
+var errUnauthorized = errors.New("unauthorized")
+
const (
sessionCtxKey ctxKey = iota
userCtxKey
@@ -32,16 +35,7 @@ func NewMiddleware(authSvc service.AuthService, sm auth.SessionManager) *Middlew
// For HTML page requests (Accept: text/html), redirects to /login.html instead of returning 401.
func (mw *Middleware) RequireSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- cookie, err := r.Cookie("session")
- if err != nil {
- if wantsHTML(r) {
- http.Redirect(w, r, "/login.html", http.StatusTemporaryRedirect)
- return
- }
- http.Error(w, "unauthorized", http.StatusUnauthorized)
- return
- }
- sess, err := mw.sm.ValidateSession(r.Context(), cookie.Value)
+ sess, err := mw.authenticate(r)
if err != nil || sess == nil {
if wantsHTML(r) {
http.Redirect(w, r, "/login.html", http.StatusTemporaryRedirect)
@@ -55,6 +49,34 @@ func (mw *Middleware) RequireSession(next http.Handler) http.Handler {
})
}
+func (mw *Middleware) authenticate(r *http.Request) (*model.Session, error) {
+ if token, ok := bearerToken(r); ok {
+ if mw.authSvc == nil {
+ return nil, errUnauthorized
+ }
+ return mw.authSvc.AuthenticateBearer(r.Context(), token)
+ }
+ cookie, err := r.Cookie("session")
+ if err != nil {
+ return nil, errUnauthorized
+ }
+ if mw.sm == nil {
+ return nil, errUnauthorized
+ }
+ return mw.sm.ValidateSession(r.Context(), cookie.Value)
+}
+
+func bearerToken(r *http.Request) (string, bool) {
+ fields := strings.Fields(r.Header.Get("Authorization"))
+ if len(fields) == 0 || !strings.EqualFold(fields[0], "Bearer") {
+ return "", false
+ }
+ if len(fields) != 2 {
+ return "", true
+ }
+ return fields[1], true
+}
+
// wantsHTML returns true if the request appears to be from a browser expecting an HTML page.
func wantsHTML(r *http.Request) bool {
accept := r.Header.Get("Accept")
diff --git a/player-server/internal/repository/repository.go b/player-server/internal/repository/repository.go
index c239b17..a1ad9fa 100644
--- a/player-server/internal/repository/repository.go
+++ b/player-server/internal/repository/repository.go
@@ -69,6 +69,7 @@ type GCStore interface {
// AuthServiceStore is the subset of Store required by service.AuthService.
type AuthServiceStore interface {
UserRepo
+ APITokenRepo
}
// ScannerStore is the subset of Store required by scanner.FSScanner.
diff --git a/player-server/internal/service/auth.go b/player-server/internal/service/auth.go
index 56210b2..6bd2055 100644
--- a/player-server/internal/service/auth.go
+++ b/player-server/internal/service/auth.go
@@ -3,6 +3,7 @@ package service
import (
"context"
"fmt"
+ "time"
"codeberg.org/snonux/player/internal/auth"
"codeberg.org/snonux/player/internal/clock"
@@ -16,15 +17,20 @@ type authService struct {
clock clock.Clock
hasher auth.Hasher
sm auth.SessionManager
+ tm auth.TokenManager
}
// NewAuthService creates a concrete AuthService.
-func NewAuthService(store repository.AuthServiceStore, clk clock.Clock, hasher auth.Hasher, sm auth.SessionManager) *authService {
+func NewAuthService(store repository.AuthServiceStore, clk clock.Clock, hasher auth.Hasher, sm auth.SessionManager, tm auth.TokenManager) *authService {
+ if tm == nil {
+ tm = auth.NewTokenManager()
+ }
return &authService{
store: store,
clock: clk,
hasher: hasher,
sm: sm,
+ tm: tm,
}
}
@@ -85,6 +91,70 @@ func (s *authService) Login(ctx context.Context, username, password string) (*Au
return &AuthResult{User: user, SessionID: sessID}, nil
}
+// CreateAPIToken creates a hashed API token and returns the one-time plaintext value.
+func (s *authService) CreateAPIToken(ctx context.Context, userID int64, name string, expiresAt *time.Time) (*CreateAPITokenResult, error) {
+ plaintext, hash := s.tm.Generate()
+ now := s.clock.Now()
+ token := &model.APIToken{
+ UserID: userID,
+ TokenHash: hash,
+ Name: name,
+ ExpiresAt: expiresAt,
+ CreatedAt: now,
+ }
+
+ id, err := s.store.Create(ctx, token)
+ if err != nil {
+ return nil, fmt.Errorf("create api token: %w", err)
+ }
+ token.ID = id
+
+ return &CreateAPITokenResult{Token: token, Plaintext: plaintext}, nil
+}
+
+// ListAPITokens returns API tokens owned by a user.
+func (s *authService) ListAPITokens(ctx context.Context, userID int64) ([]model.APIToken, error) {
+ return s.store.ListByUser(ctx, userID)
+}
+
+// RevokeAPIToken deletes an API token owned by a user.
+func (s *authService) RevokeAPIToken(ctx context.Context, userID, tokenID int64) error {
+ tokens, err := s.store.ListByUser(ctx, userID)
+ if err != nil {
+ return fmt.Errorf("list api tokens: %w", err)
+ }
+ if !hasAPIToken(tokens, tokenID) {
+ return ErrNotFound
+ }
+ if err := s.store.DeleteByID(ctx, tokenID); err != nil {
+ return fmt.Errorf("revoke api token: %w", err)
+ }
+ return nil
+}
+
+// AuthenticateBearer validates a Bearer token and returns a synthetic session.
+func (s *authService) AuthenticateBearer(ctx context.Context, plaintext string) (*model.Session, error) {
+ if plaintext == "" {
+ return nil, ErrInvalidCredentials
+ }
+
+ token, err := s.store.GetByHash(ctx, s.tm.Hash(plaintext))
+ if err != nil {
+ return nil, fmt.Errorf("get api token: %w", err)
+ }
+ if token == nil {
+ return nil, ErrInvalidCredentials
+ }
+
+ now := s.clock.Now()
+ if token.ExpiresAt != nil && now.After(*token.ExpiresAt) {
+ return nil, ErrInvalidCredentials
+ }
+ _ = s.store.TouchLastUsed(ctx, token.ID, now)
+
+ return syntheticSession(token, now), nil
+}
+
// CountUsers returns the number of user accounts.
func (s *authService) CountUsers(ctx context.Context) (int, error) {
return s.store.CountUsers(ctx)
@@ -94,3 +164,25 @@ func (s *authService) CountUsers(ctx context.Context) (int, error) {
func (s *authService) GetUserByID(ctx context.Context, id int64) (*model.User, error) {
return s.store.GetUserByID(ctx, id)
}
+
+func hasAPIToken(tokens []model.APIToken, tokenID int64) bool {
+ for _, token := range tokens {
+ if token.ID == tokenID {
+ return true
+ }
+ }
+ return false
+}
+
+func syntheticSession(token *model.APIToken, now time.Time) *model.Session {
+ expiresAt := now.Add(100 * 365 * 24 * time.Hour)
+ if token.ExpiresAt != nil {
+ expiresAt = *token.ExpiresAt
+ }
+ return &model.Session{
+ ID: fmt.Sprintf("api-token:%d", token.ID),
+ UserID: token.UserID,
+ ExpiresAt: expiresAt,
+ CreatedAt: token.CreatedAt,
+ }
+}
diff --git a/player-server/internal/service/auth_test.go b/player-server/internal/service/auth_test.go
new file mode 100644
index 0000000..213f6e5
--- /dev/null
+++ b/player-server/internal/service/auth_test.go
@@ -0,0 +1,162 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "codeberg.org/snonux/player/internal/clock"
+ "codeberg.org/snonux/player/internal/model"
+ "codeberg.org/snonux/player/internal/repository"
+)
+
+type fixedTokenManager struct {
+ plaintext string
+ hash string
+}
+
+func (m fixedTokenManager) Generate() (string, string) {
+ return m.plaintext, m.hash
+}
+
+func (m fixedTokenManager) Hash(plaintext string) string {
+ if plaintext == m.plaintext {
+ return m.hash
+ }
+ return "unknown"
+}
+
+func TestAuthService_APITokenCRUD(t *testing.T) {
+ ctx := context.Background()
+ now := time.Date(2026, 5, 17, 12, 0, 0, 0, time.UTC)
+ expiresAt := now.Add(time.Hour)
+ clk := &clock.MockClock{T: now}
+
+ var created *model.APIToken
+ var deletedID int64
+ store := &repository.MockStore{
+ APITokenRepo: repository.MockAPITokenRepo{
+ CreateFunc: func(ctx context.Context, token *model.APIToken) (int64, error) {
+ created = token
+ return 7, nil
+ },
+ ListByUserFunc: func(ctx context.Context, userID int64) ([]model.APIToken, error) {
+ if userID != 42 {
+ return nil, nil
+ }
+ return []model.APIToken{{ID: 7, UserID: userID, Name: "automation"}}, nil
+ },
+ DeleteByIDFunc: func(ctx context.Context, id int64) error {
+ deletedID = id
+ return nil
+ },
+ },
+ }
+ svc := NewAuthService(store, clk, nil, nil, fixedTokenManager{plaintext: "plain", hash: "hashed"})
+
+ result, err := svc.CreateAPIToken(ctx, 42, "automation", &expiresAt)
+ if err != nil {
+ t.Fatalf("create api token: %v", err)
+ }
+ if result.Plaintext != "plain" || result.Token.ID != 7 {
+ t.Fatalf("unexpected result: %#v", result)
+ }
+ if created == nil || created.UserID != 42 || created.TokenHash != "hashed" || created.CreatedAt != now {
+ t.Fatalf("unexpected created token: %#v", created)
+ }
+
+ tokens, err := svc.ListAPITokens(ctx, 42)
+ if err != nil {
+ t.Fatalf("list api tokens: %v", err)
+ }
+ if len(tokens) != 1 || tokens[0].ID != 7 {
+ t.Fatalf("unexpected tokens: %#v", tokens)
+ }
+
+ if err := svc.RevokeAPIToken(ctx, 42, 7); err != nil {
+ t.Fatalf("revoke api token: %v", err)
+ }
+ if deletedID != 7 {
+ t.Fatalf("expected delete id 7, got %d", deletedID)
+ }
+ if err := svc.RevokeAPIToken(ctx, 42, 8); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("expected ErrNotFound, got %v", err)
+ }
+}
+
+func TestAuthService_AuthenticateBearer(t *testing.T) {
+ ctx := context.Background()
+ now := time.Date(2026, 5, 17, 12, 0, 0, 0, time.UTC)
+ createdAt := now.Add(-time.Hour)
+ expiresAt := now.Add(time.Hour)
+ expiredAt := now.Add(-time.Second)
+
+ tests := []struct {
+ name string
+ token *model.APIToken
+ plaintext string
+ wantErr error
+ wantTouch bool
+ }{
+ {
+ name: "valid",
+ token: &model.APIToken{ID: 5, UserID: 42, CreatedAt: createdAt, ExpiresAt: &expiresAt},
+ plaintext: "plain",
+ wantTouch: true,
+ },
+ {
+ name: "revoked",
+ plaintext: "plain",
+ wantErr: ErrInvalidCredentials,
+ },
+ {
+ name: "expired",
+ token: &model.APIToken{ID: 5, UserID: 42, CreatedAt: createdAt, ExpiresAt: &expiredAt},
+ plaintext: "plain",
+ wantErr: ErrInvalidCredentials,
+ },
+ {
+ name: "empty",
+ wantErr: ErrInvalidCredentials,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ touched := false
+ store := &repository.MockStore{
+ APITokenRepo: repository.MockAPITokenRepo{
+ GetByHashFunc: func(ctx context.Context, tokenHash string) (*model.APIToken, error) {
+ if tokenHash != "hashed" {
+ t.Fatalf("unexpected hash: %q", tokenHash)
+ }
+ return tt.token, nil
+ },
+ TouchLastUsedFunc: func(ctx context.Context, id int64, lastUsedAt time.Time) error {
+ touched = true
+ if id != 5 || lastUsedAt != now {
+ t.Fatalf("unexpected touch: id=%d at=%v", id, lastUsedAt)
+ }
+ return nil
+ },
+ },
+ }
+ svc := NewAuthService(store, &clock.MockClock{T: now}, nil, nil, fixedTokenManager{plaintext: "plain", hash: "hashed"})
+
+ sess, err := svc.AuthenticateBearer(ctx, tt.plaintext)
+ if !errors.Is(err, tt.wantErr) {
+ t.Fatalf("expected error %v, got %v", tt.wantErr, err)
+ }
+ if touched != tt.wantTouch {
+ t.Fatalf("expected touched=%v, got %v", tt.wantTouch, touched)
+ }
+ if tt.wantErr != nil {
+ return
+ }
+ if sess == nil || sess.ID != "api-token:5" || sess.UserID != 42 || sess.ExpiresAt != expiresAt {
+ t.Fatalf("unexpected session: %#v", sess)
+ }
+ })
+ }
+}
diff --git a/player-server/internal/service/mock.go b/player-server/internal/service/mock.go
index 2593d9e..ef844a0 100644
--- a/player-server/internal/service/mock.go
+++ b/player-server/internal/service/mock.go
@@ -363,10 +363,14 @@ func (m *MockAdminService) RevokePermission(ctx context.Context, setID, userID i
// MockAuthService is a fake AuthService for testing.
type MockAuthService struct {
- BootstrapFunc func(ctx context.Context, username, password string) (*AuthResult, error)
- LoginFunc func(ctx context.Context, username, password string) (*AuthResult, error)
- CountUsersFunc func(ctx context.Context) (int, error)
- GetUserByIDFunc func(ctx context.Context, id int64) (*model.User, error)
+ BootstrapFunc func(ctx context.Context, username, password string) (*AuthResult, error)
+ LoginFunc func(ctx context.Context, username, password string) (*AuthResult, error)
+ CreateAPITokenFunc func(ctx context.Context, userID int64, name string, expiresAt *time.Time) (*CreateAPITokenResult, error)
+ ListAPITokensFunc func(ctx context.Context, userID int64) ([]model.APIToken, error)
+ RevokeAPITokenFunc func(ctx context.Context, userID, tokenID int64) error
+ AuthenticateBearerFunc func(ctx context.Context, plaintext string) (*model.Session, error)
+ CountUsersFunc func(ctx context.Context) (int, error)
+ GetUserByIDFunc func(ctx context.Context, id int64) (*model.User, error)
}
// Bootstrap calls BootstrapFunc or returns nil.
@@ -385,6 +389,38 @@ func (m *MockAuthService) Login(ctx context.Context, username, password string)
return nil, nil
}
+// CreateAPIToken calls CreateAPITokenFunc or returns nil.
+func (m *MockAuthService) CreateAPIToken(ctx context.Context, userID int64, name string, expiresAt *time.Time) (*CreateAPITokenResult, error) {
+ if m.CreateAPITokenFunc != nil {
+ return m.CreateAPITokenFunc(ctx, userID, name, expiresAt)
+ }
+ return nil, nil
+}
+
+// ListAPITokens calls ListAPITokensFunc or returns nil.
+func (m *MockAuthService) ListAPITokens(ctx context.Context, userID int64) ([]model.APIToken, error) {
+ if m.ListAPITokensFunc != nil {
+ return m.ListAPITokensFunc(ctx, userID)
+ }
+ return nil, nil
+}
+
+// RevokeAPIToken calls RevokeAPITokenFunc or returns nil.
+func (m *MockAuthService) RevokeAPIToken(ctx context.Context, userID, tokenID int64) error {
+ if m.RevokeAPITokenFunc != nil {
+ return m.RevokeAPITokenFunc(ctx, userID, tokenID)
+ }
+ return nil
+}
+
+// AuthenticateBearer calls AuthenticateBearerFunc or returns nil.
+func (m *MockAuthService) AuthenticateBearer(ctx context.Context, plaintext string) (*model.Session, error) {
+ if m.AuthenticateBearerFunc != nil {
+ return m.AuthenticateBearerFunc(ctx, plaintext)
+ }
+ return nil, nil
+}
+
// CountUsers calls CountUsersFunc or returns 0.
func (m *MockAuthService) CountUsers(ctx context.Context) (int, error) {
if m.CountUsersFunc != nil {
diff --git a/player-server/internal/service/service.go b/player-server/internal/service/service.go
index 79298d7..c4914b6 100644
--- a/player-server/internal/service/service.go
+++ b/player-server/internal/service/service.go
@@ -207,12 +207,26 @@ type AuthService interface {
Bootstrap(ctx context.Context, username, password string) (*AuthResult, error)
// Login authenticates a user and creates a session.
Login(ctx context.Context, username, password string) (*AuthResult, error)
+ // CreateAPIToken creates a hashed API token and returns the one-time plaintext value.
+ CreateAPIToken(ctx context.Context, userID int64, name string, expiresAt *time.Time) (*CreateAPITokenResult, error)
+ // ListAPITokens returns API tokens owned by a user.
+ ListAPITokens(ctx context.Context, userID int64) ([]model.APIToken, error)
+ // RevokeAPIToken deletes an API token owned by a user.
+ RevokeAPIToken(ctx context.Context, userID, tokenID int64) error
+ // AuthenticateBearer validates a Bearer token and returns a synthetic session.
+ AuthenticateBearer(ctx context.Context, plaintext string) (*model.Session, error)
// CountUsers returns the number of user accounts.
CountUsers(ctx context.Context) (int, error)
// GetUserByID returns a user by database ID.
GetUserByID(ctx context.Context, id int64) (*model.User, error)
}
+// CreateAPITokenResult contains the stored token metadata and one-time plaintext token.
+type CreateAPITokenResult struct {
+ Token *model.APIToken
+ Plaintext string
+}
+
// AuthResult contains the authenticated user and session ID.
type AuthResult struct {
User *model.User