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 selection: OAuthJwksSelection,
94 pub nonce_comparison: OAuthNonceComparison,
95 pub verify_claims: Option<Arc<dyn OAuthIdTokenClaimsVerifier>>,
98}
99impl OAuthIdTokenConfig {
100 #[must_use]
101 pub fn google() -> Self {
102 Self {
103 issuers: vec![
104 "https://accounts.google.com".into(),
105 "accounts.google.com".into(),
106 ],
107 audience: None,
108 client_ids: None,
109 jwks_source: Arc::new(HttpOAuthJwksSource::new(
110 "https://www.googleapis.com/oauth2/v3/certs",
111 )),
112 max_age_secs: Some(3600),
113 allow_opaque_token: false,
114 algorithm: Some(Algorithm::RS256),
115 selection: OAuthJwksSelection::First,
116 nonce_comparison: OAuthNonceComparison::Exact,
117 verify_claims: None,
118 }
119 }
120 #[must_use]
121 pub fn apple() -> Self {
122 Self {
123 issuers: vec!["https://appleid.apple.com".into()],
124 audience: None,
125 client_ids: None,
126 jwks_source: Arc::new(HttpOAuthJwksSource::new(
127 "https://appleid.apple.com/auth/keys",
128 )),
129 max_age_secs: Some(3600),
130 allow_opaque_token: false,
131 algorithm: None,
132 selection: OAuthJwksSelection::ExactKid,
133 nonce_comparison: OAuthNonceComparison::ExactOrSha256,
134 verify_claims: None,
135 }
136 }
137}
138impl std::fmt::Debug for OAuthIdTokenConfig {
139 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140 f.debug_struct("OAuthIdTokenConfig")
141 .field("issuers", &self.issuers)
142 .field("audience", &self.audience)
143 .field("client_ids", &self.client_ids)
144 .field("max_age_secs", &self.max_age_secs)
145 .field("allow_opaque_token", &self.allow_opaque_token)
146 .field("algorithm", &self.algorithm)
147 .field("selection", &self.selection)
148 .field("nonce_comparison", &self.nonce_comparison)
149 .field("verify_claims", &self.verify_claims.is_some())
150 .finish_non_exhaustive()
151 }
152}
153
154pub(super) async fn verify_provider_token(
155 provider: &OAuthProvider,
156 token: &str,
157 nonce: Option<&str>,
158) -> bool {
159 if provider.disable_id_token_sign_in {
160 return false;
161 }
162 if let Some(verifier) = &provider.verify_id_token {
163 return verifier
164 .verify_id_token(token, nonce)
165 .await
166 .unwrap_or(false);
167 }
168 let Some(config) = &provider.id_token else {
169 return false;
170 };
171 if token.split('.').count() != 3 {
172 return config.allow_opaque_token;
173 }
174 let mut audiences = vec![provider.client_id.clone()];
175 audiences.extend(provider.additional_client_ids.clone());
176 let verified = verify_jwks_token(
177 token,
178 config
179 .audience
180 .as_deref()
181 .or(config.client_ids.as_deref())
182 .unwrap_or(&audiences),
183 nonce,
184 config,
185 )
186 .await;
187 verified.is_some_and(|claims| {
188 hosted_domain_allowed(provider, claims.get("hd").and_then(JsValue::as_str))
189 })
190}
191
192pub(crate) fn hosted_domain_allowed(provider: &OAuthProvider, claim: Option<&str>) -> bool {
193 provider
194 .hosted_domain
195 .as_deref()
196 .filter(|domain| !domain.is_empty())
197 .is_none_or(|domain| {
198 claim.is_some_and(|claim| !claim.is_empty() && (domain == "*" || domain == claim))
199 })
200}
201
202pub(crate) async fn verify_jwks_token(
203 token: &str,
204 audiences: &[String],
205 nonce: Option<&str>,
206 config: &OAuthIdTokenConfig,
207) -> Option<JsValue> {
208 let parts: Vec<_> = token.split('.').collect();
209 let [header_encoded, payload_encoded, signature] = parts.as_slice() else {
210 return None;
211 };
212 let (signed, _) = token.rsplit_once('.')?;
213 let raw_header = base64::engine::general_purpose::URL_SAFE_NO_PAD
214 .decode(header_encoded)
215 .ok()?;
216 let header = parse_value(std::str::from_utf8(&raw_header).ok()?).ok()?;
217 _ = header.as_object()?;
218 let algorithm: Algorithm =
219 serde_json::from_value(header.get("alg")?.to_json_value().ok()?).ok()?;
220 if matches!(
221 config.selection,
222 OAuthJwksSelection::AllMatching | OAuthJwksSelection::RemoteRs256
223 ) && config.algorithm.is_some_and(|fixed| fixed != algorithm)
224 {
225 return None;
226 }
227 let keys = config.jwks_source.fetch_keys().await.ok()?;
228 let kid = header.get("kid");
229 let mut selected: Vec<_> = keys
230 .into_iter()
231 .filter(|key| match config.selection {
232 OAuthJwksSelection::ExactKid => match kid {
233 None => key.get("kid").is_none(),
234 Some(kid) => kid.to_json_value().ok().as_ref() == key.get("kid"),
235 },
236 OAuthJwksSelection::RemoteRs256 => remote_rsa_key_matches(key, kid),
237 OAuthJwksSelection::First | OAuthJwksSelection::AllMatching => {
238 kid.filter(|kid| js_truthy(kid)).is_none_or(|kid| {
239 kid.as_str()
240 .is_some_and(|kid| key.get("kid").and_then(Value::as_str) == Some(kid))
241 })
242 }
243 })
244 .collect();
245 if matches!(config.selection, OAuthJwksSelection::RemoteRs256)
246 && (selected.len() != 1 || !selected.first().is_some_and(remote_rsa_public_key))
247 {
248 return None;
249 }
250 if matches!(config.selection, OAuthJwksSelection::ExactKid) {
251 selected.truncate(1);
252 }
253 let mut public_keys = Vec::new();
254 for key in selected {
255 if config.algorithm.is_none() {
256 let imported: Algorithm = serde_json::from_value(key.get("alg")?.clone()).ok()?;
257 if imported != algorithm {
258 return None;
259 }
260 }
261 let key: jsonwebtoken::jwk::Jwk = serde_json::from_value(key).ok()?;
262 public_keys.push(DecodingKey::from_jwk(&key).ok()?);
263 }
264 if config.algorithm.is_some_and(|fixed| fixed != algorithm) {
265 return None;
266 }
267 if !matches!(
268 config.selection,
269 OAuthJwksSelection::AllMatching | OAuthJwksSelection::RemoteRs256
270 ) {
271 public_keys.truncate(1);
272 }
273 if let Some(crit) = header.get("crit") {
274 let names = crit.as_array()?;
275 if names.is_empty()
276 || names.iter().any(|name| name.as_str() != Some("b64"))
277 || header.get("b64").and_then(JsValue::as_bool) != Some(true)
278 {
279 return None;
280 }
281 }
282 for key in public_keys {
283 if jsonwebtoken::crypto::verify(signature, signed.as_bytes(), &key, algorithm).ok()
284 == Some(true)
285 {
286 let raw_payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
287 .decode(payload_encoded)
288 .ok()?;
289 let payload = parse_value(std::str::from_utf8(&raw_payload).ok()?).ok()?;
290 if valid_claims(&payload, audiences, config) {
291 if let Some(nonce) = nonce.filter(|value| !value.is_empty()) {
292 let claim = payload.get("nonce").and_then(JsValue::as_str)?;
293 if claim != nonce
294 && (!matches!(config.nonce_comparison, OAuthNonceComparison::ExactOrSha256)
295 || claim
296 != crate::token_crypto::hex_lower(&Sha256::digest(
297 nonce.as_bytes(),
298 )))
299 {
300 return None;
301 }
302 }
303 if config
304 .verify_claims
305 .as_ref()
306 .is_none_or(|policy| policy.verify_claims(&payload))
307 {
308 return Some(payload);
309 }
310 }
311 }
312 }
313 None
314}
315
316fn remote_rsa_key_matches(key: &Value, kid: Option<&JsValue>) -> bool {
317 key.get("kty").and_then(Value::as_str) == Some("RSA")
318 && kid.is_none_or(|kid| {
319 kid.as_str()
320 .is_some_and(|kid| key.get("kid").and_then(Value::as_str) == Some(kid))
321 })
322 && key
323 .get("alg")
324 .is_none_or(|value| value.as_str() == Some("RS256"))
325 && key
326 .get("use")
327 .is_none_or(|value| value.as_str() == Some("sig"))
328 && key.get("ext").is_none_or(Value::is_boolean)
329 && key.get("key_ops").is_none_or(|value| {
330 value.as_array().is_some_and(|operations| {
331 operations
332 .iter()
333 .any(|operation| operation.as_str() == Some("verify"))
334 && operations.iter().enumerate().all(|(index, operation)| {
335 operation.is_string()
336 && operations
337 .iter()
338 .take(index)
339 .all(|previous| previous != operation)
340 })
341 })
342 })
343}
344
345pub(super) fn remote_rsa_public_key(key: &Value) -> bool {
346 if key.get("d").is_some() {
347 return false;
348 }
349 let Some(modulus) = key.get("n").and_then(Value::as_str).and_then(|value| {
350 base64::engine::general_purpose::URL_SAFE_NO_PAD
351 .decode(value)
352 .ok()
353 }) else {
354 return false;
355 };
356 let Some((offset, first)) = modulus.iter().enumerate().find(|(_, byte)| **byte != 0) else {
357 return false;
358 };
359 modulus
360 .len()
361 .checked_sub(offset)
362 .and_then(|length| length.checked_mul(8))
363 .and_then(|bits| {
364 usize::try_from(first.leading_zeros())
365 .ok()
366 .and_then(|zeros| bits.checked_sub(zeros))
367 })
368 .is_some_and(|bits| bits >= 2048)
369}
370
371#[expect(
372 clippy::as_conversions,
373 clippy::cast_precision_loss,
374 reason = "JWT numeric dates deliberately retain JavaScript floating point comparison"
375)]
376fn valid_claims(payload: &JsValue, audiences: &[String], config: &OAuthIdTokenConfig) -> bool {
377 if (config.issuers.is_empty() && config.verify_claims.is_none())
378 || (!config.issuers.is_empty()
379 && !payload
380 .get("iss")
381 .and_then(JsValue::as_str)
382 .is_some_and(|issuer| config.issuers.iter().any(|configured| issuer == configured)))
383 {
384 return false;
385 }
386 let matches_audience = match payload.get("aud") {
387 Some(JsValue::String(value)) => audiences.contains(value),
388 Some(JsValue::Array(values)) => values.iter().any(|value| {
389 value
390 .as_str()
391 .is_some_and(|value| audiences.iter().any(|audience| audience == value))
392 }),
393 _ => false,
394 };
395 if !matches_audience {
396 return false;
397 }
398 let now = Utc::now().timestamp() as f64;
399 let iat = match payload.get("iat") {
400 None => None,
401 Some(value) => match value.as_f64() {
402 Some(date) => Some(date),
403 None => return false,
404 },
405 };
406 if let Some(max_age) = config.max_age_secs {
407 let Some(iat) = iat else {
408 return false;
409 };
410 if iat > now || now - iat > max_age as f64 {
411 return false;
412 }
413 }
414 for (claim, lower_bound) in [("exp", true), ("nbf", false)] {
415 if let Some(value) = payload.get(claim) {
416 let Some(date) = value.as_f64() else {
417 return false;
418 };
419 if lower_bound && date <= now || !lower_bound && date > now {
420 return false;
421 }
422 }
423 }
424 true
425}
426fn js_truthy(value: &JsValue) -> bool {
427 match value {
428 JsValue::Null => false,
429 JsValue::Bool(value) => *value,
430 JsValue::Number(value) => *value != 0.0 && !value.is_nan(),
431 JsValue::String(value) => !value.is_empty(),
432 JsValue::Date(_) | JsValue::Array(_) | JsValue::Object(_) => true,
433 }
434}