clawspec_core/client/
security.rs

1//! OpenAPI Security Scheme support for clawspec.
2//!
3//! This module provides types for defining and configuring OpenAPI security schemes
4//! that are included in the generated specification. Security schemes describe
5//! the authentication methods available for your API.
6//!
7//! # Overview
8//!
9//! Security in OpenAPI consists of two parts:
10//! 1. **Security Schemes**: Definitions of authentication methods (Bearer, Basic, API Key, etc.)
11//! 2. **Security Requirements**: References to schemes that must be satisfied for an operation
12//!
13//! # Example
14//!
15//! ```rust
16//! use clawspec_core::{ApiClient, SecurityScheme, SecurityRequirement, ApiKeyLocation};
17//!
18//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
19//! let client = ApiClient::builder()
20//!     .with_security_scheme("bearerAuth", SecurityScheme::bearer())
21//!     .with_security_scheme("apiKey", SecurityScheme::api_key("X-API-Key", ApiKeyLocation::Header))
22//!     .with_default_security(SecurityRequirement::new("bearerAuth"))
23//!     .build()?;
24//! # Ok(())
25//! # }
26//! ```
27//!
28//! # Generated OpenAPI
29//!
30//! The security schemes are output in the `components.securitySchemes` section:
31//!
32//! ```yaml
33//! components:
34//!   securitySchemes:
35//!     bearerAuth:
36//!       type: http
37//!       scheme: bearer
38//!     apiKey:
39//!       type: apiKey
40//!       name: X-API-Key
41//!       in: header
42//! security:
43//!   - bearerAuth: []
44//! ```
45
46use indexmap::IndexMap;
47use utoipa::openapi::security::{
48    ApiKey as UtoipaApiKey, ApiKeyValue, AuthorizationCode, ClientCredentials, Flow, Http,
49    HttpAuthScheme, Implicit, OAuth2 as UtoipaOAuth2, OpenIdConnect as UtoipaOpenIdConnect,
50    Password, Scopes, SecurityScheme as UtoipaSecurityScheme,
51};
52
53/// OpenAPI security scheme configuration.
54///
55/// This enum represents the different types of security schemes supported by OpenAPI.
56/// Each variant maps directly to an OpenAPI security scheme type.
57///
58/// # Supported Schemes
59///
60/// - **Bearer**: HTTP Bearer token authentication (RFC 6750)
61/// - **Basic**: HTTP Basic authentication (RFC 7617)
62/// - **ApiKey**: API key passed in header, query, or cookie
63/// - **OAuth2**: OAuth 2.0 authentication flows
64/// - **OpenIdConnect**: OpenID Connect Discovery
65///
66/// # Example
67///
68/// ```rust
69/// use clawspec_core::{SecurityScheme, ApiKeyLocation};
70///
71/// // Simple bearer token
72/// let bearer = SecurityScheme::bearer();
73///
74/// // Bearer with JWT format hint
75/// let jwt = SecurityScheme::bearer_with_format("JWT");
76///
77/// // API key in header
78/// let api_key = SecurityScheme::api_key("X-API-Key", ApiKeyLocation::Header);
79///
80/// // Basic auth
81/// let basic = SecurityScheme::basic();
82/// ```
83#[derive(Debug, Clone, PartialEq)]
84pub enum SecurityScheme {
85    /// HTTP Bearer authentication (RFC 6750).
86    ///
87    /// Used for token-based authentication where the client sends
88    /// an `Authorization: Bearer <token>` header.
89    Bearer {
90        /// Optional format hint (e.g., "JWT" for JSON Web Tokens)
91        format: Option<String>,
92        /// Description for documentation
93        description: Option<String>,
94    },
95
96    /// HTTP Basic authentication (RFC 7617).
97    ///
98    /// Uses `Authorization: Basic <base64(username:password)>` header.
99    Basic {
100        /// Description for documentation
101        description: Option<String>,
102    },
103
104    /// API Key authentication.
105    ///
106    /// The API key can be passed in a header, query parameter, or cookie.
107    ApiKey {
108        /// Name of the header, query parameter, or cookie
109        name: String,
110        /// Where the API key is passed
111        location: ApiKeyLocation,
112        /// Description for documentation
113        description: Option<String>,
114    },
115
116    /// OAuth 2.0 authentication.
117    ///
118    /// Supports multiple OAuth2 flows: authorization code, client credentials,
119    /// implicit, and password.
120    OAuth2 {
121        /// OAuth2 flows configuration (boxed to reduce enum size)
122        flows: Box<OAuth2Flows>,
123        /// Description for documentation
124        description: Option<String>,
125    },
126
127    /// OpenID Connect Discovery.
128    ///
129    /// Uses OpenID Connect for authentication with automatic discovery
130    /// of the provider's configuration.
131    OpenIdConnect {
132        /// OpenID Connect discovery URL
133        open_id_connect_url: String,
134        /// Description for documentation
135        description: Option<String>,
136    },
137}
138
139impl SecurityScheme {
140    /// Creates a simple HTTP Bearer authentication scheme.
141    ///
142    /// # Example
143    ///
144    /// ```rust
145    /// use clawspec_core::SecurityScheme;
146    ///
147    /// let scheme = SecurityScheme::bearer();
148    /// ```
149    pub fn bearer() -> Self {
150        Self::Bearer {
151            format: None,
152            description: None,
153        }
154    }
155
156    /// Creates an HTTP Bearer authentication scheme with a format hint.
157    ///
158    /// # Arguments
159    ///
160    /// * `format` - Format hint (e.g., "JWT" for JSON Web Tokens)
161    ///
162    /// # Example
163    ///
164    /// ```rust
165    /// use clawspec_core::SecurityScheme;
166    ///
167    /// let scheme = SecurityScheme::bearer_with_format("JWT");
168    /// ```
169    pub fn bearer_with_format(format: impl Into<String>) -> Self {
170        Self::Bearer {
171            format: Some(format.into()),
172            description: None,
173        }
174    }
175
176    /// Creates an HTTP Basic authentication scheme.
177    ///
178    /// # Example
179    ///
180    /// ```rust
181    /// use clawspec_core::SecurityScheme;
182    ///
183    /// let scheme = SecurityScheme::basic();
184    /// ```
185    pub fn basic() -> Self {
186        Self::Basic { description: None }
187    }
188
189    /// Creates an API Key authentication scheme.
190    ///
191    /// # Arguments
192    ///
193    /// * `name` - Name of the header, query parameter, or cookie
194    /// * `location` - Where the API key is passed
195    ///
196    /// # Example
197    ///
198    /// ```rust
199    /// use clawspec_core::{SecurityScheme, ApiKeyLocation};
200    ///
201    /// let scheme = SecurityScheme::api_key("X-API-Key", ApiKeyLocation::Header);
202    /// ```
203    pub fn api_key(name: impl Into<String>, location: ApiKeyLocation) -> Self {
204        Self::ApiKey {
205            name: name.into(),
206            location,
207            description: None,
208        }
209    }
210
211    /// Creates an OpenID Connect authentication scheme.
212    ///
213    /// # Arguments
214    ///
215    /// * `url` - OpenID Connect discovery URL
216    ///
217    /// # Example
218    ///
219    /// ```rust
220    /// use clawspec_core::SecurityScheme;
221    ///
222    /// let scheme = SecurityScheme::openid_connect("https://auth.example.com/.well-known/openid-configuration");
223    /// ```
224    pub fn openid_connect(url: impl Into<String>) -> Self {
225        Self::OpenIdConnect {
226            open_id_connect_url: url.into(),
227            description: None,
228        }
229    }
230
231    /// Adds a description to the security scheme.
232    ///
233    /// # Example
234    ///
235    /// ```rust
236    /// use clawspec_core::SecurityScheme;
237    ///
238    /// let scheme = SecurityScheme::bearer()
239    ///     .with_description("JWT token obtained from /auth/login");
240    /// ```
241    pub fn with_description(mut self, description: impl Into<String>) -> Self {
242        match &mut self {
243            SecurityScheme::Bearer {
244                description: desc, ..
245            } => *desc = Some(description.into()),
246            SecurityScheme::Basic { description: desc } => *desc = Some(description.into()),
247            SecurityScheme::ApiKey {
248                description: desc, ..
249            } => *desc = Some(description.into()),
250            SecurityScheme::OAuth2 {
251                description: desc, ..
252            } => *desc = Some(description.into()),
253            SecurityScheme::OpenIdConnect {
254                description: desc, ..
255            } => *desc = Some(description.into()),
256        }
257        self
258    }
259
260    /// Converts this security scheme to a utoipa SecurityScheme.
261    pub(crate) fn to_utoipa(&self) -> UtoipaSecurityScheme {
262        match self {
263            SecurityScheme::Bearer {
264                format,
265                description,
266            } => {
267                let mut http = Http::new(HttpAuthScheme::Bearer);
268                if let Some(fmt) = format {
269                    http.bearer_format = Some(fmt.clone());
270                }
271                if let Some(desc) = description {
272                    http.description = Some(desc.clone());
273                }
274                UtoipaSecurityScheme::Http(http)
275            }
276            SecurityScheme::Basic { description } => {
277                let mut http = Http::new(HttpAuthScheme::Basic);
278                if let Some(desc) = description {
279                    http.description = Some(desc.clone());
280                }
281                UtoipaSecurityScheme::Http(http)
282            }
283            SecurityScheme::ApiKey {
284                name,
285                location,
286                description,
287            } => {
288                let api_key_value = if let Some(desc) = description {
289                    ApiKeyValue::with_description(name, desc)
290                } else {
291                    ApiKeyValue::new(name)
292                };
293                let api_key = match location {
294                    ApiKeyLocation::Header => UtoipaApiKey::Header(api_key_value),
295                    ApiKeyLocation::Query => UtoipaApiKey::Query(api_key_value),
296                    ApiKeyLocation::Cookie => UtoipaApiKey::Cookie(api_key_value),
297                };
298                UtoipaSecurityScheme::ApiKey(api_key)
299            }
300            SecurityScheme::OAuth2 { flows, description } => {
301                let mut oauth2 = flows.to_utoipa();
302                if let Some(desc) = description {
303                    oauth2.description = Some(desc.clone());
304                }
305                UtoipaSecurityScheme::OAuth2(oauth2)
306            }
307            SecurityScheme::OpenIdConnect {
308                open_id_connect_url,
309                description,
310            } => {
311                let mut oidc = UtoipaOpenIdConnect::new(open_id_connect_url);
312                if let Some(desc) = description {
313                    oidc.description = Some(desc.clone());
314                }
315                UtoipaSecurityScheme::OpenIdConnect(oidc)
316            }
317        }
318    }
319}
320
321/// Location where an API key is passed.
322#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
323pub enum ApiKeyLocation {
324    /// API key in HTTP header
325    Header,
326    /// API key in query parameter
327    Query,
328    /// API key in cookie
329    Cookie,
330}
331
332/// OAuth2 flow configurations.
333///
334/// Represents the different OAuth2 flows supported by OpenAPI.
335#[derive(Debug, Clone, PartialEq, Default)]
336pub struct OAuth2Flows {
337    /// Authorization Code flow
338    pub authorization_code: Option<OAuth2Flow>,
339    /// Client Credentials flow
340    pub client_credentials: Option<OAuth2Flow>,
341    /// Implicit flow (deprecated in OAuth 2.1)
342    pub implicit: Option<OAuth2ImplicitFlow>,
343    /// Password flow (deprecated in OAuth 2.1)
344    pub password: Option<OAuth2Flow>,
345}
346
347impl OAuth2Flows {
348    /// Creates a new OAuth2Flows with authorization code flow.
349    pub fn authorization_code(
350        authorization_url: impl Into<String>,
351        token_url: impl Into<String>,
352        scopes: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
353    ) -> Self {
354        Self {
355            authorization_code: Some(OAuth2Flow {
356                authorization_url: Some(authorization_url.into()),
357                token_url: token_url.into(),
358                refresh_url: None,
359                scopes: scopes
360                    .into_iter()
361                    .map(|(k, v)| (k.into(), v.into()))
362                    .collect(),
363            }),
364            ..Default::default()
365        }
366    }
367
368    /// Creates a new OAuth2Flows with client credentials flow.
369    pub fn client_credentials(
370        token_url: impl Into<String>,
371        scopes: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
372    ) -> Self {
373        Self {
374            client_credentials: Some(OAuth2Flow {
375                authorization_url: None,
376                token_url: token_url.into(),
377                refresh_url: None,
378                scopes: scopes
379                    .into_iter()
380                    .map(|(k, v)| (k.into(), v.into()))
381                    .collect(),
382            }),
383            ..Default::default()
384        }
385    }
386
387    fn to_utoipa(&self) -> UtoipaOAuth2 {
388        let mut flows: Vec<Flow> = Vec::new();
389
390        if let Some(flow) = &self.authorization_code {
391            let scopes = Scopes::from_iter(flow.scopes.clone());
392            let auth_code = if let Some(ref refresh) = flow.refresh_url {
393                AuthorizationCode::with_refresh_url(
394                    flow.authorization_url.as_deref().unwrap_or_default(),
395                    &flow.token_url,
396                    scopes,
397                    refresh,
398                )
399            } else {
400                AuthorizationCode::new(
401                    flow.authorization_url.as_deref().unwrap_or_default(),
402                    &flow.token_url,
403                    scopes,
404                )
405            };
406            flows.push(Flow::AuthorizationCode(auth_code));
407        }
408
409        if let Some(flow) = &self.client_credentials {
410            let scopes = Scopes::from_iter(flow.scopes.clone());
411            let client_creds = if let Some(ref refresh) = flow.refresh_url {
412                ClientCredentials::with_refresh_url(&flow.token_url, scopes, refresh)
413            } else {
414                ClientCredentials::new(&flow.token_url, scopes)
415            };
416            flows.push(Flow::ClientCredentials(client_creds));
417        }
418
419        if let Some(flow) = &self.implicit {
420            let scopes = Scopes::from_iter(flow.scopes.clone());
421            let implicit = if let Some(ref refresh) = flow.refresh_url {
422                Implicit::with_refresh_url(&flow.authorization_url, scopes, refresh)
423            } else {
424                Implicit::new(&flow.authorization_url, scopes)
425            };
426            flows.push(Flow::Implicit(implicit));
427        }
428
429        if let Some(flow) = &self.password {
430            let scopes = Scopes::from_iter(flow.scopes.clone());
431            let password = if let Some(ref refresh) = flow.refresh_url {
432                Password::with_refresh_url(&flow.token_url, scopes, refresh)
433            } else {
434                Password::new(&flow.token_url, scopes)
435            };
436            flows.push(Flow::Password(password));
437        }
438
439        UtoipaOAuth2::new(flows)
440    }
441}
442
443/// OAuth2 flow configuration (for flows with token URL).
444#[derive(Debug, Clone, PartialEq)]
445pub struct OAuth2Flow {
446    /// Authorization URL (required for authorization_code, not for client_credentials)
447    pub authorization_url: Option<String>,
448    /// Token URL
449    pub token_url: String,
450    /// Refresh URL (optional)
451    pub refresh_url: Option<String>,
452    /// Available scopes
453    pub scopes: IndexMap<String, String>,
454}
455
456/// OAuth2 implicit flow configuration.
457#[derive(Debug, Clone, PartialEq)]
458pub struct OAuth2ImplicitFlow {
459    /// Authorization URL
460    pub authorization_url: String,
461    /// Refresh URL (optional)
462    pub refresh_url: Option<String>,
463    /// Available scopes
464    pub scopes: IndexMap<String, String>,
465}
466
467/// Security requirement specifying which scheme and scopes are needed.
468///
469/// A security requirement references a security scheme by name and optionally
470/// specifies required scopes (for OAuth2 schemes).
471///
472/// # Example
473///
474/// ```rust
475/// use clawspec_core::SecurityRequirement;
476///
477/// // Simple requirement (no scopes)
478/// let bearer_req = SecurityRequirement::new("bearerAuth");
479///
480/// // OAuth2 with required scopes
481/// let oauth_req = SecurityRequirement::with_scopes("oauth2", ["read:users", "write:users"]);
482/// ```
483#[derive(Debug, Clone, PartialEq, Eq)]
484pub struct SecurityRequirement {
485    /// Name of the security scheme (must match a registered scheme)
486    pub name: String,
487    /// Required scopes (empty for non-OAuth schemes)
488    pub scopes: Vec<String>,
489}
490
491impl SecurityRequirement {
492    /// Creates a new security requirement without scopes.
493    ///
494    /// # Arguments
495    ///
496    /// * `name` - Name of the security scheme
497    ///
498    /// # Example
499    ///
500    /// ```rust
501    /// use clawspec_core::SecurityRequirement;
502    ///
503    /// let req = SecurityRequirement::new("bearerAuth");
504    /// ```
505    pub fn new(name: impl Into<String>) -> Self {
506        Self {
507            name: name.into(),
508            scopes: Vec::new(),
509        }
510    }
511
512    /// Creates a new security requirement with scopes.
513    ///
514    /// # Arguments
515    ///
516    /// * `name` - Name of the security scheme
517    /// * `scopes` - Required OAuth2 scopes
518    ///
519    /// # Example
520    ///
521    /// ```rust
522    /// use clawspec_core::SecurityRequirement;
523    ///
524    /// let req = SecurityRequirement::with_scopes("oauth2", ["read:users", "write:users"]);
525    /// ```
526    pub fn with_scopes(
527        name: impl Into<String>,
528        scopes: impl IntoIterator<Item = impl Into<String>>,
529    ) -> Self {
530        Self {
531            name: name.into(),
532            scopes: scopes.into_iter().map(Into::into).collect(),
533        }
534    }
535
536    /// Converts to utoipa SecurityRequirement.
537    pub(crate) fn to_utoipa(&self) -> utoipa::openapi::security::SecurityRequirement {
538        utoipa::openapi::security::SecurityRequirement::new(
539            &self.name,
540            self.scopes.iter().map(String::as_str),
541        )
542    }
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548
549    #[test]
550    fn test_bearer_scheme_creation() {
551        let scheme = SecurityScheme::bearer();
552        assert!(matches!(
553            scheme,
554            SecurityScheme::Bearer {
555                format: None,
556                description: None
557            }
558        ));
559    }
560
561    #[test]
562    fn test_bearer_with_format() {
563        let scheme = SecurityScheme::bearer_with_format("JWT");
564        assert!(matches!(
565            scheme,
566            SecurityScheme::Bearer {
567                format: Some(ref f),
568                description: None
569            } if f == "JWT"
570        ));
571    }
572
573    #[test]
574    fn test_basic_scheme_creation() {
575        let scheme = SecurityScheme::basic();
576        assert!(matches!(
577            scheme,
578            SecurityScheme::Basic { description: None }
579        ));
580    }
581
582    #[test]
583    fn test_api_key_scheme_creation() {
584        let scheme = SecurityScheme::api_key("X-API-Key", ApiKeyLocation::Header);
585        assert!(matches!(
586            scheme,
587            SecurityScheme::ApiKey {
588                ref name,
589                location: ApiKeyLocation::Header,
590                description: None
591            } if name == "X-API-Key"
592        ));
593    }
594
595    #[test]
596    fn test_with_description() {
597        let scheme = SecurityScheme::bearer().with_description("JWT Bearer token");
598        assert!(matches!(
599            scheme,
600            SecurityScheme::Bearer {
601                format: None,
602                description: Some(ref d)
603            } if d == "JWT Bearer token"
604        ));
605    }
606
607    #[test]
608    fn test_security_requirement_new() {
609        let req = SecurityRequirement::new("bearerAuth");
610        assert_eq!(req.name, "bearerAuth");
611        assert!(req.scopes.is_empty());
612    }
613
614    #[test]
615    fn test_security_requirement_with_scopes() {
616        let req = SecurityRequirement::with_scopes("oauth2", ["read:users", "write:users"]);
617        assert_eq!(req.name, "oauth2");
618        assert_eq!(req.scopes, vec!["read:users", "write:users"]);
619    }
620
621    #[test]
622    fn test_bearer_to_utoipa() {
623        let scheme = SecurityScheme::bearer_with_format("JWT").with_description("JWT token");
624        let utoipa_scheme = scheme.to_utoipa();
625
626        assert!(matches!(utoipa_scheme, UtoipaSecurityScheme::Http(_)));
627    }
628
629    #[test]
630    fn test_basic_to_utoipa() {
631        let scheme = SecurityScheme::basic();
632        let utoipa_scheme = scheme.to_utoipa();
633
634        assert!(matches!(utoipa_scheme, UtoipaSecurityScheme::Http(_)));
635    }
636
637    #[test]
638    fn test_api_key_to_utoipa() {
639        let scheme = SecurityScheme::api_key("X-API-Key", ApiKeyLocation::Header);
640        let utoipa_scheme = scheme.to_utoipa();
641
642        assert!(matches!(utoipa_scheme, UtoipaSecurityScheme::ApiKey(_)));
643    }
644
645    #[test]
646    fn test_openid_connect_to_utoipa() {
647        let scheme = SecurityScheme::openid_connect("https://auth.example.com/.well-known/openid");
648        let utoipa_scheme = scheme.to_utoipa();
649
650        assert!(matches!(
651            utoipa_scheme,
652            UtoipaSecurityScheme::OpenIdConnect(_)
653        ));
654    }
655
656    #[test]
657    fn test_oauth2_authorization_code_flows() {
658        let flows = OAuth2Flows::authorization_code(
659            "https://auth.example.com/authorize",
660            "https://auth.example.com/token",
661            [("read:users", "Read user data")],
662        );
663
664        assert!(flows.authorization_code.is_some());
665        assert!(flows.client_credentials.is_none());
666    }
667
668    #[test]
669    fn test_oauth2_client_credentials_flows() {
670        let flows = OAuth2Flows::client_credentials(
671            "https://auth.example.com/token",
672            [("api:access", "API access")],
673        );
674
675        assert!(flows.client_credentials.is_some());
676        assert!(flows.authorization_code.is_none());
677    }
678
679    #[test]
680    fn test_security_requirement_to_utoipa() {
681        let req = SecurityRequirement::with_scopes("oauth2", ["read:users"]);
682        let utoipa_req = req.to_utoipa();
683
684        // Verify the requirement was created (internal structure)
685        assert!(format!("{utoipa_req:?}").contains("oauth2"));
686    }
687}