ubl_auth/
lib.rs

1
2#![forbid(unsafe_code)]
3
4/// Re-export json_atomic for LLM-first canonical JSON serialization.
5pub use json_atomic;
6
7use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64URL, Engine as _};
8use ed25519_dalek::{VerifyingKey, Signature};
9use once_cell::sync::Lazy;
10use parking_lot::Mutex;
11use serde::{Deserialize, Serialize};
12use serde_json::Value as Json;
13use std::{collections::HashMap, time::{SystemTime, UNIX_EPOCH}};
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Claims {
17    pub sub: String,
18    #[serde(default)]
19    pub iss: Option<String>,
20    #[serde(default)]
21    pub aud: Option<Aud>,
22    #[serde(default)]
23    pub exp: Option<i64>,
24    #[serde(default)]
25    pub nbf: Option<i64>,
26    #[serde(default)]
27    pub iat: Option<i64>,
28    #[serde(default)]
29    pub jti: Option<String>,
30    #[serde(default)]
31    pub scope: Option<String>,
32    #[serde(flatten)]
33    pub extra: HashMap<String, Json>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(untagged)]
38pub enum Aud {
39    One(String),
40    Many(Vec<String>),
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct VerifyOptions {
45    pub leeway_secs: i64,
46    pub issuer: Option<String>,
47    pub audience: Option<String>,
48    pub now: Option<i64>,
49}
50impl Default for VerifyOptions {
51    fn default() -> Self {
52        Self { leeway_secs: 300, issuer: None, audience: None, now: None }
53    }
54}
55impl VerifyOptions {
56    pub fn with_issuer(mut self, iss: &str) -> Self { self.issuer = Some(iss.to_string()); self }
57    pub fn with_audience(mut self, aud: &str) -> Self { self.audience = Some(aud.to_string()); self }
58    pub fn with_leeway(mut self, secs: i64) -> Self { self.leeway_secs = secs; self }
59    pub fn with_now(mut self, now: i64) -> Self { self.now = Some(now); self }
60}
61
62#[derive(Debug, thiserror::Error)]
63pub enum VerifyError {
64    #[error("bad token format")]
65    BadFormat,
66    #[error("base64 decode failed")]
67    Base64,
68    #[error("json parse failed")]
69    Json,
70    #[error("alg not allowed (expected EdDSA)")]
71    Alg,
72    #[error("missing kid in JWT header")]
73    Kid,
74    #[error("jwks http error: {0}")]
75    JwksHttp(String),
76    #[error("jwks parse error")]
77    JwksJson,
78    #[error("no matching key for kid")]
79    NoKey,
80    #[error("invalid signature")]
81    Signature,
82    #[error("claim 'exp' expired")]
83    Expired,
84    #[error("claim 'nbf' in future")]
85    NotYetValid,
86    #[error("issuer mismatch")]
87    Issuer,
88    #[error("audience mismatch")]
89    Audience,
90    #[error("missing sub")]
91    MissingSub,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct Jwk { pub kty:String, #[serde(default)] pub crv:Option<String>, #[serde(default)] pub x:Option<String>, #[serde(default)] pub kid:Option<String> }
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct Jwks { pub keys: Vec<Jwk> }
98
99#[derive(Debug, Clone)]
100pub struct JwksCacheEntry { pub jwks: Jwks, pub fetched_at: i64 }
101#[derive(Debug)]
102pub struct JwksCache { ttl_secs: i64, inner: Mutex<HashMap<String, JwksCacheEntry>> }
103
104static GLOBAL_JWKS: Lazy<JwksCache> = Lazy::new(|| JwksCache::new(300));
105
106impl JwksCache {
107    pub fn new(ttl_secs: i64) -> Self { Self { ttl_secs, inner: Mutex::new(HashMap::new()) } }
108    pub fn put(&self, uri: &str, jwks: Jwks) {
109        let mut m = self.inner.lock();
110        m.insert(uri.to_string(), JwksCacheEntry{ jwks, fetched_at: now_ts() });
111    }
112    pub fn get_fresh(&self, uri: &str) -> Option<Jwks> {
113        let m = self.inner.lock();
114        if let Some(entry) = m.get(uri) {
115            if now_ts() - entry.fetched_at <= self.ttl_secs {
116                return Some(entry.jwks.clone());
117            }
118        }
119        None
120    }
121}
122
123pub fn verify_ed25519_jwt_with_jwks(token: &str, jwks_uri: &str, opts: &VerifyOptions) -> Result<Claims, VerifyError> {
124    verify_ed25519_jwt_with_cache(token, jwks_uri, &GLOBAL_JWKS, opts)
125}
126
127pub fn verify_ed25519_jwt_with_cache(token: &str, jwks_uri: &str, cache: &JwksCache, opts: &VerifyOptions) -> Result<Claims, VerifyError> {
128    let (header, payload, sig, signing_input) = split_and_decode(token)?;
129
130    let alg = header.get("alg").and_then(|v| v.as_str()).ok_or(VerifyError::Alg)?;
131    if alg != "EdDSA" { return Err(VerifyError::Alg); }
132    let kid = header.get("kid").and_then(|v| v.as_str()).ok_or(VerifyError::Kid)?;
133
134    let jwks = if let Some(j) = cache.get_fresh(jwks_uri) { j } else {
135        let fetched = fetch_jwks(jwks_uri)?;
136        cache.put(jwks_uri, fetched.clone());
137        fetched
138    };
139    let vk = key_by_kid(&jwks, kid).ok_or(VerifyError::NoKey)?;
140
141    vk.verify_strict(signing_input.as_bytes(), &sig).map_err(|_| VerifyError::Signature)?;
142
143    let claims: Claims = serde_json::from_value(payload).map_err(|_| VerifyError::Json)?;
144    check_claims(&claims, opts)?;
145    Ok(claims)
146}
147
148fn split_and_decode(token: &str) -> Result<(Json, Json, Signature, String), VerifyError> {
149    let parts: Vec<&str> = token.split('.').collect();
150    if parts.len() != 3 { return Err(VerifyError::BadFormat); }
151    let header_json = String::from_utf8(B64URL.decode(parts[0].as_bytes()).map_err(|_| VerifyError::Base64)?).map_err(|_| VerifyError::Base64)?;
152    let payload_json = String::from_utf8(B64URL.decode(parts[1].as_bytes()).map_err(|_| VerifyError::Base64)?).map_err(|_| VerifyError::Base64)?;
153    let sig_bytes = B64URL.decode(parts[2].as_bytes()).map_err(|_| VerifyError::Base64)?;
154    let sig = Signature::from_bytes(sig_bytes[..].try_into().map_err(|_| VerifyError::Signature)?);
155    let header: Json = serde_json::from_str(&header_json).map_err(|_| VerifyError::Json)?;
156    let payload: Json = serde_json::from_str(&payload_json).map_err(|_| VerifyError::Json)?;
157    Ok((header, payload, sig, format!("{}.{}", parts[0], parts[1])))
158}
159
160fn fetch_jwks(uri: &str) -> Result<Jwks, VerifyError> {
161    let resp = ureq::get(uri).call().map_err(|e| VerifyError::JwksHttp(e.to_string()))?;
162    let body = resp.into_string().map_err(|e| VerifyError::JwksHttp(e.to_string()))?;
163    serde_json::from_str(&body).map_err(|_| VerifyError::JwksJson)
164}
165
166fn key_by_kid(jwks: &Jwks, kid: &str) -> Option<VerifyingKey> {
167    for k in &jwks.keys {
168        if k.kty != "OKP" { continue; }
169        if k.crv.as_deref() != Some("Ed25519") { continue; }
170        let k_kid = k.kid.as_deref().unwrap_or_default();
171        if k_kid == kid || k_kid.is_empty() {
172            if let Some(x) = &k.x {
173                if let Ok(bytes) = B64URL.decode(x.as_bytes()) {
174                    if let Ok(vk) = VerifyingKey::from_bytes(bytes[..].try_into().ok()?) {
175                        return Some(vk);
176                    }
177                }
178            }
179        }
180    }
181    None
182}
183
184pub fn now_ts() -> i64 {
185    let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
186    now
187}
188
189fn check_claims(c: &Claims, opts: &VerifyOptions) -> Result<(), VerifyError> {
190    let now = opts.now.unwrap_or_else(now_ts);
191    if c.sub.is_empty() { return Err(VerifyError::MissingSub); }
192    if let Some(exp) = c.exp {
193        if now > exp + opts.leeway_secs { return Err(VerifyError::Expired); }
194    }
195    if let Some(nbf) = c.nbf {
196        if now + opts.leeway_secs < nbf { return Err(VerifyError::NotYetValid); }
197    }
198    if let Some(iat) = c.iat {
199        if iat > now + opts.leeway_secs { return Err(VerifyError::NotYetValid); }
200    }
201    if let Some(ref iss) = opts.issuer {
202        if c.iss.as_deref() != Some(iss) { return Err(VerifyError::Issuer); }
203    }
204    if let Some(ref aud) = opts.audience {
205        match &c.aud {
206            None => return Err(VerifyError::Audience),
207            Some(Aud::One(s)) if s != aud => return Err(VerifyError::Audience),
208            Some(Aud::Many(v)) if !v.iter().any(|x| x == aud) => return Err(VerifyError::Audience),
209            _ => {}
210        }
211    }
212    Ok(())
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use rand::{SeedableRng, rngs::StdRng};
219    use ed25519_dalek::{SigningKey, Signer};
220    use serde_json::json;
221    use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL;
222    use json_atomic::canonize;
223
224    #[test]
225    fn roundtrip_sign_and_verify_with_cache() {
226        let mut rng = StdRng::seed_from_u64(42);
227        let sk = SigningKey::generate(&mut rng);
228        let vk = sk.verifying_key();
229        let x = B64URL.encode(vk.to_bytes());
230
231        let cache = JwksCache::new(3600);
232        cache.put("mem://jwks", Jwks{ keys: vec![ Jwk{ kty:"OKP".into(), crv:Some("Ed25519".into()), x:Some(x), kid:Some("test".into()) } ]});
233
234        let header = json!({"alg":"EdDSA","kid":"test","typ":"JWT"});
235        let now = now_ts();
236        let payload = json!({
237            "sub":"did:key:zTest",
238            "iss":"https://id.ubl.agency",
239            "aud":"demo",
240            "iat": now,
241            "nbf": now - 5,
242            "exp": now + 3600
243        });
244        let hdr = B64URL.encode(canonize(&header).unwrap());
245        let pld = B64URL.encode(canonize(&payload).unwrap());
246        let msg = format!("{}.{}", hdr, pld);
247        let sig = sk.sign(msg.as_bytes());
248        let jwt = format!("{}.{}", msg, B64URL.encode(sig.to_bytes()));
249
250        let opts = VerifyOptions::default().with_issuer("https://id.ubl.agency").with_audience("demo");
251        let claims = verify_ed25519_jwt_with_cache(&jwt, "mem://jwks", &cache, &opts).expect("verify");
252        assert_eq!(claims.sub, "did:key:zTest");
253    }
254}