fix(hosted): preserve direct handoff membership continuity

This commit is contained in:
Richard Courtman 2026-03-26 23:40:02 +00:00
parent 55df4927e9
commit 57a3f19a84
10 changed files with 313 additions and 39 deletions

View file

@ -156,6 +156,12 @@ but the canonical contract is that a successful handoff exchange must reconcile
tenant organization membership for the handed-off account member before the
browser follows the new session into protected routes, rather than landing on a
fresh `access_denied` immediately after session minting.
That same hosted continuity contract also applies to the older direct tenant
magic-link path. Lifecycle-adjacent control-plane redirects through
`/auth/cloud-handoff` must preserve canonical account/user/role identity in the
handoff token long enough for the tenant runtime to repair org membership
before it lands in protected hosted routes, rather than letting direct opens
diverge from the newer portal exchange path.
Agent lifecycle owns the install/register/update continuity surfaces, but it
does not own unified-resource history or control-plane timeline persistence.

View file

@ -156,11 +156,12 @@ Own canonical runtime payload shapes between backend and frontend.
15. Keep Patrol intelligence summary transport semantics single-voiced: the canonical overall-health payload and Patrol run-history payload together must support one primary assessment plus one explicit verification explanation, and frontend consumers must not need to derive a second compact assessment or verification verdict row from the same payloads beneath the primary summary card.
16. Keep Pulse Mobile relay credential minting and permission ownership on backend ownership: `internal/api/router_routes_auth_security.go`, `internal/api/security_tokens.go`, `internal/api/auth.go`, `internal/api/router_routes_ai_relay.go`, and `frontend-modern/src/api/security.ts` may expose the canonical mobile runtime token creator and governed route gates, but browser callers must only consume that route and must not define the mobile runtime scope, compatibility gate list, or token-purpose metadata locally.
17. Keep hosted tenant browser-session precedence on the shared auth boundary: `internal/api/auth.go`, `internal/api/contract_test.go`, and hosted tenant callers must treat a valid `pulse_session` as authoritative before any API-only token fallback or no-local-auth anonymous fallback, so cloud handoff can continue into protected hosted routes without flattening the operator back to `anonymous` or forcing a browser session through bearer-token-only mode after the tenant has minted API tokens.
18. Keep tenant settings-scope authorization aligned with org management: `internal/api/security_setup_fix.go`, `internal/api/contract_test.go`, and settings-bound hosted callers must allow the current non-default org owner/admin membership to exercise privileged tenant routes, rather than requiring a separate configured local admin identity after hosted handoff.
19. Keep mobile onboarding payload reads aligned with the server-owned relay-mobile credential: `internal/api/router_routes_ai_relay.go`, `internal/api/onboarding_handlers.go`, and `internal/api/contract_test.go` must allow the dedicated `relay:mobile:access` scope to reach the governed QR, deep-link, and connection-validation payloads without reintroducing a broader `settings:read` requirement for token-authenticated pairing clients.
20. Keep hosted billing-state quickstart payload fields on the shared API contract: `internal/api/hosted_entitlement_refresh.go`, `internal/api/subscription_state_handlers.go`, and `internal/api/contract_test.go` must preserve `quickstart_credits_granted`, `quickstart_credits_used`, and `quickstart_credits_granted_at` through hosted signup, hosted lease refresh, and billing-state reads instead of letting lease rewrites silently erase seeded quickstart inventory.
21. Keep hosted AI settings bootstrap on the shared API contract: `internal/api/ai_hosted_runtime.go`, `internal/api/ai_handlers.go`, `internal/api/ai_handler.go`, and `internal/api/contract_test.go` must treat a missing `ai.enc` in hosted mode as a canonical bootstrap condition, persist one machine-owned quickstart-backed AI config when hosted entitlements grant AI capability, and preserve that configured settings payload as the same public contract that Chat, Patrol, and AI Settings consume.
22. Keep shared auth-store and universal rate-limit behavior on canonical router ownership: `internal/api/router.go`, `internal/api/rate_limit_config.go`, `internal/api/session_store.go`, `internal/api/csrf_store.go`, `internal/api/recovery_tokens.go`, `internal/api/cloud_handoff_handlers.go`, and `internal/api/contract_test.go` must keep session/CSRF/recovery-token persistence scoped to the configured runtime data path, make each router retain and synchronously shut down the exact auth-store workers it initialized during router teardown or reinitialization, preserve hosted browser-session precedence during handoff-adjacent auth checks, and ensure hosted rate-limit presentation plus in-memory limiter counters stay owned by the current router instance instead of leaking across fresh routers or test harnesses.
18. Keep hosted direct-tenant magic-link continuity on the shared auth boundary: `internal/cloudcp/auth/handlers.go`, `pkg/cloudauth/handoff.go`, `internal/api/cloud_handoff.go`, and the hosted handoff tests must preserve account/user/role claims from the control-plane magic-link redirect through the tenant cloud-handoff token so the tenant can repair canonical org membership before redirecting the browser into protected hosted routes.
19. Keep tenant settings-scope authorization aligned with org management: `internal/api/security_setup_fix.go`, `internal/api/contract_test.go`, and settings-bound hosted callers must allow the current non-default org owner/admin membership to exercise privileged tenant routes, rather than requiring a separate configured local admin identity after hosted handoff.
20. Keep mobile onboarding payload reads aligned with the server-owned relay-mobile credential: `internal/api/router_routes_ai_relay.go`, `internal/api/onboarding_handlers.go`, and `internal/api/contract_test.go` must allow the dedicated `relay:mobile:access` scope to reach the governed QR, deep-link, and connection-validation payloads without reintroducing a broader `settings:read` requirement for token-authenticated pairing clients.
21. Keep hosted billing-state quickstart payload fields on the shared API contract: `internal/api/hosted_entitlement_refresh.go`, `internal/api/subscription_state_handlers.go`, and `internal/api/contract_test.go` must preserve `quickstart_credits_granted`, `quickstart_credits_used`, and `quickstart_credits_granted_at` through hosted signup, hosted lease refresh, and billing-state reads instead of letting lease rewrites silently erase seeded quickstart inventory.
22. Keep hosted AI settings bootstrap on the shared API contract: `internal/api/ai_hosted_runtime.go`, `internal/api/ai_handlers.go`, `internal/api/ai_handler.go`, and `internal/api/contract_test.go` must treat a missing `ai.enc` in hosted mode as a canonical bootstrap condition, persist one machine-owned quickstart-backed AI config when hosted entitlements grant AI capability, and preserve that configured settings payload as the same public contract that Chat, Patrol, and AI Settings consume.
23. Keep shared auth-store and universal rate-limit behavior on canonical router ownership: `internal/api/router.go`, `internal/api/rate_limit_config.go`, `internal/api/session_store.go`, `internal/api/csrf_store.go`, `internal/api/recovery_tokens.go`, `internal/api/cloud_handoff_handlers.go`, and `internal/api/contract_test.go` must keep session/CSRF/recovery-token persistence scoped to the configured runtime data path, make each router retain and synchronously shut down the exact auth-store workers it initialized during router teardown or reinitialization, preserve hosted browser-session precedence during handoff-adjacent auth checks, and ensure hosted rate-limit presentation plus in-memory limiter counters stay owned by the current router instance instead of leaking across fresh routers or test harnesses.
## Forbidden Paths

