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(in crate::plugins) fn hosted_domain_allowed(
193 provider: &OAuthProvider,
194 claim: Option<&str>,
195) -> bool {
196 provider
197 .hosted_domain
198 .as_deref()
199 .filter(|domain| !domain.is_empty())
200 .is_none_or(|domain| {
201 claim.is_some_and(|claim| !claim.is_empty() && (domain == "*" || domain == claim))
202 })
203}
204
205pub(in crate::plugins) async fn verify_jwks_token(
206 token: &str,
207 audiences: &[String],
208 nonce: Option<&str>,
209 config: &OAuthIdTokenConfig,
210) -> Option<JsValue> {
211 let parts: Vec<_> = token.split('.').collect();
212 let [header_encoded, payload_encoded, signature] = parts.as_slice() else {
213 return None;
214 };
215 let (signed, _) = token.rsplit_once('.')?;
216 let raw_header = base64::engine::general_purpose::URL_SAFE_NO_PAD
217 .decode(header_encoded)
218 .ok()?;
219 let header = parse_value(std::str::from_utf8(&raw_header).ok()?).ok()?;
220 let _ignored_as_object = header.as_object()?;
221 let algorithm: Algorithm =
222 serde_json::from_value(header.get("alg")?.to_json_value().ok()?).ok()?;
223 if matches!(
224 config.selection,
225 OAuthJwksSelection::AllMatching | OAuthJwksSelection::RemoteRs256
226 ) && config.algorithm.is_some_and(|fixed| fixed != algorithm)
227 {
228 return None;
229 }
230 let keys = config.jwks_source.fetch_keys().await.ok()?;
231 let kid = header.get("kid");
232 let mut selected: Vec<_> = keys
233 .into_iter()
234 .filter(|key| match config.selection {
235 OAuthJwksSelection::ExactKid => match kid {
236 None => key.get("kid").is_none(),
237 Some(kid) => kid.to_json_value().ok().as_ref() == key.get("kid"),
238 },
239 OAuthJwksSelection::RemoteRs256 => remote_rsa_key_matches(key, kid),
240 OAuthJwksSelection::First | OAuthJwksSelection::AllMatching => {
241 kid.filter(|kid| js_truthy(kid)).is_none_or(|kid| {
242 kid.as_str()
243 .is_some_and(|kid| key.get("kid").and_then(Value::as_str) == Some(kid))
244 })
245 }
246 })
247 .collect();
248 if matches!(config.selection, OAuthJwksSelection::RemoteRs256)
249 && (selected.len() != 1 || !selected.first().is_some_and(remote_rsa_public_key))
250 {
251 return None;
252 }
253 if matches!(config.selection, OAuthJwksSelection::ExactKid) {
254 selected.truncate(1);
255 }
256 let mut public_keys = Vec::new();
257 for key in selected {
258 if config.algorithm.is_none() {
259 let imported: Algorithm = serde_json::from_value(key.get("alg")?.clone()).ok()?;
260 if imported != algorithm {
261 return None;
262 }
263 }
264 let key: jsonwebtoken::jwk::Jwk = serde_json::from_value(key).ok()?;
265 public_keys.push(DecodingKey::from_jwk(&key).ok()?);
266 }
267 if config.algorithm.is_some_and(|fixed| fixed != algorithm) {
268 return None;
269 }
270 if !matches!(
271 config.selection,
272 OAuthJwksSelection::AllMatching | OAuthJwksSelection::RemoteRs256
273 ) {
274 public_keys.truncate(1);
275 }
276 if let Some(crit) = header.get("crit") {
277 let names = crit.as_array()?;
278 if names.is_empty()
279 || names.iter().any(|name| name.as_str() != Some("b64"))
280 || header.get("b64").and_then(JsValue::as_bool) != Some(true)
281 {
282 return None;
283 }
284 }
285 for key in public_keys {
286 if jsonwebtoken::crypto::verify(signature, signed.as_bytes(), &key, algorithm).ok()
287 == Some(true)
288 {
289 let raw_payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
290 .decode(payload_encoded)
291 .ok()?;
292 let payload = parse_value(std::str::from_utf8(&raw_payload).ok()?).ok()?;
293 if valid_claims(&payload, audiences, config) {
294 if let Some(nonce) = nonce.filter(|value| !value.is_empty()) {
295 let claim = payload.get("nonce").and_then(JsValue::as_str)?;
296 if claim != nonce
297 && (!matches!(config.nonce_comparison, OAuthNonceComparison::ExactOrSha256)
298 || claim
299 != Sha256::digest(nonce.as_bytes())
300 .iter()
301 .map(|byte| format!("{byte:02x}"))
302 .collect::<String>())
303 {
304 return None;
305 }
306 }
307 if config
308 .verify_claims
309 .as_ref()
310 .is_none_or(|policy| policy.verify_claims(&payload))
311 {
312 return Some(payload);
313 }
314 }
315 }
316 }
317 None
318}
319
320fn remote_rsa_key_matches(key: &Value, kid: Option<&JsValue>) -> bool {
321 key.get("kty").and_then(Value::as_str) == Some("RSA")
322 && kid.is_none_or(|kid| {
323 kid.as_str()
324 .is_some_and(|kid| key.get("kid").and_then(Value::as_str) == Some(kid))
325 })
326 && key
327 .get("alg")
328 .is_none_or(|value| value.as_str() == Some("RS256"))
329 && key
330 .get("use")
331 .is_none_or(|value| value.as_str() == Some("sig"))
332 && key.get("ext").is_none_or(Value::is_boolean)
333 && key.get("key_ops").is_none_or(|value| {
334 value.as_array().is_some_and(|operations| {
335 operations
336 .iter()
337 .any(|operation| operation.as_str() == Some("verify"))
338 && operations.iter().enumerate().all(|(index, operation)| {
339 operation.is_string()
340 && operations
341 .iter()
342 .take(index)
343 .all(|previous| previous != operation)
344 })
345 })
346 })
347}
348
349pub(super) fn remote_rsa_public_key(key: &Value) -> bool {
350 if key.get("d").is_some() {
351 return false;
352 }
353 let Some(modulus) = key.get("n").and_then(Value::as_str).and_then(|value| {
354 base64::engine::general_purpose::URL_SAFE_NO_PAD
355 .decode(value)
356 .ok()
357 }) else {
358 return false;
359 };
360 let Some((offset, first)) = modulus.iter().enumerate().find(|(_, byte)| **byte != 0) else {
361 return false;
362 };
363 modulus
364 .len()
365 .checked_sub(offset)
366 .and_then(|length| length.checked_mul(8))
367 .and_then(|bits| {
368 usize::try_from(first.leading_zeros())
369 .ok()
370 .and_then(|zeros| bits.checked_sub(zeros))
371 })
372 .is_some_and(|bits| bits >= 2048)
373}
374
375#[expect(
376 clippy::as_conversions,
377 clippy::cast_precision_loss,
378 reason = "JWT numeric dates deliberately retain JavaScript floating point comparison"
379)]
380fn valid_claims(payload: &JsValue, audiences: &[String], config: &OAuthIdTokenConfig) -> bool {
381 if (config.issuers.is_empty() && config.verify_claims.is_none())
382 || (!config.issuers.is_empty()
383 && !payload
384 .get("iss")
385 .and_then(JsValue::as_str)
386 .is_some_and(|issuer| config.issuers.iter().any(|configured| issuer == configured)))
387 {
388 return false;
389 }
390 let matches_audience = match payload.get("aud") {
391 Some(JsValue::String(value)) => audiences.contains(value),
392 Some(JsValue::Array(values)) => values.iter().any(|value| {
393 value
394 .as_str()
395 .is_some_and(|value| audiences.iter().any(|audience| audience == value))
396 }),
397 _ => false,
398 };
399 if !matches_audience {
400 return false;
401 }
402 let now = Utc::now().timestamp() as f64;
403 let iat = match payload.get("iat") {
404 None => None,
405 Some(value) => match value.as_f64() {
406 Some(date) => Some(date),
407 None => return false,
408 },
409 };
410 if let Some(max_age) = config.max_age_secs {
411 let Some(iat) = iat else {
412 return false;
413 };
414 if iat > now || now - iat > max_age as f64 {
415 return false;
416 }
417 }
418 for (claim, lower_bound) in [("exp", true), ("nbf", false)] {
419 if let Some(value) = payload.get(claim) {
420 let Some(date) = value.as_f64() else {
421 return false;
422 };
423 if lower_bound && date <= now || !lower_bound && date > now {
424 return false;
425 }
426 }
427 }
428 true
429}
430fn js_truthy(value: &JsValue) -> bool {
431 match value {
432 JsValue::Null => false,
433 JsValue::Bool(value) => *value,
434 JsValue::Number(value) => *value != 0.0 && !value.is_nan(),
435 JsValue::String(value) => !value.is_empty(),
436 JsValue::Array(_) | JsValue::Object(_) => true,
437 }
438}