Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-auth-oidc/src/rp.rs

//! # The OIDC **relying party**: authorization code + PKCE, verified offline
//!
//! The rest of this crate validates a BEARER that somebody else obtained. This
//! module is the other half: a server that LOGS A PERSON IN through their
//! company's IdP (Entra ID, Okta, Google, Keycloak, Zitadel …) with the
//! authorization-code flow, PKCE (S256), `state` and `nonce`.
//!
//! **Pure: no network, no clock, no randomness.** Every input the flow needs
//! from outside — the discovery document, the JWKS, the token endpoint's
//! answer, the current time, the random bytes for `state`/`nonce`/the PKCE
//! verifier — is handed in by the caller, so the server keeps its own HTTP
//! client (gunnar's appliance already links one) and its own clock, and every
//! rule here is testable without a socket. The pieces:
//!
//! * [`Discovery::parse`] — `/.well-known/openid-configuration`, with the
//!   issuer held to an EXACT match (OIDC Discovery §4.3) and every endpoint
//!   required to be `https` (loopback `http` only when the caller allows it,
//!   for an in-process test IdP).
//! * [`Pkce::from_verifier`], [`random_token`], [`authorize_url`] — the
//!   redirect to the IdP.
//! * [`token_request_form`], [`basic_auth_header`], [`parse_token_response`]
//!   — the code exchange (`client_secret_basic`, RFC 6749 §2.3.1).
//! * [`verify_id_token`] — the ID token, checked as OIDC Core §3.1.3.7 says,
//!   with a strict **algorithm allow-list** ([`DEFAULT_ALGORITHMS`]: RS*,
//!   PS*, ES256/ES384 — never `none`, never `HS*`), the key's TYPE matched to
//!   the algorithm (an `HS256` header over an RSA key is refused twice: by the
//!   allow-list and by the key family), `iss` exact, `aud` ∋ client id (and
//!   `azp` when there are several), `exp`/`nbf`/`iat` against the caller's
//!   clock with a bounded skew, and the `nonce` compared in constant time.

use jsonwebtoken::jwk::{
    AlgorithmParameters, EllipticCurve, Jwk, JwkSet, KeyAlgorithm, PublicKeyUse,
};
use jsonwebtoken::{Algorithm, DecodingKey, Validation};
use serde::Deserialize;

/// Why the relying party refused, in words a log line can carry. Never holds
/// a token, a code, a secret or a nonce.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RpError {
    /// The input is not what the protocol says it must be.
    Malformed(String),
    /// A configuration value is unusable (an `http` endpoint, a wrong issuer).
    Config(String),
    /// The token's `kid` is not in the JWKS: the caller refetches the JWKS
    /// once (key rotation) and tries again.
    UnknownKey(String),
    /// The signature, the algorithm or the key does not hold.
    Signature(String),
    /// A claim does not hold (issuer, audience, time, nonce).
    Claim(String),
    /// The IdP answered with an OAuth error (`error`, `error_description`).
    Idp(String),
}

impl std::fmt::Display for RpError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            RpError::Malformed(m) => write!(f, "malformed: {m}"),
            RpError::Config(m) => write!(f, "configuration: {m}"),
            RpError::UnknownKey(m) => write!(f, "unknown signing key: {m}"),
            RpError::Signature(m) => write!(f, "signature: {m}"),
            RpError::Claim(m) => write!(f, "claim: {m}"),
            RpError::Idp(m) => write!(f, "the identity provider refused: {m}"),
        }
    }
}

impl std::error::Error for RpError {}

/// The ID-token algorithms a relying party accepts unless told otherwise:
/// asymmetric only. `none` does not exist in [`Algorithm`] at all, and the
/// `HS*` family is absent on purpose — an HMAC ID token is keyed with the
/// client secret, and accepting one is how a public key gets used as an HMAC
/// secret (the classic algorithm-confusion forgery).
pub const DEFAULT_ALGORITHMS: &[Algorithm] = &[
    Algorithm::RS256,
    Algorithm::RS384,
    Algorithm::RS512,
    Algorithm::PS256,
    Algorithm::PS384,
    Algorithm::PS512,
    Algorithm::ES256,
    Algorithm::ES384,
];

/// The most clock skew [`verify_id_token`] tolerates, whatever it is asked:
/// five minutes.
pub const MAX_SKEW_SECS: i64 = 300;

/// Where an issuer's discovery document lives (OIDC Discovery §4).
pub fn discovery_url(issuer: &str) -> String {
    format!(
        "{}/.well-known/openid-configuration",
        issuer.trim_end_matches('/')
    )
}

/// Is `url` acceptable as an IdP endpoint: `https`, or `http` on a loopback
/// host when `allow_loopback_http` (an in-process test IdP only).
pub fn endpoint_ok(url: &str, allow_loopback_http: bool) -> bool {
    let lower = url.trim().to_ascii_lowercase();
    if let Some(rest) = lower.strip_prefix("https://") {
        return !rest.is_empty() && !rest.starts_with('/');
    }
    if allow_loopback_http {
        if let Some(rest) = lower.strip_prefix("http://") {
            let host = rest.split(['/', '?', '#']).next().unwrap_or_default();
            let host = match host.rsplit_once(':') {
                Some((h, port)) if port.bytes().all(|b| b.is_ascii_digit()) => h,
                _ => host,
            };
            return matches!(host, "127.0.0.1" | "localhost" | "[::1]");
        }
    }
    false
}

