moonbase-licensing 0.3.0

Client for moonbase.sh licensing system
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
use crate::claims::{ActivationMethod, LicenseTokenClaims};
use crate::device_token::DeviceToken;
use backon::{BlockingRetryable, ExponentialBuilder};
use chrono::Utc;
use jsonwebtoken::errors::ErrorKind;
use jsonwebtoken::{get_current_timestamp, Algorithm, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, Sender};
use std::sync::Arc;
use std::thread::{sleep, JoinHandle};
use std::time::Duration;
use std::{fs, io, thread};
use thiserror::Error;
use ureq::http::StatusCode;

/// Represents the software's current activation state.
pub enum ActivationState {
    /// The plugin requires activation.
    ///
    /// The provided String contains the URL to open
    /// in the user's browser for online activation.
    /// If it is None, only offline activation is available at this point,
    /// but online activation may become available later with a new [ActivationState].
    NeedsActivation(Option<String>),

    /// The plugin has been successfully activated.
    Activated(LicenseTokenClaims),
}

/// Errors that can occur during the activation process.
#[derive(Error, Debug)]
pub enum ActivationError {
    /// An error occurred when validating a cached token.
    #[error("Could not validate cached token: {0}")]
    LoadCachedToken(#[from] CachedTokenError),

    /// Could not persist the token to disk for caching purposes.
    #[error("Could not save license token to disk: {0}")]
    SaveCachedToken(#[from] io::Error),

    /// Could not fetch the online activation URL from the Moonbase API.
    #[error("Could not fetch online activation url: {0}")]
    FetchActivationUrl(MoonbaseApiError),

    /// Could not fetch the activation state of an online token from the Moonbase API.
    #[error("Could not fetch activation state of online token: {0}")]
    FetchActivationState(MoonbaseApiError),

    /// Could not validate an offline token provided by the user.
    #[error("Could not validate offline token: {0}")]
    OfflineToken(#[from] OfflineTokenValidationError),
}

#[derive(Error, Debug)]
pub enum OfflineTokenValidationError {
    #[error("the license token is invalid: {0}")]
    Invalid(#[from] jsonwebtoken::errors::Error),
    #[error("inapplicable token: {0}")]
    Inapplicable(#[from] InapplicableTokenError),
    #[error("the license token is not an offline token")]
    NoOfflineToken,
}

#[derive(Error, Debug)]
pub enum CachedTokenError {
    /// An I/O error occurred when reading the token file from disk.
    #[error("error loading cached token file: {0}")]
    Io(#[from] io::Error),

    /// The token failed validation by the JWT parser.
    #[error("invalid JWT payload: {0}")]
    Invalid(#[from] jsonwebtoken::errors::Error),

    /// The token is not valid for the product or hardware device.
    #[error("inapplicable token: {0}")]
    Inapplicable(#[from] InapplicableTokenError),

    /// Online validation by Moonbase failed.
    #[error("online validation failed: {1}")]
    ValidationFailed(ValidationFailedType, String),

    /// The token is valid, but too old to trust,
    /// and it couldn't be refreshed.
    #[error("token could not be refreshed")]
    RefreshFailed(#[from] MoonbaseApiError),
}

#[derive(Error, Debug)]
pub enum InapplicableTokenError {
    #[error("the license token is not valid for this device")]
    InvalidDeviceSignature,
}

#[derive(Error, Debug)]
pub enum MoonbaseApiError {
    /// An I/O error occurred when contacting the Moonbase API.
    #[error("issues contacting API: {0}")]
    Io(#[from] ureq::Error),

    /// We received an unexpected response from the Moonbase API.
    #[error("unexpected response with status code {0} and body {1}")]
    UnexpectedResponse(StatusCode, String),

    /// The token returned by the Moonbase API was malformed.
    #[error("invalid token: {0}")]
    InvalidToken(#[from] jsonwebtoken::errors::Error),
}

/// The reason why online license validation failed.
#[derive(Debug)]
pub enum ValidationFailedType {
    LicenseRevoked,
    LicenseActivationRevoked,
    LicenseExpired,
    NoEligibleLicense,
    /// Unknown error type in Moonbase API response -
    /// if this is reached, this library needs updating!
    Unknown,
}

/// Configuration options for the [LicenseActivator].
#[derive(Clone)]
pub struct LicenseActivationConfig {
    /// The Moonbase vendor id for the store.
    /// Used to determine the API endpoint, i.e.
    /// https://{vendor_id}.moonbase.sh
    pub vendor_id: String,
    /// The Moonbase product id that a license needs to be valid for.
    pub product_id: String,
    /// The public key to verify the signed JWT payload.
    pub jwt_pubkey: String,

    /// The path where the cached license token payload is stored on disk.
    pub cached_token_path: PathBuf,

    /// User-friendly display name of the device the software is running on.
    /// Reported to Moonbase when activating a license.
    pub device_name: String,
    /// The unique signature of the device the software is running on.
    pub device_signature: String,

    /// The age threshold beyond which the activator attempts to refresh online tokens.
    /// Before this age, the token is accepted without attempting any further online validation.
    pub online_token_refresh_threshold: Duration,
    /// The age threshold beyond which an online token is deemed
    /// too old to trust and must be refreshed before being accepted.
    pub online_token_expiration_threshold: Duration,
}

/// Performs license activation.
pub struct LicenseActivator {
    cfg: LicenseActivationConfig,

    /// Receiver for the main thread to poll changes to the license activation state.
    ///
    /// Once [ActivationState::Activated] has been received,
    /// the consumer can stop reading from this channel,
    /// as the license activation won't be revoked again during this session.
    ///
    /// Until the first value is received,
    /// the license activation state is undetermined,
    /// and the user should just be shown a "loading" state.
    pub state_recv: Receiver<ActivationState>,
    state_send: Sender<ActivationState>,

    /// Receiver for the main thread to poll errors encountered during license activation.
    ///
    /// Which of these you want to display is up to your discretion.
    /// You may want to display only the most recent error,
    /// or perhaps display each error and make them dismissable.
    pub error_recv: Receiver<ActivationError>,
    error_send: Sender<ActivationError>,

    /// While this is true, the license activator polls the Moonbase API
    /// to check if the user has activated the license online.
    ///
    /// Set this to false whenever the user isn't on the online activation screen
    /// to avoid spamming the Moonbase API and getting rate limited.
    pub poll_online_activation: Arc<AtomicBool>,

    /// Whether the worker thread should keep running.
    running: Arc<AtomicBool>,
    /// Join handle for the worker thread.
    join: Option<JoinHandle<()>>,
}

impl Drop for LicenseActivator {
    fn drop(&mut self) {
        self.running.store(false, Ordering::Relaxed);
        self.join.take().unwrap().join().unwrap();
    }
}

impl LicenseActivator {
    /// Creates a new license activator,
    /// spawning the background threads that perform license checking.
    ///
    /// These background threads run until activation is successful
    /// or the [LicenseActivator] is dropped.
    pub fn spawn(cfg: LicenseActivationConfig) -> Self {
        // create communication channels to report activation state changes to calling thread
        let (state_send, state_recv) = std::sync::mpsc::channel();
        let (error_send, error_recv) = std::sync::mpsc::channel();

        // spawn worker thread
        let running = Arc::new(AtomicBool::new(true));
        let running_clone = running.clone();

        let poll_online_activation = Arc::new(AtomicBool::new(false));
        let poll_online_activation_clone = poll_online_activation.clone();

        let state_send_clone = state_send.clone();
        let error_send_clone = error_send.clone();
        let cfg_clone = cfg.clone();

        let join = thread::spawn(|| {
            worker_thread(
                running_clone,
                state_send_clone,
                error_send_clone,
                poll_online_activation_clone,
                cfg_clone,
            );
        });

        Self {
            cfg,

            state_recv,
            state_send,

            error_recv,
            error_send,

            poll_online_activation,

            running,
            join: Some(join),
        }
    }

    /// Creates and returns the contents to write to the machine file used for offline activation.
    pub fn machine_file_contents(&self) -> String {
        DeviceToken::new(
            self.cfg.device_signature.clone(),
            self.cfg.device_name.clone(),
            self.cfg.product_id.clone(),
        )
        .serialize()
    }

    /// Submits the given offline activation token for validation,
    /// caching it on disk if it's valid.
    ///
    /// The result of the validation can be obtained
    /// from the state and error receivers as usual.
    pub fn submit_offline_activation_token(&mut self, token: &str) {
        match self.check_offline_activation_token(token) {
            Ok(claims) => {
                _ = self.state_send.send(ActivationState::Activated(claims));

                // stop the worker thread, as we don't need any more validation from here on
                self.running.store(false, Ordering::Relaxed);

                // persist the token on disk
                if let Err(e) = fs::write(&self.cfg.cached_token_path, token) {
                    _ = self.error_send.send(ActivationError::SaveCachedToken(e));
                }
            }
            Err(e) => _ = self.error_send.send(ActivationError::OfflineToken(e)),
        }
    }

    fn check_offline_activation_token(
        &mut self,
        token: &str,
    ) -> Result<LicenseTokenClaims, OfflineTokenValidationError> {
        let claims = parse_token(&self.cfg, token)?;

        if claims.method != ActivationMethod::Offline {
            return Err(OfflineTokenValidationError::NoOfflineToken);
        }

        validate_token_applicable(&self.cfg, &claims)?;

        Ok(claims)
    }
}

impl LicenseActivationConfig {
    /// Returns the base URL to make any Moonbase API requests to.
    fn moonbase_api_base_url(&self) -> String {
        format!("https://{}.moonbase.sh", self.vendor_id)
    }
}

fn worker_thread(
    running: Arc<AtomicBool>,
    state_send: Sender<ActivationState>,
    error_send: Sender<ActivationError>,
    poll_online_activation: Arc<AtomicBool>,
    cfg: LicenseActivationConfig,
) {
    // first, try to load a cached license token from disk
    match check_cached_token(&cfg, running.clone()) {
        Ok(Some(result)) => {
            _ = state_send.send(ActivationState::Activated(result.claims));

            if let Some(token) = result.new_token {
                // persist the new token on disk
                if let Err(e) = fs::write(&cfg.cached_token_path, token) {
                    _ = error_send.send(ActivationError::SaveCachedToken(e));
                }
            }

            return;
        }
        Ok(None) => {
            // no cached token was found
        }
        Err(e) => {
            // cached token couldn't be validated
            _ = error_send.send(ActivationError::LoadCachedToken(e));
        }
    }

    // we don't have a valid cached token -
    // the user has to activate the plugin either offline or online.

    // we don't yet have a URL to provide for online activation,
    // but we can supply that in a subsequent state update.
    _ = state_send.send(ActivationState::NeedsActivation(None));

    // ask Moonbase for the endpoints to perform online activation
    let activation_urls = match (|| moonbase_request_online_activation(&cfg))
        .retry(
            &ExponentialBuilder::default()
                .with_max_delay(Duration::from_secs(10))
                .with_max_times(10),
        )
        .when(|_| running.load(Ordering::Relaxed))
        .call()
    {
        Ok(activation_urls) => Some(activation_urls),
        Err(e) => {
            // we couldn't get an online activation URL from Moonbase after several tries
            _ = error_send.send(ActivationError::FetchActivationUrl(e));
            None
        }
    };

    if let Some(activation_urls) = activation_urls.as_ref() {
        // we got the URLs for online activation
        // send the user-facing activation URL to the main thread
        _ = state_send.send(ActivationState::NeedsActivation(Some(
            activation_urls.browser.clone(),
        )));
    }

    // now we're waiting for the user to activate the plugin,
    // or the thread to be stopped
    while running.load(Ordering::Relaxed) {
        sleep(Duration::from_secs(5));

        match activation_urls.as_ref() {
            Some(activation_urls) if poll_online_activation.load(Ordering::Relaxed) => {
                // the user is attempting online activation -
                // check if they have succeeded

                match moonbase_check_online_activation(&cfg, &activation_urls.request) {
                    Ok(Some((token, claims))) => {
                        // the software has been activated!
                        _ = state_send.send(ActivationState::Activated(claims));

                        // persist the token on disk
                        if let Err(e) = fs::write(&cfg.cached_token_path, token) {
                            _ = error_send.send(ActivationError::SaveCachedToken(e));
                        }

                        return;
                    }
                    Ok(None) => {
                        // not yet activated - simply try again
                    }
                    Err(e) => {
                        _ = error_send.send(ActivationError::FetchActivationState(e));
                    }
                }
            }
            _ => {
                // if the user isn't currently attempting to activate the plugin in this plugin instance,
                // check if another instance of the software has activated the plugin in the meantime
                if let Ok(Some(result)) = check_cached_token(&cfg, running.clone()) {
                    _ = state_send.send(ActivationState::Activated(result.claims));

                    if let Some(token) = result.new_token {
                        // persist the new token on disk
                        if let Err(e) = fs::write(&cfg.cached_token_path, token) {
                            _ = error_send.send(ActivationError::SaveCachedToken(e));
                        }
                    }

                    return;
                }
            }
        }
    }
}

struct CachedTokenCheckResult {
    /// The claims that were validated.
    claims: LicenseTokenClaims,
    /// A new, refreshed token that must be cached on disk.
    new_token: Option<String>,
}

/// Checks whether there is an existing license token on disk
/// that represents an activated license.
///
/// Online tokens are refreshed if required,
/// and the new token is returned in this case.
///
/// None is returned if no cached token exists.
fn check_cached_token(
    cfg: &LicenseActivationConfig,
    running: Arc<AtomicBool>,
) -> Result<Option<CachedTokenCheckResult>, CachedTokenError> {
    match load_cached_token(cfg) {
        Ok(Some((token, claims))) => {
            match claims.method {
                ActivationMethod::Offline => {
                    // it's an offline activated token,
                    // so it will stay valid forever.
                    // validation succeeded!
                    Ok(Some(CachedTokenCheckResult {
                        claims,
                        new_token: None,
                    }))
                }
                ActivationMethod::Online => {
                    // it's an online activated token,
                    // so we should check if it's still valid

                    let token_validation_age = Utc::now() - claims.last_validated;

                    // Convert validation age to Duration.
                    // If last_validated lies in the future from the perspective
                    // of the machine running this code (conversion returns Error),
                    // we can't trust the token and require re-validation.
                    let token_validation_age = token_validation_age.to_std().ok();

                    if let Some(token_validation_age) = token_validation_age {
                        if token_validation_age < cfg.online_token_refresh_threshold {
                            // if the token was last validated very recently,
                            // we just accept it and don't even attempt to refresh and validate it.
                            // this minimizes API requests and waiting time for the user.
                            return Ok(Some(CachedTokenCheckResult {
                                claims,
                                new_token: None,
                            }));
                        }
                    }

                    // try to validate and refresh the token
                    match (|| moonbase_refresh_token(cfg, &token))
                        .retry(
                            &ExponentialBuilder::default()
                                .with_max_delay(Duration::from_secs(5))
                                .with_max_times(5),
                        )
                        .when(|_| running.load(Ordering::Relaxed))
                        .call()
                    {
                        Ok(TokenValidationResponse::Valid(new_token, claims)) => {
                            // the token was validated, and we received a refreshed one.
                            Ok(Some(CachedTokenCheckResult {
                                claims,
                                new_token: Some(new_token),
                            }))
                        }
                        Ok(TokenValidationResponse::ValidationFailed(failure_type, detail)) => {
                            Err(CachedTokenError::ValidationFailed(failure_type, detail))
                        }
                        Err(e) => {
                            // the cached token couldn't be validated.

                            if let Some(token_validation_age) = token_validation_age {
                                if token_validation_age < cfg.online_token_expiration_threshold {
                                    // if the token was validated somewhat recently,
                                    // we give the user the benefit of the doubt
                                    // and allow them to use the token without refreshing.
                                    return Ok(Some(CachedTokenCheckResult {
                                        claims,
                                        new_token: None,
                                    }));
                                }
                            }

                            Err(e.into())
                        }
                    }
                }
            }
        }
        Ok(None) => Ok(None),
        Err(e) => Err(e),
    }
}

/// Parses and validates a license token file on disk.
///
/// If a license token is returned, it is or has been valid at some point in time,
/// but in the case of an Online activated license,
/// the caller should still check the `last_validated` field
/// and validate online if necessary.
fn load_cached_token(
    cfg: &LicenseActivationConfig,
) -> Result<Option<(String, LicenseTokenClaims)>, CachedTokenError> {
    if !fs::exists(&cfg.cached_token_path)? {
        return Ok(None);
    }

    let token = fs::read_to_string(&cfg.cached_token_path)?;

    // parse and validate the token
    let claims = parse_token(cfg, &token)?;

    // ensure the token applies to this product and device
    validate_token_applicable(cfg, &claims)?;

    Ok(Some((token, claims)))
}

/// Parses a JWT token and checks its validity.
///
/// This does not validate whether the token
/// applies to the current hardware and product,
/// only whether it's a well-formed token.
fn parse_token(
    cfg: &LicenseActivationConfig,
    token: &str,
) -> Result<LicenseTokenClaims, jsonwebtoken::errors::Error> {
    let mut validation = Validation::new(Algorithm::RS256);
    validation.set_audience(&[&cfg.product_id]);

    // disable validation of expiry as it's not always given
    validation.required_spec_claims.clear();
    validation.validate_exp = false;

    let claims = jsonwebtoken::decode::<LicenseTokenClaims>(
        token,
        &DecodingKey::from_rsa_pem(cfg.jwt_pubkey.as_bytes()).unwrap(),
        &validation,
    )?
    .claims;

    // validate token expiration date
    // similar to how the library does it when validate_exp is true
    if let Some(expires_at) = claims.expires_at {
        if expires_at.timestamp() as u64 - validation.reject_tokens_expiring_in_less_than
            < get_current_timestamp() - validation.leeway
        {
            return Err(ErrorKind::ExpiredSignature.into());
        }
    }
    Ok(claims)
}

fn validate_token_applicable(
    cfg: &LicenseActivationConfig,
    claims: &LicenseTokenClaims,
) -> Result<(), InapplicableTokenError> {
    if claims.device_signature != cfg.device_signature {
        return Err(InapplicableTokenError::InvalidDeviceSignature);
    }

    Ok(())
}

enum TokenValidationResponse {
    /// The token is valid and a refreshed token is provided.
    Valid(String, LicenseTokenClaims),
    /// Online validation failed with a specific reason.
    ValidationFailed(ValidationFailedType, String),
}

/// Asks the Moonbase API whether the given license token is still valid.
/// If it is, a new token with updated `last_updated` property is returned.
fn moonbase_refresh_token(
    cfg: &LicenseActivationConfig,
    token: &str,
) -> Result<TokenValidationResponse, MoonbaseApiError> {
    let response = ureq::post(format!(
        "{}/api/client/licenses/{}/validate",
        cfg.moonbase_api_base_url(),
        cfg.product_id
    ))
    .config()
    .http_status_as_error(false)
    .timeout_global(Some(Duration::from_secs(10)))
    .build()
    .content_type("text/plain")
    .send(token)?;

    let status = response.status();

    if status == StatusCode::OK {
        // the token was successfully validated.
        // the response body contains the refreshed token
        let token = response.into_body().read_to_string()?;

        // parse the refreshed token
        return match parse_token(cfg, &token) {
            Ok(claims) => Ok(TokenValidationResponse::Valid(token, claims)),
            Err(_) => Err(MoonbaseApiError::UnexpectedResponse(status, token)),
        };
    }

    // Moonbase responds with 400 Bad Request if the license is not valid anymore
    if status == StatusCode::BAD_REQUEST {
        // error responses use the standard problem details format:
        // https://www.rfc-editor.org/rfc/rfc9457.html
        let body = response.into_body().read_to_string()?;
        let problem: ProblemDetails = serde_json::from_str(&body)
            .map_err(|_| MoonbaseApiError::UnexpectedResponse(status, body.clone()))?;
        let failure_type = match problem.error_type.as_str() {
            "LicenseRevoked" => ValidationFailedType::LicenseRevoked,
            "LicenseActivationRevoked" => ValidationFailedType::LicenseActivationRevoked,
            "LicenseExpired" => ValidationFailedType::LicenseExpired,
            "NoEligibleLicense" => ValidationFailedType::NoEligibleLicense,
            _ => ValidationFailedType::Unknown,
        };
        return Ok(TokenValidationResponse::ValidationFailed(
            failure_type,
            problem.detail,
        ));
    }

    // Moonbase responded with a status code that we don't expect.
    Err(MoonbaseApiError::UnexpectedResponse(
        status,
        response
            .into_body()
            .read_to_string()
            // don't propagate any errors when reading the response body here,
            // as reporting the actual status code error is more important
            .unwrap_or("".to_string()),
    ))
}

#[derive(Deserialize)]
struct ProblemDetails {
    #[serde(rename = "errorType")]
    error_type: String,
    detail: String,
}

#[derive(Serialize)]
struct ActivationUrlsRequestPayload {
    #[serde(rename = "deviceName")]
    device_name: String,
    #[serde(rename = "deviceSignature")]
    device_signature: String,
}

#[derive(Deserialize)]
struct ActivationUrls {
    /// The API endpoint to check whether the user
    /// has activated the software.
    request: String,
    /// The URL at which the user can activate
    /// the software in their browser.
    browser: String,
}

/// Asks the Moonbase API for the URLs to perform online activation.
fn moonbase_request_online_activation(
    cfg: &LicenseActivationConfig,
) -> Result<ActivationUrls, MoonbaseApiError> {
    let response = ureq::post(format!(
        "{}/api/client/activations/{}/request",
        cfg.moonbase_api_base_url(),
        cfg.product_id
    ))
    .config()
    .timeout_global(Some(Duration::from_secs(10)))
    .build()
    .send_json(ActivationUrlsRequestPayload {
        device_name: cfg.device_name.clone(),
        device_signature: cfg.device_signature.clone(),
    })?;

    let status = response.status();
    if status == StatusCode::OK {
        // parse the response body
        let mut body = response.into_body();
        return match body.read_json::<ActivationUrls>() {
            Ok(response) => Ok(response),
            Err(_) => Err(MoonbaseApiError::UnexpectedResponse(
                status,
                body.read_to_string().unwrap_or("".into()),
            )),
        };
    }

    // Moonbase responded with a status code that we don't expect.
    Err(MoonbaseApiError::UnexpectedResponse(
        status,
        response
            .into_body()
            .read_to_string()
            // don't propagate any errors when reading the response body here,
            // as reporting the actual status code error is more important
            .unwrap_or("".into()),
    ))
}

/// Polls the given Moonbase activation URL to check if the user
/// has activated their software using online activation.
///
/// Returns `None` if the product has not yet been activated.
fn moonbase_check_online_activation(
    cfg: &LicenseActivationConfig,
    url: &str,
) -> Result<Option<(String, LicenseTokenClaims)>, MoonbaseApiError> {
    let response = ureq::get(url)
        .config()
        .timeout_global(Some(Duration::from_secs(10)))
        .build()
        .call()?;

    let status = response.status();

    if status == StatusCode::NO_CONTENT {
        // the product has not yet been activated.
        return Ok(None);
    }

    if status == StatusCode::OK {
        // the product was activated.
        // the response body contains the license token
        let token = response.into_body().read_to_string()?;

        // parse the token
        let claims = parse_token(cfg, &token)?;
        return Ok(Some((token, claims)));
    }

    // Moonbase responded with a status code that we don't expect.
    Err(MoonbaseApiError::UnexpectedResponse(
        status,
        response
            .into_body()
            .read_to_string()
            // don't propagate any errors when reading the response body here,
            // as reporting the actual status code error is more important
            .unwrap_or("".to_string()),
    ))
}