Skip to main content

aauth_protocol/
jwt.rs

1//! Compact JWS: minting with a [`SigningKey`] and verifying against a [`Jwk`],
2//! under fully-specified algorithms only.
3
4use base64::engine::general_purpose::URL_SAFE_NO_PAD;
5use base64::Engine;
6use serde_json::{Map, Value};
7
8use crate::error::{Error, Result};
9use crate::jwk::{Algorithm, Jwk};
10use crate::keys::SigningKey;
11
12/// A JWT split into its decoded header and claims. Nothing here is verified.
13#[derive(Clone, Debug)]
14pub struct Decoded {
15    /// The JOSE header.
16    pub header: Map<String, Value>,
17    /// The claims.
18    pub claims: Map<String, Value>,
19    signing_input: String,
20    signature: Vec<u8>,
21}
22
23impl Decoded {
24    /// The `typ` header.
25    pub fn typ(&self) -> Option<&str> {
26        self.header.get("typ").and_then(Value::as_str)
27    }
28
29    /// The `alg` header.
30    pub fn alg(&self) -> Option<&str> {
31        self.header.get("alg").and_then(Value::as_str)
32    }
33
34    /// The `kid` header.
35    pub fn kid(&self) -> Option<&str> {
36        self.header.get("kid").and_then(Value::as_str)
37    }
38
39    /// A non-empty string claim.
40    pub fn claim_str(&self, name: &str) -> Option<&str> {
41        self.claims
42            .get(name)
43            .and_then(Value::as_str)
44            .filter(|s| !s.is_empty())
45    }
46
47    /// Whether `key` signed this JWT under `algorithm`.
48    pub fn verify_signature(&self, key: &Jwk, algorithm: Algorithm) -> bool {
49        key.verify(algorithm, self.signing_input.as_bytes(), &self.signature)
50    }
51}
52
53fn decode_part(part: &str) -> Result<Map<String, Value>> {
54    let bytes = URL_SAFE_NO_PAD
55        .decode(part.trim_end_matches('='))
56        .map_err(|_| Error::Invalid("JWT part is not base64url".into()))?;
57    match serde_json::from_slice(&bytes) {
58        Ok(Value::Object(map)) => Ok(map),
59        _ => Err(Error::Invalid("JWT part is not a JSON object".into())),
60    }
61}
62
63/// Split a compact JWS without verifying it.
64pub fn decode(jwt: &str) -> Result<Decoded> {
65    let mut parts = jwt.split('.');
66    let (Some(header), Some(claims), Some(signature), None) =
67        (parts.next(), parts.next(), parts.next(), parts.next())
68    else {
69        return Err(Error::Invalid("not a compact JWS".into()));
70    };
71    Ok(Decoded {
72        header: decode_part(header)?,
73        claims: decode_part(claims)?,
74        signing_input: format!("{header}.{claims}"),
75        signature: URL_SAFE_NO_PAD
76            .decode(signature)
77            .map_err(|_| Error::Invalid("JWT signature is not base64url".into()))?,
78    })
79}
80
81/// Mint a JWT: `header` gains `alg`, and `kid` when the key's thumbprint is
82/// wanted and none is given.
83pub fn encode(key: &SigningKey, mut header: Map<String, Value>, claims: &Value) -> String {
84    header.insert("alg".into(), Value::from(key.algorithm().as_str()));
85    let input = format!(
86        "{}.{}",
87        URL_SAFE_NO_PAD.encode(Value::Object(header).to_string()),
88        URL_SAFE_NO_PAD.encode(claims.to_string())
89    );
90    let signature = key.sign(input.as_bytes());
91    format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature))
92}
93
94/// Seconds until a JWT's `exp`, less `margin`; `None` when unreadable or due.
95/// For caches deciding how long to keep a token they did not mint.
96pub fn remaining_secs(jwt: &str, now: i64, margin: i64) -> Option<u64> {
97    let payload = jwt.split('.').nth(1)?;
98    let bytes = URL_SAFE_NO_PAD.decode(payload).ok()?;
99    let exp = serde_json::from_slice::<Value>(&bytes)
100        .ok()?
101        .get("exp")?
102        .as_i64()?;
103    let left = exp - now - margin;
104    (left > 0).then_some(left as u64)
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use serde_json::json;
111
112    #[test]
113    fn a_minted_jwt_verifies_and_a_tampered_one_does_not() {
114        let key = SigningKey::generate();
115        let header = json!({"typ": "aa-agent+jwt"}).as_object().unwrap().clone();
116        let jwt = encode(&key, header, &json!({"sub": "a"}));
117        let decoded = decode(&jwt).unwrap();
118        assert_eq!(decoded.alg(), Some("Ed25519"));
119        assert!(decoded.verify_signature(&key.public_jwk(), Algorithm::Ed25519));
120
121        let mut parts: Vec<&str> = jwt.split('.').collect();
122        let forged = URL_SAFE_NO_PAD.encode(json!({"sub": "b"}).to_string());
123        parts[1] = &forged;
124        let tampered = decode(&parts.join(".")).unwrap();
125        assert!(!tampered.verify_signature(&key.public_jwk(), Algorithm::Ed25519));
126    }
127
128    #[test]
129    fn a_token_is_kept_until_shortly_before_it_expires() {
130        let enc = |v: Value| URL_SAFE_NO_PAD.encode(v.to_string());
131        let jwt = |exp: i64| {
132            format!(
133                "{}.{}.sig",
134                enc(json!({"alg": "ES256"})),
135                enc(json!({"exp": exp}))
136            )
137        };
138        assert_eq!(remaining_secs(&jwt(1_100), 1_000, 30), Some(70));
139        assert_eq!(remaining_secs(&jwt(1_020), 1_000, 30), None);
140        assert_eq!(remaining_secs("garbage", 1_000, 30), None);
141    }
142}