/// The parts of a discovery document the code flow uses.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Discovery {
    pub issuer: String,
    pub authorization_endpoint: String,
    pub token_endpoint: String,
    pub jwks_uri: String,
    /// Empty when the IdP does not say.
    pub id_token_signing_alg_values_supported: Vec<String>,
    /// Empty when the IdP does not say (S256 is still sent: RFC 7636 §4.2).
    pub code_challenge_methods_supported: Vec<String>,
    /// The discovered `userinfo_endpoint` (https), when the IdP names one. The
    /// ONLY userinfo URL a bearer check may call (A34-1): never a guessed
    /// `{issuer}/userinfo`.
    pub userinfo_endpoint: Option<String>,
    /// The discovered RFC 7662 `introspection_endpoint` (https), when the IdP
    /// names one: what binds an opaque token to an audience.
    pub introspection_endpoint: Option<String>,
}

#[derive(Deserialize)]
struct DiscoveryWire {
    issuer: String,
    authorization_endpoint: String,
    token_endpoint: String,
    jwks_uri: String,
    #[serde(default)]
    id_token_signing_alg_values_supported: Vec<String>,
    #[serde(default)]
    code_challenge_methods_supported: Vec<String>,
    #[serde(default)]
    userinfo_endpoint: Option<String>,
    #[serde(default)]
    introspection_endpoint: Option<String>,
}

impl Discovery {
    /// Parse a discovery document and hold it to `expected_issuer` — EXACTLY
    /// (one trailing `/` aside, which IdPs disagree on): a document that names
    /// another issuer is somebody else's, and its keys would verify somebody
    /// else's tokens.
    pub fn parse(
        json: &str,
        expected_issuer: &str,
        allow_loopback_http: bool,
    ) -> Result<Self, RpError> {
        let wire: DiscoveryWire = serde_json::from_str(json).map_err(|e| {
            RpError::Malformed(format!("the discovery document does not parse: {e}"))
        })?;
        if wire.issuer.trim_end_matches('/') != expected_issuer.trim().trim_end_matches('/') {
            return Err(RpError::Config(format!(
                "the discovery document names issuer {:?}, not the configured {:?}",
                wire.issuer, expected_issuer
            )));
        }
        for (what, url) in [
            ("authorization_endpoint", &wire.authorization_endpoint),
            ("token_endpoint", &wire.token_endpoint),
            ("jwks_uri", &wire.jwks_uri),
        ] {
            if !endpoint_ok(url, allow_loopback_http) {
                return Err(RpError::Config(format!(
                    "{what} {url:?} is not an https URL"
                )));
            }
        }
        let optional = |what: &str, url: Option<String>| -> Result<Option<String>, RpError> {
            match url.map(|u| u.trim().to_owned()).filter(|u| !u.is_empty()) {
                Some(u) if !endpoint_ok(&u, allow_loopback_http) => Err(RpError::Config(format!(
                    "{what} {u:?} is not an https URL"
                ))),
                other => Ok(other),
            }
        };
        let userinfo_endpoint = optional("userinfo_endpoint", wire.userinfo_endpoint)?;
        let introspection_endpoint = optional("introspection_endpoint", wire.introspection_endpoint)?;
        if !wire.code_challenge_methods_supported.is_empty()
            && !wire
                .code_challenge_methods_supported
                .iter()
                .any(|m| m == "S256")
        {
            return Err(RpError::Config(
                "the identity provider does not support PKCE S256".to_owned(),
            ));
        }
        Ok(Discovery {
            issuer: wire.issuer,
            authorization_endpoint: wire.authorization_endpoint,
            token_endpoint: wire.token_endpoint,
            jwks_uri: wire.jwks_uri,
            id_token_signing_alg_values_supported: wire.id_token_signing_alg_values_supported,
            code_challenge_methods_supported: wire.code_challenge_methods_supported,
            userinfo_endpoint,
            introspection_endpoint,
        })
    }
}

/// `bytes`, base64url without padding — `state`, `nonce` and the PKCE
/// verifier from the caller's CSPRNG. Thirty-two bytes make 43 characters,
/// the shortest verifier RFC 7636 allows.
pub fn random_token(bytes: &[u8]) -> String {
    use base64::Engine;
    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}

/// A PKCE pair (RFC 7636, S256 only — `plain` is never sent).
#[derive(Clone, PartialEq, Eq)]
pub struct Pkce {
    /// Kept by the server until the code is exchanged; never logged.
    pub verifier: String,
    /// `BASE64URL(SHA256(verifier))`, sent in the authorization request.
    pub challenge: String,
}

impl std::fmt::Debug for Pkce {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Pkce")
            .field("challenge", &self.challenge)
            .finish_non_exhaustive()
    }
}

impl Pkce {
    /// The S256 challenge for `verifier` (43–128 unreserved characters).
    pub fn from_verifier(verifier: &str) -> Result<Self, RpError> {
        use sha2::Digest;
        let ok = (43..=128).contains(&verifier.len())
            && verifier
                .bytes()
                .all(|b| b.is_ascii_alphanumeric() || b"-._~".contains(&b));
        if !ok {
            return Err(RpError::Malformed(
                "a PKCE verifier is 43-128 unreserved characters".to_owned(),
            ));
        }
        Ok(Pkce {
            verifier: verifier.to_owned(),
            challenge: random_token(&sha2::Sha256::digest(verifier.as_bytes())),
        })
    }
}

/// RFC 3986 percent-encoding of everything but the unreserved set.
pub fn percent_encode(text: &str) -> String {
    let mut out = String::with_capacity(text.len());
    for b in text.bytes() {
        if b.is_ascii_alphanumeric() || b"-._~".contains(&b) {
            out.push(b as char);
        } else {
            out.push_str(&format!("%{b:02X}"));
        }
    }
    out
}

