Skip to main content

navcore_signalk_client/
token.rs

1//! Reading what a Signal K device token says about itself.
2//!
3//! Storing the token is the application's own business -- see
4//! [`crate::access`]'s own module doc -- but reading what one already held
5//! actually says is not application-specific at all: any client speaking to
6//! a Signal K server holds the identical JWT shape and needs the identical
7//! answer to "is this still good".
8
9use base64::Engine;
10use base64::engine::general_purpose::URL_SAFE_NO_PAD;
11
12/// What is known about the token this installation holds.
13///
14/// Read out of the token itself rather than remembered separately. The
15/// server states the device name and the expiry when it issues it, and a
16/// second copy kept elsewhere is a second thing that can be wrong.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Token {
19    /// The device name the server approved.
20    pub device: String,
21    /// When it stops being accepted, as seconds since the epoch. `None`
22    /// when the server issued it without an expiry.
23    pub expires_at: Option<i64>,
24}
25
26impl Token {
27    /// Reads what a Signal K device token says about itself.
28    ///
29    /// The token is a JWT: three dot-separated parts, of which the middle
30    /// one is base64url-encoded JSON. Only read here, never checked -- the
31    /// signature is over a secret only the server holds, so this cannot
32    /// verify anything and does not pretend to. It is the server's word,
33    /// displayed as the server's word.
34    #[must_use]
35    pub fn read(raw: &str) -> Option<Self> {
36        let payload = raw.split('.').nth(1)?;
37        let json: serde_json::Value =
38            serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload).ok()?).ok()?;
39
40        Some(Self {
41            device: json.get("device")?.as_str()?.to_owned(),
42            expires_at: json.get("exp").and_then(serde_json::Value::as_i64),
43        })
44    }
45
46    /// Whether the server would still accept it, as far as the token says,
47    /// at `now_unix` (seconds since the epoch).
48    ///
49    /// "As far as the token says" is the whole caveat: a token can also
50    /// stop working because the administrator removed the device, and
51    /// nothing in the token itself shows that. Only the server can answer
52    /// that, by refusing.
53    ///
54    /// `now_unix` is a parameter rather than read from the system clock
55    /// here, the same reason every other time-sensitive answer in this
56    /// workspace takes `now` in: a caller replaying a recorded run gets the
57    /// identical answer a live one would have.
58    #[must_use]
59    pub fn expired(&self, now_unix: i64) -> bool {
60        self.expires_at.is_some_and(|at| at <= now_unix)
61    }
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67
68    /// The payload of a real device token, re-encoded. No signature: this
69    /// reads tokens, it cannot check them, and a test that carried a valid
70    /// signature would imply otherwise.
71    fn token_with(payload: &str) -> String {
72        let encoded = URL_SAFE_NO_PAD.encode(payload.as_bytes());
73        format!("header.{encoded}.signature")
74    }
75
76    #[test]
77    fn a_token_states_its_device_and_expiry() {
78        let raw = token_with(r#"{"device":"nav-client-desktop","iat":1,"exp":1818254445}"#);
79        let token = Token::read(&raw).expect("a token");
80        assert_eq!(token.device, "nav-client-desktop");
81        assert_eq!(token.expires_at, Some(1_818_254_445));
82        assert!(!token.expired(1_700_000_000));
83    }
84
85    #[test]
86    fn a_token_may_have_no_expiry_at_all() {
87        // The server's own default is NEVER, in which case it signs without
88        // an exp claim. Never expiring is not the same as expired.
89        let token = Token::read(&token_with(r#"{"device":"nav-client","iat":1}"#)).expect("a token");
90        assert_eq!(token.expires_at, None);
91        assert!(!token.expired(9_999_999_999));
92        assert!(!token.expired(0));
93    }
94
95    #[test]
96    fn a_token_from_the_past_is_expired() {
97        let token = Token::read(&token_with(r#"{"device":"nav-client","exp":1000}"#)).expect("a token");
98        assert!(token.expired(1001));
99        assert!(token.expired(1000));
100        assert!(!token.expired(999));
101    }
102
103    #[test]
104    fn rubbish_is_not_read_as_a_token() {
105        // Better no answer than a confident wrong one: a truncated or
106        // hand-edited file must not present itself as a working credential.
107        assert!(Token::read("").is_none());
108        assert!(Token::read("not-a-jwt").is_none());
109        assert!(Token::read("header.!!!!.signature").is_none());
110        assert!(Token::read(&token_with(r#"{"no-device":true}"#)).is_none());
111    }
112}