From 57a3f19a84837b7916fa53ab48062adaeb107e00 Mon Sep 17 00:00:00 2001 From: Richard Courtman Date: Thu, 26 Mar 2026 23:40:02 +0000 Subject: [PATCH] fix(hosted): preserve direct handoff membership continuity --- .../v6/internal/subsystems/agent-lifecycle.md | 6 ++ .../v6/internal/subsystems/api-contracts.md | 11 +-- .../internal/subsystems/storage-recovery.md | 5 ++ internal/api/cloud_handoff.go | 17 +++- internal/api/cloud_handoff_test.go | 62 +++++++++++++++ internal/api/contract_test.go | 60 ++++++++++++++ internal/cloudcp/auth/handlers.go | 44 ++++++++--- .../cloudcp/auth/handlers_magiclink_test.go | 26 ++++++ pkg/cloudauth/handoff.go | 79 ++++++++++++++----- pkg/cloudauth/handoff_test.go | 42 ++++++++++ 10 files changed, 313 insertions(+), 39 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index af7233991..68d407050 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 7204dc2f0..6e6538aab 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 903cfd49e..0bdc9084b 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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 diff --git a/internal/api/cloud_handoff.go b/internal/api/cloud_handoff.go index f2144d50b..315e22ac7 100644 --- a/internal/api/cloud_handoff.go +++ b/internal/api/cloud_handoff.go @@ -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) diff --git a/internal/api/cloud_handoff_test.go b/internal/api/cloud_handoff_test.go index 341c1e04d..0958344cb 100644 --- a/internal/api/cloud_handoff_test.go +++ b/internal/api/cloud_handoff_test.go @@ -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) + } +} diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index c577d84f6..776fe9f1c 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -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{ diff --git a/internal/cloudcp/auth/handlers.go b/internal/cloudcp/auth/handlers.go index 9cff18894..69d7cde3f 100644 --- a/internal/cloudcp/auth/handlers.go +++ b/internal/cloudcp/auth/handlers.go @@ -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 := ®istry.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 } diff --git a/internal/cloudcp/auth/handlers_magiclink_test.go b/internal/cloudcp/auth/handlers_magiclink_test.go index f08fb2d9a..e97d45ea1 100644 --- a/internal/cloudcp/auth/handlers_magiclink_test.go +++ b/internal/cloudcp/auth/handlers_magiclink_test.go @@ -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") + } } diff --git a/pkg/cloudauth/handoff.go b/pkg/cloudauth/handoff.go index 602012fba..512f54703 100644 --- a/pkg/cloudauth/handoff.go +++ b/pkg/cloudauth/handoff.go @@ -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 { diff --git a/pkg/cloudauth/handoff_test.go b/pkg/cloudauth/handoff_test.go index 1a7cd7b99..5d2180f94 100644 --- a/pkg/cloudauth/handoff_test.go +++ b/pkg/cloudauth/handoff_test.go @@ -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()