diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-17 21:31:20 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-17 21:31:20 +0300 |
| commit | e8bdc1d09837d2bb0d1daec11af63da9d27fab43 (patch) | |
| tree | cc34ad1f3bb538a93e318e18968d63fdc2857b40 /player-server/internal | |
| parent | a44aab26ae83d35d7bf83a4531c6c7fa77702b0d (diff) | |
Add Bearer token authentication alongside session cookies
Diffstat (limited to 'player-server/internal')
| -rw-r--r-- | player-server/internal/api/handlers_podcast_test.go | 2 | ||||
| -rw-r--r-- | player-server/internal/api/handlers_test.go | 117 | ||||
| -rw-r--r-- | player-server/internal/api/middleware.go | 42 | ||||
| -rw-r--r-- | player-server/internal/repository/repository.go | 1 | ||||
| -rw-r--r-- | player-server/internal/service/auth.go | 94 | ||||
| -rw-r--r-- | player-server/internal/service/auth_test.go | 162 | ||||
| -rw-r--r-- | player-server/internal/service/mock.go | 44 | ||||
| -rw-r--r-- | player-server/internal/service/service.go | 14 |
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 |
