Skip to main content

better_auth_api/plugins/jwt/
verification.rs

1use super::*;
2use base64::{
3    Engine as _, alphabet,
4    engine::{
5        DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig, general_purpose::URL_SAFE_NO_PAD,
6    },
7};
8use josekit::jws::JwsVerifier;
9
10impl JwtPlugin {
11    /// Verify a token with adapter keys. Invalid tokens and verification adapter errors return `None`.
12    ///
13    /// The optional issuer overrides the configured issuer. Like upstream `verifyJWT`,
14    /// this reads local adapter keys even when `remote_url` is configured.
15    pub async fn verify<S: AuthSchema>(
16        &self,
17        token: &str,
18        issuer: Option<&str>,
19        ctx: &AuthContext<S>,
20    ) -> AuthResult<Option<Map<String, Value>>> {
21        ctx.with_native_context(Default::default(), |resolved| async move {
22            let body = verification_body(token, issuer);
23            let mut endpoint = EndpointContext::new(
24                None,
25                better_auth_core::FieldValue::from_json(body)?,
26                &resolved,
27            );
28            endpoint.path = Some("virtual:");
29            self.verify_in_endpoint(token, issuer, &endpoint).await
30        })
31        .await
32    }
33
34    /// Verify with the supplied endpoint context and its key-adapter callbacks.
35    pub async fn verify_in_endpoint<S: AuthSchema>(
36        &self,
37        token: &str,
38        issuer: Option<&str>,
39        endpoint: &EndpointContext<'_, S>,
40    ) -> AuthResult<Option<Map<String, Value>>> {
41        if !has_key_id(token) {
42            return Ok(None);
43        }
44        let keys = match self.read_keys(endpoint).await {
45            Ok(Some(keys)) => keys,
46            Ok(None) => return Ok(None),
47            Err(error) => {
48                better_auth_core::observability::logger::current().debug(
49                    "JWT verification failed",
50                    &[better_auth_core::observability::LogArgument::Error(&error)],
51                );
52                return Ok(None);
53            }
54        };
55        let ctx = endpoint.auth;
56        let issuer = issuer
57            .filter(|issuer| !issuer.is_empty())
58            .or(self.config.issuer.as_deref())
59            .or(ctx.config.base_url.as_static());
60        let audience = self
61            .config
62            .audience
63            .as_ref()
64            .map(JwtAudience::recipients)
65            .or_else(|| ctx.config.base_url.as_static().map(|base| vec![base]));
66        let verified = verify_local(
67            token,
68            &keys,
69            self.config.algorithm,
70            issuer,
71            audience.as_deref(),
72            0,
73        );
74        Ok(verified.filter(|payload| {
75            ["sub", "aud"].iter().all(|claim| {
76                payload
77                    .get(*claim)
78                    .is_some_and(crate::plugins::json_body::is_truthy)
79            })
80        }))
81    }
82}
83
84pub(super) fn verification_body(token: &str, issuer: Option<&str>) -> Value {
85    let mut body = Map::from_iter([("token".into(), token.into())]);
86    if let Some(issuer) = issuer {
87        let _ = body.insert("issuer".into(), issuer.into());
88    }
89    body.into()
90}
91
92pub(super) fn has_key_id(token: &str) -> bool {
93    raw_header(token).is_some_and(|header| header.has_key_id())
94}
95
96#[serde_with::serde_as]
97#[derive(serde::Deserialize)]
98#[serde(transparent)]
99pub(super) struct Header(
100    // JSON property names may contain unpaired UTF-16 surrogates; serde's byte strings retain them.
101    #[serde_as(as = "std::collections::HashMap<serde_with::Bytes, _>")]
102    std::collections::HashMap<Vec<u8>, Box<serde_json::value::RawValue>>,
103);
104
105impl Header {
106    pub(super) fn has_key_id(&self) -> bool {
107        self.0
108            .get(b"kid".as_slice())
109            .is_some_and(|kid| match kid.get().as_bytes().first() {
110                Some(b'"') => kid.get() != "\"\"",
111                Some(b'[' | b'{') => true,
112                _ => serde_json::from_str::<Value>(kid.get())
113                    .is_ok_and(|value| crate::plugins::json_body::is_truthy(&value)),
114            })
115    }
116
117    pub(super) fn has_type(&self, expected: &str) -> bool {
118        self.0.get(b"typ".as_slice()).is_some_and(|typ| {
119            serde_json::from_str::<String>(typ.get()).is_ok_and(|typ| typ == expected)
120        })
121    }
122}
123
124pub(super) fn raw_header(token: &str) -> Option<Header> {
125    if token.split('.').count() != 3 {
126        return None;
127    }
128    let encoded = token.split('.').next()?;
129    let alphabet = if encoded.contains(['-', '_']) {
130        &alphabet::URL_SAFE
131    } else {
132        &alphabet::STANDARD
133    };
134    let engine = GeneralPurpose::new(
135        alphabet,
136        GeneralPurposeConfig::new()
137            .with_decode_allow_trailing_bits(true)
138            .with_decode_padding_mode(DecodePaddingMode::Indifferent),
139    );
140    let encoded = encoded.split('=').next()?;
141    // The upstream decoder validates incomplete sextets, discards their bits, and stops at padding.
142    let encoded = if encoded.len() % 4 == 1 {
143        let _ = engine
144            .decode([*encoded.as_bytes().last()?, b'A', b'A', b'A'])
145            .ok()?;
146        encoded.get(..encoded.len() - 1)?
147    } else {
148        encoded
149    };
150    let bytes = engine.decode(encoded).ok()?;
151    let text = String::from_utf8_lossy(&bytes);
152    serde_json::from_str(text.strip_prefix('\u{feff}').unwrap_or(&text)).ok()
153}
154
155pub(super) fn protected_header(token: &str) -> Option<JwsHeader> {
156    let bytes = URL_SAFE_NO_PAD.decode(token.split('.').next()?).ok()?;
157    JwsHeader::from_bytes(&bytes).ok()
158}
159
160pub(super) fn verify_local(
161    token: &str,
162    keys: &[better_auth_core::Jwk],
163    default_algorithm: JwtAlgorithm,
164    issuer: Option<&str>,
165    audience: Option<&[&str]>,
166    tolerance: i64,
167) -> Option<Map<String, Value>> {
168    let header = protected_header(token)?;
169    let key = keys.iter().find(|key| key.id.as_str() == header.key_id())?;
170    let algorithm = key.alg.as_deref().unwrap_or(default_algorithm.name());
171    if header.algorithm() != Some(algorithm) {
172        return None;
173    }
174    let public = Jwk::from_bytes(&key.public_key).ok()?;
175    let verifier: Box<dyn JwsVerifier> = match algorithm {
176        "EdDSA" => Box::new(jws::EdDSA.verifier_from_jwk(&public).ok()?),
177        "RS256" => Box::new(jws::RS256.verifier_from_jwk(&public).ok()?),
178        "ES256" => Box::new(jws::ES256.verifier_from_jwk(&public).ok()?),
179        "ES512" => Box::new(jws::ES512.verifier_from_jwk(&public).ok()?),
180        "PS256" => Box::new(jws::PS256.verifier_from_jwk(&public).ok()?),
181        _ => return None,
182    };
183    // The JWT payload parser rejects claim types that upstream verification accepts.
184    let (payload, _) = josekit::jws::deserialize_compact(token, verifier.as_ref()).ok()?;
185    let claims: Map<String, Value> = serde_json::from_slice(&payload).ok()?;
186    if issuer.is_some_and(|issuer| claims.get("iss").and_then(Value::as_str) != Some(issuer)) {
187        return None;
188    }
189    if let Some(audience) = audience {
190        let aud = claims.get("aud")?;
191        if !aud.as_str().is_some_and(|value| audience.contains(&value))
192            && !aud.as_array().is_some_and(|values| {
193                values.iter().any(|value| {
194                    value
195                        .as_str()
196                        .is_some_and(|value| audience.contains(&value))
197                })
198            })
199        {
200            return None;
201        }
202    }
203    let now = Utc::now().timestamp() as f64;
204    for field in ["iat", "exp", "nbf"] {
205        if let Some(value) = claims.get(field) {
206            let value = value.as_f64()?;
207            if (field == "exp" && value <= now - tolerance as f64)
208                || (field == "nbf" && value > now + tolerance as f64)
209            {
210                return None;
211            }
212        }
213    }
214    Some(claims)
215}