/// `application/x-www-form-urlencoded`, as RFC 6749 §2.3.1 says the client id
/// and secret are encoded before they go into the Basic header.
fn form_encode(text: &str) -> String {
    let mut out = String::with_capacity(text.len());
    for b in text.bytes() {
        match b {
            b' ' => out.push('+'),
            b if b.is_ascii_alphanumeric() || b"-._*".contains(&b) => out.push(b as char),
            b => out.push_str(&format!("%{b:02X}")),
        }
    }
    out
}

/// What the redirect to the IdP carries.
#[derive(Debug, Clone)]
pub struct AuthorizeRequest<'a> {
    pub authorization_endpoint: &'a str,
    pub client_id: &'a str,
    pub redirect_uri: &'a str,
    /// `openid` is added when absent.
    pub scopes: &'a [&'a str],
    pub state: &'a str,
    pub nonce: &'a str,
    pub code_challenge: &'a str,
    /// Optional `login_hint` (the address the person typed).
    pub login_hint: Option<&'a str>,
}

/// The authorization URL: `response_type=code`, PKCE S256, `state`, `nonce`.
/// A query the endpoint already carries is kept.
pub fn authorize_url(request: &AuthorizeRequest<'_>) -> Result<String, RpError> {
    for (what, value) in [
        ("client_id", request.client_id),
        ("redirect_uri", request.redirect_uri),
        ("state", request.state),
        ("nonce", request.nonce),
        ("code_challenge", request.code_challenge),
    ] {
        if value.trim().is_empty() {
            return Err(RpError::Malformed(format!("{what} is required")));
        }
    }
    let mut scopes: Vec<&str> = request
        .scopes
        .iter()
        .copied()
        .filter(|s| !s.trim().is_empty())
        .collect();
    if !scopes.contains(&"openid") {
        scopes.insert(0, "openid");
    }
    let mut pairs = vec![
        ("response_type", "code".to_owned()),
        ("client_id", request.client_id.to_owned()),
        ("redirect_uri", request.redirect_uri.to_owned()),
        ("scope", scopes.join(" ")),
        ("state", request.state.to_owned()),
        ("nonce", request.nonce.to_owned()),
        ("code_challenge", request.code_challenge.to_owned()),
        ("code_challenge_method", "S256".to_owned()),
    ];
    if let Some(hint) = request.login_hint.filter(|h| !h.trim().is_empty()) {
        pairs.push(("login_hint", hint.trim().to_owned()));
    }
    let query = pairs
        .iter()
        .map(|(k, v)| format!("{k}={}", percent_encode(v)))
        .collect::<Vec<_>>()
        .join("&");
    let sep = if request.authorization_endpoint.contains('?') {
        '&'
    } else {
        '?'
    };
    Ok(format!("{}{sep}{query}", request.authorization_endpoint))
}

/// The token request's form fields (`grant_type=authorization_code`). The
/// client authenticates with [`basic_auth_header`]; `client_id` is in the
/// form too, which every IdP accepts beside Basic.
pub fn token_request_form(
    code: &str,
    redirect_uri: &str,
    code_verifier: &str,
    client_id: &str,
) -> Vec<(String, String)> {
    vec![
        ("grant_type".to_owned(), "authorization_code".to_owned()),
        ("code".to_owned(), code.to_owned()),
        ("redirect_uri".to_owned(), redirect_uri.to_owned()),
        ("code_verifier".to_owned(), code_verifier.to_owned()),
        ("client_id".to_owned(), client_id.to_owned()),
    ]
}

/// The form fields as one `application/x-www-form-urlencoded` body.
pub fn form_body(fields: &[(String, String)]) -> String {
    fields
        .iter()
        .map(|(k, v)| format!("{}={}", form_encode(k), form_encode(v)))
        .collect::<Vec<_>>()
        .join("&")
}

/// `Basic base64(urlencode(id):urlencode(secret))` — `client_secret_basic`.
pub fn basic_auth_header(client_id: &str, client_secret: &str) -> String {
    use base64::Engine;
    let pair = format!("{}:{}", form_encode(client_id), form_encode(client_secret));
    format!(
        "Basic {}",
        base64::engine::general_purpose::STANDARD.encode(pair)
    )
}

/// The token endpoint's answer: the ID token is what the login rests on.
#[derive(Clone, PartialEq, Eq)]
pub struct TokenAnswer {
    pub id_token: String,
    pub token_type: String,
}

impl std::fmt::Debug for TokenAnswer {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("TokenAnswer")
            .field("token_type", &self.token_type)
            .finish_non_exhaustive()
    }
}

/// Parse the token endpoint's body: an `id_token`, or the IdP's OAuth error
/// ([`RpError::Idp`], its `error` and `error_description` only).
pub fn parse_token_response(json: &str) -> Result<TokenAnswer, RpError> {
    let value: serde_json::Value = serde_json::from_str(json)
        .map_err(|e| RpError::Malformed(format!("the token response does not parse: {e}")))?;
    if let Some(error) = value.get("error").and_then(|e| e.as_str()) {
        let description = value
            .get("error_description")
            .and_then(|d| d.as_str())
            .unwrap_or_default();
        let description: String = description.chars().take(200).collect();
        return Err(RpError::Idp(if description.is_empty() {
            error.to_owned()
        } else {
            format!("{error}: {description}")
        }));
    }
    let id_token = value
        .get("id_token")
        .and_then(|t| t.as_str())
        .filter(|t| !t.is_empty())
        .ok_or_else(|| RpError::Malformed("the token response carries no id_token".to_owned()))?;
    let token_type = value
        .get("token_type")
        .and_then(|t| t.as_str())
        .unwrap_or_default()
        .to_owned();
    Ok(TokenAnswer {
        id_token: id_token.to_owned(),
        token_type,
    })
}