View file

@ -1098,6 +1098,11 @@ recovery-adjacent hosted flows that reuse settings-bound helpers must allow
the current org owner/admin membership to continue through privileged tenant
routes after cloud handoff instead of requiring a separate configured local
admin identity that hosted tenants do not carry.
That same hosted continuity assumption also applies when operators arrive via
the older direct tenant magic-link path. Recovery- and storage-adjacent hosted
opens that still redirect through `/auth/cloud-handoff` must carry enough
canonical account/role identity for the tenant runtime to repair membership
before protected routes load, not just the newer portal exchange path.
That same adjacent onboarding boundary must also keep the dedicated
relay-mobile bootstrap credential sufficient for QR, deep-link, and
connection-validation reads, so hosted recovery/support flows that hand a

View file

@ -43,22 +43,31 @@ func HandleCloudHandoff(dataPath string) http.HandlerFunc {
return
}
email, tenantID, expiresAt, err := cloudauth.VerifyWithExpiry(handoffKey, tokenStr)
claims, err := cloudauth.VerifyClaimsWithExpiry(handoffKey, tokenStr)
if err != nil {
log.Warn().Err(err).Msg("Cloud handoff token verification failed")
http.Redirect(w, r, "/login?error=handoff_invalid", http.StatusTemporaryRedirect)
return
}
email = normalizeHandoffEmail(email)
tenantID = strings.TrimSpace(tenantID)
email := normalizeHandoffEmail(claims.Email)
tenantID := strings.TrimSpace(claims.TenantID)
if email == "" || !isValidOrganizationID(tenantID) {
log.Warn().Str("tenant_id", tenantID).Msg("Cloud handoff token rejected due to invalid tenant ID")
http.Redirect(w, r, "/login?error=handoff_invalid", http.StatusTemporaryRedirect)
return
}
if err := ensureHandoffOrganizationMembership(dataPath, tenantID, email, claims.Role); err != nil {
log.Error().
Err(err).
Str("tenant_id", tenantID).
Str("email", email).
Msg("Cloud handoff membership repair failed")
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
tokenHash := sha256.Sum256([]byte(tokenStr))
replayID := "handoff:" + hex.EncodeToString(tokenHash[:])
stored, storeErr := replay.checkAndStore(replayID, expiresAt)
stored, storeErr := replay.checkAndStore(replayID, claims.ExpiresAt)
if storeErr != nil {
log.Error().Err(storeErr).Msg("Cloud handoff replay-store failure")
http.Error(w, "Internal server error", http.StatusInternalServerError)

View file

@ -10,6 +10,8 @@ import (
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/pkg/cloudauth"
)
@ -165,3 +167,63 @@ func TestHandleCloudHandoffLowercasesSessionEmailIdentity(t *testing.T) {
t.Fatalf("session username = %q, want %q", session.Username, "operator.owner+mixed@pulserelay.pro")
}
}
func TestHandleCloudHandoffEnsuresTenantOrganizationMembershipFromClaims(t *testing.T) {
resetPersistentAuthStoresForTests()
t.Cleanup(resetPersistentAuthStoresForTests)
dataPath := t.TempDir()
key := []byte("0123456789abcdef0123456789abcdef")
if err := os.WriteFile(filepath.Join(dataPath, cloudauth.HandoffKeyFile), key, 0o600); err != nil {
t.Fatalf("write handoff key: %v", err)
}
tenantID := "tenant-claims-membership"
mtp := config.NewMultiTenantPersistence(dataPath)
if err := mtp.SaveOrganization(&models.Organization{
ID: tenantID,
DisplayName: "Claims Membership",
Status: models.OrgStatusActive,
CreatedAt: time.Now().UTC(),
OwnerUserID: "legacy-owner@example.com",
Members: []models.OrganizationMember{
{UserID: "legacy-owner@example.com", Role: models.OrgRoleOwner, AddedAt: time.Now().UTC()},
},
}); err != nil {
t.Fatalf("save organization: %v", err)
}
token, err := cloudauth.SignWithClaims(key, cloudauth.Claims{
Email: "courtmanr@gmail.com",
TenantID: tenantID,
AccountID: "acct-claims-membership",
UserID: "user-claims-membership",
Role: "owner",
}, 5*time.Minute)
if err != nil {
t.Fatalf("sign handoff token: %v", err)
}
handler := HandleCloudHandoff(dataPath)
req := httptest.NewRequest(http.MethodGet, "/auth/cloud-handoff?token="+url.QueryEscape(token), nil)
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusTemporaryRedirect {
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusTemporaryRedirect, rec.Body.String())
}
if got := rec.Header().Get("Location"); got != "/" {
t.Fatalf("redirect = %q, want %q", got, "/")
}
org, err := mtp.LoadOrganization(tenantID)
if err != nil {
t.Fatalf("load organization: %v", err)
}
if org.OwnerUserID != "legacy-owner@example.com" {
t.Fatalf("ownerUserID = %q, want %q", org.OwnerUserID, "legacy-owner@example.com")
}
if got := org.GetMemberRole("courtmanr@gmail.com"); got != models.OrgRoleOwner {
t.Fatalf("member role = %q, want %q", got, models.OrgRoleOwner)
}
}

