package auth import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "errors" "fmt" "io" "net/url" "os" "strings" "time" "github.com/rcourtman/pulse-go-rewrite/internal/securityutil" "github.com/rs/zerolog/log" ) const ( magicLinkTTL = 15 * time.Minute magicLinkPrefix = "ml1_" hmacKeyFile = ".cp_magic_link_key" hmacKeySize = 32 maxHMACKeyFileSize = 64 ) type MagicLinkTarget string const ( MagicLinkTargetTenant MagicLinkTarget = "tenant" MagicLinkTargetPortal MagicLinkTarget = "portal" ) // Token holds the validated data from a consumed magic link token. type Token struct { Email string TenantID string Target MagicLinkTarget ExpiresAt time.Time } // Service manages magic link token generation and validation for the control plane. // It does NOT import internal/api — it is a standalone reimplementation using its own SQLite store. type Service struct { hmacKey []byte store *Store ttl time.Duration now func() time.Time } // NewService creates a Service backed by a SQLite store in cpDataDir. // It loads (or generates) an HMAC key from {cpDataDir}/.cp_magic_link_key. func NewService(cpDataDir string) (*Service, error) { normalizedDir, err := securityutil.NormalizeStorageDir(cpDataDir) if err != nil { return nil, fmt.Errorf("cp data dir is required: %w", err) } if err := ensureOwnerOnlyDir(normalizedDir); err != nil { return nil, fmt.Errorf("ensure cp data dir: %w", err) } keyPath, err := securityutil.JoinStorageLeaf(normalizedDir, hmacKeyFile) if err != nil { return nil, fmt.Errorf("resolve magic link hmac key path: %w", err) } key, err := loadOrGenerateKey(keyPath) if err != nil { return nil, fmt.Errorf("magic link hmac key: %w", err) } store, err := NewStore(normalizedDir) if err != nil { return nil, fmt.Errorf("magic link store: %w", err) } return &Service{ hmacKey: key, store: store, ttl: magicLinkTTL, now: time.Now, }, nil } // GenerateToken creates a new magic link token for the given email and tenant. // Returns a string in the format "ml1_" that can be included in a URL. func (s *Service) GenerateToken(email, tenantID string) (string, error) { return s.generateTokenForTarget(email, tenantID, MagicLinkTargetTenant) } // GeneratePortalToken creates a new portal-targeted magic link token. tenantID // is optional and, when present, is used during verification to ensure the // control-plane membership exists before the session is established. func (s *Service) GeneratePortalToken(email, tenantID string) (string, error) { return s.generateTokenForTarget(email, tenantID, MagicLinkTargetPortal) } func (s *Service) generateTokenForTarget(email, tenantID string, target MagicLinkTarget) (string, error) { if s == nil { return "", fmt.Errorf("magic link service not configured") } email = strings.ToLower(strings.TrimSpace(email)) tenantID = strings.TrimSpace(tenantID) if email == "" { return "", fmt.Errorf("email is required") } switch target { case MagicLinkTargetTenant: if tenantID == "" { return "", fmt.Errorf("tenantID is required") } case MagicLinkTargetPortal: // tenantID is optional for portal-targeted sign-in. default: return "", fmt.Errorf("unsupported magic link target %q", target) } expiresAt := s.now().UTC().Add(s.ttl) expiresAt = time.Unix(expiresAt.Unix(), 0).UTC() token, err := randomToken() if err != nil { return "", err } tokenHash := signHMAC(s.hmacKey, token) if err := s.store.Put(tokenHash, &TokenRecord{ Email: email, TenantID: tenantID, Target: string(target), ExpiresAt: expiresAt, }); err != nil { return "", err } return token, nil } // ValidateToken atomically consumes a token and returns the associated data. // The token can only be used once. func (s *Service) ValidateToken(token string) (*Token, error) { if s == nil { return nil, ErrTokenInvalid } token = strings.TrimSpace(token) if token == "" { return nil, ErrTokenInvalid } tokenHash := signHMAC(s.hmacKey, token) rec, err := s.store.Consume(tokenHash, s.now().UTC()) if err != nil { return nil, err } return &Token{ Email: rec.Email, TenantID: rec.TenantID, Target: MagicLinkTarget(rec.Target), ExpiresAt: rec.ExpiresAt, }, nil } // BuildVerifyURL constructs the full magic link verification URL. func BuildVerifyURL(baseURL, token string) string { baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") if baseURL == "" || token == "" { return "" } u, err := url.Parse(baseURL) if err != nil { return "" } u.Path = strings.TrimRight(u.Path, "/") + "/auth/magic-link/verify" q := u.Query() q.Set("token", token) u.RawQuery = q.Encode() return u.String() } // Close releases resources held by the service. func (s *Service) Close() { if s == nil { return } if s.store != nil { s.store.Close() } } func loadOrGenerateKey(path string) ([]byte, error) { data, err := securityutil.ReadSecureStorageFile(path, maxHMACKeyFileSize) if err == nil { if len(data) != hmacKeySize { return nil, fmt.Errorf("invalid key file %s: expected %d bytes, got %d", path, hmacKeySize, len(data)) } return append([]byte(nil), data...), nil } if err != nil && !os.IsNotExist(err) { if errors.Is(err, securityutil.ErrUnsafeStorageFile) { return nil, fmt.Errorf("unsafe key file %s: %w", path, err) } return nil, fmt.Errorf("read key file %s: %w", path, err) } key := make([]byte, hmacKeySize) if _, err := io.ReadFull(rand.Reader, key); err != nil { return nil, fmt.Errorf("generate key: %w", err) } if err := securityutil.WriteSecureStorageFile(path, key, privateDirPerm, 0o600); err != nil { return nil, fmt.Errorf("write key file %s: %w", path, err) } log.Info().Str("path", path).Msg("Generated new magic link HMAC key") return key, nil } func randomToken() (string, error) { raw := make([]byte, 32) if _, err := io.ReadFull(rand.Reader, raw); err != nil { return "", fmt.Errorf("generate token: %w", err) } return magicLinkPrefix + base64.RawURLEncoding.EncodeToString(raw), nil } func signHMAC(key []byte, payload string) []byte { mac := hmac.New(sha256.New, key) mac.Write([]byte(payload)) return mac.Sum(nil) }