use std::str::FromStr;
use std::sync::Arc;
use axum::extract::rejection::FormRejection;
use axum::extract::{Form, State};
use axum::http::{StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::post;
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use cheers_core::{Actor, PrincipalId, PrincipalStatus, Scope, TokenVerifier};
use cheers_server::{
BundleStore, CampPrincipalStore, EdgeVerifier, GrantStore, McpAuthority, McpMintError,
MintedMcpToken, OwnershipStore, RevocationReader,
};
use crate::discovery::TOKEN_ENDPOINT_PATH;
pub const TOKEN_EXCHANGE_GRANT: &str = "urn:ietf:params:oauth:grant-type:token-exchange";
pub const CLIENT_CREDENTIALS_GRANT: &str = "client_credentials";
pub const GRANT_TYPES_SUPPORTED: &[&str] = &[TOKEN_EXCHANGE_GRANT, CLIENT_CREDENTIALS_GRANT];
pub const PASETO_ASSERTION_TYPE: &str = "urn:cheers:client-assertion-type:paseto-v4-public";
pub const TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED: &[&str] = &[PASETO_ASSERTION_TYPE];
pub const CAMP_BOOTSTRAP_TOKEN_TYPE: &str = "urn:cheers:token-type:camp-bootstrap";
pub const ACCESS_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:access_token";
pub struct TokenEndpointState<B, G, O, V, Rd> {
pub mcp: Arc<McpAuthority<B, G, O>>,
pub edge: Arc<EdgeVerifier<V, Rd>>,
pub camps: Arc<dyn CampPrincipalStore>,
}
impl<B, G, O, V, Rd> std::fmt::Debug for TokenEndpointState<B, G, O, V, Rd> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TokenEndpointState").finish_non_exhaustive()
}
}
pub fn router<B, G, O, V, Rd>(state: Arc<TokenEndpointState<B, G, O, V, Rd>>) -> Router
where
B: BundleStore + Send + Sync + 'static,
G: GrantStore + Send + Sync + 'static,
O: OwnershipStore + Send + Sync + 'static,
V: TokenVerifier + Send + Sync + 'static,
Rd: RevocationReader + Send + Sync + 'static,
{
Router::new()
.route(TOKEN_ENDPOINT_PATH, post(token::<B, G, O, V, Rd>))
.with_state(state)
}
#[derive(Debug, Default, Deserialize)]
pub struct TokenRequest {
pub grant_type: Option<String>,
pub audience: Option<String>,
pub scope: Option<String>,
pub subject_token: Option<String>,
pub subject_token_type: Option<String>,
pub actor_token: Option<String>,
pub actor_token_type: Option<String>,
pub client_assertion_type: Option<String>,
pub client_assertion: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TokenResponse {
pub access_token: String,
pub token_type: String,
pub expires_in: i64,
pub scope: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub issued_token_type: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OAuthErrorCode {
InvalidRequest,
InvalidClient,
InvalidGrant,
InvalidScope,
UnsupportedGrantType,
ServerError,
TemporarilyUnavailable,
}
impl OAuthErrorCode {
pub fn as_str(self) -> &'static str {
match self {
Self::InvalidRequest => "invalid_request",
Self::InvalidClient => "invalid_client",
Self::InvalidGrant => "invalid_grant",
Self::InvalidScope => "invalid_scope",
Self::UnsupportedGrantType => "unsupported_grant_type",
Self::ServerError => "server_error",
Self::TemporarilyUnavailable => "temporarily_unavailable",
}
}
fn status(self) -> StatusCode {
match self {
Self::InvalidClient => StatusCode::UNAUTHORIZED,
Self::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
Self::TemporarilyUnavailable => StatusCode::SERVICE_UNAVAILABLE,
_ => StatusCode::BAD_REQUEST,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OAuthError {
pub code: OAuthErrorCode,
pub description: String,
}
impl OAuthError {
fn new(code: OAuthErrorCode, description: impl Into<String>) -> Self {
Self { code, description: description.into() }
}
}
#[derive(Serialize)]
struct OAuthErrorBody<'a> {
error: &'a str,
error_description: &'a str,
}
impl IntoResponse for OAuthError {
fn into_response(self) -> Response {
let body = OAuthErrorBody {
error: self.code.as_str(),
error_description: &self.description,
};
(self.code.status(), no_store(), Json(body)).into_response()
}
}
fn no_store() -> [(header::HeaderName, &'static str); 2] {
[(header::CACHE_CONTROL, "no-store"), (header::PRAGMA, "no-cache")]
}
pub fn map_mint_error(e: McpMintError) -> OAuthError {
use OAuthErrorCode::*;
let code = match &e {
McpMintError::AssertionMalformed(_)
| McpMintError::AssertionUnknownKid(_)
| McpMintError::AssertionRetiredKey(_)
| McpMintError::AssertionPrincipalMismatch { .. }
| McpMintError::AssertionBadAudience { .. }
| McpMintError::AssertionExpired { .. }
| McpMintError::AssertionLifetimeTooLong { .. } => InvalidClient,
McpMintError::AssertionReplayed(_) => InvalidGrant,
McpMintError::AudNotEntitled { .. }
| McpMintError::InvalidScope { .. }
| McpMintError::UnentitledScopes { .. }
| McpMintError::NoGrantedScopes { .. } => InvalidScope,
McpMintError::TtlOutOfRange { .. } => InvalidRequest,
McpMintError::Store(_) | McpMintError::JtiStore(_) => TemporarilyUnavailable,
_ => ServerError,
};
let description = match code {
ServerError | TemporarilyUnavailable => {
tracing::error!(error = %e, "POST /token mint failure");
"token mint failed".to_owned()
}
_ => e.to_string(),
};
OAuthError::new(code, description)
}
pub async fn token<B, G, O, V, Rd>(
State(state): State<Arc<TokenEndpointState<B, G, O, V, Rd>>>,
form: Result<Form<TokenRequest>, FormRejection>,
) -> Result<Response, OAuthError>
where
B: BundleStore,
G: GrantStore,
O: OwnershipStore,
V: TokenVerifier + Send + Sync,
Rd: RevocationReader,
{
let Form(req) = form.map_err(|e| {
OAuthError::new(OAuthErrorCode::InvalidRequest, format!("malformed form body: {e}"))
})?;
let now = now_unix();
let grant_type = required(&req.grant_type, "grant_type")?;
match grant_type {
TOKEN_EXCHANGE_GRANT => exchange(&state, &req, now).await,
CLIENT_CREDENTIALS_GRANT => client_credentials(&state, &req, now).await,
other => Err(OAuthError::new(
OAuthErrorCode::UnsupportedGrantType,
format!("grant_type '{other}' is not supported"),
)),
}
}
async fn exchange<B, G, O, V, Rd>(
state: &TokenEndpointState<B, G, O, V, Rd>,
req: &TokenRequest,
now: i64,
) -> Result<Response, OAuthError>
where
B: BundleStore,
G: GrantStore,
O: OwnershipStore,
V: TokenVerifier + Send + Sync,
Rd: RevocationReader,
{
use OAuthErrorCode::*;
let subject_token = required(&req.subject_token, "subject_token")?;
let subject_type = required(&req.subject_token_type, "subject_token_type")?;
if subject_type != ACCESS_TOKEN_TYPE {
return Err(OAuthError::new(
InvalidRequest,
format!("subject_token_type must be '{ACCESS_TOKEN_TYPE}'"),
));
}
let actor_token = required(&req.actor_token, "actor_token")?;
let actor_type = required(&req.actor_token_type, "actor_token_type")?;
if actor_type != CAMP_BOOTSTRAP_TOKEN_TYPE {
return Err(OAuthError::new(
InvalidRequest,
format!("actor_token_type must be '{CAMP_BOOTSTRAP_TOKEN_TYPE}'"),
));
}
let audience = required(&req.audience, "audience")?;
let scopes = parse_scopes(state.mcp.scopes(), req.scope.as_deref())?;
if scopes.is_empty() {
return Err(OAuthError::new(InvalidRequest, "scope is required for token exchange"));
}
let camp = resolve_camp(state.camps.as_ref(), actor_token, now).await?;
let session = state
.edge
.verify_at(subject_token, now)
.await
.map_err(|_| OAuthError::new(InvalidGrant, "subject_token is invalid, expired or revoked"))?;
let user = PrincipalId::user(session.sub.as_str());
let minted = state
.mcp
.mint_token_exchange(user, camp.clone(), Some(Actor::new(camp)), audience, scopes, now)
.await
.map_err(map_mint_error)?;
Ok(success(minted, now, Some(ACCESS_TOKEN_TYPE)))
}
async fn client_credentials<B, G, O, V, Rd>(
state: &TokenEndpointState<B, G, O, V, Rd>,
req: &TokenRequest,
now: i64,
) -> Result<Response, OAuthError>
where
B: BundleStore,
G: GrantStore,
O: OwnershipStore,
{
let assertion_type = required(&req.client_assertion_type, "client_assertion_type")?;
if assertion_type != PASETO_ASSERTION_TYPE {
return Err(OAuthError::new(
OAuthErrorCode::InvalidClient,
format!("client_assertion_type must be '{PASETO_ASSERTION_TYPE}'"),
));
}
let assertion = required(&req.client_assertion, "client_assertion")?;
let audience = required(&req.audience, "audience")?;
let scopes = parse_scopes(state.mcp.scopes(), req.scope.as_deref())?;
let minted = state
.mcp
.mint_service(assertion, audience, &scopes, now)
.await
.map_err(map_mint_error)?;
Ok(success(minted, now, None))
}
async fn resolve_camp(
camps: &dyn CampPrincipalStore,
token: &str,
now: i64,
) -> Result<PrincipalId, OAuthError> {
let invalid = || OAuthError::new(OAuthErrorCode::InvalidGrant, "actor_token is invalid, expired or revoked");
let unavailable = |e: cheers_core::StoreError| {
tracing::error!(error = %e, "POST /token camp credential lookup failed");
OAuthError::new(OAuthErrorCode::TemporarilyUnavailable, "credential store unavailable")
};
let cred = camps.get_credential(token).await.map_err(unavailable)?.ok_or_else(invalid)?;
if cred.revoked || cred.expires_at <= now {
return Err(invalid());
}
match camps.get_principal(&cred.camp_id).await.map_err(unavailable)? {
Some(p) if p.status == PrincipalStatus::Active => Ok(cred.camp_id),
_ => Err(invalid()),
}
}
fn success(minted: MintedMcpToken, now: i64, issued_token_type: Option<&str>) -> Response {
let body = TokenResponse {
access_token: minted.token,
token_type: "Bearer".to_owned(),
expires_in: (minted.claims.exp - now).max(0),
scope: minted.claims.scope.iter().map(|s| s.as_wire()).collect::<Vec<_>>().join(" "),
issued_token_type: issued_token_type.map(str::to_owned),
};
(StatusCode::OK, no_store(), Json(body)).into_response()
}
fn required<'a>(field: &'a Option<String>, name: &str) -> Result<&'a str, OAuthError> {
match field.as_deref() {
Some(v) if !v.is_empty() => Ok(v),
_ => Err(OAuthError::new(OAuthErrorCode::InvalidRequest, format!("missing '{name}'"))),
}
}
fn parse_scopes(
registry: &cheers_core::ScopeRegistry,
raw: Option<&str>,
) -> Result<Vec<Scope>, OAuthError> {
raw.unwrap_or_default()
.split_ascii_whitespace()
.map(|s| {
let scope = Scope::from_str(s)
.map_err(|e| OAuthError::new(OAuthErrorCode::InvalidScope, e.to_string()))?;
if !registry.contains(&scope) {
return Err(OAuthError::new(
OAuthErrorCode::InvalidScope,
format!("unknown scope '{scope}'"),
));
}
Ok(scope)
})
.collect()
}
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()
}