pub mod middleware; use anyhow::Result; use chrono::Utc; use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use uuid::Uuid; #[allow(dead_code)] pub mod permissions { pub const READ: u128 = 1; pub const WRITE: u128 = 2; pub const DELETE: u128 = 4; pub const ADMIN_QUERY: u128 = 8; pub const ADMIN_CACHE: u128 = 16; pub const SUPER_ADMIN: u128 = 32; } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Claims { pub sub: String, pub permissions: String, // u128 stored as decimal string pub exp: usize, } impl Claims { pub fn permissions_mask(&self) -> u128 { self.permissions.parse().unwrap_or(0) } pub fn has_permission(&self, bit: u128) -> bool { self.permissions_mask() & bit != 0 } } pub fn encode_jwt(username: &str, mask: u128, secret: &str, expiry_secs: u64) -> Result { let exp = (Utc::now().timestamp() as u64 + expiry_secs) as usize; let claims = Claims { sub: username.to_string(), permissions: mask.to_string(), exp, }; let token = encode( &Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()), )?; Ok(token) } /// Generates a new API key: `mrc_<32 random hex chars>`. /// Returns `(plain_key, key_prefix, key_hash)`. pub fn generate_api_key() -> (String, String, String) { let raw = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); let key = format!("mrc_{}", raw); let prefix = raw[..8].to_string(); let hash = hash_api_key(&key); (key, prefix, hash) } pub fn hash_api_key(key: &str) -> String { format!("{:x}", Sha256::digest(key.as_bytes())) } /// Resolves an API key token against the database. /// Updates `last_used_at` on success. pub async fn resolve_api_key(token: &str, pool: &sqlx::PgPool) -> Option { if !token.starts_with("mrc_") { return None; } let hash = hash_api_key(token); let row: (String, String) = sqlx::query_as( "UPDATE api_keys SET last_used_at = now() WHERE key_hash = $1 AND (expires_at IS NULL OR expires_at > now()) RETURNING name, permissions_mask", ) .bind(&hash) .fetch_optional(pool) .await .ok()??; Some(Claims { sub: row.0, permissions: row.1, exp: usize::MAX, }) } pub fn decode_jwt(token: &str, secret: &str) -> Result { let data = decode::( token, &DecodingKey::from_secret(secret.as_bytes()), &Validation::default(), )?; Ok(data.claims) } #[cfg(test)] mod tests { use super::*; #[test] fn test_encode_decode_roundtrip() { let secret = "test_secret"; let token = encode_jwt("alice", 63u128, secret, 3600).unwrap(); let claims = decode_jwt(&token, secret).unwrap(); assert_eq!(claims.sub, "alice"); assert_eq!(claims.permissions_mask(), 63u128); } #[test] fn test_has_permission() { let claims = Claims { sub: "bob".into(), permissions: "9".into(), // READ (1) + ADMIN_QUERY (8) exp: 9999999999, }; assert!(claims.has_permission(permissions::READ)); assert!(claims.has_permission(permissions::ADMIN_QUERY)); assert!(!claims.has_permission(permissions::SUPER_ADMIN)); } }