diff --git a/README.md b/README.md index 1f67c7a..9508847 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,21 @@ while (!license) { licensing.store().store_local_license(*license); ``` +`request_activation` takes an optional `moonbase::activation_method`. Pass +`activation_method::offline` to have the same browser flow mint an *offline* +license instead: + +```cpp +auto request = licensing.request_activation(moonbase::activation_method::offline); +``` + +The URL, the polling and the storage step are unchanged, but the resulting token +carries `method: Offline`, so it is validated locally for good +(`validate_token_online` short-circuits it) and +[cannot be revoked](#revoking-an-activation). The product must have offline +activations enabled in Moonbase, otherwise the call throws +`license_invalid_error` reading "Product does not allow offline activations". + On startup, validate the stored token. `validate_token_online` runs the local checks (signature, device fingerprint, expiry) and then re-validates against the Moonbase API when needed: @@ -142,6 +157,18 @@ fallback — revoke is a one-shot operation. ## Offline Activation +There are two routes to an offline license, and which one fits depends on +whether the machine has network *at activation time*: + +- **It does:** run the normal browser activation and ask for an offline license + with [`request_activation(activation_method::offline)`](#basic-usage). + Nothing else about the flow changes. +- **It does not:** use the file-based exchange below, which involves no network + on the device at all. + +Either way the resulting token is permanent and unrevokable; it stays valid until +the machine's device fingerprint changes. + 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 diff --git a/docs/juce.md b/docs/juce.md index 5188d79..1895fd8 100644 --- a/docs/juce.md +++ b/docs/juce.md @@ -164,6 +164,32 @@ void timerCallback() override `pollPendingActivation()` is non-blocking. The poll cadence is up to you; once a second is plenty for a UI-driven flow. +`beginActivation()` takes an optional `moonbase::activation_method`. Pass +`activation_method::offline` to have the same browser flow mint an offline +license instead: + +```cpp +const auto url = unlockStatus.beginActivation(moonbase::activation_method::offline); +``` + +Only the query the bridge sends changes; the URL you launch, the polling and the +resulting unlock are identical. The token it produces carries +`method: Offline`, so it is never re-validated online and can't be revoked. The +product must have offline activations enabled in Moonbase, otherwise +`beginActivation` throws `moonbase::license_invalid_error`. Use +`pendingActivationMethod()` to label the wait while the poll is in flight: + +```cpp +const auto pending = unlockStatus.pendingActivationMethod(); +statusLabel.setText(pending == moonbase::activation_method::offline + ? "Activating offline..." + : "Waiting for activation...", + juce::dontSendNotification); +``` + +Unlike `deviceTokenContents()` / `activateOffline()` below, this route needs +network on the device at activation time; it just doesn't need any afterwards. + ## Deactivating Two paths, depending on whether you want to free the seat server-side: @@ -202,9 +228,12 @@ 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: +For machines that have network at activation time but not afterwards, prefer +[`beginActivation(activation_method::offline)`](#activation-flow) above. + +For machines with no internet access at all, 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. diff --git a/examples/juce/MoonbaseJuceBridge.h b/examples/juce/MoonbaseJuceBridge.h index 31ec72d..aeee186 100644 --- a/examples/juce/MoonbaseJuceBridge.h +++ b/examples/juce/MoonbaseJuceBridge.h @@ -646,13 +646,32 @@ class MoonbaseUnlockStatus : public juce::OnlineUnlockStatus // Begins a new browser activation. Returns the URL to hand to // juce::URL::launchInDefaultBrowser. Throws moonbase::api_error on // network/server failure. - juce::URL beginActivation() + // + // Pass moonbase::activation_method::offline to have the browser flow mint an + // offline license instead: same URL, same polling, but the resulting token + // never revalidates against the API and cannot be revoked. Requires the + // product to have offline activations enabled, otherwise this throws + // moonbase::license_invalid_error. Unlike deviceTokenContents() / + // activateOffline(), this route needs network on the device at activation + // time. + juce::URL beginActivation( + moonbase::activation_method method = moonbase::activation_method::online) { const juce::ScopedLock lock(stateLock_); - pendingRequest_ = licensing_->request_activation(); + pendingRequest_ = licensing_->request_activation(method); return juce::URL(juce::String(pendingRequest_->browser_url)); } + // The method the in-flight browser activation was started with, or nullopt + // when none is pending. Lets a UI label the wait ("Activating offline..."). + [[nodiscard]] std::optional pendingActivationMethod() const + { + const juce::ScopedLock lock(stateLock_); + if (!pendingRequest_) + return std::nullopt; + return pendingRequest_->method; + } + // Non-blocking poll. Returns true the first call after the user finishes // activation in the browser; returns false otherwise. Run from a juce::Timer // on the message thread. diff --git a/include/moonbase/client.hpp b/include/moonbase/client.hpp index 8b86ae0..5098ee5 100644 --- a/include/moonbase/client.hpp +++ b/include/moonbase/client.hpp @@ -156,11 +156,14 @@ class license_client { } } - [[nodiscard]] activation_request request_activation() const + [[nodiscard]] activation_request request_activation( + activation_method method = activation_method::online) const { - const auto url = detail::append_query( - detail::request_path(options_), - detail::client_query(options_)); + // Only the request endpoint accepts "method"; client_query is shared + // with /validate and /revoke, which do not, so add it here. + auto query = detail::client_query(options_); + query["method"] = to_string(method); + const auto url = detail::append_query(detail::request_path(options_), query); const auto payload = nlohmann::json{ {"deviceName", device_ids_->device_name()}, @@ -181,7 +184,11 @@ class license_client { } try { - return nlohmann::json::parse(response.body).get(); + auto result = nlohmann::json::parse(response.body).get(); + // The response carries only id/request/browser, so record what we + // asked for rather than leaving the default. + result.method = method; + return result; } catch (const std::exception& ex) { throw api_error( static_cast(response.status_code), diff --git a/include/moonbase/licensing.hpp b/include/moonbase/licensing.hpp index ea2306d..0e9394a 100644 --- a/include/moonbase/licensing.hpp +++ b/include/moonbase/licensing.hpp @@ -61,9 +61,24 @@ class licensing { client_ = std::make_shared(options_, device_ids_, validator_, transport_); } - [[nodiscard]] activation_request request_activation() const + // Starts a browser activation. Poll the returned request with + // get_requested_activation until it yields a license. + // + // method (optional): pass activation_method::offline to ask the backend for + // an offline license. The browser flow itself is identical, but the token it + // mints carries method: Offline, so it is validated locally for good + // (validate_token_online short-circuits it) and cannot be revoked. The + // product must have offline activations enabled, otherwise the call throws + // license_invalid_error reading "Product does not allow offline activations". + // + // This is the second route to an offline license, alongside the file-based + // generate_device_token / read_offline_license exchange. Use this one when + // the machine has network at activation time but not afterwards; use the + // file exchange when it has no network at all. + [[nodiscard]] activation_request request_activation( + activation_method method = activation_method::online) const { - return client_->request_activation(); + return client_->request_activation(method); } [[nodiscard]] std::optional get_requested_activation( diff --git a/include/moonbase/types.hpp b/include/moonbase/types.hpp index f3cf3db..4a30358 100644 --- a/include/moonbase/types.hpp +++ b/include/moonbase/types.hpp @@ -111,6 +111,10 @@ struct activation_request { std::string id; std::string request_url; std::string browser_url; + // The activation method this request was started with. Set client-side from + // the argument to request_activation: the client API returns only id/request/ + // browser, so it is never read off the response. + activation_method method = activation_method::online; }; struct licensing_options { @@ -243,6 +247,7 @@ inline void to_json(nlohmann::json& json, const activation_request& value) {"id", value.id}, {"request", value.request_url}, {"browser", value.browser_url}, + {"activationMethod", to_string(value.method)}, }; } @@ -251,6 +256,12 @@ inline void from_json(const nlohmann::json& json, activation_request& value) json.at("id").get_to(value.id); json.at("request").get_to(value.request_url); json.at("browser").get_to(value.browser_url); + // Optional on purpose: this parses the API response, which carries only + // id/request/browser. request_activation stamps the requested method + // afterwards. The key is only present when round-tripping our own to_json. + value.method = (json.contains("activationMethod") && json.at("activationMethod").is_string()) + ? activation_method_from_string(json.at("activationMethod").get()) + : activation_method::online; } } // namespace moonbase diff --git a/modules/moonbase_licensing/moonbase/client.hpp b/modules/moonbase_licensing/moonbase/client.hpp index 8b86ae0..5098ee5 100644 --- a/modules/moonbase_licensing/moonbase/client.hpp +++ b/modules/moonbase_licensing/moonbase/client.hpp @@ -156,11 +156,14 @@ class license_client { } } - [[nodiscard]] activation_request request_activation() const + [[nodiscard]] activation_request request_activation( + activation_method method = activation_method::online) const { - const auto url = detail::append_query( - detail::request_path(options_), - detail::client_query(options_)); + // Only the request endpoint accepts "method"; client_query is shared + // with /validate and /revoke, which do not, so add it here. + auto query = detail::client_query(options_); + query["method"] = to_string(method); + const auto url = detail::append_query(detail::request_path(options_), query); const auto payload = nlohmann::json{ {"deviceName", device_ids_->device_name()}, @@ -181,7 +184,11 @@ class license_client { } try { - return nlohmann::json::parse(response.body).get(); + auto result = nlohmann::json::parse(response.body).get(); + // The response carries only id/request/browser, so record what we + // asked for rather than leaving the default. + result.method = method; + return result; } catch (const std::exception& ex) { throw api_error( static_cast(response.status_code), diff --git a/modules/moonbase_licensing/moonbase/licensing.hpp b/modules/moonbase_licensing/moonbase/licensing.hpp index ea2306d..0e9394a 100644 --- a/modules/moonbase_licensing/moonbase/licensing.hpp +++ b/modules/moonbase_licensing/moonbase/licensing.hpp @@ -61,9 +61,24 @@ class licensing { client_ = std::make_shared(options_, device_ids_, validator_, transport_); } - [[nodiscard]] activation_request request_activation() const + // Starts a browser activation. Poll the returned request with + // get_requested_activation until it yields a license. + // + // method (optional): pass activation_method::offline to ask the backend for + // an offline license. The browser flow itself is identical, but the token it + // mints carries method: Offline, so it is validated locally for good + // (validate_token_online short-circuits it) and cannot be revoked. The + // product must have offline activations enabled, otherwise the call throws + // license_invalid_error reading "Product does not allow offline activations". + // + // This is the second route to an offline license, alongside the file-based + // generate_device_token / read_offline_license exchange. Use this one when + // the machine has network at activation time but not afterwards; use the + // file exchange when it has no network at all. + [[nodiscard]] activation_request request_activation( + activation_method method = activation_method::online) const { - return client_->request_activation(); + return client_->request_activation(method); } [[nodiscard]] std::optional get_requested_activation( diff --git a/modules/moonbase_licensing/moonbase/types.hpp b/modules/moonbase_licensing/moonbase/types.hpp index f3cf3db..4a30358 100644 --- a/modules/moonbase_licensing/moonbase/types.hpp +++ b/modules/moonbase_licensing/moonbase/types.hpp @@ -111,6 +111,10 @@ struct activation_request { std::string id; std::string request_url; std::string browser_url; + // The activation method this request was started with. Set client-side from + // the argument to request_activation: the client API returns only id/request/ + // browser, so it is never read off the response. + activation_method method = activation_method::online; }; struct licensing_options { @@ -243,6 +247,7 @@ inline void to_json(nlohmann::json& json, const activation_request& value) {"id", value.id}, {"request", value.request_url}, {"browser", value.browser_url}, + {"activationMethod", to_string(value.method)}, }; } @@ -251,6 +256,12 @@ inline void from_json(const nlohmann::json& json, activation_request& value) json.at("id").get_to(value.id); json.at("request").get_to(value.request_url); json.at("browser").get_to(value.browser_url); + // Optional on purpose: this parses the API response, which carries only + // id/request/browser. request_activation stamps the requested method + // afterwards. The key is only present when round-tripping our own to_json. + value.method = (json.contains("activationMethod") && json.at("activationMethod").is_string()) + ? activation_method_from_string(json.at("activationMethod").get()) + : activation_method::online; } } // namespace moonbase diff --git a/tests/client_tests.cpp b/tests/client_tests.cpp index e05e56e..a8208f2 100644 --- a/tests/client_tests.cpp +++ b/tests/client_tests.cpp @@ -77,6 +77,7 @@ TEST_CASE("request_activation posts device information and parses response") CHECK(request.method == "POST"); CHECK(request.url.find("https://demo.moonbase.sh/api/client/activations/demo-app/request?") == 0); CHECK(request.url.find("format=JWT") != std::string::npos); + CHECK(request.url.find("method=Online") != 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); @@ -90,6 +91,54 @@ TEST_CASE("request_activation posts device information and parses response") const auto body = nlohmann::json::parse(request.body); CHECK(body.at("deviceName") == "Test Device"); CHECK(body.at("deviceSignature") == "device-id"); + + CHECK(response.method == activation_method::online); +} + +TEST_CASE("request_activation asks for an offline license") +{ + client_fixture fixture({ + http_response{ + 200, + {}, + R"({"id":"request-123","request":"https://demo.moonbase.sh/api/client/activations/request-123?format=JWT","browser":"https://demo.moonbase.sh/activate?token=request-123"})"}, + }); + + const auto response = fixture.client.request_activation(activation_method::offline); + + // The response carries no method, so the client records what it asked for. + CHECK(response.method == activation_method::offline); + CHECK(response.id == "request-123"); + + REQUIRE(fixture.transport->requests.size() == 1); + const auto& request = fixture.transport->requests.front(); + CHECK(request.url.find("method=Offline") != std::string::npos); + CHECK(request.url.find("method=Online") == std::string::npos); + + // Everything other than the method is unchanged from the online request. + CHECK(request.method == "POST"); + CHECK(request.url.find("https://demo.moonbase.sh/api/client/activations/demo-app/request?") == 0); + CHECK(request.url.find("format=JWT") != std::string::npos); + CHECK(request.headers.at("Content-Type") == "application/json"); + + const auto body = nlohmann::json::parse(request.body); + CHECK(body.at("deviceName") == "Test Device"); + CHECK(body.at("deviceSignature") == "device-id"); +} + +TEST_CASE("request_activation reports a product that disallows offline activations") +{ + client_fixture fixture({ + http_response{ + 400, + {}, + R"({"title":"Not allowed","detail":"Product does not allow offline activations"})"}, + }); + + CHECK_THROWS_WITH_AS( + (void)fixture.client.request_activation(activation_method::offline), + "Product does not allow offline activations", + license_invalid_error); } TEST_CASE("request_activation throws for API errors") @@ -161,6 +210,8 @@ TEST_CASE("validate_token_online posts the JWT and parses the refreshed response CHECK(request.method == "POST"); CHECK(request.url.find("https://demo.moonbase.sh/api/client/licenses/demo-app/validate?") == 0); CHECK(request.url.find("format=JWT") != std::string::npos); + // "method" is only accepted on the request endpoint. + CHECK(request.url.find("method=") == 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); @@ -213,6 +264,8 @@ TEST_CASE("revoke_activation posts the JWT to the revoke endpoint") 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); + // "method" is only accepted on the request endpoint. + CHECK(request.url.find("method=") == 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); diff --git a/tests/licensing_tests.cpp b/tests/licensing_tests.cpp index 35bbc72..da2e0f9 100644 --- a/tests/licensing_tests.cpp +++ b/tests/licensing_tests.cpp @@ -163,6 +163,59 @@ TEST_CASE("validate_token_online never contacts the API for offline-activated to CHECK(fixture.transport->requests.empty()); } +TEST_CASE("request_activation defaults to an online license") +{ + facade_fixture fixture; + fixture.transport->responses.push_back(http_response{ + 200, + {}, + R"({"id":"request-123","request":"https://demo.moonbase.sh/api/client/activations/request-123","browser":"https://demo.moonbase.sh/activate?token=request-123"})"}); + + const auto request = fixture.instance.request_activation(); + + CHECK(request.method == activation_method::online); + REQUIRE(fixture.transport->requests.size() == 1); + CHECK(fixture.transport->requests.front().url.find("method=Online") != std::string::npos); +} + +TEST_CASE("a browser activation requested as offline yields an offline license") +{ + facade_fixture fixture; + fixture.transport->responses.push_back(http_response{ + 200, + {}, + R"({"id":"request-123","request":"https://demo.moonbase.sh/api/client/activations/request-123","browser":"https://demo.moonbase.sh/activate?token=request-123"})"}); + + const auto request = fixture.instance.request_activation(activation_method::offline); + + CHECK(request.method == activation_method::offline); + REQUIRE(fixture.transport->requests.size() == 1); + CHECK(fixture.transport->requests.front().url.find("method=Offline") != std::string::npos); + + // The backend mints a token carrying method: Offline once the user finishes + // in the browser. Polling is identical to the online flow. + auto claims = moonbase::tests::default_claims(); + claims["method"] = "Offline"; + claims["validated"] = moonbase::tests::now_seconds() - (24 * 60 * 60 * 365); // ancient + fixture.transport->responses.push_back( + http_response{200, {}, fixture.make_token(claims)}); + + const auto activated = fixture.instance.get_requested_activation(request); + + REQUIRE(activated.has_value()); + CHECK(activated->method == activation_method::offline); + CHECK(fixture.transport->requests.size() == 2); + + // From here it behaves like any other offline license: validated locally + // however stale it is, and never revocable. + CHECK(fixture.instance.validate_token_online(activated->token).method == + activation_method::offline); + CHECK_THROWS_AS( + fixture.instance.revoke_activation(activated->token), + operation_not_supported_error); + CHECK(fixture.transport->requests.size() == 2); // neither call hit the API +} + TEST_CASE("generate_device_token emits a base64 JSON descriptor of the device and product") { facade_fixture fixture;