manta_server/server/handlers/
auth.rs

1//! Public-router auth handlers (`POST /api/v1/auth/{token,validate}`).
2//!
3//! Deliberately not behind the `BearerToken` extractor — these are the
4//! endpoints clients call *to obtain* a bearer token. The defensive
5//! middleware (rate limit, body redaction) lives in
6//! `crate::server::auth_middleware`; this file just maps requests to
7//! `service::auth`.
8
9use std::net::SocketAddr;
10use std::sync::Arc;
11
12use axum::{
13  Json,
14  extract::{ConnectInfo, State},
15  http::StatusCode,
16  response::IntoResponse,
17};
18use manta_shared::common::audit;
19use manta_shared::shared::auth::{
20  AuthTokenRequest, AuthTokenResponse, ValidateTokenRequest,
21};
22
23use super::{ErrorResponse, ServerState, SiteHeader, SiteName};
24use crate::service;
25
26/// Single generic 401 surfaced to clients for any `/auth/*` failure.
27/// Detail stays server-side in `tracing::warn!`.
28fn generic_invalid_credentials() -> (StatusCode, Json<ErrorResponse>) {
29  (
30    StatusCode::UNAUTHORIZED,
31    Json(ErrorResponse {
32      error: "invalid credentials".to_string(),
33    }),
34  )
35}
36
37/// POST /api/v1/auth/token — exchange username/password for a CSM token.
38#[utoipa::path(post, path = "/auth/token", tag = "auth",
39  params(SiteHeader),
40  request_body = AuthTokenRequest,
41  responses(
42    (status = 200, description = "Token issued", body = AuthTokenResponse),
43    (status = 401, description = "Invalid credentials", body = ErrorResponse),
44    (status = 429, description = "Rate limit exceeded", body = ErrorResponse),
45    (status = 500, description = "Internal error", body = ErrorResponse),
46  )
47)]
48#[tracing::instrument(skip_all)]
49pub async fn auth_token(
50  State(state): State<Arc<ServerState>>,
51  SiteName(site_name): SiteName,
52  ConnectInfo(peer): ConnectInfo<SocketAddr>,
53  Json(req): Json<AuthTokenRequest>,
54) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
55  let infra = state.infra_context(&site_name).map_err(|e| {
56    tracing::warn!("auth_token: site lookup failed: {}", e);
57    generic_invalid_credentials()
58  })?;
59  let source_ip = peer.ip().to_string();
60
61  tracing::info!(
62    user = %req.username,
63    site = %site_name,
64    from = %source_ip,
65    "auth_token: credential exchange requested"
66  );
67
68  match service::auth::get_api_token(&infra, &req.username, &req.password).await
69  {
70    Ok(token) => {
71      tracing::info!(
72        user = %req.username,
73        site = %site_name,
74        from = %source_ip,
75        "auth_token: token issued"
76      );
77      audit::send_auth_audit(
78        state.auditor.as_ref(),
79        "success",
80        &req.username,
81        &source_ip,
82        &site_name,
83      )
84      .await;
85      Ok(Json(AuthTokenResponse { token }))
86    }
87    Err(e) => {
88      tracing::warn!(
89        "auth_token: backend rejected user={} site={} from={}: {}",
90        req.username,
91        site_name,
92        source_ip,
93        e
94      );
95      audit::send_auth_audit(
96        state.auditor.as_ref(),
97        "failure",
98        &req.username,
99        &source_ip,
100        &site_name,
101      )
102      .await;
103      Err(generic_invalid_credentials())
104    }
105  }
106}
107
108/// POST /api/v1/auth/validate — check whether a CSM token is still valid.
109#[utoipa::path(post, path = "/auth/validate", tag = "auth",
110  params(SiteHeader),
111  request_body = ValidateTokenRequest,
112  responses(
113    (status = 200, description = "Token is valid"),
114    (status = 401, description = "Token rejected", body = ErrorResponse),
115    (status = 429, description = "Rate limit exceeded", body = ErrorResponse),
116    (status = 500, description = "Internal error", body = ErrorResponse),
117  )
118)]
119#[tracing::instrument(skip_all)]
120pub async fn auth_validate(
121  State(state): State<Arc<ServerState>>,
122  SiteName(site_name): SiteName,
123  Json(req): Json<ValidateTokenRequest>,
124) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
125  let infra = state.infra_context(&site_name).map_err(|e| {
126    tracing::warn!("auth_validate: site lookup failed: {}", e);
127    generic_invalid_credentials()
128  })?;
129  tracing::info!(site = %site_name, "auth_validate: token check requested");
130  match service::auth::validate_api_token(&infra, &req.token).await {
131    Ok(()) => {
132      tracing::info!(site = %site_name, "auth_validate: token accepted");
133      Ok(StatusCode::OK)
134    }
135    Err(e) => {
136      tracing::warn!("auth_validate: backend rejected token: {}", e);
137      Err(generic_invalid_credentials())
138    }
139  }
140}