package auth import ( "context" "errors" "fmt" "net/url" "path/filepath" "strings" "time" "github.com/google/uuid" "git.librete.ch/public/librenotes/internal/storage" ) // nowFn is overridable in tests. var nowFn = func() time.Time { return time.Now().UTC() } // Service orchestrates the magic-link login flow: request a link by // email and verify a returned link to issue a JWT session. type Service struct { users *storage.UserStore tokens *TokenStore limiter *RateLimiter mailer Mailer signer *Signer baseURL string dataDir string } // Config bundles dependencies for NewService. type Config struct { Users *storage.UserStore Tokens *TokenStore Limiter *RateLimiter Mailer Mailer Signer *Signer // BaseURL is the public origin used to build verification links, // e.g. "https://librenot.es". BaseURL string // DataDir is the parent directory under which per-user note // directories are created on first login. DataDir string } // NewService validates and assembles a Service. func NewService(c Config) (*Service, error) { if c.Users == nil || c.Tokens == nil || c.Limiter == nil || c.Mailer == nil || c.Signer == nil { return nil, fmt.Errorf("auth: missing dependency") } if c.BaseURL == "" { return nil, fmt.Errorf("auth: BaseURL required") } if c.DataDir == "" { return nil, fmt.Errorf("auth: DataDir required") } return &Service{ users: c.Users, tokens: c.Tokens, limiter: c.Limiter, mailer: c.Mailer, signer: c.Signer, baseURL: strings.TrimRight(c.BaseURL, "/"), dataDir: c.DataDir, }, nil } // RequestLogin generates a magic link and emails it. The user is // auto-created on first login. Returns ErrRateLimited if the email has // exceeded the limiter window. func (s *Service) RequestLogin(ctx context.Context, email string) error { email = strings.ToLower(strings.TrimSpace(email)) if !looksLikeEmail(email) { return fmt.Errorf("invalid email") } if err := s.limiter.Check(ctx, email); err != nil { return err } user, err := s.users.GetByEmail(ctx, email) if errors.Is(err, storage.ErrNotFound) { id := uuid.NewString() path := filepath.Join(s.dataDir, id) created, cerr := s.users.Create(ctx, storage.User{ ID: id, Email: email, StoragePath: path, }) if cerr != nil { return fmt.Errorf("create user: %w", cerr) } user = created } else if err != nil { return fmt.Errorf("lookup user: %w", err) } plaintext, err := s.tokens.Issue(ctx, user.ID, email) if err != nil { return err } link := s.baseURL + "/auth/verify?token=" + url.QueryEscape(plaintext) if err := s.mailer.SendMagicLink(ctx, email, link); err != nil { return fmt.Errorf("send mail: %w", err) } return nil } // VerifyResult holds the outcome of a successful magic-link verification. type VerifyResult struct { JWT string User storage.User } // Verify consumes a magic-link token and returns a signed session JWT. func (s *Service) Verify(ctx context.Context, token string) (VerifyResult, error) { userID, err := s.tokens.Consume(ctx, token) if err != nil { return VerifyResult{}, err } user, err := s.users.GetByID(ctx, userID) if err != nil { return VerifyResult{}, fmt.Errorf("load user: %w", err) } now := nowFn() if err := s.users.UpdateLastLogin(ctx, user.ID, now); err != nil { return VerifyResult{}, fmt.Errorf("update last login: %w", err) } jwtStr, err := s.signer.Issue(user.ID, user.Email) if err != nil { return VerifyResult{}, err } return VerifyResult{JWT: jwtStr, User: user}, nil } // looksLikeEmail does a minimal sanity check; full validation is left // to the SMTP server. We just want to reject obvious garbage. func looksLikeEmail(s string) bool { at := strings.IndexByte(s, '@') if at <= 0 || at == len(s)-1 { return false } if strings.ContainsAny(s, " \t\r\n") { return false } return strings.IndexByte(s[at+1:], '.') >= 0 }