use std::time::{SystemTime, UNIX_EPOCH}; use base64::Engine; use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; use ed25519_dalek::{Signature, Signer, SigningKey}; use serde::Serialize; use serde::de::DeserializeOwned; use crate::claims::AppClaims; use crate::error::JwtError; use crate::jwks::Jwks; pub const ALG: &str = "EdDSA"; pub const TYP: &str = "at+jwt"; pub const ISSUER: &str = "auth"; pub const PRIVATE_KEY_BYTES: usize = 32; #[derive(Serialize)] struct EncodedHeader<'a> { alg: &'a str, kid: &'a str, typ: &'a str, } #[derive(serde::Deserialize)] struct Header { alg: String, #[serde(default)] kid: String, #[serde(default)] typ: String, } pub fn now_secs() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|elapsed| elapsed.as_secs() as i64) .unwrap_or_default() } pub fn signing_key(material: &str) -> Result { let material = material.trim(); if material.is_empty() { return Err(JwtError::Config( "jwt.private_key must not be empty".to_owned(), )); } let decoded = STANDARD .decode(material) .or_else(|_| URL_SAFE_NO_PAD.decode(material)) .map_err(|error| { JwtError::Config(format!("jwt.private_key is not valid base64: {error}")) })?; let seed: [u8; PRIVATE_KEY_BYTES] = decoded.try_into().map_err(|_| { JwtError::Config(format!( "jwt.private_key must decode to {PRIVATE_KEY_BYTES} bytes" )) })?; Ok(SigningKey::from_bytes(&seed)) } pub fn sign(key: &SigningKey, kid: &str, claims: &T) -> Result { sign_typed(key, kid, TYP, claims) } /// 按指定 `typ` 签发;访问令牌用 [`TYP`]。 pub fn sign_typed( key: &SigningKey, kid: &str, typ: &str, claims: &T, ) -> Result { let header = EncodedHeader { alg: ALG, kid, typ }; let header = serde_json::to_vec(&header).map_err(internal)?; let payload = serde_json::to_vec(claims).map_err(internal)?; let input = format!( "{}.{}", URL_SAFE_NO_PAD.encode(header), URL_SAFE_NO_PAD.encode(payload) ); let signature = key.sign(input.as_bytes()); Ok(format!( "{input}.{}", URL_SAFE_NO_PAD.encode(signature.to_bytes()) )) } pub fn verify(jwks: &Jwks, token: &str) -> Result { verify_typed(jwks, token, TYP) } /// `typ` 必须与调用方期望的类别逐字相等。 pub fn verify_typed( jwks: &Jwks, token: &str, expected_typ: &str, ) -> Result { let token = token.trim(); let (header_b64, rest) = token.split_once('.').ok_or(JwtError::Malformed)?; let (payload_b64, signature_b64) = rest.split_once('.').ok_or(JwtError::Malformed)?; if signature_b64.contains('.') { return Err(JwtError::Malformed); } let header: Header = decode_json(header_b64)?; if header.alg != ALG { return Err(JwtError::Malformed); } if header.typ != expected_typ { return Err(JwtError::Malformed); } let key = jwks .find(&header.kid) .ok_or(JwtError::UnknownKey)? .verifying_key()?; let signature: [u8; 64] = URL_SAFE_NO_PAD .decode(signature_b64) .map_err(|_| JwtError::Malformed)? .try_into() .map_err(|_| JwtError::Malformed)?; let signature = Signature::from_bytes(&signature); let signed = &token[..token.len() - signature_b64.len() - 1]; key.verify_strict(signed.as_bytes(), &signature) .map_err(|_| JwtError::BadSignature)?; decode_json(payload_b64) } /// 只解码声明区,供调用方在验签前选择 issuer/audience/key 配置。 /// 返回值不得作为认证结论,调用方仍须执行完整的 [`verify_typed`] 校验。 pub fn claims_unverified(token: &str) -> Result { let token = token.trim(); let (_, rest) = token.split_once('.').ok_or(JwtError::Malformed)?; let (payload_b64, signature_b64) = rest.split_once('.').ok_or(JwtError::Malformed)?; if signature_b64.contains('.') { return Err(JwtError::Malformed); } decode_json(payload_b64) } pub fn verify_app( jwks: &Jwks, token: &str, issuer: &str, audience: &str, leeway_secs: i64, ) -> Result { let claims: AppClaims = verify(jwks, token)?; if claims.issuer != issuer { return Err(JwtError::BadIssuer); } if claims.audience != audience { return Err(JwtError::WrongAudience); } if claims.is_expired(now_secs(), leeway_secs) { return Err(JwtError::Expired); } if claims.issued_at > now_secs().saturating_add(leeway_secs.max(0)) || claims.expires_at <= claims.issued_at || claims.token_id.is_empty() { return Err(JwtError::Malformed); } Ok(claims) } fn decode_json(input: &str) -> Result { let bytes = URL_SAFE_NO_PAD .decode(input) .map_err(|_| JwtError::Malformed)?; serde_json::from_slice(&bytes).map_err(|_| JwtError::Malformed) } fn internal(error: serde_json::Error) -> JwtError { JwtError::Internal(error.to_string()) } #[cfg(test)] mod tests { use super::*; #[test] fn service_audiences_are_isolated_with_shared_signing_keys() { let key = SigningKey::from_bytes(&[7; 32]); let jwks = Jwks::single("fixture", &key.verifying_key()); let now = now_secs(); for (audience, other) in [("api", "git"), ("git", "api")] { let claims: AppClaims = serde_json::from_value(serde_json::json!({ "iss":"fixture", "client_id":audience, "sub":"7", "aud":audience, "jti":"fixture", "iat":now, "exp":now + 900 })) .unwrap(); let token = sign(&key, "fixture", &claims).unwrap(); assert!(verify_app(&jwks, &token, "fixture", audience, 0).is_ok()); assert!(matches!( verify_app(&jwks, &token, "fixture", other, 0), Err(JwtError::WrongAudience) )); } } #[test] fn unverified_claims_reject_malformed_segments() { let key = SigningKey::from_bytes(&[7; 32]); let claims = AppClaims { is_admin: false, issuer: "fixture".into(), app_id: "git".into(), user_id: 7, audience: "git".into(), token_id: "fixture".into(), issued_at: now_secs(), expires_at: now_secs() + 60, security_version: 1, session_id: Some("session".into()), mfa_verified: false, scope: None, username: Some("fixture".into()), token_type: "access".into(), }; let token = sign(&key, "fixture", &claims).unwrap(); let decoded: AppClaims = claims_unverified(&token).unwrap(); assert_eq!(decoded.app_id, "git"); assert!(claims_unverified::("one.two").is_err()); assert!(claims_unverified::("one.two.three.four").is_err()); } }