use crate::auth::error::AuthError;
use chrono::Utc;
use jsonwebtoken::{Algorithm, EncodingKey, Header};
use serde::Serialize;
pub const CLIENT_ASSERTION_TYPE_JWT_BEARER: &str =
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
pub const MAX_CLIENT_ASSERTION_LIFETIME_SECS: i64 = 300;
#[derive(Debug, Serialize)]
struct AssertionClaims<'a> {
iss: &'a str,
sub: &'a str,
aud: &'a str,
jti: String,
exp: i64,
iat: i64,
}
#[tracing::instrument(skip(encoding_key))]
pub fn mint_client_assertion(
client_id: &str,
audience: &str,
encoding_key: &EncodingKey,
alg: Algorithm,
kid: Option<&str>,
lifetime_secs: i64,
) -> Result<String, AuthError> {
let lifetime_secs = lifetime_secs.clamp(1, MAX_CLIENT_ASSERTION_LIFETIME_SECS);
let now = Utc::now();
let exp = now + chrono::Duration::seconds(lifetime_secs);
let claims = AssertionClaims {
iss: client_id,
sub: client_id,
aud: audience,
jti: uuid::Uuid::new_v4().to_string(),
exp: exp.timestamp(),
iat: now.timestamp(),
};
let mut header = Header::new(alg);
header.kid = kid.map(str::to_string);
let assertion = jsonwebtoken::encode(&header, &claims, encoding_key).map_err(|e| {
tracing::error!(client_id = %client_id, error = %e, "failed to sign private_key_jwt client assertion");
AuthError::Token(e.to_string())
})?;
tracing::debug!(client_id = %client_id, audience = %audience, "minted private_key_jwt client assertion");
Ok(assertion)
}
#[cfg(test)]
mod tests {
use super::*;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use serde_json::Value;
const TEST_ED25519_PRIVATE_KEY_PEM: &[u8] = b"-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIKIPR2jojpdobYr1M/pjIRuMONpZGYQ+y5yxSqKX9T9/
-----END PRIVATE KEY-----";
fn decode_parts(jwt: &str) -> (Value, Value) {
let mut parts = jwt.split('.');
let header_b64 = parts.next().unwrap();
let payload_b64 = parts.next().unwrap();
let header: Value =
serde_json::from_slice(&URL_SAFE_NO_PAD.decode(header_b64).unwrap()).unwrap();
let payload: Value =
serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload_b64).unwrap()).unwrap();
(header, payload)
}
#[test]
fn mints_an_assertion_with_iss_sub_client_id_and_correct_aud() {
let key = EncodingKey::from_ed_pem(TEST_ED25519_PRIVATE_KEY_PEM).unwrap();
let jwt = mint_client_assertion(
"svc-1",
"https://auth.example.com/token",
&key,
Algorithm::EdDSA,
None,
60,
)
.unwrap();
let (header, payload) = decode_parts(&jwt);
assert_eq!(header["alg"], "EdDSA");
assert_eq!(payload["iss"], "svc-1");
assert_eq!(payload["sub"], "svc-1");
assert_eq!(payload["aud"], "https://auth.example.com/token");
}
#[test]
fn mints_a_fresh_jti_every_call() {
let key = EncodingKey::from_ed_pem(TEST_ED25519_PRIVATE_KEY_PEM).unwrap();
let jwt_a = mint_client_assertion(
"svc-1",
"https://auth.example.com/token",
&key,
Algorithm::EdDSA,
None,
60,
)
.unwrap();
let jwt_b = mint_client_assertion(
"svc-1",
"https://auth.example.com/token",
&key,
Algorithm::EdDSA,
None,
60,
)
.unwrap();
let (_, payload_a) = decode_parts(&jwt_a);
let (_, payload_b) = decode_parts(&jwt_b);
let jti_a = payload_a["jti"].as_str().unwrap();
let jti_b = payload_b["jti"].as_str().unwrap();
assert!(!jti_a.is_empty());
assert!(!jti_b.is_empty());
assert_ne!(jti_a, jti_b, "each minted assertion must carry its own jti");
}
#[test]
fn clamps_a_lifetime_beyond_the_max_to_the_max() {
let key = EncodingKey::from_ed_pem(TEST_ED25519_PRIVATE_KEY_PEM).unwrap();
let jwt = mint_client_assertion(
"svc-1",
"https://auth.example.com/token",
&key,
Algorithm::EdDSA,
None,
MAX_CLIENT_ASSERTION_LIFETIME_SECS * 100,
)
.unwrap();
let (_, payload) = decode_parts(&jwt);
let exp = payload["exp"].as_i64().unwrap();
let iat = payload["iat"].as_i64().unwrap();
assert!(
exp - iat <= MAX_CLIENT_ASSERTION_LIFETIME_SECS,
"exp must never be minted further out than MAX_CLIENT_ASSERTION_LIFETIME_SECS, \
got a lifetime of {} seconds",
exp - iat
);
}
#[test]
fn stamps_the_given_kid_onto_the_header() {
let key = EncodingKey::from_ed_pem(TEST_ED25519_PRIVATE_KEY_PEM).unwrap();
let jwt = mint_client_assertion(
"svc-1",
"https://auth.example.com/token",
&key,
Algorithm::EdDSA,
Some("key-1"),
60,
)
.unwrap();
let (header, _) = decode_parts(&jwt);
assert_eq!(header["kid"], "key-1");
}
}