Skip to main content

session_guard/
aide.rs

1//! OpenAPI support through [`aide`], and so through `rovo`.
2//!
3//! With the `aide` feature the extractors and error types document themselves:
4//!
5//! - [`AuthSession`] marks its route as requiring the [`SECURITY_SCHEME`] cookie and documents
6//!   the layer's 401 rejections. `Option<AuthSession>` adds the requirement as well, so a bypass
7//!   route that also takes an API key lists both as alternatives.
8//! - [`Sessions`] leaves the route public: it is what login and step-up handlers take.
9//! - [`GuardError`] and [`StepUpError`] document the responses they convert into.
10//!
11//! [`register`] adds the scheme itself to the spec; call it once, after `finish_api`. With rovo,
12//! `@security session_cookie` on a handler adds the same requirement explicitly.
13//!
14//! ```
15//! use aide::{axum::{ApiRouter, routing::get}, openapi::OpenApi};
16//! use session_guard::{AuthSession, Guard, MemoryStore};
17//! use uuid::Uuid;
18//!
19//! async fn me(session: AuthSession<Uuid>) -> String {
20//!     session.subject.to_string()
21//! }
22//!
23//! let guard = Guard::new(MemoryStore::new());
24//! let mut api = OpenApi::default();
25//! let app: axum::Router = ApiRouter::new()
26//!     .api_route("/me", get(me))
27//!     .with_state(guard.clone())
28//!     .finish_api(&mut api);
29//! session_guard::aide::register(&mut api, &guard);
30//! ```
31
32use ::aide::{
33    OperationInput, OperationOutput,
34    generate::GenContext,
35    openapi::{ApiKeyLocation, MediaType, OpenApi, Operation, ReferenceOr, Response, SchemaObject, SecurityScheme},
36    transform::TransformOperation,
37};
38use serde_json::json;
39
40use crate::{
41    AuthSession, Guard, GuardError, Rejection, SessionStore, Subject,
42    axum::{Sessions, StepUpError},
43};
44
45/// The name of the session cookie's security scheme: `session_cookie`.
46pub const SECURITY_SCHEME: &str = "session_cookie";
47
48/// The session cookie as an OpenAPI `apiKey` scheme, named after the guard's cookie.
49#[expect(clippy::default_trait_access, reason = "aide does not re-export IndexMap")]
50pub fn security_scheme<St: SessionStore, S: Subject>(guard: &Guard<St, S>) -> SecurityScheme {
51    SecurityScheme::ApiKey {
52        location: ApiKeyLocation::Cookie,
53        name: guard.cookie_name().to_owned(),
54        description: Some("Session cookie, rotated by the server.".to_owned()),
55        extensions: Default::default(),
56    }
57}
58
59/// Adds [`security_scheme`] to `api` as [`SECURITY_SCHEME`].
60pub fn register<St: SessionStore, S: Subject>(api: &mut OpenApi, guard: &Guard<St, S>) {
61    api.components
62        .get_or_insert_default()
63        .security_schemes
64        .insert(SECURITY_SCHEME.to_owned(), ReferenceOr::Item(security_scheme(guard)));
65}
66
67impl<S> OperationInput for AuthSession<S> {
68    fn operation_input(_: &mut GenContext, operation: &mut Operation) {
69        let _ = TransformOperation::new(operation).security_requirement(SECURITY_SCHEME);
70    }
71
72    fn inferred_early_responses(_: &mut GenContext, _: &mut Operation) -> Vec<(Option<u16>, Response)> {
73        let codes = [
74            Rejection::NotAuthenticated,
75            Rejection::Expired,
76            Rejection::Revoked,
77            Rejection::StepUpRequired,
78        ]
79        .map(Rejection::as_str);
80        vec![(Some(401), error_response("No valid session.", &codes))]
81    }
82}
83
84impl<St, S> OperationInput for Sessions<St, S> {}
85
86impl OperationOutput for GuardError {
87    type Inner = Self;
88
89    fn operation_response(_: &mut GenContext, _: &mut Operation) -> Option<Response> {
90        Some(internal())
91    }
92
93    fn inferred_responses(_: &mut GenContext, _: &mut Operation) -> Vec<(Option<u16>, Response)> {
94        vec![(Some(500), internal())]
95    }
96}
97
98impl OperationOutput for StepUpError {
99    type Inner = Self;
100
101    fn inferred_responses(_: &mut GenContext, _: &mut Operation) -> Vec<(Option<u16>, Response)> {
102        let rejected = [Rejection::NotAuthenticated, Rejection::Expired, Rejection::Revoked].map(Rejection::as_str);
103        vec![
104            (Some(401), error_response("No valid session.", &rejected)),
105            (
106                Some(403),
107                error_response("The verified subject does not own the session.", &["subject_mismatch"]),
108            ),
109            (Some(500), internal()),
110        ]
111    }
112}
113
114fn internal() -> Response {
115    error_response("Internal error.", &["internal"])
116}
117
118/// A JSON `{"error": <code>}` response.
119fn error_response(description: &str, codes: &[&str]) -> Response {
120    let schema = json!({
121        "type": "object",
122        "required": ["error"],
123        "properties": { "error": { "type": "string", "enum": codes } },
124    });
125    Response {
126        description: description.to_owned(),
127        content: [(
128            "application/json".to_owned(),
129            MediaType {
130                schema: Some(SchemaObject {
131                    json_schema: serde_json::from_value(schema).unwrap_or_default(),
132                    external_docs: None,
133                    example: None,
134                }),
135                ..MediaType::default()
136            },
137        )]
138        .into(),
139        ..Response::default()
140    }
141}