Skip to main content

silicon_accounts_client/
jwt.rs

1//! Local verification of Silicon Accounts access tokens (EdDSA / Ed25519 JWTs).
2
3use jsonwebtoken::errors::ErrorKind;
4use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header};
5use serde::de::{self, Deserializer};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9use crate::error::{Result, TokenError};
10use crate::types::{AccountKind, Jwks};
11
12/// The claims of a Silicon Accounts access token.
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14#[non_exhaustive]
15pub struct Claims {
16    /// Issuer: the Silicon Accounts public URL.
17    #[serde(default)]
18    pub iss: String,
19    /// The account uuid.
20    pub sub: String,
21    /// The app id(s) the token was issued to.
22    #[serde(deserialize_with = "one_or_many")]
23    pub aud: Vec<String>,
24    /// Expiry (unix seconds).
25    pub exp: i64,
26    /// Issued at (unix seconds).
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub iat: Option<i64>,
29    /// Not before (unix seconds).
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub nbf: Option<i64>,
32    /// Token id.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub jti: Option<String>,
35    /// Carbon or Silicon.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub kind: Option<AccountKind>,
38    /// The account's public id when the token was issued (it may have changed since).
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub id: Option<String>,
41    /// Membership id (`{app_id}:{uuid}`).
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub mid: Option<String>,
44    /// Token family id.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub fid: Option<String>,
47    /// Space-separated scopes.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub scope: Option<String>,
50}
51
52impl Claims {
53    /// Scopes as a list.
54    pub fn scopes(&self) -> Vec<&str> {
55        self.scope
56            .as_deref()
57            .map(|s| s.split_whitespace().collect())
58            .unwrap_or_default()
59    }
60
61    /// True when the token carries `scope`.
62    pub fn has_scope(&self, scope: &str) -> bool {
63        self.scopes().contains(&scope)
64    }
65}
66
67fn one_or_many<'de, D: Deserializer<'de>>(
68    deserializer: D,
69) -> std::result::Result<Vec<String>, D::Error> {
70    match Value::deserialize(deserializer)? {
71        Value::String(s) => Ok(vec![s]),
72        Value::Array(items) => items
73            .into_iter()
74            .map(|v| match v {
75                Value::String(s) => Ok(s),
76                _ => Err(de::Error::custom("aud entries must be strings")),
77            })
78            .collect(),
79        _ => Err(de::Error::custom(
80            "aud must be a string or a list of strings",
81        )),
82    }
83}
84
85/// What to check when verifying an access token locally.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct VerifyOptions {
88    /// Accepted audiences (your app id). Must not be empty.
89    pub audiences: Vec<String>,
90    /// Accepted issuer (the Silicon Accounts public URL), when you want it checked.
91    pub issuer: Option<String>,
92    /// Allowed clock skew in seconds (default 30).
93    pub leeway_seconds: u64,
94}
95
96impl VerifyOptions {
97    /// Accept tokens issued to `app_id`.
98    pub fn for_app(app_id: &str) -> Self {
99        Self {
100            audiences: vec![app_id.to_owned()],
101            issuer: None,
102            leeway_seconds: 30,
103        }
104    }
105
106    /// Also require this issuer.
107    pub fn with_issuer(mut self, issuer: impl Into<String>) -> Self {
108        self.issuer = Some(issuer.into());
109        self
110    }
111}
112
113/// Verifies an access token with a JWKS (from [`crate::AccountsClient::jwks`]): EdDSA
114/// signature by a key in the set, `exp`/`nbf`, `aud` and (optionally) `iss`.
115///
116/// This cannot see revocation (signed-out accounts, removed access); tokens live at most
117/// 30 minutes. Call `introspect` when you must know about revocation immediately.
118pub fn verify_access_token(jwks: &Jwks, token: &str, options: &VerifyOptions) -> Result<Claims> {
119    let token = token.trim();
120    let header = decode_header(token).map_err(|e| TokenError::Malformed(describe(e.kind())))?;
121    if header.alg != Algorithm::EdDSA {
122        return Err(TokenError::UnsupportedAlgorithm(format!("{:?}", header.alg)).into());
123    }
124    let jwk = match header.kid.as_deref() {
125        Some(kid) => jwks.find(kid).ok_or_else(|| TokenError::UnknownKey {
126            kid: Some(kid.to_owned()),
127        })?,
128        None if jwks.keys.len() == 1 => &jwks.keys[0],
129        None => return Err(TokenError::UnknownKey { kid: None }.into()),
130    };
131    if jwk.kty != "OKP" || jwk.crv.as_deref() != Some("Ed25519") {
132        return Err(TokenError::InvalidKey(format!(
133            "it has kty `{}` and crv `{}`, expected OKP / Ed25519",
134            jwk.kty,
135            jwk.crv.as_deref().unwrap_or("none")
136        ))
137        .into());
138    }
139    let x = jwk
140        .x
141        .as_deref()
142        .ok_or_else(|| TokenError::InvalidKey("it has no `x` value".to_owned()))?;
143    // jsonwebtoken slices the key to 32 bytes without checking; check first so a short key
144    // is an error, not a panic.
145    let raw = base64::Engine::decode(
146        &base64::engine::general_purpose::URL_SAFE_NO_PAD,
147        x.trim_end_matches('='),
148    )
149    .map_err(|e| TokenError::InvalidKey(format!("`x` is not base64url ({e})")))?;
150    if raw.len() != 32 {
151        return Err(TokenError::InvalidKey(format!(
152            "`x` is {} bytes, an Ed25519 public key is 32",
153            raw.len()
154        ))
155        .into());
156    }
157    let key = DecodingKey::from_ed_components(x)
158        .map_err(|e| TokenError::InvalidKey(describe(e.kind())))?;
159
160    if options.audiences.is_empty() {
161        return Err(crate::Error::invalid_input(
162            "Refusing to verify an access token without an expected audience: any app's token would pass.",
163            "Pass your app id as the audience (VerifyOptions::for_app).",
164        ));
165    }
166    let mut validation = Validation::new(Algorithm::EdDSA);
167    validation.leeway = options.leeway_seconds;
168    validation.validate_nbf = true;
169    validation.set_audience(&options.audiences);
170    let mut required = vec!["exp", "sub", "aud"];
171    if let Some(issuer) = &options.issuer {
172        validation.set_issuer(&[issuer]);
173        required.push("iss");
174    }
175    validation.set_required_spec_claims(&required);
176
177    match decode::<Claims>(token, &key, &validation) {
178        Ok(data) => Ok(data.claims),
179        Err(e) => Err(match e.kind() {
180            ErrorKind::InvalidSignature => TokenError::BadSignature,
181            ErrorKind::ExpiredSignature => TokenError::Expired {
182                exp: unverified_exp(token),
183            },
184            ErrorKind::ImmatureSignature => TokenError::NotYetValid,
185            ErrorKind::InvalidAudience => TokenError::WrongAudience {
186                expected: options.audiences.clone(),
187            },
188            ErrorKind::InvalidIssuer => TokenError::WrongIssuer {
189                expected: options.issuer.clone().unwrap_or_default(),
190            },
191            ErrorKind::MissingRequiredClaim(claim) | ErrorKind::InvalidClaimFormat(claim) => {
192                TokenError::MissingClaim(claim.clone())
193            }
194            ErrorKind::InvalidAlgorithm => {
195                TokenError::UnsupportedAlgorithm("a non-EdDSA algorithm".to_owned())
196            }
197            ErrorKind::InvalidEddsaKey | ErrorKind::InvalidKeyFormat => {
198                TokenError::InvalidKey(describe(e.kind()))
199            }
200            other => TokenError::Malformed(describe(other)),
201        }
202        .into()),
203    }
204}
205
206/// Reads `exp` without verifying anything, only to make an expiry message precise.
207fn unverified_exp(token: &str) -> Option<i64> {
208    let payload = token.split('.').nth(1)?;
209    let bytes =
210        base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, payload).ok()?;
211    serde_json::from_slice::<Value>(&bytes)
212        .ok()?
213        .get("exp")?
214        .as_i64()
215}
216
217fn describe(kind: &ErrorKind) -> String {
218    match kind {
219        ErrorKind::InvalidToken => {
220            "it does not have three base64url parts separated by dots".to_owned()
221        }
222        ErrorKind::Base64(e) => format!("a part is not valid base64url ({e})"),
223        ErrorKind::Json(e) => format!("a part is not valid JSON ({e})"),
224        ErrorKind::Utf8(e) => format!("a part is not valid UTF-8 ({e})"),
225        ErrorKind::InvalidEddsaKey => "the key is not a 32-byte Ed25519 public key".to_owned(),
226        ErrorKind::InvalidKeyFormat => "the key has an invalid format".to_owned(),
227        other => format!("{other:?}"),
228    }
229}
230
231#[cfg(test)]
232#[allow(clippy::unwrap_used)]
233mod tests {
234    use base64::Engine;
235    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
236    use ed25519_dalek::SigningKey;
237    use ed25519_dalek::pkcs8::EncodePrivateKey;
238    use jsonwebtoken::{EncodingKey, Header, encode};
239    use serde_json::json;
240
241    use super::*;
242    use crate::error::Error;
243    use crate::types::Jwk;
244
245    fn keypair(seed: u8) -> (EncodingKey, Jwk) {
246        let signing = SigningKey::from_bytes(&[seed; 32]);
247        let der = signing.to_pkcs8_der().unwrap();
248        let encoding = EncodingKey::from_ed_der(der.as_bytes());
249        let x = URL_SAFE_NO_PAD.encode(signing.verifying_key().as_bytes());
250        (encoding, Jwk::ed25519(format!("k{seed}"), x))
251    }
252
253    fn token(key: &EncodingKey, kid: &str, claims: &Value) -> String {
254        let mut header = Header::new(Algorithm::EdDSA);
255        header.kid = Some(kid.to_owned());
256        encode(&header, claims, key).unwrap()
257    }
258
259    fn claims(aud: &str, exp_offset: i64) -> Value {
260        let now = time::OffsetDateTime::now_utc().unix_timestamp();
261        json!({
262            "iss": "http://127.0.0.1:8590", "sub": "a8K", "aud": aud, "exp": now + exp_offset, "iat": now,
263            "nbf": now, "jti": "j1", "kind": "silicon", "id": "si:scout", "mid": "remind:a8K", "fid": "f1",
264            "scope": "profile timezone"
265        })
266    }
267
268    #[test]
269    fn verifies_a_valid_token() {
270        let (key, jwk) = keypair(7);
271        let jwks = Jwks::new(vec![jwk]);
272        let t = token(&key, "k7", &claims("remind", 600));
273        let verified = verify_access_token(
274            &jwks,
275            &t,
276            &VerifyOptions::for_app("remind").with_issuer("http://127.0.0.1:8590"),
277        )
278        .unwrap();
279        assert_eq!(verified.sub, "a8K");
280        assert_eq!(verified.aud, vec!["remind".to_owned()]);
281        assert_eq!(verified.kind, Some(AccountKind::Silicon));
282        assert!(verified.has_scope("timezone"));
283    }
284
285    #[test]
286    fn rejects_wrong_audience_expiry_unknown_key_and_tampering() {
287        let (key, jwk) = keypair(7);
288        let (other_key, _) = keypair(9);
289        let jwks = Jwks::new(vec![jwk]);
290        let options = VerifyOptions::for_app("remind");
291
292        let wrong_aud = token(&key, "k7", &claims("briefcase", 600));
293        let err = verify_access_token(&jwks, &wrong_aud, &options).unwrap_err();
294        assert!(
295            matches!(err, Error::Token(TokenError::WrongAudience { .. })),
296            "{err}"
297        );
298
299        let expired = token(&key, "k7", &claims("remind", -3600));
300        let err = verify_access_token(&jwks, &expired, &options).unwrap_err();
301        assert!(
302            matches!(err, Error::Token(TokenError::Expired { exp: Some(_) })),
303            "{err}"
304        );
305
306        let unknown = token(&other_key, "k9", &claims("remind", 600));
307        let err = verify_access_token(&jwks, &unknown, &options).unwrap_err();
308        assert!(
309            matches!(err, Error::Token(TokenError::UnknownKey { .. })),
310            "{err}"
311        );
312
313        let forged = token(&other_key, "k7", &claims("remind", 600));
314        let err = verify_access_token(&jwks, &forged, &options).unwrap_err();
315        assert!(
316            matches!(err, Error::Token(TokenError::BadSignature)),
317            "{err}"
318        );
319
320        let err = verify_access_token(&jwks, "not-a-jwt", &options).unwrap_err();
321        assert!(
322            matches!(err, Error::Token(TokenError::Malformed(_))),
323            "{err}"
324        );
325        assert_eq!(err.code(), "token_malformed");
326
327        let err = verify_access_token(
328            &jwks,
329            &wrong_aud,
330            &VerifyOptions {
331                audiences: vec![],
332                issuer: None,
333                leeway_seconds: 0,
334            },
335        )
336        .unwrap_err();
337        assert_eq!(err.code(), "invalid_input");
338    }
339}