From 6929c1c8fb92acf810db7c2b86fe973c36ada417 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 11 Jun 2026 08:34:26 +0100 Subject: [PATCH] fix(licensing): clear activation only on explicit revocation codes, not any 401 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go half of the licensing-policy client work from the v6 upgrade sweep (license findings 5 and 6); the copy and docs landed in 3a51429a5 and the license-server side lands in pulse-pro. The grant refresh loop treated every HTTP 401 as revocation and wiped the licence and activation file immediately, but the server returns 401 for token-not-found and token-expired as well as revocation, so a single spurious 401 dropped a paying customer to Community until a successful re-exchange. Activation state is now cleared only when the 401 carries an explicit revocation code (TOKEN_REVOKED, INSTALLATION_REVOKED, LICENSE_REVOKED); any other 401 keeps the licence and retries, letting the grant's 72h expiry plus the 7-day grace window govern degradation. The bump_license_version immediate-refresh path in the revocation poller had the same any-401 wipe and gets the same guard. ClassifyLegacyExchangeError now maps a 409 MAX_INSTALLATIONS exchange error to failed/exchange_installation_limit with the free_installation_slot action instead of the retryable pending/exchange_conflict state, since retrying can never succeed until a slot is freed server-side. Other 409s keep the pending conflict semantics. 🤖 Generated with Claude Code Co-Authored-By: Claude --- pkg/licensing/commercial_migration.go | 37 +++--- pkg/licensing/commercial_migration_test.go | 17 +++ pkg/licensing/grant_refresh.go | 39 ++++++- pkg/licensing/grant_refresh_test.go | 128 ++++++++++++++++++++- pkg/licensing/revocation_poll.go | 4 +- 5 files changed, 202 insertions(+), 23 deletions(-) diff --git a/pkg/licensing/commercial_migration.go b/pkg/licensing/commercial_migration.go index e26b90dd2..6971dbf41 100644 --- a/pkg/licensing/commercial_migration.go +++ b/pkg/licensing/commercial_migration.go @@ -16,18 +16,20 @@ const ( CommercialMigrationStatePending CommercialMigrationState = "pending" CommercialMigrationStateFailed CommercialMigrationState = "failed" - CommercialMigrationReasonExchangeUnavailable CommercialMigrationReason = "exchange_unavailable" - CommercialMigrationReasonExchangeRateLimited CommercialMigrationReason = "exchange_rate_limited" - CommercialMigrationReasonExchangeConflict CommercialMigrationReason = "exchange_conflict" - CommercialMigrationReasonExchangeInvalid CommercialMigrationReason = "exchange_invalid" - CommercialMigrationReasonExchangeMalformed CommercialMigrationReason = "exchange_malformed" - CommercialMigrationReasonExchangeRevoked CommercialMigrationReason = "exchange_revoked" - CommercialMigrationReasonExchangeNonMigratable CommercialMigrationReason = "exchange_non_migratable" - CommercialMigrationReasonExchangeUnsupportedKey CommercialMigrationReason = "exchange_unsupported" + CommercialMigrationReasonExchangeUnavailable CommercialMigrationReason = "exchange_unavailable" + CommercialMigrationReasonExchangeRateLimited CommercialMigrationReason = "exchange_rate_limited" + CommercialMigrationReasonExchangeConflict CommercialMigrationReason = "exchange_conflict" + CommercialMigrationReasonExchangeInstallationLimit CommercialMigrationReason = "exchange_installation_limit" + CommercialMigrationReasonExchangeInvalid CommercialMigrationReason = "exchange_invalid" + CommercialMigrationReasonExchangeMalformed CommercialMigrationReason = "exchange_malformed" + CommercialMigrationReasonExchangeRevoked CommercialMigrationReason = "exchange_revoked" + CommercialMigrationReasonExchangeNonMigratable CommercialMigrationReason = "exchange_non_migratable" + CommercialMigrationReasonExchangeUnsupportedKey CommercialMigrationReason = "exchange_unsupported" - CommercialMigrationActionRetryActivation CommercialMigrationAction = "retry_activation" - CommercialMigrationActionUseV6Activation CommercialMigrationAction = "use_v6_activation_key" - CommercialMigrationActionEnterSupportedV5 CommercialMigrationAction = "enter_supported_v5_key" + CommercialMigrationActionRetryActivation CommercialMigrationAction = "retry_activation" + CommercialMigrationActionUseV6Activation CommercialMigrationAction = "use_v6_activation_key" + CommercialMigrationActionEnterSupportedV5 CommercialMigrationAction = "enter_supported_v5_key" + CommercialMigrationActionFreeInstallationSlot CommercialMigrationAction = "free_installation_slot" ) // CommercialMigrationStatus is the explicit v6-owned contract for unresolved @@ -113,8 +115,17 @@ func ClassifyLegacyExchangeError(err error) *CommercialMigrationStatus { status.Reason = CommercialMigrationReasonExchangeRevoked status.RecommendedAction = CommercialMigrationActionUseV6Activation case 409: - status.State = CommercialMigrationStatePending - status.Reason = CommercialMigrationReasonExchangeConflict + if strings.EqualFold(strings.TrimSpace(serverErr.Code), "MAX_INSTALLATIONS") { + // The key has used up its installation slots server-side. + // Retrying from this instance can never succeed until a slot + // is freed, so this is terminal, not pending. + status.State = CommercialMigrationStateFailed + status.Reason = CommercialMigrationReasonExchangeInstallationLimit + status.RecommendedAction = CommercialMigrationActionFreeInstallationSlot + } else { + status.State = CommercialMigrationStatePending + status.Reason = CommercialMigrationReasonExchangeConflict + } case 410: status.State = CommercialMigrationStateFailed status.Reason = CommercialMigrationReasonExchangeNonMigratable diff --git a/pkg/licensing/commercial_migration_test.go b/pkg/licensing/commercial_migration_test.go index f82c961b0..9c45776a7 100644 --- a/pkg/licensing/commercial_migration_test.go +++ b/pkg/licensing/commercial_migration_test.go @@ -11,6 +11,7 @@ func TestClassifyLegacyExchangeError(t *testing.T) { err error wantState CommercialMigrationState wantReason CommercialMigrationReason + wantAction CommercialMigrationAction }{ { name: "retryable server error stays pending", @@ -36,6 +37,19 @@ func TestClassifyLegacyExchangeError(t *testing.T) { wantState: CommercialMigrationStateFailed, wantReason: CommercialMigrationReasonExchangeUnsupportedKey, }, + { + name: "installation limit conflict is terminal with slot guidance", + err: fmt.Errorf("activation failed: %w", &LicenseServerError{StatusCode: 409, Code: "MAX_INSTALLATIONS"}), + wantState: CommercialMigrationStateFailed, + wantReason: CommercialMigrationReasonExchangeInstallationLimit, + wantAction: CommercialMigrationActionFreeInstallationSlot, + }, + { + name: "other conflict stays pending", + err: fmt.Errorf("activation failed: %w", &LicenseServerError{StatusCode: 409, Code: "EXCHANGE_IN_PROGRESS"}), + wantState: CommercialMigrationStatePending, + wantReason: CommercialMigrationReasonExchangeConflict, + }, } for _, tt := range tests { @@ -50,6 +64,9 @@ func TestClassifyLegacyExchangeError(t *testing.T) { if got.Reason != tt.wantReason { t.Fatalf("reason=%q, want %q", got.Reason, tt.wantReason) } + if tt.wantAction != "" && got.RecommendedAction != tt.wantAction { + t.Fatalf("action=%q, want %q", got.RecommendedAction, tt.wantAction) + } }) } } diff --git a/pkg/licensing/grant_refresh.go b/pkg/licensing/grant_refresh.go index f87d1df5f..723bb1f7b 100644 --- a/pkg/licensing/grant_refresh.go +++ b/pkg/licensing/grant_refresh.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "math/rand/v2" + "strings" "sync" "time" @@ -156,15 +157,26 @@ func (s *Service) runGrantRefreshLoop(ctx context.Context) { if err := s.refreshGrantOnce(ctx); err != nil { consecutiveFailures++ - var apiErr *LicenseServerError - if errors.As(err, &apiErr) && apiErr.StatusCode == 401 { - // 401 means the installation token is revoked or expired. - // Clear activation state and revert to free tier. - log.Warn().Msg("Grant refresh received 401 (revoked/expired), clearing activation") + if isRevokedActivationError(err) { + // The server explicitly says this installation or licence is + // revoked. Clear activation state and revert to free tier. + log.Warn().Msg("Grant refresh rejected as revoked, clearing activation") s.clearActivationState() return } + var apiErr *LicenseServerError + if errors.As(err, &apiErr) && apiErr.StatusCode == 401 { + // A 401 without an explicit revocation code also covers + // token-not-found, token-expired, and transient server-side + // auth issues. Keep the licence: the grant's own expiry plus + // the grace window governs degradation, and recovery happens + // via a later successful refresh or startup re-exchange. + log.Warn(). + Str("code", apiErr.Code). + Msg("Grant refresh got 401 without revocation code, keeping licence and retrying") + } + log.Warn(). Err(err). Int("consecutive_failures", consecutiveFailures). @@ -273,6 +285,23 @@ func (s *Service) refreshGrantOnce(ctx context.Context) error { return nil } +// isRevokedActivationError reports whether err is a license-server 401 that +// explicitly indicates the installation or licence has been revoked. Only +// these codes justify wiping local activation state; any other 401 (token +// not found, token expired, malformed body) keeps the licence and relies on +// grant-expiry grace instead. +func isRevokedActivationError(err error) bool { + var apiErr *LicenseServerError + if !errors.As(err, &apiErr) || apiErr.StatusCode != 401 { + return false + } + switch strings.ToUpper(strings.TrimSpace(apiErr.Code)) { + case "TOKEN_REVOKED", "INSTALLATION_REVOKED", "LICENSE_REVOKED": + return true + } + return false +} + // clearActivationState clears the activation state and reverts to free tier. // Called when the license server indicates the installation is revoked. func (s *Service) clearActivationState() { diff --git a/pkg/licensing/grant_refresh_test.go b/pkg/licensing/grant_refresh_test.go index 29a96170c..438595821 100644 --- a/pkg/licensing/grant_refresh_test.go +++ b/pkg/licensing/grant_refresh_test.go @@ -3,6 +3,7 @@ package licensing import ( "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "os" @@ -117,11 +118,134 @@ func TestGrantRefreshLoop_RefreshesGrant(t *testing.T) { } } +func TestIsRevokedActivationError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {"revoked token", &LicenseServerError{StatusCode: 401, Code: "TOKEN_REVOKED"}, true}, + {"revoked token lowercase", &LicenseServerError{StatusCode: 401, Code: "token_revoked"}, true}, + {"revoked installation", &LicenseServerError{StatusCode: 401, Code: "INSTALLATION_REVOKED"}, true}, + {"revoked license", &LicenseServerError{StatusCode: 401, Code: "LICENSE_REVOKED"}, true}, + {"wrapped revoked token", fmt.Errorf("refresh grant: %w", &LicenseServerError{StatusCode: 401, Code: "TOKEN_REVOKED"}), true}, + {"expired token", &LicenseServerError{StatusCode: 401, Code: "TOKEN_EXPIRED"}, false}, + {"token not found", &LicenseServerError{StatusCode: 401, Code: "INVALID_TOKEN"}, false}, + {"401 without structured code", &LicenseServerError{StatusCode: 401, Code: "http_401"}, false}, + {"revocation code on non-401", &LicenseServerError{StatusCode: 403, Code: "TOKEN_REVOKED"}, false}, + {"plain error", fmt.Errorf("connection refused"), false}, + {"nil error", nil, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isRevokedActivationError(tt.err); got != tt.want { + t.Fatalf("isRevokedActivationError() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGrantRefreshLoop_NonRevocation401KeepsActivation(t *testing.T) { + setupTestPublicKey(t) + + // A 401 without an explicit revocation code (token not found, expired, + // transient server auth issues) must NOT wipe the licence — the grant's + // own expiry plus grace governs degradation instead. + var refreshCount atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + refreshCount.Add(1) + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]any{ + "code": "INVALID_TOKEN", + "message": "Installation token not found", + }) + })) + defer server.Close() + + initialGrantJWT := makeTestGrantJWT(t, &GrantClaims{ + LicenseID: "lic_spurious_401", + Tier: "pro", + State: "active", + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), + }) + + tmpDir, err := os.MkdirTemp("", "pulse-refresh-spurious-401-*") + if err != nil { + t.Fatalf("create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + p, err := NewPersistence(tmpDir) + if err != nil { + t.Fatalf("create persistence: %v", err) + } + + svc := NewService() + svc.SetLicenseServerClient(NewLicenseServerClient(server.URL)) + svc.SetPersistence(p) + + state := &ActivationState{ + InstallationID: "inst_spurious", + InstallationToken: "pit_live_spurious", + LicenseID: "lic_spurious_401", + GrantJWT: initialGrantJWT, + GrantJTI: "grant_old", + InstanceFingerprint: "fp-spurious", + } + if err := p.SaveActivationState(state); err != nil { + t.Fatalf("save state: %v", err) + } + + svc.mu.Lock() + svc.activationState = state + svc.mu.Unlock() + if err := svc.RestoreActivation(state); err != nil { + t.Fatalf("RestoreActivation: %v", err) + } + + svc.StartGrantRefresh(context.Background()) + svc.mu.RLock() + loop := svc.grantRefresh + svc.mu.RUnlock() + loop.mu.Lock() + loop.refreshInterval = time.Millisecond + loop.jitterPercent = 0 + loop.mu.Unlock() + + // Wait for at least one failed refresh attempt. + deadline := time.After(5 * time.Second) + for refreshCount.Load() == 0 { + select { + case <-deadline: + svc.StopGrantRefresh() + t.Fatal("timed out waiting for refresh attempt") + default: + time.Sleep(10 * time.Millisecond) + } + } + // Give the loop a moment to (incorrectly) clear state if it were going to. + time.Sleep(50 * time.Millisecond) + svc.StopGrantRefresh() + + if svc.Current() == nil { + t.Fatal("expected licence to survive a non-revocation 401") + } + if !svc.IsActivated() { + t.Fatal("expected activation to survive a non-revocation 401") + } + if !p.ActivationStateExists() { + t.Fatal("expected persisted activation state to survive a non-revocation 401") + } +} + func TestGrantRefreshLoop_401ClearsActivation(t *testing.T) { setupTestPublicKey(t) - // This test exercises the full runGrantRefreshLoop 401 branch, which calls - // clearActivationState to revert to free tier and exit the loop. + // This test exercises the runGrantRefreshLoop revocation branch: a 401 + // carrying an explicit revocation code calls clearActivationState to + // revert to free tier and exit the loop. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) json.NewEncoder(w).Encode(map[string]any{ diff --git a/pkg/licensing/revocation_poll.go b/pkg/licensing/revocation_poll.go index bf830c8ea..f35770504 100644 --- a/pkg/licensing/revocation_poll.go +++ b/pkg/licensing/revocation_poll.go @@ -2,7 +2,6 @@ package licensing import ( "context" - "errors" "fmt" "math/rand/v2" "sync" @@ -225,8 +224,7 @@ func (s *Service) handleRevocationEvent(event RevocationEvent) { Msg("License version bumped — triggering immediate grant refresh") go func() { if err := s.refreshGrantOnce(context.Background()); err != nil { - var apiErr *LicenseServerError - if errors.As(err, &apiErr) && apiErr.StatusCode == 401 { + if isRevokedActivationError(err) { s.clearActivationState() return }