mod create;
mod get;
mod list;
pub mod me;
mod remove;
mod rotate;
use axum::extract::FromRequest;
use axum::http::Request;
use bytes::Bytes;
use axum_extra::headers::HeaderMapExt;
use axum::body::Body;
use axum::routing::{delete, get, post};
use reduct_base::error::ErrorCode;
use std::sync::Arc;
use crate::api::http::token::create::create_token;
use crate::api::http::token::get::get_token;
use crate::api::http::token::list::list_tokens;
use crate::api::http::token::remove::remove_token;
use crate::api::http::token::rotate::rotate_token;
use crate::api::http::{HttpError, StateKeeper};
use reduct_base::msg::token_api::{
Permissions, Token, TokenCreateRequest, TokenCreateResponse, TokenList,
};
use reduct_macros::{IntoResponse, Twin};
use serde::Deserialize;
use crate::auth::token_repository::token_is_expired;
use chrono::Utc;
#[derive(IntoResponse, Twin)]
pub struct TokenAxum(Token);
#[derive(IntoResponse, Twin)]
pub struct TokenCreateResponseAxum(TokenCreateResponse);
#[derive(IntoResponse, Twin, Default)]
pub struct TokenListAxum(TokenList);
#[derive(IntoResponse, Twin)]
pub struct TokenCreateRequestAxum(TokenCreateRequest);
impl<S> FromRequest<S> for TokenCreateRequestAxum
where
Bytes: FromRequest<S>,
S: Send + Sync,
{
type Rejection = HttpError;
async fn from_request(req: Request<Body>, state: &S) -> Result<Self, Self::Rejection> {
let bytes = Bytes::from_request(req, state)
.await
.map_err(|_| HttpError::new(ErrorCode::UnprocessableEntity, "Invalid body"))?;
parse_token_create_request(bytes).map(TokenCreateRequestAxum::from)
}
}
pub(super) fn create_token_api_routes() -> axum::Router<Arc<StateKeeper>> {
axum::Router::new()
.route("/", get(list_tokens))
.route("/{token_name}", post(create_token))
.route("/{token_name}", get(get_token))
.route("/{token_name}", delete(remove_token))
.route("/{token_name}/rotate", post(rotate_token))
}
pub(crate) fn populate_token_status(token: &mut Token) {
token.is_expired = token_is_expired(token, Utc::now());
}
fn parse_token_create_request(body: Bytes) -> Result<TokenCreateRequest, HttpError> {
serde_json::from_slice::<CompatTokenCreateRequest>(&body)
.map(Into::into)
.map_err(|_| HttpError::new(ErrorCode::UnprocessableEntity, "Invalid body"))
}
#[cfg_attr(not(test), allow(dead_code))]
fn parse_token_create_request_v2(body: Bytes) -> Result<TokenCreateRequest, HttpError> {
serde_json::from_slice::<V2TokenCreateRequestBody>(&body)
.map(Into::into)
.map_err(|_| HttpError::new(ErrorCode::UnprocessableEntity, "Invalid body"))
}
#[derive(Deserialize)]
#[serde(untagged)]
enum CompatTokenCreateRequest {
V2(V2TokenCreateRequestBody),
V1(V1PermissionsRequest),
}
impl From<CompatTokenCreateRequest> for TokenCreateRequest {
fn from(value: CompatTokenCreateRequest) -> Self {
match value {
CompatTokenCreateRequest::V2(request) => request.into(),
CompatTokenCreateRequest::V1(request) => TokenCreateRequest {
permissions: request.permissions,
expires_at: None,
ttl: None,
ip_allowlist: request.ip_allowlist,
},
}
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct V2TokenCreateRequestBody {
permissions: Permissions,
#[serde(default)]
expires_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default)]
ttl: Option<u64>,
#[serde(default)]
ip_allowlist: Vec<String>,
}
impl From<V2TokenCreateRequestBody> for TokenCreateRequest {
fn from(value: V2TokenCreateRequestBody) -> Self {
TokenCreateRequest {
permissions: value.permissions,
expires_at: value.expires_at,
ttl: value.ttl,
ip_allowlist: value.ip_allowlist,
}
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct V1PermissionsRequest {
#[serde(flatten)]
permissions: Permissions,
#[serde(default)]
ip_allowlist: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_token_create_request_from_request() {
let req = Request::builder()
.method("POST")
.uri("/tokens/new")
.body(Body::from(
r#"{"permissions":{"full_access":true,"read":["bucket-1"],"write":[]},"expires_at":"2026-03-16T10:00:00Z"}"#,
))
.unwrap();
let parsed = TokenCreateRequestAxum::from_request(req, &())
.await
.unwrap();
assert_eq!(parsed.0.permissions.full_access, true);
assert_eq!(parsed.0.permissions.read, vec!["bucket-1"]);
assert_eq!(
parsed.0.expires_at,
Some("2026-03-16T10:00:00Z".parse().unwrap())
);
}
#[tokio::test]
async fn test_token_create_request_from_request_invalid() {
let req = Request::builder()
.method("POST")
.uri("/tokens/new")
.body(Body::from(r#"{"permissions":"invalid"}"#))
.unwrap();
let err = TokenCreateRequestAxum::from_request(req, &())
.await
.err()
.unwrap();
assert_eq!(err.status(), ErrorCode::UnprocessableEntity);
}
#[tokio::test]
async fn test_token_create_request_from_request_missing_permissions() {
let req = Request::builder()
.method("POST")
.uri("/tokens/new")
.body(Body::from(r#"{"expires_at":"2026-03-16T10:00:00Z"}"#))
.unwrap();
let err = TokenCreateRequestAxum::from_request(req, &())
.await
.err()
.unwrap();
assert_eq!(err.status(), ErrorCode::UnprocessableEntity);
}
#[tokio::test]
async fn test_token_create_request_from_request_v1_permissions_only() {
let req = Request::builder()
.method("POST")
.uri("/tokens/new")
.body(Body::from(
r#"{"full_access":true,"read":["bucket-1"],"write":[]}"#,
))
.unwrap();
let parsed = TokenCreateRequestAxum::from_request(req, &())
.await
.unwrap();
assert_eq!(parsed.0.permissions.full_access, true);
assert_eq!(parsed.0.permissions.read, vec!["bucket-1"]);
assert_eq!(parsed.0.permissions.write.len(), 0);
assert_eq!(parsed.0.expires_at, None);
}
#[test]
fn test_parse_token_create_request_v2_strict() {
let parsed = parse_token_create_request_v2(Bytes::from(
r#"{"permissions":{"full_access":true,"read":["bucket-1"],"write":[]},"expires_at":"2026-03-16T10:00:00Z","ttl":3600}"#,
))
.unwrap();
assert!(parsed.permissions.full_access);
assert_eq!(parsed.permissions.read, vec!["bucket-1"]);
assert_eq!(
parsed.expires_at,
Some("2026-03-16T10:00:00Z".parse().unwrap())
);
assert_eq!(parsed.ttl, Some(3600));
}
#[test]
fn test_parse_token_create_request_v2_rejects_v1_shape() {
let err = parse_token_create_request_v2(Bytes::from(
r#"{"full_access":true,"read":["bucket-1"],"write":[]}"#,
))
.unwrap_err();
assert_eq!(err.status(), ErrorCode::UnprocessableEntity);
}
#[test]
fn test_parse_token_create_request_v2_rejects_expires_in() {
let err = parse_token_create_request_v2(Bytes::from(
r#"{"permissions":{"full_access":true,"read":["bucket-1"],"write":[]},"expires_in":"5d"}"#,
))
.unwrap_err();
assert_eq!(err.status(), ErrorCode::UnprocessableEntity);
}
#[test]
fn test_parse_token_create_request_v1_uses_permissions_structure() {
let parsed = parse_token_create_request(Bytes::from(
r#"{"full_access":true,"read":["bucket-1"],"write":[]}"#,
))
.unwrap();
assert!(parsed.permissions.full_access);
assert_eq!(parsed.permissions.read, vec!["bucket-1"]);
assert_eq!(parsed.expires_at, None);
}
}