Skip to main content

openauth_plugins/open_api/
mod.rs

1//! OpenAPI schema and reference plugin.
2
3use 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('&', "&amp;")
205        .replace('"', "&quot;")
206        .replace('<', "&lt;")
207        .replace('>', "&gt;")
208}
209
210fn escape_js_string(value: &str) -> String {
211    value.replace('\\', "\\\\").replace('"', "\\\"")
212}