View file

@ -31,6 +31,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/internal/updates"
authpkg "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
"github.com/rcourtman/pulse-go-rewrite/pkg/cloudauth"
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
"github.com/rcourtman/pulse-go-rewrite/pkg/reporting"
)
@ -2761,6 +2762,65 @@ func TestContract_HostedCloudHandoffEnsuresTenantOrganizationMembership(t *testi
}
}
func TestContract_HostedDirectCloudHandoffPreservesMembershipClaims(t *testing.T) {
key := []byte("test-direct-handoff-key")
configDir := t.TempDir()
resetPersistentAuthStoresForTests()
t.Cleanup(resetPersistentAuthStoresForTests)
if err := os.WriteFile(filepath.Join(configDir, cloudauth.HandoffKeyFile), key, 0o600); err != nil {
t.Fatalf("write direct handoff key: %v", err)
}
tenantID := "tenant-direct-contract"
mtp := config.NewMultiTenantPersistence(configDir)
if err := mtp.SaveOrganization(&models.Organization{
ID: tenantID,
DisplayName: "Direct Contract Membership",
Status: models.OrgStatusActive,
CreatedAt: time.Now().UTC(),
OwnerUserID: "legacy-owner@example.com",
Members: []models.OrganizationMember{
{UserID: "legacy-owner@example.com", Role: models.OrgRoleOwner, AddedAt: time.Now().UTC()},
},
}); err != nil {
t.Fatalf("save organization: %v", err)
}
token, err := cloudauth.SignWithClaims(key, cloudauth.Claims{
Email: "courtmanr@gmail.com",
TenantID: tenantID,
AccountID: "acct-direct-contract",
UserID: "user-direct-contract",
Role: "owner",
}, time.Hour)
if err != nil {
t.Fatalf("sign direct handoff claims: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/auth/cloud-handoff?token="+url.QueryEscape(token), nil)
rec := httptest.NewRecorder()
HandleCloudHandoff(configDir).ServeHTTP(rec, req)
if rec.Code != http.StatusTemporaryRedirect {
t.Fatalf("status=%d, want %d: %s", rec.Code, http.StatusTemporaryRedirect, rec.Body.String())
}
if got := rec.Header().Get("Location"); got != "/" {
t.Fatalf("redirect=%q, want %q", got, "/")
}
org, err := mtp.LoadOrganization(tenantID)
if err != nil {
t.Fatalf("load organization: %v", err)
}
if org.OwnerUserID != "legacy-owner@example.com" {
t.Fatalf("ownerUserID=%q, want %q", org.OwnerUserID, "legacy-owner@example.com")
}
if got := org.GetMemberRole("courtmanr@gmail.com"); got != models.OrgRoleOwner {
t.Fatalf("member role=%q, want %q", got, models.OrgRoleOwner)
}
}
func TestContract_APITokenDeleteRejectsScopeEscalation(t *testing.T) {
router := &Router{
config: &config.Config{

View file

@ -130,8 +130,25 @@ func HandleMagicLinkVerify(svc *Service, reg *registry.TenantRegistry, tenantsDi
return
}
handoffClaims := cloudauth.Claims{
Email: token.Email,
TenantID: tenant.ID,
}
userID, membershipRole, membershipErr := ensureAccountUserAndMembership(reg, tenant, token.Email)
if membershipErr != nil {
log.Warn().
Err(membershipErr).
Str("tenant_id", tenant.ID).
Str("email", token.Email).
Msg("Failed to resolve account membership for cloud handoff token")
} else {
handoffClaims.UserID = userID
handoffClaims.AccountID = tenant.AccountID
handoffClaims.Role = string(membershipRole)
}
// Sign a short-lived handoff token.
handoffToken, err := cloudauth.Sign(handoffKey, token.Email, tenant.ID, handoffTTL)
handoffToken, err := cloudauth.SignWithClaims(handoffKey, handoffClaims, handoffTTL)
if err != nil {
auditEvent(r, "cp_magic_link_verify", "failure").
Err(err).
@ -146,9 +163,9 @@ func HandleMagicLinkVerify(svc *Service, reg *registry.TenantRegistry, tenantsDi
redirectURL := fmt.Sprintf("https://%s.%s/auth/cloud-handoff?token=%s",
tenant.ID, baseDomain, handoffToken)
if userID, err := ensureAccountUserAndMembership(reg, tenant, token.Email); err != nil {
if membershipErr != nil {
log.Warn().
Err(err).
Err(membershipErr).
Str("tenant_id", tenant.ID).
Str("email", token.Email).
Msg("Failed to establish control-plane session identity")
@ -406,30 +423,30 @@ func ensureAccountUser(reg *registry.TenantRegistry, email string) (string, erro
return user.ID, nil
}
func ensureAccountUserAndMembership(reg *registry.TenantRegistry, tenant *registry.Tenant, email string) (string, error) {
func ensureAccountUserAndMembership(reg *registry.TenantRegistry, tenant *registry.Tenant, email string) (string, registry.MemberRole, error) {
if reg == nil {
return "", fmt.Errorf("registry unavailable")
return "", "", fmt.Errorf("registry unavailable")
}
if tenant == nil {
return "", fmt.Errorf("tenant is required")
return "", "", fmt.Errorf("tenant is required")
}
accountID := strings.TrimSpace(tenant.AccountID)
if accountID == "" {
return "", fmt.Errorf("tenant has no account id")
return "", "", fmt.Errorf("tenant has no account id")
}
email = strings.ToLower(strings.TrimSpace(email))
if email == "" {
return "", fmt.Errorf("email is required")
return "", "", fmt.Errorf("email is required")
}
userID, err := ensureAccountUser(reg, email)
if err != nil {
return "", err
return "", "", err
}
m, err := reg.GetMembership(accountID, userID)
if err != nil {
return "", fmt.Errorf("lookup membership: %w", err)
return "", "", fmt.Errorf("lookup membership: %w", err)
}
if m == nil {
newMembership := &registry.AccountMembership{
@ -440,10 +457,13 @@ func ensureAccountUserAndMembership(reg *registry.TenantRegistry, tenant *regist
if createErr := reg.CreateMembership(newMembership); createErr != nil {
reloaded, reloadErr := reg.GetMembership(accountID, userID)
if reloadErr != nil || reloaded == nil {
return "", fmt.Errorf("create membership: %w", createErr)
return "", "", fmt.Errorf("create membership: %w", createErr)
}
m = reloaded
} else {
m = newMembership
}
}
return userID, nil
return userID, m.Role, nil
}

View file

@ -3,6 +3,7 @@ package auth
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
@ -142,4 +143,29 @@ func TestHandleMagicLinkVerifyTenantTargetStillRedirectsToTenantHandoff(t *testi
if membership == nil {
t.Fatal("expected tenant owner membership to be created")
}
handoffToken := strings.TrimPrefix(location, "https://t-tenant-1.cloud.example.com/auth/cloud-handoff?token=")
handoffToken, err = url.QueryUnescape(handoffToken)
if err != nil {
t.Fatalf("QueryUnescape handoff token: %v", err)
}
claims, err := cloudauth.VerifyClaimsWithExpiry(handoffKey, handoffToken)
if err != nil {
t.Fatalf("VerifyClaimsWithExpiry: %v", err)
}
if claims.Email != "tenant@example.com" {
t.Fatalf("claims.Email = %q, want %q", claims.Email, "tenant@example.com")
}
if claims.TenantID != "t-tenant-1" {
t.Fatalf("claims.TenantID = %q, want %q", claims.TenantID, "t-tenant-1")
}
if claims.AccountID != accountID {
t.Fatalf("claims.AccountID = %q, want %q", claims.AccountID, accountID)
}
if claims.Role != string(registry.MemberRoleOwner) {
t.Fatalf("claims.Role = %q, want %q", claims.Role, registry.MemberRoleOwner)
}
if strings.TrimSpace(claims.UserID) == "" {
t.Fatal("expected claims.UserID to be populated")
}
}

View file

@ -22,10 +22,23 @@ var (
// handoffPayload is the JSON structure inside a handoff token.
type handoffPayload struct {
Email string `json:"e"`
TenantID string `json:"t"`
Expiry int64 `json:"x"`
Nonce string `json:"n"`
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.
@ -40,10 +53,20 @@ func GenerateHandoffKey() ([]byte, error) {
// 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 email == "" || tenantID == "" {
if claims.Email == "" || claims.TenantID == "" {
return "", fmt.Errorf("email and tenantID are required")
}
@ -53,10 +76,13 @@ func Sign(key []byte, email, tenantID string, ttl time.Duration) (string, error)
}
payload := handoffPayload{
Email: email,
TenantID: tenantID,
Expiry: time.Now().UTC().Add(ttl).Unix(),
Nonce: base64.RawURLEncoding.EncodeToString(nonce),
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 {
@ -78,8 +104,18 @@ func Verify(key []byte, tokenStr string) (email, tenantID string, err error) {
// 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 "", "", time.Time{}, ErrHandoffInvalid
return Claims{}, ErrHandoffInvalid
}
// Split into payload.signature
@ -91,7 +127,7 @@ func VerifyWithExpiry(key []byte, tokenStr string) (email, tenantID string, expi
}
}
if dotIdx < 1 || dotIdx >= len(tokenStr)-1 {
return "", "", time.Time{}, ErrHandoffInvalid
return Claims{}, ErrHandoffInvalid
}
payloadB64 := tokenStr[:dotIdx]
@ -99,35 +135,42 @@ func VerifyWithExpiry(key []byte, tokenStr string) (email, tenantID string, expi
payloadBytes, err := base64.RawURLEncoding.DecodeString(payloadB64)
if err != nil {
return "", "", time.Time{}, ErrHandoffInvalid
return Claims{}, ErrHandoffInvalid
}
sigBytes, err := base64.RawURLEncoding.DecodeString(sigB64)
if err != nil {
return "", "", time.Time{}, ErrHandoffInvalid
return Claims{}, ErrHandoffInvalid
}
// Verify HMAC
expected := computeHMAC(key, payloadBytes)
if !hmac.Equal(sigBytes, expected) {
return "", "", time.Time{}, ErrHandoffInvalid
return Claims{}, ErrHandoffInvalid
}
// Decode payload
var p handoffPayload
if err := json.Unmarshal(payloadBytes, &p); err != nil {
return "", "", time.Time{}, ErrHandoffInvalid
return Claims{}, ErrHandoffInvalid
}
// Check expiry
if time.Now().UTC().Unix() > p.Expiry {
return "", "", time.Time{}, ErrHandoffExpired
return Claims{}, ErrHandoffExpired
}
if p.Email == "" || p.TenantID == "" {
return "", "", time.Time{}, ErrHandoffInvalid
return Claims{}, ErrHandoffInvalid
}
return p.Email, p.TenantID, time.Unix(p.Expiry, 0).UTC(), nil
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 {

View file

@ -124,6 +124,48 @@ func TestVerifyWithExpiryReturnsTokenExpiry(t *testing.T) {
}
}
func TestSignVerifyClaimsRoundTrip(t *testing.T) {
key, _ := GenerateHandoffKey()
ttl := 90 * time.Second
before := time.Now().UTC()
token, err := SignWithClaims(key, Claims{
Email: "owner@example.com",
TenantID: "t-claims",
AccountID: "acct-claims",
UserID: "user-claims",
Role: "owner",
}, ttl)
if err != nil {
t.Fatalf("SignWithClaims: %v", err)
}
claims, err := VerifyClaimsWithExpiry(key, token)
if err != nil {
t.Fatalf("VerifyClaimsWithExpiry: %v", err)
}
if claims.Email != "owner@example.com" {
t.Fatalf("claims.Email = %q, want %q", claims.Email, "owner@example.com")
}
if claims.TenantID != "t-claims" {
t.Fatalf("claims.TenantID = %q, want %q", claims.TenantID, "t-claims")
}
if claims.AccountID != "acct-claims" {
t.Fatalf("claims.AccountID = %q, want %q", claims.AccountID, "acct-claims")
}
if claims.UserID != "user-claims" {
t.Fatalf("claims.UserID = %q, want %q", claims.UserID, "user-claims")
}
if claims.Role != "owner" {
t.Fatalf("claims.Role = %q, want %q", claims.Role, "owner")
}
lowerBound := before.Add(ttl - 5*time.Second)
upperBound := before.Add(ttl + 5*time.Second)
if claims.ExpiresAt.Before(lowerBound) || claims.ExpiresAt.After(upperBound) {
t.Fatalf("claims.ExpiresAt = %s, expected between %s and %s", claims.ExpiresAt, lowerBound, upperBound)
}
}
func TestVerifyEmptyInputs(t *testing.T) {
key, _ := GenerateHandoffKey()