package auth import ( "context" "errors" "fmt" "log/slog" "strings" "time" "github.com/flowy-live/llink/genproto/aero" "github.com/flowy-live/llink/internal/utils" "github.com/redis/go-redis/v9" "go.jetify.com/typeid" ) const ( sessionExpiry = time.Hour * 24 * 15 extendSessionThreshold = time.Hour * 24 * 3 codeExpiry = time.Minute * 15 ) var ( ErrInvalidCode = errors.New("invalid code") ErrSessionNotFound = errors.New("session not found") ) type sessionTokenPrefix struct{} func (sessionTokenPrefix) Prefix() string { return "session" } type sessionToken struct { typeid.TypeID[sessionTokenPrefix] } func newSessionToken() (sessionToken, error) { return typeid.New[sessionToken]() } type AuthService interface { // RequestSignInCode generates a code and emails it to the provided email. // To retrieve a session, client must verify with VerifySignInCode. RequestSignInCode(ctx context.Context, email string) error // VerifySignInCode returns ErrInvalidCode if incorrect code VerifySignInCode(ctx context.Context, email, code string) (sessionToken string, err error) // GetSession returns ErrSessionNotFound if no valid session GetSession(ctx context.Context, sessionToken string) (email string, err error) // ExtendSession returns ErrSessionNotFound if no valid session ExtendSession(ctx context.Context, sessionToken string) error SignOut(ctx context.Context, sessionToken string) error IsSystemAdmin(ctx context.Context, email string) bool } type authServiceImpl struct { redisClient *redis.Client aeroSvc pbaero.PrimaryClient } func NewAuthService(redisClient *redis.Client, aeroSvc pbaero.PrimaryClient) AuthService { return &authServiceImpl{redisClient: redisClient, aeroSvc: aeroSvc} } func (a *authServiceImpl) IsSystemAdmin(ctx context.Context, email string) bool { formattedEmail, err := utils.NormalizeEmail(email) if err != nil { slog.Error("problem validating email", "error", err) return false } if strings.Contains(formattedEmail, "@flowylabs.ai") { return true } return false } func (a *authServiceImpl) RequestSignInCode(ctx context.Context, email string) error { if email == "" { return errors.New("email is required") } code := utils.RandomStringNumbers(4) formattedEmail, err := utils.NormalizeEmail(email) if err != nil { return errors.New("email is not valid") } err = a.redisClient.Set(ctx, formattedEmail, code, codeExpiry).Err() if err != nil { slog.Error("error setting code in redis", "error", err) return fmt.Errorf("error storing sign-in code: %w", err) } message := fmt.Sprintf("Here is your one-time code for signing into Flowy: %s\n\nPlease do not share this with anyone.\n\nBest, \nFlowy Team", code) subject := fmt.Sprintf("Sign In - Your One-Time Code for Flowy.llink") _, err = a.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{ ToEmails: []string{formattedEmail}, Subject: subject, TemplateData: &pbaero.ShootEmailRequest_SimpleTextData{ SimpleTextData: &pbaero.SimpleTextData{ Message: message, }, }, }) if err != nil { return fmt.Errorf("an error occurred while sending the email: %w", err) } slog.Info("sent sign in code", "email", formattedEmail) return nil } func (a *authServiceImpl) VerifySignInCode(ctx context.Context, email, code string) (string, error) { formattedEmail, err := utils.NormalizeEmail(email) if err != nil { return "", fmt.Errorf("invalid email: %w", err) } storedCode, err := a.redisClient.Get(ctx, formattedEmail).Result() if err != nil { if errors.Is(err, redis.Nil) { return "", ErrInvalidCode } slog.Error("error getting code from redis", "error", err) return "", fmt.Errorf("error verifying code: %w", err) } if storedCode != code { return "", ErrInvalidCode } if err := a.redisClient.Del(ctx, formattedEmail).Err(); err != nil { slog.Error("error deleting code from redis", "error", err) } token, err := a.createSession(ctx, formattedEmail) if err != nil { return "", err } return token, nil } func (a *authServiceImpl) GetSession(ctx context.Context, token string) (string, error) { email, err := a.redisClient.Get(ctx, token).Result() if err != nil { if errors.Is(err, redis.Nil) { return "", ErrSessionNotFound } return "", fmt.Errorf("error getting session: %w", err) } return email, nil } func (a *authServiceImpl) ExtendSession(ctx context.Context, token string) error { ttl, err := a.redisClient.TTL(ctx, token).Result() if err != nil { return fmt.Errorf("error checking session TTL: %w", err) } if ttl < 0 { return ErrSessionNotFound } if ttl < extendSessionThreshold { if err := a.redisClient.Expire(ctx, token, sessionExpiry).Err(); err != nil { return fmt.Errorf("error extending session: %w", err) } } return nil } func (a *authServiceImpl) SignOut(ctx context.Context, token string) error { if err := a.redisClient.Del(ctx, token).Err(); err != nil { return fmt.Errorf("error deleting session: %w", err) } return nil } func (a *authServiceImpl) createSession(ctx context.Context, email string) (string, error) { formattedEmail, err := utils.NormalizeEmail(email) if err != nil { return "", fmt.Errorf("invalid email: %w", err) } token, err := newSessionToken() if err != nil { return "", fmt.Errorf("error generating session token: %w", err) } if err := a.redisClient.Set(ctx, token.String(), formattedEmail, sessionExpiry).Err(); err != nil { return "", fmt.Errorf("error storing session: %w", err) } return token.String(), nil }