arcly-http-identity 0.9.0

CIAM building-block engine for arcly-http: identity store traits, password hashing, MFA (TOTP), passwordless & account lifecycle, risk signals, and an OIDC provider — storage-agnostic, zero-lock, all I/O behind traits.
Documentation
//! Inbound OIDC `id_token` verification — the difference between "social login"
//! and real *federation*.
//!
//! Consuming a provider's OAuth2 access token + userinfo endpoint (what the
//! example's `OAuth2Provider`s do) proves the app can *call the provider*, not
//! that the end user authenticated for *this* app. Proper OIDC verifies the
//! signed `id_token`: signature against the provider's JWKS, `iss`, `aud`
//! (== this app's client id), `exp`, and the `nonce` the app planted at
//! `/authorize` (replay defence).
//!
//! Network stays out of the crate: the app fetches the provider JWKS (cached)
//! and hands the public keys in. Verification itself is done here with
//! `jsonwebtoken`.

use async_trait::async_trait;
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
use serde::Deserialize;

use crate::error::{IdentityError, Result};

/// The verified, trusted subset of an `id_token`'s claims.
#[derive(Debug, Clone)]
pub struct VerifiedIdToken {
    pub iss: String,
    pub sub: String,
    pub aud: String,
    pub email: Option<String>,
    pub email_verified: bool,
    pub name: Option<String>,
    pub nonce: Option<String>,
    /// Any additional claims (e.g. `groups`, `hd`) for attribute/role mapping.
    pub extra: serde_json::Map<String, serde_json::Value>,
}

/// Verifies inbound provider `id_token`s. Implement over your JWKS cache; a
/// ready [`JwtIdTokenVerifier`] is provided for the common case.
#[async_trait]
pub trait IdTokenVerifier: Send + Sync + 'static {
    /// Verify signature + `iss`/`aud`/`exp` and, when `expected_nonce` is set,
    /// that the token's `nonce` matches. Returns the trusted claims.
    async fn verify(&self, id_token: &str, expected_nonce: Option<&str>)
        -> Result<VerifiedIdToken>;
}

#[derive(Debug, Deserialize)]
struct RawIdClaims {
    iss: String,
    sub: String,
    // `aud` is validated by `jsonwebtoken` against the configured audience
    // (string or array), so it isn't re-read here.
    #[serde(default)]
    email: Option<String>,
    #[serde(default)]
    email_verified: Option<serde_json::Value>,
    #[serde(default)]
    name: Option<String>,
    #[serde(default)]
    nonce: Option<String>,
    #[serde(flatten)]
    extra: serde_json::Map<String, serde_json::Value>,
}

/// A `kid`-keyed public verification key from the provider JWKS.
struct KeyEntry {
    kid: Option<String>,
    alg: Algorithm,
    key: DecodingKey,
}

/// Concrete verifier: validates `iss`/`aud`/`exp` and `nonce` against a set of
/// provider public keys the app has fetched from the JWKS endpoint.
pub struct JwtIdTokenVerifier {
    issuer: String,
    audience: String,
    keys: Vec<KeyEntry>,
    leeway_secs: u64,
}

impl JwtIdTokenVerifier {
    /// `issuer` must equal the provider's `iss`; `audience` is this app's OIDC
    /// client id (the token's `aud`).
    pub fn new(issuer: impl Into<String>, audience: impl Into<String>) -> Self {
        Self {
            issuer: issuer.into(),
            audience: audience.into(),
            keys: Vec::new(),
            leeway_secs: 60,
        }
    }

    /// Add an RSA public key (PEM) from the provider JWKS, tagged with its `kid`.
    pub fn add_rsa_pem(mut self, kid: Option<String>, alg: Algorithm, pem: &str) -> Result<Self> {
        let key = DecodingKey::from_rsa_pem(pem.as_bytes())
            .map_err(|e| IdentityError::Internal(format!("id_token key: {e}")))?;
        self.keys.push(KeyEntry { kid, alg, key });
        Ok(self)
    }

    /// Add an EC public key (PEM), tagged with its `kid`.
    pub fn add_ec_pem(mut self, kid: Option<String>, alg: Algorithm, pem: &str) -> Result<Self> {
        let key = DecodingKey::from_ec_pem(pem.as_bytes())
            .map_err(|e| IdentityError::Internal(format!("id_token key: {e}")))?;
        self.keys.push(KeyEntry { kid, alg, key });
        Ok(self)
    }

    fn select_key(&self, kid: Option<&str>) -> Option<&KeyEntry> {
        // Prefer an exact kid match; fall back to the sole key when the token
        // carries no kid (some providers omit it).
        self.keys
            .iter()
            .find(|k| k.kid.as_deref() == kid && kid.is_some())
            .or_else(|| {
                if self.keys.len() == 1 {
                    self.keys.first()
                } else {
                    self.keys.iter().find(|k| k.kid.is_none())
                }
            })
    }
}

#[async_trait]
impl IdTokenVerifier for JwtIdTokenVerifier {
    async fn verify(
        &self,
        id_token: &str,
        expected_nonce: Option<&str>,
    ) -> Result<VerifiedIdToken> {
        let header = decode_header(id_token).map_err(|_| IdentityError::InvalidToken)?;
        let entry = self
            .select_key(header.kid.as_deref())
            .ok_or(IdentityError::InvalidToken)?;

        let mut validation = Validation::new(entry.alg);
        validation.set_issuer(&[self.issuer.as_str()]);
        validation.set_audience(&[self.audience.as_str()]);
        validation.validate_exp = true;
        validation.leeway = self.leeway_secs;

        let data = decode::<RawIdClaims>(id_token, &entry.key, &validation)
            .map_err(|_| IdentityError::InvalidToken)?;
        let claims = data.claims;

        // Replay defence: the nonce we planted at /authorize must come back.
        if let Some(expected) = expected_nonce {
            match &claims.nonce {
                Some(n) if n == expected => {}
                _ => return Err(IdentityError::InvalidToken),
            }
        }

        // `aud` may be an array; jsonwebtoken already checked membership, but
        // normalise to the configured audience for the return value.
        let email_verified = matches!(
            claims.email_verified,
            Some(serde_json::Value::Bool(true)) | Some(serde_json::Value::String(_)) // some providers send "true"
        ) && !matches!(
            claims.email_verified,
            Some(serde_json::Value::String(ref s)) if s != "true"
        );

        Ok(VerifiedIdToken {
            iss: claims.iss,
            sub: claims.sub,
            aud: self.audience.clone(),
            email: claims.email,
            email_verified,
            name: claims.name,
            nonce: claims.nonce,
            extra: claims.extra,
        })
    }
}