use rustlavel_auth::base64;
use rustlavel_core::{Error, Json, Result};
use crate::Scopes;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Claims {
pub subject: Option<String>,
pub client: Option<String>,
pub scopes: Scopes,
pub expires_at: Option<i64>,
}
impl Claims {
pub fn owner(&self) -> Option<&str> {
self.subject.as_deref().or(self.client.as_deref())
}
pub fn is_service(&self) -> bool {
self.subject.is_none()
}
pub fn expired(&self, now: i64) -> bool {
self.expires_at.is_some_and(|at| at <= now)
}
}
pub fn verify_signed(token: &str, public_key: &[u8], now: i64) -> Result<Claims> {
use p256::ecdsa::signature::Verifier;
let refuse = || Error::msg("the token was refused");
let mut parts = token.split('.');
let (header, payload, signature) = match (parts.next(), parts.next(), parts.next(), parts.next())
{
(Some(header), Some(payload), Some(signature), None) => (header, payload, signature),
_ => return Err(refuse()),
};
let head = Json::parse(&decode_part(header).ok_or_else(refuse)?).map_err(|_| refuse())?;
if head.get("alg").and_then(Json::as_str) != Some("ES256") {
return Err(refuse());
}
let point = p256::EncodedPoint::from_bytes(public_key).map_err(|_| refuse())?;
let key = p256::ecdsa::VerifyingKey::from_encoded_point(&point).map_err(|_| refuse())?;
let raw = base64::decode(signature).ok_or_else(refuse)?;
let parsed = p256::ecdsa::Signature::from_slice(&raw).map_err(|_| refuse())?;
let signed = format!("{header}.{payload}");
key.verify(signed.as_bytes(), &parsed).map_err(|_| refuse())?;
let body = Json::parse(&decode_part(payload).ok_or_else(refuse)?).map_err(|_| refuse())?;
let claims = claims_from(&body).ok_or_else(refuse)?;
if claims.expired(now) {
return Err(refuse());
}
Ok(claims)
}
pub fn read_introspection(body: &Json, now: i64) -> Result<Claims> {
let refuse = || Error::msg("the token was refused");
if body.get("active").and_then(Json::as_bool) != Some(true) {
return Err(refuse());
}
let claims = claims_from(body).ok_or_else(refuse)?;
if claims.expired(now) {
return Err(refuse());
}
Ok(claims)
}
fn claims_from(body: &Json) -> Option<Claims> {
let subject = body
.get("sub")
.and_then(Json::as_str)
.map(str::to_string)
.filter(|subject| !subject.is_empty());
body.get("client_id").and_then(Json::as_str).or(subject.as_deref())?;
Some(Claims {
subject,
client: body.get("client_id").and_then(Json::as_str).map(str::to_string),
scopes: Scopes::parse(body.get("scope").and_then(Json::as_str).unwrap_or("")),
expires_at: body.get("exp").and_then(Json::as_f64).map(|at| at as i64),
})
}
fn decode_part(part: &str) -> Option<String> {
String::from_utf8(base64::decode(part)?).ok()
}
pub const INTROSPECTION_TTL: std::time::Duration = std::time::Duration::from_secs(30);
#[cfg(test)]
mod tests {
use super::*;
use p256::ecdsa::{SigningKey, signature::Signer};
fn signed_token(claims: &str, key: &SigningKey, algorithm: &str) -> String {
let header = base64::encode_url(format!(r#"{{"alg":"{algorithm}","typ":"JWT"}}"#).as_bytes());
let payload = base64::encode_url(claims.as_bytes());
let signing_input = format!("{header}.{payload}");
let signature: p256::ecdsa::Signature = key.sign(signing_input.as_bytes());
format!("{signing_input}.{}", base64::encode_url(&signature.to_bytes()))
}
fn key() -> SigningKey {
SigningKey::from_bytes(&[7u8; 32].into()).expect("a valid scalar")
}
fn public(key: &SigningKey) -> Vec<u8> {
key.verifying_key().to_encoded_point(false).as_bytes().to_vec()
}
#[test]
fn a_signed_token_is_read_and_its_claims_come_back() {
let key = key();
let token = signed_token(
r#"{"sub":"42","client_id":"checkout","scope":"orders.read orders.write","exp":2000}"#,
&key,
"ES256",
);
let claims = verify_signed(&token, &public(&key), 1000).expect("a good token");
assert_eq!(claims.subject.as_deref(), Some("42"));
assert_eq!(claims.client.as_deref(), Some("checkout"));
assert!(claims.scopes.contains("orders.read"));
assert_eq!(claims.expires_at, Some(2000));
}
#[test]
fn a_token_naming_another_algorithm_is_refused() {
let key = key();
for algorithm in ["none", "HS256", "RS256", "es256"] {
let token = signed_token(r#"{"sub":"42","exp":2000}"#, &key, algorithm);
assert!(
verify_signed(&token, &public(&key), 1000).is_err(),
"a token claiming `alg: {algorithm}` was accepted"
);
}
}
#[test]
fn a_tampered_payload_is_refused() {
let key = key();
let token = signed_token(r#"{"sub":"42","exp":2000}"#, &key, "ES256");
let (head, rest) = token.split_once('.').unwrap();
let (_, signature) = rest.split_once('.').unwrap();
let forged = format!(
"{head}.{}.{signature}",
base64::encode_url(br#"{"sub":"1","exp":2000}"#)
);
assert!(verify_signed(&forged, &public(&key), 1000).is_err());
}
#[test]
fn a_token_signed_by_somebody_else_is_refused() {
let mine = key();
let theirs = SigningKey::from_bytes(&[9u8; 32].into()).unwrap();
let token = signed_token(r#"{"sub":"42","exp":2000}"#, &theirs, "ES256");
assert!(verify_signed(&token, &public(&mine), 1000).is_err());
}
#[test]
fn an_expired_signed_token_is_refused() {
let key = key();
let token = signed_token(r#"{"sub":"42","exp":1000}"#, &key, "ES256");
assert!(verify_signed(&token, &public(&key), 1000).is_err(), "exp == now is expired");
assert!(verify_signed(&token, &public(&key), 999).is_ok());
}
#[test]
fn something_that_is_not_a_token_is_refused() {
let key = key();
for rubbish in ["", "a", "a.b", "a.b.c.d", "....", "not a token at all"] {
assert!(verify_signed(rubbish, &public(&key), 1000).is_err(), "{rubbish:?}");
}
}
#[test]
fn an_active_introspection_response_is_read_and_its_claims_come_back() {
let body = Json::parse(
r#"{"active":true,"sub":"42","client_id":"checkout","scope":"orders.read orders.write","exp":2000}"#,
)
.unwrap();
let claims = read_introspection(&body, 1000).expect("an active token");
assert_eq!(claims.subject.as_deref(), Some("42"));
assert_eq!(claims.client.as_deref(), Some("checkout"));
assert!(claims.scopes.contains("orders.write"));
}
#[test]
fn an_inactive_or_silent_response_is_refused() {
for body in [
r#"{"active":false,"sub":"42"}"#,
r#"{"sub":"42","exp":2000}"#,
r#"{"active":"true","sub":"42"}"#,
r#"{}"#,
] {
let parsed = Json::parse(body).unwrap();
assert!(read_introspection(&parsed, 1000).is_err(), "{body}");
}
}
#[test]
fn an_expired_introspection_response_is_refused() {
let body = Json::parse(r#"{"active":true,"sub":"42","exp":1000}"#).unwrap();
assert!(read_introspection(&body, 1000).is_err(), "a cached answer outlives being true");
assert!(read_introspection(&body, 999).is_ok());
}
#[test]
fn a_response_with_no_subject_is_refused() {
for body in [r#"{"active":true}"#, r#"{"active":true,"sub":""}"#] {
let parsed = Json::parse(body).unwrap();
assert!(read_introspection(&parsed, 1000).is_err(), "{body}");
}
}
#[test]
fn a_token_without_an_expiry_is_not_treated_as_expired() {
let body = Json::parse(r#"{"active":true,"sub":"42"}"#).unwrap();
assert!(read_introspection(&body, i64::MAX).is_ok());
}
#[test]
fn a_token_with_no_subject_is_a_service_rather_than_a_refusal() {
let body = Json::parse(
r#"{"active":true,"client_id":"demo","scope":"orders.read","token_type":"Bearer"}"#,
)
.unwrap();
let claims = read_introspection(&body, 0).expect("a machine's token is still a token");
assert!(claims.is_service());
assert_eq!(claims.subject, None);
assert_eq!(claims.owner(), Some("demo"), "a service's rows need an owner too");
assert!(claims.scopes.contains("orders.read"));
}
#[test]
fn a_users_token_keeps_its_own_subject() {
let body = Json::parse(r#"{"active":true,"sub":"42","client_id":"demo"}"#).unwrap();
let claims = read_introspection(&body, 0).unwrap();
assert!(!claims.is_service());
assert_eq!(claims.owner(), Some("42"), "the user was attributed to the client");
}
#[test]
fn an_empty_subject_is_the_same_as_none() {
let body = Json::parse(r#"{"active":true,"sub":"","client_id":"demo"}"#).unwrap();
assert_eq!(read_introspection(&body, 0).unwrap().subject, None);
}
#[test]
fn a_token_naming_nobody_at_all_is_still_refused() {
let body = Json::parse(r#"{"active":true,"scope":"orders.read"}"#).unwrap();
assert!(read_introspection(&body, 0).is_err());
}
#[test]
fn both_paths_produce_the_same_claims() {
let key = key();
let token = signed_token(
r#"{"sub":"42","client_id":"checkout","scope":"orders.read","exp":2000}"#,
&key,
"ES256",
);
let introspected = Json::parse(
r#"{"active":true,"sub":"42","client_id":"checkout","scope":"orders.read","exp":2000}"#,
)
.unwrap();
assert_eq!(
verify_signed(&token, &public(&key), 1000).unwrap(),
read_introspection(&introspected, 1000).unwrap()
);
}
}