1use std::collections::BTreeMap;
2
3use http::Method;
4use serde::{Deserialize, Serialize};
5use serde_json::{json, Value};
6
7use crate::context::AuthContext;
8
9use super::endpoint::AsyncAuthEndpoint;
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct OpenApiOperation {
13 pub operation_id: Option<String>,
14 pub summary: Option<String>,
15 pub description: Option<String>,
16 pub tags: Vec<String>,
17 pub parameters: Vec<Value>,
18 pub request_body: Option<Value>,
19 pub responses: BTreeMap<String, Value>,
20}
21
22impl OpenApiOperation {
23 pub fn new(operation_id: impl Into<String>) -> Self {
24 Self {
25 operation_id: Some(operation_id.into()),
26 summary: None,
27 description: None,
28 tags: Vec::new(),
29 parameters: Vec::new(),
30 request_body: None,
31 responses: BTreeMap::new(),
32 }
33 }
34
35 #[must_use]
36 pub fn summary(mut self, summary: impl Into<String>) -> Self {
37 self.summary = Some(summary.into());
38 self
39 }
40
41 #[must_use]
42 pub fn description(mut self, description: impl Into<String>) -> Self {
43 self.description = Some(description.into());
44 self
45 }
46
47 #[must_use]
48 pub fn tag(mut self, tag: impl Into<String>) -> Self {
49 self.tags.push(tag.into());
50 self
51 }
52
53 #[must_use]
54 pub fn request_body(mut self, request_body: Value) -> Self {
55 self.request_body = Some(request_body);
56 self
57 }
58
59 #[must_use]
60 pub fn parameter(mut self, parameter: Value) -> Self {
61 self.parameters.push(parameter);
62 self
63 }
64
65 #[must_use]
66 pub fn response(mut self, status: impl Into<String>, response: Value) -> Self {
67 self.responses.insert(status.into(), response);
68 self
69 }
70}
71
72pub(super) fn openapi_operation_for_endpoint(endpoint: &AsyncAuthEndpoint) -> Value {
73 let mut operation = endpoint
74 .options
75 .openapi
76 .clone()
77 .unwrap_or_else(|| OpenApiOperation {
78 operation_id: endpoint.options.operation_id.clone(),
79 summary: None,
80 description: None,
81 tags: Vec::new(),
82 parameters: Vec::new(),
83 request_body: None,
84 responses: BTreeMap::new(),
85 });
86 let operation_id = operation
87 .operation_id
88 .clone()
89 .or_else(|| endpoint.options.operation_id.clone());
90 if operation.summary.is_none() {
91 operation.summary = operation_id.as_deref().map(humanize_operation_id);
92 }
93 if operation.description.is_none() {
94 operation.description = operation
95 .summary
96 .as_ref()
97 .map(|summary| format!("{summary} endpoint"));
98 }
99 add_missing_path_parameters(&mut operation.parameters, &endpoint.path);
100 let request_body = operation.request_body.or_else(|| {
101 endpoint
102 .options
103 .body_schema
104 .as_ref()
105 .map(|schema| {
106 json!({
107 "required": true,
108 "content": {
109 "application/json": {
110 "schema": schema.openapi_schema(),
111 },
112 },
113 })
114 })
115 .or_else(|| {
116 method_uses_request_body(&endpoint.method).then(|| {
117 json!({
118 "content": {
119 "application/json": {
120 "schema": {
121 "type": "object",
122 "properties": {},
123 },
124 },
125 },
126 })
127 })
128 })
129 });
130 let mut responses = default_openapi_responses();
131 for (status, response) in operation.responses {
132 responses.insert(status, response);
133 }
134 if !responses
135 .keys()
136 .any(|status| status.starts_with('2') || status.starts_with('3'))
137 {
138 responses.insert(
139 "200".to_owned(),
140 json_openapi_response(
141 "Success",
142 json!({
143 "type": "object",
144 "properties": {},
145 }),
146 ),
147 );
148 }
149 let mut tags = if operation.tags.is_empty() {
150 vec![tag_for_endpoint(endpoint, operation_id.as_deref())]
151 } else {
152 Vec::new()
153 };
154 for tag in operation.tags {
155 if !tags.iter().any(|existing| existing == &tag) {
156 tags.push(tag);
157 }
158 }
159
160 let mut value = serde_json::Map::new();
161 value.insert(
162 "tags".to_owned(),
163 Value::Array(tags.into_iter().map(Value::String).collect()),
164 );
165 if let Some(description) = operation.description {
166 value.insert("description".to_owned(), Value::String(description));
167 }
168 if let Some(summary) = operation.summary {
169 value.insert("summary".to_owned(), Value::String(summary));
170 }
171 if let Some(operation_id) = operation_id {
172 value.insert("operationId".to_owned(), Value::String(operation_id));
173 }
174 value.insert(
175 "security".to_owned(),
176 json!([
177 {
178 "bearerAuth": [],
179 },
180 ]),
181 );
182 value.insert("parameters".to_owned(), Value::Array(operation.parameters));
183 if let Some(request_body) = request_body {
184 value.insert("requestBody".to_owned(), request_body);
185 }
186 value.insert("responses".to_owned(), Value::Object(responses));
187 Value::Object(value)
188}
189
190fn add_missing_path_parameters(parameters: &mut Vec<Value>, path: &str) {
191 for name in path
192 .split('/')
193 .filter_map(|part| part.strip_prefix(':'))
194 .filter(|name| !name.is_empty())
195 {
196 let exists = parameters.iter().any(|parameter| {
197 parameter.get("name").and_then(Value::as_str) == Some(name)
198 && parameter.get("in").and_then(Value::as_str) == Some("path")
199 });
200 if !exists {
201 parameters.push(path_param(name, &format!("Path parameter `{name}`")));
202 }
203 }
204}
205
206fn humanize_operation_id(operation_id: &str) -> String {
207 let mut words = Vec::new();
208 let mut current = String::new();
209 for character in operation_id.chars() {
210 if character == '_' || character == '-' {
211 if !current.is_empty() {
212 words.push(std::mem::take(&mut current));
213 }
214 continue;
215 }
216 if character.is_uppercase() && !current.is_empty() {
217 words.push(std::mem::take(&mut current));
218 }
219 current.push(character.to_ascii_lowercase());
220 }
221 if !current.is_empty() {
222 words.push(current);
223 }
224
225 let mut summary = words.join(" ");
226 if let Some(first) = summary.get_mut(0..1) {
227 first.make_ascii_uppercase();
228 }
229 summary
230}
231
232fn tag_for_endpoint(endpoint: &AsyncAuthEndpoint, operation_id: Option<&str>) -> String {
233 if let Some(tag) = tag_for_operation_id(operation_id.unwrap_or_default()) {
234 return tag.to_owned();
235 }
236 let first_segment = endpoint
237 .path
238 .split('/')
239 .find(|segment| !segment.is_empty())
240 .unwrap_or_default();
241 tag_for_path_segment(first_segment)
242 .unwrap_or("Default")
243 .to_owned()
244}
245
246fn tag_for_operation_id(operation_id: &str) -> Option<&'static str> {
247 if operation_id.starts_with("mcp") || operation_id.starts_with("getMcp") {
248 Some("MCP")
249 } else if operation_id.contains("JWT")
250 || operation_id.contains("JSONWeb")
251 || operation_id.ends_with("JWT")
252 {
253 Some("JWT")
254 } else if operation_id.contains("OAuth2") {
255 Some("Generic OAuth")
256 } else if operation_id.contains("Siwe") {
257 Some("SIWE")
258 } else if operation_id.contains("PhoneNumber") {
259 Some("Phone Number")
260 } else if operation_id.contains("TwoFactor")
261 || operation_id.contains("BackupCode")
262 || operation_id.contains("Otp")
263 {
264 Some("Two Factor")
265 } else if operation_id.starts_with("organization") || operation_id.contains("Organization") {
266 Some("Organization")
267 } else {
268 None
269 }
270}
271
272fn tag_for_path_segment(segment: &str) -> Option<&'static str> {
273 match segment {
274 ".well-known" | "mcp" => Some("MCP"),
275 "admin" => Some("Admin"),
276 "anonymous" | "delete-anonymous-user" => Some("Anonymous"),
277 "device" | "device-authorization" => Some("Device Authorization"),
278 "email-otp" => Some("Email OTP"),
279 "oauth2" => Some("Generic OAuth"),
280 "jwt" | "jwks" | "token" => Some("JWT"),
281 "magic-link" => Some("Magic Link"),
282 "multi-session" => Some("Multi Session"),
283 "oauth-proxy" => Some("OAuth Proxy"),
284 "one-tap" => Some("One Tap"),
285 "one-time-token" => Some("One Time Token"),
286 "open-api" => Some("Open API"),
287 "organization" => Some("Organization"),
288 "phone-number" => Some("Phone Number"),
289 "siwe" => Some("SIWE"),
290 "two-factor" => Some("Two Factor"),
291 "username" => Some("Username"),
292 _ => None,
293 }
294}
295
296pub fn build_openapi_schema(context: &AuthContext, async_endpoints: &[AsyncAuthEndpoint]) -> Value {
297 let mut paths = serde_json::Map::new();
298 for endpoint in async_endpoints {
299 if endpoint.options.server_only || endpoint.options.hide_from_openapi {
300 continue;
301 }
302 let path = paths
303 .entry(to_openapi_path(&endpoint.path))
304 .or_insert_with(|| Value::Object(serde_json::Map::new()));
305 let Value::Object(methods) = path else {
306 continue;
307 };
308 methods.insert(
309 endpoint.method.as_str().to_ascii_lowercase(),
310 openapi_operation_for_endpoint(endpoint),
311 );
312 }
313 json!({
314 "openapi": "3.1.1",
315 "info": {
316 "title": "OpenAuth",
317 "description": "API Reference for your OpenAuth instance",
318 "version": crate::VERSION,
319 },
320 "components": {
321 "schemas": openapi_model_schemas(),
322 "securitySchemes": {
323 "apiKeyCookie": {
324 "type": "apiKey",
325 "in": "cookie",
326 "name": "apiKeyCookie",
327 "description": "API Key authentication via cookie",
328 },
329 "bearerAuth": {
330 "type": "http",
331 "scheme": "bearer",
332 "description": "Bearer token authentication",
333 },
334 },
335 },
336 "security": [
337 {
338 "apiKeyCookie": [],
339 "bearerAuth": [],
340 },
341 ],
342 "servers": [
343 {
344 "url": context.base_url,
345 },
346 ],
347 "tags": [
348 {
349 "name": "Default",
350 "description": "Default endpoints that are included with OpenAuth by default. These endpoints are not part of any plugin.",
351 },
352 ],
353 "paths": paths,
354 })
355}
356
357fn method_uses_request_body(method: &Method) -> bool {
358 matches!(*method, Method::POST | Method::PATCH | Method::PUT)
359}
360
361pub(super) fn to_openapi_path(path: &str) -> String {
362 path.split('/')
363 .map(|part| {
364 part.strip_prefix(':')
365 .map(|name| format!("{{{name}}}"))
366 .unwrap_or_else(|| part.to_owned())
367 })
368 .collect::<Vec<_>>()
369 .join("/")
370}
371
372fn default_openapi_responses() -> serde_json::Map<String, Value> {
373 let mut responses = serde_json::Map::new();
374 responses.insert(
375 "400".to_owned(),
376 openapi_error_response(
377 "Bad Request. Usually due to missing parameters, or invalid parameters.",
378 true,
379 ),
380 );
381 responses.insert(
382 "401".to_owned(),
383 openapi_error_response(
384 "Unauthorized. Due to missing or invalid authentication.",
385 true,
386 ),
387 );
388 responses.insert(
389 "403".to_owned(),
390 openapi_error_response(
391 "Forbidden. You do not have permission to access this resource or to perform this action.",
392 false,
393 ),
394 );
395 responses.insert(
396 "404".to_owned(),
397 openapi_error_response("Not Found. The requested resource was not found.", false),
398 );
399 responses.insert(
400 "429".to_owned(),
401 openapi_error_response(
402 "Too Many Requests. You have exceeded the rate limit. Try again later.",
403 false,
404 ),
405 );
406 responses.insert(
407 "500".to_owned(),
408 openapi_error_response(
409 "Internal Server Error. This is a problem with the server that you cannot fix.",
410 false,
411 ),
412 );
413 responses
414}
415
416fn openapi_error_response(description: &str, require_message: bool) -> Value {
417 let required = require_message.then(|| json!(["message"]));
418 let mut schema = serde_json::Map::new();
419 schema.insert("type".to_owned(), Value::String("object".to_owned()));
420 schema.insert(
421 "properties".to_owned(),
422 json!({
423 "message": {
424 "type": "string",
425 },
426 }),
427 );
428 if let Some(required) = required {
429 schema.insert("required".to_owned(), required);
430 }
431 json!({
432 "content": {
433 "application/json": {
434 "schema": Value::Object(schema),
435 },
436 },
437 "description": description,
438 })
439}
440
441pub fn json_openapi_response(description: &str, schema: Value) -> Value {
442 json!({
443 "description": description,
444 "content": {
445 "application/json": {
446 "schema": schema,
447 },
448 },
449 })
450}
451
452pub fn empty_openapi_response(description: &str) -> Value {
453 json!({
454 "description": description,
455 })
456}
457
458pub fn redirect_openapi_response(description: &str) -> Value {
459 json!({
460 "description": description,
461 "headers": {
462 "Location": {
463 "description": "Redirect target",
464 "schema": {
465 "type": "string",
466 "format": "uri",
467 },
468 },
469 },
470 })
471}
472
473pub fn query_param(name: &str, description: &str) -> Value {
474 json!({
475 "name": name,
476 "in": "query",
477 "required": false,
478 "description": description,
479 "schema": {
480 "type": "string",
481 },
482 })
483}
484
485pub fn path_param(name: &str, description: &str) -> Value {
486 json!({
487 "name": name,
488 "in": "path",
489 "required": true,
490 "description": description,
491 "schema": {
492 "type": "string",
493 },
494 })
495}
496
497pub(super) fn openapi_model_schemas() -> Value {
498 json!({
499 "User": {
500 "type": "object",
501 "properties": {
502 "id": { "type": "string" },
503 "email": { "type": "string", "format": "email" },
504 "name": { "type": "string" },
505 "image": { "type": "string", "format": "uri", "nullable": true },
506 "emailVerified": { "type": "boolean" },
507 "createdAt": { "type": "string", "format": "date-time" },
508 "updatedAt": { "type": "string", "format": "date-time" },
509 },
510 "required": ["id", "email", "name", "emailVerified", "createdAt", "updatedAt"],
511 },
512 "Session": {
513 "type": "object",
514 "properties": {
515 "id": { "type": "string" },
516 "userId": { "type": "string" },
517 "expiresAt": { "type": "string", "format": "date-time" },
518 "token": { "type": "string" },
519 "ipAddress": { "type": "string", "nullable": true },
520 "userAgent": { "type": "string", "nullable": true },
521 "createdAt": { "type": "string", "format": "date-time" },
522 "updatedAt": { "type": "string", "format": "date-time" },
523 },
524 "required": ["id", "userId", "expiresAt", "token", "createdAt", "updatedAt"],
525 },
526 "Account": {
527 "type": "object",
528 "properties": {
529 "id": { "type": "string" },
530 "providerId": { "type": "string" },
531 "accountId": { "type": "string" },
532 "userId": { "type": "string" },
533 "accessToken": { "type": "string", "nullable": true },
534 "refreshToken": { "type": "string", "nullable": true },
535 "idToken": { "type": "string", "nullable": true },
536 "scope": { "type": "string", "nullable": true },
537 "password": { "type": "string", "nullable": true },
538 "createdAt": { "type": "string", "format": "date-time" },
539 "updatedAt": { "type": "string", "format": "date-time" },
540 },
541 "required": ["id", "providerId", "accountId", "userId", "createdAt", "updatedAt"],
542 },
543 "Verification": {
544 "type": "object",
545 "properties": {
546 "id": { "type": "string" },
547 "identifier": { "type": "string" },
548 "value": { "type": "string" },
549 "expiresAt": { "type": "string", "format": "date-time" },
550 "createdAt": { "type": "string", "format": "date-time" },
551 "updatedAt": { "type": "string", "format": "date-time" },
552 },
553 "required": ["id", "identifier", "value", "expiresAt", "createdAt", "updatedAt"],
554 },
555 })
556}