use super::{Error, get_duration_conf, get_hash_key, get_str_conf};
use arc_swap::ArcSwapOption;
use async_trait::async_trait;
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use bytes::{Bytes, BytesMut};
use http::StatusCode;
use humantime::parse_duration;
use jsonwebtoken::jwk::JwkSet;
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header};
use pingap_config::{PluginCategory, PluginConf};
use pingap_core::{
Ctx, ModifyResponseBody, Plugin, PluginStep, RequestPluginResult,
ResponseBodyPluginResult, ResponsePluginResult,
};
use pingap_core::{
HTTP_HEADER_CONTENT_JSON, HTTP_HEADER_TRANSFER_CHUNKED, HttpResponse,
};
use pingora::http::ResponseHeader;
use pingora::proxy::Session;
use serde::Deserialize;
use std::borrow::Cow;
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use tokio::time::sleep;
use tracing::debug;
use tracing::error;
const PLUGIN_ID: &str = "_jwt_";
type Result<T, E = Error> = std::result::Result<T, E>;
#[derive(Debug, Default, Deserialize)]
struct JwtHeader {
alg: String,
}
#[derive(Debug, Default, Deserialize)]
struct Claims {
exp: Option<f64>,
nbf: Option<f64>,
}
#[derive(Deserialize)]
struct NoClaims {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HmacRejection {
Format,
Signature,
Expired,
NotYetValid,
}
impl HmacRejection {
fn message(self) -> Bytes {
Bytes::from_static(match self {
Self::Format => b"Jwt authorization format is invalid",
Self::Signature => b"Jwt authorization is invalid",
Self::Expired => b"Jwt authorization is expired",
Self::NotYetValid => b"Jwt authorization is not yet valid",
})
}
}
fn strip_bearer(value: &str) -> &str {
match value.split_once(' ') {
Some((scheme, token)) if scheme.eq_ignore_ascii_case("bearer") => {
token.trim_start()
},
_ => value,
}
}
fn signature_matches(
hash: &[u8],
signature: &str,
encoded: &mut [u8; 86],
) -> bool {
match URL_SAFE_NO_PAD.encode_slice(hash, encoded) {
Ok(len) => {
pingap_core::constant_time_eq(&encoded[..len], signature.as_bytes())
},
Err(_) => false,
}
}
fn verify_hmac_token(
token: &str,
secret: &[u8],
pinned_alg: &str,
now: u64,
) -> std::result::Result<(), HmacRejection> {
let Some((header, rest)) = token.split_once('.') else {
return Err(HmacRejection::Format);
};
let Some((payload, signature)) = rest.split_once('.') else {
return Err(HmacRejection::Format);
};
if signature.contains('.') {
return Err(HmacRejection::Format);
}
let jwt_header: JwtHeader = URL_SAFE_NO_PAD
.decode(header)
.ok()
.and_then(|raw| serde_json::from_slice(&raw).ok())
.unwrap_or_default();
let alg = jwt_header.alg.as_str();
if !pinned_alg.is_empty() && alg != pinned_alg {
return Err(HmacRejection::Signature);
}
let content = &token.as_bytes()[..header.len() + 1 + payload.len()];
let mut encoded = [0u8; 86];
let valid = match alg {
"HS256" => {
let hash = hmac_sha256::HMAC::mac(content, secret);
signature_matches(&hash, signature, &mut encoded)
},
"HS512" => {
let hash = hmac_sha512::HMAC::mac(content, secret);
signature_matches(&hash, signature, &mut encoded)
},
_ => false,
};
if !valid {
return Err(HmacRejection::Signature);
}
let claims: Claims = URL_SAFE_NO_PAD
.decode(payload)
.ok()
.and_then(|raw| serde_json::from_slice(&raw).ok())
.ok_or(HmacRejection::Format)?;
let now = now as f64;
if claims.exp.is_some_and(|exp| exp < now) {
return Err(HmacRejection::Expired);
}
if claims.nbf.is_some_and(|nbf| nbf > now) {
return Err(HmacRejection::NotYetValid);
}
Ok(())
}
pub struct JwtAuth {
plugin_step: PluginStep,
auth_path: String,
secret: String,
header: Option<String>,
query: Option<String>,
cookie: Option<String>,
algorithm: String,
decoding_key: Option<(DecodingKey, Validation)>,
jwks: Option<Arc<JwksSource>>,
delay: Option<Duration>,
unauthorized_resp: HttpResponse,
hash_value: String,
}
fn build_asymmetric_key(
algorithm: &str,
public_key: &str,
) -> Result<Option<(DecodingKey, Validation)>> {
let Ok(alg) = Algorithm::from_str(algorithm) else {
return Ok(None);
};
let is_asymmetric = matches!(
alg,
Algorithm::RS256
| Algorithm::RS384
| Algorithm::RS512
| Algorithm::PS256
| Algorithm::PS384
| Algorithm::PS512
| Algorithm::ES256
| Algorithm::ES384
);
if !is_asymmetric {
return Ok(None);
}
if public_key.is_empty() {
return Err(Error::Invalid {
category: PluginCategory::Jwt.to_string(),
message: "public_key is required for asymmetric algorithms"
.to_string(),
});
}
let key = match alg {
Algorithm::ES256 | Algorithm::ES384 => {
DecodingKey::from_ec_pem(public_key.as_bytes())
},
_ => DecodingKey::from_rsa_pem(public_key.as_bytes()),
}
.map_err(|e| Error::Invalid {
category: PluginCategory::Jwt.to_string(),
message: format!("invalid public_key: {e}"),
})?;
Ok(Some((key, jwks_validation(alg))))
}
struct JwkEntry {
kid: Option<String>,
key: DecodingKey,
}
struct JwksCache {
keys: Vec<JwkEntry>,
fetched_at: Instant,
}
impl JwksCache {
fn candidates<'a>(
&'a self,
kid: Option<&'a str>,
) -> impl Iterator<Item = &'a DecodingKey> {
self.keys
.iter()
.filter(move |entry| {
kid.is_none_or(|kid| entry.kid.as_deref() == Some(kid))
})
.map(|entry| &entry.key)
}
fn verify(
&self,
token: &str,
kid: Option<&str>,
validation: &Validation,
) -> bool {
self.candidates(kid)
.any(|key| decode::<NoClaims>(token, key, validation).is_ok())
}
}
struct JwksSource {
url: String,
ttl: Duration,
cooldown: Duration,
client: reqwest::Client,
cache: ArcSwapOption<JwksCache>,
refresh_lock: tokio::sync::Mutex<()>,
}
impl JwksSource {
async fn fetch(&self) -> std::result::Result<JwksCache, String> {
let resp = self
.client
.get(&self.url)
.send()
.await
.map_err(|e| e.to_string())?;
let set = resp.json::<JwkSet>().await.map_err(|e| e.to_string())?;
let keys = set
.keys
.iter()
.filter_map(|jwk| {
DecodingKey::from_jwk(jwk).ok().map(|key| JwkEntry {
kid: jwk.common.key_id.clone(),
key,
})
})
.collect();
Ok(JwksCache {
keys,
fetched_at: Instant::now(),
})
}
async fn refresh(&self) {
let _guard = self.refresh_lock.lock().await;
if let Some(cache) = self.cache.load_full()
&& cache.fetched_at.elapsed() < self.cooldown
{
return;
}
match self.fetch().await {
Ok(cache) => self.cache.store(Some(Arc::new(cache))),
Err(e) => {
error!(category = "jwt", error = e, "fetch jwks failed");
},
}
}
async fn verify(&self, token: &str) -> bool {
let Ok(header) = decode_header(token) else {
return false;
};
if !is_asymmetric_alg(header.alg) {
return false;
}
let validation = jwks_validation(header.alg);
let kid = header.kid.as_deref();
if let Some(cache) = self.cache.load_full()
&& cache.fetched_at.elapsed() <= self.ttl
&& cache.verify(token, kid, &validation)
{
return true;
}
self.refresh().await;
self.cache
.load_full()
.is_some_and(|cache| cache.verify(token, kid, &validation))
}
}
fn jwks_validation(alg: Algorithm) -> Validation {
let mut validation = Validation::new(alg);
validation.validate_aud = false;
validation
}
fn is_asymmetric_alg(alg: Algorithm) -> bool {
matches!(
alg,
Algorithm::RS256
| Algorithm::RS384
| Algorithm::RS512
| Algorithm::PS256
| Algorithm::PS384
| Algorithm::PS512
| Algorithm::ES256
| Algorithm::ES384
| Algorithm::EdDSA
)
}
fn build_jwks_source(value: &PluginConf) -> Result<Option<Arc<JwksSource>>> {
let url = get_str_conf(value, "jwks_url");
if url.is_empty() {
return Ok(None);
}
reqwest::Url::parse(&url).map_err(|e| Error::Invalid {
category: PluginCategory::Jwt.to_string(),
message: format!("invalid jwks_url: {e}"),
})?;
let ttl = get_duration_conf(value, "jwks_ttl")
.unwrap_or(Duration::from_secs(3600));
let cooldown = ttl.min(Duration::from_secs(10));
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| Error::Invalid {
category: PluginCategory::Jwt.to_string(),
message: e.to_string(),
})?;
Ok(Some(Arc::new(JwksSource {
url,
ttl,
cooldown,
client,
cache: ArcSwapOption::empty(),
refresh_lock: tokio::sync::Mutex::new(()),
})))
}
impl TryFrom<&PluginConf> for JwtAuth {
type Error = Error;
fn try_from(value: &PluginConf) -> Result<Self> {
let hash_value = get_hash_key(value);
let header = get_str_conf(value, "header");
let query = get_str_conf(value, "query");
let cookie = get_str_conf(value, "cookie");
if header.is_empty() && query.is_empty() && cookie.is_empty() {
return Err(Error::Invalid {
category: PluginCategory::Jwt.to_string(),
message: "Jwt key or key type is not allowed empty".to_string(),
});
}
let header = if header.is_empty() {
None
} else {
Some(header)
};
let query = if query.is_empty() { None } else { Some(query) };
let cookie = if cookie.is_empty() {
None
} else {
Some(cookie)
};
let delay = get_str_conf(value, "delay");
let delay = if !delay.is_empty() {
let d = parse_duration(&delay).map_err(|e| Error::Invalid {
category: PluginCategory::Jwt.to_string(),
message: e.to_string(),
})?;
Some(d)
} else {
None
};
let algorithm = get_str_conf(value, "algorithm");
let decoding_key = build_asymmetric_key(
&algorithm,
&get_str_conf(value, "public_key"),
)?;
let jwks = build_jwks_source(value)?;
let params = Self {
hash_value,
plugin_step: PluginStep::Request,
secret: get_str_conf(value, "secret"),
auth_path: get_str_conf(value, "auth_path"),
algorithm,
decoding_key,
jwks,
delay,
header,
query,
cookie,
unauthorized_resp: HttpResponse {
status: StatusCode::UNAUTHORIZED,
body: Bytes::from_static(b"Invalid or expired jwt"),
..Default::default()
},
};
if params.decoding_key.is_none() && params.jwks.is_none() {
if params.secret.is_empty() {
return Err(Error::Invalid {
category: PluginCategory::Jwt.to_string(),
message: "Jwt secret is not allowed empty".to_string(),
});
}
if !matches!(params.algorithm.as_str(), "" | "HS256" | "HS512") {
return Err(Error::Invalid {
category: PluginCategory::Jwt.to_string(),
message: format!(
"Jwt algorithm({}) is not supported, expect HS256 or HS512, or set public_key/jwks_url",
params.algorithm
),
});
}
}
Ok(params)
}
}
impl JwtAuth {
pub fn new(params: &PluginConf) -> Result<Self> {
debug!(params = params.to_string(), "new jwt auth plugin");
Self::try_from(params)
}
}
#[async_trait]
impl Plugin for JwtAuth {
#[inline]
fn config_key(&self) -> Cow<'_, str> {
Cow::Borrowed(&self.hash_value)
}
#[inline]
async fn handle_request(
&self,
step: PluginStep,
session: &mut Session,
_ctx: &mut Ctx,
) -> pingora::Result<RequestPluginResult> {
if step != self.plugin_step {
return Ok(RequestPluginResult::Skipped);
}
let req_header = session.req_header();
if req_header.uri.path() == self.auth_path {
return Ok(RequestPluginResult::Skipped);
}
let value = if let Some(key) = &self.header {
strip_bearer(
pingap_core::get_req_header_value(req_header, key)
.unwrap_or_default(),
)
} else if let Some(key) = &self.cookie {
pingap_core::get_cookie_value(req_header, key).unwrap_or_default()
} else if let Some(key) = &self.query {
pingap_core::get_query_value(req_header, key).unwrap_or_default()
} else {
""
};
if value.is_empty() {
let mut resp = self.unauthorized_resp.clone();
resp.body = Bytes::from_static(b"Jwt authorization is missing");
return Ok(RequestPluginResult::Respond(resp));
}
if let Some((key, validation)) = &self.decoding_key {
if decode::<NoClaims>(value, key, validation).is_ok() {
return Ok(RequestPluginResult::Continue);
}
if let Some(d) = self.delay {
sleep(d).await;
}
let mut resp = self.unauthorized_resp.clone();
resp.body = Bytes::from_static(b"Jwt authorization is invalid");
return Ok(RequestPluginResult::Respond(resp));
}
if let Some(jwks) = &self.jwks {
if jwks.verify(value).await {
return Ok(RequestPluginResult::Continue);
}
if let Some(d) = self.delay {
sleep(d).await;
}
let mut resp = self.unauthorized_resp.clone();
resp.body = Bytes::from_static(b"Jwt authorization is invalid");
return Ok(RequestPluginResult::Respond(resp));
}
match verify_hmac_token(
value,
self.secret.as_bytes(),
&self.algorithm,
pingap_core::now_sec(),
) {
Ok(()) => Ok(RequestPluginResult::Continue),
Err(rejection) => {
if rejection == HmacRejection::Signature
&& let Some(d) = self.delay
{
sleep(d).await;
}
let mut resp = self.unauthorized_resp.clone();
resp.body = rejection.message();
Ok(RequestPluginResult::Respond(resp))
},
}
}
#[inline]
async fn handle_response(
&self,
session: &mut Session,
ctx: &mut Ctx,
upstream_response: &mut ResponseHeader,
) -> pingora::Result<ResponsePluginResult> {
if session.req_header().uri.path() != self.auth_path {
return Ok(ResponsePluginResult::Unchanged);
}
if !upstream_response.status.is_success() {
return Ok(ResponsePluginResult::Unchanged);
}
upstream_response.remove_header(&http::header::CONTENT_LENGTH);
let json = HTTP_HEADER_CONTENT_JSON.clone();
let _ = upstream_response.insert_header(json.0, json.1);
let _ = upstream_response.insert_header(
http::header::TRANSFER_ENCODING,
HTTP_HEADER_TRANSFER_CHUNKED.1.clone(),
);
ctx.add_modify_body_handler(
PLUGIN_ID,
Box::new(Sign {
algorithm: self.algorithm.clone(),
secret: self.secret.clone(),
buffer: BytesMut::new(),
}),
);
Ok(ResponsePluginResult::Modified)
}
fn handle_response_body(
&self,
session: &mut Session,
ctx: &mut Ctx,
body: &mut Option<bytes::Bytes>,
end_of_stream: bool,
) -> pingora::Result<ResponseBodyPluginResult> {
if let Some(modifier) = ctx.get_modify_body_handler(PLUGIN_ID) {
modifier.handle(session, body, end_of_stream)?;
let result = if end_of_stream {
ResponseBodyPluginResult::FullyReplaced
} else {
ResponseBodyPluginResult::PartialReplaced
};
Ok(result)
} else {
Ok(ResponseBodyPluginResult::Unchanged)
}
}
}
struct Sign {
secret: String,
algorithm: String,
buffer: BytesMut,
}
impl ModifyResponseBody for Sign {
fn handle(
&mut self,
_session: &Session,
body: &mut Option<bytes::Bytes>,
end_of_stream: bool,
) -> pingora::Result<()> {
if let Some(data) = body {
self.buffer.extend(&data[..]);
data.clear();
}
if !end_of_stream {
return Ok(());
}
let is_hs512 = self.algorithm == "HS512";
let alg = if is_hs512 { "HS512" } else { "HS256" };
let header = URL_SAFE_NO_PAD
.encode(r#"{"alg": ""#.to_owned() + alg + r#"","typ": "JWT"}"#);
let payload = URL_SAFE_NO_PAD.encode(&self.buffer);
let content = format!("{header}.{payload}");
let secret = self.secret.as_bytes();
let sign = if is_hs512 {
let hash = hmac_sha512::HMAC::mac(content.as_bytes(), secret);
URL_SAFE_NO_PAD.encode(hash)
} else {
let hash = hmac_sha256::HMAC::mac(content.as_bytes(), secret);
URL_SAFE_NO_PAD.encode(hash)
};
let token = format!("{content}.{sign}");
*body = Some(Bytes::from(r#"{"token": "{}"}"#.replace("{}", &token)));
Ok(())
}
fn name(&self) -> &str {
"jwt_sign"
}
}
register_plugin!("jwt", JwtAuth);
#[cfg(test)]
mod tests {
use super::*;
use pingap_config::PluginConf;
use pingap_core::{Ctx, PluginStep};
use pingora::proxy::Session;
use pretty_assertions::assert_eq;
use tokio_test::io::Builder;
#[test]
fn test_jwt_auth_params() {
let params = JwtAuth::try_from(
&toml::from_str::<PluginConf>(
r###"
secret = "123123"
cookie = "jwt"
"###,
)
.unwrap(),
)
.unwrap();
assert_eq!("jwt", params.cookie.unwrap_or_default());
assert_eq!("123123", params.secret);
let result = JwtAuth::try_from(
&toml::from_str::<PluginConf>(
r###"
cookie = "jwt"
"###,
)
.unwrap(),
);
assert_eq!(
"Plugin jwt invalid, message: Jwt secret is not allowed empty",
result.err().unwrap().to_string()
);
let result = JwtAuth::try_from(
&toml::from_str::<PluginConf>(
r###"
secret = "123123"
"###,
)
.unwrap(),
);
assert_eq!(
"Plugin jwt invalid, message: Jwt key or key type is not allowed empty",
result.err().unwrap().to_string()
);
}
#[test]
fn test_new_jwt() {
let auth = JwtAuth::new(
&toml::from_str::<PluginConf>(
r###"
secret = "123123"
cookie = "jwt"
"###,
)
.unwrap(),
)
.unwrap();
assert_eq!("jwt", auth.cookie.unwrap());
let auth = JwtAuth::new(
&toml::from_str::<PluginConf>(
r###"
secret = "123123"
cookie = "jwt"
auth_path = "/login"
"###,
)
.unwrap(),
)
.unwrap();
assert_eq!("jwt", auth.cookie.unwrap());
assert_eq!("/login", auth.auth_path);
}
#[tokio::test]
async fn test_jwt_asymmetric() {
use jsonwebtoken::{EncodingKey, Header, encode};
let public_key = r#"-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAECE/4ox+pGq+yiB3RqIXINmlHJp+l
6V8vXffF5UzI/h3RPK3l9MphCKS2wg50uVoWlBITXMRhh5LVB/93vQZa0Q==
-----END PUBLIC KEY-----"#;
let private_key = r#"-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg6V2VwZk30Az6VKMF
Bt6nfEa2r4hCQuuMB6azsjMB7xmhRANCAAQIT/ijH6kar7KIHdGohcg2aUcmn6Xp
Xy9d98XlTMj+HdE8reX0ymEIpLbCDnS5WhaUEhNcxGGHktUH/3e9BlrR
-----END PRIVATE KEY-----"#;
let err = JwtAuth::try_from(
&toml::from_str::<PluginConf>(
"header = \"Authorization\"\nalgorithm = \"ES256\"\n",
)
.unwrap(),
)
.err()
.unwrap();
assert_eq!(true, err.to_string().contains("public_key is required"));
let err = JwtAuth::try_from(
&toml::from_str::<PluginConf>(
"header = \"Authorization\"\nalgorithm = \"ES256\"\npublic_key = \"not a pem\"\n",
)
.unwrap(),
)
.err()
.unwrap();
assert_eq!(true, err.to_string().contains("invalid public_key"));
let cfg = format!(
"header = \"Authorization\"\nalgorithm = \"ES256\"\npublic_key = \"\"\"\n{public_key}\n\"\"\"\n"
);
let auth =
JwtAuth::new(&toml::from_str::<PluginConf>(&cfg).unwrap()).unwrap();
assert_eq!(true, auth.decoding_key.is_some());
let sign = |exp: u64| {
let claims = serde_json::json!({ "sub": "u1", "exp": exp });
encode(
&Header::new(Algorithm::ES256),
&claims,
&EncodingKey::from_ec_pem(private_key.as_bytes()).unwrap(),
)
.unwrap()
};
let run = async |token: String| {
let input = format!(
"GET / HTTP/1.1\r\nAuthorization: Bearer {token}\r\n\r\n"
);
let mock_io = Builder::new().read(input.as_bytes()).build();
let mut session = Session::new_h1(Box::new(mock_io));
session.read_request().await.unwrap();
auth.handle_request(
PluginStep::Request,
&mut session,
&mut Ctx::default(),
)
.await
.unwrap()
};
let ok = run(sign(pingap_core::now_sec() + 3600)).await;
assert_eq!(true, ok == RequestPluginResult::Continue);
let expired = run(sign(pingap_core::now_sec() - 3600)).await;
assert_eq!(true, matches!(expired, RequestPluginResult::Respond(_)));
}
#[tokio::test]
async fn test_jwt_jwks() {
use jsonwebtoken::{EncodingKey, Header, encode};
let public_key = r#"-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAECE/4ox+pGq+yiB3RqIXINmlHJp+l
6V8vXffF5UzI/h3RPK3l9MphCKS2wg50uVoWlBITXMRhh5LVB/93vQZa0Q==
-----END PUBLIC KEY-----"#;
let private_key = r#"-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg6V2VwZk30Az6VKMF
Bt6nfEa2r4hCQuuMB6azsjMB7xmhRANCAAQIT/ijH6kar7KIHdGohcg2aUcmn6Xp
Xy9d98XlTMj+HdE8reX0ymEIpLbCDnS5WhaUEhNcxGGHktUH/3e9BlrR
-----END PRIVATE KEY-----"#;
let new_source = |keys: Vec<JwkEntry>| JwksSource {
url: "http://127.0.0.1:1/jwks".to_string(),
ttl: Duration::from_secs(3600),
cooldown: Duration::from_secs(10),
client: reqwest::Client::new(),
cache: ArcSwapOption::new(Some(Arc::new(JwksCache {
keys,
fetched_at: Instant::now(),
}))),
refresh_lock: tokio::sync::Mutex::new(()),
};
let entry = |kid: Option<&str>| JwkEntry {
kid: kid.map(|k| k.to_string()),
key: DecodingKey::from_ec_pem(public_key.as_bytes()).unwrap(),
};
let source = new_source(vec![entry(Some("kid-1"))]);
let sign = |kid: Option<&str>, exp: u64| {
let mut header = Header::new(Algorithm::ES256);
header.kid = kid.map(|k| k.to_string());
let claims = serde_json::json!({ "sub": "u1", "exp": exp });
encode(
&header,
&claims,
&EncodingKey::from_ec_pem(private_key.as_bytes()).unwrap(),
)
.unwrap()
};
let token = sign(Some("kid-1"), pingap_core::now_sec() + 3600);
assert_eq!(true, source.verify(&token).await);
let token = sign(Some("kid-1"), pingap_core::now_sec() - 3600);
assert_eq!(false, source.verify(&token).await);
let token = sign(Some("kid-x"), pingap_core::now_sec() + 3600);
assert_eq!(false, source.verify(&token).await);
let token = sign(None, pingap_core::now_sec() + 3600);
assert_eq!(true, source.verify(&token).await);
let source = new_source(vec![entry(None)]);
let token = sign(None, pingap_core::now_sec() + 3600);
assert_eq!(true, source.verify(&token).await);
let token = sign(Some("kid-1"), pingap_core::now_sec() + 3600);
assert_eq!(false, source.verify(&token).await);
}
#[test]
fn test_verify_hmac_token() {
use jsonwebtoken::{EncodingKey, Header, encode};
let secret = b"123123";
let key = EncodingKey::from_secret(secret);
let now = pingap_core::now_sec();
let sign = |alg: Algorithm, claims: serde_json::Value| {
encode(&Header::new(alg), &claims, &key).unwrap()
};
assert_eq!(
Ok(()),
verify_hmac_token(
&sign(Algorithm::HS256, serde_json::json!({"exp": now + 60})),
secret,
"",
now
)
);
let no_typ = {
let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"HS256"}"#);
let payload = URL_SAFE_NO_PAD.encode(r#"{"exp":9999999999}"#);
let content = format!("{header}.{payload}");
let sig = URL_SAFE_NO_PAD
.encode(hmac_sha256::HMAC::mac(content.as_bytes(), secret));
format!("{content}.{sig}")
};
assert_eq!(Ok(()), verify_hmac_token(&no_typ, secret, "", now));
assert_eq!(
Ok(()),
verify_hmac_token(
&sign(
Algorithm::HS512,
serde_json::json!({"exp": now as f64 + 60.5})
),
secret,
"HS512",
now
)
);
assert_eq!(
Err(HmacRejection::Expired),
verify_hmac_token(
&sign(Algorithm::HS256, serde_json::json!({"exp": now - 1})),
secret,
"",
now
)
);
assert_eq!(
Err(HmacRejection::NotYetValid),
verify_hmac_token(
&sign(Algorithm::HS256, serde_json::json!({"nbf": now + 60})),
secret,
"",
now
)
);
assert_eq!(
Err(HmacRejection::Signature),
verify_hmac_token(
&sign(Algorithm::HS256, serde_json::json!({})),
b"other",
"",
now
)
);
assert_eq!(
Err(HmacRejection::Signature),
verify_hmac_token(
&sign(Algorithm::HS256, serde_json::json!({})),
secret,
"HS512",
now
)
);
let none = format!(
"{}.{}.",
URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#),
URL_SAFE_NO_PAD.encode("{}")
);
assert_eq!(
Err(HmacRejection::Signature),
verify_hmac_token(&none, secret, "", now)
);
for token in ["a.b", "a.b.c.d", ""] {
assert_eq!(
Err(HmacRejection::Format),
verify_hmac_token(token, secret, "", now),
"{token}"
);
}
}
#[test]
fn test_strip_bearer() {
assert_eq!("abc", strip_bearer("Bearer abc"));
assert_eq!("abc", strip_bearer("bearer abc"));
assert_eq!("abc", strip_bearer("abc"));
assert_eq!("Basic abc", strip_bearer("Basic abc"));
}
#[tokio::test]
async fn test_jwt_auth() {
let auth = JwtAuth::new(
&toml::from_str::<PluginConf>(
r###"
secret = "123123"
header = "Authorization"
"###,
)
.unwrap(),
)
.unwrap();
let headers = ["Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSm9obiIsImFkbWluIjp0cnVlLCJleHAiOjIzNDgwNTUyNjV9.j6sYJ2dCCSxskwPmvHM7WniGCbkT30z2BrjfsuQLFJc"].join("\r\n");
let input_header = format!("GET / HTTP/1.1\r\n{headers}\r\n\r\n");
let mock_io = Builder::new().read(input_header.as_bytes()).build();
let mut session = Session::new_h1(Box::new(mock_io));
session.read_request().await.unwrap();
let result = auth
.handle_request(
PluginStep::Request,
&mut session,
&mut Ctx::default(),
)
.await
.unwrap();
assert_eq!(true, result == RequestPluginResult::Continue);
let headers = ["Authorization: Bearer eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSm9obiIsImFkbWluIjp0cnVlLCJleHAiOjIzNDgwNTUyNjV9.HxFVxDd5ZiLsD1dWW1AywWMERhqk0Ck9IsdBHyD_1zap3w-waVOmFq0Yt1fWaYmh8HDtXLN6vlTd0HHYIYEGUw"].join("\r\n");
let input_header = format!("GET / HTTP/1.1\r\n{headers}\r\n\r\n");
let mock_io = Builder::new().read(input_header.as_bytes()).build();
let mut session = Session::new_h1(Box::new(mock_io));
session.read_request().await.unwrap();
let result = auth
.handle_request(
PluginStep::Request,
&mut session,
&mut Ctx::default(),
)
.await
.unwrap();
assert_eq!(true, result == RequestPluginResult::Continue);
let headers = [""].join("\r\n");
let input_header = format!("GET / HTTP/1.1\r\n{headers}\r\n\r\n");
let mock_io = Builder::new().read(input_header.as_bytes()).build();
let mut session = Session::new_h1(Box::new(mock_io));
session.read_request().await.unwrap();
let result = auth
.handle_request(
PluginStep::Request,
&mut session,
&mut Ctx::default(),
)
.await
.unwrap();
let RequestPluginResult::Respond(resp) = result else {
panic!("result is not Respond");
};
assert_eq!(401, resp.status.as_u16());
assert_eq!(
"Jwt authorization is missing",
std::string::String::from_utf8_lossy(resp.body.as_ref())
);
let headers = ["Authorization: Bearer a.b"].join("\r\n");
let input_header = format!("GET / HTTP/1.1\r\n{headers}\r\n\r\n");
let mock_io = Builder::new().read(input_header.as_bytes()).build();
let mut session = Session::new_h1(Box::new(mock_io));
session.read_request().await.unwrap();
let result = auth
.handle_request(
PluginStep::Request,
&mut session,
&mut Ctx::default(),
)
.await
.unwrap();
let RequestPluginResult::Respond(resp) = result else {
panic!("result is not Respond");
};
assert_eq!(401, resp.status.as_u16());
assert_eq!(
"Jwt authorization format is invalid",
std::string::String::from_utf8_lossy(resp.body.as_ref())
);
let headers = ["Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSm9obiIsImFkbWluIjp0cnVlLCJleHAiOjE3MTcwODQ4MDB9.zz7VHuqt9t6UGLNr5RZdfzvqMDEei"].join("\r\n");
let input_header = format!("GET / HTTP/1.1\r\n{headers}\r\n\r\n");
let mock_io = Builder::new().read(input_header.as_bytes()).build();
let mut session = Session::new_h1(Box::new(mock_io));
session.read_request().await.unwrap();
let result = auth
.handle_request(
PluginStep::Request,
&mut session,
&mut Ctx::default(),
)
.await
.unwrap();
let RequestPluginResult::Respond(resp) = result else {
panic!("result is not Respond");
};
assert_eq!(401, resp.status.as_u16());
assert_eq!(
"Jwt authorization is invalid",
std::string::String::from_utf8_lossy(resp.body.as_ref())
);
let headers = ["Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSm9obiIsImFkbWluIjp0cnVlLCJleHAiOjE3MTY5MDMyNjV9.PRS-PZafcGsV_rCL8QQfJdOJAvL5fOI_Z14N16JEcng"].join("\r\n");
let input_header = format!("GET / HTTP/1.1\r\n{headers}\r\n\r\n");
let mock_io = Builder::new().read(input_header.as_bytes()).build();
let mut session = Session::new_h1(Box::new(mock_io));
session.read_request().await.unwrap();
let result = auth
.handle_request(
PluginStep::Request,
&mut session,
&mut Ctx::default(),
)
.await
.unwrap();
let RequestPluginResult::Respond(resp) = result else {
panic!("result is not Respond");
};
assert_eq!(401, resp.status.as_u16());
assert_eq!(
"Jwt authorization is expired",
std::string::String::from_utf8_lossy(resp.body.as_ref())
);
}
const HS256_TOKEN: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSm9obiIsImFkbWluIjp0cnVlLCJleHAiOjIzNDgwNTUyNjV9.j6sYJ2dCCSxskwPmvHM7WniGCbkT30z2BrjfsuQLFJc";
const HS512_TOKEN: &str = "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSm9obiIsImFkbWluIjp0cnVlLCJleHAiOjIzNDgwNTUyNjV9.HxFVxDd5ZiLsD1dWW1AywWMERhqk0Ck9IsdBHyD_1zap3w-waVOmFq0Yt1fWaYmh8HDtXLN6vlTd0HHYIYEGUw";
async fn verify_with_algorithm(
algorithm: &str,
token: &str,
) -> RequestPluginResult {
let auth = JwtAuth::new(
&toml::from_str::<PluginConf>(&format!(
r###"
secret = "123123"
header = "Authorization"
algorithm = "{algorithm}"
"###
))
.unwrap(),
)
.unwrap();
let input_header =
format!("GET / HTTP/1.1\r\nAuthorization: Bearer {token}\r\n\r\n");
let mock_io = Builder::new().read(input_header.as_bytes()).build();
let mut session = Session::new_h1(Box::new(mock_io));
session.read_request().await.unwrap();
auth.handle_request(
PluginStep::Request,
&mut session,
&mut Ctx::default(),
)
.await
.unwrap()
}
#[tokio::test]
async fn test_jwt_pins_configured_algorithm() {
assert_eq!(
true,
verify_with_algorithm("HS256", HS256_TOKEN).await
== RequestPluginResult::Continue
);
assert_eq!(
true,
verify_with_algorithm("HS512", HS512_TOKEN).await
== RequestPluginResult::Continue
);
for (algorithm, token) in
[("HS512", HS256_TOKEN), ("HS256", HS512_TOKEN)]
{
let result = verify_with_algorithm(algorithm, token).await;
let RequestPluginResult::Respond(resp) = result else {
panic!(
"{algorithm} accepted a token signed with another algorithm"
);
};
assert_eq!(401, resp.status.as_u16());
}
assert_eq!(
true,
verify_with_algorithm("", HS256_TOKEN).await
== RequestPluginResult::Continue
);
assert_eq!(
true,
verify_with_algorithm("", HS512_TOKEN).await
== RequestPluginResult::Continue
);
}
#[test]
fn test_jwt_unsupported_hmac_algorithm() {
let err = JwtAuth::new(
&toml::from_str::<PluginConf>(
r###"
secret = "123123"
header = "Authorization"
algorithm = "HS384"
"###,
)
.unwrap(),
)
.err()
.unwrap();
assert_eq!(
"Plugin jwt invalid, message: Jwt algorithm(HS384) is not supported, expect HS256 or HS512, or set public_key/jwks_url",
err.to_string()
);
}
async fn new_auth_path_session() -> Session {
let mock_io =
Builder::new().read(b"GET /login HTTP/1.1\r\n\r\n").build();
let mut session = Session::new_h1(Box::new(mock_io));
session.read_request().await.unwrap();
session
}
#[tokio::test]
async fn test_jwt_sign() {
let auth = JwtAuth::new(
&toml::from_str::<PluginConf>(
r###"
secret = "123123"
header = "Authorization"
auth_path = "/login"
"###,
)
.unwrap(),
)
.unwrap();
let mut session = new_auth_path_session().await;
let mut ctx = Ctx::default();
let mut upstream_response =
ResponseHeader::build_no_case(200, None).unwrap();
let result = auth
.handle_response(&mut session, &mut ctx, &mut upstream_response)
.await
.unwrap();
assert_eq!(ResponsePluginResult::Modified, result);
assert_eq!(
r#"ResponseHeader { base: Parts { status: 200, version: HTTP/1.1, headers: {"content-type": "application/json; charset=utf-8", "transfer-encoding": "chunked"} }, header_name_map: None, reason_phrase: None }"#,
format!("{upstream_response:?}")
);
let mut body = Some(Bytes::from_static(b"Pingap"));
let result = auth
.handle_response_body(&mut session, &mut ctx, &mut body, true)
.unwrap();
assert_eq!(ResponseBodyPluginResult::FullyReplaced, result);
assert_eq!(
r#"{"token": "eyJhbGciOiAiSFMyNTYiLCJ0eXAiOiAiSldUIn0.UGluZ2Fw.wRLT2HhM1R-J4rVz3XCWADNIrmeInLtRGQzfJZaz-qI"}"#,
std::string::String::from_utf8_lossy(body.unwrap().as_ref())
);
}
#[tokio::test]
async fn test_jwt_sign_skips_error_response() {
let auth = JwtAuth::new(
&toml::from_str::<PluginConf>(
r###"
secret = "123123"
header = "Authorization"
auth_path = "/login"
"###,
)
.unwrap(),
)
.unwrap();
let mut session = new_auth_path_session().await;
let mut ctx = Ctx::default();
let mut upstream_response =
ResponseHeader::build_no_case(401, None).unwrap();
let result = auth
.handle_response(&mut session, &mut ctx, &mut upstream_response)
.await
.unwrap();
assert_eq!(ResponsePluginResult::Unchanged, result);
let mut body = Some(Bytes::from_static(b"invalid user or password"));
let result = auth
.handle_response_body(&mut session, &mut ctx, &mut body, true)
.unwrap();
assert_eq!(ResponseBodyPluginResult::Unchanged, result);
assert_eq!(
b"invalid user or password".as_ref(),
body.unwrap().as_ref()
);
}
}