/// What an ID token must say.
#[derive(Debug, Clone)]
pub struct IdTokenExpect<'a> {
    /// The configured issuer; `iss` must equal it (one trailing `/` aside).
    pub issuer: &'a str,
    pub client_id: &'a str,
    /// The nonce this login sent.
    pub nonce: &'a str,
    /// The caller's clock, seconds since the epoch.
    pub now_unix: i64,
    /// Tolerated clock skew, capped at [`MAX_SKEW_SECS`].
    pub skew_secs: i64,
    /// The allow-list. Use [`DEFAULT_ALGORITHMS`]; an `HS*` entry is ignored.
    pub algorithms: &'a [Algorithm],
}

/// A verified ID token's claims.
#[derive(Debug, Clone, PartialEq)]
pub struct IdTokenClaims {
    pub iss: String,
    pub sub: String,
    pub aud: Vec<String>,
    pub exp: i64,
    pub iat: i64,
    /// The whole claim set, for the claim a deployment maps to a user name.
    pub claims: serde_json::Map<String, serde_json::Value>,
}

impl IdTokenClaims {
    /// A string claim (`preferred_username`, `email`, `name`, …), trimmed;
    /// `None` when absent, empty or not a string.
    pub fn string(&self, name: &str) -> Option<String> {
        self.claims
            .get(name)
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .map(str::to_owned)
    }
}

fn is_hmac(alg: Algorithm) -> bool {
    matches!(alg, Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512)
}

/// Does `jwk` fit `alg`: its type (RSA for RS*/PS*, P-256 for ES256, P-384 for
/// ES384), its `use` (sig, when stated) and its own `alg` (when stated).
fn key_fits(jwk: &Jwk, alg: Algorithm) -> Result<(), RpError> {
    let type_fits = match (&jwk.algorithm, alg) {
        (
            AlgorithmParameters::RSA(_),
            Algorithm::RS256
            | Algorithm::RS384
            | Algorithm::RS512
            | Algorithm::PS256
            | Algorithm::PS384
            | Algorithm::PS512,
        ) => true,
        (AlgorithmParameters::EllipticCurve(ec), Algorithm::ES256) => {
            ec.curve == EllipticCurve::P256
        }
        (AlgorithmParameters::EllipticCurve(ec), Algorithm::ES384) => {
            ec.curve == EllipticCurve::P384
        }
        _ => false,
    };
    if !type_fits {
        return Err(RpError::Signature(format!(
            "the key does not fit algorithm {alg:?}"
        )));
    }
    if let Some(usage) = &jwk.common.public_key_use {
        if *usage != PublicKeyUse::Signature {
            return Err(RpError::Signature(
                "the key is not a signing key".to_owned(),
            ));
        }
    }
    if let Some(key_alg) = jwk.common.key_algorithm {
        let same = matches!(
            (key_alg, alg),
            (KeyAlgorithm::RS256, Algorithm::RS256)
                | (KeyAlgorithm::RS384, Algorithm::RS384)
                | (KeyAlgorithm::RS512, Algorithm::RS512)
                | (KeyAlgorithm::PS256, Algorithm::PS256)
                | (KeyAlgorithm::PS384, Algorithm::PS384)
                | (KeyAlgorithm::PS512, Algorithm::PS512)
                | (KeyAlgorithm::ES256, Algorithm::ES256)
                | (KeyAlgorithm::ES384, Algorithm::ES384)
        );
        if !same {
            return Err(RpError::Signature(format!(
                "the key is for {key_alg:?}, the token says {alg:?}"
            )));
        }
    }
    Ok(())
}

fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
    if a.len() != b.len() {
        return false;
    }
    a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}

fn int_claim(
    claims: &serde_json::Map<String, serde_json::Value>,
    name: &str,
) -> Result<Option<i64>, RpError> {
    match claims.get(name) {
        None | Some(serde_json::Value::Null) => Ok(None),
        Some(v) => v
            .as_i64()
            .or_else(|| v.as_f64().filter(|f| f.is_finite()).map(|f| f as i64))
            .map(Some)
            .ok_or_else(|| RpError::Claim(format!("`{name}` is not a number"))),
    }
}

/// The signature half every token check shares (ID token and bearer access
/// token, A34-13: one verifier): the size cap, the header's `alg` on the
/// allow-list and never `HS*`, the key by `kid` (a header without one only
/// when the set has exactly one key), the key fitting the algorithm
/// ([`key_fits`]: type, curve, `use`, its own `alg`), and the signature.
/// Returns the claim set, unchecked; the callers hold its claims.
fn verify_signature(
    token: &str,
    set: &JwkSet,
    algorithms: &[Algorithm],
    what: &str,
) -> Result<serde_json::Map<String, serde_json::Value>, RpError> {
    if token.len() > 16 * 1024 {
        return Err(RpError::Malformed(format!("the {what} is longer than 16 KiB")));
    }
    let header = jsonwebtoken::decode_header(token)
        .map_err(|e| RpError::Malformed(format!("the {what}'s header does not parse: {e}")))?;
    let alg = header.alg;
    if is_hmac(alg) || !algorithms.contains(&alg) {
        return Err(RpError::Signature(format!(
            "algorithm {alg:?} is not allowed for an {what}"
        )));
    }
    let jwk = match header.kid.as_deref() {
        Some(kid) => set.find(kid).ok_or_else(|| {
            RpError::UnknownKey(format!("no key {kid:?} in the identity provider's JWKS"))
        })?,
        None => match set.keys.as_slice() {
            [only] => only,
            _ => {
                return Err(RpError::Signature(format!(
                    "the {what} names no key and the JWKS holds several"
                )))
            }
        },
    };
    key_fits(jwk, alg)?;
    let key = DecodingKey::from_jwk(jwk)
        .map_err(|e| RpError::Signature(format!("the key does not load: {e}")))?;
    let mut validation = Validation::new(alg);
    validation.algorithms = vec![alg];
    // The claims are checked by the callers against the CALLER's clock;
    // jsonwebtoken would read the system clock, which a test (and a replay
    // check) cannot pin.
    validation.validate_exp = false;
    validation.validate_nbf = false;
    validation.validate_aud = false;
    validation.required_spec_claims.clear();
    jsonwebtoken::decode::<serde_json::Map<String, serde_json::Value>>(token, &key, &validation)
        .map(|data| data.claims)
        .map_err(|e| RpError::Signature(format!("the {what} does not verify: {e}")))
}

