better_auth_api/plugins/jwt/
verification.rs1use 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 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 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 #[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 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 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}