Skip to main content

soaprs_auth/
credential.rs

1//! Presented credentials with redacted secret diagnostics.
2
3use std::fmt;
4
5use soaprs_core::{SoapError, SoapResult};
6
7use crate::AuthorizationName;
8
9/// Opaque secret value whose debug and display representations are redacted.
10#[derive(Clone, PartialEq, Eq)]
11pub struct SecretString(String);
12
13impl SecretString {
14    /// Creates a non-empty secret.
15    pub fn new(value: impl Into<String>) -> SoapResult<Self> {
16        let value = value.into();
17        if value.is_empty() {
18            return Err(SoapError::validation("credential secret cannot be empty"));
19        }
20        Ok(Self(value))
21    }
22
23    /// Exposes the secret only at the authentication implementation boundary.
24    pub fn expose_secret(&self) -> &str {
25        &self.0
26    }
27}
28
29impl fmt::Debug for SecretString {
30    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31        formatter.write_str("SecretString([REDACTED])")
32    }
33}
34
35impl fmt::Display for SecretString {
36    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
37        formatter.write_str("[REDACTED]")
38    }
39}
40
41/// Transport-independent credential category.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum CredentialKind {
44    /// OAuth-style bearer credential.
45    Bearer,
46    /// Username and password credential.
47    Password,
48    /// API key credential.
49    ApiKey,
50    /// Server-side session credential.
51    Session,
52    /// Application-defined credential category.
53    Custom(AuthorizationName),
54}
55
56/// One presented credential routed to a named authentication strategy.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct Credential {
59    strategy: AuthorizationName,
60    kind: CredentialKind,
61    identifier: Option<String>,
62    secret: SecretString,
63}
64
65impl Credential {
66    /// Creates a bearer credential.
67    pub fn bearer(strategy: impl Into<String>, token: impl Into<String>) -> SoapResult<Self> {
68        Self::new(strategy, CredentialKind::Bearer, None, token)
69    }
70
71    /// Creates an API-key credential with an optional public key identifier.
72    pub fn api_key(
73        strategy: impl Into<String>,
74        identifier: Option<String>,
75        key: impl Into<String>,
76    ) -> SoapResult<Self> {
77        Self::new(strategy, CredentialKind::ApiKey, identifier, key)
78    }
79
80    /// Creates a username/password credential.
81    pub fn password(
82        strategy: impl Into<String>,
83        username: impl Into<String>,
84        password: impl Into<String>,
85    ) -> SoapResult<Self> {
86        Self::new(
87            strategy,
88            CredentialKind::Password,
89            Some(username.into()),
90            password,
91        )
92    }
93
94    /// Creates a session credential.
95    pub fn session(strategy: impl Into<String>, session_id: impl Into<String>) -> SoapResult<Self> {
96        Self::new(strategy, CredentialKind::Session, None, session_id)
97    }
98
99    /// Creates an application-defined credential.
100    pub fn custom(
101        strategy: impl Into<String>,
102        kind: impl Into<String>,
103        identifier: Option<String>,
104        secret: impl Into<String>,
105    ) -> SoapResult<Self> {
106        Self::new(
107            strategy,
108            CredentialKind::Custom(AuthorizationName::new(kind)?),
109            identifier,
110            secret,
111        )
112    }
113
114    fn new(
115        strategy: impl Into<String>,
116        kind: CredentialKind,
117        identifier: Option<String>,
118        secret: impl Into<String>,
119    ) -> SoapResult<Self> {
120        if identifier.as_ref().is_some_and(|identifier| {
121            identifier.is_empty() || identifier.chars().any(char::is_control)
122        }) {
123            return Err(SoapError::validation("invalid credential identifier"));
124        }
125        Ok(Self {
126            strategy: AuthorizationName::new(strategy)?,
127            kind,
128            identifier,
129            secret: SecretString::new(secret)?,
130        })
131    }
132
133    /// Returns the strategy selected for this credential.
134    pub fn strategy(&self) -> &AuthorizationName {
135        &self.strategy
136    }
137
138    /// Returns the credential category.
139    pub const fn kind(&self) -> &CredentialKind {
140        &self.kind
141    }
142
143    /// Returns a public username or key identifier when supplied.
144    pub fn identifier(&self) -> Option<&str> {
145        self.identifier.as_deref()
146    }
147
148    /// Returns the redacted secret wrapper.
149    pub const fn secret(&self) -> &SecretString {
150        &self.secret
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::Credential;
157
158    #[test]
159    fn credential_diagnostics_never_expose_secrets() {
160        let Some(credential) = Credential::bearer("jwt", "secret-token").ok() else {
161            panic!("valid credential");
162        };
163        assert!(!format!("{credential:?}").contains("secret-token"));
164        assert_eq!(credential.secret().to_string(), "[REDACTED]");
165        assert_eq!(credential.secret().expose_secret(), "secret-token");
166    }
167}