Skip to main content

openstack_sdk_auth_core/
lib.rs

1// Licensed under the Apache License, Version 2.0 (the "License");
2// you may not use this file except in compliance with the License.
3// You may obtain a copy of the License at
4//
5//     http://www.apache.org/licenses/LICENSE-2.0
6//
7// Unless required by applicable law or agreed to in writing, software
8// distributed under the License is distributed on an "AS IS" BASIS,
9// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10// See the License for the specific language governing permissions and
11// limitations under the License.
12//
13// SPDX-License-Identifier: Apache-2.0
14//! # Core trait for implementing OpenStack authentication plugins to [`openstack_sdk`]
15//!
16//! This crate provides the foundational types and traits required to authenticate
17//! against the OpenStack Identity service (Keystone). It defines:
18//!
19//! - [`OpenStackAuthType`] — the primary trait that all authentication plugins must
20//!   implement to enable login flows (password, token, JWT, WebSSO, etc.).
21//! - [`OpenStackMultifactorAuthMethod`] — the trait for multifactor-capable methods
22//!   that can be composed into multipass/multifactor authentication requests.
23//! - [`AuthToken`] — the structure that represents a successful Keystone authentication
24//!   result, including the bearer token and parsed server response.
25//! - [`AuthTokenScope`] — represents the authorization scope attached to a token
26//!   (project, domain, system, or unscoped).
27//! - [`AuthError`] — the unified error type covering all authentication-related
28//!   failures, including receipts for multipass flows.
29//! - [`Auth`] — the enum that wraps the current authentication state (token or none).
30//! - [`AuthState`] — describes token validity (valid, expired, about-to-expire, or unset).
31//!
32//! ## Plugin Registration
33//!
34//! Authentication plugins are registered at compile time using Rust's `inventory` crate.
35//! Implement [`OpenStackAuthType`] and submit an [`AuthPluginRegistration`] via
36//! `inventory::submit!{}`. For multipass support, additionally implement
37//! [`OpenStackMultifactorAuthMethod`] and submit an [`AuthMethodPluginRegistration`].
38//!
39//! The [`execute_auth_request`] function provides a common, instrumented pathway
40//! for sending authentication HTTP requests with timing and request-id logging.
41//!
42//! ## Examples
43//!
44//! ### Basic Authentication
45//!
46//! Authenticate by creating an `AuthToken` from an existing token string
47//! (e.g., obtained from an environment variable or a previous login):
48//!
49//! ```no_run
50//! use secrecy::{ExposeSecret, SecretString};
51//! use openstack_sdk_auth_core::{AuthToken, AuthTokenScope};
52//!
53//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
54//! let token = AuthToken::new(SecretString::from("my-token"), None);
55//! let scope = AuthTokenScope::Unscoped;
56//!
57//! println!("Scope: {:?}", token.get_scope());
58//! println!("State: {:?}", token.get_state(None));
59//! # Ok(())
60//! # }
61//! ```
62//!
63//! ### Checking Token State
64//!
65//! Tokens can be checked for validity with an optional expiration threshold:
66//!
67//! ```no_run
68//! use chrono::TimeDelta;
69//! use openstack_sdk_auth_core::AuthState;
70//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
71//! # let auth = openstack_sdk_auth_core::AuthToken::default();
72//! let offset = TimeDelta::minutes(5);
73//! match auth.get_state(Some(offset)) {
74//!     AuthState::Valid => println!("Token is valid"),
75//!     AuthState::AboutToExpire => println!("Token will expire within 5 minutes"),
76//!     AuthState::Expired => println!("Token has expired"),
77//!     AuthState::Unset => println!("No token data available"),
78//! }
79//! # Ok(())
80//! # }
81//! ```
82//!
83//! ### Setting Request Headers
84//!
85//! The `Auth` type can inject the `X-Auth-Token` header into outgoing HTTP requests:
86//!
87//! ```no_run
88//! use http::HeaderMap;
89//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
90//! # let auth = openstack_sdk_auth_core::Auth::None;
91//! let mut headers = HeaderMap::new();
92//! auth.set_header(&mut headers)?;
93//! // headers now contains the `X-Auth-Token` header if the auth type is a token.
94//! # Ok(())
95//! # }
96//! ```
97//!
98//! ### Implementing a Custom Authenticator
99//!
100//! Creating a new authentication method requires implementing [`OpenStackAuthType`]:
101//!
102//! ```no_run
103//! use std::collections::HashMap;
104//! use async_trait::async_trait;
105//! use secrecy::SecretString;
106//! use serde_json::{Value, json};
107//! use openstack_sdk_auth_core::{Auth, AuthError, AuthToken, AuthTokenScope, OpenStackAuthType};
108//!
109//! pub struct MyAuthenticator;
110//!
111//! static PLUGIN: MyAuthenticator = MyAuthenticator;
112//! inventory::submit! {
113//!     openstack_sdk_auth_core::AuthPluginRegistration { method: &PLUGIN }
114//! }
115//!
116//! #[async_trait]
117//! impl OpenStackAuthType for MyAuthenticator {
118//!     fn get_supported_auth_methods(&self) -> Vec<&'static str> {
119//!         vec!["v3myauth", "myauth"]
120//!     }
121//!
122//!     fn requirements(&self, _hints: Option<&Value>) -> Result<Value, AuthError> {
123//!         Ok(json!({
124//!             "type": "object",
125//!             "required": ["token_id"],
126//!             "properties": {
127//!                 "token_id": {
128//!                     "type": "string",
129//!                     "description": "The token identifier"
130//!                 }
131//!             }
132//!         }))
133//!     }
134//!
135//!     fn api_version(&self) -> (u8, u8) {
136//!         (3, 0)
137//!     }
138//!
139//!     async fn auth(
140//!         &self,
141//!         _http_client: &reqwest::Client,
142//!         _identity_url: &url::Url,
143//!         _values: &HashMap<String, SecretString>,
144//!         _scope: Option<&AuthTokenScope>,
145//!         _hints: Option<&Value>,
146//!     ) -> Result<Auth, AuthError> {
147//!         // Perform your authentication logic here and return an AuthToken
148//!         let auth_token = AuthToken::new("example-token", None);
149//!         Ok(Auth::AuthToken(Box::new(auth_token)))
150//!     }
151//! }
152//! ```
153//!
154//! ### Error Handling Patterns
155//!
156//! The [`AuthError`] enum provides different error variants that you can match on
157//! to handle specific failure scenarios:
158//!
159//! ```no_run
160//! use openstack_sdk_auth_core::{AuthError, Auth, AuthToken, AuthTokenScope};
161//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
162//! # fn simulate_auth_result() -> Result<Auth, AuthError> { Ok(Auth::None) }
163//!
164//! match simulate_auth_result() {
165//!     Ok(Auth::AuthToken(token)) => {
166//!         println!("Authentication successful");
167//!         println!("Token expires at: {:?}", token.get_state(None));
168//!     }
169//!     Ok(Auth::None) => {
170//!         println!("No authentication available");
171//!     }
172//!     Ok(auth) => {
173//!         println!("Unknown auth type: {:?}", auth);
174//!     }
175//!     Err(AuthError::AuthReceipt(receipt)) => {
176//!         println!("Multifactor authentication required");
177//!         let methods: Vec<_> = receipt.required_auth_methods.iter()
178//!             .flatten()
179//!             .cloned()
180//!             .collect();
181//!         println!("Required methods: {:?}", methods);
182//!     }
183//!     Err(AuthError::Serde { .. }) => {
184//!         println!("Failed to parse response (malformed JSON)");
185//!     }
186//!     Err(AuthError::UnknownAuth { code, message }) => {
187//!         println!("Unknown authentication error (code: {})", code);
188//!         if let Some(msg) = &message {
189//!             println!("Message: {}", msg);
190//!         }
191//!     }
192//!     Err(e) => {
193//!         println!("Authentication failed: {}", e);
194//!     }
195//! }
196//! # Ok(())
197//! # }
198//! ```
199//!
200//! ### Implementing a Multifactor-Authenticator
201//!
202//! To support multifactor authentication (e.g., TOTP or password + token),
203//! implement both [`OpenStackAuthType`] and [`OpenStackMultifactorAuthMethod`]:
204//!
205//! ```no_run
206//! use std::collections::HashMap;
207//! use async_trait::async_trait;
208//! use secrecy::{SecretString, ExposeSecret};
209//! use serde_json::{Value, json};
210//! use openstack_sdk_auth_core::{
211//!     Auth, AuthError, AuthToken, AuthTokenScope,
212//!     OpenStackAuthType, OpenStackMultifactorAuthMethod,
213//! };
214//!
215//! pub struct MyMultifactorAuthenticator;
216//!
217//! static PLUGIN: MyMultifactorAuthenticator = MyMultifactorAuthenticator;
218//! inventory::submit! {
219//!     openstack_sdk_auth_core::AuthPluginRegistration { method: &PLUGIN }
220//! }
221//! inventory::submit! {
222//!     openstack_sdk_auth_core::AuthMethodPluginRegistration { method: &PLUGIN }
223//! }
224//!
225//! #[async_trait]
226//! impl OpenStackMultifactorAuthMethod for MyMultifactorAuthenticator {
227//!     fn get_supported_auth_methods(&self) -> Vec<&'static str> {
228//!         vec!["v3myauth", "myauth"]
229//!     }
230//!
231//!     fn requirements(&self, _hints: Option<&Value>) -> Result<Value, AuthError> {
232//!         Ok(json!({
233//!             "type": "object",
234//!             "required": ["auth_code"],  // Additional auth method requirement
235//!             "properties": {
236//!                 "auth_code": {
237//!                     "type": "string",
238//!                     "format": "password",
239//!                     "description": "One-time authentication code"
240//!                 }
241//!             }
242//!         }))
243//!     }
244//!
245//!     /// Extracts authentication data from the values map.
246//!     fn get_auth_data(
247//!         &self,
248//!         values: &HashMap<String, SecretString>,
249//!     ) -> Result<(&'static str, Value), AuthError> {
250//!         let auth_code = values
251//!             .get("auth_code")
252//!             .ok_or_else(|| AuthError::AuthValueNotSupplied("auth_code".to_string()))?;
253//!         Ok(("myauth", json!({
254//!             "auth_code": auth_code.expose_secret()
255//!         })))
256//!     }
257//! }
258//!
259//! #[async_trait]
260//! impl OpenStackAuthType for MyMultifactorAuthenticator {
261//!     fn get_supported_auth_methods(&self) -> Vec<&'static str> {
262//!         vec!["v3myauth", "myauth"]
263//!     }
264//!
265//!     fn requirements(&self, _hints: Option<&Value>) -> Result<Value, AuthError> {
266//!         Ok(json!({
267//!             "type": "object",
268//!             "required": ["auth_code"],  
269//!             "properties": {
270//!                 "auth_code": {
271//!                     "type": "string",
272//!                     "format": "password",
273//!                     "description": "One-time authentication code"
274//!                 }
275//!             }
276//!         }))
277//!     }
278//!
279//!     fn api_version(&self) -> (u8, u8) {
280//!         (3, 0)
281//!     }
282//!
283//!     async fn auth(
284//!         &self,
285//!         _http_client: &reqwest::Client,
286//!         _identity_url: &url::Url,
287//!         _values: &HashMap<String, SecretString>,
288//!         _scope: Option<&AuthTokenScope>,
289//!         _hints: Option<&Value>,
290//!     ) -> Result<Auth, AuthError> {
291//!         // Perform multifactor authentication and return an AuthToken:
292//!         let auth_token = AuthToken::new("example-token", None);
293//!         Ok(Auth::AuthToken(Box::new(auth_token)))
294//!     }
295//! }
296//! ```
297//!
298//! ### Token Scoping
299//!
300//! Tokens can be scoped to specific projects or domains using [`AuthTokenScope`]:
301//!
302//! ```
303//! use openstack_sdk_auth_core::{AuthTokenScope};
304//! use openstack_sdk_auth_core::types::{Project, Domain};
305//!
306//! // Project-scope by ID
307//! let project_scope = AuthTokenScope::Project(Project {
308//!     id: Some("project-id-123".to_string()),
309//!     name: None,
310//!     domain: None,
311//! });
312//!
313//! // Domain-scope with domain name
314//! let domain_scope = AuthTokenScope::Domain(Domain {
315//!     id: None,
316//!     name: Some("Default".to_string()),
317//! });
318//!
319//! // Unscoped (default)
320//! let unscoped: AuthTokenScope = AuthTokenScope::default();
321//! ```
322//!
323//! ### Handling Authentication Receipts
324//!
325//! When multifactor authentication is enabled, Keystone returns an authentication receipt
326//! instead of a new token. The receipt contains information about what additional
327//! authentication methods are required:
328//!
329//! ```no_run
330//! use openstack_sdk_auth_core::{AuthTokenScope, AuthError, Auth, AuthReceiptResponse, AuthReceipt};
331//! use chrono::Local;
332//!
333//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
334//! # fn simulate_auth_result() -> Result<Auth, AuthError> {
335//! #     Ok(Auth::None)
336//! # }
337//!
338//! let scope = Some(&AuthTokenScope::Unscoped);
339//! match simulate_auth_result() {
340//!     Ok(auth) => {
341//!         match auth {
342//!             Auth::AuthToken(token) => {
343//!                 println!("Authenticated with token");
344//!                 println!("Token: {:?}", token.get_scope());
345//!             }
346//!             Auth::None => {
347//!                 println!("Not authenticated");
348//!             }
349//!             _ => {
350//!                 println!("Unknown auth type");
351//!             }
352//!         }
353//!     }
354//!     Err(AuthError::AuthReceipt(receipt)) => {
355//!         println!("Additional authentication methods required");
356//!         println!("Required methods: {:?}", receipt.required_auth_methods);
357//!         let methods: Vec<_> = receipt.receipt.methods.iter().cloned().collect();
358//!         println!("Already completed methods: {:?}", methods);
359//!
360//!         // Use the receipt token for subsequent authentication requests
361//!         if let Some(receipt_token) = &receipt.token {
362//!             println!("Receipt token: {}...", &receipt_token.chars().take(8).collect::<String>());
363//!         }
364//!     }
365//!     Err(e) => {
366//!         println!("Authentication error: {}", e);
367//!     }
368//! }
369//! # Ok(())
370//! # }
371//! ```
372//! ### Token State Management
373//!
374//! The [`AuthToken::get_state`] method can be used to determine if a token is valid and
375//! needs to be refreshed. Using an expiration offset allows you to proactively refresh
376//! tokens before they expire:
377//!
378//! ```
379//! use chrono::TimeDelta;
380//! use openstack_sdk_auth_core::{AuthToken, AuthState, types::{AuthResponse, TokenInfo, User, Project}};
381//! use secrecy::SecretString;
382//!
383//! // Create a token with expiration info
384//! let auth_info = AuthResponse {
385//!     token: TokenInfo {
386//!         expires_at: chrono::Utc::now() + TimeDelta::hours(24),
387//!         user: User::default(),
388//!         ..Default::default()
389//!     },
390//! };
391//! let token = AuthToken::new("my-token", Some(auth_info));
392//!
393//! // Check with 5-minute buffer for proactive refresh
394//! match token.get_state(Some(TimeDelta::minutes(5))) {
395//!     AuthState::Valid => {
396//!         println!("Token is valid");
397//!     }
398//!     AuthState::AboutToExpire => {
399//!         println!("Token will expire soon, refreshing...");
400//!         // Refresh the token before it expires
401//!     }
402//!     AuthState::Expired => {
403//!         println!("Token has expired, re-authenticating...");
404//!     }
405//!     AuthState::Unset => {
406//!         println!("No token data available");
407//!     }
408//! }
409//! ```
410
411use std::collections::HashMap;
412use std::fmt::{self, Debug};
413use std::time::SystemTime;
414
415use async_trait::async_trait;
416use http::{HeaderMap, HeaderValue};
417use reqwest::{Client, Request, Response};
418use secrecy::SecretString;
419use thiserror::Error;
420use tracing::{Level, event, instrument};
421
422pub mod auth_helper;
423pub mod authtoken;
424pub mod authtoken_scope;
425pub mod types;
426
427pub use authtoken::{AuthToken, AuthTokenError};
428pub use authtoken_scope::AuthTokenScope;
429pub use types::*;
430
431/// Authentication error.
432#[derive(Debug, Error)]
433#[non_exhaustive]
434pub enum AuthError {
435    /// Authentication rejected with a receipt.
436    #[error("authentication rejected")]
437    AuthReceipt(Box<AuthReceiptResponse>),
438
439    /// openstack-auth-receipt cannot be converted to string.
440    #[error("authentication receipt cannot be converted to string")]
441    AuthReceiptNotString,
442
443    /// AuthToken error.
444    #[error("AuthToken error: {}", source)]
445    AuthToken {
446        /// The source of the error.
447        #[from]
448        source: AuthTokenError,
449    },
450
451    /// Necessary data was not supplied to the auth method.
452    #[error("value necessary for the chosen auth method was not supplied to the auth method")]
453    AuthValueNotSupplied(String),
454
455    /// Keystone error.
456    #[error("authentication method error: {}", .0.message)]
457    Identity(IdentityError),
458
459    // TODO: Move out
460    /// Necessary data was not supplied to the auth method.
461    #[error("plugin specified malformed requirements")]
462    PluginMalformedRequirement,
463
464    /// (De)Serialization error.
465    #[error("failed to deserialize response body: {}", source)]
466    Serde {
467        /// The source of the error.
468        #[from]
469        source: serde_json::Error,
470    },
471
472    /// Header error.
473    #[error("header value error: {}", source)]
474    HeaderValue {
475        /// The source of the error.
476        #[from]
477        source: http::header::InvalidHeaderValue,
478    },
479
480    /// AuthPlugin error.
481    #[error("plugin error: {}", source)]
482    Plugin {
483        /// The source of the error.
484        #[source]
485        source: Box<dyn std::error::Error + Send + Sync + 'static>,
486    },
487
488    /// Reqwest error.
489    #[error(transparent)]
490    Reqwest {
491        /// The error source
492        #[from]
493        source: reqwest::Error,
494    },
495
496    /// Identity error not supported.
497    #[error("identity service error")]
498    UnknownAuth {
499        /// The error code.
500        code: u16,
501        /// The error body.
502        message: Option<String>,
503    },
504
505    /// Url parse error.
506    #[error(transparent)]
507    Url {
508        /// The error source
509        #[from]
510        source: url::ParseError,
511    },
512}
513
514impl AuthError {
515    pub fn plugin<E>(error: E) -> Self
516    where
517        E: std::error::Error + Send + Sync + 'static,
518    {
519        Self::Plugin {
520            source: Box::new(error),
521        }
522    }
523}
524
525/// The trait for authentication "plugins" for enabling OpenStack authentication.
526#[async_trait]
527pub trait OpenStackAuthType: Send + Sync {
528    /// Return list of supported authentication methods.
529    fn get_supported_auth_methods(&self) -> Vec<&'static str>;
530
531    /// Get the json schema of the data the plugin requires to complete the authentication.
532    fn requirements(
533        &self,
534        hints: Option<&serde_json::Value>,
535    ) -> Result<serde_json::Value, AuthError>;
536
537    /// Get the API version of the Identity Service the plugin need to communicate to.
538    fn api_version(&self) -> (u8, u8);
539
540    /// Authenticate the client with the configuration.
541    async fn auth(
542        &self,
543        http_client: &reqwest::Client,
544        identity_url: &url::Url,
545        values: &HashMap<String, SecretString>,
546        scope: Option<&AuthTokenScope>,
547        hints: Option<&serde_json::Value>,
548    ) -> Result<Auth, AuthError>;
549}
550
551/// Registry entry for authentication plugins.
552pub struct AuthPluginRegistration {
553    /// The authentication method implementation.
554    pub method: &'static dyn OpenStackAuthType,
555}
556
557// Essential: This creates the global registry for this specific struct
558inventory::collect!(AuthPluginRegistration);
559
560/// The trait for multifactor-capable authentication methods.
561pub trait OpenStackMultifactorAuthMethod: Send + Sync {
562    /// Return list of supported authentication methods.
563    fn get_supported_auth_methods(&self) -> Vec<&'static str>;
564
565    /// Get the json schema of the data the plugin requires to complete the authentication.
566    fn requirements(
567        &self,
568        hints: Option<&serde_json::Value>,
569    ) -> Result<serde_json::Value, AuthError>;
570
571    /// Authenticate the client with the configuration.
572    fn get_auth_data(
573        &self,
574        values: &HashMap<String, SecretString>,
575    ) -> Result<(&'static str, serde_json::Value), AuthError>;
576}
577
578/// Registry entry for multifactor-capable authentication methods.
579pub struct AuthMethodPluginRegistration {
580    /// The authentication method implementation.
581    pub method: &'static dyn OpenStackMultifactorAuthMethod,
582}
583inventory::collect!(AuthMethodPluginRegistration);
584
585#[instrument(name="request", skip_all, fields(http.uri = request.url().as_str(), http.method = request.method().as_str(), openstack.ver=request.headers().get("openstack-api-version").map(|v| v.to_str().unwrap_or(""))))]
586pub async fn execute_auth_request(
587    client: &Client,
588    request: Request,
589) -> Result<Response, reqwest::Error> {
590    let url = request.url().clone();
591    let method = request.method().clone();
592    let start = SystemTime::now();
593    let rsp = client.execute(request).await?;
594    let elapsed = SystemTime::now().duration_since(start).unwrap_or_default();
595    event!(
596        name: "http_request",
597        Level::INFO,
598        url=url.as_str(),
599        duration_ms=elapsed.as_millis(),
600        status=rsp.status().as_u16(),
601        method=method.as_str(),
602        request_id=rsp.headers().get("x-openstack-request-id").map(|v| v.to_str().unwrap_or("")),
603        "Request completed with status {}",
604        rsp.status(),
605    );
606    Ok(rsp)
607}
608
609/// Force the linker to include all auth plugin crates by iterating over the
610/// inventory registries. Must be called during client initialization since
611/// plugin crates are otherwise stripped by the linker (no other code
612/// references them), causing `AuthType` lookups to fail at runtime.
613#[inline(never)]
614pub fn anchor_plugins() {
615    let auth_methods: Vec<&str> = inventory::iter::<AuthPluginRegistration>
616        .into_iter()
617        .flat_map(|x| x.method.get_supported_auth_methods())
618        .collect();
619    let multifactor_methods: Vec<&str> = inventory::iter::<AuthMethodPluginRegistration>
620        .into_iter()
621        .flat_map(|x| x.method.get_supported_auth_methods())
622        .collect();
623    tracing::debug!(
624        "Discovered auth plugins: {:?}; multifactor auth plugins: {:?}",
625        auth_methods,
626        multifactor_methods
627    );
628}
629
630/// An OpenStack Authentication type
631#[derive(Clone)]
632#[non_exhaustive]
633pub enum Auth {
634    /// An X-Auth-Token
635    AuthToken(Box<AuthToken>),
636    /// Unauthenticated access
637    None,
638}
639
640impl Auth {
641    /// Adds X-Auth-Token header to a request headers.
642    ///
643    /// Returns an error if the token string cannot be parsed as a header value.
644    pub fn set_header(&self, headers: &mut HeaderMap<HeaderValue>) -> Result<(), AuthError> {
645        if let Auth::AuthToken(token) = self {
646            token.set_header(headers)?;
647        }
648
649        Ok(())
650    }
651}
652
653impl fmt::Debug for Auth {
654    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
655        write!(
656            f,
657            "Auth {}",
658            match self {
659                Auth::AuthToken(_) => "Token",
660                Auth::None => "unauthed",
661            }
662        )
663    }
664}
665
666impl TryFrom<http::Response<bytes::Bytes>> for Auth {
667    type Error = AuthError;
668    fn try_from(value: http::Response<bytes::Bytes>) -> Result<Self, Self::Error> {
669        Ok(Self::AuthToken(Box::new(AuthToken::try_from(value)?)))
670    }
671}
672
673/// Authentication state enum
674#[derive(Debug, Eq, PartialEq)]
675pub enum AuthState {
676    /// Auth is valid
677    Valid,
678    /// Expired
679    Expired,
680    /// About to expire
681    AboutToExpire,
682    /// Authentication is missing
683    Unset,
684}
685
686/// Builder error.
687///
688/// A wrapper error that is used instead of the error generated by the
689/// `derive_builder`.
690#[derive(Debug, Error)]
691#[non_exhaustive]
692pub enum BuilderError {
693    /// Uninitialized field.
694    #[error("{0}")]
695    UninitializedField(String),
696    /// Custom validation error.
697    #[error("{0}")]
698    Validation(String),
699}
700
701impl From<String> for BuilderError {
702    fn from(s: String) -> Self {
703        Self::Validation(s)
704    }
705}
706
707impl From<derive_builder::UninitializedFieldError> for BuilderError {
708    fn from(ufe: derive_builder::UninitializedFieldError) -> Self {
709        Self::UninitializedField(ufe.to_string())
710    }
711}
712
713#[cfg(test)]
714mod tests {
715    use super::*;
716    use crate::types::{AuthResponse, TokenInfo};
717    use std::hash::{Hash, Hasher};
718
719    #[test]
720    fn test_auth_validity_unset() {
721        let auth = super::AuthToken::default();
722        assert!(matches!(auth.get_state(None), AuthState::Unset));
723    }
724
725    #[test]
726    fn test_auth_validity_expired() {
727        let auth = super::AuthToken::new(
728            String::new(),
729            Some(AuthResponse {
730                token: TokenInfo {
731                    expires_at: chrono::Utc::now() - chrono::TimeDelta::days(1),
732                    ..Default::default()
733                },
734            }),
735        );
736        assert!(matches!(auth.get_state(None), AuthState::Expired));
737    }
738
739    #[test]
740    fn test_auth_validity_expire_soon() {
741        let auth = super::AuthToken::new(
742            String::new(),
743            Some(AuthResponse {
744                token: TokenInfo {
745                    expires_at: chrono::Utc::now() + chrono::TimeDelta::minutes(10),
746                    ..Default::default()
747                },
748            }),
749        );
750        assert!(matches!(
751            auth.get_state(Some(chrono::TimeDelta::minutes(15))),
752            AuthState::AboutToExpire
753        ));
754    }
755
756    #[test]
757    fn test_auth_validity_valid() {
758        let auth = super::AuthToken::new(
759            String::new(),
760            Some(AuthResponse {
761                token: TokenInfo {
762                    expires_at: chrono::Utc::now() + chrono::TimeDelta::days(1),
763                    ..Default::default()
764                },
765            }),
766        );
767        assert!(matches!(auth.get_state(None), AuthState::Valid));
768    }
769
770    #[test]
771    fn test_auth_set_header_invalid_token() {
772        let auth = Auth::AuthToken(Box::new(super::AuthToken::new(
773            "invalid\nheader\nvalue",
774            None,
775        )));
776        let mut headers = HeaderMap::new();
777        let result = auth.set_header(&mut headers);
778        assert!(result.is_err());
779    }
780
781    #[test]
782    fn test_project_domain_eq_hash() {
783        use std::collections::HashSet;
784
785        let p1 = Project {
786            id: Some("1".into()),
787            name: Some("proj".into()),
788            domain: Some(Domain {
789                id: Some("d1".into()),
790                name: Some("D".into()),
791            }),
792        };
793        let p2 = Project {
794            id: Some("1".into()),
795            name: Some("proj".into()),
796            domain: Some(Domain {
797                id: Some("d1".into()),
798                name: Some("D".into()),
799            }),
800        };
801        assert_eq!(p1, p2);
802
803        let h1 = {
804            let mut h = std::collections::hash_map::DefaultHasher::new();
805            p1.hash(&mut h);
806            h.finish()
807        };
808        let h2 = {
809            let mut h = std::collections::hash_map::DefaultHasher::new();
810            p2.hash(&mut h);
811            h.finish()
812        };
813        assert_eq!(h1, h2, "equal projects must have equal hashes");
814
815        let mut pset = HashSet::new();
816        pset.insert(p1.clone());
817        assert!(pset.contains(&p2), "HashSet should contain equal project");
818    }
819
820    #[test]
821    fn test_project_both_name_and_domain_none() {
822        let p1 = Project {
823            id: Some("x".into()),
824            name: None,
825            domain: None,
826        };
827        let p2 = Project {
828            id: Some("y".into()),
829            name: None,
830            domain: None,
831        };
832        assert_ne!(p1, p2);
833    }
834
835    #[test]
836    fn test_project_domain_none_vs_some() {
837        let p1 = Project {
838            id: None,
839            name: Some("p".into()),
840            domain: None,
841        };
842        let p2 = Project {
843            id: None,
844            name: Some("p".into()),
845            domain: Some(Domain {
846                id: None,
847                name: None,
848            }),
849        };
850        assert_ne!(p1, p2);
851    }
852
853    #[test]
854    fn test_domain_both_names_none() {
855        let d1 = Domain {
856            id: Some("x".into()),
857            name: None,
858        };
859        let d2 = Domain {
860            id: Some("y".into()),
861            name: None,
862        };
863        assert_ne!(d1, d2);
864    }
865
866    #[test]
867    fn test_domain_none_vs_some_name() {
868        let d1 = Domain {
869            id: None,
870            name: None,
871        };
872        let d2 = Domain {
873            id: None,
874            name: Some("D".into()),
875        };
876        assert_ne!(d1, d2);
877    }
878
879    #[test]
880    fn test_project_hashset_dedup() {
881        use std::collections::HashSet;
882        let d = Domain {
883            id: Some("d".into()),
884            name: Some("D".into()),
885        };
886        let p1 = Project {
887            id: Some("1".into()),
888            name: Some("p".into()),
889            domain: Some(d.clone()),
890        };
891        let p2 = Project {
892            id: Some("1".into()),
893            name: Some("p".into()),
894            domain: Some(d.clone()),
895        };
896        let p3 = Project {
897            id: Some("1".into()),
898            name: Some("p".into()),
899            domain: Some(d),
900        };
901
902        let mut set = HashSet::new();
903        set.insert(p1);
904        set.insert(p2);
905        set.insert(p3);
906        assert_eq!(
907            set.len(),
908            1,
909            "HashSet should deduplicate identical projects"
910        );
911    }
912
913    #[test]
914    fn test_auth_scope_from_response_project() {
915        let response = AuthResponse {
916            token: TokenInfo {
917                project: Some(Project {
918                    id: Some("1".into()),
919                    name: Some("p".into()),
920                    domain: None,
921                }),
922                ..Default::default()
923            },
924        };
925        let scope: AuthTokenScope = (&response).into();
926        assert!(matches!(scope, AuthTokenScope::Project(_)));
927    }
928
929    #[test]
930    fn test_auth_scope_from_response_domain() {
931        let response = AuthResponse {
932            token: TokenInfo {
933                project: None,
934                domain: Some(Domain {
935                    id: Some("1".into()),
936                    name: Some("D".into()),
937                }),
938                ..Default::default()
939            },
940        };
941        let scope: AuthTokenScope = (&response).into();
942        assert!(matches!(scope, AuthTokenScope::Domain(_)));
943    }
944
945    #[test]
946    fn test_auth_scope_from_response_system() {
947        let response = AuthResponse {
948            token: TokenInfo {
949                project: None,
950                domain: None,
951                system: Some(System { all: Some(true) }),
952                ..Default::default()
953            },
954        };
955        let scope: AuthTokenScope = (&response).into();
956        assert!(matches!(scope, AuthTokenScope::System(_)));
957    }
958
959    #[test]
960    fn test_auth_scope_from_response_unscoped() {
961        let response = AuthResponse {
962            token: TokenInfo {
963                project: None,
964                domain: None,
965                system: None,
966                ..Default::default()
967            },
968        };
969        let scope: AuthTokenScope = (&response).into();
970        assert!(matches!(scope, AuthTokenScope::Unscoped));
971    }
972
973    #[test]
974    fn test_auth_scope_priority_project_over_domain() {
975        let response = AuthResponse {
976            token: TokenInfo {
977                project: Some(Project {
978                    id: Some("1".into()),
979                    name: Some("p".into()),
980                    domain: None,
981                }),
982                domain: Some(Domain {
983                    id: Some("1".into()),
984                    name: Some("D".into()),
985                }),
986                ..Default::default()
987            },
988        };
989        let scope: AuthTokenScope = (&response).into();
990        assert!(matches!(scope, AuthTokenScope::Project(_)));
991    }
992
993    #[test]
994    fn test_auth_scope_priority_domain_over_system() {
995        let response = AuthResponse {
996            token: TokenInfo {
997                project: None,
998                domain: Some(Domain {
999                    id: Some("1".into()),
1000                    name: Some("D".into()),
1001                }),
1002                system: Some(System { all: Some(true) }),
1003                ..Default::default()
1004            },
1005        };
1006        let scope: AuthTokenScope = (&response).into();
1007        assert!(matches!(scope, AuthTokenScope::Domain(_)));
1008    }
1009
1010    #[test]
1011    fn test_auth_none_set_header_noop() {
1012        let auth = Auth::None;
1013        let mut headers = HeaderMap::new();
1014        let result = auth.set_header(&mut headers);
1015        assert!(result.is_ok());
1016        assert!(headers.is_empty());
1017    }
1018
1019    #[test]
1020    fn test_auth_token_set_header() {
1021        let auth = Auth::AuthToken(Box::new(super::AuthToken::new("my-token", None)));
1022        let mut headers = HeaderMap::new();
1023        auth.set_header(&mut headers).unwrap();
1024        assert!(headers.contains_key("X-Auth-Token"));
1025    }
1026
1027    #[test]
1028    fn test_auth_debug_token() {
1029        let auth = Auth::AuthToken(Box::new(super::AuthToken::new("tok", None)));
1030        let debug = format!("{:?}", auth);
1031        assert!(debug.contains("Token"));
1032    }
1033
1034    #[test]
1035    fn test_auth_debug_none() {
1036        let auth = Auth::None;
1037        let debug = format!("{:?}", auth);
1038        assert!(debug.contains("unauthed"));
1039    }
1040
1041    #[test]
1042    fn test_try_from_http_response_for_auth() {
1043        let auth = AuthResponse::default();
1044        let json = serde_json::to_string(&auth).unwrap();
1045        let body: bytes::Bytes = json.into_bytes().into();
1046
1047        let http_response = http::Response::builder()
1048            .header("x-subject-token", "tok")
1049            .body(body)
1050            .unwrap();
1051
1052        let result = Auth::try_from(http_response);
1053        assert!(result.is_ok());
1054        assert!(matches!(result.unwrap(), Auth::AuthToken(_)));
1055    }
1056
1057    #[test]
1058    fn test_auth_error_plugin_constructor() {
1059        #[derive(Debug)]
1060        struct MyErr(&'static str);
1061        impl std::fmt::Display for MyErr {
1062            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1063                write!(f, "{}", self.0)
1064            }
1065        }
1066        impl std::error::Error for MyErr {}
1067
1068        let err = AuthError::plugin(MyErr("plug"));
1069        assert!(matches!(err, AuthError::Plugin { .. }));
1070        assert!(format!("{}", err).contains("plug"));
1071    }
1072
1073    #[test]
1074    fn test_auth_error_display_unknown_auth_with_message() {
1075        let err = AuthError::UnknownAuth {
1076            code: 500,
1077            message: Some("upstream timeout".into()),
1078        };
1079        assert_eq!(format!("{}", err), "identity service error");
1080    }
1081
1082    #[test]
1083    fn test_auth_error_display_unknown_auth_without_message() {
1084        let err = AuthError::UnknownAuth {
1085            code: 502,
1086            message: None,
1087        };
1088        assert_eq!(format!("{}", err), "identity service error");
1089    }
1090
1091    #[test]
1092    fn test_auth_error_display_auth_value_not_supplied() {
1093        let err = AuthError::AuthValueNotSupplied("user".to_string());
1094        assert!(format!("{}", err).contains("value necessary"));
1095    }
1096
1097    #[test]
1098    fn test_builder_error_from_string() {
1099        let err: BuilderError = "custom validation".to_string().into();
1100        assert!(matches!(err, BuilderError::Validation(_)));
1101        assert!(format!("{}", err).contains("custom validation"));
1102    }
1103
1104    #[test]
1105    fn test_auth_token_scope_default() {
1106        let scope = AuthTokenScope::default();
1107        assert!(matches!(scope, AuthTokenScope::Unscoped));
1108    }
1109
1110    #[test]
1111    fn test_auth_receipt_not_string_display() {
1112        let err = AuthError::AuthReceiptNotString;
1113        assert!(format!("{}", err).contains("receipt"));
1114    }
1115
1116    #[test]
1117    fn test_auth_error_identity_display() {
1118        let err = AuthError::Identity(IdentityError {
1119            code: 401,
1120            message: "unauthorized".into(),
1121        });
1122        assert!(format!("{}", err).contains("authentication method error"));
1123        assert!(format!("{}", err).contains("unauthorized"));
1124    }
1125
1126    #[test]
1127    fn test_name_or_id_serialize() {
1128        let id = NameOrId::Id("abc".into());
1129        let json = serde_json::to_string(&id).unwrap();
1130        assert!(json.contains("id"));
1131        assert!(json.contains("abc"));
1132
1133        let name = NameOrId::Name("myproj".into());
1134        let json = serde_json::to_string(&name).unwrap();
1135        assert!(json.contains("name"));
1136        assert!(json.contains("myproj"));
1137    }
1138
1139    #[test]
1140    fn test_name_or_id_deserialize() {
1141        let json = r#"{"id": "abc"}"#;
1142        let parsed: NameOrId = serde_json::from_str(json).unwrap();
1143        assert!(matches!(parsed, NameOrId::Id(ref s) if s == "abc"));
1144
1145        let json = r#"{"name": "myproj"}"#;
1146        let parsed: NameOrId = serde_json::from_str(json).unwrap();
1147        assert!(matches!(parsed, NameOrId::Name(ref s) if s == "myproj"));
1148    }
1149
1150    #[test]
1151    fn test_project_serialize_human_readable_skips_none() {
1152        let p = Project {
1153            id: Some("1".into()),
1154            name: None,
1155            domain: None,
1156        };
1157        let json = serde_json::to_string(&p).unwrap();
1158        assert!(json.contains("id"));
1159    }
1160
1161    #[test]
1162    fn test_domain_serialize_human_readable_skips_none() {
1163        let d = Domain {
1164            id: Some("1".into()),
1165            name: None,
1166        };
1167        let json = serde_json::to_string(&d).unwrap();
1168        assert!(json.contains("id"));
1169    }
1170
1171    #[test]
1172    fn test_project_serialize_all_fields() {
1173        let d = Domain {
1174            id: Some("d".into()),
1175            name: Some("D".into()),
1176        };
1177        let p = Project {
1178            id: Some("1".into()),
1179            name: Some("p".into()),
1180            domain: Some(d),
1181        };
1182        let json = serde_json::to_string(&p).unwrap();
1183        assert!(json.contains("id"));
1184        assert!(json.contains("name"));
1185        assert!(json.contains("domain"));
1186    }
1187
1188    #[test]
1189    fn test_auth_state_debug() {
1190        assert_eq!(format!("{:?}", AuthState::Valid), "Valid");
1191        assert_eq!(format!("{:?}", AuthState::Expired), "Expired");
1192        assert_eq!(format!("{:?}", AuthState::AboutToExpire), "AboutToExpire");
1193        assert_eq!(format!("{:?}", AuthState::Unset), "Unset");
1194    }
1195
1196    #[test]
1197    fn test_auth_receipt_response_serialization() {
1198        let receipt = AuthReceiptResponse {
1199            receipt: AuthReceipt {
1200                methods: vec!["password".to_string()],
1201                user: User {
1202                    id: "u".to_string(),
1203                    name: "user".to_string(),
1204                    ..Default::default()
1205                },
1206                expires_at: chrono::Local::now(),
1207                ..Default::default()
1208            },
1209            required_auth_methods: vec![vec!["totp".to_string()]],
1210            token: Some("tok".to_string()),
1211        };
1212        let json = serde_json::to_string(&receipt).unwrap();
1213        assert!(json.contains("receipt"));
1214        assert!(json.contains("password"));
1215        assert!(json.contains("totp"));
1216    }
1217
1218    #[test]
1219    fn test_anchor_plugins() {
1220        anchor_plugins();
1221    }
1222}