entropy-auth 2026.7.31

Authentication and authorization for Entropy Softworks server and API projects
//! Client-credentials grant verdict (RFC 6749 §4.4).
//!
//! [`evaluate_client_credentials`] is the storage-free decision an
//! authorization server makes at the token endpoint when a client requests an
//! access token in its own name (machine-to-machine — no end user, no
//! authorization code, no refresh token). Given the outcome of client
//! authentication and the requesting client's registration, it decides
//! whether an access token may be issued and for what scope.
//!
//! The function performs no I/O and mutates no state. The caller authenticates
//! the client (secret or `private_key_jwt` assertion — see
//! [`verify_client_assertion`](super::verify_client_assertion)), decides
//! whether the client is permitted this grant (deployment policy — e.g. only
//! "service" app registrations), then evaluates and, on
//! [`ClientCredentialsVerdict::Accepted`], mints + stores the access token.
//!
//! # Checks (in order)
//!
//! 1. **Client authentication** — the client authenticated successfully
//!    (§3.2.1: the client-credentials grant is only for confidential clients).
//! 2. **Grant permitted** — the deployment allows this client the
//!    client-credentials grant (the caller's policy verdict).
//! 3. **Active** — the client registration is active.
//! 4. **Scope** — every requested scope is within the client's allowed set
//!    (§3.3). An empty request defers to the caller's default (the client's
//!    full allowed set) and always passes here.

use core::fmt;

use super::client::RegisteredClient;
use super::code::ClientAuthResult;

/// The reason a client-credentials token request is denied.
#[doc(alias = "client_credentials_error")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ClientCredentialsDenied {
    /// Client authentication failed (RFC 6749 §5.2 `invalid_client`).
    ClientAuthenticationFailed,
    /// The client is not permitted the client-credentials grant
    /// (§5.2 `unauthorized_client`).
    GrantNotAllowed,
    /// The client registration is inactive (`invalid_client`).
    ClientInactive,
    /// A requested scope is outside the client's allowed set
    /// (§5.2 `invalid_scope`).
    ScopeNotAllowed,
}

impl ClientCredentialsDenied {
    /// A short, stable diagnostic string (not the RFC error code).
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::ClientAuthenticationFailed => "client authentication failed",
            Self::GrantNotAllowed => "client not permitted the client_credentials grant",
            Self::ClientInactive => "client is inactive",
            Self::ScopeNotAllowed => "requested scope exceeds the client's allowed scopes",
        }
    }
}

impl fmt::Display for ClientCredentialsDenied {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "client-credentials denied: {}", self.as_str())
    }
}

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

/// The verdict of evaluating a client-credentials token request.
#[doc(alias = "client_credentials_verdict")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ClientCredentialsVerdict {
    /// The request may be granted. The caller now mints + stores the access
    /// token for the resolved scope (the requested scope, or the client's
    /// full allowed set when the request omitted `scope`).
    Accepted,
    /// The request must be denied; the variant carries the reason.
    Denied(ClientCredentialsDenied),
}

/// A client-credentials token request as the server sees it.
#[derive(Debug, Clone, Copy)]
pub struct ClientCredentialsRequest<'a> {
    /// The outcome of authenticating the requesting client.
    pub client_auth: ClientAuthResult,
    /// The requesting client's registration.
    pub client: &'a RegisteredClient,
    /// The space-delimited `scope` from the request (empty when omitted).
    pub requested_scope: &'a str,
    /// Whether the deployment permits this client the client-credentials
    /// grant (the caller's policy decision — e.g. the app registration's type
    /// is "service"). Kept an explicit input so the *decision* stays here in
    /// the crate while the *policy source* stays in the caller.
    pub grant_allowed: bool,
}

/// Evaluates whether a client-credentials token request may be granted.
///
/// Returns [`ClientCredentialsVerdict::Accepted`] only when every check in the
/// module-level list passes; otherwise [`ClientCredentialsVerdict::Denied`]
/// with the first failing reason. Pure and side-effect-free.
#[must_use]
pub fn evaluate_client_credentials(req: &ClientCredentialsRequest<'_>) -> ClientCredentialsVerdict {
    use ClientCredentialsDenied as D;
    use ClientCredentialsVerdict::{Accepted, Denied};

    if req.client_auth != ClientAuthResult::Authenticated {
        return Denied(D::ClientAuthenticationFailed);
    }
    if !req.grant_allowed {
        return Denied(D::GrantNotAllowed);
    }
    if !req.client.active() {
        return Denied(D::ClientInactive);
    }
    // An empty scope request defers to the caller's default (the client's
    // allowed set); a non-empty request must be a subset.
    if !req.requested_scope.is_empty() && !req.client.allows_scopes(req.requested_scope) {
        return Denied(D::ScopeNotAllowed);
    }

    Accepted
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::oauth::server::{ClientType, RegisteredClient};

    fn client() -> RegisteredClient {
        RegisteredClient::builder("svc_app", ClientType::Confidential)
            .allowed_scope("read")
            .allowed_scope("write")
            .require_pkce(false)
            .active(true)
            .build()
            .unwrap()
    }

    fn req<'a>(
        c: &'a RegisteredClient,
        auth: ClientAuthResult,
        scope: &'a str,
        grant_allowed: bool,
    ) -> ClientCredentialsRequest<'a> {
        ClientCredentialsRequest {
            client_auth: auth,
            client: c,
            requested_scope: scope,
            grant_allowed,
        }
    }

    #[test]
    fn accepts_authenticated_active_in_scope() {
        let c = client();
        assert_eq!(
            evaluate_client_credentials(&req(&c, ClientAuthResult::Authenticated, "read", true)),
            ClientCredentialsVerdict::Accepted
        );
    }

    #[test]
    fn empty_scope_is_accepted() {
        let c = client();
        assert_eq!(
            evaluate_client_credentials(&req(&c, ClientAuthResult::Authenticated, "", true)),
            ClientCredentialsVerdict::Accepted
        );
    }

    #[test]
    fn rejects_failed_auth() {
        let c = client();
        assert_eq!(
            evaluate_client_credentials(&req(&c, ClientAuthResult::Failed, "read", true)),
            ClientCredentialsVerdict::Denied(ClientCredentialsDenied::ClientAuthenticationFailed)
        );
    }

    #[test]
    fn rejects_grant_not_allowed() {
        let c = client();
        assert_eq!(
            evaluate_client_credentials(&req(&c, ClientAuthResult::Authenticated, "read", false)),
            ClientCredentialsVerdict::Denied(ClientCredentialsDenied::GrantNotAllowed)
        );
    }

    #[test]
    fn rejects_scope_outside_allowed() {
        let c = client();
        assert_eq!(
            evaluate_client_credentials(&req(
                &c,
                ClientAuthResult::Authenticated,
                "read admin",
                true
            )),
            ClientCredentialsVerdict::Denied(ClientCredentialsDenied::ScopeNotAllowed)
        );
    }

    #[test]
    fn rejects_inactive_client() {
        let c = RegisteredClient::builder("svc_app", ClientType::Confidential)
            .allowed_scope("read")
            .require_pkce(false)
            .active(false)
            .build()
            .unwrap();
        assert_eq!(
            evaluate_client_credentials(&req(&c, ClientAuthResult::Authenticated, "read", true)),
            ClientCredentialsVerdict::Denied(ClientCredentialsDenied::ClientInactive)
        );
    }
}