use std::collections::HashMap;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use jsonwebtoken::{
jwk::{self, KeyAlgorithm},
DecodingKey,
};
use serde::Deserialize;
use thiserror::Error;
#[derive(Clone)]
#[allow(dead_code)]
pub struct Jwks {
pub keys: HashMap<String, Jwk>,
}
#[derive(Deserialize)]
struct OIDCConfig {
jwks_uri: String,
}
impl Jwks {
pub async fn from_oidc_url(oidc_url: impl Into<String>) -> Result<Self, JwksError> {
let url_str = oidc_url.into();
Self::validate_url_scheme(&url_str)?;
Self::from_oidc_url_with_client(&reqwest::Client::default(), url_str).await
}
pub async fn from_oidc_url_with_client(
client: &reqwest::Client,
oidc_url: impl Into<String>,
) -> Result<Self, JwksError> {
let oidc_config = client
.get(oidc_url.into())
.send()
.await?
.json::<OIDCConfig>()
.await?;
let jwks_uri = oidc_config.jwks_uri;
Self::from_jwks_url_with_client(client, &jwks_uri).await
}
pub async fn from_jwks_url(jwks_url: impl Into<String>) -> Result<Self, JwksError> {
let url_str = jwks_url.into();
Self::validate_url_scheme(&url_str)?;
Self::from_jwks_url_with_client(&reqwest::Client::default(), url_str).await
}
pub async fn from_jwks_url_with_client(
client: &reqwest::Client,
jwks_url: impl Into<String>,
) -> Result<Self, JwksError> {
let jwks: jwk::JwkSet = client.get(jwks_url.into()).send().await?.json().await?;
let mut keys = HashMap::new();
for jwk in jwks.keys {
let JwkEntry { kid, jwk } = jwk.try_into()?;
keys.insert(kid, jwk);
}
Ok(Self { keys })
}
fn validate_url_scheme(url: &str) -> Result<(), JwksError> {
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err(JwksError::InvalidUrlScheme(url.to_string()));
}
Ok(())
}
}
#[derive(Clone)]
#[allow(dead_code)]
pub struct Jwk {
pub alg: Option<KeyAlgorithm>,
pub decoding_key: DecodingKey,
}
#[allow(dead_code)]
pub struct JwkEntry {
pub kid: String,
pub jwk: Jwk,
}
impl JwkEntry {
pub fn from_jsonwebkey_ref(jwk: &jwk::Jwk) -> Result<Self, JwkError> {
let kid = jwk.common.key_id.clone().ok_or(JwkError::MissingKeyId)?;
let alg = jwk.common.key_algorithm;
let decoding_key = match &jwk.algorithm {
jwk::AlgorithmParameters::RSA(params) => {
DecodingKey::from_rsa_components(¶ms.n, ¶ms.e)
}
jwk::AlgorithmParameters::EllipticCurve(params) => {
DecodingKey::from_ec_components(¶ms.x, ¶ms.y)
}
jwk::AlgorithmParameters::OctetKeyPair(params) => {
DecodingKey::from_ed_components(¶ms.x)
}
jwk::AlgorithmParameters::OctetKey(params) => {
let base64_decoded = URL_SAFE_NO_PAD.decode(¶ms.value).map_err(|err| {
JwkError::DecodingError {
key_id: kid.clone(),
error: err.into(),
}
})?;
Ok(DecodingKey::from_secret(&base64_decoded))
}
}
.map_err(|err| JwkError::DecodingError {
key_id: kid.clone(),
error: err,
})?;
Ok(Self {
kid,
jwk: Jwk { alg, decoding_key },
})
}
}
impl TryFrom<jwk::Jwk> for JwkEntry {
type Error = JwkError;
fn try_from(j: jwk::Jwk) -> Result<Self, Self::Error> {
JwkEntry::from_jsonwebkey_ref(&j)
}
}
#[derive(Debug, Error)]
pub enum JwksError {
#[error("could not fetch config from authority: {0}")]
FetchError(#[from] reqwest::Error),
#[error("there was an error with an individual key: {0}")]
KeyError(#[from] JwkError),
#[error("URL scheme is required - URL must start with http:// or https://. Got: {0}")]
InvalidUrlScheme(String),
}
#[derive(Debug, Error)]
pub enum JwkError {
#[error("could not construct a decoding key for {key_id:?}: {error:?}")]
DecodingError {
key_id: String,
error: jsonwebtoken::errors::Error,
},
#[error("the key {key_id:?} does not specify an algorithm")]
MissingAlgorithm { key_id: String },
#[error("the key is missing the `kid` attribute")]
MissingKeyId,
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
use httpmock::prelude::*;
#[tokio::test]
async fn can_fetch_and_parse_jwks_from_jwks_url() {
let server = MockServer::start();
let jwks_path = "/oauth2/v3/certs";
let jwks = json!({
"keys": [
{
"use": "sig",
"n": "jb1Ps3fdt0oPYPbQlfZqKkCXrM1qJ5EkfBHSMrPXPzh9QLwa43WCLEdrTcf5vI8cNwbgSxDlCDS2BzHQC0hYPwFkJaD6y6NIIcwdSMcKlQPwk4-sqJbz55_gyUWjifcpXXKbXDdnd2QzSE2YipareOPJaBs3Ybuvf_EePnYoKEhXNeGm_T3546A56uOV2mNEe6e-RaIa76i8kcx_8JP3FjqxZSWRrmGYwZJhTGbeY5pfOS6v_EYpA4Up1kZANWReeC3mgh3O78f5nKEDxwPf99bIQ22fIC2779HbfzO-ybqR_EJ0zv8LlqfT7dMjZs25LH8Jw5wGWjP_9efP8emTOw",
"kty": "RSA",
"alg": "RS256",
"e": "AQAB",
"kid": "91413cf4fa0cb92a3c3f5a054509132c47660937"
},
{
"n": "tgkwz0K80MycaI2Dz_jHkErJ_IHUPTlx4LR_6wltAHQW_ZwhMzINNH8vbWo8P5F2YLDiIbuslF9y7Q3izsPX3XWQyt6LI8ZT4gmGXQBumYMKx2VtbmTYIysKY8AY7x5UCDO-oaAcBuKQvWc5E31kXm6d6vfaEZjrMc_KT3DsFdN0LcAkB-Q9oYcVl7YEgAN849ROKUs6onf7eukj1PHwDzIBgA9AExJaKen0wITvxQv3H_BRXB7m6hFkLbK5Jo18gl3UxJ7Em29peEwi8Psn7MuI7CwhFNchKhjZM9eaMX27tpDPqR15-I6CA5Zf94rabUGWYph5cFXKWPPr8dskQQ",
"alg": "RS256",
"use": "sig",
"kid": "1f40f0a8ef3d880978dc82f25c3ec317c6a5b781",
"e": "AQAB",
"kty": "RSA"
}
]
});
let _ = server.mock(|when, then| {
when.method(GET).path(jwks_path);
then.status(200)
.header("content-type", "application/json")
.body(jwks.to_string());
});
let jwks_url = server.url(jwks_path);
let jwks = Jwks::from_jwks_url(&jwks_url).await.unwrap();
assert_eq!(jwks.keys.len(), 2);
assert_eq!(
jwks.keys
.get("91413cf4fa0cb92a3c3f5a054509132c47660937")
.unwrap()
.alg,
Some(KeyAlgorithm::RS256)
);
assert_eq!(
jwks.keys
.get("1f40f0a8ef3d880978dc82f25c3ec317c6a5b781")
.unwrap()
.alg,
Some(KeyAlgorithm::RS256)
);
_ = &jwks
.keys
.get("91413cf4fa0cb92a3c3f5a054509132c47660937")
.expect("key one should be found");
_ = &jwks
.keys
.get("1f40f0a8ef3d880978dc82f25c3ec317c6a5b781")
.expect("key two should be found");
}
#[tokio::test]
async fn can_fetch_and_parse_jwks_from_oidc_config_url() {
let oidc_server = MockServer::start();
let oidc_config_path = "/.well-known/openid-configuration";
let jwks_path = "/oauth2/v3/certs";
let jwks_url = oidc_server.url(jwks_path);
let oidc_config = json!({
"issuer": "https://accounts.google.com",
"authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
"device_authorization_endpoint": "https://oauth2.googleapis.com/device/code",
"token_endpoint": "https://oauth2.googleapis.com/token",
"userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
"revocation_endpoint": "https://oauth2.googleapis.com/revoke",
"jwks_uri": jwks_url,
"response_types_supported": [
"code",
"token",
"id_token",
"code token",
"code id_token",
"token id_token",
"code token id_token",
"none"
],
"subject_types_supported": [
"public"
],
"id_token_signing_alg_values_supported": [
"RS256"
],
"scopes_supported": [
"openid",
"email",
"profile"
],
"token_endpoint_auth_methods_supported": [
"client_secret_post",
"client_secret_basic"
],
"claims_supported": [
"aud",
"email",
"email_verified",
"exp",
"family_name",
"given_name",
"iat",
"iss",
"locale",
"name",
"picture",
"sub"
],
"code_challenge_methods_supported": [
"plain",
"S256"
],
"grant_types_supported": [
"authorization_code",
"refresh_token",
"urn:ietf:params:oauth:grant-type:device_code",
"urn:ietf:params:oauth:grant-type:jwt-bearer"
]
});
let _ = oidc_server.mock(|when, then| {
when.method(GET).path(oidc_config_path);
then.status(200)
.header("content-type", "application/json")
.body(oidc_config.to_string());
});
let jwks = json!({
"keys": [
{
"use": "sig",
"n": "jb1Ps3fdt0oPYPbQlfZqKkCXrM1qJ5EkfBHSMrPXPzh9QLwa43WCLEdrTcf5vI8cNwbgSxDlCDS2BzHQC0hYPwFkJaD6y6NIIcwdSMcKlQPwk4-sqJbz55_gyUWjifcpXXKbXDdnd2QzSE2YipareOPJaBs3Ybuvf_EePnYoKEhXNeGm_T3546A56uOV2mNEe6e-RaIa76i8kcx_8JP3FjqxZSWRrmGYwZJhTGbeY5pfOS6v_EYpA4Up1kZANWReeC3mgh3O78f5nKEDxwPf99bIQ22fIC2779HbfzO-ybqR_EJ0zv8LlqfT7dMjZs25LH8Jw5wGWjP_9efP8emTOw",
"kty": "RSA",
"alg": "RS256",
"e": "AQAB",
"kid": "91413cf4fa0cb92a3c3f5a054509132c47660937"
},
{
"n": "tgkwz0K80MycaI2Dz_jHkErJ_IHUPTlx4LR_6wltAHQW_ZwhMzINNH8vbWo8P5F2YLDiIbuslF9y7Q3izsPX3XWQyt6LI8ZT4gmGXQBumYMKx2VtbmTYIysKY8AY7x5UCDO-oaAcBuKQvWc5E31kXm6d6vfaEZjrMc_KT3DsFdN0LcAkB-Q9oYcVl7YEgAN849ROKUs6onf7eukj1PHwDzIBgA9AExJaKen0wITvxQv3H_BRXB7m6hFkLbK5Jo18gl3UxJ7Em29peEwi8Psn7MuI7CwhFNchKhjZM9eaMX27tpDPqR15-I6CA5Zf94rabUGWYph5cFXKWPPr8dskQQ",
"alg": "RS256",
"use": "sig",
"kid": "1f40f0a8ef3d880978dc82f25c3ec317c6a5b781",
"e": "AQAB",
"kty": "RSA"
}
]
});
let _ = oidc_server.mock(|when, then| {
when.method(GET).path(jwks_path);
then.status(200)
.header("content-type", "application/json")
.body(jwks.to_string());
});
let oidc_config_url = oidc_server.url(oidc_config_path);
let jwks = Jwks::from_oidc_url(&oidc_config_url).await.unwrap();
assert_eq!(jwks.keys.len(), 2);
assert_eq!(
jwks.keys
.get("91413cf4fa0cb92a3c3f5a054509132c47660937")
.unwrap()
.alg,
Some(KeyAlgorithm::RS256)
);
assert_eq!(
jwks.keys
.get("1f40f0a8ef3d880978dc82f25c3ec317c6a5b781")
.unwrap()
.alg,
Some(KeyAlgorithm::RS256)
);
_ = &jwks
.keys
.get("91413cf4fa0cb92a3c3f5a054509132c47660937")
.expect("key one should be found");
_ = &jwks
.keys
.get("1f40f0a8ef3d880978dc82f25c3ec317c6a5b781")
.expect("key two should be found");
}
#[tokio::test]
async fn handles_network_errors() {
let jwks_url = "http://localhost:9999/nonexistent";
let result = Jwks::from_jwks_url(jwks_url).await;
assert!(matches!(result, Err(JwksError::FetchError(_))));
}
#[tokio::test]
async fn handles_network_errors_with_custom_client() {
let server = MockServer::start();
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(1))
.build()
.unwrap();
let result = Jwks::from_jwks_url_with_client(&client, &server.url("/slow")).await;
assert!(matches!(result, Err(JwksError::FetchError(_))));
}
#[tokio::test]
async fn handles_invalid_json_response() {
let server = MockServer::start();
let _ = server.mock(|when, then| {
when.method(GET).path("/invalid-json");
then.status(200)
.header("content-type", "application/json")
.body("{ invalid json }");
});
let result = Jwks::from_jwks_url(&server.url("/invalid-json")).await;
assert!(matches!(result, Err(JwksError::FetchError(_))));
}
#[tokio::test]
async fn handles_non_json_response() {
let server = MockServer::start();
let _ = server.mock(|when, then| {
when.method(GET).path("/text");
then.status(200)
.header("content-type", "text/plain")
.body("not json");
});
let result = Jwks::from_jwks_url(&server.url("/text")).await;
assert!(matches!(result, Err(JwksError::FetchError(_))));
}
#[tokio::test]
async fn handles_http_error_response() {
let server = MockServer::start();
let _ = server.mock(|when, then| {
when.method(GET).path("/error");
then.status(404);
});
let result = Jwks::from_jwks_url(&server.url("/error")).await;
assert!(matches!(result, Err(JwksError::FetchError(_))));
}
#[tokio::test]
async fn handles_missing_keys_array() {
let server = MockServer::start();
let jwks = json!({
"not_keys": []
});
let _ = server.mock(|when, then| {
when.method(GET).path("/no-keys");
then.status(200)
.header("content-type", "application/json")
.body(jwks.to_string());
});
let result = Jwks::from_jwks_url(&server.url("/no-keys")).await;
assert!(matches!(result, Err(JwksError::FetchError(_))));
}
#[tokio::test]
async fn handles_key_without_kid() {
let server = MockServer::start();
let jwks = json!({
"keys": [{
"kty": "RSA",
"n": "jb1Ps3fdt0oPYPbQlfZqKkCXrM1qJ5EkfBHSMrPXPzh9QLwa43WCLEdrTcf5vI8cNwbgSxDlCDS2BzHQC0hYPwFkJaD6y6NIIcwdSMcKlQPwk4-sqJbz55_gyUWjifcpXXKbXDdnd2QzSE2YipareOPJaBs3Ybuvf_EePnYoKEhXNeGm_T3546A56uOV2mNEe6e-RaIa76i8kcx_8JP3FjqxZSWRrmGYwZJhTGbeY5pfOS6v_EYpA4Up1kZANWReeC3mgh3O78f5nKEDxwPf99bIQ22fIC2779HbfzO-ybqR_EJ0zv8LlqfT7dMjZs25LH8Jw5wGWjP_9efP8emTOw",
"e": "AQAB",
"alg": "RS256"
}]
});
let _ = server.mock(|when, then| {
when.method(GET).path("/no-kid");
then.status(200)
.header("content-type", "application/json")
.body(jwks.to_string());
});
let result = Jwks::from_jwks_url(&server.url("/no-kid")).await;
assert!(matches!(
result,
Err(JwksError::KeyError(JwkError::MissingKeyId))
));
}
#[tokio::test]
async fn handles_key_without_algorithm() {
let server = MockServer::start();
let jwks = json!({
"keys": [{
"kty": "RSA",
"n": "jb1Ps3fdt0oPYPbQlfZqKkCXrM1qJ5EkfBHSMrPXPzh9QLwa43WCLEdrTcf5vI8cNwbgSxDlCDS2BzHQC0hYPwFkJaD6y6NIIcwdSMcKlQPwk4-sqJbz55_gyUWjifcpXXKbXDdnd2QzSE2YipareOPJaBs3Ybuvf_EePnYoKEhXNeGm_T3546A56uOV2mNEe6e-RaIa76i8kcx_8JP3FjqxZSWRrmGYwZJhTGbeY5pfOS6v_EYpA4Up1kZANWReeC3mgh3O78f5nKEDxwPf99bIQ22fIC2779HbfzO-ybqR_EJ0zv8LlqfT7dMjZs25LH8Jw5wGWjP_9efP8emTOw",
"e": "AQAB",
"kid": "no-alg-key"
}]
});
let _ = server.mock(|when, then| {
when.method(GET).path("/no-alg");
then.status(200)
.header("content-type", "application/json")
.body(jwks.to_string());
});
let jwks = Jwks::from_jwks_url(&server.url("/no-alg")).await.unwrap();
assert_eq!(jwks.keys.len(), 1);
let key = jwks.keys.get("no-alg-key").unwrap();
assert_eq!(key.alg, None);
}
#[tokio::test]
async fn handles_empty_jwks() {
let server = MockServer::start();
let jwks = json!({"keys": []});
let _ = server.mock(|when, then| {
when.method(GET).path("/empty");
then.status(200)
.header("content-type", "application/json")
.body(jwks.to_string());
});
let jwks = Jwks::from_jwks_url(&server.url("/empty")).await.unwrap();
assert_eq!(jwks.keys.len(), 0);
}
#[tokio::test]
async fn handles_oidc_network_error() {
let server = MockServer::start();
let result = Jwks::from_oidc_url(&server.url("/nonexistent")).await;
assert!(matches!(result, Err(JwksError::FetchError(_))));
}
#[tokio::test]
async fn handles_invalid_oidc_config() {
let server = MockServer::start();
let invalid_config = json!({
"invalid_field": "invalid_value"
});
let _ = server.mock(|when, then| {
when.method(GET).path("/invalid-oidc");
then.status(200)
.header("content-type", "application/json")
.body(invalid_config.to_string());
});
let result = Jwks::from_oidc_url(&server.url("/invalid-oidc")).await;
assert!(matches!(result, Err(JwksError::FetchError(_))));
}
#[tokio::test]
async fn handles_url_without_scheme() {
let result = Jwks::from_jwks_url("example.com/.well-known/jwks.json").await;
assert!(matches!(result, Err(JwksError::InvalidUrlScheme(_))));
}
#[tokio::test]
async fn handles_oidc_url_without_scheme() {
let result =
Jwks::from_oidc_url("accounts.google.com/.well-known/openid-configuration").await;
assert!(matches!(result, Err(JwksError::InvalidUrlScheme(_))));
}
#[tokio::test]
async fn can_parse_ec_keys_with_ec_p256_algorithms() {
let server = MockServer::start();
let jwks_path = "/ec-keys";
let jwks = json!({
"keys": [
{
"use": "sig",
"kty": "EC",
"alg": "ES256",
"crv": "P-256",
"x": "MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4",
"y": "4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM",
"kid": "ec-p256-key"
}
]
});
let _ = server.mock(|when, then| {
when.method(GET).path(jwks_path);
then.status(200)
.header("content-type", "application/json")
.body(jwks.to_string());
});
let jwks = Jwks::from_jwks_url(&server.url(jwks_path)).await.unwrap();
assert_eq!(jwks.keys.len(), 1);
let p256_key = jwks.keys.get("ec-p256-key").unwrap();
assert_eq!(p256_key.alg, Some(KeyAlgorithm::ES256));
}
#[tokio::test]
async fn handles_ec_key_missing_coordinates() {
let server = MockServer::start();
let jwks_path = "/ec-missing-coords";
let jwks = json!({
"keys": [
{
"use": "sig",
"kty": "EC",
"alg": "ES256",
"crv": "P-256",
"x": "MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4",
"kid": "ec-missing-y"
}
]
});
let _ = server.mock(|when, then| {
when.method(GET).path(jwks_path);
then.status(200)
.header("content-type", "application/json")
.body(jwks.to_string());
});
let result = Jwks::from_jwks_url(&server.url(jwks_path)).await;
assert!(matches!(result, Err(JwksError::FetchError(_))));
}
#[tokio::test]
async fn handles_ec_key_invalid_coordinates() {
let server = MockServer::start();
let jwks_path = "/ec-invalid-coords";
let jwks = json!({
"keys": [
{
"use": "sig",
"kty": "EC",
"alg": "ES256",
"crv": "P-256",
"x": "invalid_base64!",
"y": "4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM",
"kid": "ec-invalid-x"
}
]
});
let _ = server.mock(|when, then| {
when.method(GET).path(jwks_path);
then.status(200)
.header("content-type", "application/json")
.body(jwks.to_string());
});
let result = Jwks::from_jwks_url(&server.url(jwks_path)).await;
assert!(matches!(
result,
Err(JwksError::KeyError(JwkError::DecodingError { .. }))
));
}
#[tokio::test]
async fn handles_ec_key_missing_curve() {
let server = MockServer::start();
let jwks_path = "/ec-missing-curve";
let jwks = json!({
"keys": [
{
"use": "sig",
"kty": "EC",
"alg": "ES256",
"x": "MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4",
"y": "4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM",
"kid": "ec-missing-curve"
}
]
});
let _ = server.mock(|when, then| {
when.method(GET).path(jwks_path);
then.status(200)
.header("content-type", "application/json")
.body(jwks.to_string());
});
let result = Jwks::from_jwks_url(&server.url(jwks_path)).await;
assert!(matches!(result, Err(JwksError::FetchError(_))));
}
#[tokio::test]
async fn can_parse_mixed_key_types() {
let server = MockServer::start();
let jwks_path = "/mixed-keys";
let jwks = json!({
"keys": [
{
"use": "sig",
"n": "jb1Ps3fdt0oPYPbQlfZqKkCXrM1qJ5EkfBHSMrPXPzh9QLwa43WCLEdrTcf5vI8cNwbgSxDlCDS2BzHQC0hYPwFkJaD6y6NIIcwdSMcKlQPwk4-sqJbz55_gyUWjifcpXXKbXDdnd2QzSE2YipareOPJaBs3Ybuvf_EePnYoKEhXNeGm_T3546A56uOV2mNEe6e-RaIa76i8kcx_8JP3FjqxZSWRrmGYwZJhTGbeY5pfOS6v_EYpA4Up1kZANWReeC3mgh3O78f5nKEDxwPf99bIQ22fIC2779HbfzO-ybqR_EJ0zv8LlqfT7dMjZs25LH8Jw5wGWjP_9efP8emTOw",
"kty": "RSA",
"alg": "RS256",
"e": "AQAB",
"kid": "rsa-key"
},
{
"use": "sig",
"kty": "EC",
"alg": "ES256",
"crv": "P-256",
"x": "MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4",
"y": "4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM",
"kid": "ec-key"
},
{
"use": "sig",
"kty": "oct",
"alg": "HS256",
"k": "GawgguFyGrWKav7AX4VKUg",
"kid": "symmetric-key"
}
]
});
let _ = server.mock(|when, then| {
when.method(GET).path(jwks_path);
then.status(200)
.header("content-type", "application/json")
.body(jwks.to_string());
});
let jwks = Jwks::from_jwks_url(&server.url(jwks_path)).await.unwrap();
assert_eq!(jwks.keys.len(), 3);
let rsa_key = jwks.keys.get("rsa-key").unwrap();
assert_eq!(rsa_key.alg, Some(KeyAlgorithm::RS256));
let ec_key = jwks.keys.get("ec-key").unwrap();
assert_eq!(ec_key.alg, Some(KeyAlgorithm::ES256));
let symmetric_key = jwks.keys.get("symmetric-key").unwrap();
assert_eq!(symmetric_key.alg, Some(KeyAlgorithm::HS256));
}
#[tokio::test]
async fn e2e_jwt_sign_and_verify_with_rsa_key() {
use jsonwebtoken::{decode, encode, EncodingKey, Header, Validation};
use rsa::pkcs1::EncodeRsaPrivateKey;
use rsa::traits::PublicKeyParts;
use serde_json::json;
let server = MockServer::start();
let jwks_path = "/jwks";
let mut rng = rand::thread_rng();
let private_key = rsa::RsaPrivateKey::new(&mut rng, 2048).unwrap();
let public_key = private_key.to_public_key();
let n = URL_SAFE_NO_PAD.encode(public_key.n().to_bytes_be());
let e = URL_SAFE_NO_PAD.encode(public_key.e().to_bytes_be());
let jwks_data = json!({
"keys": [
{
"use": "sig",
"n": n,
"kty": "RSA",
"alg": "RS256",
"e": e,
"kid": "test-rsa-key"
}
]
});
let _ = server.mock(|when, then| {
when.method(GET).path(jwks_path);
then.status(200)
.header("content-type", "application/json")
.body(jwks_data.to_string());
});
let jwks_url = server.url(jwks_path);
let jwks = Jwks::from_jwks_url(&jwks_url).await.unwrap();
let now = jsonwebtoken::get_current_timestamp();
let claims = json!({
"sub": "1234567890",
"name": "John Doe",
"iat": now,
"exp": now + 3600
});
let header = Header::new(jsonwebtoken::Algorithm::RS256);
let encoding_key =
EncodingKey::from_rsa_der(&private_key.to_pkcs1_der().unwrap().to_bytes());
let token = encode(&header, &claims, &encoding_key).unwrap();
let test_key = jwks.keys.get("test-rsa-key").unwrap();
let validation = Validation::new(jsonwebtoken::Algorithm::RS256);
let token_data =
decode::<serde_json::Value>(&token, &test_key.decoding_key, &validation).unwrap();
assert_eq!(token_data.claims, claims);
assert_eq!(token_data.header.alg, jsonwebtoken::Algorithm::RS256);
}
#[tokio::test]
async fn e2e_jwt_sign_and_verify_with_symmetric_key() {
use jsonwebtoken::{decode, encode, EncodingKey, Header, Validation};
use serde_json::json;
let server = MockServer::start();
let jwks_path = "/jwks";
let symmetric_key = b"my-super-secret-symmetric-key";
let encoded_key = URL_SAFE_NO_PAD.encode(symmetric_key);
let jwks_data = json!({
"keys": [
{
"use": "sig",
"kty": "oct",
"alg": "HS256",
"k": encoded_key,
"kid": "test-symmetric-key"
}
]
});
let _ = server.mock(|when, then| {
when.method(GET).path(jwks_path);
then.status(200)
.header("content-type", "application/json")
.body(jwks_data.to_string());
});
let jwks_url = server.url(jwks_path);
let jwks = Jwks::from_jwks_url(&jwks_url).await.unwrap();
let now = jsonwebtoken::get_current_timestamp();
let claims = json!({
"sub": "1234567890",
"name": "Alice Smith",
"iat": now,
"exp": now + 3600
});
let header = Header::new(jsonwebtoken::Algorithm::HS256);
let encoding_key = EncodingKey::from_secret(symmetric_key);
let token = encode(&header, &claims, &encoding_key).unwrap();
let test_key = jwks.keys.get("test-symmetric-key").unwrap();
let validation = Validation::new(jsonwebtoken::Algorithm::HS256);
let token_data =
decode::<serde_json::Value>(&token, &test_key.decoding_key, &validation).unwrap();
assert_eq!(token_data.claims, claims);
assert_eq!(token_data.header.alg, jsonwebtoken::Algorithm::HS256);
}
#[tokio::test]
async fn e2e_jwt_verify_with_oidc_discovery() {
use jsonwebtoken::{decode, encode, EncodingKey, Header, Validation};
use rsa::pkcs1::EncodeRsaPrivateKey;
use rsa::traits::PublicKeyParts;
let oidc_server = MockServer::start();
let oidc_config_path = "/.well-known/openid-configuration";
let jwks_path = "/oauth2/v3/certs";
let jwks_url = oidc_server.url(jwks_path);
let mut rng = rand::thread_rng();
let private_key = rsa::RsaPrivateKey::new(&mut rng, 2048).unwrap();
let public_key = private_key.to_public_key();
let n = URL_SAFE_NO_PAD.encode(public_key.n().to_bytes_be());
let e = URL_SAFE_NO_PAD.encode(public_key.e().to_bytes_be());
let oidc_config = json!({
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/oauth2/v2/auth",
"token_endpoint": "https://auth.example.com/oauth2/v2/token",
"jwks_uri": jwks_url,
"response_types_supported": ["code"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"scopes_supported": ["openid", "email", "profile"]
});
let _ = oidc_server.mock(|when, then| {
when.method(GET).path(oidc_config_path);
then.status(200)
.header("content-type", "application/json")
.body(oidc_config.to_string());
});
let jwks_data = json!({
"keys": [
{
"use": "sig",
"n": n,
"kty": "RSA",
"alg": "RS256",
"e": e,
"kid": "test-oidc-key"
}
]
});
let _ = oidc_server.mock(|when, then| {
when.method(GET).path(jwks_path);
then.status(200)
.header("content-type", "application/json")
.body(jwks_data.to_string());
});
let oidc_config_url = oidc_server.url(oidc_config_path);
let jwks = Jwks::from_oidc_url(&oidc_config_url).await.unwrap();
let now = jsonwebtoken::get_current_timestamp();
let claims = json!({
"sub": "1234567890",
"name": "Bob Johnson",
"iat": now,
"exp": now + 3600,
"iss": "https://auth.example.com"
});
let header = Header::new(jsonwebtoken::Algorithm::RS256);
let encoding_key =
EncodingKey::from_rsa_der(&private_key.to_pkcs1_der().unwrap().to_bytes());
let token = encode(&header, &claims, &encoding_key).unwrap();
let test_key = jwks.keys.get("test-oidc-key").unwrap();
let mut validation = Validation::new(jsonwebtoken::Algorithm::RS256);
validation.set_issuer(&["https://auth.example.com"]);
let token_data =
decode::<serde_json::Value>(&token, &test_key.decoding_key, &validation).unwrap();
assert_eq!(token_data.claims, claims);
assert_eq!(token_data.header.alg, jsonwebtoken::Algorithm::RS256);
}
}