1use super::providers::OAuthProvider;
3use alibi_core::utils::json::{JsValue, parse_value};
4use async_trait::async_trait;
5use base64::Engine;
6use chrono::Utc;
7use jsonwebtoken::{Algorithm, DecodingKey};
8use serde_json::Value;
9use sha2::{Digest, Sha256};
10use std::sync::Arc;
11
12#[async_trait]
14pub trait OAuthJwksSource: Send + Sync {
15 async fn fetch_keys(&self) -> Result<Vec<Value>, String>;
16}
17
18pub trait OAuthIdTokenClaimsVerifier: Send + Sync {
21 fn verify_claims(&self, claims: &JsValue) -> bool;
22}
23
24pub struct HttpOAuthJwksSource {
26 url: String,
27 client: reqwest::Client,
28}
29impl HttpOAuthJwksSource {
30 #[must_use]
31 pub fn new(url: impl Into<String>) -> Self {
32 Self {
33 url: url.into(),
34 client: reqwest::Client::new(),
35 }
36 }
37}
38#[async_trait]
39impl OAuthJwksSource for HttpOAuthJwksSource {
40 async fn fetch_keys(&self) -> Result<Vec<Value>, String> {
41 let bytes = self
42 .client
43 .get(&self.url)
44 .send()
45 .await
46 .map_err(|error| error.to_string())?
47 .error_for_status()
48 .map_err(|error| error.to_string())?
49 .bytes()
50 .await
51 .map_err(|error| error.to_string())?;
52 let data: Value =
53 alibi_core::utils::json::from_slice(&bytes).map_err(|error| error.to_string())?;
54 data.get("keys")
55 .and_then(Value::as_array)
56 .cloned()
57 .ok_or_else(|| "Keys not found".into())
58 }
59}
60
61#[derive(Debug, Clone, Copy)]
62pub enum OAuthJwksSelection {
63 First,
65 AllMatching,
67 ExactKid,
69 RemoteRs256,
71}
72#[derive(Debug, Clone, Copy)]
73pub enum OAuthNonceComparison {
74 Exact,
75 ExactOrSha256,
76}
77
78#[derive(Clone)]
80pub struct OAuthIdTokenConfig {
81 pub issuers: Vec<String>,
82 pub audience: Option<Vec<String>>,
83 pub client_ids: Option<Vec<String>>,
85 pub jwks_source: Arc<dyn OAuthJwksSource>,
86 pub max_age_secs: Option<u64>,
88 pub allow_opaque_token: bool,
91 pub algorithm: Option<Algorithm>,
93 pub algorithms: Option<Vec<Algorithm>>,
96 pub default_key_algorithm: Option<Algorithm>,
98 pub selection: OAuthJwksSelection,
99 pub nonce_comparison: OAuthNonceComparison,
100 pub verify_claims: Option<Arc<dyn OAuthIdTokenClaimsVerifier>>,
103}
104impl OAuthIdTokenConfig {
105 #[must_use]
106 pub fn google() -> Self {
107 Self {
108 issuers: vec![
109 "https://accounts.google.com".into(),
110 "accounts.google.com".into(),
111 ],
112 audience: None,
113 client_ids: None,
114 jwks_source: Arc::new(HttpOAuthJwksSource::new(
115 "https://www.googleapis.com/oauth2/v3/certs",
116 )),
117 max_age_secs: Some(3600),
118 allow_opaque_token: false,
119 algorithm: Some(Algorithm::RS256),
120 algorithms: Some(vec![Algorithm::RS256]),
121 default_key_algorithm: None,
122 selection: OAuthJwksSelection::First,
123 nonce_comparison: OAuthNonceComparison::Exact,
124 verify_claims: None,
125 }
126 }
127 #[must_use]
128 pub fn apple() -> Self {
129 Self {
130 issuers: vec!["https://appleid.apple.com".into()],
131 audience: None,
132 client_ids: None,
133 jwks_source: Arc::new(HttpOAuthJwksSource::new(
134 "https://appleid.apple.com/auth/keys",
135 )),
136 max_age_secs: Some(3600),
137 allow_opaque_token: false,
138 algorithm: None,
139 algorithms: Some(vec![Algorithm::RS256]),
140 default_key_algorithm: None,
141 selection: OAuthJwksSelection::ExactKid,
142 nonce_comparison: OAuthNonceComparison::ExactOrSha256,
143 verify_claims: None,
144 }
145 }
146}
147impl std::fmt::Debug for OAuthIdTokenConfig {
148 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149 f.debug_struct("OAuthIdTokenConfig")
150 .field("issuers", &self.issuers)
151 .field("audience", &self.audience)
152 .field("client_ids", &self.client_ids)
153 .field("max_age_secs", &self.max_age_secs)
154 .field("allow_opaque_token", &self.allow_opaque_token)
155 .field("algorithm", &self.algorithm)
156 .field("algorithms", &self.algorithms)
157 .field("default_key_algorithm", &self.default_key_algorithm)
158 .field("selection", &self.selection)
159 .field("nonce_comparison", &self.nonce_comparison)
160 .field("verify_claims", &self.verify_claims.is_some())
161 .finish_non_exhaustive()
162 }
163}
164
165pub(super) async fn verify_provider_token(
166 provider: &OAuthProvider,
167 token: &str,
168 nonce: Option<&str>,
169) -> bool {
170 if provider.disable_id_token_sign_in {
171 return false;
172 }
173 if let Some(verifier) = &provider.verify_id_token {
174 return verifier
175 .verify_id_token(token, nonce)
176 .await
177 .unwrap_or(false);
178 }
179 let Some(config) = &provider.id_token else {
180 return false;
181 };
182 if token.split('.').count() != 3 {
183 return config.allow_opaque_token;
184 }
185 let mut audiences = vec![provider.client_id.clone()];
186 audiences.extend(provider.additional_client_ids.clone());
187 let audiences = config
188 .audience
189 .as_deref()
190 .or(config.client_ids.as_deref())
191 .unwrap_or(&audiences);
192 if audiences.iter().all(String::is_empty) {
194 return false;
195 }
196 let verified = verify_jwks_token(token, audiences, nonce, config).await;
197 verified.is_some_and(|claims| {
198 hosted_domain_allowed(provider, claims.get("hd").and_then(JsValue::as_str))
199 })
200}
201
202pub(crate) fn hosted_domain_allowed(provider: &OAuthProvider, claim: Option<&str>) -> bool {
203 provider
204 .hosted_domain
205 .as_deref()
206 .filter(|domain| !domain.is_empty())
207 .is_none_or(|domain| {
208 claim.is_some_and(|claim| !claim.is_empty() && (domain == "*" || domain == claim))
209 })
210}
211
212pub(crate) async fn verify_jwks_token(
213 token: &str,
214 audiences: &[String],
215 nonce: Option<&str>,
216 config: &OAuthIdTokenConfig,
217) -> Option<JsValue> {
218 let parts: Vec<_> = token.split('.').collect();
219 let [header_encoded, payload_encoded, signature] = parts.as_slice() else {
220 return None;
221 };
222 let (signed, _) = token.rsplit_once('.')?;
223 let raw_header = base64::engine::general_purpose::URL_SAFE_NO_PAD
224 .decode(header_encoded)
225 .ok()?;
226 let header = parse_value(std::str::from_utf8(&raw_header).ok()?).ok()?;
227 _ = header.as_object()?;
228 let algorithm: Algorithm =
229 serde_json::from_value(header.get("alg")?.to_json_value().ok()?).ok()?;
230 if config
233 .algorithms
234 .as_ref()
235 .is_some_and(|allowed| !allowed.contains(&algorithm))
236 {
237 return None;
238 }
239 if matches!(
240 config.selection,
241 OAuthJwksSelection::AllMatching | OAuthJwksSelection::RemoteRs256
242 ) && config.algorithm.is_some_and(|fixed| fixed != algorithm)
243 {
244 return None;
245 }
246 let keys = config.jwks_source.fetch_keys().await.ok()?;
247 let kid = header.get("kid");
248 let mut selected: Vec<_> = keys
249 .into_iter()
250 .filter(|key| match config.selection {
251 OAuthJwksSelection::ExactKid => match kid {
252 None => key.get("kid").is_none(),
253 Some(kid) => kid.to_json_value().ok().as_ref() == key.get("kid"),
254 },
255 OAuthJwksSelection::RemoteRs256 => remote_rsa_key_matches(key, kid),
256 OAuthJwksSelection::First | OAuthJwksSelection::AllMatching => {
257 kid.filter(|kid| js_truthy(kid)).is_none_or(|kid| {
258 kid.as_str()
259 .is_some_and(|kid| key.get("kid").and_then(Value::as_str) == Some(kid))
260 })
261 }
262 })
263 .collect();
264 if matches!(config.selection, OAuthJwksSelection::RemoteRs256)
265 && (selected.len() != 1 || !selected.first().is_some_and(remote_rsa_public_key))
266 {
267 return None;
268 }
269 if matches!(config.selection, OAuthJwksSelection::ExactKid) {
270 selected.truncate(1);
271 }
272 let mut public_keys = Vec::new();
273 for key in selected {
274 if config.algorithm.is_none() {
275 let imported: Algorithm = match key.get("alg").filter(|alg| !alg.is_null()) {
276 Some(alg) => serde_json::from_value(alg.clone()).ok()?,
277 None => config.default_key_algorithm?,
278 };
279 if imported != algorithm {
280 return None;
281 }
282 }
283 let key: jsonwebtoken::jwk::Jwk = serde_json::from_value(key).ok()?;
284 public_keys.push(DecodingKey::from_jwk(&key).ok()?);
285 }
286 if config.algorithm.is_some_and(|fixed| fixed != algorithm) {
287 return None;
288 }
289 if !matches!(
290 config.selection,
291 OAuthJwksSelection::AllMatching | OAuthJwksSelection::RemoteRs256
292 ) {
293 public_keys.truncate(1);
294 }
295 if let Some(crit) = header.get("crit") {
296 let names = crit.as_array()?;
297 if names.is_empty()
298 || names.iter().any(|name| name.as_str() != Some("b64"))
299 || header.get("b64").and_then(JsValue::as_bool) != Some(true)
300 {
301 return None;
302 }
303 }
304 for key in public_keys {
305 if jsonwebtoken::crypto::verify(signature, signed.as_bytes(), &key, algorithm).ok()
306 == Some(true)
307 {
308 let raw_payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
309 .decode(payload_encoded)
310 .ok()?;
311 let payload = parse_value(std::str::from_utf8(&raw_payload).ok()?).ok()?;
312 if valid_claims(&payload, audiences, config) {
313 if let Some(nonce) = nonce.filter(|value| !value.is_empty()) {
314 let claim = payload.get("nonce").and_then(JsValue::as_str)?;
315 if claim != nonce
316 && (!matches!(config.nonce_comparison, OAuthNonceComparison::ExactOrSha256)
317 || claim
318 != crate::token_crypto::hex_lower(&Sha256::digest(
319 nonce.as_bytes(),
320 )))
321 {
322 return None;
323 }
324 }
325 if config
326 .verify_claims
327 .as_ref()
328 .is_none_or(|policy| policy.verify_claims(&payload))
329 {
330 return Some(payload);
331 }
332 }
333 }
334 }
335 None
336}
337
338fn remote_rsa_key_matches(key: &Value, kid: Option<&JsValue>) -> bool {
339 key.get("kty").and_then(Value::as_str) == Some("RSA")
340 && kid.is_none_or(|kid| {
341 kid.as_str()
342 .is_some_and(|kid| key.get("kid").and_then(Value::as_str) == Some(kid))
343 })
344 && key
345 .get("alg")
346 .is_none_or(|value| value.as_str() == Some("RS256"))
347 && key
348 .get("use")
349 .is_none_or(|value| value.as_str() == Some("sig"))
350 && key.get("ext").is_none_or(Value::is_boolean)
351 && key.get("key_ops").is_none_or(|value| {
352 value.as_array().is_some_and(|operations| {
353 operations
354 .iter()
355 .any(|operation| operation.as_str() == Some("verify"))
356 && operations.iter().enumerate().all(|(index, operation)| {
357 operation.is_string()
358 && operations
359 .iter()
360 .take(index)
361 .all(|previous| previous != operation)
362 })
363 })
364 })
365}
366
367pub(super) fn remote_rsa_public_key(key: &Value) -> bool {
368 if key.get("d").is_some() {
369 return false;
370 }
371 let Some(modulus) = key.get("n").and_then(Value::as_str).and_then(|value| {
372 base64::engine::general_purpose::URL_SAFE_NO_PAD
373 .decode(value)
374 .ok()
375 }) else {
376 return false;
377 };
378 let Some((offset, first)) = modulus.iter().enumerate().find(|(_, byte)| **byte != 0) else {
379 return false;
380 };
381 modulus
382 .len()
383 .checked_sub(offset)
384 .and_then(|length| length.checked_mul(8))
385 .and_then(|bits| {
386 usize::try_from(first.leading_zeros())
387 .ok()
388 .and_then(|zeros| bits.checked_sub(zeros))
389 })
390 .is_some_and(|bits| bits >= 2048)
391}
392
393pub(crate) const CLOCK_TOLERANCE_SECS: f64 = 30.0;
395pub(crate) const REQUIRED_CLAIMS: [&str; 5] = ["iss", "sub", "aud", "exp", "iat"];
399
400#[expect(
401 clippy::as_conversions,
402 clippy::cast_precision_loss,
403 reason = "JWT numeric dates deliberately retain JavaScript floating point comparison"
404)]
405fn valid_claims(payload: &JsValue, audiences: &[String], config: &OAuthIdTokenConfig) -> bool {
406 if REQUIRED_CLAIMS
407 .iter()
408 .any(|claim| payload.get(claim).is_none())
409 {
410 return false;
411 }
412 if (config.issuers.is_empty() && config.verify_claims.is_none())
413 || (!config.issuers.is_empty()
414 && !payload
415 .get("iss")
416 .and_then(JsValue::as_str)
417 .is_some_and(|issuer| config.issuers.iter().any(|configured| issuer == configured)))
418 {
419 return false;
420 }
421 let matches_audience = match payload.get("aud") {
422 Some(JsValue::String(value)) => audiences.contains(value),
423 Some(JsValue::Array(values)) => values.iter().any(|value| {
424 value
425 .as_str()
426 .is_some_and(|value| audiences.iter().any(|audience| audience == value))
427 }),
428 _ => false,
429 };
430 if !matches_audience {
431 return false;
432 }
433 let now = Utc::now().timestamp() as f64;
434 let mut dates = [0.0; 3];
435 for (date, claim) in dates.iter_mut().zip(["iat", "exp", "nbf"]) {
436 match payload.get(claim) {
437 None => *date = f64::NAN,
438 Some(value) => match value.as_f64() {
439 Some(value) => *date = value,
440 None => return false,
441 },
442 }
443 }
444 let [iat, exp, nbf] = dates;
445 if nbf > now + CLOCK_TOLERANCE_SECS || exp <= now - CLOCK_TOLERANCE_SECS {
446 return false;
447 }
448 config.max_age_secs.is_none_or(|max_age| {
449 let age = now - iat;
450 age - CLOCK_TOLERANCE_SECS <= max_age as f64 && age >= -CLOCK_TOLERANCE_SECS
451 })
452}
453fn js_truthy(value: &JsValue) -> bool {
454 match value {
455 JsValue::Null => false,
456 JsValue::Bool(value) => *value,
457 JsValue::Number(value) => *value != 0.0 && !value.is_nan(),
458 JsValue::String(value) => !value.is_empty(),
459 JsValue::Date(_) | JsValue::Array(_) | JsValue::Object(_) => true,
460 }
461}