use kalamdb_commons::UserId;
use serde::{Deserialize, Serialize};
use crate::error::Result;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Credentials {
pub instance: String,
pub jwt_token: String,
#[serde(default)]
pub user: Option<UserId>,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub expires_at: Option<String>,
#[serde(default)]
pub server_url: Option<String>,
#[serde(default)]
pub refresh_token: Option<String>,
#[serde(default)]
pub refresh_expires_at: Option<String>,
}
impl Credentials {
pub fn new(instance: String, jwt_token: String) -> Self {
Self {
instance,
jwt_token,
user: None,
name: None,
email: None,
expires_at: None,
server_url: None,
refresh_token: None,
refresh_expires_at: None,
}
}
pub fn with_details(
instance: String,
jwt_token: String,
user: impl Into<UserId>,
expires_at: String,
server_url: Option<String>,
) -> Self {
Self {
instance,
jwt_token,
user: Some(user.into()),
name: None,
email: None,
expires_at: Some(expires_at),
server_url,
refresh_token: None,
refresh_expires_at: None,
}
}
pub fn with_refresh_token(
instance: String,
jwt_token: String,
user: impl Into<UserId>,
expires_at: String,
server_url: Option<String>,
refresh_token: Option<String>,
refresh_expires_at: Option<String>,
) -> Self {
Self {
instance,
jwt_token,
user: Some(user.into()),
name: None,
email: None,
expires_at: Some(expires_at),
server_url,
refresh_token,
refresh_expires_at,
}
}
pub fn with_identity_metadata(mut self, name: Option<String>, email: Option<String>) -> Self {
self.name = name.and_then(|value| {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
});
self.email = email.and_then(|value| {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
});
self
}
pub fn display_label(&self) -> Option<&str> {
self.name
.as_deref()
.or(self.email.as_deref())
.or_else(|| self.user.as_ref().map(UserId::as_str))
}
pub fn get_server_url(&self) -> &str {
self.server_url.as_deref().unwrap_or(&self.instance)
}
pub fn is_expired(&self) -> bool {
if let Some(expires_at) = &self.expires_at {
if let Ok(exp_ms) = crate::timestamp::parse_iso8601(expires_at) {
return exp_ms < crate::timestamp::now();
}
}
false
}
pub fn is_refresh_expired(&self) -> bool {
match (&self.refresh_token, &self.refresh_expires_at) {
(Some(_), Some(expires_at)) => {
if let Ok(exp_ms) = crate::timestamp::parse_iso8601(expires_at) {
exp_ms < crate::timestamp::now()
} else {
true
}
},
_ => true,
}
}
pub fn can_refresh(&self) -> bool {
self.refresh_token.is_some() && !self.is_refresh_expired()
}
}
pub trait CredentialStore {
fn get_credentials(&self, instance: &str) -> Result<Option<Credentials>>;
fn set_credentials(&mut self, credentials: &Credentials) -> Result<()>;
fn delete_credentials(&mut self, instance: &str) -> Result<()>;
fn list_instances(&self) -> Result<Vec<String>>;
fn has_credentials(&self, instance: &str) -> Result<bool> {
Ok(self.get_credentials(instance)?.is_some())
}
}
#[derive(Debug, Default, Clone)]
pub struct MemoryCredentialStore {
credentials: std::collections::HashMap<String, Credentials>,
}
impl MemoryCredentialStore {
pub fn new() -> Self {
Self {
credentials: std::collections::HashMap::new(),
}
}
}
impl CredentialStore for MemoryCredentialStore {
fn get_credentials(&self, instance: &str) -> Result<Option<Credentials>> {
Ok(self.credentials.get(instance).cloned())
}
fn set_credentials(&mut self, credentials: &Credentials) -> Result<()> {
self.credentials.insert(credentials.instance.clone(), credentials.clone());
Ok(())
}
fn delete_credentials(&mut self, instance: &str) -> Result<()> {
self.credentials.remove(instance);
Ok(())
}
fn list_instances(&self) -> Result<Vec<String>> {
Ok(self.credentials.keys().cloned().collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_credentials_creation() {
let creds = Credentials::new(
"local".to_string(),
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test".to_string(),
);
assert_eq!(creds.instance, "local");
assert_eq!(creds.jwt_token, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test");
assert_eq!(creds.user, None);
assert_eq!(creds.expires_at, None);
assert_eq!(creds.server_url, None);
assert_eq!(creds.get_server_url(), "local");
}
#[test]
fn test_credentials_with_details() {
let creds = Credentials::with_details(
"prod".to_string(),
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test".to_string(),
"alice".to_string(),
"2025-12-31T23:59:59Z".to_string(),
Some("https://db.example.com".to_string()),
);
assert_eq!(creds.instance, "prod");
assert_eq!(creds.user, Some(UserId::from("alice")));
assert_eq!(creds.expires_at, Some("2025-12-31T23:59:59Z".to_string()));
assert_eq!(creds.server_url, Some("https://db.example.com".to_string()));
assert_eq!(creds.get_server_url(), "https://db.example.com");
}
#[test]
fn test_credentials_expiry_check() {
let expired_creds = Credentials::with_details(
"test".to_string(),
"token".to_string(),
"user".to_string(),
"2020-01-01T00:00:00Z".to_string(),
None,
);
assert!(expired_creds.is_expired());
let valid_creds = Credentials::with_details(
"test".to_string(),
"token".to_string(),
"user".to_string(),
"2099-12-31T23:59:59Z".to_string(),
None,
);
assert!(!valid_creds.is_expired());
let no_expiry = Credentials::new("test".to_string(), "token".to_string());
assert!(!no_expiry.is_expired());
}
#[test]
fn test_memory_store_basic_operations() {
let mut store = MemoryCredentialStore::new();
assert_eq!(store.get_credentials("local").unwrap(), None);
assert!(!store.has_credentials("local").unwrap());
let creds = Credentials::new("local".to_string(), "jwt_token_here".to_string());
store.set_credentials(&creds).unwrap();
let retrieved = store.get_credentials("local").unwrap();
assert_eq!(retrieved, Some(creds.clone()));
assert!(store.has_credentials("local").unwrap());
store.delete_credentials("local").unwrap();
assert_eq!(store.get_credentials("local").unwrap(), None);
}
#[test]
fn test_memory_store_multiple_instances() {
let mut store = MemoryCredentialStore::new();
let creds1 = Credentials::with_details(
"local".to_string(),
"token1".to_string(),
"alice".to_string(),
"2099-12-31T23:59:59Z".to_string(),
None,
);
let creds2 = Credentials::with_details(
"prod".to_string(),
"token2".to_string(),
"bob".to_string(),
"2099-12-31T23:59:59Z".to_string(),
None,
);
let creds3 = Credentials::with_details(
"dev".to_string(),
"token3".to_string(),
"carol".to_string(),
"2099-12-31T23:59:59Z".to_string(),
None,
);
store.set_credentials(&creds1).unwrap();
store.set_credentials(&creds2).unwrap();
store.set_credentials(&creds3).unwrap();
let instances = store.list_instances().unwrap();
assert_eq!(instances.len(), 3);
assert!(instances.contains(&"local".to_string()));
assert!(instances.contains(&"prod".to_string()));
assert!(instances.contains(&"dev".to_string()));
assert_eq!(
store.get_credentials("local").unwrap().unwrap().user,
Some(UserId::from("alice"))
);
assert_eq!(store.get_credentials("prod").unwrap().unwrap().user, Some(UserId::from("bob")));
assert_eq!(
store.get_credentials("dev").unwrap().unwrap().user,
Some(UserId::from("carol"))
);
}
#[test]
fn test_memory_store_overwrite() {
let mut store = MemoryCredentialStore::new();
let creds1 = Credentials::new("local".to_string(), "old_token".to_string());
let creds2 = Credentials::new("local".to_string(), "new_token".to_string());
store.set_credentials(&creds1).unwrap();
store.set_credentials(&creds2).unwrap();
let retrieved = store.get_credentials("local").unwrap().unwrap();
assert_eq!(retrieved.jwt_token, "new_token");
}
#[test]
fn test_credentials_serialization() {
let creds = Credentials::with_details(
"prod".to_string(),
"eyJhbGciOiJIUzI1NiJ9.test".to_string(),
"alice".to_string(),
"2099-12-31T23:59:59Z".to_string(),
Some("https://db.example.com".to_string()),
);
let json = serde_json::to_string(&creds).unwrap();
let deserialized: Credentials = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, creds);
}
#[test]
fn test_credentials_with_refresh_token() {
let creds = Credentials::with_refresh_token(
"prod".to_string(),
"access_token".to_string(),
"alice".to_string(),
"2099-01-01T00:00:00Z".to_string(),
Some("https://db.example.com".to_string()),
Some("refresh_token".to_string()),
Some("2099-01-08T00:00:00Z".to_string()),
);
assert_eq!(creds.refresh_token, Some("refresh_token".to_string()));
assert_eq!(creds.refresh_expires_at, Some("2099-01-08T00:00:00Z".to_string()));
assert!(!creds.is_expired());
assert!(!creds.is_refresh_expired());
assert!(creds.can_refresh());
}
#[test]
fn test_credentials_refresh_expired() {
let creds = Credentials::with_refresh_token(
"test".to_string(),
"access_token".to_string(),
"user".to_string(),
"2099-12-31T23:59:59Z".to_string(),
None,
Some("old_refresh_token".to_string()),
Some("2020-01-01T00:00:00Z".to_string()), );
assert!(!creds.is_expired());
assert!(creds.is_refresh_expired());
assert!(!creds.can_refresh());
}
#[test]
fn test_credentials_no_refresh_token() {
let creds = Credentials::with_details(
"test".to_string(),
"access_token".to_string(),
"user".to_string(),
"2020-01-01T00:00:00Z".to_string(), None,
);
assert!(creds.is_expired());
assert!(creds.is_refresh_expired()); assert!(!creds.can_refresh());
}
}