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
//! OpenID Connect **Provider** — turn the app into an identity provider.
//!
//! This layer is transport-agnostic: it exposes pure async methods
//! (`authorize`, `token`, `userinfo`, `discovery_document`, `jwks`) returning
//! serde types. The app mounts them on whatever routes it likes (a reference
//! controller lives in the example). Access/ID tokens are signed by the
//! framework's [`JwtService`], which must be configured with an **asymmetric**
//! algorithm (RS256/ES256) so the JWKS can publish a verify key — see
//! Phase 0 in `docs/CIAM_PLAN.md`.
//!
//! Supported: Authorization Code + PKCE, refresh, token introspection
//! (RFC 7662) and revocation (RFC 7009). Client-credentials is a small addition
//! on top of [`OidcProvider::token`].

mod client;
mod code;

pub use client::{ClientAuth, OidcClient, OidcClientRegistry};
pub use code::{AuthCode, AuthCodeStore, MemoryAuthCodeStore};

use std::sync::Arc;

use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;

use arcly_http_core::auth::JwtService;

use crate::error::{IdentityError, Result};
use crate::identity::{new_id, IdentityService};
use crate::store::UserStore;

/// Static provider configuration.
#[derive(Debug, Clone)]
pub struct OidcConfig {
    /// The issuer URL (`iss`) — must exactly match the deployment's public base.
    pub issuer: String,
    /// The public JWKS document the provider serves at `/.well-known/jwks.json`.
    /// Supplied by the app because deriving a JWK from a PEM requires an ASN.1
    /// parser the framework deliberately does not link. Build it once from your
    /// public key (e.g. with `josekit`/`jsonwebkey`) and hand it in here.
    pub jwks: serde_json::Value,
    /// Lifetime of issued `id_token`s in seconds.
    pub id_token_ttl_secs: u64,
    /// Authorization-code lifetime in seconds (short — 60s is typical).
    pub code_ttl_secs: u64,
}

/// The OIDC provider. Provide via DI and mount its methods.
pub struct OidcProvider {
    config: OidcConfig,
    clients: Arc<OidcClientRegistry>,
    codes: Arc<dyn AuthCodeStore>,
    jwt: Arc<JwtService>,
    users: Arc<dyn UserStore>,
    identity: IdentityService,
}

/// Parsed `/authorize` request (already URL-decoded by the controller).
#[derive(Debug, Clone, Deserialize)]
pub struct AuthorizeRequest {
    pub client_id: String,
    pub redirect_uri: String,
    pub response_type: String,
    pub scope: String,
    #[serde(default)]
    pub state: Option<String>,
    #[serde(default)]
    pub nonce: Option<String>,
    #[serde(default)]
    pub code_challenge: Option<String>,
    #[serde(default)]
    pub code_challenge_method: Option<String>,
}

/// The `/token` exchange response (Authorization Code grant).
#[derive(Debug, Clone, Serialize)]
pub struct OidcTokenResponse {
    pub access_token: String,
    pub id_token: String,
    pub refresh_token: String,
    pub token_type: &'static str,
    pub expires_in: u64,
    pub scope: String,
}

impl OidcProvider {
    pub fn new(
        config: OidcConfig,
        clients: Arc<OidcClientRegistry>,
        codes: Arc<dyn AuthCodeStore>,
        jwt: Arc<JwtService>,
        users: Arc<dyn UserStore>,
        identity: IdentityService,
    ) -> Self {
        Self {
            config,
            clients,
            codes,
            jwt,
            users,
            identity,
        }
    }

    /// The OIDC discovery document.
    pub fn discovery_document(&self) -> serde_json::Value {
        let iss = &self.config.issuer;
        serde_json::json!({
            "issuer": iss,
            "authorization_endpoint": format!("{iss}/oauth2/authorize"),
            "token_endpoint": format!("{iss}/oauth2/token"),
            "userinfo_endpoint": format!("{iss}/oauth2/userinfo"),
            "jwks_uri": format!("{iss}/.well-known/jwks.json"),
            "introspection_endpoint": format!("{iss}/oauth2/introspect"),
            "revocation_endpoint": format!("{iss}/oauth2/revoke"),
            "response_types_supported": ["code"],
            "grant_types_supported": ["authorization_code", "refresh_token"],
            "subject_types_supported": ["public"],
            "id_token_signing_alg_values_supported": [self.jwt.algorithm_name()],
            "scopes_supported": ["openid", "profile", "email", "offline_access"],
            "token_endpoint_auth_methods_supported": ["client_secret_post", "none"],
            "code_challenge_methods_supported": ["S256"],
        })
    }

    /// The JWKS document (verification keys).
    pub fn jwks(&self) -> serde_json::Value {
        self.config.jwks.clone()
    }

