Skip to main content

alibi_api/plugins/oauth/
id_token.rs

1//! Signature and claim verification for trusted OAuth JWKS authorities.
2use 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/// Application-owned key transport/cache. Request claims never choose its URL.
13#[async_trait]
14pub trait OAuthJwksSource: Send + Sync {
15    async fn fetch_keys(&self) -> Result<Vec<Value>, String>;
16}
17
18/// Application-owned policy applied to claims only after cryptographic and
19/// standard claim verification. It must not select an untrusted key authority.
20pub trait OAuthIdTokenClaimsVerifier: Send + Sync {
21    fn verify_claims(&self, claims: &JsValue) -> bool;
22}
23
24/// HTTP transport for an application-configured provider key authority.
25pub 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    /// Published social factories select the first returned matching key.
64    First,
65    /// Google's separately published One Tap helper retries matching keys.
66    AllMatching,
67    /// Apple matches the header key ID literally, without a truthiness fallback.
68    ExactKid,
69    /// JOSE remote RSA selection filters metadata and rejects ambiguous keys.
70    RemoteRs256,
71}
72#[derive(Debug, Clone, Copy)]
73pub enum OAuthNonceComparison {
74    Exact,
75    ExactOrSha256,
76}
77
78/// Trusted provider policy. An explicit audience overrides provider client IDs.
79#[derive(Clone)]
80pub struct OAuthIdTokenConfig {
81    pub issuers: Vec<String>,
82    pub audience: Option<Vec<String>>,
83    /// Explicit client-ID array supplied by the provider builder, including an empty array.
84    pub client_ids: Option<Vec<String>>,
85    pub jwks_source: Arc<dyn OAuthJwksSource>,
86    /// When absent, issued-at is optional and is not bounded by token age.
87    pub max_age_secs: Option<u64>,
88    /// Permits opaque candidates; the provider must independently authenticate
89    /// the access token in its actual user-info operation before admission.
90    pub allow_opaque_token: bool,
91    /// Fixed import algorithm, or the JWK's declared algorithm for Apple.
92    pub algorithm: Option<Algorithm>,
93    pub selection: OAuthJwksSelection,
94    pub nonce_comparison: OAuthNonceComparison,
95    /// A trusted additional policy, including tenant binding for multi-tenant
96    /// issuers. Empty fixed issuers require this policy and otherwise fail closed.
97    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}