use axum::extract::State;
use axum::http::{Request, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use std::sync::Arc;
use sz_rust_auth_facade::refresh::{
MemoryRefreshTokenStore, MemoryTokenBlacklist, RefreshTokenStore, RefreshTokenVerifier,
SsoJwtCodec, TokenBlacklist,
};
#[derive(Debug, Clone)]
pub struct AuthenticatedUser {
pub user_id: i64,
pub username: String,
}
pub struct SsoMiddlewareConfig {
codec: SsoJwtCodec,
blacklist: Arc<dyn TokenBlacklist>,
store: Arc<dyn RefreshTokenStore>,
issuer: String,
allow_all_action: Vec<String>,
}
impl SsoMiddlewareConfig {
pub fn local(
secret: impl Into<String>,
issuer: impl Into<String>,
blacklist: Arc<dyn TokenBlacklist>,
store: Arc<dyn RefreshTokenStore>,
allow_all_action: Vec<String>,
) -> Self {
Self {
codec: SsoJwtCodec::new(secret),
blacklist,
store,
issuer: issuer.into(),
allow_all_action,
}
}
pub fn local_memory(
secret: impl Into<String>,
issuer: impl Into<String>,
allow_all_action: Vec<String>,
) -> Self {
Self::local(
secret,
issuer,
Arc::new(MemoryTokenBlacklist::new()),
Arc::new(MemoryRefreshTokenStore::new()),
allow_all_action,
)
}
fn is_allowed(&self, path: &str) -> bool {
for pattern in &self.allow_all_action {
if pattern == "*" || pattern == path {
return true;
}
if pattern.ends_with('*') && path.starts_with(&pattern[..pattern.len() - 1]) {
return true;
}
}
false
}
}
impl std::fmt::Debug for SsoMiddlewareConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SsoMiddlewareConfig")
.field("codec", &self.codec)
.field("issuer", &self.issuer)
.field("allow_all_action", &self.allow_all_action)
.finish_non_exhaustive()
}
}
pub async fn sso_middleware(
State(config): State<Arc<SsoMiddlewareConfig>>,
req: Request<axum::body::Body>,
next: Next,
) -> Response {
let path = req.uri().path().to_string();
if config.is_allowed(&path) {
return next.run(req).await;
}
let auth_header = match req.headers().get("authorization") {
Some(v) => v.to_str().unwrap_or(""),
None => return unauthorized("missing authorization header"),
};
let token = match auth_header.strip_prefix("Bearer ") {
Some(t) => t,
None => return unauthorized("invalid authorization scheme"),
};
let verifier = RefreshTokenVerifier::new(
config.codec.clone(),
config.blacklist.clone(),
config.store.clone(),
config.issuer.clone(),
);
match verifier.verify_access(token).await {
Ok(claims) => {
let user_id = claims.user_id.unwrap_or(0);
let username = claims.sub.clone();
let mut req = req;
req.extensions_mut().insert(AuthenticatedUser { user_id, username });
next.run(req).await
}
Err(e) => {
tracing::warn!(error = %e, "SSO token validation failed");
unauthorized(&e.to_string())
}
}
}
fn unauthorized(msg: &str) -> Response {
(
StatusCode::UNAUTHORIZED,
[("Cache-Control", "no-store"), ("Pragma", "no-cache")],
format!("{{\"code\":-1,\"msg\":\"{msg}\"}}"),
)
.into_response()
}
#[cfg(feature = "remote-validate")]
pub struct RemoteValidateConfig {
pub endpoint: String,
pub timeout: std::time::Duration,
client: reqwest::Client,
pub allow_all_action: Vec<String>,
}
#[cfg(feature = "remote-validate")]
impl RemoteValidateConfig {
pub fn new(
endpoint: impl Into<String>,
timeout: std::time::Duration,
allow_all_action: Vec<String>,
) -> Self {
let client = reqwest::Client::builder()
.timeout(timeout)
.pool_max_idle_per_host(32)
.build()
.expect("failed to build reqwest client");
Self {
endpoint: endpoint.into(),
timeout,
client,
allow_all_action,
}
}
fn is_allowed(&self, path: &str) -> bool {
for pattern in &self.allow_all_action {
if pattern == "*" || pattern == path {
return true;
}
if pattern.ends_with('*') && path.starts_with(&pattern[..pattern.len() - 1]) {
return true;
}
}
false
}
}
#[cfg(feature = "remote-validate")]
pub async fn sso_middleware_remote(
State(config): State<Arc<RemoteValidateConfig>>,
req: Request<axum::body::Body>,
next: Next,
) -> Response {
let path = req.uri().path().to_string();
if config.is_allowed(&path) {
return next.run(req).await;
}
let auth_header = match req.headers().get("authorization") {
Some(v) => v.to_str().unwrap_or(""),
None => return unauthorized("missing authorization header"),
};
let token = match auth_header.strip_prefix("Bearer ") {
Some(t) => t,
None => return unauthorized("invalid authorization scheme"),
};
let validate_url = format!("{}?token={}", config.endpoint, token);
match config.client.get(&validate_url).send().await {
Ok(resp) if resp.status().is_success() => {
match resp.json::<serde_json::Value>().await {
Ok(json) => {
let data = json.get("data").cloned().unwrap_or_default();
let user_id = data.get("user_id").and_then(|v| v.as_i64()).unwrap_or(0);
let mut req = req;
req.extensions_mut().insert(AuthenticatedUser {
user_id,
username: String::new(),
});
next.run(req).await
}
Err(_) => service_unavailable(),
}
}
Ok(resp) if resp.status() == StatusCode::UNAUTHORIZED => {
unauthorized("token invalid or expired")
}
_ => service_unavailable(),
}
}
#[cfg(feature = "remote-validate")]
fn service_unavailable() -> Response {
(
StatusCode::SERVICE_UNAVAILABLE,
[("Cache-Control", "no-store"), ("Pragma", "no-cache")],
r#"{"code":-1,"msg":"认证服务暂时不可用"}"#,
)
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::Request;
use axum::routing::get;
use axum::Router;
use sz_rust_auth_facade::refresh::{
RefreshTokenConfig, RefreshTokenIssuer,
};
use tower::ServiceExt;
async fn handler(req: Request<Body>) -> String {
let user = req.extensions().get::<AuthenticatedUser>();
match user {
Some(u) => format!("user_id={}, username={}", u.user_id, u.username),
None => "no user".to_string(),
}
}
fn make_app() -> (Router, RefreshTokenIssuer) {
let codec = SsoJwtCodec::new("test-secret");
let blacklist: Arc<dyn TokenBlacklist> = Arc::new(MemoryTokenBlacklist::new());
let store: Arc<dyn RefreshTokenStore> = Arc::new(MemoryRefreshTokenStore::new());
let config = RefreshTokenConfig::default();
let issuer = RefreshTokenIssuer::new(codec.clone(), blacklist.clone(), store.clone(), config.clone());
let mw_config = Arc::new(SsoMiddlewareConfig::local(
"test-secret",
config.issuer.clone(),
blacklist,
store,
vec!["/public/*".to_string()],
));
let app = Router::new()
.route("/protected", get(handler))
.route("/public/health", get(handler))
.layer(axum::middleware::from_fn_with_state(mw_config, sso_middleware));
(app, issuer)
}
#[tokio::test]
async fn test_middleware_allows_whitelist() {
let (app, _) = make_app();
let resp = app
.oneshot(Request::builder().uri("/public/health").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_middleware_rejects_missing_token() {
let (app, _) = make_app();
let resp = app
.oneshot(Request::builder().uri("/protected").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_middleware_accepts_valid_token() {
let (app, issuer) = make_app();
let pair = issuer.issue(42, "alice").await.unwrap();
let resp = app
.oneshot(
Request::builder()
.uri("/protected")
.header("authorization", format!("Bearer {}", pair.access_token))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let text = String::from_utf8(body.to_vec()).unwrap();
assert!(text.contains("user_id=42"));
assert!(text.contains("username=alice"));
}
#[tokio::test]
async fn test_middleware_rejects_refresh_token_as_access() {
let (app, issuer) = make_app();
let pair = issuer.issue(42, "alice").await.unwrap();
let resp = app
.oneshot(
Request::builder()
.uri("/protected")
.header("authorization", format!("Bearer {}", pair.refresh_token))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_middleware_rejects_invalid_token() {
let (app, _) = make_app();
let resp = app
.oneshot(
Request::builder()
.uri("/protected")
.header("authorization", "Bearer invalid.token.here")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
}