Files
pulse/internal/cloudcp/auth/magiclink.go
rcourtman 0e40ec07cb Make the provider MSP portal honest and self-sufficient without an email provider
Exercised the full provider portal as a pilot MSP would and fixed what
made it feel broken:

- The signed-out portal promised "a sign-in link is on the way" even when
  the control plane has no email provider (the bundle default), and team
  invitations silently sent nothing. The portal bootstrap now carries
  email_sign_in_available and provider_hosted_mode; the sign-in page shows
  the host command that actually prints a link, and the invite panel says
  invitation emails are not sent and how to hand over a link instead.
- New "provider-msp portal-link --email" CLI mints a one-time portal link
  for an account member or pending invitee, so teammates can sign in at
  all on email-less installs (bootstrap only covers the owner).
- Portal sessions were fixed at 12h; CP_SESSION_TTL now configures them
  and provider-hosted MSP mode defaults to 7 days.
- Creating a client past the license cap showed a generic "Failed to
  create workspace." toast: the limit error is now a JSON payload with
  current/limit, the API client no longer drops non-JSON error bodies
  (double body read), and the toast explains the license limit.
- Copy polish: provider-mode sign-in intro (no refunds/privacy register),
  least-privilege default invite role, queue tile label matches "Client
  onboarding", softer Support tab with a docs/MSP.md pointer, setup.sh
  summary now prints the bootstrap next step and day-2 sign-in commands,
  .env.example and docs/MSP.md document portal sign-in and sessions.

Contracts updated (cloud-paid, api-contracts, deployment-installability,
security-privacy) with verification pins in tenant_handlers_test,
config_test, magiclink_test, and provider_msp_deploy_test.

Verified live against a dockerless control plane: portal-link for an
invitee redeems, promotes the invitation, and sets a 7-day session;
the at-cap toast shows the license copy; portal vitest suite and
cloudcp/auth/account/installtests Go suites pass.
2026-07-10 11:22:57 +01:00

231 lines
6.1 KiB
Go

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
sessionTTL 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_<random>" 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)
}