Skip to main content

alibi_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(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}