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}