Files
pulse/internal/cloudcp/auth/session.go
T
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

179 lines
4.9 KiB
Go

package auth
import (
"crypto/hmac"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
)
const (
// SessionCookieName is the cookie used for control-plane authenticated sessions.
// The __Host- prefix binds the cookie to the origin (Secure, Path="/", no Domain)
// preventing subdomain injection attacks.
SessionCookieName = "__Host-pulse_cp_session"
// SessionTTL is the default session token lifetime.
SessionTTL = 12 * time.Hour
sessionPrefix = "cps1_"
)
var (
ErrSessionInvalid = errors.New("session token invalid")
ErrSessionExpired = errors.New("session token expired")
)
// SetSessionTTL overrides the session token lifetime issued by this service.
// Values <= 0 leave the default in place.
func (s *Service) SetSessionTTL(ttl time.Duration) {
if s == nil || ttl <= 0 {
return
}
s.sessionTTL = ttl
}
// SessionTTLOrDefault returns the configured session lifetime, falling back
// to the package default.
func (s *Service) SessionTTLOrDefault() time.Duration {
if s == nil || s.sessionTTL <= 0 {
return SessionTTL
}
return s.sessionTTL
}
// SessionClaims are the authenticated claims for a control-plane session.
type SessionClaims struct {
UserID string
Email string
SessionVersion int64
IssuedAt time.Time
ExpiresAt time.Time
}
type sessionPayload struct {
UserID string `json:"u"`
Email string `json:"e"`
Version int64 `json:"v"`
IssuedAt int64 `json:"i"`
Expiry int64 `json:"x"`
}
// GenerateSessionToken creates an HMAC-signed control-plane session token.
func (s *Service) GenerateSessionToken(userID, email string, ttl time.Duration) (string, error) {
return s.GenerateSessionTokenWithVersion(userID, email, 1, ttl)
}
// GenerateSessionTokenWithVersion creates an HMAC-signed control-plane session token
// bound to a user session-version counter.
func (s *Service) GenerateSessionTokenWithVersion(userID, email string, sessionVersion int64, ttl time.Duration) (string, error) {
if s == nil || len(s.hmacKey) == 0 {
return "", ErrSessionInvalid
}
userID = strings.TrimSpace(userID)
email = strings.ToLower(strings.TrimSpace(email))
if userID == "" || email == "" {
return "", fmt.Errorf("userID and email are required")
}
if sessionVersion < 1 {
sessionVersion = 1
}
if ttl <= 0 {
ttl = SessionTTL
}
now := s.now().UTC()
payload := sessionPayload{
UserID: userID,
Email: email,
Version: sessionVersion,
IssuedAt: now.Unix(),
Expiry: now.Add(ttl).Unix(),
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("marshal session payload: %w", err)
}
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadBytes)
sig := signHMAC(s.hmacKey, string(payloadBytes))
sigB64 := base64.RawURLEncoding.EncodeToString(sig)
return sessionPrefix + payloadB64 + "." + sigB64, nil
}
// ValidateSessionToken validates a session token and returns claims on success.
func (s *Service) ValidateSessionToken(token string) (*SessionClaims, error) {
if s == nil || len(s.hmacKey) == 0 {
return nil, ErrSessionInvalid
}
token = strings.TrimSpace(token)
if token == "" || !strings.HasPrefix(token, sessionPrefix) {
return nil, ErrSessionInvalid
}
raw := strings.TrimPrefix(token, sessionPrefix)
dot := strings.IndexByte(raw, '.')
if dot <= 0 || dot >= len(raw)-1 {
return nil, ErrSessionInvalid
}
payloadB64 := raw[:dot]
sigB64 := raw[dot+1:]
payloadBytes, err := base64.RawURLEncoding.DecodeString(payloadB64)
if err != nil {
return nil, ErrSessionInvalid
}
sigBytes, err := base64.RawURLEncoding.DecodeString(sigB64)
if err != nil {
return nil, ErrSessionInvalid
}
expectedSig := signHMAC(s.hmacKey, string(payloadBytes))
if !hmac.Equal(sigBytes, expectedSig) {
return nil, ErrSessionInvalid
}
var payload sessionPayload
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
return nil, ErrSessionInvalid
}
if strings.TrimSpace(payload.UserID) == "" || strings.TrimSpace(payload.Email) == "" {
return nil, ErrSessionInvalid
}
if payload.Version < 1 {
payload.Version = 1
}
now := s.now().UTC().Unix()
if now > payload.Expiry {
return nil, ErrSessionExpired
}
return &SessionClaims{
UserID: strings.TrimSpace(payload.UserID),
Email: strings.ToLower(strings.TrimSpace(payload.Email)),
SessionVersion: payload.Version,
IssuedAt: time.Unix(payload.IssuedAt, 0).UTC(),
ExpiresAt: time.Unix(payload.Expiry, 0).UTC(),
}, nil
}
// SessionTokenFromRequest extracts the session token from an HTTP request,
// checking the Authorization Bearer header first, then the session cookie.
func SessionTokenFromRequest(r *http.Request) string {
auth := strings.TrimSpace(r.Header.Get("Authorization"))
if strings.HasPrefix(auth, "Bearer ") {
return strings.TrimSpace(strings.TrimPrefix(auth, "Bearer "))
}
cookie, err := r.Cookie(SessionCookieName)
if err == nil {
return strings.TrimSpace(cookie.Value)
}
return ""
}