From 4603402b842024d9211e5bfbd3b55463fc21b0a0 Mon Sep 17 00:00:00 2001 From: jordan Date: Sun, 22 Feb 2026 11:17:42 -0700 Subject: [PATCH] feat: OTP supports unified register+login flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously SendOTP silently dropped requests for unknown emails, so new users had no passwordless path in. Now: - SendOTP: if REGISTRATION_ENABLED and email unknown, generates and sends the code anyway (UserID nil until verify) - VerifyOTP: if email unknown after valid code, auto-registers the user (emailVerified=true — OTP delivery proves ownership, name defaults to email local-part) then creates a session REGISTRATION_ENABLED=false continues to block unknown emails at SendOTP, preserving invite-only / closed-beta behaviour. Co-Authored-By: Claude Sonnet 4.6 --- .../service/internal/service/auth.go.tmpl | 58 ++++++++++++++++--- 1 file changed, 49 insertions(+), 9 deletions(-) diff --git a/internal/adapter/templates/templates/components/service/internal/service/auth.go.tmpl b/internal/adapter/templates/templates/components/service/internal/service/auth.go.tmpl index d7cd85f..ed88ae7 100644 --- a/internal/adapter/templates/templates/components/service/internal/service/auth.go.tmpl +++ b/internal/adapter/templates/templates/components/service/internal/service/auth.go.tmpl @@ -277,24 +277,33 @@ func (s *AuthService) ChangePassword(ctx context.Context, userID, currentPasswor return s.users.SetPassword(ctx, uid, newHash) } -// SendOTP generates and logs a one-time password for the given email. -// In production, this would send an email. In dev mode, the code is logged to stdout. +// SendOTP generates a one-time password for the given email. +// If the email is not registered and registration is enabled, the code is still +// sent — the account will be created when the code is verified. This supports a +// unified register+login flow with a single OTP email. func (s *AuthService) SendOTP(ctx context.Context, email, ip string) error { user, err := s.users.GetByEmail(ctx, email) if err != nil { - if errors.Is(err, domain.ErrUserNotFound) { - // Don't reveal whether email exists - s.logger.Info("OTP requested for unknown email", "email", email) + if !errors.Is(err, domain.ErrUserNotFound) { + return err + } + // Unknown email: only proceed if registration is open. + if !s.registrationEnabled { + s.logger.Info("OTP requested for unknown email (registration disabled)", "email", email) return nil } - return err + // Registration enabled — send code anyway. UserID will be nil until verify. + user = nil } code := generateOTP() - uid := user.ID + var uid *domain.UserID + if user != nil { + uid = &user.ID + } authCode := &domain.AuthCode{ ID: "acd_" + generateID(), - UserID: &uid, + UserID: uid, Email: email, Code: code, Purpose: domain.PurposeLoginOTP, @@ -315,6 +324,8 @@ func (s *AuthService) SendOTP(ctx context.Context, email, ip string) error { } // VerifyOTP verifies a one-time password and returns a login token. +// If the email has no account yet and registration is enabled, the account is +// created automatically — OTP delivery proves email ownership. func (s *AuthService) VerifyOTP(ctx context.Context, email, code, ip, userAgent string) (*LoginOutput, error) { authCode, err := s.codes.FindValid(ctx, email, code, domain.PurposeLoginOTP) if err != nil { @@ -327,7 +338,17 @@ func (s *AuthService) VerifyOTP(ctx context.Context, email, code, ip, userAgent user, err := s.users.GetByEmail(ctx, email) if err != nil { - return nil, err + if !errors.Is(err, domain.ErrUserNotFound) { + return nil, err + } + if !s.registrationEnabled { + return nil, domain.ErrRegistrationDisabled + } + // Auto-register: OTP delivery already proved email ownership. + user, err = s.autoRegisterViaOTP(ctx, email) + if err != nil { + return nil, err + } } _ = s.users.UpdateLastLogin(ctx, user.ID) @@ -336,6 +357,25 @@ func (s *AuthService) VerifyOTP(ctx context.Context, email, code, ip, userAgent return s.createSession(ctx, user, ip, userAgent) } +// autoRegisterViaOTP creates a minimal user account for an email that just +// verified an OTP. Email is considered verified because OTP delivery proved +// ownership. The name defaults to the local part of the email address. +func (s *AuthService) autoRegisterViaOTP(ctx context.Context, email string) (*domain.User, error) { + name := email + if at := strings.IndexByte(email, '@'); at > 0 { + name = email[:at] + } + userID := domain.UserID("usr_" + generateID()) + user := domain.NewUser(userID, email, name) + user.EmailVerified = true // OTP delivery proves ownership + + if err := s.users.Create(ctx, user); err != nil { + return nil, err + } + s.logger.Info("user auto-registered via OTP", "user_id", string(userID), "email", email) + return user, nil +} + // SendMagicLink generates and logs a magic link token. func (s *AuthService) SendMagicLink(ctx context.Context, email, ip string) error { // Magic links can work for existing users.