Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 0 additions & 13 deletions .github/workflows/test-sdk-js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,19 +85,6 @@ jobs:
cd sdk-js
git checkout master || git checkout main

- name: Patch SDK JS workflows for stability
run: |
python3 - <<'PY'
from pathlib import Path

path = Path("sdk-js/examples/blobs/workflows/blobs-js.yml")
if path.exists():
content = path.read_text()
content = content.replace("seconds: 3", "seconds: 15")
content = content.replace("seconds: 6", "seconds: 15")
path.write_text(content)
PY

- name: Install SDK JS dependencies
working-directory: sdk-js
run: |
Expand Down
72 changes: 71 additions & 1 deletion crates/auth/src/auth/token/jwt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,30 @@ impl TokenManager {
}
}

async fn touch_key_last_activity(&self, key_id: &str) -> Result<(), AuthError> {
// Re-fetch key to avoid overwriting revocations with stale data.
let Some(mut key) = self
.key_manager
.get_key(key_id)
.await
.map_err(|e| AuthError::StorageError(e.to_string()))?
else {
return Ok(());
};

key.metadata.touch();
self.key_manager
.set_key(key_id, &key)
.await
.map_err(|e| AuthError::StorageError(e.to_string()))?;

Ok(())
}

/// Verify a JWT token from request headers
///
/// This method validates the token, checks for idle timeout, and updates the
/// last activity timestamp to implement sliding window session management.
pub async fn verify_token_from_headers(
&self,
headers: &HeaderMap,
Expand Down Expand Up @@ -365,6 +388,28 @@ impl TokenManager {
return Err(AuthError::InvalidToken("Key has been revoked".to_string()));
}

// Check for idle timeout - if the session has been inactive for too long, reject it
if key.metadata.is_idle(self.config.idle_timeout) {
tracing::debug!(
"Session for key {} has exceeded idle timeout of {} seconds",
claims.sub,
self.config.idle_timeout
);
return Err(AuthError::InvalidToken(
"Session has expired due to inactivity".to_string(),
));
}

// Update last activity timestamp (sliding window expiration)
if let Err(e) = self.touch_key_last_activity(&claims.sub).await {
// Log the error but don't fail the request - activity tracking is best-effort
tracing::warn!(
"Failed to update last activity for key {}: {}",
claims.sub,
e
);
}

Ok(AuthResponse {
is_valid: true,
key_id: claims.sub,
Expand Down Expand Up @@ -423,7 +468,7 @@ impl TokenManager {
let claims = self.verify_token(refresh_token).await?;

// Get the key and verify it's valid
let key = self
let mut key = self
.key_manager
.get_key(&claims.sub)
.await
Expand All @@ -440,6 +485,31 @@ impl TokenManager {
return Err(AuthError::InvalidToken("Key is not valid".to_string()));
}

// Check for idle timeout - if the session has been inactive for too long, reject it
if key.metadata.is_idle(self.config.idle_timeout) {
tracing::debug!(
"Session for key {} has exceeded idle timeout of {} seconds",
claims.sub,
self.config.idle_timeout
);
return Err(AuthError::InvalidToken(
"Session has expired due to inactivity".to_string(),
));
}

// Update last activity timestamp (sliding window expiration)
if let Err(e) = self.touch_key_last_activity(&claims.sub).await {
// Log the error but don't fail the refresh - activity tracking is best-effort
tracing::warn!(
"Failed to update last activity for key {}: {}",
claims.sub,
e
);
} else {
// Keep the in-memory key in sync for client rotation.
key.metadata.touch();
}

match key.key_type {
// For root tokens, simply generate new tokens with the same ID
KeyType::Root => {
Expand Down
9 changes: 9 additions & 0 deletions crates/auth/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ pub struct JwtConfig {
/// Refresh token expiry time in seconds (default: 30 days)
#[serde(default = "default_refresh_token_expiry")]
pub refresh_token_expiry: u64,

/// Idle timeout in seconds - sessions are revoked after this period of inactivity
/// (default: 30 minutes, set to 0 to disable idle timeout)
#[serde(default = "default_idle_timeout")]
pub idle_timeout: u64,
}

fn default_access_token_expiry() -> u64 {
Expand All @@ -69,6 +74,10 @@ fn default_refresh_token_expiry() -> u64 {
30 * 24 * 3600 // 30 days
}

fn default_idle_timeout() -> u64 {
30 * 60 // 30 minutes
}

/// Storage configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
Expand Down
1 change: 1 addition & 0 deletions crates/auth/src/embedded.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ pub fn default_config() -> AuthConfig {
issuer: "calimero-auth".to_string(),
access_token_expiry: 3600,
refresh_token_expiry: 2592000,
idle_timeout: 1800, // 30 minutes
},
storage: StorageConfig::RocksDB {
path: "auth".into(),
Expand Down
135 changes: 134 additions & 1 deletion crates/auth/src/storage/models/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,10 @@ pub struct KeyMetadata {
pub created_at: u64,
/// When the key was revoked
pub revoked_at: Option<u64>,
/// When the key was last used (for idle timeout tracking)
/// Defaults to created_at if not set (for backward compatibility with existing keys)
#[serde(default)]
pub last_activity: Option<u64>,
}

impl Default for KeyMetadata {
Expand All @@ -245,14 +249,143 @@ impl Default for KeyMetadata {
impl KeyMetadata {
/// Create new key metadata
pub fn new() -> Self {
let now = Utc::now().timestamp() as u64;
Self {
created_at: Utc::now().timestamp() as u64,
created_at: now,
revoked_at: None,
last_activity: Some(now),
}
}

/// Revoke the key
pub fn revoke(&mut self) {
self.revoked_at = Some(Utc::now().timestamp() as u64);
}

/// Update the last activity timestamp
pub fn touch(&mut self) {
self.last_activity = Some(Utc::now().timestamp() as u64);
}

/// Get the last activity timestamp, falling back to "now" for legacy keys
pub fn get_last_activity(&self) -> u64 {
self.last_activity
.unwrap_or_else(|| Utc::now().timestamp() as u64)
}

/// Check if the key has been idle for longer than the specified timeout
///
/// # Arguments
///
/// * `idle_timeout_secs` - The idle timeout in seconds (0 means disabled)
///
/// # Returns
///
/// * `bool` - true if the key is idle (exceeded timeout), false otherwise
pub fn is_idle(&self, idle_timeout_secs: u64) -> bool {
if idle_timeout_secs == 0 {
return false; // Idle timeout disabled
}
let now = Utc::now().timestamp() as u64;
let last_activity = self.last_activity.unwrap_or(now);
now.saturating_sub(last_activity) > idle_timeout_secs
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_key_metadata_new() {
let metadata = KeyMetadata::new();
assert!(metadata.revoked_at.is_none());
assert!(metadata.last_activity.is_some());
// last_activity should be approximately equal to created_at
assert_eq!(metadata.last_activity.unwrap(), metadata.created_at);
}

#[test]
fn test_key_metadata_touch() {
let mut metadata = KeyMetadata::new();
let original_activity = metadata.get_last_activity();
// Touch should update the last_activity
metadata.touch();
// Since we can't easily test time changes, just verify it's set
assert!(metadata.last_activity.is_some());
assert!(metadata.get_last_activity() >= original_activity);
}

#[test]
fn test_key_metadata_get_last_activity_with_value() {
let mut metadata = KeyMetadata::new();
metadata.last_activity = Some(12345);
assert_eq!(metadata.get_last_activity(), 12345);
}

#[test]
fn test_key_metadata_get_last_activity_fallback() {
let metadata = KeyMetadata {
created_at: 1,
revoked_at: None,
last_activity: None,
};
let before = Utc::now().timestamp() as u64;
let last_activity = metadata.get_last_activity();
let after = Utc::now().timestamp() as u64;
// Should fall back to a current timestamp for legacy keys
assert!(last_activity >= before);
assert!(last_activity <= after);
}

#[test]
fn test_key_metadata_is_idle_disabled() {
let mut metadata = KeyMetadata::new();
// Set last_activity to a very old timestamp
metadata.last_activity = Some(1);
// With idle_timeout of 0, should never be idle
assert!(!metadata.is_idle(0));
}

#[test]
fn test_key_metadata_is_idle_not_expired() {
let metadata = KeyMetadata::new();
// Just created, with a 30 minute timeout, should not be idle
assert!(!metadata.is_idle(30 * 60));
}

#[test]
fn test_key_metadata_is_idle_expired() {
let mut metadata = KeyMetadata::new();
// Set last_activity to 2 hours ago
let now = Utc::now().timestamp() as u64;
metadata.last_activity = Some(now.saturating_sub(2 * 60 * 60));
// With a 30 minute timeout, should be idle
assert!(metadata.is_idle(30 * 60));
}

#[test]
fn test_key_metadata_backward_compatibility() {
// Simulate a key from before idle timeout was added (no last_activity)
let metadata = KeyMetadata {
created_at: 1000,
revoked_at: None,
last_activity: None,
};
// Legacy keys should not be treated as idle immediately
assert!(!metadata.is_idle(30 * 60));
}

#[test]
fn test_key_is_valid_and_not_idle() {
let key = Key::new_root_key_with_permissions(
"test_pub_key".to_string(),
"near".to_string(),
vec!["admin".to_string()],
None,
);
assert!(key.is_valid());
// Newly created key should not be idle
assert!(!key.metadata.is_idle(30 * 60));
}
}
Loading
Loading