Pulse/internal/cloudcp/handoff/handoff.go
2026-03-18 16:06:30 +00:00

120 lines
3 KiB
Go

package handoff
import (
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry"
)
const (
issuer = "pulse-cloud-control-plane"
defaultTTL = 60 * time.Second
)
// HandoffClaims are the logical claims that the control plane wants to assert
// when handing off an authenticated account user into a tenant container.
type HandoffClaims struct {
TenantID string
UserID string
AccountID string
Email string
Role registry.MemberRole
IssuedAt time.Time
ExpiresAt time.Time
JTI string
}
type jwtHandoffClaims struct {
AccountID string `json:"account_id"`
Email string `json:"email"`
Role registry.MemberRole `json:"role"`
jwt.RegisteredClaims
}
// MintHandoffToken mints a short-lived HS256 JWT signed with the per-tenant handoff.key.
//
// JWT registered claims:
// - iss: pulse-cloud-control-plane
// - aud: <tenant-id>
// - sub: <user-id>
// - iat, exp, jti
//
// Custom claims:
// - account_id, email, role
func MintHandoffToken(secret []byte, claims HandoffClaims) (string, error) {
if len(secret) == 0 {
return "", fmt.Errorf("secret is required")
}
claims.TenantID = sanitizeID(claims.TenantID)
claims.UserID = sanitizeID(claims.UserID)
claims.AccountID = sanitizeID(claims.AccountID)
if claims.TenantID == "" || claims.UserID == "" || claims.AccountID == "" {
return "", fmt.Errorf("tenantID, userID, and accountID are required")
}
if claims.Email == "" {
return "", fmt.Errorf("email is required")
}
now := time.Now().UTC()
if claims.IssuedAt.IsZero() {
claims.IssuedAt = now
}
claims.IssuedAt = claims.IssuedAt.UTC()
if claims.ExpiresAt.IsZero() {
claims.ExpiresAt = claims.IssuedAt.Add(defaultTTL)
}
claims.ExpiresAt = claims.ExpiresAt.UTC()
if !claims.ExpiresAt.After(claims.IssuedAt) {
return "", fmt.Errorf("expiresAt must be after issuedAt")
}
if claims.JTI == "" {
jti, err := randomJTI128()
if err != nil {
return "", err
}
claims.JTI = jti
}
jc := jwtHandoffClaims{
AccountID: claims.AccountID,
Email: claims.Email,
Role: claims.Role,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: issuer,
Subject: claims.UserID,
Audience: jwt.ClaimStrings{claims.TenantID},
IssuedAt: jwt.NewNumericDate(claims.IssuedAt),
ExpiresAt: jwt.NewNumericDate(claims.ExpiresAt),
ID: claims.JTI,
},
}
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, jc)
signed, err := tok.SignedString(secret)
if err != nil {
return "", fmt.Errorf("sign jwt: %w", err)
}
return signed, nil
}
func randomJTI128() (string, error) {
b := make([]byte, 16) // 128-bit
if _, err := io.ReadFull(rand.Reader, b); err != nil {
return "", fmt.Errorf("generate jti: %w", err)
}
return hex.EncodeToString(b), nil
}
func sanitizeID(s string) string {
// IDs are generated by the registry; trimming prevents surprising mismatches.
return strings.TrimSpace(s)
}