Skip to main content

haystack_server/ops/
about.rs

1//! The `about` op — server identity and SCRAM authentication handshake.
2//!
3//! # Overview
4//!
5//! `GET /api/about` is dual-purpose:
6//! - **Unauthenticated** (HELLO / SCRAM): drives the three-phase SCRAM
7//!   SHA-256 handshake (HELLO → server challenge, SCRAM → client proof
8//!   verification, BEARER → authenticated access).
9//! - **Authenticated** (BEARER token): returns the server about grid.
10//!
11//! `POST /api/close` revokes the bearer token (logout).
12//!
13//! # Request
14//!
15//! No request grid is required. Authentication state is conveyed via the
16//! `Authorization` header (`HELLO`, `SCRAM`, or `BEARER` scheme).
17//!
18//! # Response Grid Columns
19//!
20//! | Column          | Kind | Description                          |
21//! |-----------------|------|--------------------------------------|
22//! | `haystackVersion` | Str | Haystack specification version (e.g. `"4.0"`) |
23//! | `serverName`    | Str  | Server implementation name           |
24//! | `serverVersion` | Str  | Server software version              |
25//! | `productName`   | Str  | Product name                         |
26//! | `productUri`    | Uri  | Product homepage URI                 |
27//! | `moduleName`    | Str  | Module / crate name                  |
28//! | `moduleVersion` | Str  | Module / crate version               |
29//!
30//! # Errors
31//!
32//! - **401 Unauthorized** — missing or invalid `Authorization` header.
33//! - **403 Forbidden** — HELLO lookup failed or SCRAM proof verification failed.
34//! - **500 Internal Server Error** — grid encoding failure.
35
36use actix_web::http::StatusCode;
37use actix_web::{HttpMessage, HttpRequest, HttpResponse, web};
38
39use haystack_core::auth::{AuthHeader, parse_auth_header};
40use haystack_core::data::{HCol, HDict, HGrid};
41use haystack_core::kinds::Kind;
42
43use crate::content;
44use crate::error::error_grid;
45use crate::state::AppState;
46
47/// GET /api/about
48///
49/// Handles three cases:
50/// 1. No Authorization header or HELLO -> 401 with WWW-Authenticate
51/// 2. SCRAM -> verify client proof, return 200 with Authentication-Info
52/// 3. BEARER -> return about grid
53pub async fn handle(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
54    let accept = req
55        .headers()
56        .get("Accept")
57        .and_then(|v| v.to_str().ok())
58        .unwrap_or("");
59
60    // If auth is not enabled, just return the about grid
61    if !state.auth.is_enabled() {
62        return respond_about_grid(accept);
63    }
64
65    let auth_header = req
66        .headers()
67        .get("Authorization")
68        .and_then(|v| v.to_str().ok());
69
70    match auth_header {
71        None => {
72            // No auth header: return 401 prompting for HELLO
73            HttpResponse::Unauthorized()
74                .insert_header(("WWW-Authenticate", "HELLO"))
75                .body("Authentication required")
76        }
77        Some(header) => {
78            match parse_auth_header(header) {
79                Ok(AuthHeader::Hello { username, data }) => {
80                    // HELLO phase: create handshake and return challenge
81                    match state.auth.handle_hello(&username, data.as_deref()) {
82                        Ok(www_auth) => HttpResponse::Unauthorized()
83                            .insert_header(("WWW-Authenticate", www_auth))
84                            .body(""),
85                        Err(e) => {
86                            log::warn!("HELLO failed for {username}: {e}");
87                            let grid = error_grid(&format!("authentication failed: {e}"));
88                            respond_error_grid(&grid, accept, StatusCode::FORBIDDEN)
89                        }
90                    }
91                }
92                Ok(AuthHeader::Scram {
93                    handshake_token,
94                    data,
95                }) => {
96                    // SCRAM phase: verify client proof
97                    match state.auth.handle_scram(&handshake_token, &data) {
98                        Ok((_auth_token, auth_info)) => HttpResponse::Ok()
99                            .insert_header(("Authentication-Info", auth_info))
100                            .body(""),
101                        Err(e) => {
102                            log::warn!("SCRAM verification failed: {e}");
103                            let grid = error_grid("authentication failed");
104                            respond_error_grid(&grid, accept, StatusCode::FORBIDDEN)
105                        }
106                    }
107                }
108                Ok(AuthHeader::Bearer { auth_token }) => {
109                    // BEARER phase: validate token and return about grid
110                    match state.auth.validate_token(&auth_token) {
111                        Some(_user) => respond_about_grid(accept),
112                        None => {
113                            let grid = error_grid("invalid or expired auth token");
114                            respond_error_grid(&grid, accept, StatusCode::UNAUTHORIZED)
115                        }
116                    }
117                }
118                Err(e) => {
119                    log::warn!("Invalid Authorization header: {e}");
120                    HttpResponse::BadRequest().body(format!("Invalid Authorization header: {e}"))
121                }
122            }
123        }
124    }
125}
126
127/// POST /api/close — revoke the bearer token (logout).
128///
129/// Invalidates the BEARER auth token supplied in the `Authorization`
130/// header and returns an empty grid on success.
131pub async fn handle_close(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
132    let accept = req
133        .headers()
134        .get("Accept")
135        .and_then(|v| v.to_str().ok())
136        .unwrap_or("");
137
138    if let Some(user) = req.extensions().get::<crate::auth::AuthUser>() {
139        // Find the token from the Authorization header
140        if let Some(auth_header) = req
141            .headers()
142            .get("Authorization")
143            .and_then(|v| v.to_str().ok())
144            && let Ok(AuthHeader::Bearer { auth_token }) = parse_auth_header(auth_header)
145        {
146            state.auth.revoke_token(&auth_token);
147        }
148        log::info!("User {} logged out", user.username);
149    }
150
151    // Return empty grid
152    let grid = HGrid::new();
153    match content::encode_response_grid(&grid, accept) {
154        Ok((body, ct)) => HttpResponse::Ok().content_type(ct).body(body),
155        Err(_) => HttpResponse::InternalServerError().body("encoding error"),
156    }
157}
158
159/// Build and encode the about grid response.
160fn respond_about_grid(accept: &str) -> HttpResponse {
161    let mut row = HDict::new();
162    row.set("haystackVersion", Kind::Str("4.0".to_string()));
163    row.set("serverName", Kind::Str("rusty-haystack".to_string()));
164    row.set("serverVersion", Kind::Str("0.6.3".to_string()));
165    row.set("productName", Kind::Str("rusty-haystack".to_string()));
166    row.set(
167        "productUri",
168        Kind::Uri(haystack_core::kinds::Uri::new(
169            "https://github.com/jscott3201/rusty-haystack",
170        )),
171    );
172    row.set("moduleName", Kind::Str("haystack-server".to_string()));
173    row.set("moduleVersion", Kind::Str("0.6.3".to_string()));
174
175    let cols = vec![
176        HCol::new("haystackVersion"),
177        HCol::new("serverName"),
178        HCol::new("serverVersion"),
179        HCol::new("productName"),
180        HCol::new("productUri"),
181        HCol::new("moduleName"),
182        HCol::new("moduleVersion"),
183    ];
184
185    let grid = HGrid::from_parts(HDict::new(), cols, vec![row]);
186    match content::encode_response_grid(&grid, accept) {
187        Ok((body, ct)) => HttpResponse::Ok().content_type(ct).body(body),
188        Err(e) => {
189            log::error!("Failed to encode about grid: {e}");
190            HttpResponse::InternalServerError().body("encoding error")
191        }
192    }
193}
194
195/// Encode an error grid and return it as an HttpResponse.
196fn respond_error_grid(grid: &HGrid, accept: &str, status: StatusCode) -> HttpResponse {
197    match content::encode_response_grid(grid, accept) {
198        Ok((body, ct)) => HttpResponse::build(status).content_type(ct).body(body),
199        Err(_) => HttpResponse::build(status).body("error"),
200    }
201}