use crate::{AliceId, Identity};
const B64_CHARS: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
fn base64url_encode(data: &[u8]) -> String {
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
let triple = (b0 << 16) | (b1 << 8) | b2;
out.push(B64_CHARS[((triple >> 18) & 0x3F) as usize] as char);
out.push(B64_CHARS[((triple >> 12) & 0x3F) as usize] as char);
if chunk.len() > 1 {
out.push(B64_CHARS[((triple >> 6) & 0x3F) as usize] as char);
}
if chunk.len() > 2 {
out.push(B64_CHARS[(triple & 0x3F) as usize] as char);
}
}
out
}
fn base64url_decode(s: &str) -> Option<Vec<u8>> {
let mut buf = Vec::with_capacity(s.len() * 3 / 4);
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
let a = decode_b64_char(bytes[i])?;
let b = if i + 1 < bytes.len() {
decode_b64_char(bytes[i + 1])?
} else {
0
};
let c = if i + 2 < bytes.len() {
decode_b64_char(bytes[i + 2])?
} else {
0
};
let d = if i + 3 < bytes.len() {
decode_b64_char(bytes[i + 3])?
} else {
0
};
let triple =
(u32::from(a) << 18) | (u32::from(b) << 12) | (u32::from(c) << 6) | u32::from(d);
buf.push((triple >> 16) as u8);
let remaining = bytes.len() - i;
if remaining > 2 {
buf.push((triple >> 8) as u8);
}
if remaining > 3 {
buf.push(triple as u8);
}
i += 4;
}
Some(buf)
}
const fn decode_b64_char(c: u8) -> Option<u8> {
match c {
b'A'..=b'Z' => Some(c - b'A'),
b'a'..=b'z' => Some(c - b'a' + 26),
b'0'..=b'9' => Some(c - b'0' + 52),
b'-' => Some(62),
b'_' => Some(63),
_ => None,
}
}
#[derive(Debug, Clone)]
pub struct JwtClaims {
pub iss: String,
pub sub: String,
pub exp: u64,
pub iat: u64,
}
impl JwtClaims {
#[must_use]
pub fn new(iss: &str, sub: &str, ttl_secs: u64) -> Self {
Self {
iss: iss.into(),
sub: sub.into(),
exp: ttl_secs, iat: 0,
}
}
#[must_use]
pub const fn with_iat(mut self, iat: u64) -> Self {
let ttl = self.exp; self.iat = iat;
self.exp = iat + ttl;
self
}
#[must_use]
pub const fn is_expired(&self, now: u64) -> bool {
now >= self.exp
}
fn to_json(&self) -> String {
format!(
r#"{{"iss":"{}","sub":"{}","exp":{},"iat":{}}}"#,
self.iss, self.sub, self.exp, self.iat
)
}
fn from_json(json: &str) -> Option<Self> {
let iss = extract_string_field(json, "iss")?;
let sub = extract_string_field(json, "sub")?;
let exp = extract_number_field(json, "exp")?;
let iat = extract_number_field(json, "iat")?;
Some(Self { iss, sub, exp, iat })
}
}
fn extract_string_field(json: &str, key: &str) -> Option<String> {
let needle = format!(r#""{key}":""#);
let start = json.find(&needle)? + needle.len();
let end = json[start..].find('"')? + start;
Some(json[start..end].to_string())
}
fn extract_number_field(json: &str, key: &str) -> Option<u64> {
let needle = format!(r#""{key}":"#);
let start = json.find(&needle)? + needle.len();
let rest = &json[start..];
let end = rest.find(|c: char| !c.is_ascii_digit())?;
rest[..end].parse().ok()
}
const JWT_HEADER: &str = r#"{"alg":"EdDSA","typ":"JWT"}"#;
#[must_use]
pub fn create_jwt(identity: &Identity, claims: &JwtClaims) -> String {
let header_b64 = base64url_encode(JWT_HEADER.as_bytes());
let payload_b64 = base64url_encode(claims.to_json().as_bytes());
let signing_input = format!("{header_b64}.{payload_b64}");
let sig = identity.sign(signing_input.as_bytes());
format!("{signing_input}.{}", base64url_encode(&sig.0))
}
#[must_use]
pub fn verify_jwt(token: &str, verifier: &AliceId) -> Option<JwtClaims> {
let parts: Vec<&str> = token.splitn(3, '.').collect();
if parts.len() != 3 {
return None;
}
let signing_input = format!("{}.{}", parts[0], parts[1]);
let sig_bytes = base64url_decode(parts[2])?;
if sig_bytes.len() != 64 {
return None;
}
let mut sig_arr = [0u8; 64];
sig_arr.copy_from_slice(&sig_bytes);
let sig = crate::AliceSig(sig_arr);
if crate::verify(verifier, signing_input.as_bytes(), &sig).is_err() {
return None;
}
let payload_bytes = base64url_decode(parts[1])?;
let payload_str = core::str::from_utf8(&payload_bytes).ok()?;
JwtClaims::from_json(payload_str)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base64url_roundtrip() {
let data = b"hello, ALICE-Auth JWT!";
let encoded = base64url_encode(data);
let decoded = base64url_decode(&encoded).unwrap();
assert_eq!(decoded, data);
}
#[test]
fn base64url_empty() {
let encoded = base64url_encode(b"");
assert_eq!(encoded, "");
let decoded = base64url_decode("").unwrap();
assert!(decoded.is_empty());
}
#[test]
fn base64url_single_byte() {
let encoded = base64url_encode(b"a");
let decoded = base64url_decode(&encoded).unwrap();
assert_eq!(decoded, b"a");
}
#[test]
fn base64url_two_bytes() {
let encoded = base64url_encode(b"ab");
let decoded = base64url_decode(&encoded).unwrap();
assert_eq!(decoded, b"ab");
}
#[test]
fn jwt_create_and_verify() {
let id = Identity::gen().unwrap();
let claims = JwtClaims::new("alice", "user-42", 3600).with_iat(1000);
let token = create_jwt(&id, &claims);
assert_eq!(token.matches('.').count(), 2);
let verified = verify_jwt(&token, &id.id()).unwrap();
assert_eq!(verified.iss, "alice");
assert_eq!(verified.sub, "user-42");
assert_eq!(verified.iat, 1000);
assert_eq!(verified.exp, 4600);
}
#[test]
fn jwt_reject_tampered_payload() {
let id = Identity::gen().unwrap();
let claims = JwtClaims::new("alice", "user-1", 3600);
let token = create_jwt(&id, &claims);
let parts: Vec<&str> = token.splitn(3, '.').collect();
let tampered = format!(
"{}.{}.{}",
parts[0],
base64url_encode(b"tampered"),
parts[2]
);
assert!(verify_jwt(&tampered, &id.id()).is_none());
}
#[test]
fn jwt_reject_wrong_key() {
let id1 = Identity::gen().unwrap();
let id2 = Identity::gen().unwrap();
let claims = JwtClaims::new("alice", "user-1", 3600);
let token = create_jwt(&id1, &claims);
assert!(verify_jwt(&token, &id2.id()).is_none());
}
#[test]
fn jwt_reject_invalid_format() {
let id = Identity::gen().unwrap();
assert!(verify_jwt("not-a-jwt", &id.id()).is_none());
assert!(verify_jwt("a.b", &id.id()).is_none());
}
#[test]
fn claims_is_expired() {
let claims = JwtClaims::new("a", "b", 100).with_iat(1000);
assert!(!claims.is_expired(1050));
assert!(claims.is_expired(1100));
assert!(claims.is_expired(2000));
}
#[test]
fn claims_json_roundtrip() {
let claims = JwtClaims {
iss: "alice-auth".into(),
sub: "user-99".into(),
exp: 9999,
iat: 1000,
};
let json = claims.to_json();
let parsed = JwtClaims::from_json(&json).unwrap();
assert_eq!(parsed.iss, "alice-auth");
assert_eq!(parsed.sub, "user-99");
assert_eq!(parsed.exp, 9999);
assert_eq!(parsed.iat, 1000);
}
#[test]
fn claims_new_defaults() {
let claims = JwtClaims::new("iss", "sub", 3600);
assert_eq!(claims.iat, 0);
assert_eq!(claims.exp, 3600); }
#[test]
fn jwt_header_is_eddsa() {
let id = Identity::gen().unwrap();
let claims = JwtClaims::new("a", "b", 100);
let token = create_jwt(&id, &claims);
let header_b64 = token.split('.').next().unwrap();
let header = base64url_decode(header_b64).unwrap();
let header_str = core::str::from_utf8(&header).unwrap();
assert!(header_str.contains("EdDSA"));
assert!(header_str.contains("JWT"));
}
#[test]
fn base64url_no_padding() {
let encoded = base64url_encode(b"test");
assert!(!encoded.contains('='));
}
#[test]
fn base64url_url_safe() {
let data: Vec<u8> = (0..=255).collect();
let encoded = base64url_encode(&data);
assert!(!encoded.contains('+'));
assert!(!encoded.contains('/'));
}
}