自己手写 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 条生产避坑清单

  1. alg 白名单(最重要):只用 RS256/ES256,拒绝 none 和意外算法。
  2. 校验 iss + aud:否则跨应用的 token 能互相打通。
  3. JWKS 必须缓存 + 后台刷新:否则每次请求打 IdP,密钥轮转会集体断服(惊群效应)。
  4. 授权码流校验 state 参数:防 CSRF,不能只校验 code
  5. access token 别塞前端可改的 cookie:用不透明 session id + 服务端存储 claims。
  6. 时钟漂移 leeway 别太大:30 秒足够,太大等于放宽过期校验。

把认证交给标准化的 OIDC + JWKS,你只需要守住「白名单算法 + 校验签发者/受众」这两条铁律,剩下的密钥轮转、吊销都交给 IdP——这才是 Rust 后端该有的安心。