/// `iss` exact (one trailing `/` aside).
fn check_issuer(
    claims: &serde_json::Map<String, serde_json::Value>,
    issuer: &str,
    what: &str,
) -> Result<String, RpError> {
    let iss = claims
        .get("iss")
        .and_then(|v| v.as_str())
        .unwrap_or_default()
        .to_owned();
    if iss.is_empty() || iss.trim_end_matches('/') != issuer.trim().trim_end_matches('/') {
        return Err(RpError::Claim(format!(
            "the {what}'s issuer is {iss:?}, not {issuer:?}"
        )));
    }
    Ok(iss)
}

/// `aud` (a string or an array of strings) containing `wanted`.
fn check_audience(
    claims: &serde_json::Map<String, serde_json::Value>,
    wanted: &str,
    what: &str,
) -> Result<Vec<String>, RpError> {
    let aud: Vec<String> = match claims.get("aud") {
        Some(serde_json::Value::String(one)) => vec![one.clone()],
        Some(serde_json::Value::Array(many)) => many
            .iter()
            .map(|v| {
                v.as_str()
                    .map(str::to_owned)
                    .ok_or_else(|| RpError::Claim("`aud` holds a non-string".to_owned()))
            })
            .collect::<Result<_, _>>()?,
        _ => return Err(RpError::Claim(format!("the {what} has no audience"))),
    };
    if wanted.trim().is_empty() || !aud.iter().any(|a| a == wanted) {
        return Err(RpError::Claim(format!(
            "the {what} is not for this audience (wrong audience)"
        )));
    }
    Ok(aud)
}

/// `exp` in the future, `iat` (required when `iat_required`) and `nbf` not
/// in it, all within the skew. Returns (`exp`, `iat` or 0).
fn check_times(
    claims: &serde_json::Map<String, serde_json::Value>,
    now: i64,
    skew_secs: i64,
    iat_required: bool,
    what: &str,
) -> Result<(i64, i64), RpError> {
    let skew = skew_secs.clamp(0, MAX_SKEW_SECS);
    let exp = int_claim(claims, "exp")?
        .ok_or_else(|| RpError::Claim(format!("the {what} has no `exp`")))?;
    if exp <= now - skew {
        return Err(RpError::Claim(format!("the {what} has expired")));
    }
    let iat = match int_claim(claims, "iat")? {
        Some(iat) => iat,
        None if iat_required => {
            return Err(RpError::Claim(format!("the {what} has no `iat`")))
        }
        None => 0,
    };
    if iat > now + skew {
        return Err(RpError::Claim(format!("the {what} was issued in the future")));
    }
    if let Some(nbf) = int_claim(claims, "nbf")? {
        if nbf > now + skew {
            return Err(RpError::Claim(format!("the {what} is not valid yet")));
        }
    }
    Ok((exp, iat))
}

/// `sub` present and at most 255 bytes.
fn check_subject(
    claims: &serde_json::Map<String, serde_json::Value>,
    what: &str,
) -> Result<String, RpError> {
    let sub = claims
        .get("sub")
        .and_then(|v| v.as_str())
        .unwrap_or_default()
        .to_owned();
    if sub.is_empty() || sub.len() > 255 {
        return Err(RpError::Claim(format!(
            "the {what}'s subject is missing or longer than 255"
        )));
    }
    Ok(sub)
}

/// **Verify an ID token** (OIDC Core §3.1.3.7) against `jwks_json`.
///
/// The header's `alg` must be on `expect.algorithms` and never `HS*`; the key
/// is found by `kid` (a header without one is accepted only when the JWKS has
/// exactly one key) and must fit the algorithm; the signature is
/// `jsonwebtoken`'s. Then, against the caller's clock: `iss` exact, `aud`
/// containing the client id, `azp` the client id when present (and required
/// when `aud` has several values), `exp` in the future and `iat`/`nbf` not in
/// it (both within the skew), `sub` present, `nonce` equal.
pub fn verify_id_token(
    token: &str,
    jwks_json: &str,
    expect: &IdTokenExpect<'_>,
) -> Result<IdTokenClaims, RpError> {
    const WHAT: &str = "ID token";
    if token.len() > 16 * 1024 {
        return Err(RpError::Malformed(
            "the ID token is longer than 16 KiB".to_owned(),
        ));
    }
    let set: JwkSet = serde_json::from_str(jwks_json)
        .map_err(|e| RpError::Malformed(format!("the JWKS does not parse: {e}")))?;
    let claims = verify_signature(token, &set, expect.algorithms, WHAT)?;
    let iss = check_issuer(&claims, expect.issuer, WHAT)?;
    let aud = check_audience(&claims, expect.client_id, WHAT).map_err(|e| match e {
        RpError::Claim(m) if m.contains("wrong audience") => RpError::Claim(
            "the ID token is not for this client (wrong audience)".to_owned(),
        ),
        other => other,
    })?;
    match claims.get("azp").and_then(|v| v.as_str()) {
        Some(azp) if azp != expect.client_id => {
            return Err(RpError::Claim(
                "the ID token's authorized party is another client".to_owned(),
            ))
        }
        None if aud.len() > 1 => {
            return Err(RpError::Claim(
                "the ID token has several audiences and no authorized party".to_owned(),
            ))
        }
        _ => {}
    }
    let (exp, iat) = check_times(&claims, expect.now_unix, expect.skew_secs, true, WHAT)?;
    let sub = check_subject(&claims, WHAT)?;
    let nonce = claims
        .get("nonce")
        .and_then(|v| v.as_str())
        .unwrap_or_default();
    if expect.nonce.is_empty() || !constant_time_eq(nonce.as_bytes(), expect.nonce.as_bytes()) {
        return Err(RpError::Claim(
            "the ID token's nonce is not this login's".to_owned(),
        ));
    }
    Ok(IdTokenClaims {
        iss,
        sub,
        aud,
        exp,
        iat,
        claims,
    })
}

