自己手写 JWT 校验,十个人有八个会漏掉点东西:alg=none 没拦、aud/iss 没校验、密钥硬编码、时钟漂移没容忍……任何一个疏漏都能让认证形同虚设。
OIDC(OpenID Connect) 把这套校验标准化了:它约定 IdP(Keycloak / Auth0 / Okta)通过 JWKS 端点定期发布公钥,你的服务只管拿公钥验签,密钥轮转全自动。本文用 Axum 实写一个生产级的 Bearer Token 校验中间件。
依赖
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
jsonwebtoken = "9"
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "1"
第一步:定义 Claims 和配置
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct Claims {
pub sub: String, // 用户唯一 ID
pub exp: usize, // 过期时间
pub iss: String, // 签发者
pub aud: String, // 受众(你的 client_id)
}
pub struct AuthConfig {
pub issuer: String, // 例如 https://your.keycloak.com/realms/demo
pub audience: String, // 你的 client_id
pub jwks_url: String, // 例如上面 issuer + /.well-known/jwks.json
}
第二步:从 JWKS 拉公钥
JWKS 是一组 RSA 公钥,每条带一个 kid(key id)。验签时按 token 头里的 kid 挑对应的公钥:
use jsonwebtoken::DecodingKey;
#[derive(Deserialize)]
struct Jwks {
keys: Vec<Jwk>,
}
#[derive(Deserialize)]
struct Jwk {
kid: String,
n: String, // RSA 模数(Base64URL)
e: String, // RSA 指数
}
// 简化版:每次重新拉取。生产务必加缓存 + 后台刷新!
async fn fetch_jwk(config: &AuthConfig, kid: &str) -> Result<DecodingKey, AuthError> {
let jwks: Jwks = reqwest::get(&config.jwks_url).await?.json().await?;
let key = jwks
.keys
.into_iter()
.find(|k| k.kid == kid)
.ok_or(AuthError::KeyNotFound)?;
// 用 RSA 组件构造验签密钥,绝不在代码里硬编码密钥
Ok(DecodingKey::from_rsa_components(&key.n, &key.e)?)
}
第三步:校验令牌(含全部安全项)
use jsonwebtoken::{decode, decode_header, Algorithm, Validation};
pub async fn validate_token(token: &str, config: &AuthConfig) -> Result<Claims, AuthError> {
let header = decode_header(token)?;
// ① 最关键:alg 必须白名单,绝不信任 token 自带的 alg(防 alg=none)
if header.alg != Algorithm::RS256 {
return Err(AuthError::UnsupportedAlgorithm);
}
let kid = header.kid.ok_or(AuthError::MissingKid)?;
let key = fetch_jwk(config, &kid).await?;
let mut validation = Validation::new(Algorithm::RS256);
validation.set_issuer(&[&config.issuer]); // ② 校验签发者
validation.set_audience(&[&config.audience]); // ③ 校验受众
validation.validate_exp = true;
validation.leeway = 30; // ④ 时钟漂移容忍 30 秒
let data = decode::<Claims>(token, &key, &validation)?;
Ok(data.claims)
}
①②③④ 这四条缺一不可——只验签名不验 iss/aud,别人拿别的应用签的 token 也能进你的服务。
第四步:Axum 中间件保护路由
use axum::{
extract::{Request, Extension},
middleware::{self, Next},
response::Response,
routing::get,
Router,
};
use http::StatusCode;
pub async fn require_auth(mut req: Request, next: Next) -> Result<Response, StatusCode> {
// 取 Bearer token
let token = req
.headers()
.get(http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or(StatusCode::UNAUTHORIZED)?;
// 实际项目里从 app state 取 AuthConfig,并复用缓存的 JWKS 客户端
let config = get_config(&req);
let claims = validate_token(token, &config)
.await
.map_err(|_| StatusCode::UNAUTHORIZED)?;
// 把 claims 放进请求扩展,handler 可直接提取
req.extensions_mut().insert(claims);
Ok(next.run(req).await)
}
async fn protected(Extension(claims): Extension<Claims>) -> String {
format!("你好,用户 {}!", claims.sub)
}
// 路由:只对 /protected 套认证层
let app = Router::new()
.route("/protected", get(protected))
.route_layer(middleware::from_fn(require_auth));
想更省事?一行接入
如果你不想自己维护 JWKS 缓存和刷新,直接用社区封装好的 crate:
async-oidc-jwt-validator = "0.1"
let config = OidcConfig::new_with_discovery(
"https://your.keycloak.com/realms/demo".into(),
"your-client-id".into(),
)
.await?;
let validator = OidcValidator::new(config);
let claims = validator.validate::<Claims>(token).await?;
它会自动发现 JWKS 端点、内存缓存公钥、强制校验 issuer/audience/signature/expiration,支持 Keycloak、Auth0、Okta、Google。
6 条生产避坑清单
- alg 白名单(最重要):只用 RS256/ES256,拒绝
none和意外算法。 - 校验
iss+aud:否则跨应用的 token 能互相打通。 - JWKS 必须缓存 + 后台刷新:否则每次请求打 IdP,密钥轮转会集体断服(惊群效应)。
- 授权码流校验
state参数:防 CSRF,不能只校验code。 - access token 别塞前端可改的 cookie:用不透明 session id + 服务端存储 claims。
- 时钟漂移
leeway别太大:30 秒足够,太大等于放宽过期校验。
把认证交给标准化的 OIDC + JWKS,你只需要守住「白名单算法 + 校验签发者/受众」这两条铁律,剩下的密钥轮转、吊销都交给 IdP——这才是 Rust 后端该有的安心。