diff --git a/README.md b/README.md index bd0389e..1e1f8d4 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,25 @@ even when calling `validate_token_online` — the SDK never contacts the API for them. Use `validate_token_local` directly when you want the local-only check explicitly. +## Revoking an Activation + +To free up the activation seat for the current device — typically wired to a +"Deactivate" or "Sign out" button — call `revoke_activation` with the JWT: + +```cpp +if (auto local = licensing.store().load_local_license()) { + licensing.revoke_activation(local->token); // server-side revoke + clears local store +} +``` + +On success the SDK both tells the server to release the seat and deletes the +matching license from the local store. Revoke is only meaningful for +online-activated paid licenses; calling it for offline or trial tokens raises +`operation_not_supported_error` without contacting the API. Server rejections +(`license_invalid_error`) and transport failures (`api_error`) propagate the +same way they do for `validate_token_online`, but with no grace-period +fallback — revoke is a one-shot operation. + ## Custom Fingerprinting and Storage ```cpp diff --git a/docs/juce.md b/docs/juce.md index b087eba..15beacf 100644 --- a/docs/juce.md +++ b/docs/juce.md @@ -157,7 +157,41 @@ void timerCallback() override `pollPendingActivation()` is non-blocking. The poll cadence is up to you; once a second is plenty for a UI-driven flow. -To revoke locally: `unlockStatus.clearLicense();` +## Deactivating + +Two paths, depending on whether you want to free the seat server-side: + +- `revokeActivation()` / `revokeActivationAsync(callback)` — calls the + Moonbase backend to release this device's activation and then clears local + state. Use this for "Deactivate" / "Sign out" buttons in your UI so the + user can re-activate elsewhere without burning a seat. +- `clearLicense()` — local-only forget. Use it for offline or trial licenses + (which can't be revoked), or when you just want this device to stop + recognising the license without telling the server. + +`revokeActivationAsync` is recommended for UI: the network call runs on a +`juce::Thread` and the callback is delivered on the message thread, with the +same generation-gating as `tryLoadStoredLicenseAsync` (a slow revoke can't +clobber a freshly activated license). + +The callback receives a `RevokeOutcome`: + +| Outcome | Meaning | +| --- | --- | +| `Revoked` | Seat freed server-side (or token was already gone). Bridge is now locked. | +| `NoLicense` | No license loaded — nothing to do. | +| `NotRevokable` | Token is offline-activated or a trial. Bridge state unchanged. Call `clearLicense()` if you still want a local forget. | +| `Unreachable` | Transport failure. Bridge state unchanged so the user can retry. | + +```cpp +unlockStatus.revokeActivationAsync( + [this](auto outcome) { + using O = moonbase::juce_bridge::MoonbaseUnlockStatus::RevokeOutcome; + if (outcome == O::NotRevokable) + unlockStatus.clearLicense(); // trial / offline → local-only fallback + repaintActivationLabel(); + }); +``` ## Gating features diff --git a/examples/juce/MoonbaseJuceBridge.h b/examples/juce/MoonbaseJuceBridge.h index 6d2a399..8b6870c 100644 --- a/examples/juce/MoonbaseJuceBridge.h +++ b/examples/juce/MoonbaseJuceBridge.h @@ -422,6 +422,194 @@ class MoonbaseUnlockStatus : public juce::OnlineUnlockStatus }); } + enum class RevokeOutcome + { + Revoked, // Server-side revoke succeeded (or token was already gone). Bridge is now locked. + NoLicense, // No license is currently loaded — nothing to revoke. Bridge state unchanged. + NotRevokable, // Token is offline-activated or a trial. Bridge state unchanged. + // Caller can fall back to clearLicense() for a local-only forget. + Unreachable, // Transport failure (network down, 5xx). Bridge state unchanged so the + // caller can retry; no seat has been freed server-side. + }; + + // Server-side revoke of the currently loaded activation. On success, frees + // the seat on the Moonbase backend and clears the local store + bridge + // state. On NotRevokable / Unreachable the bridge state is left alone so + // the caller can decide between a local-only fallback (clearLicense()) or + // a retry. + // + // Synchronous: blocks the calling thread on libcurl. Prefer + // revokeActivationAsync() from UI threads. + RevokeOutcome revokeActivation() + { + std::string token; + std::string activationId; + { + const juce::ScopedLock lock(stateLock_); + if (!current_) + return RevokeOutcome::NoLicense; + token = current_->token; + activationId = current_->activation_id; + } + + ++validationGeneration_; + + auto clearMatchingLocal = [&] { + try + { + auto stored = licensing_->store().load_local_license(); + if (stored && stored->activation_id == activationId) + licensing_->store().delete_local_license(); + } + catch (const moonbase::storage_error&) {} + }; + + try + { + licensing_->revoke_activation(token); + } + catch (const moonbase::operation_not_supported_error&) + { + return RevokeOutcome::NotRevokable; + } + catch (const moonbase::license_invalid_error&) + { + // Server says the token is unknown/invalid — treat as already gone. + // The SDK didn't reach its own store-cleanup, so do it here, gated + // on activation_id so we never wipe an unrelated cached license. + clearMatchingLocal(); + setUnlocked(std::nullopt); + return RevokeOutcome::Revoked; + } + catch (const moonbase::license_expired_error&) + { + clearMatchingLocal(); + setUnlocked(std::nullopt); + return RevokeOutcome::Revoked; + } + catch (const std::exception&) + { + return RevokeOutcome::Unreachable; + } + + // SDK facade has already cleared the matching license from the store. + setUnlocked(std::nullopt); + return RevokeOutcome::Revoked; + } + + // Non-blocking variant of revokeActivation. + // + // Threading: state mutation, store cleanup, and the callback all run on + // the JUCE message thread, regardless of which thread invokes this + // method. The network call runs on a juce::Thread. + // + // Staleness: same generation-gating as tryLoadStoredLicenseAsync — if the + // user calls clearLicense(), kicks off another async operation, or + // finishes a new activation while a revoke is in flight, the older + // continuation is dropped silently. Both state mutation AND the user + // callback are dropped, so a stale revoke can never overwrite the UI of + // a freshly activated license, and a destroyed bridge never receives + // its callback. + // + // Lifetime: licensing_ is a shared_ptr so the background thread keeps the + // SDK alive even if the bridge is destroyed mid-request. A + // juce::WeakReference protects the message-thread continuation from + // touching a destroyed bridge. + void revokeActivationAsync(std::function onComplete = {}) + { + std::string token; + std::string activationId; + { + const juce::ScopedLock lock(stateLock_); + if (current_) + { + token = current_->token; + activationId = current_->activation_id; + } + } + + const auto generation = ++validationGeneration_; + juce::WeakReference safeThis(this); + auto licensingHandle = licensing_; + auto cb = std::move(onComplete); + + if (token.empty()) + { + // Fast path: nothing loaded. Still go through the message-thread + // gate so a destroyed bridge or a superseding op silently drops + // the callback, matching the rest of the async surface. + juce::MessageManager::callAsync( + [safeThis, generation, cb = std::move(cb)]() mutable + { + auto* self = safeThis.get(); + if (self == nullptr + || generation != self->validationGeneration_.load()) + return; + if (cb) + cb(RevokeOutcome::NoLicense); + }); + return; + } + + juce::Thread::launch( + [licensingHandle, token, activationId, generation, safeThis, + cb = std::move(cb)]() mutable + { + RevokeOutcome outcome = RevokeOutcome::Revoked; + try + { + licensingHandle->revoke_activation(token); + } + catch (const moonbase::operation_not_supported_error&) + { + outcome = RevokeOutcome::NotRevokable; + } + catch (const moonbase::license_invalid_error&) + { + outcome = RevokeOutcome::Revoked; + } + catch (const moonbase::license_expired_error&) + { + outcome = RevokeOutcome::Revoked; + } + catch (const std::exception&) + { + outcome = RevokeOutcome::Unreachable; + } + + juce::MessageManager::callAsync( + [safeThis, generation, outcome, activationId, + licensingHandle, cb = std::move(cb)]() mutable + { + auto* self = safeThis.get(); + if (self == nullptr + || generation != self->validationGeneration_.load()) + return; + + if (outcome == RevokeOutcome::Revoked) + { + // Cover the invalid/expired server-response path + // where the SDK threw before its own store + // cleanup. activation_id matching ensures a stale + // revoke whose generation somehow still matches + // can't wipe an unrelated cached license. + try + { + auto stored = licensingHandle->store().load_local_license(); + if (stored && stored->activation_id == activationId) + licensingHandle->store().delete_local_license(); + } + catch (const moonbase::storage_error&) {} + + self->setUnlocked(std::nullopt); + } + + if (cb) + cb(outcome); + }); + }); + } + // Begins a new browser activation. Returns the URL to hand to // juce::URL::launchInDefaultBrowser. Throws moonbase::api_error on // network/server failure. diff --git a/examples/juce/PluginActivationComponent.h b/examples/juce/PluginActivationComponent.h index 7b5be88..aea6ded 100644 --- a/examples/juce/PluginActivationComponent.h +++ b/examples/juce/PluginActivationComponent.h @@ -26,7 +26,7 @@ class PluginActivationComponent : public juce::Component, activateButton_.onClick = [this] { startActivation(); }; addAndMakeVisible(deactivateButton_); - deactivateButton_.onClick = [this] { unlockStatus_.clearLicense(); refreshLabel(); }; + deactivateButton_.onClick = [this] { startRevoke(); }; // Async load: never blocks the message thread on libcurl. The label is // updated optimistically from the local-validation result, then again @@ -119,6 +119,31 @@ ERUn++6CVMPvZo67jVbTY+GCXYfW4gGVZQIDAQAB } } + void startRevoke() + { + refreshLabel("Revoking activation..."); + unlockStatus_.revokeActivationAsync([this](auto outcome) + { + using O = moonbase::juce_bridge::MoonbaseUnlockStatus::RevokeOutcome; + switch (outcome) + { + case O::NotRevokable: + // Trial or offline-activated — server-side revoke isn't + // applicable, so fall back to a local-only forget. + unlockStatus_.clearLicense(); + refreshLabel(); + break; + case O::Unreachable: + refreshLabel("Could not reach Moonbase to revoke. Try again when online."); + break; + case O::Revoked: + case O::NoLicense: + refreshLabel(); + break; + } + }); + } + void refreshLabel(const juce::String& explicitMessage = {}) { if (explicitMessage.isNotEmpty()) diff --git a/include/moonbase/client.hpp b/include/moonbase/client.hpp index d6f203e..19a1b72 100644 --- a/include/moonbase/client.hpp +++ b/include/moonbase/client.hpp @@ -45,6 +45,14 @@ inline std::string validate_path(const licensing_options& options) "/validate"; } +inline std::string revoke_path(const licensing_options& options) +{ + return trim_trailing_slashes(options.endpoint) + + "/api/client/licenses/" + + url_encode(options.product_id) + + "/revoke"; +} + inline std::map client_query(const licensing_options& options) { std::map query{{"format", "JWT"}}; @@ -193,6 +201,24 @@ class license_client { return validator_->validate_token(response.body); } + void revoke_activation(std::string_view token) const + { + const auto url = detail::append_query( + detail::revoke_path(options_), + detail::client_query(options_)); + + http_request request; + request.method = "POST"; + request.url = url; + request.headers = detail::default_headers("text/plain"); + request.body = std::string(token); + + const auto response = transport_->send(request); + if (response.status_code < 200 || response.status_code >= 300) { + detail::throw_for_problem(response.status_code, response.body); + } + } + [[nodiscard]] std::optional get_requested_activation( const activation_request& activation) const { diff --git a/include/moonbase/errors.hpp b/include/moonbase/errors.hpp index 33378d4..bb6312b 100644 --- a/include/moonbase/errors.hpp +++ b/include/moonbase/errors.hpp @@ -11,6 +11,7 @@ enum class error_type { license_expired, storage_error, configuration_error, + operation_not_supported, }; class moonbase_error : public std::runtime_error { @@ -78,4 +79,12 @@ class storage_error : public moonbase_error { } }; +class operation_not_supported_error : public moonbase_error { +public: + explicit operation_not_supported_error(const std::string& message) + : moonbase_error(error_type::operation_not_supported, message) + { + } +}; + } // namespace moonbase diff --git a/include/moonbase/licensing.hpp b/include/moonbase/licensing.hpp index 2ee3d7d..d643c21 100644 --- a/include/moonbase/licensing.hpp +++ b/include/moonbase/licensing.hpp @@ -90,6 +90,36 @@ class licensing { } } + void revoke_activation(std::string_view token) const + { + // allow_expired: a long-running app may click "Deactivate" after the + // local token has aged out, but the server-side seat is still + // allocated until we POST to /revoke. + const auto local = validator_->validate_token_allow_expired(token); + + if (local.method == activation_method::offline) { + throw operation_not_supported_error( + "Offline-activated licenses cannot be revoked"); + } + if (local.trial) { + throw operation_not_supported_error( + "Trial licenses cannot be revoked"); + } + + client_->revoke_activation(token); + + // Store cleanup is best-effort — the server-side seat is already + // freed, so a local IO failure must not surface as a revoke failure + // (callers would retry against a token the server no longer knows). + try { + if (auto stored = store_->load_local_license(); + stored && stored->activation_id == local.activation_id) { + store_->delete_local_license(); + } + } catch (const storage_error&) { + } + } + [[nodiscard]] license_store& store() noexcept { return *store_; } [[nodiscard]] const license_store& store() const noexcept { return *store_; } diff --git a/include/moonbase/validator.hpp b/include/moonbase/validator.hpp index 6d6bb96..1f07602 100644 --- a/include/moonbase/validator.hpp +++ b/include/moonbase/validator.hpp @@ -316,6 +316,21 @@ class license_validator { } [[nodiscard]] license validate_token(std::string_view token) const + { + return validate_token_internal(token, false); + } + + // Same as validate_token, but does not throw on a past `exp`. Intended for + // operations like revoke where the seat may still be allocated server-side + // even after the local token has aged out. All other checks (signature, + // audience, issuer, device match) still apply. + [[nodiscard]] license validate_token_allow_expired(std::string_view token) const + { + return validate_token_internal(token, true); + } + +private: + [[nodiscard]] license validate_token_internal(std::string_view token, bool allow_expired) const { const auto token_string = detail::trim_ascii_whitespace(std::string(token)); const auto parts = detail::split_jwt(token_string); @@ -372,7 +387,8 @@ class license_validator { ? detail::object_claim_or_empty(payload, "t:properties") : detail::object_claim_or_empty(payload, "l:properties"); - if (result.expires_at && *result.expires_at < std::chrono::system_clock::now()) { + if (!allow_expired && result.expires_at + && *result.expires_at < std::chrono::system_clock::now()) { throw license_expired_error("License has expired"); } @@ -385,7 +401,6 @@ class license_validator { return result; } -private: licensing_options options_; std::shared_ptr fingerprints_; detail::evp_pkey_ptr key_; diff --git a/tests/client_tests.cpp b/tests/client_tests.cpp index b291a60..45d3591 100644 --- a/tests/client_tests.cpp +++ b/tests/client_tests.cpp @@ -188,3 +188,43 @@ TEST_CASE("validate_token_online re-validates the refreshed JWT locally") CHECK_THROWS_AS((void)fixture.client.validate_token_online("token"), license_invalid_error); } + +TEST_CASE("revoke_activation posts the JWT to the revoke endpoint") +{ + client_fixture fixture({ + http_response{200, {}, ""}, + }); + + fixture.client.revoke_activation("original.jwt.token"); + + REQUIRE(fixture.transport->requests.size() == 1); + const auto& request = fixture.transport->requests.front(); + CHECK(request.method == "POST"); + CHECK(request.url.find("https://demo.moonbase.sh/api/client/licenses/demo-app/revoke?") == 0); + CHECK(request.url.find("format=JWT") != std::string::npos); + CHECK(request.url.find("platform=Mac") != std::string::npos); + CHECK(request.url.find("appVersion=1.2.3") != std::string::npos); + CHECK(request.url.find("meta%5Bchannel%5D=test") != std::string::npos); + CHECK(request.headers.at("Content-Type") == "text/plain"); + CHECK(request.headers.at("x-mb-client") == "moonbase-cpp"); + CHECK(request.body == "original.jwt.token"); +} + +TEST_CASE("revoke_activation maps API errors") +{ + SUBCASE("invalid") + { + client_fixture fixture({ + http_response{400, {}, R"({"title":"Invalid","detail":"Token is not valid"})"}, + }); + CHECK_THROWS_AS(fixture.client.revoke_activation("token"), license_invalid_error); + } + + SUBCASE("server error") + { + client_fixture fixture({ + http_response{500, {}, R"({"title":"Failure","detail":"Backend failed"})"}, + }); + CHECK_THROWS_AS(fixture.client.revoke_activation("token"), api_error); + } +} diff --git a/tests/licensing_tests.cpp b/tests/licensing_tests.cpp index 82daded..5ccecb5 100644 --- a/tests/licensing_tests.cpp +++ b/tests/licensing_tests.cpp @@ -210,3 +210,134 @@ TEST_CASE("validate_token_online honors a custom min interval") CHECK(fixture.transport->requests.size() == 1); } + +TEST_CASE("revoke_activation refuses offline tokens without contacting the API") +{ + facade_fixture fixture; + auto claims = moonbase::tests::default_claims(); + claims["method"] = "Offline"; + const auto token = fixture.make_token(claims); + + CHECK_THROWS_AS(fixture.instance.revoke_activation(token), + operation_not_supported_error); + CHECK(fixture.transport->requests.empty()); +} + +TEST_CASE("revoke_activation refuses trial tokens without contacting the API") +{ + facade_fixture fixture; + auto claims = moonbase::tests::default_claims(); + claims["trial"] = true; + const auto token = fixture.make_token(claims); + + CHECK_THROWS_AS(fixture.instance.revoke_activation(token), + operation_not_supported_error); + CHECK(fixture.transport->requests.empty()); +} + +TEST_CASE("revoke_activation calls the API and clears the matching local license") +{ + facade_fixture fixture; + const auto token = fixture.make_token(moonbase::tests::default_claims()); + const auto stored = fixture.instance.validator().validate_token(token); + fixture.instance.store().store_local_license(stored); + + fixture.transport->responses.push_back(http_response{200, {}, ""}); + + fixture.instance.revoke_activation(token); + + REQUIRE(fixture.transport->requests.size() == 1); + CHECK(fixture.transport->requests.front().url.find( + "/api/client/licenses/demo-app/revoke") != std::string::npos); + CHECK_FALSE(fixture.instance.store().load_local_license().has_value()); +} + +TEST_CASE("revoke_activation leaves an unrelated stored license alone") +{ + facade_fixture fixture; + + auto stored_claims = moonbase::tests::default_claims(); + stored_claims["id"] = "activation-A"; + const auto stored_token = fixture.make_token(stored_claims); + const auto stored_license = + fixture.instance.validator().validate_token(stored_token); + fixture.instance.store().store_local_license(stored_license); + + auto revoke_claims = moonbase::tests::default_claims(); + revoke_claims["id"] = "activation-B"; + const auto revoke_token = fixture.make_token(revoke_claims); + + fixture.transport->responses.push_back(http_response{200, {}, ""}); + + fixture.instance.revoke_activation(revoke_token); + + REQUIRE(fixture.transport->requests.size() == 1); + const auto remaining = fixture.instance.store().load_local_license(); + REQUIRE(remaining.has_value()); + CHECK(remaining->activation_id == "activation-A"); +} + +TEST_CASE("revoke_activation still POSTs when the local token has expired") +{ + facade_fixture fixture; + auto claims = moonbase::tests::default_claims(); + claims["exp"] = moonbase::tests::now_seconds() - 60; // already past + const auto token = fixture.make_token(claims); + + fixture.transport->responses.push_back(http_response{200, {}, ""}); + + // Must not throw license_expired_error; the seat is still allocated + // server-side and the request should reach the API. + fixture.instance.revoke_activation(token); + + REQUIRE(fixture.transport->requests.size() == 1); + CHECK(fixture.transport->requests.front().url.find( + "/api/client/licenses/demo-app/revoke") != std::string::npos); +} + +namespace { + +class throwing_license_store : public license_store { +public: + std::optional load_local_license() override + { + throw storage_error("load failed"); + } + + void store_local_license(const license&) override {} + + void delete_local_license() override + { + throw storage_error("delete failed"); + } +}; + +} // namespace + +TEST_CASE("revoke_activation succeeds even if local store cleanup fails") +{ + auto fingerprints = + std::make_shared("Test Device", "device-id"); + auto transport = std::make_shared(); + auto store = std::make_shared(); + + moonbase::tests::generated_key key = moonbase::tests::generate_key(); + licensing_options options; + options.endpoint = "https://demo.moonbase.sh"; + options.product_id = "demo-app"; + options.public_key = key.public_pem; + options.account_id = "tenant-1"; + + licensing instance(std::move(options), store, fingerprints, transport); + + const auto token = moonbase::tests::make_token( + key.key.get(), moonbase::tests::default_claims()); + transport->responses.push_back(http_response{200, {}, ""}); + + // The server-side seat is freed the moment the API returns 200 — a local + // storage failure must not turn that into a thrown error that callers + // would interpret as "retry against a token the server no longer knows". + instance.revoke_activation(token); + + REQUIRE(transport->requests.size() == 1); +}