use chrono::{Duration, Utc};
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;
use crate::storage::{MemoryTokenStore, StorageError, TokenRecord, TokenStore};
pub const TOKEN_PREFIX: &str = "la_sk_";
pub const ADMIN_SCOPE: &str = "admin";
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TokenClaims {
pub sub: String,
pub iat: i64,
pub exp: i64,
#[serde(default)]
pub label: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub scope: String,
}
impl TokenClaims {
#[must_use]
pub fn is_admin(&self) -> bool {
self.scope == ADMIN_SCOPE
}
}
#[derive(Debug, Default, Clone)]
pub struct IssueRequest<'a> {
pub ttl_hours: i64,
pub label: &'a str,
pub account: Option<&'a str>,
pub max_requests: Option<u64>,
pub scope: &'a str,
}
#[derive(Clone)]
pub struct TokenManager {
secret: String,
store: Arc<dyn TokenStore>,
}
impl TokenManager {
#[must_use]
pub fn new(secret: &str) -> Self {
Self::with_store(secret, Arc::new(MemoryTokenStore::new()))
}
#[must_use]
pub fn with_store(secret: &str, store: Arc<dyn TokenStore>) -> Self {
Self {
secret: secret.to_string(),
store,
}
}
#[must_use]
pub fn store(&self) -> Arc<dyn TokenStore> {
Arc::clone(&self.store)
}
pub fn issue_token(
&self,
ttl_hours: i64,
label: &str,
) -> Result<String, jsonwebtoken::errors::Error> {
self.issue_token_for(ttl_hours, label, None)
}
pub fn issue_token_for(
&self,
ttl_hours: i64,
label: &str,
account: Option<&str>,
) -> Result<String, jsonwebtoken::errors::Error> {
self.issue_token_full(ttl_hours, label, account, None)
}
pub fn issue_token_full(
&self,
ttl_hours: i64,
label: &str,
account: Option<&str>,
max_requests: Option<u64>,
) -> Result<String, jsonwebtoken::errors::Error> {
self.issue(&IssueRequest {
ttl_hours,
label,
account,
max_requests,
scope: "",
})
}
pub fn issue_admin_token(
&self,
ttl_hours: i64,
label: &str,
) -> Result<String, jsonwebtoken::errors::Error> {
self.issue(&IssueRequest {
ttl_hours,
label,
account: None,
max_requests: None,
scope: ADMIN_SCOPE,
})
}
pub fn issue(&self, request: &IssueRequest<'_>) -> Result<String, jsonwebtoken::errors::Error> {
let ttl_hours = request.ttl_hours;
let label = request.label;
let account = request.account;
let max_requests = request.max_requests;
let now = Utc::now();
let exp = now + Duration::hours(ttl_hours);
let claims = TokenClaims {
sub: Uuid::new_v4().to_string(),
iat: now.timestamp(),
exp: exp.timestamp(),
label: label.to_string(),
scope: request.scope.to_string(),
};
let jwt = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(self.secret.as_bytes()),
)?;
let record = TokenRecord {
id: claims.sub.clone(),
label: claims.label.clone(),
issued_at: claims.iat,
expires_at: claims.exp,
revoked: false,
account: account.map(String::from),
max_requests,
used_requests: 0,
scope: claims.scope,
};
if let Err(e) = self.store.put(record) {
tracing::warn!("token store put failed: {e}");
}
Ok(format!("{TOKEN_PREFIX}{jwt}"))
}
pub fn enforce_request_budget(&self, token_id: &str) -> Result<(), TokenError> {
match self.store.try_consume_request(token_id) {
Ok(true) => Ok(()),
Ok(false) => Err(TokenError::LimitExceeded),
Err(e) => Err(TokenError::Storage(e.to_string())),
}
}
pub fn account_for(&self, token_id: &str) -> Result<Option<String>, TokenError> {
self.store
.get(token_id)
.map(|record| record.and_then(|record| record.account))
.map_err(|error| TokenError::Storage(error.to_string()))
}
pub fn validate_token(&self, token: &str) -> Result<TokenClaims, TokenError> {
let jwt = token
.strip_prefix(TOKEN_PREFIX)
.ok_or(TokenError::InvalidPrefix)?;
let token_data = decode::<TokenClaims>(
jwt,
&DecodingKey::from_secret(self.secret.as_bytes()),
&Validation::default(),
)
.map_err(|e| match e.kind() {
jsonwebtoken::errors::ErrorKind::ExpiredSignature => TokenError::Expired,
_ => TokenError::Invalid(e.to_string()),
})?;
let revoked = self
.store
.get(&token_data.claims.sub)
.map_err(|e| TokenError::Storage(e.to_string()))?
.is_some_and(|r| r.revoked);
if revoked {
return Err(TokenError::Revoked);
}
Ok(token_data.claims)
}
pub fn validate_admin_token(&self, token: &str) -> Result<TokenClaims, TokenError> {
let claims = self.validate_token(token)?;
if claims.is_admin() {
Ok(claims)
} else {
Err(TokenError::InsufficientScope)
}
}
pub fn has_active_admin_token(&self) -> Result<bool, TokenError> {
let now = Utc::now().timestamp();
Ok(self.list_tokens()?.iter().any(|record| {
record.scope == ADMIN_SCOPE && !record.revoked && record.expires_at > now
}))
}
pub fn rotate_admin_token(
&self,
current_sub: &str,
ttl_hours: i64,
label: &str,
) -> Result<String, TokenError> {
if !self
.list_tokens()?
.iter()
.any(|record| record.id == current_sub)
{
return Err(TokenError::Invalid(format!(
"unknown token id {current_sub}"
)));
}
let replacement = self
.issue_admin_token(ttl_hours, label)
.map_err(|e| TokenError::Invalid(e.to_string()))?;
self.revoke_token(current_sub)?;
Ok(replacement)
}
pub fn revoke_token(&self, token_id: &str) -> Result<(), TokenError> {
match self.store.revoke(token_id) {
Ok(_) => Ok(()),
Err(e) => Err(TokenError::Storage(e.to_string())),
}
}
pub fn list_tokens(&self) -> Result<Vec<TokenRecord>, TokenError> {
self.store
.list()
.map_err(|e: StorageError| TokenError::Storage(e.to_string()))
}
}
#[must_use]
pub fn constant_time_eq(a: &str, b: &str) -> bool {
use sha2::{Digest, Sha256};
let left = Sha256::digest(a.as_bytes());
let right = Sha256::digest(b.as_bytes());
let mut diff = 0u8;
for (x, y) in left.iter().zip(right.iter()) {
diff |= x ^ y;
}
diff == 0
}
#[derive(Debug)]
pub enum TokenError {
InvalidPrefix,
Expired,
Revoked,
Invalid(String),
InsufficientScope,
LimitExceeded,
Storage(String),
}
impl std::fmt::Display for TokenError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidPrefix => {
write!(f, "Token must start with '{TOKEN_PREFIX}' prefix")
}
Self::Expired => write!(f, "Token has expired"),
Self::Revoked => write!(f, "Token has been revoked"),
Self::Invalid(msg) => write!(f, "Invalid token: {msg}"),
Self::InsufficientScope => {
write!(f, "Token does not carry the '{ADMIN_SCOPE}' scope")
}
Self::LimitExceeded => {
write!(f, "Token has reached its request limit")
}
Self::Storage(msg) => write!(f, "Token storage error: {msg}"),
}
}
}
impl std::error::Error for TokenError {}
#[cfg(test)]
mod tests {
use super::*;
fn test_manager() -> TokenManager {
TokenManager::new("test-secret-for-unit-tests")
}
#[test]
fn test_issue_token_has_prefix() {
let mgr = test_manager();
let token = mgr.issue_token(24, "test").expect("should issue token");
assert!(token.starts_with(TOKEN_PREFIX));
}
#[test]
fn test_validate_valid_token() {
let mgr = test_manager();
let token = mgr.issue_token(24, "my-label").expect("should issue");
let claims = mgr.validate_token(&token).expect("should validate");
assert_eq!(claims.label, "my-label");
assert!(!claims.sub.is_empty());
}
#[test]
fn test_validate_wrong_prefix() {
let mgr = test_manager();
let result = mgr.validate_token("wrong_prefix_abc");
assert!(matches!(result, Err(TokenError::InvalidPrefix)));
}
#[test]
fn test_validate_invalid_jwt() {
let mgr = test_manager();
let result = mgr.validate_token("la_sk_not-a-valid-jwt");
assert!(matches!(result, Err(TokenError::Invalid(_))));
}
#[test]
fn test_revoke_token() {
let mgr = test_manager();
let token = mgr.issue_token(24, "revoke-me").expect("should issue");
let claims = mgr.validate_token(&token).expect("should validate first");
mgr.revoke_token(&claims.sub).expect("should revoke");
let result = mgr.validate_token(&token);
assert!(matches!(result, Err(TokenError::Revoked)));
}
#[test]
fn test_expired_token() {
let mgr = test_manager();
let token = mgr.issue_token(0, "expired").expect("should issue");
let result = mgr.validate_token(&token);
match result {
Ok(_) | Err(TokenError::Expired) => {} Err(e) => panic!("Unexpected error: {e}"),
}
}
#[test]
fn test_list_tokens_returns_records() {
let mgr = test_manager();
let _t1 = mgr.issue_token(1, "one").unwrap();
let _t2 = mgr.issue_token(1, "two").unwrap();
let list = mgr.list_tokens().unwrap();
assert_eq!(list.len(), 2);
let labels: Vec<_> = list.iter().map(|r| r.label.as_str()).collect();
assert!(labels.contains(&"one"));
assert!(labels.contains(&"two"));
}
#[test]
fn account_binding_is_available_during_request_routing() {
let mgr = test_manager();
let token = mgr.issue_token_for(1, "bound", Some("account-2")).unwrap();
let claims = mgr.validate_token(&token).unwrap();
assert_eq!(
mgr.account_for(&claims.sub).unwrap().as_deref(),
Some("account-2")
);
}
#[test]
fn test_unlimited_token_never_hits_budget() {
let mgr = test_manager();
let token = mgr.issue_token(24, "unlimited").unwrap();
let claims = mgr.validate_token(&token).unwrap();
for _ in 0..1000 {
mgr.enforce_request_budget(&claims.sub)
.expect("unlimited token must never be limited");
}
}
#[test]
fn test_request_budget_enforced() {
let mgr = test_manager();
let token = mgr
.issue_token_full(24, "capped", None, Some(3))
.expect("should issue capped token");
let claims = mgr.validate_token(&token).unwrap();
mgr.enforce_request_budget(&claims.sub).unwrap();
mgr.enforce_request_budget(&claims.sub).unwrap();
mgr.enforce_request_budget(&claims.sub).unwrap();
let r = mgr.enforce_request_budget(&claims.sub);
assert!(matches!(r, Err(TokenError::LimitExceeded)));
let rec = mgr
.list_tokens()
.unwrap()
.into_iter()
.find(|r| r.id == claims.sub)
.unwrap();
assert_eq!(rec.max_requests, Some(3));
assert_eq!(rec.used_requests, 3);
}
#[test]
fn test_budget_for_unknown_token_is_permitted() {
let mgr = test_manager();
mgr.enforce_request_budget("no-such-id").unwrap();
}
#[test]
fn test_persistent_store_roundtrip() {
use crate::storage::TextTokenStore;
let dir = tempfile::tempdir().unwrap();
let store: Arc<dyn TokenStore> =
Arc::new(TextTokenStore::open(dir.path().join("t.lino")).unwrap());
let mgr = TokenManager::with_store("k", Arc::clone(&store));
let tok = mgr.issue_token(1, "persisted").unwrap();
let claims = mgr.validate_token(&tok).unwrap();
let store2: Arc<dyn TokenStore> =
Arc::new(TextTokenStore::open(dir.path().join("t.lino")).unwrap());
let mgr2 = TokenManager::with_store("k", store2);
assert_eq!(mgr2.list_tokens().unwrap().len(), 1);
mgr2.revoke_token(&claims.sub).unwrap();
let store3: Arc<dyn TokenStore> =
Arc::new(TextTokenStore::open(dir.path().join("t.lino")).unwrap());
let mgr3 = TokenManager::with_store("k", store3);
let r = mgr3.validate_token(&tok);
assert!(matches!(r, Err(TokenError::Revoked)));
}
#[test]
fn test_admin_scope_is_carried_by_claims_and_records() {
let mgr = test_manager();
let token = mgr.issue_admin_token(1, "ops").expect("should issue");
let claims = mgr.validate_token(&token).expect("should validate");
assert!(claims.is_admin());
assert_eq!(claims.scope, ADMIN_SCOPE);
let records = mgr.list_tokens().expect("should list");
assert_eq!(records.len(), 1);
assert_eq!(records[0].scope, ADMIN_SCOPE);
}
#[test]
fn test_client_tokens_carry_no_scope() {
let mgr = test_manager();
let token = mgr.issue_token(1, "client").expect("should issue");
let claims = mgr.validate_token(&token).expect("should validate");
assert!(!claims.is_admin());
assert!(claims.scope.is_empty());
assert!(matches!(
mgr.validate_admin_token(&token),
Err(TokenError::InsufficientScope)
));
}
#[test]
fn test_has_active_admin_token_tracks_revocation_and_expiry() {
let mgr = test_manager();
assert!(!mgr.has_active_admin_token().expect("should query"));
mgr.issue_token(1, "client").expect("should issue");
assert!(
!mgr.has_active_admin_token().expect("should query"),
"client tokens must not satisfy the admin-credential check"
);
mgr.issue(&IssueRequest {
ttl_hours: -1,
label: "stale",
scope: ADMIN_SCOPE,
..IssueRequest::default()
})
.expect("should issue");
assert!(
!mgr.has_active_admin_token().expect("should query"),
"expired admin tokens must not count"
);
let token = mgr.issue_admin_token(1, "ops").expect("should issue");
assert!(mgr.has_active_admin_token().expect("should query"));
let claims = mgr.validate_token(&token).expect("should validate");
mgr.revoke_token(&claims.sub).expect("should revoke");
assert!(!mgr.has_active_admin_token().expect("should query"));
}
#[test]
fn test_rotate_admin_token_issues_a_replacement_and_revokes_the_old_one() {
let mgr = test_manager();
let old = mgr.issue_admin_token(1, "ops").expect("should issue");
let old_claims = mgr.validate_token(&old).expect("should validate");
let new = mgr
.rotate_admin_token(&old_claims.sub, 2, "ops-rotated")
.expect("should rotate");
let new_claims = mgr.validate_admin_token(&new).expect("should validate");
assert_eq!(new_claims.label, "ops-rotated");
assert_ne!(new_claims.sub, old_claims.sub);
assert!(matches!(mgr.validate_token(&old), Err(TokenError::Revoked)));
assert!(mgr.has_active_admin_token().expect("should query"));
}
#[test]
fn test_rotate_admin_token_rejects_an_unknown_subject() {
let mgr = test_manager();
let live = mgr.issue_admin_token(1, "ops").expect("should issue");
assert!(mgr.rotate_admin_token("not-an-id", 1, "typo").is_err());
assert!(mgr.validate_admin_token(&live).is_ok());
assert_eq!(mgr.list_tokens().expect("should list").len(), 1);
}
#[test]
fn test_constant_time_eq_matches_string_equality() {
assert!(constant_time_eq("", ""));
assert!(constant_time_eq("s3cret", "s3cret"));
assert!(!constant_time_eq("s3cret", "s3crev"));
assert!(!constant_time_eq("s3cret", "s3cre"));
assert!(!constant_time_eq("s3cre", "s3cret"));
}
}