mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-20 18:23:47 +00:00
0e40ec07cb
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.
179 lines
4.9 KiB
Go
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 ""
|
|
}
|