package service import ( "context" "sync" "time" "github.com/snip/backend/internal/domain" "github.com/snip/backend/internal/port" ) const sessionTTL = 30 * 24 * time.Hour // AuthService coordinates the OAuth/OIDC login dance and sessions. It supports // any number of identity providers (Google, generic OIDC) keyed by name. // providers may be nil on construction and injected later via SetProviders. type AuthService struct { mu sync.RWMutex providers map[string]port.IdentityProvider users port.UserRepository sessions port.SessionManager } func NewAuthService(providers []port.IdentityProvider, users port.UserRepository, sessions port.SessionManager) *AuthService { s := &AuthService{users: users, sessions: sessions} s.setProviders(providers) return s } // SetProviders replaces the provider set; safe to call from a background goroutine. func (s *AuthService) SetProviders(providers []port.IdentityProvider) { s.mu.Lock() defer s.mu.Unlock() s.setProviders(providers) } func (s *AuthService) setProviders(providers []port.IdentityProvider) { m := make(map[string]port.IdentityProvider, len(providers)) for _, p := range providers { if p != nil { m[p.Name()] = p } } s.providers = m } // Providers lists configured provider names (e.g. "google", "oidc"). func (s *AuthService) Providers() []string { s.mu.RLock() defer s.mu.RUnlock() out := make([]string, 0, len(s.providers)) for name := range s.providers { out = append(out, name) } return out } // AuthURL returns the provider's authorization URL for the given state. func (s *AuthService) AuthURL(provider, state string) (string, error) { s.mu.RLock() p, ok := s.providers[provider] s.mu.RUnlock() if !ok { return "", domain.ErrNotFound } return p.AuthURL(state), nil } // Complete exchanges an auth code, upserts the user and issues a session token. func (s *AuthService) Complete(ctx context.Context, provider, code string) (*domain.User, string, error) { s.mu.RLock() p, ok := s.providers[provider] s.mu.RUnlock() if !ok { return nil, "", domain.ErrNotFound } id, err := p.Exchange(ctx, code) if err != nil { return nil, "", err } user, err := s.users.Upsert(ctx, &domain.User{ Email: id.Email, Name: displayName(id), Provider: provider, Subject: id.Subject, }) if err != nil { return nil, "", err } token, err := s.sessions.Issue(user.ID, sessionTTL) if err != nil { return nil, "", err } return user, token, nil } // Me resolves a session token back to a user. func (s *AuthService) Me(ctx context.Context, token string) (*domain.User, error) { userID, err := s.sessions.Verify(token) if err != nil { return nil, domain.ErrUnauthorized } return s.users.GetByID(ctx, userID) } func displayName(id *port.Identity) string { if id.Name != "" { return id.Name } if id.Email != "" { return id.Email } return "there" } // NoopRecorder discards clicks; handy for tests and the api command (which // doesn't serve redirects). type NoopRecorder struct{} func (NoopRecorder) Record(string) {}