openauth_plugins/open_api/
mod.rs1use http::{header, Method, StatusCode};
4use openauth_core::api::{
5 api_error, build_openapi_schema, core_auth_async_endpoints, create_auth_endpoint, ApiErrorCode,
6 ApiResponse, AsyncAuthEndpoint, AuthEndpointOptions, OpenApiOperation,
7};
8use openauth_core::context::AuthContext;
9use openauth_core::error::OpenAuthError;
10use openauth_core::plugin::AuthPlugin;
11use serde::{Deserialize, Serialize};
12use serde_json::json;
13
14pub const UPSTREAM_PLUGIN_ID: &str = "open-api";
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct OpenApiOptions {
18 pub path: String,
19 pub disable_default_reference: bool,
20 pub theme: String,
21 pub nonce: Option<String>,
22}
23
24impl Default for OpenApiOptions {
25 fn default() -> Self {
26 Self {
27 path: "/reference".to_owned(),
28 disable_default_reference: false,
29 theme: "default".to_owned(),
30 nonce: None,
31 }
32 }
33}
34
35impl OpenApiOptions {
36 #[must_use]
37 pub fn path(mut self, path: impl Into<String>) -> Self {
38 self.path = normalize_path(path.into());
39 self
40 }
41
42 #[must_use]
43 pub fn disable_default_reference(mut self, disabled: bool) -> Self {
44 self.disable_default_reference = disabled;
45 self
46 }
47
48 #[must_use]
49 pub fn theme(mut self, theme: impl Into<String>) -> Self {
50 self.theme = theme.into();
51 self
52 }
53
54 #[must_use]
55 pub fn nonce(mut self, nonce: impl Into<String>) -> Self {
56 self.nonce = Some(nonce.into());
57 self
58 }
59}
60
61pub fn open_api(options: OpenApiOptions) -> AuthPlugin {
62 AuthPlugin::new(UPSTREAM_PLUGIN_ID)
63 .with_version(crate::VERSION)
64 .with_options(serde_json::to_value(&options).unwrap_or(serde_json::Value::Null))
65 .with_endpoint(generate_schema_endpoint())
66 .with_endpoint(reference_endpoint(options))
67}
68
69fn generate_schema_endpoint() -> AsyncAuthEndpoint {
70 create_auth_endpoint(
71 "/open-api/generate-schema",
72 Method::GET,
73 AuthEndpointOptions::new()
74 .operation_id("generateOpenAPISchema")
75 .openapi(
76 OpenApiOperation::new("generateOpenAPISchema")
77 .description("Generate the OpenAPI schema for this OpenAuth instance")
78 .response(
79 "200",
80 json!({
81 "description": "OpenAPI schema",
82 "content": {
83 "application/json": {
84 "schema": {
85 "type": "object"
86 }
87 }
88 }
89 }),
90 ),
91 ),
92 move |context, _request| {
93 Box::pin(async move {
94 json_response(
95 StatusCode::OK,
96 serde_json::to_vec(&schema_for_context(context))
97 .map_err(|error| OpenAuthError::Api(error.to_string()))?,
98 )
99 })
100 },
101 )
102}
103
104fn reference_endpoint(options: OpenApiOptions) -> AsyncAuthEndpoint {
105 let path = options.path.clone();
106 create_auth_endpoint(
107 path,
108 Method::GET,
109 AuthEndpointOptions::new()
110 .operation_id("openApiReference")
111 .hide_from_openapi()
112 .openapi(
113 OpenApiOperation::new("openApiReference")
114 .summary("OpenAPI reference")
115 .description("Serve the interactive OpenAPI reference"),
116 ),
117 move |context, _request| {
118 let options = options.clone();
119 Box::pin(async move {
120 if options.disable_default_reference {
121 return api_error(StatusCode::NOT_FOUND, ApiErrorCode::NotFound);
122 }
123 html_response(get_html(
124 &schema_for_context(context),
125 &options.theme,
126 options.nonce.as_deref(),
127 ))
128 })
129 },
130 )
131}
132
133fn schema_for_context(context: &AuthContext) -> serde_json::Value {
134 let mut endpoints = context
135 .adapter()
136 .map(core_auth_async_endpoints)
137 .unwrap_or_default();
138 for plugin in &context.plugins {
139 endpoints.extend(plugin.endpoints.iter().cloned());
140 }
141 build_openapi_schema(context, &endpoints)
142}
143
144fn get_html(api_reference: &serde_json::Value, theme: &str, nonce: Option<&str>) -> String {
145 let nonce_attr = nonce
146 .map(|nonce| format!(" nonce=\"{}\"", escape_html_attr(nonce)))
147 .unwrap_or_default();
148 format!(
149 r#"<!doctype html>
150<html>
151 <head>
152 <title>OpenAuth API Reference</title>
153 <meta charset="utf-8" />
154 <meta name="viewport" content="width=device-width, initial-scale=1" />
155 </head>
156 <body>
157 <script id="api-reference" type="application/json">{api_reference}</script>
158 <script{nonce_attr}>
159 var configuration = {{
160 theme: "{theme}",
161 metaData: {{
162 title: "OpenAuth API",
163 description: "API Reference for your OpenAuth instance"
164 }}
165 }}
166 document.getElementById("api-reference").dataset.configuration =
167 JSON.stringify(configuration)
168 </script>
169 <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"{nonce_attr}></script>
170 </body>
171</html>"#,
172 api_reference = api_reference,
173 theme = escape_js_string(theme),
174 nonce_attr = nonce_attr,
175 )
176}
177
178fn json_response(status: StatusCode, body: Vec<u8>) -> Result<ApiResponse, OpenAuthError> {
179 http::Response::builder()
180 .status(status)
181 .header(header::CONTENT_TYPE, "application/json")
182 .body(body)
183 .map_err(|error| OpenAuthError::Api(error.to_string()))
184}
185
186fn html_response(body: String) -> Result<ApiResponse, OpenAuthError> {
187 http::Response::builder()
188 .status(StatusCode::OK)
189 .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
190 .body(body.into_bytes())
191 .map_err(|error| OpenAuthError::Api(error.to_string()))
192}
193
194fn normalize_path(path: String) -> String {
195 if path.starts_with('/') {
196 path
197 } else {
198 format!("/{path}")
199 }
200}
201
202fn escape_html_attr(value: &str) -> String {
203 value
204 .replace('&', "&")
205 .replace('"', """)
206 .replace('<', "<")
207 .replace('>', ">")
208}
209
210fn escape_js_string(value: &str) -> String {
211 value.replace('\\', "\\\\").replace('"', "\\\"")
212}