alibi-plugins 0.11.0

Built-in authentication plugins and API metadata for Alibi
Documentation
//! Verified OIDC identity claims from the configured JWKS authority.
use super::{AuthContext, AuthResult, AuthSchema, Value, api};
use jsonwebtoken::{Algorithm, DecodingKey, Validation};
/// Clock skew allowed on `exp` and `nbf`, matching the shared upstream ID token policy.
const ID_TOKEN_CLOCK_TOLERANCE: f64 = 30.0;
pub(super) async fn verify<S: AuthSchema>(
    token: &str,
    endpoint: &str,
    client_id: &str,
    issuer: &str,
    ctx: &AuthContext<S>,
) -> AuthResult<Value> {
    let invalid = || api(400, "token_not_verified");
    // An empty audience would accept tokens issued to any client.
    if client_id.is_empty() {
        return Err(invalid());
    }
    let header = jsonwebtoken::decode_header(token).map_err(|_| invalid())?;
    let key_type = match header.alg {
        Algorithm::RS256
        | Algorithm::RS384
        | Algorithm::RS512
        | Algorithm::PS256
        | Algorithm::PS384
        | Algorithm::PS512 => "RSA",
        Algorithm::ES256 | Algorithm::ES384 => "EC",
        Algorithm::EdDSA => "OKP",
        _ => return Err(invalid()),
    };
    if header
        .crit
        .as_ref()
        .is_some_and(|claims| claims.is_empty() || claims.iter().any(|claim| claim != "b64"))
    {
        return Err(invalid());
    }
    let b64 = header.extras.inner().get("b64");
    if b64.is_some_and(|value| value.as_bool() != Some(true))
        || header
            .crit
            .as_ref()
            .is_some_and(|claims| claims.iter().any(|claim| claim == "b64"))
            && b64.is_none()
    {
        return Err(invalid());
    }
    let (client, endpoint) = super::callback::outbound_client(endpoint, ctx)
        .await
        .map_err(|_| invalid())?;
    let jwks = client
        .get(endpoint)
        .send()
        .await
        .map_err(|_| invalid())?
        .error_for_status()
        .map_err(|_| invalid())?
        .json::<Value>()
        .await
        .map_err(|_| invalid())?;
    let algorithm = serde_json::to_value(header.alg)?;
    let keys = jwks
        .get("keys")
        .and_then(Value::as_array)
        .ok_or_else(invalid)?;
    let selected = keys
        .iter()
        .filter(|key| {
            key.get("kty").and_then(Value::as_str) == Some(key_type)
                && header
                    .kid
                    .as_deref()
                    .is_none_or(|kid| key.get("kid").and_then(Value::as_str) == Some(kid))
                && key.get("alg").is_none_or(|alg| alg == &algorithm)
                && key
                    .get("use")
                    .is_none_or(|usage| usage.as_str() == Some("sig"))
                && key.get("key_ops").is_none_or(|ops| {
                    ops.as_array()
                        .is_some_and(|ops| ops.iter().any(|op| op.as_str() == Some("verify")))
                })
                && match header.alg {
                    Algorithm::ES256 => key.get("crv").and_then(Value::as_str) == Some("P-256"),
                    Algorithm::ES384 => key.get("crv").and_then(Value::as_str) == Some("P-384"),
                    Algorithm::EdDSA => key.get("crv").and_then(Value::as_str) == Some("Ed25519"),
                    _ => true,
                }
        })
        .collect::<Vec<_>>();
    let [selected] = selected.as_slice() else {
        return Err(invalid());
    };
    let jwk: jsonwebtoken::jwk::Jwk =
        serde_json::from_value((*selected).clone()).map_err(|_| invalid())?;
    let key = DecodingKey::from_jwk(&jwk).map_err(|_| invalid())?;
    let mut policy = Validation::new(header.alg);
    policy.set_required_spec_claims(&["iss", "aud"]);
    policy.set_issuer(&[issuer]);
    policy.set_audience(&[client_id]);
    policy.validate_exp = false;
    policy.validate_nbf = false;
    policy.leeway = 0;
    let claims = jsonwebtoken::decode::<Value>(token, &key, &policy)
        .map_err(|_| invalid())?
        .claims;
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map_err(|_| invalid())?
        .as_secs_f64()
        .floor();
    // Every ID token carries `sub`, `exp` and `iat`; time claims allow the same
    // 30-second clock skew as social sign-in.
    if ["sub", "exp", "iat"]
        .iter()
        .any(|field| claims.get(field).is_none())
    {
        return Err(invalid());
    }
    for field in ["iat", "exp", "nbf"] {
        if let Some(value) = claims.get(field) {
            let value = value.as_f64().ok_or_else(invalid)?;
            if (field == "exp" && value <= now - ID_TOKEN_CLOCK_TOLERANCE)
                || (field == "nbf" && value > now + ID_TOKEN_CLOCK_TOLERANCE)
            {
                return Err(invalid());
            }
        }
    }
    if claims
        .get("aud")
        .and_then(Value::as_array)
        .is_some_and(|audiences| audiences.len() > 1)
        && claims.get("azp").is_none()
        || claims
            .get("azp")
            .is_some_and(|party| party.as_str() != Some(client_id))
    {
        return Err(invalid());
    }
    if claims
        .get("sub")
        .and_then(Value::as_str)
        .is_none_or(str::is_empty)
    {
        return Err(api(400, "id_token_subject_missing"));
    }
    Ok(claims)
}