Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
35 changes: 32 additions & 3 deletions docs/juce.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
23 changes: 21 additions & 2 deletions examples/juce/MoonbaseJuceBridge.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<moonbase::activation_method> 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.
Expand Down
17 changes: 12 additions & 5 deletions include/moonbase/client.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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()},
Expand All @@ -181,7 +184,11 @@ class license_client {
}

try {
return nlohmann::json::parse(response.body).get<activation_request>();
auto result = nlohmann::json::parse(response.body).get<activation_request>();
// 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<int>(response.status_code),
Expand Down
19 changes: 17 additions & 2 deletions include/moonbase/licensing.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,24 @@ class licensing {
client_ = std::make_shared<license_client>(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<license> get_requested_activation(
Expand Down
11 changes: 11 additions & 0 deletions include/moonbase/types.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)},
};
}

Expand All @@ -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<std::string>())
: activation_method::online;
}

} // namespace moonbase
17 changes: 12 additions & 5 deletions modules/moonbase_licensing/moonbase/client.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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()},
Expand All @@ -181,7 +184,11 @@ class license_client {
}

try {
return nlohmann::json::parse(response.body).get<activation_request>();
auto result = nlohmann::json::parse(response.body).get<activation_request>();
// 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<int>(response.status_code),
Expand Down
19 changes: 17 additions & 2 deletions modules/moonbase_licensing/moonbase/licensing.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,24 @@ class licensing {
client_ = std::make_shared<license_client>(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<license> get_requested_activation(
Expand Down
11 changes: 11 additions & 0 deletions modules/moonbase_licensing/moonbase/types.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)},
};
}

Expand All @@ -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<std::string>())
: activation_method::online;
}

} // namespace moonbase
53 changes: 53 additions & 0 deletions tests/client_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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")
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading