use std::sync::Arc;
use axum::Json;
use axum::Router;
use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode};
use axum::routing::post;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use serde::{Deserialize, Serialize};
use cheers_core::{Principal, PrincipalId, TokenVerifier, UserId};
use cheers_server::{
EdgeVerifier, NewServicePrincipal, ProvisionedKey, RevocationReader,
ServicePrincipalAuthority, ServicePrincipalStore, SigningKey,
};
use crate::error::RouteError;
use crate::me::authenticate;
pub trait OperatorPolicy: Send + Sync {
fn is_operator(&self, user: &UserId) -> bool;
}
pub struct AdminAuthState<V, Rd, S> {
pub edge: Arc<EdgeVerifier<V, Rd>>,
pub authority: Arc<ServicePrincipalAuthority<S>>,
pub operators: Arc<dyn OperatorPolicy>,
}
impl<V, Rd, S> std::fmt::Debug for AdminAuthState<V, Rd, S> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AdminAuthState").finish_non_exhaustive()
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct CreateServicePrincipalBody {
pub desired_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProvisionResponse {
pub principal: Principal,
pub signing_key: SigningKey,
pub secret_key_b64: String,
}
impl ProvisionResponse {
fn from_provisioned(p: ProvisionedKey) -> Self {
Self {
principal: p.principal,
signing_key: p.signing_key,
secret_key_b64: URL_SAFE_NO_PAD.encode(p.secret_key),
}
}
}
pub fn router<V, Rd, S>(state: Arc<AdminAuthState<V, Rd, S>>) -> Router
where
V: TokenVerifier + Send + Sync + 'static,
Rd: RevocationReader + Send + Sync + 'static,
S: ServicePrincipalStore + 'static,
{
Router::new()
.route(
"/admin/service-principals",
post(create_service_principal::<V, Rd, S>),
)
.route(
"/admin/service-principals/{id}/rotate",
post(rotate_service_principal::<V, Rd, S>),
)
.with_state(state)
}
pub async fn create_service_principal<V, Rd, S>(
State(state): State<Arc<AdminAuthState<V, Rd, S>>>,
headers: HeaderMap,
Json(body): Json<CreateServicePrincipalBody>,
) -> Result<(StatusCode, Json<ProvisionResponse>), RouteError>
where
V: TokenVerifier + Send + Sync,
Rd: RevocationReader,
S: ServicePrincipalStore,
{
let now = now_unix();
let claims = authenticate(&headers, &state.edge, now).await?;
if !state.operators.is_operator(&claims.sub) {
return Err(RouteError::NotOperator);
}
let provisioned = state
.authority
.provision(NewServicePrincipal::new(body.desired_id), now)
.await?;
Ok((
StatusCode::CREATED,
Json(ProvisionResponse::from_provisioned(provisioned)),
))
}
pub async fn rotate_service_principal<V, Rd, S>(
State(state): State<Arc<AdminAuthState<V, Rd, S>>>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<Json<ProvisionResponse>, RouteError>
where
V: TokenVerifier + Send + Sync,
Rd: RevocationReader,
S: ServicePrincipalStore,
{
let now = now_unix();
let claims = authenticate(&headers, &state.edge, now).await?;
if !state.operators.is_operator(&claims.sub) {
return Err(RouteError::NotOperator);
}
let target = PrincipalId::service(id);
let provisioned = state.authority.rotate(&target, now).await?;
Ok(Json(ProvisionResponse::from_provisioned(provisioned)))
}
fn now_unix() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode, header};
use cheers_core::{Claims, DeviceBinding, DeviceId, PrincipalId, TokenMinter, UserId};
use cheers_server::{
EdgeVerifier, MemoryServicePrincipalStore, PasetoV4SecretMinter, RevocationReader,
ServicePrincipalAuthority, SigningKeyStatus,
};
use tower::ServiceExt;
#[derive(Default, Clone, Debug)]
struct NoopRevoked;
#[async_trait::async_trait]
impl RevocationReader for NoopRevoked {
async fn is_revoked(&self, _jti: &str) -> Result<bool, cheers_core::StoreError> {
Ok(false)
}
async fn is_device_revoked(&self, _device: &DeviceId, _seq: u64) -> Result<bool, cheers_core::StoreError> {
Ok(false)
}
async fn is_membership_revoked(
&self,
_key: &cheers_core::RevocationKey,
_kind: &str,
_id: &str,
_principal: &cheers_core::PrincipalId,
_snapshot_epoch: u64,
) -> Result<bool, cheers_core::StoreError> {
Ok(false)
}
}
#[derive(Default, Clone, Debug)]
struct AllowList(HashSet<UserId>);
impl AllowList {
fn with(ids: &[&str]) -> Self {
Self(ids.iter().map(|s| UserId::new(*s)).collect())
}
}
impl OperatorPolicy for AllowList {
fn is_operator(&self, user: &UserId) -> bool {
self.0.contains(user)
}
}
fn now() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.expect("clock past epoch")
}
fn rig(
operators: &[&str],
) -> (
Router,
PasetoV4SecretMinter,
Arc<ServicePrincipalAuthority<MemoryServicePrincipalStore>>,
) {
let (minter, verifier) = PasetoV4SecretMinter::generate().expect("paseto v4 keypair");
let edge = Arc::new(EdgeVerifier::new(verifier, NoopRevoked));
let authority = Arc::new(ServicePrincipalAuthority::new(
MemoryServicePrincipalStore::new(),
));
let state = Arc::new(AdminAuthState {
edge,
authority: authority.clone(),
operators: Arc::new(AllowList::with(operators)),
});
let app = Router::new().nest("/api", router(state));
(app, minter, authority)
}
fn mint_session_for(minter: &PasetoV4SecretMinter, user: &str, now: i64) -> String {
let claims = Claims::new(
UserId::new(user),
DeviceId::new("admin-laptop"),
DeviceBinding::Passkey,
now,
now + 600,
);
minter.mint(&claims).expect("session mint")
}
fn bearer(token: &str) -> (header::HeaderName, String) {
(header::AUTHORIZATION, format!("Bearer {token}"))
}
async fn body_json<T: for<'de> serde::Deserialize<'de>>(body: Body) -> T {
let bytes = to_bytes(body, 16 * 1024).await.expect("body bytes");
serde_json::from_slice(&bytes).expect("json decode")
}
#[tokio::test]
async fn create_service_principal_returns_secret_exactly_once() {
let (app, minter, authority) = rig(&["alice"]);
let token = mint_session_for(&minter, "alice", now());
let (k, v) = bearer(&token);
let req = Request::builder()
.method("POST")
.uri("/api/admin/service-principals")
.header(k, v)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"desired_id":"yubaba-1"}"#))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let body: ProvisionResponse = body_json(resp.into_body()).await;
assert_eq!(body.principal.id, PrincipalId::service("yubaba-1"));
assert_eq!(body.signing_key.status, SigningKeyStatus::Active);
let raw = URL_SAFE_NO_PAD
.decode(body.secret_key_b64.as_bytes())
.expect("secret decodes");
assert_eq!(raw.len(), 64);
let live = authority.published_signing_keys(now()).await.unwrap();
assert_eq!(live.len(), 1);
assert_eq!(live[0].kid, body.signing_key.kid);
}
#[tokio::test]
async fn create_service_principal_rejects_non_operator_with_403() {
let (app, minter, authority) = rig(&["alice"]); let token = mint_session_for(&minter, "bob", now());
let (k, v) = bearer(&token);
let req = Request::builder()
.method("POST")
.uri("/api/admin/service-principals")
.header(k, v)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"desired_id":"yubaba-2"}"#))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let live = authority.published_signing_keys(now()).await.unwrap();
assert!(live.is_empty(), "non-operator must not provision: {live:?}");
}
#[tokio::test]
async fn create_service_principal_rejects_missing_bearer_with_401() {
let (app, _minter, _) = rig(&["alice"]);
let req = Request::builder()
.method("POST")
.uri("/api/admin/service-principals")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"desired_id":"yubaba-3"}"#))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn create_service_principal_rejects_duplicate_id_with_409() {
let (app, minter, _) = rig(&["alice"]);
let token = mint_session_for(&minter, "alice", now());
let make_req = || {
let (k, v) = bearer(&token);
Request::builder()
.method("POST")
.uri("/api/admin/service-principals")
.header(k, v)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"desired_id":"yubaba-dup"}"#))
.unwrap()
};
let resp = app.clone().oneshot(make_req()).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let resp = app.oneshot(make_req()).await.unwrap();
assert_eq!(resp.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn rotate_service_principal_retires_old_and_issues_fresh() {
let (app, minter, authority) = rig(&["alice"]);
let token = mint_session_for(&minter, "alice", now());
let (k, v) = bearer(&token);
let req = Request::builder()
.method("POST")
.uri("/api/admin/service-principals")
.header(k, v)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"desired_id":"yubaba-r"}"#))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let first: ProvisionResponse = body_json(resp.into_body()).await;
let (k, v) = bearer(&token);
let req = Request::builder()
.method("POST")
.uri("/api/admin/service-principals/yubaba-r/rotate")
.header(k, v)
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let second: ProvisionResponse = body_json(resp.into_body()).await;
assert_ne!(second.signing_key.kid, first.signing_key.kid);
assert_ne!(
second.signing_key.public_key,
first.signing_key.public_key
);
assert_ne!(second.secret_key_b64, first.secret_key_b64);
let live = authority.published_signing_keys(now()).await.unwrap();
let mut kids: Vec<_> = live.iter().map(|k| k.kid.clone()).collect();
kids.sort();
let mut expected = vec![first.signing_key.kid, second.signing_key.kid];
expected.sort();
assert_eq!(kids, expected);
}
#[tokio::test]
async fn rotate_service_principal_unknown_id_returns_404() {
let (app, minter, _) = rig(&["alice"]);
let token = mint_session_for(&minter, "alice", now());
let (k, v) = bearer(&token);
let req = Request::builder()
.method("POST")
.uri("/api/admin/service-principals/ghost/rotate")
.header(k, v)
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn rotate_service_principal_rejects_non_operator_with_403() {
let (app, minter, _) = rig(&["alice"]);
let token = mint_session_for(&minter, "bob", now());
let (k, v) = bearer(&token);
let req = Request::builder()
.method("POST")
.uri("/api/admin/service-principals/yubaba-x/rotate")
.header(k, v)
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn provision_response_serializes_secret_as_base64url_no_pad() {
let authority = ServicePrincipalAuthority::new(MemoryServicePrincipalStore::new());
let provisioned = authority
.provision(NewServicePrincipal::new("yubaba-enc"), 1_000)
.await
.unwrap();
let response = ProvisionResponse::from_provisioned(provisioned);
let json = serde_json::to_string(&response).unwrap();
assert!(json.contains("\"secret_key_b64\""));
assert_eq!(response.secret_key_b64.len(), 86);
assert!(!response.secret_key_b64.contains('='), "{response:?}");
let raw = URL_SAFE_NO_PAD
.decode(response.secret_key_b64.as_bytes())
.unwrap();
assert_eq!(raw.len(), 64);
}
#[test]
fn operator_policy_is_dyn_compatible() {
fn _f(_: &dyn OperatorPolicy) {}
}
}