soaprs-auth 0.4.0

Protocol-neutral authentication and authorization contracts for soaprs
Documentation
//! Presented credentials with redacted secret diagnostics.

use std::fmt;

use soaprs_core::{SoapError, SoapResult};

use crate::AuthorizationName;

/// Opaque secret value whose debug and display representations are redacted.
#[derive(Clone, PartialEq, Eq)]
pub struct SecretString(String);

impl SecretString {
    /// Creates a non-empty secret.
    pub fn new(value: impl Into<String>) -> SoapResult<Self> {
        let value = value.into();
        if value.is_empty() {
            return Err(SoapError::validation("credential secret cannot be empty"));
        }
        Ok(Self(value))
    }

    /// Exposes the secret only at the authentication implementation boundary.
    pub fn expose_secret(&self) -> &str {
        &self.0
    }
}

impl fmt::Debug for SecretString {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str("SecretString([REDACTED])")
    }
}

impl fmt::Display for SecretString {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str("[REDACTED]")
    }
}

/// Transport-independent credential category.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CredentialKind {
    /// OAuth-style bearer credential.
    Bearer,
    /// Username and password credential.
    Password,
    /// API key credential.
    ApiKey,
    /// Server-side session credential.
    Session,
    /// Application-defined credential category.
    Custom(AuthorizationName),
}

/// One presented credential routed to a named authentication strategy.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Credential {
    strategy: AuthorizationName,
    kind: CredentialKind,
    identifier: Option<String>,
    secret: SecretString,
}

impl Credential {
    /// Creates a bearer credential.
    pub fn bearer(strategy: impl Into<String>, token: impl Into<String>) -> SoapResult<Self> {
        Self::new(strategy, CredentialKind::Bearer, None, token)
    }

    /// Creates an API-key credential with an optional public key identifier.
    pub fn api_key(
        strategy: impl Into<String>,
        identifier: Option<String>,
        key: impl Into<String>,
    ) -> SoapResult<Self> {
        Self::new(strategy, CredentialKind::ApiKey, identifier, key)
    }

    /// Creates a username/password credential.
    pub fn password(
        strategy: impl Into<String>,
        username: impl Into<String>,
        password: impl Into<String>,
    ) -> SoapResult<Self> {
        Self::new(
            strategy,
            CredentialKind::Password,
            Some(username.into()),
            password,
        )
    }

    /// Creates a session credential.
    pub fn session(strategy: impl Into<String>, session_id: impl Into<String>) -> SoapResult<Self> {
        Self::new(strategy, CredentialKind::Session, None, session_id)
    }

    /// Creates an application-defined credential.
    pub fn custom(
        strategy: impl Into<String>,
        kind: impl Into<String>,
        identifier: Option<String>,
        secret: impl Into<String>,
    ) -> SoapResult<Self> {
        Self::new(
            strategy,
            CredentialKind::Custom(AuthorizationName::new(kind)?),
            identifier,
            secret,
        )
    }

    fn new(
        strategy: impl Into<String>,
        kind: CredentialKind,
        identifier: Option<String>,
        secret: impl Into<String>,
    ) -> SoapResult<Self> {
        if identifier.as_ref().is_some_and(|identifier| {
            identifier.is_empty() || identifier.chars().any(char::is_control)
        }) {
            return Err(SoapError::validation("invalid credential identifier"));
        }
        Ok(Self {
            strategy: AuthorizationName::new(strategy)?,
            kind,
            identifier,
            secret: SecretString::new(secret)?,
        })
    }

    /// Returns the strategy selected for this credential.
    pub fn strategy(&self) -> &AuthorizationName {
        &self.strategy
    }

    /// Returns the credential category.
    pub const fn kind(&self) -> &CredentialKind {
        &self.kind
    }

    /// Returns a public username or key identifier when supplied.
    pub fn identifier(&self) -> Option<&str> {
        self.identifier.as_deref()
    }

    /// Returns the redacted secret wrapper.
    pub const fn secret(&self) -> &SecretString {
        &self.secret
    }
}

#[cfg(test)]
mod tests {
    use super::Credential;

    #[test]
    fn credential_diagnostics_never_expose_secrets() {
        let Some(credential) = Credential::bearer("jwt", "secret-token").ok() else {
            panic!("valid credential");
        };
        assert!(!format!("{credential:?}").contains("secret-token"));
        assert_eq!(credential.secret().to_string(), "[REDACTED]");
        assert_eq!(credential.secret().expose_secret(), "secret-token");
    }
}