mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-02 13:30:13 +00:00
180 lines
4.9 KiB
Go
180 lines
4.9 KiB
Go
package cloudauth
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"time"
|
|
)
|
|
|
|
// HandoffKeyFile is the filename for the per-tenant handoff key written into the tenant data dir.
|
|
const HandoffKeyFile = ".cloud_handoff_key"
|
|
|
|
var (
|
|
ErrHandoffInvalid = errors.New("handoff token invalid")
|
|
ErrHandoffExpired = errors.New("handoff token expired")
|
|
)
|
|
|
|
// handoffPayload is the JSON structure inside a handoff token.
|
|
type handoffPayload struct {
|
|
Email string `json:"e"`
|
|
TenantID string `json:"t"`
|
|
AccountID string `json:"a,omitempty"`
|
|
UserID string `json:"u,omitempty"`
|
|
Role string `json:"r,omitempty"`
|
|
Expiry int64 `json:"x"`
|
|
Nonce string `json:"n"`
|
|
}
|
|
|
|
// Claims describes the control-plane identity metadata encoded in a handoff token.
|
|
type Claims struct {
|
|
Email string
|
|
TenantID string
|
|
AccountID string
|
|
UserID string
|
|
Role string
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// GenerateHandoffKey returns 32 cryptographically random bytes suitable for HMAC-SHA256 signing.
|
|
func GenerateHandoffKey() ([]byte, error) {
|
|
key := make([]byte, 32)
|
|
if _, err := io.ReadFull(rand.Reader, key); err != nil {
|
|
return nil, fmt.Errorf("generate handoff key: %w", err)
|
|
}
|
|
return key, nil
|
|
}
|
|
|
|
// Sign creates an HMAC-SHA256-signed handoff token encoding the given email, tenant ID, and TTL.
|
|
// The returned string is base64url-encoded: payload + "." + signature.
|
|
func Sign(key []byte, email, tenantID string, ttl time.Duration) (string, error) {
|
|
return SignWithClaims(key, Claims{
|
|
Email: email,
|
|
TenantID: tenantID,
|
|
}, ttl)
|
|
}
|
|
|
|
// SignWithClaims creates an HMAC-SHA256-signed handoff token encoding the given
|
|
// handoff claims and TTL. Role/account/user metadata is optional and preserved
|
|
// for hosted runtime membership repair.
|
|
func SignWithClaims(key []byte, claims Claims, ttl time.Duration) (string, error) {
|
|
if len(key) == 0 {
|
|
return "", fmt.Errorf("handoff key is empty")
|
|
}
|
|
if claims.Email == "" || claims.TenantID == "" {
|
|
return "", fmt.Errorf("email and tenantID are required")
|
|
}
|
|
|
|
nonce := make([]byte, 16)
|
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
|
return "", fmt.Errorf("generate nonce: %w", err)
|
|
}
|
|
|
|
payload := handoffPayload{
|
|
Email: claims.Email,
|
|
TenantID: claims.TenantID,
|
|
AccountID: claims.AccountID,
|
|
UserID: claims.UserID,
|
|
Role: claims.Role,
|
|
Expiry: time.Now().UTC().Add(ttl).Unix(),
|
|
Nonce: base64.RawURLEncoding.EncodeToString(nonce),
|
|
}
|
|
payloadBytes, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return "", fmt.Errorf("marshal handoff payload: %w", err)
|
|
}
|
|
|
|
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadBytes)
|
|
sig := computeHMAC(key, payloadBytes)
|
|
sigB64 := base64.RawURLEncoding.EncodeToString(sig)
|
|
|
|
return payloadB64 + "." + sigB64, nil
|
|
}
|
|
|
|
// Verify decodes and validates a handoff token. Returns the email and tenant ID on success.
|
|
func Verify(key []byte, tokenStr string) (email, tenantID string, err error) {
|
|
email, tenantID, _, err = VerifyWithExpiry(key, tokenStr)
|
|
return email, tenantID, err
|
|
}
|
|
|
|
// VerifyWithExpiry decodes and validates a handoff token and also returns its expiry.
|
|
func VerifyWithExpiry(key []byte, tokenStr string) (email, tenantID string, expiresAt time.Time, err error) {
|
|
claims, err := VerifyClaimsWithExpiry(key, tokenStr)
|
|
if err != nil {
|
|
return "", "", time.Time{}, err
|
|
}
|
|
return claims.Email, claims.TenantID, claims.ExpiresAt, nil
|
|
}
|
|
|
|
// VerifyClaimsWithExpiry decodes and validates a handoff token and returns the
|
|
// full preserved control-plane claims plus expiry.
|
|
func VerifyClaimsWithExpiry(key []byte, tokenStr string) (Claims, error) {
|
|
if len(key) == 0 || tokenStr == "" {
|
|
return Claims{}, ErrHandoffInvalid
|
|
}
|
|
|
|
// Split into payload.signature
|
|
dotIdx := -1
|
|
for i := 0; i < len(tokenStr); i++ {
|
|
if tokenStr[i] == '.' {
|
|
dotIdx = i
|
|
break
|
|
}
|
|
}
|
|
if dotIdx < 1 || dotIdx >= len(tokenStr)-1 {
|
|
return Claims{}, ErrHandoffInvalid
|
|
}
|
|
|
|
payloadB64 := tokenStr[:dotIdx]
|
|
sigB64 := tokenStr[dotIdx+1:]
|
|
|
|
payloadBytes, err := base64.RawURLEncoding.DecodeString(payloadB64)
|
|
if err != nil {
|
|
return Claims{}, ErrHandoffInvalid
|
|
}
|
|
sigBytes, err := base64.RawURLEncoding.DecodeString(sigB64)
|
|
if err != nil {
|
|
return Claims{}, ErrHandoffInvalid
|
|
}
|
|
|
|
// Verify HMAC
|
|
expected := computeHMAC(key, payloadBytes)
|
|
if !hmac.Equal(sigBytes, expected) {
|
|
return Claims{}, ErrHandoffInvalid
|
|
}
|
|
|
|
// Decode payload
|
|
var p handoffPayload
|
|
if err := json.Unmarshal(payloadBytes, &p); err != nil {
|
|
return Claims{}, ErrHandoffInvalid
|
|
}
|
|
|
|
// Check expiry
|
|
if time.Now().UTC().Unix() > p.Expiry {
|
|
return Claims{}, ErrHandoffExpired
|
|
}
|
|
|
|
if p.Email == "" || p.TenantID == "" {
|
|
return Claims{}, ErrHandoffInvalid
|
|
}
|
|
|
|
return Claims{
|
|
Email: p.Email,
|
|
TenantID: p.TenantID,
|
|
AccountID: p.AccountID,
|
|
UserID: p.UserID,
|
|
Role: p.Role,
|
|
ExpiresAt: time.Unix(p.Expiry, 0).UTC(),
|
|
}, nil
|
|
}
|
|
|
|
func computeHMAC(key, data []byte) []byte {
|
|
mac := hmac.New(sha256.New, key)
|
|
mac.Write(data)
|
|
return mac.Sum(nil)
|
|
}
|