arcly-http-identity 0.7.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
//! OIDC client (relying party) registry.

use std::collections::HashMap;

use subtle::ConstantTimeEq;

/// A registered relying party.
#[derive(Debug, Clone)]
pub struct OidcClient {
    pub client_id: String,
    /// `None` for a public client (SPA / native app) — PKCE is then required.
    /// `Some(secret)` for a confidential client (server-side).
    pub client_secret: Option<String>,
    /// Exact-match allow-list of redirect URIs (no wildcard — open redirects are
    /// a top OAuth vulnerability).
    pub redirect_uris: Vec<String>,
    pub allowed_scopes: Vec<String>,
}

impl OidcClient {
    pub fn allows_redirect(&self, uri: &str) -> bool {
        self.redirect_uris.iter().any(|u| u == uri)
    }
    /// Public clients (no secret) must use PKCE.
    pub fn requires_pkce(&self) -> bool {
        self.client_secret.is_none()
    }
}

/// Client authentication presented at the token endpoint.
#[derive(Debug, Clone)]
pub struct ClientAuth {
    pub client_id: String,
    /// `None` for public clients using PKCE only.
    pub client_secret: Option<String>,
}

/// Frozen registry of clients. Build at boot; look up per request.
#[derive(Default)]
pub struct OidcClientRegistry {
    clients: HashMap<String, OidcClient>,
}

impl OidcClientRegistry {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn register(mut self, client: OidcClient) -> Self {
        self.clients.insert(client.client_id.clone(), client);
        self
    }

    pub fn get(&self, client_id: &str) -> Option<&OidcClient> {
        self.clients.get(client_id)
    }

    /// Authenticate a token-endpoint request: verifies the client exists and, for
    /// confidential clients, that the secret matches (constant-time). Public
    /// clients authenticate via PKCE at the code-exchange step, not here.
    pub fn authenticate(&self, auth: &ClientAuth) -> Option<&OidcClient> {
        let client = self.clients.get(&auth.client_id)?;
        match (&client.client_secret, &auth.client_secret) {
            // Confidential: secret must match.
            (Some(expected), Some(presented)) => {
                let ok: bool = expected.as_bytes().ct_eq(presented.as_bytes()).into();
                ok.then_some(client)
            }
            // Public: no secret expected, none presented.
            (None, None) => Some(client),
            // Mismatched expectation → reject.
            _ => None,
        }
    }
}