Skip to content
Clone
9362a10
228 lines 7.1 KB Rust
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<SigningKey, JwtError> {
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<T: Serialize>(key: &SigningKey, kid: &str, claims: &T) -> Result<String, JwtError> {
sign_typed(key, kid, TYP, claims)
}
/// 按指定 `typ` 签发;访问令牌用 [`TYP`]。
pub fn sign_typed<T: Serialize>(
key: &SigningKey,
kid: &str,
typ: &str,
claims: &T,
) -> Result<String, JwtError> {
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<T: DeserializeOwned>(jwks: &Jwks, token: &str) -> Result<T, JwtError> {
verify_typed(jwks, token, TYP)
}
/// `typ` 必须与调用方期望的类别逐字相等。
pub fn verify_typed<T: DeserializeOwned>(
jwks: &Jwks,
token: &str,
expected_typ: &str,
) -> Result<T, JwtError> {
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<T: DeserializeOwned>(token: &str) -> Result<T, JwtError> {
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<AppClaims, JwtError> {
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<T: DeserializeOwned>(input: &str) -> Result<T, JwtError> {
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::<AppClaims>("one.two").is_err());
assert!(claims_unverified::<AppClaims>("one.two.three.four").is_err());
}
}