package auth import ( "context" "errors" "fmt" "time" "github.com/google/uuid" "backend/internal/modules/users" "backend/internal/platform/security" ) // UserFinder is the minimal slice of the users module this service needs. // Defined here (consumer side) rather than depending on the full // users.Repository, so auth only ever reads user records, never writes them. type UserFinder interface { FindByEmail(ctx context.Context, email string) (*users.User, error) FindByID(ctx context.Context, id uuid.UUID) (*users.User, error) } type Service struct { users UserFinder refresh RefreshStore jwtSecret string accessTTL time.Duration refreshTTL time.Duration } func NewService(userFinder UserFinder, refresh RefreshStore, jwtSecret string, accessTTL, refreshTTL time.Duration) *Service { return &Service{ users: userFinder, refresh: refresh, jwtSecret: jwtSecret, accessTTL: accessTTL, refreshTTL: refreshTTL, } } func (s *Service) RefreshTTL() time.Duration { return s.refreshTTL } // Login authenticates a user for a specific space (aud) and enforces that // the account's role matches that space (an admin account cannot log into // the customer space and vice versa, even before the customer module ships). func (s *Service) Login(ctx context.Context, aud security.Audience, expectedRole, email, password string) (*TokenPair, *users.User, error) { user, err := s.users.FindByEmail(ctx, email) if err != nil { if errors.Is(err, users.ErrNotFound) { return nil, nil, ErrInvalidCredentials } return nil, nil, err } if !user.IsActive { return nil, nil, ErrAccountDisabled } if user.Role != expectedRole { return nil, nil, ErrInvalidCredentials } ok, err := security.VerifyPassword(user.PasswordHash, password) if err != nil || !ok { return nil, nil, ErrInvalidCredentials } pair, err := s.issuePair(ctx, aud, user) if err != nil { return nil, nil, err } return pair, user, nil } // Refresh rotates a refresh token (single use) and issues a fresh pair. If // the presented token is unknown to the store (already used, revoked, or // expired) it is treated as invalid; genuine reuse of a stolen token simply // fails from that point on since the old token was deleted at issuance time. func (s *Service) Refresh(ctx context.Context, aud security.Audience, refreshToken string) (*TokenPair, error) { newRefreshToken, userID, err := s.refresh.Rotate(ctx, aud, refreshToken, s.refreshTTL) if err != nil { return nil, err } user, err := s.users.FindByID(ctx, userID) if err != nil { return nil, fmt.Errorf("lookup user for refresh: %w", err) } if !user.IsActive { _ = s.refresh.RevokeAllForUser(ctx, aud, userID) return nil, ErrAccountDisabled } access, err := security.IssueAccessToken(s.jwtSecret, s.accessTTL, user.ID, user.Role, aud) if err != nil { return nil, fmt.Errorf("issue access token: %w", err) } return &TokenPair{AccessToken: access, RefreshToken: newRefreshToken}, nil } func (s *Service) Logout(ctx context.Context, aud security.Audience, refreshToken string) error { return s.refresh.Revoke(ctx, aud, refreshToken) } func (s *Service) LogoutAll(ctx context.Context, aud security.Audience, userID uuid.UUID) error { return s.refresh.RevokeAllForUser(ctx, aud, userID) } func (s *Service) Me(ctx context.Context, userID uuid.UUID) (*users.User, error) { return s.users.FindByID(ctx, userID) } func (s *Service) issuePair(ctx context.Context, aud security.Audience, user *users.User) (*TokenPair, error) { access, err := security.IssueAccessToken(s.jwtSecret, s.accessTTL, user.ID, user.Role, aud) if err != nil { return nil, fmt.Errorf("issue access token: %w", err) } refreshToken, err := s.refresh.Issue(ctx, aud, user.ID, s.refreshTTL) if err != nil { return nil, fmt.Errorf("issue refresh token: %w", err) } return &TokenPair{AccessToken: access, RefreshToken: refreshToken}, nil }