From 94eee666ecf3d379b036bb2c4cceb640c8a59adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20L=C3=B8nner=C3=B8d=20Madsen?= Date: Tue, 16 Jun 2026 13:28:56 +0200 Subject: [PATCH] feat: add offline activation flow Add SDK support for Moonbase's file-based offline activation: emit a device token ("machine file") and read back the offline license token. - licensing::generate_device_token() emits the base64-encoded device token (id/name/productId/format) for upload to the activation page. - licensing::read_offline_license() validates a downloaded license token locally and requires method == offline. - JUCE bridge gains deviceTokenContents() and activateOffline(); the sample app wires both to Save machine file / Load license token buttons and shows richer license details (activation method, product, add-ons, subscription, expiry). - Unit tests for both new SDK methods; README/docs updated, including the three ways to exchange the device token (hosted portal, embedded storefront activate_product intent, custom API/SDK flow). --- README.md | 48 ++++++++ docs/juce.md | 40 ++++++ examples/juce/MoonbaseJuceBridge.h | 56 +++++++++ examples/juce/PluginActivationComponent.h | 142 +++++++++++++++++++--- examples/juce/README.md | 11 ++ include/moonbase/licensing.hpp | 37 ++++++ tests/licensing_tests.cpp | 56 +++++++++ 7 files changed, 376 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index b38bf3d..0e3e436 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,54 @@ online-activated paid licenses; calling it for offline or trial tokens raises same way they do for `validate_token_online`, but with no grace-period fallback — revoke is a one-shot operation. +## Offline Activation + +For machines without internet access, Moonbase supports a file-based flow: the +app emits a **device token** ("machine file"), the user exchanges it for a +license token on the Moonbase activation page, and the app reads that token back +in. No network is involved on the device. + +1. Generate the device token and write it to a file (conventionally `.dt`): + + ```cpp + const auto device_token = licensing.generate_device_token(); + std::ofstream("device-token.dt") << device_token; + ``` + +2. The user uploads `device-token.dt` and receives a license token file (the + raw JWT, conventionally `license.mb`) in return. They can do this through any + of: + + - **Moonbase's hosted portal** — `https://.moonbase.sh/activate`. + - **The embedded storefront on your own site** — trigger the + [`activate_product`](https://moonbase.sh/docs/storefronts/embedded/#call-methods) + intent (`Moonbase.activate_product()`), which prompts for the device token + and hands back the license token file. The `deviceTokenFileExtension` + (default `.dt`) and `licenseTokenFileName` (default `license-file.mb`) + config options control the file types involved. + - **Your own custom flow** — drive the exchange yourself with the Moonbase + [APIs and SDKs](https://moonbase.sh/docs/licensing/offline-activations/) + (the `/api/customer/inventory/activate` endpoint). + +3. Read the downloaded token back in, validate it locally, and persist it: + + ```cpp + std::ifstream file("license.mb"); + const std::string token((std::istreambuf_iterator(file)), + std::istreambuf_iterator()); + + auto license = licensing.read_offline_license(token); // local validation only + licensing.store().store_local_license(license); + ``` + +`read_offline_license` runs the same local checks as `validate_token_local` +(signature, audience, issuer, device fingerprint, expiry) and additionally +requires the token to have been issued via offline activation, throwing +`license_invalid_error` otherwise. On startup, validate the stored token with +`validate_token_local` — offline tokens are never re-validated against the API +and [cannot be revoked](#revoking-an-activation); they stay valid until the +machine's device fingerprint changes. + ## Custom Fingerprinting and Storage ```cpp diff --git a/docs/juce.md b/docs/juce.md index a5b42ab..d16e713 100644 --- a/docs/juce.md +++ b/docs/juce.md @@ -193,6 +193,46 @@ unlockStatus.revokeActivationAsync( }); ``` +## Offline activation + +For machines without internet access, the bridge wraps Moonbase's file-based +flow. Emit a device token ("machine file"), have the user exchange it for a +license token, then load the token back in: + +```cpp +// Step 1: write the machine file for the user to upload. +const auto deviceToken = unlockStatus.deviceTokenContents(); +file.replaceWithText(juce::String(deviceToken)); // e.g. "MyPlugin.dt" + +// Step 2: load the license token the user downloaded from the portal. +using O = moonbase::juce_bridge::MoonbaseUnlockStatus::OfflineActivationOutcome; +if (unlockStatus.activateOffline(downloadedFile.loadFileAsString()) == O::Activated) + repaintActivationLabel(); // unlockStatus.isMoonbaseUnlocked() is now true +else + showError("That license token is not valid for this device."); +``` + +The user can exchange the device token for a license token through any of: + +- **Moonbase's hosted portal** — `https://.moonbase.sh/activate`. +- **The embedded storefront on your own site** — the + [`activate_product`](https://moonbase.sh/docs/storefronts/embedded/#call-methods) + intent (`Moonbase.activate_product()`) prompts for the `.dt` and returns the + license token file. +- **Your own custom flow** — drive the exchange with the Moonbase + [APIs and SDKs](https://moonbase.sh/docs/licensing/offline-activations/). + +`activateOffline` validates the token **locally only** (signature, audience, +device fingerprint, expiry, and that it was issued via offline activation), +persists it to the store on success, and transitions the bridge to unlocked. It +does no network I/O, so it's safe to call synchronously on the message thread. +Offline-activated tokens are never re-validated online and can't be revoked — +use `clearLicense()` for a local-only forget. + +[`PluginActivationComponent.h`](../examples/juce/PluginActivationComponent.h) +wires both steps to **Save machine file…** / **Load license token…** buttons +using `juce::FileChooser`. + ## Gating features ```cpp diff --git a/examples/juce/MoonbaseJuceBridge.h b/examples/juce/MoonbaseJuceBridge.h index 2443523..82855d8 100644 --- a/examples/juce/MoonbaseJuceBridge.h +++ b/examples/juce/MoonbaseJuceBridge.h @@ -677,6 +677,62 @@ class MoonbaseUnlockStatus : public juce::OnlineUnlockStatus return true; } + // ---- Offline activation -------------------------------------------------- + + // Returns the device token ("machine file") contents for offline + // activation. Write this to a file (conventionally ".dt") and have the user + // upload it at https://.moonbase.sh/activate to obtain an + // offline-activated license token in return. + [[nodiscard]] std::string deviceTokenContents() const + { + return licensing_->generate_device_token(); + } + + enum class OfflineActivationOutcome + { + Activated, // Token validated locally and persisted. Bridge is now unlocked. + Invalid, // Token failed local validation, wasn't an offline token, or + // wasn't issued for this device. Bridge state unchanged. + }; + + // Activates from an offline license token the user downloaded from the + // portal (the raw JWT contents of the license file). Validation is local + // only — no network — so this is safe to call synchronously on the message + // thread. On success the token is persisted and the bridge transitions to + // unlocked. + OfflineActivationOutcome activateOffline(const juce::String& tokenContents) + { + moonbase::license lic; + try + { + lic = licensing_->read_offline_license(tokenContents.trim().toStdString()); + } + catch (const std::exception&) + { + return OfflineActivationOutcome::Invalid; + } + + ++validationGeneration_; // invalidate any in-flight async revalidation + + try + { + // Under the update lock so an in-flight validate_token_online for a + // prior token can't race-persist over the newly-activated license. + auto guard = licensing_->store().lock_for_update(); + licensing_->store().store_local_license(lic); + } + catch (const moonbase::storage_error&) + { + // Activation succeeded but persistence failed; still mark unlocked + // for this session so the user isn't locked out by a bad disk. + } + + const juce::ScopedLock lock(stateLock_); + pendingRequest_.reset(); + setUnlockedLocked(std::move(lic)); + return OfflineActivationOutcome::Activated; + } + // Drops the local license and any pending activation request. void clearLicense() { diff --git a/examples/juce/PluginActivationComponent.h b/examples/juce/PluginActivationComponent.h index aea6ded..2397ff8 100644 --- a/examples/juce/PluginActivationComponent.h +++ b/examples/juce/PluginActivationComponent.h @@ -10,6 +10,8 @@ #include "MoonbaseJuceBridge.h" +#include + #include class PluginActivationComponent : public juce::Component, @@ -20,7 +22,7 @@ class PluginActivationComponent : public juce::Component, : unlockStatus_(makeOptions(), makeStore()) { addAndMakeVisible(statusLabel_); - statusLabel_.setJustificationType(juce::Justification::centredLeft); + statusLabel_.setJustificationType(juce::Justification::topLeft); addAndMakeVisible(activateButton_); activateButton_.onClick = [this] { startActivation(); }; @@ -28,13 +30,21 @@ class PluginActivationComponent : public juce::Component, addAndMakeVisible(deactivateButton_); deactivateButton_.onClick = [this] { startRevoke(); }; + // Offline activation: emit a machine file, then load the license token + // the user receives back from the Moonbase activation page. + addAndMakeVisible(saveMachineFileButton_); + saveMachineFileButton_.onClick = [this] { saveMachineFile(); }; + + addAndMakeVisible(loadLicenseButton_); + loadLicenseButton_.onClick = [this] { loadLicenseToken(); }; + // Async load: never blocks the message thread on libcurl. The label is // updated optimistically from the local-validation result, then again // from the message-thread callback once the online check resolves. unlockStatus_.tryLoadStoredLicenseAsync([this](auto) { refreshLabel(); }); refreshLabel(); - setSize(520, 184); + setSize(520, 320); } ~PluginActivationComponent() override @@ -45,12 +55,19 @@ class PluginActivationComponent : public juce::Component, void resized() override { auto area = getLocalBounds().reduced(16); - statusLabel_.setBounds(area.removeFromTop(96)); + statusLabel_.setBounds(area.removeFromTop(184)); area.removeFromTop(8); - auto buttons = area.removeFromTop(36); - activateButton_.setBounds(buttons.removeFromLeft(200)); - buttons.removeFromLeft(12); - deactivateButton_.setBounds(buttons.removeFromLeft(200)); + + auto onlineRow = area.removeFromTop(36); + activateButton_.setBounds(onlineRow.removeFromLeft(200)); + onlineRow.removeFromLeft(12); + deactivateButton_.setBounds(onlineRow.removeFromLeft(200)); + + area.removeFromTop(12); + auto offlineRow = area.removeFromTop(36); + saveMachineFileButton_.setBounds(offlineRow.removeFromLeft(200)); + offlineRow.removeFromLeft(12); + loadLicenseButton_.setBounds(offlineRow.removeFromLeft(200)); } [[nodiscard]] bool isUnlocked() const noexcept @@ -144,6 +161,62 @@ ERUn++6CVMPvZo67jVbTY+GCXYfW4gGVZQIDAQAB }); } + // Offline activation, step 1: write the device token ("machine file") to + // disk. The user uploads it at https://.moonbase.sh/activate. + void saveMachineFile() + { + const auto deviceToken = unlockStatus_.deviceTokenContents(); + + fileChooser_ = std::make_unique( + "Save machine file", + juce::File::getSpecialLocation(juce::File::userDesktopDirectory) + .getChildFile("MoonbaseJuceExample.dt"), + "*.dt"); + + const auto flags = juce::FileBrowserComponent::saveMode + | juce::FileBrowserComponent::canSelectFiles + | juce::FileBrowserComponent::warnAboutOverwriting; + + fileChooser_->launchAsync(flags, [this, deviceToken](const juce::FileChooser& chooser) + { + const auto file = chooser.getResult(); + if (file == juce::File{}) + return; // user cancelled + + if (file.replaceWithText(deviceToken)) + refreshLabel("Machine file saved. Upload it at your Moonbase activation\n" + "page, then load the license token you receive back."); + else + refreshLabel("Could not write the machine file."); + }); + } + + // Offline activation, step 2: load the offline license token the user + // downloaded from the portal. Validation is local-only — no network. + void loadLicenseToken() + { + fileChooser_ = std::make_unique( + "Load license token", + juce::File::getSpecialLocation(juce::File::userDesktopDirectory), + "*.mb;*.jwt;*.txt"); + + const auto flags = juce::FileBrowserComponent::openMode + | juce::FileBrowserComponent::canSelectFiles; + + fileChooser_->launchAsync(flags, [this](const juce::FileChooser& chooser) + { + const auto file = chooser.getResult(); + if (file == juce::File{}) + return; // user cancelled + + using Outcome = moonbase::juce_bridge::MoonbaseUnlockStatus::OfflineActivationOutcome; + if (unlockStatus_.activateOffline(file.loadFileAsString()) == Outcome::Activated) + refreshLabel(); + else + refreshLabel("That license token is not valid for this device."); + }); + } + void refreshLabel(const juce::String& explicitMessage = {}) { if (explicitMessage.isNotEmpty()) @@ -155,22 +228,59 @@ ERUn++6CVMPvZo67jVbTY+GCXYfW4gGVZQIDAQAB if (auto license = unlockStatus_.moonbaseLicense()) { juce::String text; - text << "Unlocked as " << juce::String(license->issued_to.email); + + // Who the license is issued to (name + email when both are present). + text << "Unlocked as "; + if (! license->issued_to.name.empty()) + text << juce::String(license->issued_to.name) << " <" + << juce::String(license->issued_to.email) << ">"; + else + text << juce::String(license->issued_to.email); if (license->trial) text << " (trial)"; + // Activation method — "Online" or "Offline". Offline-activated + // licenses are validated locally only and can't be revoked. + text << "\nActivation: " + << juce::String(moonbase::to_string(license->method)); + + // The licensed product (and its current release, when the token + // carries one). + text << "\nProduct: " << juce::String(license->licensed_product.name); + if (license->licensed_product.current_release_version) + text << " v" + << juce::String(*license->licensed_product.current_release_version); + + // Any sub-products / add-ons this license owns. + if (! license->owned_sub_product_ids.empty()) + { + juce::StringArray addons; + for (const auto& id : license->owned_sub_product_ids) + addons.add(juce::String(id)); + text << "\nAdd-ons: " << addons.joinIntoString(", "); + } + + // Subscription this license is tied to, if any. + if (license->subscription_id) + text << "\nSubscription: " << juce::String(*license->subscription_id); + // getExpiryTime() comes from juce::OnlineUnlockStatus and is // populated from the Moonbase license's expires_at by the bridge. const auto expiry = unlockStatus_.getExpiryTime(); - if (expiry.toMilliseconds() > 0) - text << "\nExpires " << expiry.toString(true, true); + text << "\nExpires: " + << (expiry.toMilliseconds() > 0 ? expiry.toString(true, true) + : juce::String("never")); // Last successful validation — refreshed by validate_token_online // (or the local-only fallback within grace) and persisted into the - // license's `validated_at` claim. - const auto validatedMs = std::chrono::duration_cast( - license->validated_at.time_since_epoch()).count(); - text << "\nLast validated " << juce::Time(validatedMs).toString(true, true); + // license's `validated_at` claim. Not meaningful for offline tokens, + // which are never re-validated online. + if (license->method != moonbase::activation_method::offline) + { + const auto validatedMs = std::chrono::duration_cast( + license->validated_at.time_since_epoch()).count(); + text << "\nLast validated " << juce::Time(validatedMs).toString(true, true); + } statusLabel_.setText(text, juce::dontSendNotification); } @@ -185,6 +295,10 @@ ERUn++6CVMPvZo67jVbTY+GCXYfW4gGVZQIDAQAB juce::Label statusLabel_; juce::TextButton activateButton_ { "Activate..." }; juce::TextButton deactivateButton_ { "Deactivate" }; + juce::TextButton saveMachineFileButton_ { "Save machine file..." }; + juce::TextButton loadLicenseButton_ { "Load license token..." }; + + std::unique_ptr fileChooser_; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(PluginActivationComponent) }; diff --git a/examples/juce/README.md b/examples/juce/README.md index 98dab96..32e3926 100644 --- a/examples/juce/README.md +++ b/examples/juce/README.md @@ -5,6 +5,17 @@ against the public demo environment (`https://demo.moonbase.sh`, product `demo-app`). The same files double as drop-in reference code for your own plugin. +The window has two rows of buttons: + +- **Activate… / Deactivate** — the online browser activation flow. +- **Save machine file… / Load license token…** — the offline flow: save the + device token (`.dt`), exchange it for a license token, then load the token you + get back. The device token can be uploaded to Moonbase's hosted portal + (`https://.moonbase.sh/activate`), to your own site via the embedded + storefront's [`activate_product`](https://moonbase.sh/docs/storefronts/embedded/#call-methods) + intent, or to a custom flow built on the Moonbase + [APIs and SDKs](https://moonbase.sh/docs/licensing/offline-activations/). + ## Files - `MoonbaseJuceBridge.h` — the bridge: fingerprint provider, metadata helper, diff --git a/include/moonbase/licensing.hpp b/include/moonbase/licensing.hpp index 5b70e27..c347d3d 100644 --- a/include/moonbase/licensing.hpp +++ b/include/moonbase/licensing.hpp @@ -10,6 +10,7 @@ #include "moonbase/client.hpp" #include "moonbase/default_fingerprint.hpp" +#include "moonbase/detail/base64.hpp" #include "moonbase/errors.hpp" #include "moonbase/fingerprint.hpp" #include "moonbase/http.hpp" @@ -61,6 +62,42 @@ class licensing { return validator_->validate_token(token); } + // Emits the device token ("machine file") for the offline activation flow. + // The returned string is a base64-encoded JSON descriptor of this device + + // product; write it to a file (conventionally with a ".dt" extension) that + // the customer uploads at https://.moonbase.sh/activate to obtain an + // offline-activated license token in return. + [[nodiscard]] std::string generate_device_token() const + { + const nlohmann::json payload{ + {"id", fingerprints_->device_id()}, + {"name", fingerprints_->device_name()}, + {"productId", options_.product_id}, + // The Moonbase API expects this to always be "JWT". + {"format", "JWT"}, + }; + const auto json = payload.dump(); + return detail::base64_encode( + reinterpret_cast(json.data()), json.size()); + } + + // Reads back the offline-activated license token the customer downloaded + // from the portal (the raw JWT contents of the license file). Validates it + // locally — signature, audience, issuer, device fingerprint, and expiry — + // and requires the token to have been issued via offline activation. + // Offline tokens are never re-validated against the API, so there is no + // online counterpart. Persist the result with + // store().store_local_license(...), mirroring the online flow. + [[nodiscard]] license read_offline_license(std::string_view token) const + { + auto local = validator_->validate_token(token); + if (local.method != activation_method::offline) { + throw license_invalid_error( + "License token is not an offline-activated license"); + } + return local; + } + // should_persist (optional): evaluated inside the SDK's locks immediately // before the refreshed license would be written to the store. When it // returns false, the SDK skips the persist but still returns the diff --git a/tests/licensing_tests.cpp b/tests/licensing_tests.cpp index 61b980c..02afeac 100644 --- a/tests/licensing_tests.cpp +++ b/tests/licensing_tests.cpp @@ -163,6 +163,62 @@ TEST_CASE("validate_token_online never contacts the API for offline-activated to CHECK(fixture.transport->requests.empty()); } +TEST_CASE("generate_device_token emits a base64 JSON descriptor of the device and product") +{ + facade_fixture fixture; + + const auto device_token = fixture.instance.generate_device_token(); + const auto json = nlohmann::json::parse( + moonbase::detail::bytes_to_string(moonbase::detail::base64_decode(device_token))); + + CHECK(json.at("id").get() == "device-id"); + CHECK(json.at("name").get() == "Test Device"); + CHECK(json.at("productId").get() == "demo-app"); + CHECK(json.at("format").get() == "JWT"); + + // No network involved when emitting the machine file. + CHECK(fixture.transport->requests.empty()); +} + +TEST_CASE("read_offline_license accepts an offline token for this device") +{ + facade_fixture fixture; + auto claims = moonbase::tests::default_claims(); + claims["method"] = "Offline"; + const auto token = fixture.make_token(claims); + + const auto result = fixture.instance.read_offline_license(token); + + CHECK(result.method == activation_method::offline); + CHECK(result.issued_to.email == "jane@example.com"); + CHECK(fixture.transport->requests.empty()); +} + +TEST_CASE("read_offline_license rejects an online token") +{ + facade_fixture fixture; + auto claims = moonbase::tests::default_claims(); + claims["method"] = "Online"; + const auto token = fixture.make_token(claims); + + CHECK_THROWS_AS( + (void)fixture.instance.read_offline_license(token), + license_invalid_error); + CHECK(fixture.transport->requests.empty()); +} + +TEST_CASE("read_offline_license rejects a token issued for another device") +{ + facade_fixture fixture; + auto claims = moonbase::tests::default_claims("other-device"); + claims["method"] = "Offline"; + const auto token = fixture.make_token(claims); + + CHECK_THROWS_AS( + (void)fixture.instance.read_offline_license(token), + license_invalid_error); +} + TEST_CASE("validate_token_online runs local validation before any HTTP call") { facade_fixture fixture;