    /// Handle `/authorize` for an **already-authenticated** end user
    /// (`user_id` established by the app's login session). Validates the client
    /// and PKCE, mints a single-use authorization code, and returns the
    /// redirect URL (with `code` + echoed `state`) to send the browser to.
    pub async fn authorize(&self, req: &AuthorizeRequest, user_id: &str) -> Result<String> {
        if req.response_type != "code" {
            return Err(IdentityError::Forbidden);
        }
        let client = self
            .clients
            .get(&req.client_id)
            .ok_or(IdentityError::Forbidden)?;
        if !client.allows_redirect(&req.redirect_uri) {
            return Err(IdentityError::Forbidden);
        }
        // PKCE is mandatory for public clients.
        if client.requires_pkce()
            && (req.code_challenge.is_none()
                || req.code_challenge_method.as_deref() != Some("S256"))
        {
            return Err(IdentityError::Forbidden);
        }

        let code = new_id();
        self.codes
            .put(AuthCode {
                code: code.clone(),
                client_id: req.client_id.clone(),
                user_id: user_id.to_owned(),
                redirect_uri: req.redirect_uri.clone(),
                scope: req.scope.clone(),
                nonce: req.nonce.clone(),
                code_challenge: req.code_challenge.clone(),
                expires_at: JwtService::unix_now() + self.config.code_ttl_secs,
            })
            .await?;

        let mut url = format!("{}?code={}", req.redirect_uri, code);
        if let Some(state) = &req.state {
            url.push_str(&format!("&state={state}"));
        }
        Ok(url)
    }

    /// Handle the `authorization_code` grant at `/token`: verify the code, PKCE
    /// verifier, and client auth, then mint access + id + refresh tokens.
    pub async fn token(
        &self,
        code: &str,
        redirect_uri: &str,
        client_auth: &ClientAuth,
        code_verifier: Option<&str>,
    ) -> Result<OidcTokenResponse> {
        let client = self
            .clients
            .authenticate(client_auth)
            .ok_or(IdentityError::Forbidden)?;

        let record = self
            .codes
            .take(code)
            .await?
            .ok_or(IdentityError::InvalidToken)?;

        // Bind the code to the client + redirect it was issued for.
        if record.client_id != client.client_id || record.redirect_uri != redirect_uri {
            return Err(IdentityError::InvalidToken);
        }
        if JwtService::unix_now() >= record.expires_at {
            return Err(IdentityError::InvalidToken);
        }
        // PKCE verification.
        if let Some(challenge) = &record.code_challenge {
            let verifier = code_verifier.ok_or(IdentityError::InvalidToken)?;
            if !pkce_matches(verifier, challenge) {
                return Err(IdentityError::InvalidToken);
            }
        }

        let user = self
            .users
            .find_by_id(&record.user_id)
            .await?
            .ok_or(IdentityError::NotFound)?;

        // Access + refresh via the shared identity flow (keeps guards working).
        let pair = self.identity.mint(&user).await?;
        let id_token = self.mint_id_token(&user, &client.client_id, record.nonce.as_deref())?;

        Ok(OidcTokenResponse {
            access_token: pair.access_token,
            id_token,
            refresh_token: pair.refresh_token,
            token_type: "Bearer",
            expires_in: pair.expires_in,
            scope: record.scope,
        })
    }

    /// The UserInfo response for a validated access token's subject.
    pub async fn userinfo(&self, access_token: &str) -> Result<serde_json::Value> {
        let claims = self
            .jwt
            .decode_access(access_token)
            .ok_or(IdentityError::InvalidToken)?;
        let sub = claims
            .get("sub")
            .and_then(|v| v.as_str())
            .ok_or(IdentityError::InvalidToken)?;
        let user = self
            .users
            .find_by_id(sub)
            .await?
            .ok_or(IdentityError::NotFound)?;
        Ok(serde_json::json!({
            "sub": user.id,
            "email": user.email,
            "email_verified": user.email_verified,
            "roles": user.roles,
        }))
    }

    /// RFC 7662 token introspection.
    pub fn introspect(&self, token: &str) -> serde_json::Value {
        match self.jwt.decode(token) {
            Some(claims) => {
                let mut obj = serde_json::Map::new();
                obj.insert("active".into(), true.into());
                for (k, v) in claims.iter() {
                    obj.insert(k.clone(), v.clone());
                }
                serde_json::Value::Object(obj)
            }
            None => serde_json::json!({ "active": false }),
        }
    }

    /// RFC 7009 revocation — revoke a refresh token's chain. Access tokens are
    /// short-lived and stateless; introspection reflects their natural expiry.
    pub async fn revoke(&self, token: &str) -> Result<()> {
        self.identity.logout(token).await
    }

    fn mint_id_token(
        &self,
        user: &crate::model::Identity,
        audience: &str,
        nonce: Option<&str>,
    ) -> Result<String> {
        let now = JwtService::unix_now();
        let mut claims = serde_json::Map::new();
        claims.insert("iss".into(), self.config.issuer.clone().into());
        claims.insert("sub".into(), user.id.clone().into());
        claims.insert("aud".into(), audience.to_owned().into());
        claims.insert("iat".into(), now.into());
        claims.insert("exp".into(), (now + self.config.id_token_ttl_secs).into());
        if let Some(email) = &user.email {
            claims.insert("email".into(), email.clone().into());
            claims.insert("email_verified".into(), user.email_verified.into());
        }
        if let Some(nonce) = nonce {
            claims.insert("nonce".into(), nonce.to_owned().into());
        }
        self.jwt
            .sign(&serde_json::Value::Object(claims))
            .map_err(|e| IdentityError::Internal(e.to_string()))
    }
}

/// PKCE S256: `BASE64URL(SHA256(verifier)) == challenge`, constant-time.
fn pkce_matches(verifier: &str, challenge: &str) -> bool {
    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
    use base64::Engine;
    let mut h = Sha256::new();
    h.update(verifier.as_bytes());
    let computed = URL_SAFE_NO_PAD.encode(h.finalize());
    computed.as_bytes().ct_eq(challenge.as_bytes()).into()
}