/// What a bearer ACCESS token (a JWT a resource server is handed) must say.
#[derive(Debug, Clone)]
pub struct AccessTokenExpect<'a> {
    /// The configured issuer; `iss` must equal it (one trailing `/` aside).
    pub issuer: &'a str,
    /// This resource server's audience; `aud` must contain it. Required:
    /// an empty audience refuses every token.
    pub audience: &'a str,
    /// The caller's clock, seconds since the epoch.
    pub now_unix: i64,
    /// Tolerated clock skew, capped at [`MAX_SKEW_SECS`].
    pub skew_secs: i64,
    /// The allow-list. Use [`DEFAULT_ALGORITHMS`]; an `HS*` entry is ignored.
    pub algorithms: &'a [Algorithm],
}

/// **Verify a bearer access token** (a JWT) with the same checks as
/// [`verify_id_token`] (A34-1, A34-3, A34-13, A34-29): the algorithm
/// allow-list, the key fitting the algorithm, the signature, `iss` exact,
/// `aud` containing the configured audience (REQUIRED), `exp` required and in
/// the future, `iat`/`nbf` not in it, `sub` present. There is no nonce and no
/// `azp` rule: an access token's `azp` names the client that asked for it, not
/// this server.
pub fn verify_access_token(
    token: &str,
    set: &JwkSet,
    expect: &AccessTokenExpect<'_>,
) -> Result<IdTokenClaims, RpError> {
    const WHAT: &str = "access token";
    if expect.audience.trim().is_empty() {
        return Err(RpError::Config(
            "no audience is configured, so no access token can be bound to this server".to_owned(),
        ));
    }
    let claims = verify_signature(token, set, expect.algorithms, WHAT)?;
    let iss = check_issuer(&claims, expect.issuer, WHAT)?;
    let aud = check_audience(&claims, expect.audience, WHAT)?;
    let (exp, iat) = check_times(&claims, expect.now_unix, expect.skew_secs, false, WHAT)?;
    let sub = check_subject(&claims, WHAT)?;
    Ok(IdTokenClaims {
        iss,
        sub,
        aud,
        exp,
        iat,
        claims,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_support::{jwks_json, sign, TEST_KID};
    use jsonwebtoken::{encode, EncodingKey, Header};
    use serde_json::json;

    const ISS: &str = "https://idp.example.test/realms/acme";
    const CLIENT: &str = "gunnar-acme";
    const NONCE: &str = "n-0S6_WzA2Mj";
    const NOW: i64 = 1_800_000_000;

    // A throwaway P-256 key for the ES256 path; test-only, never a credential.
    const EC_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg9WExgS1XLDET7dQj\nbF0XxO29mUF1fpz98MGagi99HQuhRANCAARp0GFV7+URFFOg69Wpi2TiAEo4F1uN\nO6zaV3ctsK5HRELg86bwzn8zSz4gN5K7kw/mRb2LJO1cXfMTdT7Ywj8t\n-----END PRIVATE KEY-----\n";
    const EC_X: &str = "adBhVe_lERRToOvVqYtk4gBKOBdbjTus2ld3LbCuR0Q";
    const EC_Y: &str = "QuDzpvDOfzNLPiA3kruTD-ZFvYsk7Vxd8xN1PtjCPy0";

    fn claims() -> serde_json::Value {
        json!({
            "iss": ISS, "sub": "00u1abcd", "aud": CLIENT, "exp": NOW + 300, "iat": NOW - 5,
            "nonce": NONCE, "preferred_username": "bob@acme.example", "email": "bob@acme.example",
        })
    }

    fn expect() -> IdTokenExpect<'static> {
        IdTokenExpect {
            issuer: ISS,
            client_id: CLIENT,
            nonce: NONCE,
            now_unix: NOW,
            skew_secs: 60,
            algorithms: DEFAULT_ALGORITHMS,
        }
    }

    fn with(edit: impl FnOnce(&mut serde_json::Map<String, serde_json::Value>)) -> String {
        let mut c = claims();
        edit(c.as_object_mut().unwrap());
        sign(c)
    }

    #[test]
    fn a_good_token_verifies_and_carries_its_claims() {
        let got = verify_id_token(&sign(claims()), &jwks_json(), &expect()).expect("verifies");
        assert_eq!(got.sub, "00u1abcd");
        assert_eq!(
            got.string("preferred_username").as_deref(),
            Some("bob@acme.example")
        );
        assert_eq!(got.aud, vec![CLIENT.to_owned()]);
    }

    #[test]
    fn wrong_audience_is_refused() {
        let t = with(|c| {
            c.insert("aud".into(), json!("somebody-else"));
        });
        assert!(matches!(
            verify_id_token(&t, &jwks_json(), &expect()),
            Err(RpError::Claim(_))
        ));
    }

    #[test]
    fn several_audiences_need_an_azp_naming_this_client() {
        let t = with(|c| {
            c.insert("aud".into(), json!([CLIENT, "other"]));
        });
        assert!(matches!(
            verify_id_token(&t, &jwks_json(), &expect()),
            Err(RpError::Claim(_))
        ));
        let t = with(|c| {
            c.insert("aud".into(), json!([CLIENT, "other"]));
            c.insert("azp".into(), json!(CLIENT));
        });
        assert!(verify_id_token(&t, &jwks_json(), &expect()).is_ok());
        let t = with(|c| {
            c.insert("azp".into(), json!("other"));
        });
        assert!(matches!(
            verify_id_token(&t, &jwks_json(), &expect()),
            Err(RpError::Claim(_))
        ));
    }

    #[test]
    fn wrong_issuer_is_refused() {
        let t = with(|c| {
            c.insert("iss".into(), json!("https://evil.example.test"));
        });
        assert!(matches!(
            verify_id_token(&t, &jwks_json(), &expect()),
            Err(RpError::Claim(_))
        ));
    }

    #[test]
    fn expiry_and_clock_skew() {
        // Expired 30 s ago: inside a 60 s skew, accepted; 120 s ago, refused.
        let t = with(|c| {
            c.insert("exp".into(), json!(NOW - 30));
        });
        assert!(verify_id_token(&t, &jwks_json(), &expect()).is_ok());
        let t = with(|c| {
            c.insert("exp".into(), json!(NOW - 120));
        });
        assert!(matches!(
            verify_id_token(&t, &jwks_json(), &expect()),
            Err(RpError::Claim(_))
        ));
        // Issued 10 minutes in the future: refused; a skew asked above the cap is capped.
        let t = with(|c| {
            c.insert("iat".into(), json!(NOW + 600));
        });
        let mut e = expect();
        e.skew_secs = 10_000;
        assert!(matches!(
            verify_id_token(&t, &jwks_json(), &e),
            Err(RpError::Claim(_))
        ));
        let t = with(|c| {
            c.insert("nbf".into(), json!(NOW + 600));
        });
        assert!(matches!(
            verify_id_token(&t, &jwks_json(), &expect()),
            Err(RpError::Claim(_))
        ));
    }

    #[test]
    fn a_wrong_or_missing_nonce_is_refused() {
        let t = with(|c| {
            c.insert("nonce".into(), json!("another-login"));
        });
        assert!(matches!(
            verify_id_token(&t, &jwks_json(), &expect()),
            Err(RpError::Claim(_))
        ));
        let t = with(|c| {
            c.remove("nonce");
        });
        assert!(matches!(
            verify_id_token(&t, &jwks_json(), &expect()),
            Err(RpError::Claim(_))
        ));
    }

    #[test]
    fn a_tampered_payload_does_not_verify() {
        let t = sign(claims());
        let mut parts: Vec<String> = t.split('.').map(str::to_owned).collect();
        let forged = with(|c| {
            c.insert("sub".into(), json!("admin"));
        });
        parts[1] = forged.split('.').nth(1).unwrap().to_owned();
        let t = parts.join(".");
        assert!(matches!(
            verify_id_token(&t, &jwks_json(), &expect()),
            Err(RpError::Signature(_))
        ));
    }

    #[test]
    fn alg_none_is_refused() {
        use base64::Engine;
        let b = |v: &serde_json::Value| {
            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(v.to_string())
        };
        let t = format!(
            "{}.{}.",
            b(&json!({"alg": "none", "kid": TEST_KID})),
            b(&claims())
        );
        assert!(verify_id_token(&t, &jwks_json(), &expect()).is_err());
    }

    /// The algorithm-confusion forgery: HS256 keyed with the RSA public key's
    /// bytes. Refused by the allow-list, and — even when a careless caller
    /// lists HS256 — by the key family.
    #[test]
    fn hs256_over_an_rsa_key_is_refused_even_when_listed() {
        let mut header = Header::new(Algorithm::HS256);
        header.kid = Some(TEST_KID.to_owned());
        let t = encode(
            &header,
            &claims(),
            &EncodingKey::from_secret(crate::test_support::TEST_N.as_bytes()),
        )
        .unwrap();
        assert!(matches!(
            verify_id_token(&t, &jwks_json(), &expect()),
            Err(RpError::Signature(_))
        ));
        let mut e = expect();
        let careless = [Algorithm::HS256, Algorithm::RS256];
        e.algorithms = &careless;
        assert!(matches!(
            verify_id_token(&t, &jwks_json(), &e),
            Err(RpError::Signature(_))
        ));
    }

    #[test]
    fn an_unknown_kid_says_refetch() {
        let mut header = Header::new(Algorithm::RS256);
        header.kid = Some("rotated-key".to_owned());
        let key = EncodingKey::from_rsa_pem(crate::test_support::TEST_RSA_PEM.as_bytes()).unwrap();
        let t = encode(&header, &claims(), &key).unwrap();
        assert!(matches!(
            verify_id_token(&t, &jwks_json(), &expect()),
            Err(RpError::UnknownKey(_))
        ));
    }

    #[test]
    fn a_key_whose_own_alg_differs_is_refused() {
        let jwks = jwks_json().replace("\"alg\":\"RS256\"", "\"alg\":\"PS256\"");
        assert!(matches!(
            verify_id_token(&sign(claims()), &jwks, &expect()),
            Err(RpError::Signature(_))
        ));
        let jwks = jwks_json().replace("\"use\":\"sig\"", "\"use\":\"enc\"");
        assert!(matches!(
            verify_id_token(&sign(claims()), &jwks, &expect()),
            Err(RpError::Signature(_))
        ));
    }

    #[test]
    fn es256_verifies_and_an_rsa_algorithm_over_an_ec_key_is_refused() {
        let jwks = format!(
            r#"{{"keys":[{{"kty":"EC","crv":"P-256","kid":"ec1","use":"sig","x":"{EC_X}","y":"{EC_Y}"}}]}}"#
        );
        let mut header = Header::new(Algorithm::ES256);
        header.kid = Some("ec1".to_owned());
        let t = encode(
            &header,
            &claims(),
            &EncodingKey::from_ec_pem(EC_PEM.as_bytes()).unwrap(),
        )
        .unwrap();
        assert!(verify_id_token(&t, &jwks, &expect()).is_ok());
        let mut header = Header::new(Algorithm::RS256);
        header.kid = Some("ec1".to_owned());
        let key = EncodingKey::from_rsa_pem(crate::test_support::TEST_RSA_PEM.as_bytes()).unwrap();
        let t = encode(&header, &claims(), &key).unwrap();
        assert!(matches!(
            verify_id_token(&t, &jwks, &expect()),
            Err(RpError::Signature(_))
        ));
    }

    #[test]
    fn discovery_holds_the_issuer_and_https() {
        let doc = |iss: &str, base: &str| {
            format!(
                r#"{{"issuer":"{iss}","authorization_endpoint":"{base}/auth","token_endpoint":"{base}/token","jwks_uri":"{base}/jwks","code_challenge_methods_supported":["S256"]}}"#
            )
        };
        let d = Discovery::parse(&doc(ISS, "https://idp.example.test"), ISS, false).unwrap();
        assert_eq!(d.token_endpoint, "https://idp.example.test/token");
        assert!(matches!(
            Discovery::parse(
                &doc("https://other.example.test", "https://idp.example.test"),
                ISS,
                false
            ),
            Err(RpError::Config(_))
        ));
        assert!(matches!(
            Discovery::parse(&doc(ISS, "http://idp.example.test"), ISS, false),
            Err(RpError::Config(_))
        ));
        assert!(Discovery::parse(&doc(ISS, "http://127.0.0.1:8080"), ISS, true).is_ok());
        assert!(matches!(
            Discovery::parse(&doc(ISS, "http://idp.example.test"), ISS, true),
            Err(RpError::Config(_))
        ));
        let plain_only = doc(ISS, "https://idp.example.test").replace("[\"S256\"]", "[\"plain\"]");
        assert!(matches!(
            Discovery::parse(&plain_only, ISS, false),
            Err(RpError::Config(_))
        ));
    }

    #[test]
    fn the_authorize_url_carries_pkce_state_and_nonce() {
        // RFC 7636 appendix B.
        let pkce = Pkce::from_verifier("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk").unwrap();
        assert_eq!(
            pkce.challenge,
            "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
        );
        assert!(Pkce::from_verifier("short").is_err());
        let url = authorize_url(&AuthorizeRequest {
            authorization_endpoint: "https://idp.example.test/auth?tenant=acme",
            client_id: CLIENT,
            redirect_uri: "https://gunnar.example/auth/sso/oidc/callback",
            scopes: &["email", "profile"],
            state: "st",
            nonce: NONCE,
            code_challenge: &pkce.challenge,
            login_hint: Some("bob@acme.example"),
        })
        .unwrap();
        assert!(url.starts_with("https://idp.example.test/auth?tenant=acme&response_type=code&"));
        assert!(url.contains("scope=openid%20email%20profile"));
        assert!(url.contains("code_challenge_method=S256"));
        assert!(url
            .contains("redirect_uri=https%3A%2F%2Fgunnar.example%2Fauth%2Fsso%2Foidc%2Fcallback"));
        assert!(url.contains("login_hint=bob%40acme.example"));
        assert!(
            !format!("{pkce:?}").contains(&pkce.verifier),
            "the verifier never reaches a Debug line"
        );
    }

    #[test]
    fn the_token_exchange_pieces() {
        assert_eq!(basic_auth_header("my id", "s3cr%t"), {
            use base64::Engine;
            format!(
                "Basic {}",
                base64::engine::general_purpose::STANDARD.encode("my+id:s3cr%25t")
            )
        });
        let body = form_body(&token_request_form(
            "c0de",
            "https://g.example/cb",
            "v",
            CLIENT,
        ));
        assert_eq!(body, "grant_type=authorization_code&code=c0de&redirect_uri=https%3A%2F%2Fg.example%2Fcb&code_verifier=v&client_id=gunnar-acme");
        let ok = parse_token_response(
            r#"{"access_token":"a","token_type":"Bearer","id_token":"x.y.z"}"#,
        )
        .unwrap();
        assert_eq!(ok.id_token, "x.y.z");
        assert!(!format!("{ok:?}").contains("x.y.z"));
        assert_eq!(
            parse_token_response(r#"{"error":"invalid_grant","error_description":"code used"}"#),
            Err(RpError::Idp("invalid_grant: code used".to_owned()))
        );
        assert!(matches!(
            parse_token_response(r#"{"access_token":"a"}"#),
            Err(RpError::Malformed(_))
        ));
    }

    #[test]
    fn loopback_http_is_only_loopback() {
        assert!(endpoint_ok("https://idp.example.test/x", false));
        assert!(!endpoint_ok("http://127.0.0.1:9/x", false));
        assert!(endpoint_ok("http://127.0.0.1:9/x", true));
        assert!(endpoint_ok("http://localhost/x", true));
        assert!(!endpoint_ok("http://127.0.0.1.evil.test/x", true));
        assert!(!endpoint_ok("http://evil.test/127.0.0.1", true));
        assert!(!endpoint_ok("ftp://127.0.0.1/", true));
    }
}