use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use super::scope::Scope;
const TOKEN_EXPIRY_SECS: u64 = 24 * 60 * 60;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claims {
pub sub: String,
pub iat: u64,
pub exp: u64,
pub scope: Vec<Scope>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub team_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub org_id: Option<String>,
}
pub struct JwtSigner {
encoding_key: EncodingKey,
}
impl JwtSigner {
pub fn new(secret: &[u8]) -> Self {
Self {
encoding_key: EncodingKey::from_secret(secret),
}
}
pub fn sign(&self, key_id: &str, scopes: &[Scope]) -> Result<String, JwtError> {
self.sign_with_tenant(key_id, scopes, None, None)
}
pub fn sign_with_tenant(
&self,
key_id: &str,
scopes: &[Scope],
team_id: Option<String>,
org_id: Option<String>,
) -> Result<String, JwtError> {
let now = now_epoch_secs();
let claims = Claims {
sub: key_id.to_string(),
iat: now,
exp: now + TOKEN_EXPIRY_SECS,
scope: scopes.to_vec(),
team_id,
org_id,
};
encode(&Header::default(), &claims, &self.encoding_key).map_err(JwtError::Encode)
}
#[cfg(test)]
fn sign_with_expiry(&self, key_id: &str, scopes: &[Scope], exp: u64) -> Result<String, JwtError> {
let claims = Claims {
sub: key_id.to_string(),
iat: now_epoch_secs(),
exp,
scope: scopes.to_vec(),
team_id: None,
org_id: None,
};
encode(&Header::default(), &claims, &self.encoding_key).map_err(JwtError::Encode)
}
}
pub struct JwtVerifier {
decoding_key: DecodingKey,
validation: Validation,
}
impl JwtVerifier {
pub fn new(secret: &[u8]) -> Self {
let mut validation = Validation::default();
validation.set_required_spec_claims(&["sub", "iat", "exp"]);
Self {
decoding_key: DecodingKey::from_secret(secret),
validation,
}
}
pub fn verify(&self, token: &str) -> Result<Claims, JwtError> {
let data = decode::<Claims>(token, &self.decoding_key, &self.validation).map_err(JwtError::Decode)?;
Ok(data.claims)
}
}
fn now_epoch_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock before Unix epoch")
.as_secs()
}
#[derive(Debug, Error)]
pub enum JwtError {
#[error("failed to encode JWT: {0}")]
Encode(jsonwebtoken::errors::Error),
#[error("failed to decode JWT: {0}")]
Decode(jsonwebtoken::errors::Error),
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_SECRET: &[u8] = b"test-secret-key-that-is-at-least-32-bytes-long!!";
#[test]
fn test_jwt_sign_verify_roundtrip() {
let signer = JwtSigner::new(TEST_SECRET);
let verifier = JwtVerifier::new(TEST_SECRET);
let token = signer
.sign("key-123", &[Scope::Read, Scope::Write])
.expect("signing should succeed");
let claims = verifier.verify(&token).expect("verification should succeed");
assert_eq!(claims.sub, "key-123");
assert_eq!(claims.scope, vec![Scope::Read, Scope::Write]);
assert!(claims.exp > claims.iat);
}
#[test]
fn test_jwt_expired_token_rejected() {
let signer = JwtSigner::new(TEST_SECRET);
let verifier = JwtVerifier::new(TEST_SECRET);
let expired = now_epoch_secs() - 3600;
let token = signer
.sign_with_expiry("key-123", &[Scope::Read], expired)
.expect("signing should succeed");
let result = verifier.verify(&token);
assert!(result.is_err(), "expired token should be rejected");
}
#[test]
fn test_jwt_wrong_secret_rejected() {
let signer = JwtSigner::new(TEST_SECRET);
let verifier = JwtVerifier::new(b"different-secret-that-is-also-32-bytes-long!!");
let token = signer.sign("key-123", &[Scope::Read]).expect("signing should succeed");
let result = verifier.verify(&token);
assert!(result.is_err(), "token signed with different secret should be rejected");
}
#[test]
fn test_jwt_tenant_claim_roundtrip() {
let signer = JwtSigner::new(TEST_SECRET);
let verifier = JwtVerifier::new(TEST_SECRET);
let scoped = signer
.sign_with_tenant("key-1", &[Scope::Read], Some("alpha".into()), Some("org-1".into()))
.unwrap();
let claims = verifier.verify(&scoped).unwrap();
assert_eq!(claims.team_id.as_deref(), Some("alpha"));
assert_eq!(claims.org_id.as_deref(), Some("org-1"));
let plain = signer.sign("key-2", &[Scope::Read]).unwrap();
let plain_claims = verifier.verify(&plain).unwrap();
assert_eq!(plain_claims.team_id, None);
assert_eq!(plain_claims.org_id, None);
}
#[test]
fn test_jwt_scopes_preserved() {
let signer = JwtSigner::new(TEST_SECRET);
let verifier = JwtVerifier::new(TEST_SECRET);
let scopes = vec![Scope::Read, Scope::Write, Scope::Admin];
let token = signer.sign("key-456", &scopes).expect("signing should succeed");
let claims = verifier.verify(&token).expect("verification should succeed");
assert_eq!(claims.scope, scopes);
}
}