Authentication System: - Add credentials crate with keyring (OS keychain) and file fallback storage - Add auth-manager crate for unified auth across providers - Implement API key login flow for Anthropic, OpenAI, and Ollama Cloud - Add CLI commands: login, logout, auth (status) - Store credentials securely in macOS Keychain / GNOME Keyring / Windows Credential Manager API Key Helpers: - Support for password manager integration (1Password, Bitwarden, pass, AWS Secrets, Vault) - Command-based helpers with TTL caching - Priority chain: env vars → helpers → cache → stored credentials Background Token Refresh: - Automatic OAuth token refresh before expiration - Configurable check interval and refresh threshold MCP OAuth Support: - Add OAuth config to MCP server definitions - Support for SSE/HTTP transport with OAuth - Token storage with mcp: prefix Bug Fixes: - Fix keyring crate requiring explicit backend features (was using mock store) - Fix provider index not updated on credential store - Add User-Agent headers to avoid Cloudflare blocks 🤖 Generated with [Claude Code](https://claude.ai/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
392 lines
12 KiB
Rust
392 lines
12 KiB
Rust
//! Secure Credential Storage
|
|
//!
|
|
//! Provides cross-platform credential storage with keyring (OS keychain) as primary
|
|
//! storage and encrypted file as fallback.
|
|
//!
|
|
//! # Architecture
|
|
//!
|
|
//! ```text
|
|
//! CredentialManager
|
|
//! ├── KeyringStore (primary) - macOS Keychain, Linux secret-service, Windows Credential Manager
|
|
//! └── FileStore (fallback) - Encrypted JSON file in ~/.config/owlen/credentials.json
|
|
//! ```
|
|
//!
|
|
//! # Usage
|
|
//!
|
|
//! ```rust,ignore
|
|
//! use credentials::CredentialManager;
|
|
//! use llm_core::StoredCredentials;
|
|
//!
|
|
//! let manager = CredentialManager::new()?;
|
|
//!
|
|
//! // Store credentials
|
|
//! let creds = StoredCredentials {
|
|
//! provider: "anthropic".to_string(),
|
|
//! access_token: "sk-...".to_string(),
|
|
//! refresh_token: None,
|
|
//! expires_at: None,
|
|
//! };
|
|
//! manager.store("anthropic", creds)?;
|
|
//!
|
|
//! // Retrieve credentials
|
|
//! if let Some(creds) = manager.retrieve("anthropic")? {
|
|
//! println!("Found token for {}", creds.provider);
|
|
//! }
|
|
//! ```
|
|
|
|
mod file;
|
|
pub mod helpers;
|
|
mod keyring_store;
|
|
|
|
pub use file::FileStore;
|
|
pub use helpers::{
|
|
ApiKeyHelper, CommandHelper, HelperConfig, HelperError, HelperManager,
|
|
one_password_helper, bitwarden_helper, pass_helper, aws_secrets_helper, vault_helper,
|
|
};
|
|
pub use keyring_store::KeyringStore;
|
|
|
|
use llm_core::StoredCredentials;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
use thiserror::Error;
|
|
|
|
// ============================================================================
|
|
// Error Types
|
|
// ============================================================================
|
|
|
|
/// Errors that can occur during credential operations
|
|
#[derive(Error, Debug)]
|
|
pub enum CredentialError {
|
|
#[error("Keyring error: {0}")]
|
|
Keyring(String),
|
|
|
|
#[error("File storage error: {0}")]
|
|
FileStorage(String),
|
|
|
|
#[error("Serialization error: {0}")]
|
|
Serialization(#[from] serde_json::Error),
|
|
|
|
#[error("IO error: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
|
|
#[error("Credential not found for provider: {0}")]
|
|
NotFound(String),
|
|
|
|
#[error("Storage unavailable: {0}")]
|
|
Unavailable(String),
|
|
}
|
|
|
|
pub type Result<T> = std::result::Result<T, CredentialError>;
|
|
|
|
// ============================================================================
|
|
// Credential Store Trait
|
|
// ============================================================================
|
|
|
|
/// Trait for credential storage backends
|
|
pub trait CredentialStore: Send + Sync {
|
|
/// Store credentials for a provider
|
|
fn store(&self, provider: &str, credentials: &StoredCredentials) -> Result<()>;
|
|
|
|
/// Retrieve credentials for a provider
|
|
fn retrieve(&self, provider: &str) -> Result<Option<StoredCredentials>>;
|
|
|
|
/// Delete credentials for a provider
|
|
fn delete(&self, provider: &str) -> Result<()>;
|
|
|
|
/// List all providers with stored credentials
|
|
fn list_providers(&self) -> Result<Vec<String>>;
|
|
|
|
/// Check if this storage backend is available
|
|
fn is_available(&self) -> bool;
|
|
|
|
/// Get a human-readable name for this storage backend
|
|
fn name(&self) -> &'static str;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Extended Credentials
|
|
// ============================================================================
|
|
|
|
/// Extended credentials with additional metadata for storage management
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
|
pub struct ExtendedCredentials {
|
|
/// The core credentials
|
|
#[serde(flatten)]
|
|
pub credentials: StoredCredentials,
|
|
|
|
/// When the credentials were stored (Unix timestamp)
|
|
pub created_at: u64,
|
|
|
|
/// When the credentials were last used (Unix timestamp)
|
|
pub last_used_at: Option<u64>,
|
|
}
|
|
|
|
impl ExtendedCredentials {
|
|
/// Create new extended credentials from base credentials
|
|
pub fn new(credentials: StoredCredentials) -> Self {
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0);
|
|
|
|
Self {
|
|
credentials,
|
|
created_at: now,
|
|
last_used_at: None,
|
|
}
|
|
}
|
|
|
|
/// Check if the token is expired
|
|
pub fn is_expired(&self) -> bool {
|
|
if let Some(expires_at) = self.credentials.expires_at {
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0);
|
|
expires_at <= now
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Check if the token needs refresh (expires within 5 minutes)
|
|
pub fn needs_refresh(&self) -> bool {
|
|
if let Some(expires_at) = self.credentials.expires_at {
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0);
|
|
expires_at <= now + 300 // 5 minutes buffer
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Update the last used timestamp
|
|
pub fn touch(&mut self) {
|
|
self.last_used_at = Some(
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0),
|
|
);
|
|
}
|
|
}
|
|
|
|
impl From<StoredCredentials> for ExtendedCredentials {
|
|
fn from(credentials: StoredCredentials) -> Self {
|
|
Self::new(credentials)
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Credential Manager
|
|
// ============================================================================
|
|
|
|
/// Manages credential storage with fallback support
|
|
///
|
|
/// Tries keyring storage first (OS keychain), falls back to encrypted file storage.
|
|
pub struct CredentialManager {
|
|
/// Primary storage (keyring)
|
|
primary: Box<dyn CredentialStore>,
|
|
|
|
/// Fallback storage (file)
|
|
fallback: Option<Box<dyn CredentialStore>>,
|
|
}
|
|
|
|
impl CredentialManager {
|
|
/// Create a new credential manager with keyring-first, file-fallback strategy
|
|
pub fn new() -> Result<Self> {
|
|
let keyring_store = KeyringStore::new();
|
|
let file_store = FileStore::new()?;
|
|
|
|
if keyring_store.is_available() {
|
|
Ok(Self {
|
|
primary: Box::new(keyring_store),
|
|
fallback: Some(Box::new(file_store)),
|
|
})
|
|
} else {
|
|
// Keyring not available, use file as primary
|
|
Ok(Self {
|
|
primary: Box::new(file_store),
|
|
fallback: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Create a credential manager with only keyring storage (no fallback)
|
|
pub fn keyring_only() -> Result<Self> {
|
|
let keyring_store = KeyringStore::new();
|
|
if !keyring_store.is_available() {
|
|
return Err(CredentialError::Unavailable(
|
|
"Keyring not available on this system".to_string(),
|
|
));
|
|
}
|
|
Ok(Self {
|
|
primary: Box::new(keyring_store),
|
|
fallback: None,
|
|
})
|
|
}
|
|
|
|
/// Create a credential manager with only file storage
|
|
pub fn file_only() -> Result<Self> {
|
|
Ok(Self {
|
|
primary: Box::new(FileStore::new()?),
|
|
fallback: None,
|
|
})
|
|
}
|
|
|
|
/// Get the name of the active storage backend
|
|
pub fn storage_name(&self) -> &'static str {
|
|
self.primary.name()
|
|
}
|
|
|
|
/// Store credentials for a provider
|
|
pub fn store(&self, provider: &str, credentials: StoredCredentials) -> Result<()> {
|
|
// Try primary storage first
|
|
match self.primary.store(provider, &credentials) {
|
|
Ok(()) => Ok(()),
|
|
Err(primary_error) => {
|
|
// If primary fails and we have a fallback, try it
|
|
if let Some(ref fallback) = self.fallback {
|
|
fallback.store(provider, &credentials).map_err(|fallback_error| {
|
|
CredentialError::Unavailable(format!(
|
|
"Primary ({}) failed: {}; Fallback ({}) failed: {}",
|
|
self.primary.name(),
|
|
primary_error,
|
|
fallback.name(),
|
|
fallback_error
|
|
))
|
|
})
|
|
} else {
|
|
Err(primary_error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Retrieve credentials for a provider
|
|
pub fn retrieve(&self, provider: &str) -> Result<Option<StoredCredentials>> {
|
|
// Try primary storage first
|
|
match self.primary.retrieve(provider) {
|
|
Ok(Some(creds)) => Ok(Some(creds)),
|
|
Ok(None) => {
|
|
// Not in primary, try fallback
|
|
if let Some(ref fallback) = self.fallback {
|
|
fallback.retrieve(provider)
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
Err(primary_error) => {
|
|
// Primary failed, try fallback
|
|
if let Some(ref fallback) = self.fallback {
|
|
fallback.retrieve(provider)
|
|
} else {
|
|
Err(primary_error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Delete credentials for a provider
|
|
pub fn delete(&self, provider: &str) -> Result<()> {
|
|
// Delete from primary
|
|
let primary_result = self.primary.delete(provider);
|
|
|
|
// Also delete from fallback if it exists
|
|
if let Some(ref fallback) = self.fallback {
|
|
let _ = fallback.delete(provider); // Ignore fallback errors
|
|
}
|
|
|
|
primary_result
|
|
}
|
|
|
|
/// List all providers with stored credentials
|
|
pub fn list_providers(&self) -> Result<Vec<String>> {
|
|
let mut providers = self.primary.list_providers()?;
|
|
|
|
// Also check fallback
|
|
if let Some(ref fallback) = self.fallback {
|
|
if let Ok(fallback_providers) = fallback.list_providers() {
|
|
for provider in fallback_providers {
|
|
if !providers.contains(&provider) {
|
|
providers.push(provider);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(providers)
|
|
}
|
|
|
|
/// Check if credentials exist for a provider
|
|
pub fn has_credentials(&self, provider: &str) -> bool {
|
|
self.retrieve(provider).ok().flatten().is_some()
|
|
}
|
|
}
|
|
|
|
impl Default for CredentialManager {
|
|
fn default() -> Self {
|
|
Self::new().expect("Failed to create credential manager")
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Tests
|
|
// ============================================================================
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_extended_credentials_expiry() {
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs();
|
|
|
|
// Not expired - expires in 1 hour
|
|
let creds = ExtendedCredentials::new(StoredCredentials {
|
|
provider: "test".to_string(),
|
|
access_token: "token".to_string(),
|
|
refresh_token: None,
|
|
expires_at: Some(now + 3600),
|
|
});
|
|
assert!(!creds.is_expired());
|
|
assert!(!creds.needs_refresh());
|
|
|
|
// Needs refresh - expires in 2 minutes
|
|
let creds = ExtendedCredentials::new(StoredCredentials {
|
|
provider: "test".to_string(),
|
|
access_token: "token".to_string(),
|
|
refresh_token: None,
|
|
expires_at: Some(now + 120),
|
|
});
|
|
assert!(!creds.is_expired());
|
|
assert!(creds.needs_refresh());
|
|
|
|
// Expired
|
|
let creds = ExtendedCredentials::new(StoredCredentials {
|
|
provider: "test".to_string(),
|
|
access_token: "token".to_string(),
|
|
refresh_token: None,
|
|
expires_at: Some(now - 60),
|
|
});
|
|
assert!(creds.is_expired());
|
|
assert!(creds.needs_refresh());
|
|
}
|
|
|
|
#[test]
|
|
fn test_extended_credentials_no_expiry() {
|
|
let creds = ExtendedCredentials::new(StoredCredentials {
|
|
provider: "test".to_string(),
|
|
access_token: "token".to_string(),
|
|
refresh_token: None,
|
|
expires_at: None,
|
|
});
|
|
assert!(!creds.is_expired());
|
|
assert!(!creds.needs_refresh());
|
|
}
|
|
}
|