Skip to main content

linkrs_server/http/middleware/
auth.rs

1use crate::http::state::AppState;
2use crate::storage::{
3    StorageClient, StorageOperationContextOps, StorageSchemaContextOps, StorageSyncContextOps,
4};
5use axum::{
6    extract::{Request, State},
7    http::StatusCode,
8    middleware::Next,
9    response::{IntoResponse, Response},
10    Json,
11};
12use serde_json::json;
13
14/// Structured failure codes returned by the auth middleware.
15pub const CODE_UNAUTHENTICATED: &str = "unauthenticated";
16pub const CODE_ACCOUNT_LOCKED: &str = "account_locked";
17pub const CODE_PASSWORD_CHANGE_REQUIRED: &str = "password_change_required";
18
19fn deny(status: StatusCode, code: &str, message: String) -> Response {
20    (
21        status,
22        Json(json!({ "error": { "code": code, "message": message }, "status": status.as_u16() })),
23    )
24        .into_response()
25}
26
27/// Whether a must-change account may call this path.
28///
29/// Query execution stays available so the password can be rotated via
30/// `CHANGE PASSWORD`; the admin reset endpoint plus the auth self-service
31/// endpoints stay available so management-plane flows are not deadlocked.
32fn must_change_allowed(path: &str) -> bool {
33    if path == "/v1/query" || path.starts_with("/v1/query/") {
34        return true;
35    }
36    if path == "/v1/auth/me" || path == "/v1/auth/logout" {
37        return true;
38    }
39    if path.starts_with("/v1/users/") && path.ends_with("/password") {
40        return true;
41    }
42    false
43}
44
45pub async fn auth_middleware<
46    S: StorageClient
47        + StorageSchemaContextOps
48        + StorageSyncContextOps
49        + StorageOperationContextOps
50        + Clone
51        + Send
52        + Sync
53        + 'static,
54>(
55    State(state): State<AppState<S>>,
56    mut request: Request,
57    next: Next,
58) -> Response {
59    let graph_service = state.server.get_graph_service();
60
61    // Skip every session check when the operator has explicitly disabled
62    // authentication (either via enable_authorize = false or single_user_mode = true).
63    // This matches the local-deployment "no login needed" contract. We still
64    // inject a placeholder session_id so downstream handlers never have to
65    // deal with an Option type.
66    if graph_service.is_auth_disabled() {
67        request.extensions_mut().insert(0i64);
68        return next.run(request).await;
69    }
70
71    let session_id = match request
72        .headers()
73        .get("X-Session-ID")
74        .and_then(|h| h.to_str().ok())
75        .and_then(|s| s.parse::<i64>().ok())
76    {
77        Some(id) => id,
78        None => {
79            return deny(
80                StatusCode::UNAUTHORIZED,
81                CODE_UNAUTHENTICATED,
82                "missing or invalid session id".to_string(),
83            );
84        }
85    };
86
87    let session = match state.server.get_session_manager().find_session(session_id) {
88        Some(session) => session,
89        None => {
90            return deny(
91                StatusCode::UNAUTHORIZED,
92                CODE_UNAUTHENTICATED,
93                "session not found or expired".to_string(),
94            );
95        }
96    };
97
98    if state
99        .server
100        .get_graph_service()
101        .is_user_locked(&session.user())
102    {
103        return deny(
104            StatusCode::FORBIDDEN,
105            CODE_ACCOUNT_LOCKED,
106            format!("account {} is locked", session.user()),
107        );
108    }
109
110    if state
111        .server
112        .get_graph_service()
113        .must_change_password(&session.user())
114        && !must_change_allowed(request.uri().path())
115    {
116        return deny(
117            StatusCode::FORBIDDEN,
118            CODE_PASSWORD_CHANGE_REQUIRED,
119            "password change required before other operations".to_string(),
120        );
121    }
122
123    request.extensions_mut().insert(session_id);
124
125    next.run(request).await
126}