diff --git a/utoipa-swagger-ui/Cargo.toml b/utoipa-swagger-ui/Cargo.toml index 664d1106..cebcee66 100644 --- a/utoipa-swagger-ui/Cargo.toml +++ b/utoipa-swagger-ui/Cargo.toml @@ -21,6 +21,8 @@ mime_guess = { version = "2.0" } actix-web = { version = "4", optional = true } rocket = { version = "0.5.0-rc.1", features = ["json"], optional = true } utoipa = { version = "1", path = "..", default-features = false, features = [] } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0" } [package.metadata.docs.rs] features = ["actix-web", "rocket"] diff --git a/utoipa-swagger-ui/src/lib.rs b/utoipa-swagger-ui/src/lib.rs index 59806ecf..3d500e66 100644 --- a/utoipa-swagger-ui/src/lib.rs +++ b/utoipa-swagger-ui/src/lib.rs @@ -86,6 +86,8 @@ //! [^rocket]: **rocket** feature need to be enabled. use std::{borrow::Cow, error::Error, sync::Arc}; +pub mod oauth; + #[cfg(feature = "actix-web")] use actix_web::{ dev::HttpServiceFactory, guard::Get, web, web::Data, HttpResponse, Resource, @@ -125,6 +127,7 @@ struct SwaggerUiDist; pub struct SwaggerUi { path: Cow<'static, str>, urls: Vec<(Url<'static>, OpenApi)>, + oauth: Option, } #[cfg(any(feature = "actix-web", feature = "rocket"))] @@ -146,6 +149,7 @@ impl SwaggerUi { Self { path: path.into(), urls: Vec::new(), + oauth: None, } } @@ -216,6 +220,33 @@ impl SwaggerUi { self } + + /// Add oauth [`oauth::Config`] into [`SwaggerUi`]. + /// + /// Method takes one argument which exposes the [`oauth::Config`] to the user. + /// + /// # Examples + /// + /// Enable pkce with default client_id. + /// ```rust + /// # use utoipa_swagger_ui::{SwaggerUi, oauth}; + /// # use utoipa::OpenApi; + /// # #[derive(OpenApi)] + /// # #[openapi(handlers())] + /// # struct ApiDoc; + /// let swagger = SwaggerUi::new("/swagger-ui/{_:.*}") + /// .url("/api-doc/openapi.json", ApiDoc::openapi()) + /// .oauth(oauth::Config::new() + /// .client_id("client-id") + /// .scopes(vec![String::from("openid")]) + /// .use_pkce_with_authorization_code_grant(true) + /// ); + /// ``` + pub fn oauth(mut self, oauth: oauth::Config) -> Self { + self.oauth = Some(oauth); + + self + } } #[cfg(feature = "actix-web")] @@ -233,7 +264,10 @@ impl HttpServiceFactory for SwaggerUi { let swagger_resource = Resource::new(self.path.as_ref()) .guard(Get()) - .app_data(Data::new(Config::new(urls))) + .app_data(Data::new(Config { + urls: urls, + oauth: self.oauth, + })) .to(serve_swagger_ui); HttpServiceFactory::register(swagger_resource, config); @@ -271,7 +305,13 @@ impl From for Vec { routes.push(Route::new( rocket::http::Method::Get, swagger_ui.path.as_ref(), - ServeSwagger(swagger_ui.path.clone(), Arc::new(Config::new(urls))), + ServeSwagger( + swagger_ui.path.clone(), + Arc::new(Config { + urls: urls.collect(), + oauth: swagger_ui.oauth, + }), + ), )); routes.extend(api_docs); @@ -462,11 +502,22 @@ async fn serve_swagger_ui(path: web::Path, data: web::Data>) /// Url::new("api2", "/api-doc/openapi2.json") /// ]); /// ``` +/// +/// With oauth config +/// ```rust +/// # use utoipa_swagger_ui::{Config, oauth}; +/// let config = Config::with_oauth_config( +/// ["/api-doc/openapi1.json", "/api-doc/openapi2.json"], +/// oauth::Config::new(), +/// ); +/// ``` #[non_exhaustive] #[derive(Default, Clone)] pub struct Config<'a> { /// [`Url`]s the Swagger UI is serving. urls: Vec>, + /// [`oauth::Config`] the Swagger UI is using for auth flow. + oauth: Option, } impl<'a> Config<'a> { @@ -481,6 +532,28 @@ impl<'a> Config<'a> { pub fn new, U: Into>>(urls: I) -> Self { Self { urls: urls.into_iter().map(|url| url.into()).collect(), + oauth: None, + } + } + + /// Constructs a new [`Config`] from [`Iterator`] of [`Url`]s. + /// + /// # Examples + /// Create new config with oauth config + /// ```rust + /// # use utoipa_swagger_ui::{Config, oauth}; + /// let config = Config::with_oauth_config( + /// ["/api-doc/openapi1.json", "/api-doc/openapi2.json"], + /// oauth::Config::new(), + /// ); + /// ``` + pub fn with_oauth_config, U: Into>>( + urls: I, + oauth_config: oauth::Config, + ) -> Self { + Self { + urls: urls.into_iter().map(|url| url.into()).collect(), + oauth: Some(oauth_config), } } } @@ -489,6 +562,7 @@ impl<'a> From<&'a str> for Config<'a> { fn from(s: &'a str) -> Self { Self { urls: vec![Url::from(s)], + oauth: None, } } } @@ -497,6 +571,7 @@ impl From for Config<'_> { fn from(s: String) -> Self { Self { urls: vec![Url::from(s)], + oauth: None, } } } @@ -573,6 +648,13 @@ pub fn serve<'a>( }; file = format_swagger_config_urls(&mut config.urls.iter(), file); + if let Some(oauth) = &config.oauth { + match oauth::format_swagger_config(oauth, file) { + Ok(oauth_file) => file = oauth_file, + Err(error) => return Err(Box::new(error)), + } + } + bytes = Cow::Owned(file.as_bytes().to_vec()) }; diff --git a/utoipa-swagger-ui/src/oauth.rs b/utoipa-swagger-ui/src/oauth.rs new file mode 100644 index 00000000..74472973 --- /dev/null +++ b/utoipa-swagger-ui/src/oauth.rs @@ -0,0 +1,317 @@ +//! Implements Swagger UI [oauth configuration](https://github.com/swagger-api/swagger-ui/blob/master/docs/usage/oauth2.md) options. + +use std::collections::HashMap; + +use serde::Serialize; + +const END_MARKER: &str = "//"; + +/// Object used to alter Swagger UI oauth settings. +/// +/// # Examples +/// +/// ```rust +/// # use utoipa_swagger_ui::oauth; +/// let config = oauth::Config::new() +/// .client_id("client-id") +/// .use_pkce_with_authorization_code_grant(true); +/// ``` +#[non_exhaustive] +#[derive(Default, Clone, Serialize)] +#[cfg_attr(feature = "debug", derive(Debug))] +#[serde(rename_all = "camelCase")] +pub struct Config { + /// oauth client_id the Swagger UI is using for auth flow. + #[serde(skip_serializing_if = "Option::is_none")] + pub client_id: Option, + + /// oauth client_secret the Swagger UI is using for auth flow. + #[serde(skip_serializing_if = "Option::is_none")] + pub client_secret: Option, + + /// oauth realm the Swagger UI is using for auth flow. + /// realm query parameter (for oauth1) added to authorizationUrl and tokenUrl. + #[serde(skip_serializing_if = "Option::is_none")] + pub realm: Option, + + /// oauth app_name the Swagger UI is using for auth flow. + /// application name, displayed in authorization popup. + #[serde(skip_serializing_if = "Option::is_none")] + pub app_name: Option, + + /// oauth scope_separator the Swagger UI is using for auth flow. + /// scope separator for passing scopes, encoded before calling, default value is a space (encoded value %20). + #[serde(skip_serializing_if = "Option::is_none")] + pub scope_separator: Option, + + /// oauth scopes the Swagger UI is using for auth flow. + /// [`Vec`] of initially selected oauth scopes, default is empty. + #[serde(skip_serializing_if = "Option::is_none")] + pub scopes: Option>, + + /// oauth additional_query_string_params the Swagger UI is using for auth flow. + /// [`HashMap`] of additional query parameters added to authorizationUrl and tokenUrl + #[serde(skip_serializing_if = "Option::is_none")] + pub additional_query_string_params: Option>, + + /// oauth use_basic_authentication_with_access_code_grant the Swagger UI is using for auth flow. + /// Only activated for the accessCode flow. During the authorization_code request to the tokenUrl, + /// pass the [Client Password](https://tools.ietf.org/html/rfc6749#section-2.3.1) using the HTTP Basic Authentication scheme + /// (Authorization header with Basic base64encode(client_id + client_secret)). + /// The default is false + #[serde(skip_serializing_if = "Option::is_none")] + pub use_basic_authentication_with_access_code_grant: Option, + + /// oauth use_pkce_with_authorization_code_grant the Swagger UI is using for auth flow. + /// Only applies to authorizatonCode flows. [Proof Key for Code Exchange](https://tools.ietf.org/html/rfc7636) + /// brings enhanced security for OAuth public clients. + /// The default is false + #[serde(skip_serializing_if = "Option::is_none")] + pub use_pkce_with_authorization_code_grant: Option, +} + +impl Config { + /// Create a new [`Config`] for oauth auth flow. + /// + /// # Examples + /// + /// ```rust + /// # use utoipa_swagger_ui::oauth; + /// let config = oauth::Config::new(); + /// ``` + pub fn new() -> Self { + Self { + ..Default::default() + } + } + + /// Add client_id into [`Config`]. + /// + /// Method takes one argument which exposes the client_id to the user. + /// + /// # Examples + /// + /// ```rust + /// # use utoipa_swagger_ui::oauth; + /// let config = oauth::Config::new() + /// .client_id("client-id"); + /// ``` + pub fn client_id(mut self, client_id: &str) -> Self { + self.client_id = Some(String::from(client_id)); + + self + } + + /// Add client_secret into [`Config`]. + /// + /// Method takes one argument which exposes the client_secret to the user. + /// 🚨 Never use this parameter in your production environment. + /// It exposes crucial security information. This feature is intended for dev/test environments only. 🚨 + /// + /// # Examples + /// + /// ```rust + /// # use utoipa_swagger_ui::oauth; + /// let config = oauth::Config::new() + /// .client_secret("client-secret"); + /// ``` + pub fn client_secret(mut self, client_secret: &str) -> Self { + self.client_secret = Some(String::from(client_secret)); + + self + } + + /// Add realm into [`Config`]. + /// + /// Method takes one argument which exposes the realm to the user. + /// realm query parameter (for oauth1) added to authorizationUrl and tokenUrl. + /// + /// # Examples + /// + /// ```rust + /// # use utoipa_swagger_ui::oauth; + /// let config = oauth::Config::new() + /// .realm("realm"); + /// ``` + pub fn realm(mut self, realm: &str) -> Self { + self.realm = Some(String::from(realm)); + + self + } + + /// Add app_name into [`Config`]. + /// + /// Method takes one argument which exposes the app_name to the user. + /// application name, displayed in authorization popup. + /// + /// # Examples + /// + /// ```rust + /// # use utoipa_swagger_ui::oauth; + /// let config = oauth::Config::new() + /// .app_name("app-name"); + /// ``` + pub fn app_name(mut self, app_name: &str) -> Self { + self.app_name = Some(String::from(app_name)); + + self + } + + /// Add scope_separator into [`Config`]. + /// + /// Method takes one argument which exposes the scope_separator to the user. + /// scope separator for passing scopes, encoded before calling, default value is a space (encoded value %20). + /// + /// # Examples + /// + /// ```rust + /// # use utoipa_swagger_ui::oauth; + /// let config = oauth::Config::new() + /// .scope_separator(","); + /// ``` + pub fn scope_separator(mut self, scope_separator: &str) -> Self { + self.scope_separator = Some(String::from(scope_separator)); + + self + } + + /// Add scopes into [`Config`]. + /// + /// Method takes one argument which exposes the scopes to the user. + /// [`Vec`] of initially selected oauth scopes, default is empty. + /// + /// # Examples + /// + /// ```rust + /// # use utoipa_swagger_ui::oauth; + /// let config = oauth::Config::new() + /// .scopes(vec![String::from("openid")]); + /// ``` + pub fn scopes(mut self, scopes: Vec) -> Self { + self.scopes = Some(scopes); + + self + } + + /// Add additional_query_string_params into [`Config`]. + /// + /// Method takes one argument which exposes the additional_query_string_params to the user. + /// [`HashMap`] of additional query parameters added to authorizationUrl and tokenUrl + /// + /// # Examples + /// + /// ```rust + /// # use utoipa_swagger_ui::oauth; + /// # use std::collections::HashMap; + /// let config = oauth::Config::new() + /// .additional_query_string_params(HashMap::from([(String::from("a"), String::from("1"))])); + /// ``` + pub fn additional_query_string_params( + mut self, + additional_query_string_params: HashMap, + ) -> Self { + self.additional_query_string_params = Some(additional_query_string_params); + + self + } + + /// Add use_basic_authentication_with_access_code_grant into [`Config`]. + /// + /// Method takes one argument which exposes the use_basic_authentication_with_access_code_grant to the user. + /// Only activated for the accessCode flow. During the authorization_code request to the tokenUrl, + /// pass the [Client Password](https://tools.ietf.org/html/rfc6749#section-2.3.1) using the HTTP Basic Authentication scheme + /// (Authorization header with Basic base64encode(client_id + client_secret)). + /// The default is false + /// + /// # Examples + /// + /// ```rust + /// # use utoipa_swagger_ui::oauth; + /// let config = oauth::Config::new() + /// .use_basic_authentication_with_access_code_grant(true); + /// ``` + pub fn use_basic_authentication_with_access_code_grant( + mut self, + use_basic_authentication_with_access_code_grant: bool, + ) -> Self { + self.use_basic_authentication_with_access_code_grant = + Some(use_basic_authentication_with_access_code_grant); + + self + } + + /// Add use_pkce_with_authorization_code_grant into [`Config`]. + /// + /// Method takes one argument which exposes the use_pkce_with_authorization_code_grant to the user. + /// Only applies to authorizatonCode flows. [Proof Key for Code Exchange](https://tools.ietf.org/html/rfc7636) + /// brings enhanced security for OAuth public clients. + /// The default is false + /// + /// # Examples + /// + /// ```rust + /// # use utoipa_swagger_ui::oauth; + /// let config = oauth::Config::new() + /// .use_pkce_with_authorization_code_grant(true); + /// ``` + pub fn use_pkce_with_authorization_code_grant( + mut self, + use_pkce_with_authorization_code_grant: bool, + ) -> Self { + self.use_pkce_with_authorization_code_grant = Some(use_pkce_with_authorization_code_grant); + + self + } +} + +pub(crate) fn format_swagger_config(config: &Config, file: String) -> serde_json::Result { + let init_string = format!( + "{}\nui.initOAuth({});", + END_MARKER, + serde_json::to_string_pretty(config)? + ); + Ok(file.replace(END_MARKER, &init_string)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_CONTENT: &str = r###"" + // + window.ui = SwaggerUIBundle({ + {{urls}}, + dom_id: '#swagger-ui', + deepLinking: true, + presets: [ + SwaggerUIBundle.presets.apis, + SwaggerUIStandalonePreset + ], + plugins: [ + SwaggerUIBundle.plugins.DownloadUrl + ], + layout: "StandaloneLayout" + }); + // + ""###; + + #[test] + fn format_swagger_config_oauth() { + let config = Config { + client_id: Some(String::from("my-special-client")), + ..Default::default() + }; + let file = super::format_swagger_config(&config, TEST_CONTENT.to_string()).unwrap(); + + let expected = r#" +ui.initOAuth({ + "clientId": "my-special-client" +});"#; + assert!( + file.contains(expected), + "expected file to contain {}, was {}", + expected, + file + ) + } +}