Skip to main content

alibi_api/plugins/jwt/
verification.rs

1use super::*;
2impl JwtPlugin {
3    /// Verify a token against the persisted keyring and configured claims.
4    /// Invalid signatures, malformed tokens and claim failures return `None`.
5    ///
6    /// # Errors
7    ///
8    /// Returns an error if key loading, signature verification, or claim validation fails.
9    pub async fn verify_jwt(
10        &self,
11        token: &str,
12        issuer: Option<&str>,
13        request: Option<&AuthRequest>,
14        ctx: &AuthContext<impl AuthSchema>,
15    ) -> AuthResult<Option<Map<String, Value>>> {
16        let claims = self.config.claims.clone();
17        let issuer = issuer
18            .filter(|issuer| !issuer.is_empty())
19            .or(claims.issuer.as_deref())
20            .unwrap_or(&ctx.config.base_url);
21        let audience = claims
22            .audience
23            .unwrap_or_else(|| JwtAudience::One(ctx.config.base_url.clone()));
24        let policy = JwtVerifyPolicy {
25            issuer,
26            audience: &audience,
27            tolerance: 0,
28            require_nonempty_subject: true,
29        };
30        Ok(self
31            .verify_internal(token, &policy, request, ctx)
32            .await
33            .unwrap_or(None))
34    }
35
36    #[expect(
37        clippy::as_conversions,
38        clippy::cast_precision_loss,
39        reason = "JavaScript-compatible numbers deliberately retain IEEE754 rounding and guarded integer coercion at the wire or adapter boundary"
40    )]
41    pub(in crate::plugins::jwt) async fn verify_internal(
42        &self,
43        token: &str,
44        policy: &JwtVerifyPolicy<'_>,
45        request: Option<&AuthRequest>,
46        ctx: &AuthContext<impl AuthSchema>,
47    ) -> AuthResult<Option<Map<String, Value>>> {
48        let parts = token.split('.').collect::<Vec<_>>();
49        let [header, payload, signature] = parts.as_slice() else {
50            return Ok(None);
51        };
52        let header = decode_compact_json(header, false)?;
53        let Some(header_object) = header.as_object() else {
54            return Ok(None);
55        };
56        if validate_critical_header(header_object, false).is_err() {
57            return Ok(None);
58        }
59        let Some(kid) = header
60            .get("kid")
61            .and_then(Value::as_str)
62            .filter(|value| !value.is_empty())
63        else {
64            return Ok(None);
65        };
66        let Some(key) = self
67            .keys_at_path(request, "virtual:", ctx)
68            .await?
69            .into_iter()
70            .find(|key| key.id == kid)
71        else {
72            return Ok(None);
73        };
74        let algorithm = key
75            .alg
76            .as_deref()
77            .map(JwtAlgorithm::from_str)
78            .unwrap_or(Ok(self.config.key_pair.algorithm))?;
79        if header.get("alg").and_then(Value::as_str) != Some(algorithm.as_str()) {
80            return Ok(None);
81        }
82        let public: Value = serde_json::from_str(&key.public_key)?;
83        let input = format!("{}.{}", parts.first().copied().unwrap_or_default(), payload);
84        if !crypto::verify(
85            algorithm,
86            &public,
87            input.as_bytes(),
88            &decode_compact_part(signature, true)?,
89        )? {
90            return Ok(None);
91        }
92        let Some(payload) = decode_compact_json(payload, true)?.as_object().cloned() else {
93            return Ok(None);
94        };
95        let now = Utc::now().timestamp();
96        for field in ["iat", "exp", "nbf"] {
97            if payload.get(field).is_some_and(|value| !value.is_number()) {
98                return Ok(None);
99            }
100        }
101        if payload
102            .get("exp")
103            .and_then(Value::as_f64)
104            .is_some_and(|exp| exp <= (now - policy.tolerance) as f64)
105            || payload
106                .get("nbf")
107                .and_then(Value::as_f64)
108                .is_some_and(|nbf| nbf > (now + policy.tolerance) as f64)
109            || payload.get("iss").and_then(Value::as_str) != Some(policy.issuer)
110            || !payload
111                .get("aud")
112                .is_some_and(|value| policy.audience.matches(value))
113            || payload.get("aud").is_none_or(|value| !js_truthy(value))
114            || (policy.require_nonempty_subject
115                && payload.get("sub").is_none_or(|value| !js_truthy(value)))
116        {
117            return Ok(None);
118        }
119        Ok(Some(payload))
120    }
121}