Skip to main content

spiral_auth/
token.rs

1//! Bearer tokens and their unverified claims.
2
3use std::fmt::{Debug, Display, Formatter, Result as FmtResult};
4use std::hash::{Hash, Hasher};
5use std::sync::Arc;
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7
8use base64::Engine as _;
9use base64::engine::general_purpose::URL_SAFE_NO_PAD;
10use secrecy::{ExposeSecret as _, SecretString};
11use serde::de::DeserializeOwned;
12use serde::{Deserialize, Deserializer, Serialize, Serializer};
13
14use crate::{CredentialError, CredentialResult};
15
16/// A token within this lifetime of its expiry is unusable.
17const EXPIRY_MARGIN: Duration = Duration::from_secs(30);
18/// A token within this lifetime of its expiry should be replaced before its next use.
19const REFRESH_MARGIN: Duration = Duration::from_secs(60);
20
21/// A bearer token, shared behind one pointer so that it stays cheap to clone and embed.
22#[derive(Clone)]
23pub struct Token {
24    inner: Arc<TokenInner>,
25}
26
27struct TokenInner {
28    value: SecretString,
29    /// The resource the token is for, as its token response named it. Not part of the token
30    /// itself, so neither compared nor serialized.
31    resource: Option<String>,
32}
33
34impl Token {
35    /// The token itself, to send as a bearer credential or store.
36    pub fn expose_secret(&self) -> &str {
37        self.inner.value.expose_secret()
38    }
39
40    /// The resource the token is for, such as the API URL to call, when its token response
41    /// named one. Clients read this rather than the token's claims (RFC 9068 §6).
42    pub fn resource(&self) -> Option<&str> {
43        self.inner.resource.as_deref()
44    }
45
46    /// Record the resource that the token's response named.
47    pub fn with_resource(self, resource: Option<String>) -> Self {
48        Self {
49            inner: Arc::new(TokenInner {
50                value: self.inner.value.clone(),
51                resource,
52            }),
53        }
54    }
55
56    /// Decode the claims of a JWT without verifying its signature.
57    ///
58    /// Use the claims only to manage the token, such as deciding when to refresh it; the
59    /// service that accepts the token is the one that verifies it.
60    pub fn unverified_claims<T: DeserializeOwned>(&self) -> CredentialResult<T> {
61        let mut segments = self.expose_secret().split('.');
62        let (Some(_header), Some(payload), Some(_signature), None) = (
63            segments.next(),
64            segments.next(),
65            segments.next(),
66            segments.next(),
67        ) else {
68            return Err(CredentialError::invalid("the token is not a JWT"));
69        };
70        // RFC 7515 §2: JWS segments are base64url without padding.
71        let payload = URL_SAFE_NO_PAD
72            .decode(payload.trim_end_matches('='))
73            .map_err(|error| CredentialError::invalid(format!("the JWT payload: {error}")))?;
74        Ok(serde_json::from_slice(&payload)?)
75    }
76
77    /// How long until the token expires, without validating its signature.
78    ///
79    /// Zero once expired, rather than underflowing.
80    pub fn remaining_lifetime(&self) -> CredentialResult<Duration> {
81        let Expiry { exp } = self.unverified_claims()?;
82        let now = SystemTime::now()
83            .duration_since(UNIX_EPOCH)
84            .expect("the clock is after the Unix epoch")
85            .as_secs();
86        Ok(Duration::from_secs(exp.saturating_sub(now)))
87    }
88
89    /// True if the token has expired or will within 30 seconds. Such a token is unusable.
90    pub fn is_expired(&self) -> CredentialResult<bool> {
91        Ok(self.remaining_lifetime()? < EXPIRY_MARGIN)
92    }
93
94    /// True if the token expires within a minute. It is still usable, but a fresh one should
95    /// be fetched proactively.
96    pub fn needs_refresh(&self) -> CredentialResult<bool> {
97        Ok(self.remaining_lifetime()? < REFRESH_MARGIN)
98    }
99}
100
101/// The RFC 7519 §4.1.4 expiration claim. Issuers write it as an integer or a decimal.
102struct Expiry {
103    exp: u64,
104}
105
106impl<'de> Deserialize<'de> for Expiry {
107    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
108        #[derive(Deserialize)]
109        struct Claims {
110            exp: serde_json::Number,
111        }
112        let Claims { exp } = Claims::deserialize(deserializer)?;
113        let exp = exp
114            .as_u64()
115            .or_else(|| {
116                exp.as_f64()
117                    .filter(|seconds| seconds.is_finite() && *seconds >= 0.0)
118                    .map(|seconds| seconds.round() as u64)
119            })
120            .ok_or_else(|| serde::de::Error::custom("exp must be a non-negative number"))?;
121        Ok(Self { exp })
122    }
123}
124
125impl From<String> for Token {
126    fn from(value: String) -> Self {
127        Self {
128            inner: Arc::new(TokenInner {
129                value: SecretString::from(value),
130                resource: None,
131            }),
132        }
133    }
134}
135
136impl From<&str> for Token {
137    fn from(value: &str) -> Self {
138        Self::from(value.to_owned())
139    }
140}
141
142impl PartialEq for Token {
143    fn eq(&self, other: &Self) -> bool {
144        self.expose_secret() == other.expose_secret()
145    }
146}
147
148impl Eq for Token {}
149
150impl Hash for Token {
151    fn hash<H: Hasher>(&self, state: &mut H) {
152        self.expose_secret().hash(state);
153    }
154}
155
156impl Serialize for Token {
157    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
158        serializer.serialize_str(self.expose_secret())
159    }
160}
161
162impl<'de> Deserialize<'de> for Token {
163    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
164        String::deserialize(deserializer).map(Self::from)
165    }
166}
167
168impl Display for Token {
169    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
170        f.write_str(&"*".repeat(16))
171    }
172}
173
174impl Debug for Token {
175    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
176        f.debug_struct("Token")
177            .field("value", &"*".repeat(16))
178            .finish()
179    }
180}
181
182/// An unsigned JWT with `claims`, for tests of token handling.
183#[cfg(test)]
184pub(crate) fn unsigned_jwt(claims: serde_json::Value) -> Token {
185    let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#);
186    let payload = URL_SAFE_NO_PAD.encode(claims.to_string());
187    Token::from(format!("{header}.{payload}.signature"))
188}
189
190#[cfg(test)]
191mod tests {
192    use serde_json::json;
193
194    use super::*;
195
196    fn now() -> u64 {
197        SystemTime::now()
198            .duration_since(UNIX_EPOCH)
199            .unwrap()
200            .as_secs()
201    }
202
203    #[test]
204    fn remaining_lifetime_tracks_the_exp_claim() {
205        let remaining = unsigned_jwt(json!({ "exp": now() + 600 }))
206            .remaining_lifetime()
207            .unwrap();
208
209        // A one-second window absorbs the truncation to whole seconds in `exp`.
210        assert!(
211            remaining > Duration::from_secs(598) && remaining <= Duration::from_secs(600),
212            "unexpected remaining lifetime: {remaining:?}"
213        );
214    }
215
216    #[test]
217    fn reads_a_decimal_exp_and_expires_past_tokens() {
218        let token = unsigned_jwt(json!({ "exp": 1_000_000_000.0 }));
219
220        assert_eq!(token.remaining_lifetime().unwrap(), Duration::ZERO);
221        assert!(token.is_expired().unwrap());
222    }
223
224    #[test]
225    fn rejects_a_token_that_is_not_a_jwt() {
226        assert!(Token::from("not-a-jwt").remaining_lifetime().is_err());
227    }
228
229    #[test]
230    fn serializes_as_its_value_and_redacts_debug_output() {
231        let token: Token = serde_json::from_value(json!("very_secret")).unwrap();
232
233        assert_eq!(serde_json::to_value(&token).unwrap(), json!("very_secret"));
234        assert!(!format!("{token:?}").contains("very_secret"));
235    }
236}