Skip to main content

zvault_server/routes/
auth.rs

1//! Token authentication routes: `/v1/auth/token/*`
2//!
3//! Handles token creation, lookup, renewal, and revocation.
4
5use std::collections::HashMap;
6use std::sync::Arc;
7
8use axum::extract::State;
9use axum::http::StatusCode;
10use axum::routing::post;
11use axum::{Extension, Json, Router};
12use chrono::Duration;
13use serde::{Deserialize, Serialize};
14
15use crate::error::AppError;
16use crate::middleware::AuthContext;
17use crate::state::AppState;
18use zvault_core::policy::Capability;
19use zvault_core::token::CreateTokenParams;
20
21/// Build the `/v1/auth/token` router.
22pub fn router() -> Router<Arc<AppState>> {
23    Router::new()
24        .route("/create", post(create_token))
25        .route("/lookup", post(lookup_token))
26        .route("/lookup-self", post(lookup_self))
27        .route("/renew", post(renew_token))
28        .route("/renew-self", post(renew_self))
29        .route("/revoke", post(revoke_token))
30        .route("/revoke-self", post(revoke_self))
31}
32
33// ── Request / Response types ─────────────────────────────────────────
34
35#[derive(Debug, Deserialize)]
36pub struct CreateTokenRequest {
37    pub policies: Option<Vec<String>>,
38    pub ttl: Option<String>,
39    pub display_name: Option<String>,
40    pub renewable: Option<bool>,
41    pub metadata: Option<HashMap<String, String>>,
42}
43
44#[derive(Debug, Serialize)]
45pub struct TokenResponse {
46    pub client_token: String,
47    pub policies: Vec<String>,
48    pub renewable: bool,
49    pub lease_duration: Option<i64>,
50}
51
52#[derive(Debug, Serialize)]
53pub struct TokenLookupResponse {
54    pub token_hash: String,
55    pub policies: Vec<String>,
56    pub display_name: String,
57    pub renewable: bool,
58    pub created_at: String,
59    pub expires_at: Option<String>,
60}
61
62#[derive(Debug, Deserialize)]
63pub struct TokenLookupRequest {
64    pub token: String,
65}
66
67#[derive(Debug, Deserialize)]
68pub struct TokenRenewRequest {
69    pub token: Option<String>,
70    pub increment: Option<String>,
71}
72
73#[derive(Debug, Deserialize)]
74pub struct TokenRevokeRequest {
75    pub token: String,
76}
77
78// ── Handlers ─────────────────────────────────────────────────────────
79
80/// Create a child token.
81async fn create_token(
82    State(state): State<Arc<AppState>>,
83    Extension(auth): Extension<AuthContext>,
84    Json(body): Json<CreateTokenRequest>,
85) -> Result<(StatusCode, Json<TokenResponse>), AppError> {
86    state
87        .policy_store
88        .check(&auth.policies, "auth/token/create", &Capability::Sudo)
89        .await?;
90
91    let ttl = body.ttl.as_deref().map(parse_duration).transpose()?;
92    let policies = body.policies.unwrap_or_else(|| vec!["default".to_owned()]);
93
94    let token = state
95        .token_store
96        .create(CreateTokenParams {
97            policies: policies.clone(),
98            ttl,
99            max_ttl: None,
100            renewable: body.renewable.unwrap_or(true),
101            parent_hash: Some(auth.token_hash),
102            metadata: body.metadata.unwrap_or_default(),
103            display_name: body.display_name.unwrap_or_else(|| "token".to_owned()),
104        })
105        .await?;
106
107    let lease_duration = ttl.map(|d| d.num_seconds());
108
109    Ok((
110        StatusCode::OK,
111        Json(TokenResponse {
112            client_token: token,
113            policies,
114            renewable: body.renewable.unwrap_or(true),
115            lease_duration,
116        }),
117    ))
118}
119
120/// Look up a token by its plaintext value (requires sudo).
121async fn lookup_token(
122    State(state): State<Arc<AppState>>,
123    Extension(auth): Extension<AuthContext>,
124    Json(body): Json<TokenLookupRequest>,
125) -> Result<Json<TokenLookupResponse>, AppError> {
126    state
127        .policy_store
128        .check(&auth.policies, "auth/token/lookup", &Capability::Sudo)
129        .await?;
130
131    let entry = state.token_store.lookup(&body.token).await?;
132
133    Ok(Json(TokenLookupResponse {
134        token_hash: entry.token_hash,
135        policies: entry.policies,
136        display_name: entry.display_name,
137        renewable: entry.renewable,
138        created_at: entry.created_at.to_rfc3339(),
139        expires_at: entry.expires_at.map(|t| t.to_rfc3339()),
140    }))
141}
142
143/// Look up the caller's own token (allowed by default policy).
144async fn lookup_self(
145    State(state): State<Arc<AppState>>,
146    Extension(auth): Extension<AuthContext>,
147) -> Result<Json<TokenLookupResponse>, AppError> {
148    state
149        .policy_store
150        .check(&auth.policies, "auth/token/lookup-self", &Capability::Read)
151        .await?;
152
153    // Re-lookup by hash — we don't have the plaintext, but we can
154    // reconstruct the response from the auth context. For a full lookup
155    // we'd need the plaintext token, so we return what we know.
156    Ok(Json(TokenLookupResponse {
157        token_hash: auth.token_hash,
158        policies: auth.policies,
159        display_name: auth.display_name,
160        renewable: false, // We don't have this from AuthContext; safe default
161        created_at: String::new(),
162        expires_at: None,
163    }))
164}
165
166/// Renew a specific token (requires sudo).
167async fn renew_token(
168    State(state): State<Arc<AppState>>,
169    Extension(auth): Extension<AuthContext>,
170    Json(body): Json<TokenRenewRequest>,
171) -> Result<Json<TokenLookupResponse>, AppError> {
172    state
173        .policy_store
174        .check(&auth.policies, "auth/token/renew", &Capability::Sudo)
175        .await?;
176
177    let token = body
178        .token
179        .ok_or_else(|| AppError::BadRequest("missing 'token' field".to_owned()))?;
180
181    let increment = body
182        .increment
183        .as_deref()
184        .map(parse_duration)
185        .transpose()?
186        .unwrap_or_else(|| Duration::hours(1));
187
188    let entry = state.token_store.renew(&token, increment).await?;
189
190    Ok(Json(TokenLookupResponse {
191        token_hash: entry.token_hash,
192        policies: entry.policies,
193        display_name: entry.display_name,
194        renewable: entry.renewable,
195        created_at: entry.created_at.to_rfc3339(),
196        expires_at: entry.expires_at.map(|t| t.to_rfc3339()),
197    }))
198}
199
200/// Renew the caller's own token (allowed by default policy).
201async fn renew_self(
202    State(state): State<Arc<AppState>>,
203    Extension(auth): Extension<AuthContext>,
204    Json(body): Json<TokenRenewRequest>,
205) -> Result<Json<serde_json::Value>, AppError> {
206    state
207        .policy_store
208        .check(
209            &auth.policies,
210            "auth/token/renew-self",
211            &Capability::Update,
212        )
213        .await?;
214
215    // We don't have the plaintext token in AuthContext, so renew-self
216    // requires the token in the body or we return an error.
217    let token = body
218        .token
219        .ok_or_else(|| AppError::BadRequest("missing 'token' field for renew-self".to_owned()))?;
220
221    let increment = body
222        .increment
223        .as_deref()
224        .map(parse_duration)
225        .transpose()?
226        .unwrap_or_else(|| Duration::hours(1));
227
228    let entry = state.token_store.renew(&token, increment).await?;
229
230    Ok(Json(serde_json::json!({
231        "token_hash": entry.token_hash,
232        "policies": entry.policies,
233        "renewable": entry.renewable,
234        "expires_at": entry.expires_at.map(|t| t.to_rfc3339()),
235    })))
236}
237
238/// Revoke a specific token and all its children (requires sudo).
239async fn revoke_token(
240    State(state): State<Arc<AppState>>,
241    Extension(auth): Extension<AuthContext>,
242    Json(body): Json<TokenRevokeRequest>,
243) -> Result<StatusCode, AppError> {
244    state
245        .policy_store
246        .check(&auth.policies, "auth/token/revoke", &Capability::Sudo)
247        .await?;
248
249    state.token_store.revoke(&body.token).await?;
250
251    Ok(StatusCode::NO_CONTENT)
252}
253
254/// Revoke the caller's own token.
255async fn revoke_self(
256    State(state): State<Arc<AppState>>,
257    Extension(auth): Extension<AuthContext>,
258    Json(body): Json<TokenRevokeRequest>,
259) -> Result<StatusCode, AppError> {
260    // Any token can revoke itself — no policy check needed beyond auth.
261    let _ = &auth;
262
263    state.token_store.revoke(&body.token).await?;
264
265    Ok(StatusCode::NO_CONTENT)
266}
267
268// ── Helpers ──────────────────────────────────────────────────────────
269
270/// Parse a human-readable duration string like `"1h"`, `"30m"`, `"3600s"`, `"24h"`.
271///
272/// # Errors
273///
274/// Returns [`AppError::BadRequest`] if the format is unrecognized.
275fn parse_duration(s: &str) -> Result<Duration, AppError> {
276    let s = s.trim();
277    if s.is_empty() {
278        return Err(AppError::BadRequest("empty duration string".to_owned()));
279    }
280
281    // Try pure seconds first.
282    if let Ok(secs) = s.parse::<i64>() {
283        return Ok(Duration::seconds(secs));
284    }
285
286    let (num_str, unit) = s.split_at(s.len().saturating_sub(1));
287    let num: i64 = num_str
288        .parse()
289        .map_err(|_| AppError::BadRequest(format!("invalid duration: {s}")))?;
290
291    match unit {
292        "s" => Ok(Duration::seconds(num)),
293        "m" => Ok(Duration::minutes(num)),
294        "h" => Ok(Duration::hours(num)),
295        "d" => Ok(Duration::days(num)),
296        _ => Err(AppError::BadRequest(format!(
297            "unknown duration unit '{unit}', expected s/m/h/d"
298        ))),
299    }
300}