Skip to main content

moonbase_licensing/
activation.rs

1use crate::claims::{ActivationMethod, LicenseTokenClaims};
2use crate::device_token::DeviceToken;
3use backon::{BlockingRetryable, ExponentialBuilder};
4use chrono::Utc;
5use jsonwebtoken::errors::ErrorKind;
6use jsonwebtoken::{get_current_timestamp, Algorithm, DecodingKey, Validation};
7use serde::{Deserialize, Serialize};
8use std::path::PathBuf;
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::sync::mpsc::{Receiver, Sender};
11use std::sync::Arc;
12use std::thread::{sleep, JoinHandle};
13use std::time::Duration;
14use std::{fs, io, thread};
15use thiserror::Error;
16use ureq::http::StatusCode;
17
18/// Represents the software's current activation state.
19pub enum ActivationState {
20    /// The plugin requires activation.
21    ///
22    /// The provided String contains the URL to open
23    /// in the user's browser for online activation.
24    /// If it is None, only offline activation is available at this point,
25    /// but online activation may become available later with a new [ActivationState].
26    NeedsActivation(Option<String>),
27
28    /// The plugin has been successfully activated.
29    Activated(LicenseTokenClaims),
30}
31
32/// Errors that can occur during the activation process.
33#[derive(Error, Debug)]
34pub enum ActivationError {
35    /// An error occurred when validating a cached token.
36    #[error("Could not validate cached token: {0}")]
37    LoadCachedToken(#[from] CachedTokenError),
38
39    /// Could not persist the token to disk for caching purposes.
40    #[error("Could not save license token to disk: {0}")]
41    SaveCachedToken(#[from] io::Error),
42
43    /// Could not fetch the online activation URL from the Moonbase API.
44    #[error("Could not fetch online activation url: {0}")]
45    FetchActivationUrl(MoonbaseApiError),
46
47    /// Could not fetch the activation state of an online token from the Moonbase API.
48    #[error("Could not fetch activation state of online token: {0}")]
49    FetchActivationState(MoonbaseApiError),
50
51    /// Could not validate an offline token provided by the user.
52    #[error("Could not validate offline token: {0}")]
53    OfflineToken(#[from] OfflineTokenValidationError),
54}
55
56#[derive(Error, Debug)]
57pub enum OfflineTokenValidationError {
58    #[error("the license token is invalid: {0}")]
59    Invalid(#[from] jsonwebtoken::errors::Error),
60    #[error("inapplicable token: {0}")]
61    Inapplicable(#[from] InapplicableTokenError),
62    #[error("the license token is not an offline token")]
63    NoOfflineToken,
64}
65
66#[derive(Error, Debug)]
67pub enum CachedTokenError {
68    /// An I/O error occurred when reading the token file from disk.
69    #[error("error loading cached token file: {0}")]
70    Io(#[from] io::Error),
71
72    /// The token failed validation by the JWT parser.
73    #[error("invalid JWT payload: {0}")]
74    Invalid(#[from] jsonwebtoken::errors::Error),
75
76    /// The token is not valid for the product or hardware device.
77    #[error("inapplicable token: {0}")]
78    Inapplicable(#[from] InapplicableTokenError),
79
80    /// Online validation by Moonbase failed.
81    #[error("online validation failed: {1}")]
82    ValidationFailed(ValidationFailedType, String),
83
84    /// The token is valid, but too old to trust,
85    /// and it couldn't be refreshed.
86    #[error("token could not be refreshed")]
87    RefreshFailed(#[from] MoonbaseApiError),
88}
89
90#[derive(Error, Debug)]
91pub enum InapplicableTokenError {
92    #[error("the license token is not valid for this device")]
93    InvalidDeviceSignature,
94}
95
96#[derive(Error, Debug)]
97pub enum MoonbaseApiError {
98    /// An I/O error occurred when contacting the Moonbase API.
99    #[error("issues contacting API: {0}")]
100    Io(#[from] ureq::Error),
101
102    /// We received an unexpected response from the Moonbase API.
103    #[error("unexpected response with status code {0} and body {1}")]
104    UnexpectedResponse(StatusCode, String),
105
106    /// The token returned by the Moonbase API was malformed.
107    #[error("invalid token: {0}")]
108    InvalidToken(#[from] jsonwebtoken::errors::Error),
109}
110
111/// The reason why online license validation failed.
112#[derive(Debug)]
113pub enum ValidationFailedType {
114    LicenseRevoked,
115    LicenseActivationRevoked,
116    LicenseExpired,
117    NoEligibleLicense,
118    /// Unknown error type in Moonbase API response -
119    /// if this is reached, this library needs updating!
120    Unknown,
121}
122
123/// Configuration options for the [LicenseActivator].
124#[derive(Clone)]
125pub struct LicenseActivationConfig {
126    /// The Moonbase vendor id for the store.
127    /// Used to determine the API endpoint, i.e.
128    /// https://{vendor_id}.moonbase.sh
129    pub vendor_id: String,
130    /// The Moonbase product id that a license needs to be valid for.
131    pub product_id: String,
132    /// The public key to verify the signed JWT payload.
133    pub jwt_pubkey: String,
134
135    /// The path where the cached license token payload is stored on disk.
136    pub cached_token_path: PathBuf,
137
138    /// User-friendly display name of the device the software is running on.
139    /// Reported to Moonbase when activating a license.
140    pub device_name: String,
141    /// The unique signature of the device the software is running on.
142    pub device_signature: String,
143
144    /// The age threshold beyond which the activator attempts to refresh online tokens.
145    /// Before this age, the token is accepted without attempting any further online validation.
146    pub online_token_refresh_threshold: Duration,
147    /// The age threshold beyond which an online token is deemed
148    /// too old to trust and must be refreshed before being accepted.
149    pub online_token_expiration_threshold: Duration,
150}
151
152/// Performs license activation.
153pub struct LicenseActivator {
154    cfg: LicenseActivationConfig,
155
156    /// Receiver for the main thread to poll changes to the license activation state.
157    ///
158    /// Once [ActivationState::Activated] has been received,
159    /// the consumer can stop reading from this channel,
160    /// as the license activation won't be revoked again during this session.
161    ///
162    /// Until the first value is received,
163    /// the license activation state is undetermined,
164    /// and the user should just be shown a "loading" state.
165    pub state_recv: Receiver<ActivationState>,
166    state_send: Sender<ActivationState>,
167
168    /// Receiver for the main thread to poll errors encountered during license activation.
169    ///
170    /// Which of these you want to display is up to your discretion.
171    /// You may want to display only the most recent error,
172    /// or perhaps display each error and make them dismissable.
173    pub error_recv: Receiver<ActivationError>,
174    error_send: Sender<ActivationError>,
175
176    /// While this is true, the license activator polls the Moonbase API
177    /// to check if the user has activated the license online.
178    ///
179    /// Set this to false whenever the user isn't on the online activation screen
180    /// to avoid spamming the Moonbase API and getting rate limited.
181    pub poll_online_activation: Arc<AtomicBool>,
182
183    /// Whether the worker thread should keep running.
184    running: Arc<AtomicBool>,
185    /// Join handle for the worker thread.
186    join: Option<JoinHandle<()>>,
187}
188
189impl Drop for LicenseActivator {
190    fn drop(&mut self) {
191        self.running.store(false, Ordering::Relaxed);
192        self.join.take().unwrap().join().unwrap();
193    }
194}
195
196impl LicenseActivator {
197    /// Creates a new license activator,
198    /// spawning the background threads that perform license checking.
199    ///
200    /// These background threads run until activation is successful
201    /// or the [LicenseActivator] is dropped.
202    pub fn spawn(cfg: LicenseActivationConfig) -> Self {
203        // create communication channels to report activation state changes to calling thread
204        let (state_send, state_recv) = std::sync::mpsc::channel();
205        let (error_send, error_recv) = std::sync::mpsc::channel();
206
207        // spawn worker thread
208        let running = Arc::new(AtomicBool::new(true));
209        let running_clone = running.clone();
210
211        let poll_online_activation = Arc::new(AtomicBool::new(false));
212        let poll_online_activation_clone = poll_online_activation.clone();
213
214        let state_send_clone = state_send.clone();
215        let error_send_clone = error_send.clone();
216        let cfg_clone = cfg.clone();
217
218        let join = thread::spawn(|| {
219            worker_thread(
220                running_clone,
221                state_send_clone,
222                error_send_clone,
223                poll_online_activation_clone,
224                cfg_clone,
225            );
226        });
227
228        Self {
229            cfg,
230
231            state_recv,
232            state_send,
233
234            error_recv,
235            error_send,
236
237            poll_online_activation,
238
239            running,
240            join: Some(join),
241        }
242    }
243
244    /// Creates and returns the contents to write to the machine file used for offline activation.
245    pub fn machine_file_contents(&self) -> String {
246        DeviceToken::new(
247            self.cfg.device_signature.clone(),
248            self.cfg.device_name.clone(),
249            self.cfg.product_id.clone(),
250        )
251        .serialize()
252    }
253
254    /// Submits the given offline activation token for validation,
255    /// caching it on disk if it's valid.
256    ///
257    /// The result of the validation can be obtained
258    /// from the state and error receivers as usual.
259    pub fn submit_offline_activation_token(&mut self, token: &str) {
260        match self.check_offline_activation_token(token) {
261            Ok(claims) => {
262                _ = self.state_send.send(ActivationState::Activated(claims));
263
264                // stop the worker thread, as we don't need any more validation from here on
265                self.running.store(false, Ordering::Relaxed);
266
267                // persist the token on disk
268                if let Err(e) = fs::write(&self.cfg.cached_token_path, token) {
269                    _ = self.error_send.send(ActivationError::SaveCachedToken(e));
270                }
271            }
272            Err(e) => _ = self.error_send.send(ActivationError::OfflineToken(e)),
273        }
274    }
275
276    fn check_offline_activation_token(
277        &mut self,
278        token: &str,
279    ) -> Result<LicenseTokenClaims, OfflineTokenValidationError> {
280        let claims = parse_token(&self.cfg, token)?;
281
282        if claims.method != ActivationMethod::Offline {
283            return Err(OfflineTokenValidationError::NoOfflineToken);
284        }
285
286        validate_token_applicable(&self.cfg, &claims)?;
287
288        Ok(claims)
289    }
290}
291
292impl LicenseActivationConfig {
293    /// Returns the base URL to make any Moonbase API requests to.
294    fn moonbase_api_base_url(&self) -> String {
295        format!("https://{}.moonbase.sh", self.vendor_id)
296    }
297}
298
299fn worker_thread(
300    running: Arc<AtomicBool>,
301    state_send: Sender<ActivationState>,
302    error_send: Sender<ActivationError>,
303    poll_online_activation: Arc<AtomicBool>,
304    cfg: LicenseActivationConfig,
305) {
306    // first, try to load a cached license token from disk
307    match check_cached_token(&cfg, running.clone()) {
308        Ok(Some(result)) => {
309            _ = state_send.send(ActivationState::Activated(result.claims));
310
311            if let Some(token) = result.new_token {
312                // persist the new token on disk
313                if let Err(e) = fs::write(&cfg.cached_token_path, token) {
314                    _ = error_send.send(ActivationError::SaveCachedToken(e));
315                }
316            }
317
318            return;
319        }
320        Ok(None) => {
321            // no cached token was found
322        }
323        Err(e) => {
324            // cached token couldn't be validated
325            _ = error_send.send(ActivationError::LoadCachedToken(e));
326        }
327    }
328
329    // we don't have a valid cached token -
330    // the user has to activate the plugin either offline or online.
331
332    // we don't yet have a URL to provide for online activation,
333    // but we can supply that in a subsequent state update.
334    _ = state_send.send(ActivationState::NeedsActivation(None));
335
336    // ask Moonbase for the endpoints to perform online activation
337    let activation_urls = match (|| moonbase_request_online_activation(&cfg))
338        .retry(
339            &ExponentialBuilder::default()
340                .with_max_delay(Duration::from_secs(10))
341                .with_max_times(10),
342        )
343        .when(|_| running.load(Ordering::Relaxed))
344        .call()
345    {
346        Ok(activation_urls) => Some(activation_urls),
347        Err(e) => {
348            // we couldn't get an online activation URL from Moonbase after several tries
349            _ = error_send.send(ActivationError::FetchActivationUrl(e));
350            None
351        }
352    };
353
354    if let Some(activation_urls) = activation_urls.as_ref() {
355        // we got the URLs for online activation
356        // send the user-facing activation URL to the main thread
357        _ = state_send.send(ActivationState::NeedsActivation(Some(
358            activation_urls.browser.clone(),
359        )));
360    }
361
362    // now we're waiting for the user to activate the plugin,
363    // or the thread to be stopped
364    while running.load(Ordering::Relaxed) {
365        sleep(Duration::from_secs(5));
366
367        match activation_urls.as_ref() {
368            Some(activation_urls) if poll_online_activation.load(Ordering::Relaxed) => {
369                // the user is attempting online activation -
370                // check if they have succeeded
371
372                match moonbase_check_online_activation(&cfg, &activation_urls.request) {
373                    Ok(Some((token, claims))) => {
374                        // the software has been activated!
375                        _ = state_send.send(ActivationState::Activated(claims));
376
377                        // persist the token on disk
378                        if let Err(e) = fs::write(&cfg.cached_token_path, token) {
379                            _ = error_send.send(ActivationError::SaveCachedToken(e));
380                        }
381
382                        return;
383                    }
384                    Ok(None) => {
385                        // not yet activated - simply try again
386                    }
387                    Err(e) => {
388                        _ = error_send.send(ActivationError::FetchActivationState(e));
389                    }
390                }
391            }
392            _ => {
393                // if the user isn't currently attempting to activate the plugin in this plugin instance,
394                // check if another instance of the software has activated the plugin in the meantime
395                if let Ok(Some(result)) = check_cached_token(&cfg, running.clone()) {
396                    _ = state_send.send(ActivationState::Activated(result.claims));
397
398                    if let Some(token) = result.new_token {
399                        // persist the new token on disk
400                        if let Err(e) = fs::write(&cfg.cached_token_path, token) {
401                            _ = error_send.send(ActivationError::SaveCachedToken(e));
402                        }
403                    }
404
405                    return;
406                }
407            }
408        }
409    }
410}
411
412struct CachedTokenCheckResult {
413    /// The claims that were validated.
414    claims: LicenseTokenClaims,
415    /// A new, refreshed token that must be cached on disk.
416    new_token: Option<String>,
417}
418
419/// Checks whether there is an existing license token on disk
420/// that represents an activated license.
421///
422/// Online tokens are refreshed if required,
423/// and the new token is returned in this case.
424///
425/// None is returned if no cached token exists.
426fn check_cached_token(
427    cfg: &LicenseActivationConfig,
428    running: Arc<AtomicBool>,
429) -> Result<Option<CachedTokenCheckResult>, CachedTokenError> {
430    match load_cached_token(cfg) {
431        Ok(Some((token, claims))) => {
432            match claims.method {
433                ActivationMethod::Offline => {
434                    // it's an offline activated token,
435                    // so it will stay valid forever.
436                    // validation succeeded!
437                    Ok(Some(CachedTokenCheckResult {
438                        claims,
439                        new_token: None,
440                    }))
441                }
442                ActivationMethod::Online => {
443                    // it's an online activated token,
444                    // so we should check if it's still valid
445
446                    let token_validation_age = Utc::now() - claims.last_validated;
447
448                    // Convert validation age to Duration.
449                    // If last_validated lies in the future from the perspective
450                    // of the machine running this code (conversion returns Error),
451                    // we can't trust the token and require re-validation.
452                    let token_validation_age = token_validation_age.to_std().ok();
453
454                    if let Some(token_validation_age) = token_validation_age {
455                        if token_validation_age < cfg.online_token_refresh_threshold {
456                            // if the token was last validated very recently,
457                            // we just accept it and don't even attempt to refresh and validate it.
458                            // this minimizes API requests and waiting time for the user.
459                            return Ok(Some(CachedTokenCheckResult {
460                                claims,
461                                new_token: None,
462                            }));
463                        }
464                    }
465
466                    // try to validate and refresh the token
467                    match (|| moonbase_refresh_token(cfg, &token))
468                        .retry(
469                            &ExponentialBuilder::default()
470                                .with_max_delay(Duration::from_secs(5))
471                                .with_max_times(5),
472                        )
473                        .when(|_| running.load(Ordering::Relaxed))
474                        .call()
475                    {
476                        Ok(TokenValidationResponse::Valid(new_token, claims)) => {
477                            // the token was validated, and we received a refreshed one.
478                            Ok(Some(CachedTokenCheckResult {
479                                claims,
480                                new_token: Some(new_token),
481                            }))
482                        }
483                        Ok(TokenValidationResponse::ValidationFailed(failure_type, detail)) => {
484                            Err(CachedTokenError::ValidationFailed(failure_type, detail))
485                        }
486                        Err(e) => {
487                            // the cached token couldn't be validated.
488
489                            if let Some(token_validation_age) = token_validation_age {
490                                if token_validation_age < cfg.online_token_expiration_threshold {
491                                    // if the token was validated somewhat recently,
492                                    // we give the user the benefit of the doubt
493                                    // and allow them to use the token without refreshing.
494                                    return Ok(Some(CachedTokenCheckResult {
495                                        claims,
496                                        new_token: None,
497                                    }));
498                                }
499                            }
500
501                            Err(e.into())
502                        }
503                    }
504                }
505            }
506        }
507        Ok(None) => Ok(None),
508        Err(e) => Err(e),
509    }
510}
511
512/// Parses and validates a license token file on disk.
513///
514/// If a license token is returned, it is or has been valid at some point in time,
515/// but in the case of an Online activated license,
516/// the caller should still check the `last_validated` field
517/// and validate online if necessary.
518fn load_cached_token(
519    cfg: &LicenseActivationConfig,
520) -> Result<Option<(String, LicenseTokenClaims)>, CachedTokenError> {
521    if !fs::exists(&cfg.cached_token_path)? {
522        return Ok(None);
523    }
524
525    let token = fs::read_to_string(&cfg.cached_token_path)?;
526
527    // parse and validate the token
528    let claims = parse_token(cfg, &token)?;
529
530    // ensure the token applies to this product and device
531    validate_token_applicable(cfg, &claims)?;
532
533    Ok(Some((token, claims)))
534}
535
536/// Parses a JWT token and checks its validity.
537///
538/// This does not validate whether the token
539/// applies to the current hardware and product,
540/// only whether it's a well-formed token.
541fn parse_token(
542    cfg: &LicenseActivationConfig,
543    token: &str,
544) -> Result<LicenseTokenClaims, jsonwebtoken::errors::Error> {
545    let mut validation = Validation::new(Algorithm::RS256);
546    validation.set_audience(&[&cfg.product_id]);
547
548    // disable validation of expiry as it's not always given
549    validation.required_spec_claims.clear();
550    validation.validate_exp = false;
551
552    let claims = jsonwebtoken::decode::<LicenseTokenClaims>(
553        token,
554        &DecodingKey::from_rsa_pem(cfg.jwt_pubkey.as_bytes()).unwrap(),
555        &validation,
556    )?
557    .claims;
558
559    // validate token expiration date
560    // similar to how the library does it when validate_exp is true
561    if let Some(expires_at) = claims.expires_at {
562        if expires_at.timestamp() as u64 - validation.reject_tokens_expiring_in_less_than
563            < get_current_timestamp() - validation.leeway
564        {
565            return Err(ErrorKind::ExpiredSignature.into());
566        }
567    }
568    Ok(claims)
569}
570
571fn validate_token_applicable(
572    cfg: &LicenseActivationConfig,
573    claims: &LicenseTokenClaims,
574) -> Result<(), InapplicableTokenError> {
575    if claims.device_signature != cfg.device_signature {
576        return Err(InapplicableTokenError::InvalidDeviceSignature);
577    }
578
579    Ok(())
580}
581
582enum TokenValidationResponse {
583    /// The token is valid and a refreshed token is provided.
584    Valid(String, LicenseTokenClaims),
585    /// Online validation failed with a specific reason.
586    ValidationFailed(ValidationFailedType, String),
587}
588
589/// Asks the Moonbase API whether the given license token is still valid.
590/// If it is, a new token with updated `last_updated` property is returned.
591fn moonbase_refresh_token(
592    cfg: &LicenseActivationConfig,
593    token: &str,
594) -> Result<TokenValidationResponse, MoonbaseApiError> {
595    let response = ureq::post(format!(
596        "{}/api/client/licenses/{}/validate",
597        cfg.moonbase_api_base_url(),
598        cfg.product_id
599    ))
600    .config()
601    .http_status_as_error(false)
602    .timeout_global(Some(Duration::from_secs(10)))
603    .build()
604    .content_type("text/plain")
605    .send(token)?;
606
607    let status = response.status();
608
609    if status == StatusCode::OK {
610        // the token was successfully validated.
611        // the response body contains the refreshed token
612        let token = response.into_body().read_to_string()?;
613
614        // parse the refreshed token
615        return match parse_token(cfg, &token) {
616            Ok(claims) => Ok(TokenValidationResponse::Valid(token, claims)),
617            Err(_) => Err(MoonbaseApiError::UnexpectedResponse(status, token)),
618        };
619    }
620
621    // Moonbase responds with 400 Bad Request if the license is not valid anymore
622    if status == StatusCode::BAD_REQUEST {
623        // error responses use the standard problem details format:
624        // https://www.rfc-editor.org/rfc/rfc9457.html
625        let body = response.into_body().read_to_string()?;
626        let problem: ProblemDetails = serde_json::from_str(&body)
627            .map_err(|_| MoonbaseApiError::UnexpectedResponse(status, body.clone()))?;
628        let failure_type = match problem.error_type.as_str() {
629            "LicenseRevoked" => ValidationFailedType::LicenseRevoked,
630            "LicenseActivationRevoked" => ValidationFailedType::LicenseActivationRevoked,
631            "LicenseExpired" => ValidationFailedType::LicenseExpired,
632            "NoEligibleLicense" => ValidationFailedType::NoEligibleLicense,
633            _ => ValidationFailedType::Unknown,
634        };
635        return Ok(TokenValidationResponse::ValidationFailed(
636            failure_type,
637            problem.detail,
638        ));
639    }
640
641    // Moonbase responded with a status code that we don't expect.
642    Err(MoonbaseApiError::UnexpectedResponse(
643        status,
644        response
645            .into_body()
646            .read_to_string()
647            // don't propagate any errors when reading the response body here,
648            // as reporting the actual status code error is more important
649            .unwrap_or("".to_string()),
650    ))
651}
652
653#[derive(Deserialize)]
654struct ProblemDetails {
655    #[serde(rename = "errorType")]
656    error_type: String,
657    detail: String,
658}
659
660#[derive(Serialize)]
661struct ActivationUrlsRequestPayload {
662    #[serde(rename = "deviceName")]
663    device_name: String,
664    #[serde(rename = "deviceSignature")]
665    device_signature: String,
666}
667
668#[derive(Deserialize)]
669struct ActivationUrls {
670    /// The API endpoint to check whether the user
671    /// has activated the software.
672    request: String,
673    /// The URL at which the user can activate
674    /// the software in their browser.
675    browser: String,
676}
677
678/// Asks the Moonbase API for the URLs to perform online activation.
679fn moonbase_request_online_activation(
680    cfg: &LicenseActivationConfig,
681) -> Result<ActivationUrls, MoonbaseApiError> {
682    let response = ureq::post(format!(
683        "{}/api/client/activations/{}/request",
684        cfg.moonbase_api_base_url(),
685        cfg.product_id
686    ))
687    .config()
688    .timeout_global(Some(Duration::from_secs(10)))
689    .build()
690    .send_json(ActivationUrlsRequestPayload {
691        device_name: cfg.device_name.clone(),
692        device_signature: cfg.device_signature.clone(),
693    })?;
694
695    let status = response.status();
696    if status == StatusCode::OK {
697        // parse the response body
698        let mut body = response.into_body();
699        return match body.read_json::<ActivationUrls>() {
700            Ok(response) => Ok(response),
701            Err(_) => Err(MoonbaseApiError::UnexpectedResponse(
702                status,
703                body.read_to_string().unwrap_or("".into()),
704            )),
705        };
706    }
707
708    // Moonbase responded with a status code that we don't expect.
709    Err(MoonbaseApiError::UnexpectedResponse(
710        status,
711        response
712            .into_body()
713            .read_to_string()
714            // don't propagate any errors when reading the response body here,
715            // as reporting the actual status code error is more important
716            .unwrap_or("".into()),
717    ))
718}
719
720/// Polls the given Moonbase activation URL to check if the user
721/// has activated their software using online activation.
722///
723/// Returns `None` if the product has not yet been activated.
724fn moonbase_check_online_activation(
725    cfg: &LicenseActivationConfig,
726    url: &str,
727) -> Result<Option<(String, LicenseTokenClaims)>, MoonbaseApiError> {
728    let response = ureq::get(url)
729        .config()
730        .timeout_global(Some(Duration::from_secs(10)))
731        .build()
732        .call()?;
733
734    let status = response.status();
735
736    if status == StatusCode::NO_CONTENT {
737        // the product has not yet been activated.
738        return Ok(None);
739    }
740
741    if status == StatusCode::OK {
742        // the product was activated.
743        // the response body contains the license token
744        let token = response.into_body().read_to_string()?;
745
746        // parse the token
747        let claims = parse_token(cfg, &token)?;
748        return Ok(Some((token, claims)));
749    }
750
751    // Moonbase responded with a status code that we don't expect.
752    Err(MoonbaseApiError::UnexpectedResponse(
753        status,
754        response
755            .into_body()
756            .read_to_string()
757            // don't propagate any errors when reading the response body here,
758            // as reporting the actual status code error is more important
759            .unwrap_or("".to_string()),
760    ))
761}