Skip to main content

projectx_client/
credentials.rs

1// SPDX-FileCopyrightText: 2026 Kevin Monaghan
2// SPDX-License-Identifier: MIT-0
3
4//! Secret credential ownership.
5
6use std::fmt;
7
8use secrecy::{ExposeSecret, SecretString};
9
10use crate::Error;
11
12/// Credentials accepted by one of the provider's authentication endpoints.
13pub(crate) enum AuthenticationCredentials {
14    /// API-key credentials sent only to `/api/Auth/loginKey`.
15    ApiKey(Credentials),
16    /// Authorized-application credentials sent only to `/api/Auth/loginApp`.
17    Application(ApplicationCredentials),
18}
19
20impl fmt::Debug for AuthenticationCredentials {
21    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22        match self {
23            Self::ApiKey(credentials) => credentials.fmt(f),
24            Self::Application(credentials) => credentials.fmt(f),
25        }
26    }
27}
28
29/// `ProjectX` API-key credentials.
30///
31/// Debug output is always redacted. The crate exposes no public secret getters.
32pub struct Credentials {
33    user_name: SecretString,
34    api_key: SecretString,
35}
36
37impl Credentials {
38    /// Creates credentials from a `ProjectX` username and API key.
39    ///
40    /// # Errors
41    ///
42    /// Returns [`Error::Configuration`] when either value is empty or padded
43    /// with whitespace.
44    pub fn new(user_name: impl Into<String>, api_key: impl Into<String>) -> Result<Self, Error> {
45        let user_name = user_name.into();
46        let api_key = api_key.into();
47        validate_secret("username", &user_name)?;
48        validate_secret("API key", &api_key)?;
49        Ok(Self {
50            user_name: SecretString::from(user_name),
51            api_key: SecretString::from(api_key),
52        })
53    }
54
55    pub(crate) fn expose_user_name(&self) -> &str {
56        self.user_name.expose_secret()
57    }
58
59    pub(crate) fn expose_api_key(&self) -> &str {
60        self.api_key.expose_secret()
61    }
62}
63
64impl fmt::Debug for Credentials {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        f.debug_struct("Credentials")
67            .field("user_name", &"[REDACTED]")
68            .field("api_key", &"[REDACTED]")
69            .finish()
70    }
71}
72
73/// `ProjectX` authorized-application credentials.
74///
75/// Construct these credentials with [`ApplicationCredentials::builder`]. All
76/// five values are retained as secrets, Debug output is always redacted, and
77/// the crate exposes no public secret getters.
78pub struct ApplicationCredentials {
79    user_name: SecretString,
80    password: SecretString,
81    device_id: SecretString,
82    app_id: SecretString,
83    verify_key: SecretString,
84}
85
86impl ApplicationCredentials {
87    /// Starts an authorized-application credential builder.
88    pub fn builder(
89        user_name: impl Into<String>,
90        password: impl Into<String>,
91    ) -> ApplicationCredentialsBuilder {
92        ApplicationCredentialsBuilder {
93            user_name: SecretString::from(user_name.into()),
94            password: SecretString::from(password.into()),
95            device_id: None,
96            app_id: None,
97            verify_key: None,
98        }
99    }
100
101    pub(crate) fn expose_user_name(&self) -> &str {
102        self.user_name.expose_secret()
103    }
104
105    pub(crate) fn expose_password(&self) -> &str {
106        self.password.expose_secret()
107    }
108
109    pub(crate) fn expose_device_id(&self) -> &str {
110        self.device_id.expose_secret()
111    }
112
113    pub(crate) fn expose_app_id(&self) -> &str {
114        self.app_id.expose_secret()
115    }
116
117    pub(crate) fn expose_verify_key(&self) -> &str {
118        self.verify_key.expose_secret()
119    }
120}
121
122impl fmt::Debug for ApplicationCredentials {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        f.debug_struct("ApplicationCredentials")
125            .field("user_name", &"[REDACTED]")
126            .field("password", &"[REDACTED]")
127            .field("device_id", &"[REDACTED]")
128            .field("app_id", &"[REDACTED]")
129            .field("verify_key", &"[REDACTED]")
130            .finish()
131    }
132}
133
134/// Builder for validated [`ApplicationCredentials`].
135#[must_use = "an ApplicationCredentialsBuilder does nothing until build is called"]
136pub struct ApplicationCredentialsBuilder {
137    user_name: SecretString,
138    password: SecretString,
139    device_id: Option<SecretString>,
140    app_id: Option<SecretString>,
141    verify_key: Option<SecretString>,
142}
143
144impl ApplicationCredentialsBuilder {
145    /// Sets the provider device identifier.
146    pub fn device_id(mut self, device_id: impl Into<String>) -> Self {
147        self.device_id = Some(SecretString::from(device_id.into()));
148        self
149    }
150
151    /// Sets the authorized application identifier.
152    pub fn app_id(mut self, app_id: impl Into<String>) -> Self {
153        self.app_id = Some(SecretString::from(app_id.into()));
154        self
155    }
156
157    /// Sets the authorized application's verification key.
158    pub fn verify_key(mut self, verify_key: impl Into<String>) -> Self {
159        self.verify_key = Some(SecretString::from(verify_key.into()));
160        self
161    }
162
163    /// Validates and builds the authorized-application credentials.
164    ///
165    /// # Errors
166    ///
167    /// Returns [`Error::Configuration`] when a required value is missing,
168    /// empty, or padded with whitespace.
169    pub fn build(self) -> Result<ApplicationCredentials, Error> {
170        let device_id = self
171            .device_id
172            .ok_or_else(|| Error::Configuration("application device ID is required".to_owned()))?;
173        let app_id = self
174            .app_id
175            .ok_or_else(|| Error::Configuration("application identifier is required".to_owned()))?;
176        let verify_key = self.verify_key.ok_or_else(|| {
177            Error::Configuration("application verification key is required".to_owned())
178        })?;
179        validate_secret("username", self.user_name.expose_secret())?;
180        validate_secret("password", self.password.expose_secret())?;
181        validate_secret("device ID", device_id.expose_secret())?;
182        validate_secret("application identifier", app_id.expose_secret())?;
183        validate_secret("verification key", verify_key.expose_secret())?;
184        Ok(ApplicationCredentials {
185            user_name: self.user_name,
186            password: self.password,
187            device_id,
188            app_id,
189            verify_key,
190        })
191    }
192}
193
194impl fmt::Debug for ApplicationCredentialsBuilder {
195    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
196        f.debug_struct("ApplicationCredentialsBuilder")
197            .field("user_name", &"[REDACTED]")
198            .field("password", &"[REDACTED]")
199            .field("device_id", &self.device_id.as_ref().map(|_| "[REDACTED]"))
200            .field("app_id", &self.app_id.as_ref().map(|_| "[REDACTED]"))
201            .field(
202                "verify_key",
203                &self.verify_key.as_ref().map(|_| "[REDACTED]"),
204            )
205            .finish()
206    }
207}
208
209fn validate_secret(name: &str, value: &str) -> Result<(), Error> {
210    if value.is_empty() || value.trim() != value {
211        return Err(Error::Configuration(format!(
212            "{name} must be non-empty and must not contain surrounding whitespace"
213        )));
214    }
215    Ok(())
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    const USER_NAME: &str = "synthetic-user";
223    const PASSWORD: &str = "synthetic-password";
224    const DEVICE_ID: &str = "synthetic-device";
225    const APP_ID: &str = "synthetic-app";
226    const VERIFY_KEY: &str = "synthetic-verify-key";
227
228    fn build_application_credentials(
229        user_name: &str,
230        password: &str,
231        device_id: &str,
232        app_id: &str,
233        verify_key: &str,
234    ) -> Result<ApplicationCredentials, Error> {
235        ApplicationCredentials::builder(user_name, password)
236            .device_id(device_id)
237            .app_id(app_id)
238            .verify_key(verify_key)
239            .build()
240    }
241
242    #[test]
243    fn validate_secret_rejects_empty_and_padded_values() {
244        assert!(matches!(
245            validate_secret("fixture", ""),
246            Err(Error::Configuration(_))
247        ));
248        assert!(matches!(
249            validate_secret("fixture", " padded "),
250            Err(Error::Configuration(_))
251        ));
252        assert!(validate_secret("fixture", "valid-value").is_ok());
253    }
254
255    #[test]
256    fn application_credentials_require_device_application_and_verification_fields() {
257        assert!(matches!(
258            ApplicationCredentials::builder(USER_NAME, PASSWORD).build(),
259            Err(Error::Configuration(_))
260        ));
261        assert!(matches!(
262            ApplicationCredentials::builder(USER_NAME, PASSWORD)
263                .device_id(DEVICE_ID)
264                .build(),
265            Err(Error::Configuration(_))
266        ));
267        assert!(matches!(
268            ApplicationCredentials::builder(USER_NAME, PASSWORD)
269                .device_id(DEVICE_ID)
270                .app_id(APP_ID)
271                .build(),
272            Err(Error::Configuration(_))
273        ));
274    }
275
276    #[test]
277    fn application_credentials_reject_empty_or_padded_values() {
278        for result in [
279            build_application_credentials("", PASSWORD, DEVICE_ID, APP_ID, VERIFY_KEY),
280            build_application_credentials(" padded ", PASSWORD, DEVICE_ID, APP_ID, VERIFY_KEY),
281            build_application_credentials(USER_NAME, "", DEVICE_ID, APP_ID, VERIFY_KEY),
282            build_application_credentials(USER_NAME, " padded ", DEVICE_ID, APP_ID, VERIFY_KEY),
283            build_application_credentials(USER_NAME, PASSWORD, "", APP_ID, VERIFY_KEY),
284            build_application_credentials(USER_NAME, PASSWORD, " padded ", APP_ID, VERIFY_KEY),
285            build_application_credentials(USER_NAME, PASSWORD, DEVICE_ID, "", VERIFY_KEY),
286            build_application_credentials(USER_NAME, PASSWORD, DEVICE_ID, " padded ", VERIFY_KEY),
287            build_application_credentials(USER_NAME, PASSWORD, DEVICE_ID, APP_ID, ""),
288            build_application_credentials(USER_NAME, PASSWORD, DEVICE_ID, APP_ID, " padded "),
289        ] {
290            assert!(matches!(result, Err(Error::Configuration(_))));
291        }
292    }
293
294    #[test]
295    fn application_credentials_build_with_valid_values() {
296        assert!(
297            build_application_credentials(USER_NAME, PASSWORD, DEVICE_ID, APP_ID, VERIFY_KEY)
298                .is_ok()
299        );
300    }
301}