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}