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