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//! [`crate::service::auth`].
8//!
9//! An unknown `X-Manta-Site` is reported explicitly as `404 Not Found`
10//! so the CLI can fail fast instead of prompting for credentials that
11//! can never succeed. This is a deliberate trade-off: because `/auth/*`
12//! takes no bearer token, it lets an *unauthenticated* caller tell a
13//! configured site (401) from an unknown one (404) — reversing this
14//! module's former "reveal nothing about site config" stance. It is
15//! considered acceptable because site names are not secrets and the
16//! per-IP rate limiter in [`crate::server::auth_middleware`] bounds
17//! enumeration. (The authenticated endpoints already expose the same
18//! distinction once *any* syntactically-valid bearer header is present,
19//! since [`crate::server::handlers::RequestCtx`] does the site lookup
20//! before the token is validated against the backend.)
21//!
22//! Every *other* auth failure surfaces a generic `401 invalid
23//! credentials` so the response never reveals whether a username
24//! exists or what the backend actually rejected. The specific reason
25//! is captured server-side with `tracing::warn!` and sent to the
26//! audit channel when one is configured on [`ServerState`].
27
28use std::net::SocketAddr;
29use std::sync::Arc;
30
31use crate::server::common::audit;
32use axum::{
33  Json,
34  extract::{ConnectInfo, State},
35  http::StatusCode,
36  response::IntoResponse,
37};
38use manta_shared::types::auth::{
39  AuthTokenRequest, AuthTokenResponse, ValidateTokenRequest,
40};
41
42use super::{ErrorResponse, ServerState, SiteHeader, SiteName};
43use crate::service;
44
45/// Single generic 401 surfaced to clients for any `/auth/*`
46/// *credential* failure. Detail stays server-side in `tracing::warn!`.
47fn generic_invalid_credentials() -> (StatusCode, Json<ErrorResponse>) {
48  (
49    StatusCode::UNAUTHORIZED,
50    Json(ErrorResponse {
51      error: "invalid credentials".to_string(),
52    }),
53  )
54}
55
56/// `404` returned when the `X-Manta-Site` header names a site that is
57/// not configured on this server. Unlike credential failures (which
58/// stay a generic 401), an unknown site is reported explicitly so the
59/// CLI can fail fast instead of prompting for credentials that can
60/// never succeed. This intentionally reveals site existence to
61/// unauthenticated callers — see the module-level docs for the
62/// trade-off.
63fn site_not_found(site: &str) -> (StatusCode, Json<ErrorResponse>) {
64  (
65    StatusCode::NOT_FOUND,
66    Json(ErrorResponse {
67      error: format!("site '{site}' not found"),
68    }),
69  )
70}
71
72/// POST /api/v1/auth/token — exchange username/password for a CSM token.
73#[utoipa::path(post, path = "/auth/token", tag = "auth",
74  params(SiteHeader),
75  request_body = AuthTokenRequest,
76  responses(
77    (status = 200, description = "Token issued", body = AuthTokenResponse),
78    (status = 401, description = "Invalid credentials", body = ErrorResponse),
79    (status = 404, description = "Unknown site", body = ErrorResponse),
80    (status = 429, description = "Rate limit exceeded", body = ErrorResponse),
81    (status = 500, description = "Internal error", body = ErrorResponse),
82  )
83)]
84#[tracing::instrument(skip_all)]
85pub async fn auth_token(
86  State(state): State<Arc<ServerState>>,
87  SiteName(site_name): SiteName,
88  ConnectInfo(peer): ConnectInfo<SocketAddr>,
89  Json(req): Json<AuthTokenRequest>,
90) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
91  let infra = state.infra_context(&site_name).map_err(|e| {
92    tracing::warn!("auth_token: site lookup failed: {}", e);
93    site_not_found(&site_name)
94  })?;
95  let source_ip = peer.ip().to_string();
96
97  tracing::info!(
98    user = %req.username,
99    site = %site_name,
100    from = %source_ip,
101    "auth_token: credential exchange requested"
102  );
103
104  match service::auth::get_api_token(&infra, &req.username, &req.password).await
105  {
106    Ok(token) => {
107      tracing::info!(
108        user = %req.username,
109        site = %site_name,
110        from = %source_ip,
111        "auth_token: token issued"
112      );
113      audit::send_auth_audit(
114        state.auditor.as_ref(),
115        "success",
116        &req.username,
117        &source_ip,
118        &site_name,
119      )
120      .await;
121      Ok(Json(AuthTokenResponse { token }))
122    }
123    Err(e) => {
124      tracing::warn!(
125        "auth_token: backend rejected user={} site={} from={}: {}",
126        req.username,
127        site_name,
128        source_ip,
129        e
130      );
131      audit::send_auth_audit(
132        state.auditor.as_ref(),
133        "failure",
134        &req.username,
135        &source_ip,
136        &site_name,
137      )
138      .await;
139      Err(generic_invalid_credentials())
140    }
141  }
142}
143
144/// POST /api/v1/auth/validate — check whether a CSM token is still valid.
145#[utoipa::path(post, path = "/auth/validate", tag = "auth",
146  params(SiteHeader),
147  request_body = ValidateTokenRequest,
148  responses(
149    (status = 200, description = "Token is valid"),
150    (status = 401, description = "Token rejected", body = ErrorResponse),
151    (status = 404, description = "Unknown site", body = ErrorResponse),
152    (status = 429, description = "Rate limit exceeded", body = ErrorResponse),
153    (status = 500, description = "Internal error", body = ErrorResponse),
154  )
155)]
156#[tracing::instrument(skip_all)]
157pub async fn auth_validate(
158  State(state): State<Arc<ServerState>>,
159  SiteName(site_name): SiteName,
160  Json(req): Json<ValidateTokenRequest>,
161) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
162  let infra = state.infra_context(&site_name).map_err(|e| {
163    tracing::warn!("auth_validate: site lookup failed: {}", e);
164    site_not_found(&site_name)
165  })?;
166  tracing::info!(site = %site_name, "auth_validate: token check requested");
167  match service::auth::validate_api_token(&infra, &req.token).await {
168    Ok(()) => {
169      tracing::info!(site = %site_name, "auth_validate: token accepted");
170      Ok(StatusCode::OK)
171    }
172    Err(e) => {
173      tracing::warn!("auth_validate: backend rejected token: {}", e);
174      Err(generic_invalid_credentials())
175    }
176  }
177}