Files
owlen/crates/platform/credentials/src/lib.rs
vikingowl 5b0774958a feat(auth): add multi-provider authentication with secure credential storage
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>
2025-12-03 00:27:37 +01:00

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());
}
}