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 when absent.
92    pub algorithm: Option<Algorithm>,
93    /// Permitted token algorithms, checked before keys are fetched. When
94    /// absent, any algorithm the selected key supports is accepted.
95    pub algorithms: Option<Vec<Algorithm>>,
96    /// Import algorithm for a key that declares none when `algorithm` is absent.
97    pub default_key_algorithm: Option<Algorithm>,
98    pub selection: OAuthJwksSelection,
99    pub nonce_comparison: OAuthNonceComparison,
100    /// A trusted additional policy, including tenant binding for multi-tenant
101    /// issuers. Empty fixed issuers require this policy and otherwise fail closed.
102    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    // An empty audience would skip the audience check entirely.
193    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    // Pinned algorithms come from trusted configuration, never from the token
231    // header, and are enforced before any key is fetched.
232    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
393/// Clock skew allowed on `exp`, `nbf`, and `iat`, matching openid-client and oauth4webapi.
394pub(crate) const CLOCK_TOLERANCE_SECS: f64 = 30.0;
395/// Claims every ID token must contain, whatever their value.
396///
397/// See <https://openid.net/specs/openid-connect-core-1_0.html#IDToken>.
398pub(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}