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>
This commit is contained in:
@@ -7,7 +7,9 @@ members = [
|
||||
"crates/llm/anthropic",
|
||||
"crates/llm/ollama",
|
||||
"crates/llm/openai",
|
||||
"crates/platform/auth",
|
||||
"crates/platform/config",
|
||||
"crates/platform/credentials",
|
||||
"crates/platform/hooks",
|
||||
"crates/platform/permissions",
|
||||
"crates/platform/plugins",
|
||||
|
||||
@@ -14,9 +14,12 @@ color-eyre = "0.6"
|
||||
agent-core = { path = "../../core/agent" }
|
||||
llm-core = { path = "../../llm/core" }
|
||||
llm-ollama = { path = "../../llm/ollama" }
|
||||
llm-anthropic = { path = "../../llm/anthropic" }
|
||||
llm-openai = { path = "../../llm/openai" }
|
||||
tools-fs = { path = "../../tools/fs" }
|
||||
tools-bash = { path = "../../tools/bash" }
|
||||
tools-slash = { path = "../../tools/slash" }
|
||||
auth-manager = { path = "../../platform/auth" }
|
||||
config-agent = { package = "config-agent", path = "../../platform/config" }
|
||||
permissions = { path = "../../platform/permissions" }
|
||||
hooks = { path = "../../platform/hooks" }
|
||||
@@ -24,6 +27,8 @@ plugins = { path = "../../platform/plugins" }
|
||||
ui = { path = "../ui" }
|
||||
atty = "0.2"
|
||||
futures-util = "0.3.31"
|
||||
rpassword = "7"
|
||||
open = "5"
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2.0"
|
||||
|
||||
@@ -4,12 +4,15 @@ use clap::{Parser, ValueEnum};
|
||||
use color_eyre::eyre::{Result, eyre};
|
||||
use config_agent::load_settings;
|
||||
use hooks::{HookEvent, HookManager, HookResult};
|
||||
use llm_core::ChatOptions;
|
||||
use llm_core::{AuthMethod, ChatOptions, LlmProvider, ProviderType};
|
||||
use llm_anthropic::AnthropicClient;
|
||||
use llm_ollama::OllamaClient;
|
||||
use llm_openai::OpenAIClient;
|
||||
use permissions::{PermissionDecision, Tool};
|
||||
use plugins::PluginManager;
|
||||
use serde::Serialize;
|
||||
use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub use commands::{BuiltinCommands, CommandResult};
|
||||
@@ -107,6 +110,81 @@ fn generate_session_id() -> String {
|
||||
format!("session-{}", timestamp)
|
||||
}
|
||||
|
||||
/// Create an LLM provider based on settings and CLI arguments
|
||||
fn create_provider(
|
||||
settings: &config_agent::Settings,
|
||||
model_override: Option<&str>,
|
||||
api_key_override: Option<&str>,
|
||||
ollama_url_override: Option<&str>,
|
||||
) -> Result<(Arc<dyn LlmProvider>, String)> {
|
||||
// Determine which provider to use
|
||||
let provider_type = settings.get_provider().unwrap_or(ProviderType::Ollama);
|
||||
|
||||
// Get or create auth manager
|
||||
let auth_manager = auth_manager::AuthManager::new()
|
||||
.map_err(|e| eyre!("Failed to initialize auth manager: {}", e))?;
|
||||
|
||||
// Get authentication for this provider
|
||||
let auth = auth_manager.get_auth(provider_type);
|
||||
|
||||
// Determine the model to use
|
||||
let model = model_override
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| settings.get_effective_model().to_string());
|
||||
|
||||
match provider_type {
|
||||
ProviderType::Ollama => {
|
||||
// Handle Ollama Cloud vs local
|
||||
let api_key = api_key_override
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| settings.api_key.clone());
|
||||
|
||||
let use_cloud = model.ends_with("-cloud") && api_key.is_some();
|
||||
|
||||
let client = if use_cloud {
|
||||
OllamaClient::with_cloud().with_api_key(api_key.unwrap())
|
||||
} else {
|
||||
let base_url = ollama_url_override
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| settings.ollama_url.clone());
|
||||
let mut client = OllamaClient::new(base_url);
|
||||
if let Some(key) = api_key {
|
||||
client = client.with_api_key(key);
|
||||
}
|
||||
client
|
||||
};
|
||||
|
||||
Ok((Arc::new(client) as Arc<dyn LlmProvider>, model))
|
||||
}
|
||||
ProviderType::Anthropic => {
|
||||
// Try CLI override, then auth manager, then settings
|
||||
let auth_method = api_key_override
|
||||
.map(|k| AuthMethod::ApiKey(k.to_string()))
|
||||
.or_else(|| auth.ok())
|
||||
.or_else(|| settings.anthropic_api_key.clone().map(AuthMethod::ApiKey))
|
||||
.ok_or_else(|| eyre!(
|
||||
"Anthropic requires authentication. Run 'owlen login anthropic' or set ANTHROPIC_API_KEY"
|
||||
))?;
|
||||
|
||||
let client = AnthropicClient::with_auth(auth_method).with_model(&model);
|
||||
Ok((Arc::new(client) as Arc<dyn LlmProvider>, model))
|
||||
}
|
||||
ProviderType::OpenAI => {
|
||||
// Try CLI override, then auth manager, then settings
|
||||
let auth_method = api_key_override
|
||||
.map(|k| AuthMethod::ApiKey(k.to_string()))
|
||||
.or_else(|| auth.ok())
|
||||
.or_else(|| settings.openai_api_key.clone().map(AuthMethod::ApiKey))
|
||||
.ok_or_else(|| eyre!(
|
||||
"OpenAI requires authentication. Run 'owlen login openai' or set OPENAI_API_KEY"
|
||||
))?;
|
||||
|
||||
let client = OpenAIClient::with_auth(auth_method).with_model(&model);
|
||||
Ok((Arc::new(client) as Arc<dyn LlmProvider>, model))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn output_tool_result(
|
||||
format: OutputFormat,
|
||||
tool: &str,
|
||||
@@ -181,6 +259,18 @@ enum Cmd {
|
||||
Edit { path: String, old_string: String, new_string: String },
|
||||
Bash { command: String, #[arg(long)] timeout: Option<u64> },
|
||||
Slash { command_name: String, args: Vec<String> },
|
||||
/// Authenticate with an LLM provider (anthropic, openai)
|
||||
Login {
|
||||
/// Provider to authenticate with (anthropic, openai)
|
||||
provider: String,
|
||||
},
|
||||
/// Remove stored credentials for a provider
|
||||
Logout {
|
||||
/// Provider to log out from (anthropic, openai, ollama)
|
||||
provider: String,
|
||||
},
|
||||
/// Show authentication status for all providers
|
||||
Auth,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -498,30 +588,185 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Cmd::Login { provider } => {
|
||||
let provider_type = llm_core::ProviderType::from_str(&provider)
|
||||
.ok_or_else(|| eyre!(
|
||||
"Unknown provider: {}. Supported: anthropic, openai, ollama",
|
||||
provider
|
||||
))?;
|
||||
|
||||
let auth_manager = auth_manager::AuthManager::new()
|
||||
.map_err(|e| eyre!("Failed to initialize auth manager: {}", e))?;
|
||||
|
||||
// Check if OAuth is available for this provider
|
||||
let oauth_available = match provider_type {
|
||||
llm_core::ProviderType::Anthropic => llm_anthropic::AnthropicAuth::is_oauth_available(),
|
||||
llm_core::ProviderType::OpenAI => llm_openai::OpenAIAuth::is_oauth_available(),
|
||||
llm_core::ProviderType::Ollama => false,
|
||||
};
|
||||
|
||||
if oauth_available {
|
||||
// Use OAuth device flow
|
||||
println!("Starting OAuth login for {}...", provider);
|
||||
|
||||
auth_manager.login(provider_type).await
|
||||
.map_err(|e| eyre!("Login failed: {}", e))?;
|
||||
|
||||
println!("\n✅ Successfully logged in to {}!", provider);
|
||||
} else {
|
||||
// OAuth not available, prompt for API key
|
||||
println!("\n🔐 {} Login\n", provider.to_uppercase());
|
||||
|
||||
// Show provider-specific instructions
|
||||
let (console_url, local_note) = match provider_type {
|
||||
llm_core::ProviderType::Anthropic => (
|
||||
"https://console.anthropic.com/settings/keys",
|
||||
None,
|
||||
),
|
||||
llm_core::ProviderType::OpenAI => (
|
||||
"https://platform.openai.com/api-keys",
|
||||
None,
|
||||
),
|
||||
llm_core::ProviderType::Ollama => (
|
||||
"https://ollama.com/settings/keys",
|
||||
Some("Note: Local Ollama doesn't require authentication.\nThis is for Ollama Cloud access.\n"),
|
||||
),
|
||||
};
|
||||
|
||||
if let Some(note) = local_note {
|
||||
println!("{}", note);
|
||||
}
|
||||
|
||||
println!("Get your API key from: {}\n", console_url);
|
||||
|
||||
// Offer to open browser
|
||||
print!("Open browser to get API key? [Y/n]: ");
|
||||
std::io::Write::flush(&mut std::io::stdout())?;
|
||||
|
||||
let mut open_browser = String::new();
|
||||
std::io::stdin().read_line(&mut open_browser)?;
|
||||
let open_browser = open_browser.trim().to_lowercase();
|
||||
|
||||
if open_browser.is_empty() || open_browser == "y" || open_browser == "yes" {
|
||||
if let Err(e) = open::that(console_url) {
|
||||
println!("Could not open browser: {}", e);
|
||||
} else {
|
||||
println!("Opening browser...\n");
|
||||
}
|
||||
}
|
||||
|
||||
println!("Enter your API key below (input is hidden):\n");
|
||||
|
||||
// Prompt for API key
|
||||
print!("API Key: ");
|
||||
std::io::Write::flush(&mut std::io::stdout())?;
|
||||
|
||||
// Read API key (hide input)
|
||||
let api_key = rpassword::read_password()
|
||||
.map_err(|e| eyre!("Failed to read API key: {}", e))?;
|
||||
|
||||
let api_key = api_key.trim();
|
||||
if api_key.is_empty() {
|
||||
return Err(eyre!("API key cannot be empty. Login cancelled."));
|
||||
}
|
||||
|
||||
// Validate API key format (basic check)
|
||||
match provider_type {
|
||||
llm_core::ProviderType::Anthropic => {
|
||||
if !api_key.starts_with("sk-ant-") {
|
||||
println!("\n⚠️ Warning: Anthropic API keys typically start with 'sk-ant-'");
|
||||
}
|
||||
}
|
||||
llm_core::ProviderType::OpenAI => {
|
||||
if !api_key.starts_with("sk-") {
|
||||
println!("\n⚠️ Warning: OpenAI API keys typically start with 'sk-'");
|
||||
}
|
||||
}
|
||||
llm_core::ProviderType::Ollama => {
|
||||
// Ollama Cloud API keys - no specific format validation
|
||||
}
|
||||
}
|
||||
|
||||
// Store the API key
|
||||
auth_manager.store_api_key(provider_type, api_key)
|
||||
.map_err(|e| eyre!("Failed to store API key: {}", e))?;
|
||||
|
||||
println!("\n✅ API key stored successfully!");
|
||||
}
|
||||
|
||||
println!("Credentials stored in: {}", auth_manager.storage_name());
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
Cmd::Logout { provider } => {
|
||||
let provider_type = llm_core::ProviderType::from_str(&provider)
|
||||
.ok_or_else(|| eyre!(
|
||||
"Unknown provider: {}. Supported: anthropic, openai, ollama",
|
||||
provider
|
||||
))?;
|
||||
|
||||
let auth_manager = auth_manager::AuthManager::new()
|
||||
.map_err(|e| eyre!("Failed to initialize auth manager: {}", e))?;
|
||||
|
||||
auth_manager.logout(provider_type)
|
||||
.map_err(|e| eyre!("Logout failed: {}", e))?;
|
||||
|
||||
println!("Successfully logged out from {}.", provider);
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
Cmd::Auth => {
|
||||
let auth_manager = auth_manager::AuthManager::new()
|
||||
.map_err(|e| eyre!("Failed to initialize auth manager: {}", e))?;
|
||||
|
||||
println!("\n🔐 Authentication Status\n");
|
||||
println!("Storage: {}\n", auth_manager.storage_name());
|
||||
|
||||
println!("{:<12} {:<15} {:<30}", "Provider", "Status", "Details");
|
||||
println!("{}", "-".repeat(57));
|
||||
|
||||
for status in auth_manager.status() {
|
||||
let status_icon = if status.authenticated { "✅" } else { "❌" };
|
||||
let status_text = if status.authenticated { "Authenticated" } else { "Not authenticated" };
|
||||
let details = status.message.unwrap_or_else(|| "-".to_string());
|
||||
|
||||
println!(
|
||||
"{:<12} {} {:<12} {}",
|
||||
status.provider,
|
||||
status_icon,
|
||||
status_text,
|
||||
details
|
||||
);
|
||||
}
|
||||
|
||||
println!("\nTo authenticate: owlen login <provider>");
|
||||
println!("To logout: owlen logout <provider>");
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let model = args.model.unwrap_or(settings.model.clone());
|
||||
let api_key = args.api_key.or(settings.api_key.clone());
|
||||
|
||||
// Use Ollama Cloud when model has "-cloud" suffix AND API key is set
|
||||
let use_cloud = model.ends_with("-cloud") && api_key.is_some();
|
||||
let client = if use_cloud {
|
||||
OllamaClient::with_cloud().with_api_key(api_key.unwrap())
|
||||
} else {
|
||||
let base_url = args.ollama_url.unwrap_or(settings.ollama_url.clone());
|
||||
let mut client = OllamaClient::new(base_url);
|
||||
if let Some(key) = api_key {
|
||||
client = client.with_api_key(key);
|
||||
}
|
||||
client
|
||||
};
|
||||
let opts = ChatOptions::new(model);
|
||||
// Create provider based on settings and CLI args
|
||||
let (client, model) = create_provider(
|
||||
&settings,
|
||||
args.model.as_deref(),
|
||||
args.api_key.as_deref(),
|
||||
args.ollama_url.as_deref(),
|
||||
)?;
|
||||
let opts = ChatOptions::new(&model);
|
||||
|
||||
// Check if interactive mode (no prompt provided)
|
||||
if args.prompt.is_empty() {
|
||||
// Use TUI mode unless --no-tui flag is set or not a TTY
|
||||
if !args.no_tui && atty::is(atty::Stream::Stdout) {
|
||||
// Start background token refresh for long-running TUI sessions
|
||||
let auth_manager = Arc::new(
|
||||
auth_manager::AuthManager::new()
|
||||
.map_err(|e| eyre!("Failed to initialize auth manager: {}", e))?
|
||||
);
|
||||
let _token_refresher = auth_manager.clone().start_background_refresh();
|
||||
|
||||
// Launch TUI
|
||||
// Note: For now, TUI doesn't use plugin manager directly
|
||||
// In the future, we'll integrate plugin commands into TUI
|
||||
|
||||
@@ -16,8 +16,7 @@ use crossterm::{
|
||||
ExecutableCommand,
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use llm_core::{ChatMessage as LLMChatMessage, ChatOptions};
|
||||
use llm_ollama::OllamaClient;
|
||||
use llm_core::{ChatMessage as LLMChatMessage, ChatOptions, LlmProvider};
|
||||
use permissions::{Action, PermissionDecision, PermissionManager, Tool as PermTool};
|
||||
use ratatui::{
|
||||
backend::CrosstermBackend,
|
||||
@@ -28,10 +27,11 @@ use ratatui::{
|
||||
Terminal,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{io::stdout, path::PathBuf, time::SystemTime};
|
||||
use std::{io::stdout, path::PathBuf, sync::Arc, time::SystemTime};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// Holds information about a pending tool execution
|
||||
#[allow(dead_code)] // Fields used for permission popup display
|
||||
struct PendingToolCall {
|
||||
tool_name: String,
|
||||
arguments: Value,
|
||||
@@ -57,9 +57,10 @@ pub struct TuiApp {
|
||||
todo_list: TodoList,
|
||||
|
||||
// System state
|
||||
client: OllamaClient,
|
||||
client: Arc<dyn LlmProvider>,
|
||||
opts: ChatOptions,
|
||||
perms: PermissionManager,
|
||||
#[allow(dead_code)] // Reserved for tool execution context
|
||||
ctx: ToolContext,
|
||||
#[allow(dead_code)]
|
||||
settings: config_agent::Settings,
|
||||
@@ -74,7 +75,7 @@ pub struct TuiApp {
|
||||
|
||||
impl TuiApp {
|
||||
pub fn new(
|
||||
client: OllamaClient,
|
||||
client: Arc<dyn LlmProvider>,
|
||||
opts: ChatOptions,
|
||||
perms: PermissionManager,
|
||||
settings: config_agent::Settings,
|
||||
@@ -560,12 +561,13 @@ impl TuiApp {
|
||||
let _ = event_tx.send(AppEvent::StreamStart);
|
||||
|
||||
// Spawn streaming in background task
|
||||
let client = self.client.clone();
|
||||
let client = Arc::clone(&self.client);
|
||||
let opts = self.opts.clone();
|
||||
let tx = event_tx.clone();
|
||||
let message_owned = message.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
match Self::run_background_stream(&client, &opts, &message, &tx).await {
|
||||
match Self::run_background_stream(client, opts, message_owned, tx.clone()).await {
|
||||
Ok(response) => {
|
||||
let _ = tx.send(AppEvent::StreamEnd { response });
|
||||
}
|
||||
@@ -580,18 +582,16 @@ impl TuiApp {
|
||||
|
||||
/// Run streaming in background, sending chunks through channel
|
||||
async fn run_background_stream(
|
||||
client: &OllamaClient,
|
||||
opts: &ChatOptions,
|
||||
prompt: &str,
|
||||
tx: &mpsc::UnboundedSender<AppEvent>,
|
||||
client: Arc<dyn LlmProvider>,
|
||||
opts: ChatOptions,
|
||||
prompt: String,
|
||||
tx: mpsc::UnboundedSender<AppEvent>,
|
||||
) -> Result<String> {
|
||||
use llm_core::LlmProvider;
|
||||
|
||||
let messages = vec![LLMChatMessage::user(prompt)];
|
||||
let messages = vec![LLMChatMessage::user(&prompt)];
|
||||
let tools = get_tool_definitions();
|
||||
|
||||
let mut stream = client
|
||||
.chat_stream(&messages, opts, Some(&tools))
|
||||
.chat_stream(&messages, &opts, Some(&tools))
|
||||
.await
|
||||
.map_err(|e| color_eyre::eyre::eyre!("LLM provider error: {}", e))?;
|
||||
|
||||
@@ -626,6 +626,7 @@ impl TuiApp {
|
||||
/// 3. When user responds to popup, the channel is signaled and we resume
|
||||
///
|
||||
/// Returns Ok(result) if allowed and executed, Err if denied or failed
|
||||
#[allow(dead_code)] // Reserved for interactive tool permission flow
|
||||
async fn execute_tool_with_permission(
|
||||
&mut self,
|
||||
tool_name: &str,
|
||||
@@ -705,6 +706,7 @@ impl TuiApp {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Reserved for full agent loop integration
|
||||
async fn run_streaming_agent_loop(&mut self, user_prompt: &str) -> Result<String> {
|
||||
let tools = get_tool_definitions();
|
||||
let mut messages = vec![LLMChatMessage::user(user_prompt)];
|
||||
|
||||
@@ -17,10 +17,11 @@ pub use formatting::{
|
||||
};
|
||||
|
||||
use color_eyre::eyre::Result;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Run the TUI application
|
||||
pub async fn run(
|
||||
client: llm_ollama::OllamaClient,
|
||||
client: Arc<dyn llm_core::LlmProvider>,
|
||||
opts: llm_core::ChatOptions,
|
||||
perms: permissions::PermissionManager,
|
||||
settings: config_agent::Settings,
|
||||
|
||||
@@ -12,6 +12,7 @@ pub struct SystemPromptBuilder {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PromptSection {
|
||||
#[allow(dead_code)] // Used for debugging/display purposes
|
||||
name: String,
|
||||
content: String,
|
||||
priority: i32, // Lower = earlier in prompt
|
||||
|
||||
@@ -18,6 +18,7 @@ struct JsonRpcRequest {
|
||||
|
||||
/// JSON-RPC 2.0 response
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)] // jsonrpc field required for protocol compliance
|
||||
struct JsonRpcResponse {
|
||||
jsonrpc: String,
|
||||
id: u64,
|
||||
|
||||
@@ -12,7 +12,9 @@ pub struct AnthropicAuth {
|
||||
client_id: String,
|
||||
}
|
||||
|
||||
// Anthropic OAuth endpoints (these would be the real endpoints)
|
||||
// Anthropic OAuth endpoints
|
||||
// Note: Anthropic doesn't currently have a public OAuth device code flow.
|
||||
// These are placeholder endpoints. Users should use API keys instead.
|
||||
const AUTH_BASE_URL: &str = "https://console.anthropic.com";
|
||||
const DEVICE_CODE_ENDPOINT: &str = "/oauth/device/code";
|
||||
const TOKEN_ENDPOINT: &str = "/oauth/token";
|
||||
@@ -20,22 +22,41 @@ const TOKEN_ENDPOINT: &str = "/oauth/token";
|
||||
// Default client ID for Owlen CLI
|
||||
const DEFAULT_CLIENT_ID: &str = "owlen-cli";
|
||||
|
||||
// User-Agent to avoid Cloudflare blocks
|
||||
const USER_AGENT: &str = concat!("owlen/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
impl AnthropicAuth {
|
||||
/// Create a new OAuth client with the default CLI client ID
|
||||
pub fn new() -> Self {
|
||||
let http = Client::builder()
|
||||
.user_agent(USER_AGENT)
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new());
|
||||
|
||||
Self {
|
||||
http: Client::new(),
|
||||
http,
|
||||
client_id: DEFAULT_CLIENT_ID.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with a custom client ID
|
||||
pub fn with_client_id(client_id: impl Into<String>) -> Self {
|
||||
let http = Client::builder()
|
||||
.user_agent(USER_AGENT)
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new());
|
||||
|
||||
Self {
|
||||
http: Client::new(),
|
||||
http,
|
||||
client_id: client_id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if OAuth is available for Anthropic
|
||||
/// Currently, Anthropic doesn't provide public OAuth for third-party CLI tools.
|
||||
pub fn is_oauth_available() -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AnthropicAuth {
|
||||
|
||||
@@ -458,6 +458,70 @@ pub struct ChatResponse {
|
||||
pub usage: Option<Usage>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Blanket Implementations
|
||||
// ============================================================================
|
||||
|
||||
/// Allow `Arc<dyn LlmProvider>` to be used as an `LlmProvider`
|
||||
#[async_trait]
|
||||
impl LlmProvider for std::sync::Arc<dyn LlmProvider> {
|
||||
fn name(&self) -> &str {
|
||||
(**self).name()
|
||||
}
|
||||
|
||||
fn model(&self) -> &str {
|
||||
(**self).model()
|
||||
}
|
||||
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
options: &ChatOptions,
|
||||
tools: Option<&[Tool]>,
|
||||
) -> Result<ChunkStream, LlmError> {
|
||||
(**self).chat_stream(messages, options, tools).await
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
options: &ChatOptions,
|
||||
tools: Option<&[Tool]>,
|
||||
) -> Result<ChatResponse, LlmError> {
|
||||
(**self).chat(messages, options, tools).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Allow `&Arc<dyn LlmProvider>` to be used as an `LlmProvider`
|
||||
#[async_trait]
|
||||
impl LlmProvider for &std::sync::Arc<dyn LlmProvider> {
|
||||
fn name(&self) -> &str {
|
||||
(***self).name()
|
||||
}
|
||||
|
||||
fn model(&self) -> &str {
|
||||
(***self).model()
|
||||
}
|
||||
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
options: &ChatOptions,
|
||||
tools: Option<&[Tool]>,
|
||||
) -> Result<ChunkStream, LlmError> {
|
||||
(***self).chat_stream(messages, options, tools).await
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
options: &ChatOptions,
|
||||
tools: Option<&[Tool]>,
|
||||
) -> Result<ChatResponse, LlmError> {
|
||||
(***self).chat(messages, options, tools).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper for accumulating streaming tool calls
|
||||
#[derive(Default)]
|
||||
struct PartialToolCall {
|
||||
@@ -753,7 +817,7 @@ pub trait ProviderInfo {
|
||||
// ============================================================================
|
||||
|
||||
/// Supported LLM providers
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ProviderType {
|
||||
Ollama,
|
||||
|
||||
@@ -238,6 +238,7 @@ struct OllamaModelList {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)] // Fields kept for API completeness
|
||||
struct OllamaModel {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
@@ -251,6 +252,7 @@ struct OllamaModel {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)] // Fields kept for API completeness
|
||||
struct OllamaModelDetails {
|
||||
#[serde(default)]
|
||||
format: Option<String>,
|
||||
|
||||
@@ -20,22 +20,41 @@ const TOKEN_ENDPOINT: &str = "/oauth/token";
|
||||
// Default client ID for Owlen CLI
|
||||
const DEFAULT_CLIENT_ID: &str = "owlen-cli";
|
||||
|
||||
// User-Agent to avoid bot protection blocks
|
||||
const USER_AGENT: &str = concat!("owlen/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
impl OpenAIAuth {
|
||||
/// Create a new OAuth client with the default CLI client ID
|
||||
pub fn new() -> Self {
|
||||
let http = Client::builder()
|
||||
.user_agent(USER_AGENT)
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new());
|
||||
|
||||
Self {
|
||||
http: Client::new(),
|
||||
http,
|
||||
client_id: DEFAULT_CLIENT_ID.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with a custom client ID
|
||||
pub fn with_client_id(client_id: impl Into<String>) -> Self {
|
||||
let http = Client::builder()
|
||||
.user_agent(USER_AGENT)
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new());
|
||||
|
||||
Self {
|
||||
http: Client::new(),
|
||||
http,
|
||||
client_id: client_id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if OAuth is available for OpenAI
|
||||
/// Currently, OpenAI doesn't provide public OAuth device code flow for third-party CLI tools.
|
||||
pub fn is_oauth_available() -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OpenAIAuth {
|
||||
|
||||
33
crates/platform/auth/Cargo.toml
Normal file
33
crates/platform/auth/Cargo.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "auth-manager"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Unified authentication manager for LLM providers with OAuth and token refresh"
|
||||
|
||||
[dependencies]
|
||||
# Credential storage
|
||||
credentials = { path = "../credentials" }
|
||||
|
||||
# LLM provider types (AuthMethod, OAuthProvider, etc.)
|
||||
llm-core = { path = "../../llm/core" }
|
||||
|
||||
# Provider-specific OAuth implementations
|
||||
llm-anthropic = { path = "../../llm/anthropic" }
|
||||
llm-openai = { path = "../../llm/openai" }
|
||||
|
||||
# Async runtime for OAuth flows and token refresh
|
||||
tokio = { version = "1", features = ["time", "sync", "rt", "macros"] }
|
||||
|
||||
# Error handling
|
||||
thiserror = "2"
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
|
||||
# Browser opening for OAuth
|
||||
open = "5"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||
720
crates/platform/auth/src/lib.rs
Normal file
720
crates/platform/auth/src/lib.rs
Normal file
@@ -0,0 +1,720 @@
|
||||
//! Authentication Manager
|
||||
//!
|
||||
//! Provides unified authentication management for LLM providers with support for:
|
||||
//! - API key authentication
|
||||
//! - OAuth device code flow
|
||||
//! - Automatic token refresh
|
||||
//! - Credential persistence
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! use auth_manager::AuthManager;
|
||||
//! use llm_core::ProviderType;
|
||||
//!
|
||||
//! let mut manager = AuthManager::new()?;
|
||||
//!
|
||||
//! // Login with OAuth device flow
|
||||
//! manager.login(ProviderType::Anthropic).await?;
|
||||
//!
|
||||
//! // Get auth for making API calls
|
||||
//! let auth = manager.get_auth(ProviderType::Anthropic)?;
|
||||
//!
|
||||
//! // Check status
|
||||
//! for status in manager.status() {
|
||||
//! println!("{}: authenticated={}", status.provider, status.authenticated);
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
mod login;
|
||||
mod refresh;
|
||||
|
||||
pub use login::LoginFlow;
|
||||
pub use refresh::{RefreshConfig, TokenRefresher};
|
||||
|
||||
use credentials::{CredentialError, CredentialManager, HelperManager};
|
||||
use llm_core::{AuthMethod, ProviderStatus, ProviderType, StoredCredentials};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use thiserror::Error;
|
||||
|
||||
// ============================================================================
|
||||
// Error Types
|
||||
// ============================================================================
|
||||
|
||||
/// Errors that can occur during authentication operations
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AuthError {
|
||||
#[error("Credential error: {0}")]
|
||||
Credential(#[from] CredentialError),
|
||||
|
||||
#[error("OAuth error: {0}")]
|
||||
OAuth(String),
|
||||
|
||||
#[error("Provider error: {0}")]
|
||||
Provider(#[from] llm_core::LlmError),
|
||||
|
||||
#[error("Provider not supported for OAuth: {0}")]
|
||||
NotSupported(String),
|
||||
|
||||
#[error("Not authenticated for provider: {0}")]
|
||||
NotAuthenticated(String),
|
||||
|
||||
#[error("Token expired and no refresh token available")]
|
||||
TokenExpired,
|
||||
|
||||
#[error("Login cancelled by user")]
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, AuthError>;
|
||||
|
||||
// ============================================================================
|
||||
// Authentication Manager
|
||||
// ============================================================================
|
||||
|
||||
/// Manages authentication for all LLM providers
|
||||
pub struct AuthManager {
|
||||
/// Credential storage
|
||||
credentials: CredentialManager,
|
||||
|
||||
/// Cached auth methods per provider (for performance)
|
||||
cache: RwLock<HashMap<ProviderType, AuthMethod>>,
|
||||
|
||||
/// Environment variable overrides (checked first)
|
||||
env_overrides: HashMap<ProviderType, String>,
|
||||
|
||||
/// API key helpers (1Password, pass, etc.)
|
||||
helpers: RwLock<HelperManager>,
|
||||
}
|
||||
|
||||
impl AuthManager {
|
||||
/// Create a new authentication manager
|
||||
pub fn new() -> Result<Self> {
|
||||
let credentials = CredentialManager::new()?;
|
||||
let env_overrides = Self::load_env_overrides();
|
||||
|
||||
Ok(Self {
|
||||
credentials,
|
||||
cache: RwLock::new(HashMap::new()),
|
||||
env_overrides,
|
||||
helpers: RwLock::new(HelperManager::new()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Register an API key helper for a provider
|
||||
///
|
||||
/// Helpers are checked after environment variables but before stored credentials.
|
||||
/// This allows using password managers like 1Password, Bitwarden, or pass.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use credentials::one_password_helper;
|
||||
///
|
||||
/// let manager = AuthManager::new()?;
|
||||
/// manager.register_helper(
|
||||
/// ProviderType::Anthropic,
|
||||
/// Box::new(one_password_helper("Private", "Anthropic", "api_key"))
|
||||
/// );
|
||||
/// ```
|
||||
pub fn register_helper(
|
||||
&self,
|
||||
provider: ProviderType,
|
||||
helper: Box<dyn credentials::ApiKeyHelper>,
|
||||
) -> Result<()> {
|
||||
let mut helpers = self.helpers.write().map_err(|_| {
|
||||
AuthError::OAuth("Failed to acquire helpers write lock".to_string())
|
||||
})?;
|
||||
helpers.register(provider.as_str(), helper);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register a command-based API key helper
|
||||
///
|
||||
/// Convenience method for registering a shell command that outputs the API key.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// manager.register_command_helper(
|
||||
/// ProviderType::Anthropic,
|
||||
/// "op read 'op://Private/Anthropic/api_key'"
|
||||
/// )?;
|
||||
/// ```
|
||||
pub fn register_command_helper(
|
||||
&self,
|
||||
provider: ProviderType,
|
||||
command: impl Into<String>,
|
||||
) -> Result<()> {
|
||||
let mut helpers = self.helpers.write().map_err(|_| {
|
||||
AuthError::OAuth("Failed to acquire helpers write lock".to_string())
|
||||
})?;
|
||||
helpers.register_command(provider.as_str(), command);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load API key overrides from environment variables
|
||||
fn load_env_overrides() -> HashMap<ProviderType, String> {
|
||||
let mut overrides = HashMap::new();
|
||||
|
||||
// Check standard environment variables
|
||||
if let Ok(key) = std::env::var("ANTHROPIC_API_KEY") {
|
||||
overrides.insert(ProviderType::Anthropic, key);
|
||||
}
|
||||
if let Ok(key) = std::env::var("OPENAI_API_KEY") {
|
||||
overrides.insert(ProviderType::OpenAI, key);
|
||||
}
|
||||
if let Ok(key) = std::env::var("OLLAMA_API_KEY") {
|
||||
overrides.insert(ProviderType::Ollama, key);
|
||||
}
|
||||
|
||||
// Also check OWLEN_ prefixed variants
|
||||
if let Ok(key) = std::env::var("OWLEN_ANTHROPIC_API_KEY") {
|
||||
overrides.insert(ProviderType::Anthropic, key);
|
||||
}
|
||||
if let Ok(key) = std::env::var("OWLEN_OPENAI_API_KEY") {
|
||||
overrides.insert(ProviderType::OpenAI, key);
|
||||
}
|
||||
if let Ok(key) = std::env::var("OWLEN_API_KEY") {
|
||||
// Generic API key - use for Ollama if set
|
||||
if !overrides.contains_key(&ProviderType::Ollama) {
|
||||
overrides.insert(ProviderType::Ollama, key);
|
||||
}
|
||||
}
|
||||
|
||||
overrides
|
||||
}
|
||||
|
||||
/// Get authentication for a provider
|
||||
///
|
||||
/// Priority order:
|
||||
/// 1. Environment variables (ANTHROPIC_API_KEY, etc.)
|
||||
/// 2. API key helpers (1Password, pass, Bitwarden, etc.)
|
||||
/// 3. In-memory cache
|
||||
/// 4. Stored credentials
|
||||
/// 5. None (for providers that don't require auth)
|
||||
pub fn get_auth(&self, provider: ProviderType) -> Result<AuthMethod> {
|
||||
// 1. Check environment variable override first
|
||||
if let Some(key) = self.env_overrides.get(&provider) {
|
||||
return Ok(AuthMethod::ApiKey(key.clone()));
|
||||
}
|
||||
|
||||
// 2. Check API key helpers (1Password, pass, etc.)
|
||||
{
|
||||
let helpers = self.helpers.read().map_err(|_| {
|
||||
AuthError::OAuth("Failed to acquire helpers read lock".to_string())
|
||||
})?;
|
||||
|
||||
if let Some(result) = helpers.get_key(provider.as_str()) {
|
||||
match result {
|
||||
Ok(key) => return Ok(AuthMethod::ApiKey(key)),
|
||||
Err(e) => {
|
||||
// Log helper error but continue to other sources
|
||||
tracing::warn!(
|
||||
provider = provider.as_str(),
|
||||
error = %e,
|
||||
"API key helper failed, trying other sources"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check cache
|
||||
{
|
||||
let cache = self.cache.read().map_err(|_| {
|
||||
AuthError::OAuth("Failed to acquire cache read lock".to_string())
|
||||
})?;
|
||||
|
||||
if let Some(auth) = cache.get(&provider) {
|
||||
// Check if OAuth token needs refresh
|
||||
if !auth.needs_refresh() {
|
||||
return Ok(auth.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Load from stored credentials
|
||||
let provider_name = provider.as_str();
|
||||
if let Some(creds) = self.credentials.retrieve(provider_name)? {
|
||||
let auth = Self::credentials_to_auth(&creds);
|
||||
|
||||
// Cache it
|
||||
let mut cache = self.cache.write().map_err(|_| {
|
||||
AuthError::OAuth("Failed to acquire cache write lock".to_string())
|
||||
})?;
|
||||
cache.insert(provider, auth.clone());
|
||||
|
||||
return Ok(auth);
|
||||
}
|
||||
|
||||
// 5. No credentials found
|
||||
// For Ollama, return None (local doesn't need auth)
|
||||
if provider == ProviderType::Ollama {
|
||||
return Ok(AuthMethod::None);
|
||||
}
|
||||
|
||||
Err(AuthError::NotAuthenticated(provider_name.to_string()))
|
||||
}
|
||||
|
||||
/// Convert stored credentials to AuthMethod
|
||||
fn credentials_to_auth(creds: &StoredCredentials) -> AuthMethod {
|
||||
if creds.refresh_token.is_some() || creds.expires_at.is_some() {
|
||||
// This is OAuth
|
||||
AuthMethod::OAuth {
|
||||
access_token: creds.access_token.clone(),
|
||||
refresh_token: creds.refresh_token.clone(),
|
||||
expires_at: creds.expires_at,
|
||||
}
|
||||
} else {
|
||||
// This is an API key
|
||||
AuthMethod::ApiKey(creds.access_token.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Store an API key for a provider
|
||||
pub fn store_api_key(&self, provider: ProviderType, api_key: &str) -> Result<()> {
|
||||
let creds = StoredCredentials {
|
||||
provider: provider.as_str().to_string(),
|
||||
access_token: api_key.to_string(),
|
||||
refresh_token: None,
|
||||
expires_at: None,
|
||||
};
|
||||
|
||||
self.credentials.store(provider.as_str(), creds)?;
|
||||
|
||||
// Update cache
|
||||
let mut cache = self.cache.write().map_err(|_| {
|
||||
AuthError::OAuth("Failed to acquire cache write lock".to_string())
|
||||
})?;
|
||||
cache.insert(provider, AuthMethod::ApiKey(api_key.to_string()));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Store OAuth credentials for a provider
|
||||
pub fn store_oauth(
|
||||
&self,
|
||||
provider: ProviderType,
|
||||
access_token: &str,
|
||||
refresh_token: Option<&str>,
|
||||
expires_in: Option<u64>,
|
||||
) -> Result<()> {
|
||||
let expires_at = expires_in.map(|secs| {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() + secs)
|
||||
.unwrap_or(0)
|
||||
});
|
||||
|
||||
let creds = StoredCredentials {
|
||||
provider: provider.as_str().to_string(),
|
||||
access_token: access_token.to_string(),
|
||||
refresh_token: refresh_token.map(|s| s.to_string()),
|
||||
expires_at,
|
||||
};
|
||||
|
||||
self.credentials.store(provider.as_str(), creds)?;
|
||||
|
||||
// Update cache
|
||||
let auth = AuthMethod::OAuth {
|
||||
access_token: access_token.to_string(),
|
||||
refresh_token: refresh_token.map(|s| s.to_string()),
|
||||
expires_at,
|
||||
};
|
||||
|
||||
let mut cache = self.cache.write().map_err(|_| {
|
||||
AuthError::OAuth("Failed to acquire cache write lock".to_string())
|
||||
})?;
|
||||
cache.insert(provider, auth);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Perform OAuth login for a provider
|
||||
pub async fn login(&self, provider: ProviderType) -> Result<()> {
|
||||
let flow = LoginFlow::new(provider)?;
|
||||
let auth = flow.execute().await?;
|
||||
|
||||
// Store the credentials
|
||||
match &auth {
|
||||
AuthMethod::OAuth {
|
||||
access_token,
|
||||
refresh_token,
|
||||
expires_at,
|
||||
} => {
|
||||
let expires_in = expires_at.map(|exp| {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
exp.saturating_sub(now)
|
||||
});
|
||||
|
||||
self.store_oauth(
|
||||
provider,
|
||||
access_token,
|
||||
refresh_token.as_deref(),
|
||||
expires_in,
|
||||
)?;
|
||||
}
|
||||
AuthMethod::ApiKey(key) => {
|
||||
self.store_api_key(provider, key)?;
|
||||
}
|
||||
AuthMethod::None => {
|
||||
// Nothing to store
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Logout from a provider (delete stored credentials)
|
||||
pub fn logout(&self, provider: ProviderType) -> Result<()> {
|
||||
// Remove from credential store
|
||||
self.credentials.delete(provider.as_str())?;
|
||||
|
||||
// Remove from cache
|
||||
let mut cache = self.cache.write().map_err(|_| {
|
||||
AuthError::OAuth("Failed to acquire cache write lock".to_string())
|
||||
})?;
|
||||
cache.remove(&provider);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get authentication status for all providers
|
||||
pub fn status(&self) -> Vec<ProviderStatus> {
|
||||
let providers = [
|
||||
ProviderType::Ollama,
|
||||
ProviderType::Anthropic,
|
||||
ProviderType::OpenAI,
|
||||
];
|
||||
|
||||
// Check which providers have helpers configured
|
||||
let helper_providers: Vec<String> = self.helpers.read()
|
||||
.map(|h| h.providers().into_iter().map(|s| s.to_string()).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
providers
|
||||
.iter()
|
||||
.map(|provider| {
|
||||
let auth_result = self.get_auth(*provider);
|
||||
let authenticated = matches!(
|
||||
&auth_result,
|
||||
Ok(AuthMethod::ApiKey(_)) | Ok(AuthMethod::OAuth { .. })
|
||||
);
|
||||
|
||||
let has_helper = helper_providers.contains(&provider.as_str().to_string());
|
||||
|
||||
let (source, message) = if self.env_overrides.contains_key(provider) {
|
||||
("env", Some("API key from environment variable".to_string()))
|
||||
} else if has_helper && authenticated {
|
||||
("helper", Some("API key from helper command".to_string()))
|
||||
} else if authenticated {
|
||||
("stored", None)
|
||||
} else {
|
||||
("none", auth_result.err().map(|e| e.to_string()))
|
||||
};
|
||||
|
||||
ProviderStatus {
|
||||
provider: provider.as_str().to_string(),
|
||||
authenticated,
|
||||
account: None, // Would need to call provider API to get this
|
||||
model: provider.default_model().to_string(),
|
||||
endpoint: match provider {
|
||||
ProviderType::Ollama => "http://localhost:11434".to_string(),
|
||||
ProviderType::Anthropic => "https://api.anthropic.com".to_string(),
|
||||
ProviderType::OpenAI => "https://api.openai.com".to_string(),
|
||||
},
|
||||
reachable: true, // Would need to check connectivity
|
||||
message: message.or_else(|| Some(format!("Source: {}", source))),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Check if a helper is registered for a provider
|
||||
pub fn has_helper(&self, provider: ProviderType) -> bool {
|
||||
self.helpers.read()
|
||||
.map(|h| h.has_helper(provider.as_str()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Check if a provider has stored credentials
|
||||
pub fn is_authenticated(&self, provider: ProviderType) -> bool {
|
||||
matches!(
|
||||
self.get_auth(provider),
|
||||
Ok(AuthMethod::ApiKey(_)) | Ok(AuthMethod::OAuth { .. })
|
||||
)
|
||||
}
|
||||
|
||||
/// Get the credential storage backend name
|
||||
pub fn storage_name(&self) -> &'static str {
|
||||
self.credentials.storage_name()
|
||||
}
|
||||
|
||||
/// Clear all cached credentials (does not delete stored credentials)
|
||||
pub fn clear_cache(&self) {
|
||||
if let Ok(mut cache) = self.cache.write() {
|
||||
cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Start background token refresh with default configuration
|
||||
///
|
||||
/// Spawns a background task that periodically checks for expiring tokens
|
||||
/// and refreshes them before they expire.
|
||||
///
|
||||
/// Returns a `TokenRefresher` handle that can be used to stop the refresh task.
|
||||
pub fn start_background_refresh(self: Arc<Self>) -> TokenRefresher {
|
||||
self.start_background_refresh_with_config(RefreshConfig::default())
|
||||
}
|
||||
|
||||
/// Start background token refresh with custom configuration
|
||||
pub fn start_background_refresh_with_config(
|
||||
self: Arc<Self>,
|
||||
config: RefreshConfig,
|
||||
) -> TokenRefresher {
|
||||
TokenRefresher::start(self, config)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MCP Server OAuth Methods
|
||||
// =========================================================================
|
||||
|
||||
/// Get the credential key for an MCP server
|
||||
fn mcp_credential_key(server_name: &str) -> String {
|
||||
format!("mcp:{}", server_name)
|
||||
}
|
||||
|
||||
/// Store OAuth token for an MCP server
|
||||
///
|
||||
/// MCP tokens are stored with the key pattern `mcp:{server_name}`.
|
||||
pub fn store_mcp_token(
|
||||
&self,
|
||||
server_name: &str,
|
||||
access_token: &str,
|
||||
refresh_token: Option<&str>,
|
||||
expires_in: Option<u64>,
|
||||
) -> Result<()> {
|
||||
let key = Self::mcp_credential_key(server_name);
|
||||
let expires_at = expires_in.map(|secs| {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() + secs)
|
||||
.unwrap_or(0)
|
||||
});
|
||||
|
||||
let creds = StoredCredentials {
|
||||
provider: key.clone(),
|
||||
access_token: access_token.to_string(),
|
||||
refresh_token: refresh_token.map(|s| s.to_string()),
|
||||
expires_at,
|
||||
};
|
||||
|
||||
self.credentials.store(&key, creds)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get OAuth token for an MCP server
|
||||
///
|
||||
/// Returns the access token if available, or None if not authenticated.
|
||||
pub fn get_mcp_token(&self, server_name: &str) -> Result<Option<String>> {
|
||||
let key = Self::mcp_credential_key(server_name);
|
||||
|
||||
if let Some(creds) = self.credentials.retrieve(&key)? {
|
||||
// Check if token is expired
|
||||
if let Some(expires_at) = creds.expires_at {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
if expires_at <= now {
|
||||
// Token expired
|
||||
// TODO: Auto-refresh if refresh token is available
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some(creds.access_token))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if an MCP server is authenticated
|
||||
pub fn is_mcp_authenticated(&self, server_name: &str) -> bool {
|
||||
self.get_mcp_token(server_name)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// Remove stored token for an MCP server
|
||||
pub fn logout_mcp(&self, server_name: &str) -> Result<()> {
|
||||
let key = Self::mcp_credential_key(server_name);
|
||||
self.credentials.delete(&key)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all authenticated MCP servers
|
||||
pub fn list_mcp_servers(&self) -> Result<Vec<String>> {
|
||||
let providers = self.credentials.list_providers()?;
|
||||
Ok(providers
|
||||
.into_iter()
|
||||
.filter_map(|p| p.strip_prefix("mcp:").map(|s| s.to_string()))
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AuthManager {
|
||||
fn default() -> Self {
|
||||
Self::new().expect("Failed to create auth manager")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use credentials::HelperManager;
|
||||
|
||||
#[test]
|
||||
fn test_env_override_loading() {
|
||||
// Set env var (unsafe in Rust 2024 due to potential thread safety issues)
|
||||
// SAFETY: This is a single-threaded test, no concurrent access
|
||||
unsafe {
|
||||
std::env::set_var("ANTHROPIC_API_KEY", "test-key-123");
|
||||
}
|
||||
|
||||
let manager = AuthManager::new().unwrap();
|
||||
let auth = manager.get_auth(ProviderType::Anthropic).unwrap();
|
||||
|
||||
match auth {
|
||||
AuthMethod::ApiKey(key) => assert_eq!(key, "test-key-123"),
|
||||
_ => panic!("Expected API key auth"),
|
||||
}
|
||||
|
||||
// Clean up
|
||||
// SAFETY: Single-threaded test
|
||||
unsafe {
|
||||
std::env::remove_var("ANTHROPIC_API_KEY");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ollama_no_auth_required() {
|
||||
let manager = AuthManager::new().unwrap();
|
||||
let auth = manager.get_auth(ProviderType::Ollama).unwrap();
|
||||
|
||||
assert!(matches!(auth, AuthMethod::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status() {
|
||||
let manager = AuthManager::new().unwrap();
|
||||
let status = manager.status();
|
||||
|
||||
assert_eq!(status.len(), 3);
|
||||
|
||||
let provider_names: Vec<&str> = status.iter().map(|s| s.provider.as_str()).collect();
|
||||
assert!(provider_names.contains(&"ollama"));
|
||||
assert!(provider_names.contains(&"anthropic"));
|
||||
assert!(provider_names.contains(&"openai"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_helper_registration() {
|
||||
// Clear any env vars that might interfere (from other tests)
|
||||
// SAFETY: Single-threaded test context
|
||||
unsafe {
|
||||
std::env::remove_var("ANTHROPIC_API_KEY");
|
||||
std::env::remove_var("OWLEN_ANTHROPIC_API_KEY");
|
||||
}
|
||||
|
||||
// Create a fresh manager after clearing env vars
|
||||
let manager = AuthManager::new().unwrap();
|
||||
|
||||
// No helper initially
|
||||
assert!(!manager.has_helper(ProviderType::Anthropic));
|
||||
|
||||
// Register a command helper
|
||||
manager
|
||||
.register_command_helper(ProviderType::Anthropic, "echo 'helper-test-key'")
|
||||
.unwrap();
|
||||
|
||||
// Now has helper
|
||||
assert!(manager.has_helper(ProviderType::Anthropic));
|
||||
|
||||
// Should get auth from helper
|
||||
let auth = manager.get_auth(ProviderType::Anthropic).unwrap();
|
||||
match auth {
|
||||
AuthMethod::ApiKey(key) => assert_eq!(key, "helper-test-key"),
|
||||
_ => panic!("Expected API key from helper"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_helper_in_status() {
|
||||
let manager = AuthManager::new().unwrap();
|
||||
|
||||
// Register a helper
|
||||
manager
|
||||
.register_command_helper(ProviderType::OpenAI, "echo 'status-test-key'")
|
||||
.unwrap();
|
||||
|
||||
let status = manager.status();
|
||||
let openai_status = status.iter().find(|s| s.provider == "openai").unwrap();
|
||||
|
||||
assert!(openai_status.authenticated);
|
||||
assert!(openai_status
|
||||
.message
|
||||
.as_ref()
|
||||
.map(|m| m.contains("helper"))
|
||||
.unwrap_or(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_token_storage() {
|
||||
// Use file-only credential manager to avoid keyring issues in test environment
|
||||
let credentials = CredentialManager::file_only().unwrap();
|
||||
let manager = AuthManager {
|
||||
credentials,
|
||||
cache: RwLock::new(HashMap::new()),
|
||||
env_overrides: HashMap::new(),
|
||||
helpers: RwLock::new(HelperManager::new()),
|
||||
};
|
||||
|
||||
// Use a unique server name to avoid conflicts with other tests
|
||||
let server_name = format!("test-mcp-{}", std::process::id());
|
||||
|
||||
// Initially not authenticated
|
||||
assert!(!manager.is_mcp_authenticated(&server_name));
|
||||
|
||||
// Store a token
|
||||
manager
|
||||
.store_mcp_token(&server_name, "test-token-123", Some("refresh-456"), Some(3600))
|
||||
.unwrap();
|
||||
|
||||
// Now authenticated
|
||||
assert!(
|
||||
manager.is_mcp_authenticated(&server_name),
|
||||
"Expected to be authenticated after storing token"
|
||||
);
|
||||
|
||||
// Get token
|
||||
let token = manager.get_mcp_token(&server_name).unwrap();
|
||||
assert_eq!(token, Some("test-token-123".to_string()));
|
||||
|
||||
// Cleanup
|
||||
manager.logout_mcp(&server_name).unwrap();
|
||||
assert!(!manager.is_mcp_authenticated(&server_name));
|
||||
}
|
||||
}
|
||||
163
crates/platform/auth/src/login.rs
Normal file
163
crates/platform/auth/src/login.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
//! OAuth Login Flow
|
||||
//!
|
||||
//! Handles the device code OAuth flow for providers that support it.
|
||||
|
||||
use crate::{AuthError, Result};
|
||||
use llm_anthropic::{AnthropicAuth, perform_device_auth as anthropic_device_auth};
|
||||
use llm_core::{AuthMethod, DeviceCodeResponse, ProviderType};
|
||||
use llm_openai::{OpenAIAuth, perform_device_auth as openai_device_auth};
|
||||
|
||||
/// Handles OAuth login flow for a specific provider
|
||||
pub struct LoginFlow {
|
||||
provider: ProviderType,
|
||||
}
|
||||
|
||||
impl LoginFlow {
|
||||
/// Create a new login flow for a provider
|
||||
pub fn new(provider: ProviderType) -> Result<Self> {
|
||||
// Check if provider supports OAuth
|
||||
match provider {
|
||||
ProviderType::Anthropic | ProviderType::OpenAI => Ok(Self { provider }),
|
||||
ProviderType::Ollama => Err(AuthError::NotSupported(
|
||||
"Ollama does not support OAuth. Use an API key instead.".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute the login flow
|
||||
///
|
||||
/// This will:
|
||||
/// 1. Start the device code flow
|
||||
/// 2. Display instructions to the user
|
||||
/// 3. Optionally open the browser
|
||||
/// 4. Poll until authorization completes
|
||||
pub async fn execute(self) -> Result<AuthMethod> {
|
||||
match self.provider {
|
||||
ProviderType::Anthropic => self.login_anthropic().await,
|
||||
ProviderType::OpenAI => self.login_openai().await,
|
||||
ProviderType::Ollama => Err(AuthError::NotSupported(
|
||||
"Ollama does not support OAuth".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform Anthropic OAuth login
|
||||
async fn login_anthropic(&self) -> Result<AuthMethod> {
|
||||
let auth = AnthropicAuth::new();
|
||||
|
||||
println!("\n🔐 Authenticating with Anthropic...\n");
|
||||
|
||||
let result = anthropic_device_auth(&auth, |device_code| {
|
||||
self.display_device_code(device_code);
|
||||
})
|
||||
.await?;
|
||||
|
||||
println!("\n✅ Successfully authenticated with Anthropic!\n");
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Perform OpenAI OAuth login
|
||||
async fn login_openai(&self) -> Result<AuthMethod> {
|
||||
let auth = OpenAIAuth::new();
|
||||
|
||||
println!("\n🔐 Authenticating with OpenAI...\n");
|
||||
|
||||
let result = openai_device_auth(&auth, |device_code| {
|
||||
self.display_device_code(device_code);
|
||||
})
|
||||
.await?;
|
||||
|
||||
println!("\n✅ Successfully authenticated with OpenAI!\n");
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Display device code instructions to the user
|
||||
fn display_device_code(&self, device_code: &DeviceCodeResponse) {
|
||||
println!("┌─────────────────────────────────────────────────────────┐");
|
||||
println!("│ Device Authorization │");
|
||||
println!("├─────────────────────────────────────────────────────────┤");
|
||||
println!("│ │");
|
||||
println!("│ 1. Open this URL in your browser: │");
|
||||
println!("│ │");
|
||||
println!("│ {} │", format_url(&device_code.verification_uri));
|
||||
println!("│ │");
|
||||
println!("│ 2. Enter this code when prompted: │");
|
||||
println!("│ │");
|
||||
println!("│ {:^20} │", device_code.user_code);
|
||||
println!("│ │");
|
||||
println!("│ Waiting for authorization... │");
|
||||
println!("│ (Code expires in {} seconds) │", device_code.expires_in);
|
||||
println!("└─────────────────────────────────────────────────────────┘");
|
||||
|
||||
// Try to open browser automatically
|
||||
if let Some(ref complete_uri) = device_code.verification_uri_complete {
|
||||
if let Err(e) = open::that(complete_uri) {
|
||||
tracing::debug!("Could not open browser: {}", e);
|
||||
println!("\n💡 Tip: Or visit this URL directly:");
|
||||
println!(" {}\n", complete_uri);
|
||||
} else {
|
||||
println!("\n🌐 Opening browser...\n");
|
||||
}
|
||||
} else if let Err(e) = open::that(&device_code.verification_uri) {
|
||||
tracing::debug!("Could not open browser: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format URL for display (pad to fixed width)
|
||||
fn format_url(url: &str) -> String {
|
||||
if url.len() > 50 {
|
||||
url.to_string()
|
||||
} else {
|
||||
format!("{:<50}", url)
|
||||
}
|
||||
}
|
||||
|
||||
/// Interactive login flow with user prompts
|
||||
#[allow(dead_code)] // Reserved for future interactive CLI use
|
||||
pub struct InteractiveLogin;
|
||||
|
||||
impl InteractiveLogin {
|
||||
/// Prompt user to select authentication method
|
||||
#[allow(dead_code)] // Reserved for future interactive CLI use
|
||||
pub async fn prompt_and_login(provider: ProviderType) -> Result<AuthMethod> {
|
||||
match provider {
|
||||
ProviderType::Ollama => {
|
||||
println!("Ollama typically doesn't require authentication for local use.");
|
||||
println!("If you're using Ollama Cloud, please set OLLAMA_API_KEY environment variable.");
|
||||
Ok(AuthMethod::None)
|
||||
}
|
||||
ProviderType::Anthropic | ProviderType::OpenAI => {
|
||||
println!("\nAuthentication options for {}:", provider);
|
||||
println!(" 1. OAuth device flow (recommended)");
|
||||
println!(" 2. Enter API key manually");
|
||||
println!("\nStarting OAuth flow...\n");
|
||||
|
||||
let flow = LoginFlow::new(provider)?;
|
||||
flow.execute().await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_login_flow_creation() {
|
||||
// Anthropic should work
|
||||
let flow = LoginFlow::new(ProviderType::Anthropic);
|
||||
assert!(flow.is_ok());
|
||||
|
||||
// OpenAI should work
|
||||
let flow = LoginFlow::new(ProviderType::OpenAI);
|
||||
assert!(flow.is_ok());
|
||||
|
||||
// Ollama should fail
|
||||
let flow = LoginFlow::new(ProviderType::Ollama);
|
||||
assert!(flow.is_err());
|
||||
}
|
||||
}
|
||||
358
crates/platform/auth/src/refresh.rs
Normal file
358
crates/platform/auth/src/refresh.rs
Normal file
@@ -0,0 +1,358 @@
|
||||
//! Background Token Refresh
|
||||
//!
|
||||
//! Provides automatic background token refresh for OAuth credentials before they expire.
|
||||
//! This prevents authentication failures during long-running sessions.
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! The `TokenRefresher` spawns a background tokio task that:
|
||||
//! 1. Runs every `check_interval` (default: 5 minutes)
|
||||
//! 2. Scans all stored OAuth tokens
|
||||
//! 3. Refreshes any tokens expiring within `refresh_threshold` (default: 10 minutes)
|
||||
//! 4. Updates both credential store and in-memory cache on success
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! use auth_manager::{AuthManager, TokenRefresher, RefreshConfig};
|
||||
//!
|
||||
//! let auth_manager = AuthManager::new()?;
|
||||
//! let refresher = TokenRefresher::start(auth_manager.clone(), RefreshConfig::default());
|
||||
//!
|
||||
//! // The refresher runs in the background
|
||||
//! // Stop it when shutting down
|
||||
//! refresher.stop();
|
||||
//! ```
|
||||
|
||||
use crate::{AuthError, AuthManager, Result};
|
||||
use llm_core::{AuthMethod, OAuthProvider, ProviderType};
|
||||
use llm_anthropic::AnthropicAuth;
|
||||
use llm_openai::OpenAIAuth;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::watch;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
/// Configuration for background token refresh
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RefreshConfig {
|
||||
/// How often to check for expiring tokens (default: 5 minutes)
|
||||
pub check_interval: Duration,
|
||||
|
||||
/// Refresh tokens expiring within this threshold (default: 10 minutes)
|
||||
pub refresh_threshold: Duration,
|
||||
|
||||
/// Whether to enable refresh (can be disabled for testing)
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for RefreshConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
check_interval: Duration::from_secs(5 * 60), // 5 minutes
|
||||
refresh_threshold: Duration::from_secs(10 * 60), // 10 minutes
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RefreshConfig {
|
||||
/// Create a config for testing with shorter intervals
|
||||
pub fn for_testing() -> Self {
|
||||
Self {
|
||||
check_interval: Duration::from_secs(1),
|
||||
refresh_threshold: Duration::from_secs(5),
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a disabled config
|
||||
pub fn disabled() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle to a running background token refresher
|
||||
pub struct TokenRefresher {
|
||||
/// Channel to signal shutdown
|
||||
shutdown_sender: watch::Sender<bool>,
|
||||
|
||||
/// Join handle for the background task
|
||||
task_handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl TokenRefresher {
|
||||
/// Start the background token refresh task
|
||||
///
|
||||
/// Returns a handle that can be used to stop the refresher.
|
||||
pub fn start(auth_manager: Arc<AuthManager>, config: RefreshConfig) -> Self {
|
||||
let (shutdown_sender, shutdown_receiver) = watch::channel(false);
|
||||
|
||||
let task_handle = tokio::spawn(async move {
|
||||
run_refresh_loop(auth_manager, config, shutdown_receiver).await;
|
||||
});
|
||||
|
||||
Self {
|
||||
shutdown_sender,
|
||||
task_handle,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the background refresh task
|
||||
pub fn stop(self) {
|
||||
// Signal shutdown
|
||||
let _ = self.shutdown_sender.send(true);
|
||||
// The task will exit on next check interval
|
||||
}
|
||||
|
||||
/// Stop and wait for the task to complete
|
||||
pub async fn stop_and_wait(self) {
|
||||
let _ = self.shutdown_sender.send(true);
|
||||
let _ = self.task_handle.await;
|
||||
}
|
||||
|
||||
/// Check if the refresh task is still running
|
||||
pub fn is_running(&self) -> bool {
|
||||
!self.task_handle.is_finished()
|
||||
}
|
||||
}
|
||||
|
||||
/// Main refresh loop running in the background
|
||||
async fn run_refresh_loop(
|
||||
auth_manager: Arc<AuthManager>,
|
||||
config: RefreshConfig,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
) {
|
||||
if !config.enabled {
|
||||
info!("Token refresh disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
info!(
|
||||
"Starting token refresh task (check_interval: {:?}, threshold: {:?})",
|
||||
config.check_interval, config.refresh_threshold
|
||||
);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(config.check_interval) => {
|
||||
if let Err(error) = refresh_expiring_tokens(&auth_manager, &config).await {
|
||||
error!(?error, "Error during token refresh cycle");
|
||||
}
|
||||
}
|
||||
_ = shutdown.changed() => {
|
||||
if *shutdown.borrow() {
|
||||
info!("Token refresh task shutting down");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check all providers and refresh any tokens that are close to expiring
|
||||
async fn refresh_expiring_tokens(auth_manager: &AuthManager, config: &RefreshConfig) -> Result<()> {
|
||||
let providers = [ProviderType::Anthropic, ProviderType::OpenAI];
|
||||
let threshold_secs = config.refresh_threshold.as_secs();
|
||||
|
||||
for provider in providers {
|
||||
match check_and_refresh_provider(auth_manager, provider, threshold_secs).await {
|
||||
Ok(refreshed) => {
|
||||
if refreshed {
|
||||
info!(?provider, "Successfully refreshed token");
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(?provider, ?error, "Failed to refresh token");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check a single provider and refresh if needed
|
||||
///
|
||||
/// Returns `Ok(true)` if token was refreshed, `Ok(false)` if no refresh needed.
|
||||
async fn check_and_refresh_provider(
|
||||
auth_manager: &AuthManager,
|
||||
provider: ProviderType,
|
||||
threshold_secs: u64,
|
||||
) -> Result<bool> {
|
||||
// Get current auth
|
||||
let current_auth = match auth_manager.get_auth(provider) {
|
||||
Ok(auth) => auth,
|
||||
Err(_) => return Ok(false), // Not authenticated, nothing to refresh
|
||||
};
|
||||
|
||||
// Only OAuth tokens can be refreshed
|
||||
let (_current_access_token, refresh_token_str, expires_at) = match ¤t_auth {
|
||||
AuthMethod::OAuth {
|
||||
access_token,
|
||||
refresh_token: Some(refresh),
|
||||
expires_at: Some(exp),
|
||||
} => (access_token.clone(), refresh.clone(), *exp),
|
||||
_ => return Ok(false), // Not OAuth or no refresh token
|
||||
};
|
||||
|
||||
// Check if expiring soon
|
||||
let now_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
if expires_at > now_secs + threshold_secs {
|
||||
debug!(
|
||||
?provider,
|
||||
expires_in_secs = expires_at - now_secs,
|
||||
"Token not expiring soon, skipping refresh"
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
info!(
|
||||
?provider,
|
||||
expires_in_secs = expires_at.saturating_sub(now_secs),
|
||||
"Token expiring soon, refreshing"
|
||||
);
|
||||
|
||||
// Perform the refresh using provider-specific OAuth implementation
|
||||
let new_auth = perform_token_refresh(provider, &refresh_token_str).await?;
|
||||
|
||||
// Store the new credentials
|
||||
match &new_auth {
|
||||
AuthMethod::OAuth {
|
||||
access_token,
|
||||
refresh_token,
|
||||
expires_at: new_expires_at,
|
||||
} => {
|
||||
// Calculate expires_in from expires_at for store_oauth
|
||||
let expires_in = new_expires_at.map(|exp| {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
exp.saturating_sub(now)
|
||||
});
|
||||
|
||||
auth_manager.store_oauth(
|
||||
provider,
|
||||
access_token,
|
||||
refresh_token.as_deref(),
|
||||
expires_in,
|
||||
)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(AuthError::OAuth(
|
||||
"Refresh returned non-OAuth auth method".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Perform token refresh for a specific provider
|
||||
async fn perform_token_refresh(
|
||||
provider: ProviderType,
|
||||
refresh_token: &str,
|
||||
) -> Result<AuthMethod> {
|
||||
match provider {
|
||||
ProviderType::Anthropic => {
|
||||
let auth_client = AnthropicAuth::new();
|
||||
auth_client
|
||||
.refresh_token(refresh_token)
|
||||
.await
|
||||
.map_err(|e| AuthError::OAuth(format!("Anthropic refresh failed: {}", e)))
|
||||
}
|
||||
ProviderType::OpenAI => {
|
||||
let auth_client = OpenAIAuth::new();
|
||||
auth_client
|
||||
.refresh_token(refresh_token)
|
||||
.await
|
||||
.map_err(|e| AuthError::OAuth(format!("OpenAI refresh failed: {}", e)))
|
||||
}
|
||||
ProviderType::Ollama => {
|
||||
// Ollama doesn't use OAuth tokens
|
||||
Err(AuthError::NotSupported(
|
||||
"Ollama does not support OAuth refresh".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let config = RefreshConfig::default();
|
||||
assert_eq!(config.check_interval, Duration::from_secs(5 * 60));
|
||||
assert_eq!(config.refresh_threshold, Duration::from_secs(10 * 60));
|
||||
assert!(config.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_testing_config() {
|
||||
let config = RefreshConfig::for_testing();
|
||||
assert_eq!(config.check_interval, Duration::from_secs(1));
|
||||
assert_eq!(config.refresh_threshold, Duration::from_secs(5));
|
||||
assert!(config.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disabled_config() {
|
||||
let config = RefreshConfig::disabled();
|
||||
assert!(!config.enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_refresher_starts_and_stops() {
|
||||
// Create auth manager
|
||||
let auth_manager = Arc::new(AuthManager::new().unwrap());
|
||||
|
||||
// Start refresher with disabled config (won't actually run checks)
|
||||
let refresher = TokenRefresher::start(
|
||||
auth_manager,
|
||||
RefreshConfig::disabled(),
|
||||
);
|
||||
|
||||
// Give it a moment to start
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
// Should not be running since disabled
|
||||
// Note: task finishes almost immediately when disabled
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
assert!(!refresher.is_running());
|
||||
|
||||
// Stop is safe even if not running
|
||||
refresher.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_refresher_with_short_interval() {
|
||||
let auth_manager = Arc::new(AuthManager::new().unwrap());
|
||||
|
||||
// Start with very short interval
|
||||
let config = RefreshConfig {
|
||||
check_interval: Duration::from_millis(100),
|
||||
refresh_threshold: Duration::from_secs(1),
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
let refresher = TokenRefresher::start(auth_manager, config);
|
||||
|
||||
// Let it run a couple cycles
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
|
||||
// Should still be running
|
||||
assert!(refresher.is_running());
|
||||
|
||||
// Stop and wait
|
||||
refresher.stop_and_wait().await;
|
||||
}
|
||||
}
|
||||
31
crates/platform/credentials/Cargo.toml
Normal file
31
crates/platform/credentials/Cargo.toml
Normal file
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "credentials"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Secure credential storage with keyring and file fallback"
|
||||
|
||||
[dependencies]
|
||||
# Cross-platform keyring (macOS Keychain, Linux secret-service, Windows Credential Manager)
|
||||
# NOTE: keyring 3.x requires explicit backend features - without them it uses a mock store!
|
||||
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] }
|
||||
|
||||
# XDG/platform directories for config paths
|
||||
directories = "5"
|
||||
|
||||
# Serialization for credential storage
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# Error handling
|
||||
thiserror = "2"
|
||||
|
||||
# LLM core types (StoredCredentials, AuthMethod)
|
||||
llm-core = { path = "../../llm/core" }
|
||||
|
||||
# Async for potential future keyring operations
|
||||
tokio = { version = "1", features = ["sync"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
260
crates/platform/credentials/src/file.rs
Normal file
260
crates/platform/credentials/src/file.rs
Normal file
@@ -0,0 +1,260 @@
|
||||
//! File-based Credential Storage
|
||||
//!
|
||||
//! Provides fallback credential storage using an encrypted JSON file.
|
||||
//! Stores credentials in `~/.config/owlen/credentials.json` (or XDG_CONFIG_HOME on Linux).
|
||||
|
||||
use crate::{CredentialError, CredentialStore, Result};
|
||||
use directories::ProjectDirs;
|
||||
use llm_core::StoredCredentials;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::RwLock;
|
||||
|
||||
/// File extension for credentials file
|
||||
const CREDENTIALS_FILE: &str = "credentials.json";
|
||||
|
||||
/// File-based credential storage
|
||||
pub struct FileStore {
|
||||
/// Path to the credentials file
|
||||
path: PathBuf,
|
||||
|
||||
/// In-memory cache of credentials (for read performance)
|
||||
cache: RwLock<HashMap<String, StoredCredentials>>,
|
||||
}
|
||||
|
||||
impl FileStore {
|
||||
/// Create a new file store with default path
|
||||
pub fn new() -> Result<Self> {
|
||||
let path = Self::default_path()?;
|
||||
Self::with_path(path)
|
||||
}
|
||||
|
||||
/// Create a new file store with custom path
|
||||
pub fn with_path(path: PathBuf) -> Result<Self> {
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let store = Self {
|
||||
path,
|
||||
cache: RwLock::new(HashMap::new()),
|
||||
};
|
||||
|
||||
// Load existing credentials into cache
|
||||
store.load_cache()?;
|
||||
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
/// Get the default credentials file path
|
||||
fn default_path() -> Result<PathBuf> {
|
||||
// Use directories crate for cross-platform config path
|
||||
// Linux: ~/.config/owlen/credentials.json (or XDG_CONFIG_HOME)
|
||||
// macOS: ~/Library/Application Support/owlen/credentials.json
|
||||
// Windows: C:\Users\<User>\AppData\Roaming\owlen\credentials.json
|
||||
ProjectDirs::from("", "", "owlen")
|
||||
.map(|dirs| dirs.config_dir().join(CREDENTIALS_FILE))
|
||||
.ok_or_else(|| {
|
||||
CredentialError::FileStorage("Could not determine config directory".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// Load credentials from file into cache
|
||||
fn load_cache(&self) -> Result<()> {
|
||||
if !self.path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let contents = fs::read_to_string(&self.path)?;
|
||||
if contents.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let credentials: HashMap<String, StoredCredentials> = serde_json::from_str(&contents)?;
|
||||
|
||||
let mut cache = self.cache.write().map_err(|_| {
|
||||
CredentialError::FileStorage("Failed to acquire write lock".to_string())
|
||||
})?;
|
||||
|
||||
*cache = credentials;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save cache to file
|
||||
fn save_cache(&self) -> Result<()> {
|
||||
let cache = self.cache.read().map_err(|_| {
|
||||
CredentialError::FileStorage("Failed to acquire read lock".to_string())
|
||||
})?;
|
||||
|
||||
let json = serde_json::to_string_pretty(&*cache)?;
|
||||
|
||||
// Write atomically by writing to temp file then renaming
|
||||
let temp_path = self.path.with_extension("json.tmp");
|
||||
fs::write(&temp_path, &json)?;
|
||||
fs::rename(&temp_path, &self.path)?;
|
||||
|
||||
// Set restrictive permissions on Unix
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = std::fs::Permissions::from_mode(0o600);
|
||||
fs::set_permissions(&self.path, perms)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl CredentialStore for FileStore {
|
||||
fn store(&self, provider: &str, credentials: &StoredCredentials) -> Result<()> {
|
||||
{
|
||||
let mut cache = self.cache.write().map_err(|_| {
|
||||
CredentialError::FileStorage("Failed to acquire write lock".to_string())
|
||||
})?;
|
||||
|
||||
cache.insert(provider.to_string(), credentials.clone());
|
||||
}
|
||||
|
||||
self.save_cache()
|
||||
}
|
||||
|
||||
fn retrieve(&self, provider: &str) -> Result<Option<StoredCredentials>> {
|
||||
let cache = self.cache.read().map_err(|_| {
|
||||
CredentialError::FileStorage("Failed to acquire read lock".to_string())
|
||||
})?;
|
||||
|
||||
Ok(cache.get(provider).cloned())
|
||||
}
|
||||
|
||||
fn delete(&self, provider: &str) -> Result<()> {
|
||||
{
|
||||
let mut cache = self.cache.write().map_err(|_| {
|
||||
CredentialError::FileStorage("Failed to acquire write lock".to_string())
|
||||
})?;
|
||||
|
||||
cache.remove(provider);
|
||||
}
|
||||
|
||||
self.save_cache()
|
||||
}
|
||||
|
||||
fn list_providers(&self) -> Result<Vec<String>> {
|
||||
let cache = self.cache.read().map_err(|_| {
|
||||
CredentialError::FileStorage("Failed to acquire read lock".to_string())
|
||||
})?;
|
||||
|
||||
Ok(cache.keys().cloned().collect())
|
||||
}
|
||||
|
||||
fn is_available(&self) -> bool {
|
||||
// File storage is always available
|
||||
true
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"file"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_file_store_operations() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let credentials_path = temp_dir.path().join("credentials.json");
|
||||
|
||||
let store = FileStore::with_path(credentials_path.clone()).unwrap();
|
||||
|
||||
// Store credentials
|
||||
let creds = StoredCredentials {
|
||||
provider: "test-provider".to_string(),
|
||||
access_token: "test-token".to_string(),
|
||||
refresh_token: Some("refresh-token".to_string()),
|
||||
expires_at: Some(1234567890),
|
||||
};
|
||||
|
||||
store.store("test-provider", &creds).unwrap();
|
||||
|
||||
// Retrieve credentials
|
||||
let retrieved = store.retrieve("test-provider").unwrap();
|
||||
assert!(retrieved.is_some());
|
||||
let retrieved = retrieved.unwrap();
|
||||
assert_eq!(retrieved.provider, "test-provider");
|
||||
assert_eq!(retrieved.access_token, "test-token");
|
||||
assert_eq!(retrieved.refresh_token, Some("refresh-token".to_string()));
|
||||
assert_eq!(retrieved.expires_at, Some(1234567890));
|
||||
|
||||
// List providers
|
||||
let providers = store.list_providers().unwrap();
|
||||
assert_eq!(providers, vec!["test-provider"]);
|
||||
|
||||
// Delete credentials
|
||||
store.delete("test-provider").unwrap();
|
||||
let retrieved = store.retrieve("test-provider").unwrap();
|
||||
assert!(retrieved.is_none());
|
||||
|
||||
// List should be empty
|
||||
let providers = store.list_providers().unwrap();
|
||||
assert!(providers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_store_persistence() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let credentials_path = temp_dir.path().join("credentials.json");
|
||||
|
||||
// Store with first instance
|
||||
{
|
||||
let store = FileStore::with_path(credentials_path.clone()).unwrap();
|
||||
let creds = StoredCredentials {
|
||||
provider: "persistent".to_string(),
|
||||
access_token: "persistent-token".to_string(),
|
||||
refresh_token: None,
|
||||
expires_at: None,
|
||||
};
|
||||
store.store("persistent", &creds).unwrap();
|
||||
}
|
||||
|
||||
// Retrieve with new instance
|
||||
{
|
||||
let store = FileStore::with_path(credentials_path).unwrap();
|
||||
let retrieved = store.retrieve("persistent").unwrap();
|
||||
assert!(retrieved.is_some());
|
||||
assert_eq!(retrieved.unwrap().access_token, "persistent-token");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_store_multiple_providers() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let credentials_path = temp_dir.path().join("credentials.json");
|
||||
|
||||
let store = FileStore::with_path(credentials_path).unwrap();
|
||||
|
||||
// Store multiple providers
|
||||
for provider in &["anthropic", "openai", "ollama"] {
|
||||
let creds = StoredCredentials {
|
||||
provider: provider.to_string(),
|
||||
access_token: format!("{}-token", provider),
|
||||
refresh_token: None,
|
||||
expires_at: None,
|
||||
};
|
||||
store.store(provider, &creds).unwrap();
|
||||
}
|
||||
|
||||
// Verify all are stored
|
||||
let providers = store.list_providers().unwrap();
|
||||
assert_eq!(providers.len(), 3);
|
||||
|
||||
// Retrieve each
|
||||
for provider in &["anthropic", "openai", "ollama"] {
|
||||
let creds = store.retrieve(provider).unwrap().unwrap();
|
||||
assert_eq!(creds.access_token, format!("{}-token", provider));
|
||||
}
|
||||
}
|
||||
}
|
||||
434
crates/platform/credentials/src/helpers.rs
Normal file
434
crates/platform/credentials/src/helpers.rs
Normal file
@@ -0,0 +1,434 @@
|
||||
//! API Key Helpers
|
||||
//!
|
||||
//! Provides dynamic API key retrieval from external password managers and secret stores.
|
||||
//! This allows users to store their credentials securely while still using them with Owlen.
|
||||
//!
|
||||
//! # Supported Password Managers
|
||||
//!
|
||||
//! - **1Password CLI:** `op read "op://vault/item/field"`
|
||||
//! - **Bitwarden CLI:** `bw get password item`
|
||||
//! - **pass (Unix):** `pass show path/to/secret`
|
||||
//! - **Custom commands:** Any shell command that outputs the secret
|
||||
//!
|
||||
//! # Configuration
|
||||
//!
|
||||
//! API key helpers are configured in your config file:
|
||||
//!
|
||||
//! ```toml
|
||||
//! [credentials.helpers]
|
||||
//! anthropic = { command = "op read 'op://Private/Anthropic/api_key'" }
|
||||
//! openai = { command = "pass show openai/api_key", ttl_secs = 3600 }
|
||||
//! ```
|
||||
//!
|
||||
//! # Caching
|
||||
//!
|
||||
//! Command results are cached for a configurable TTL (time-to-live) to avoid
|
||||
//! repeatedly executing potentially slow commands (like 1Password which may
|
||||
//! require unlocking). Default TTL is 5 minutes.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! use credentials::helpers::{ApiKeyHelper, CommandHelper};
|
||||
//!
|
||||
//! // Create a helper for 1Password
|
||||
//! let helper = CommandHelper::new("op read 'op://Private/Anthropic/api_key'")
|
||||
//! .with_ttl(Duration::from_secs(600));
|
||||
//!
|
||||
//! // Get the key (executes command if cache expired)
|
||||
//! let api_key = helper.get_key()?;
|
||||
//! ```
|
||||
|
||||
use std::process::Command;
|
||||
use std::sync::RwLock;
|
||||
use std::time::{Duration, Instant};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors that can occur when using API key helpers
|
||||
#[derive(Error, Debug)]
|
||||
pub enum HelperError {
|
||||
#[error("Command execution failed: {0}")]
|
||||
CommandFailed(String),
|
||||
|
||||
#[error("Command returned non-zero exit code: {code}")]
|
||||
NonZeroExit { code: i32, stderr: String },
|
||||
|
||||
#[error("Command output was empty")]
|
||||
EmptyOutput,
|
||||
|
||||
#[error("Failed to parse command output: {0}")]
|
||||
ParseError(String),
|
||||
|
||||
#[error("Cache error: {0}")]
|
||||
CacheError(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, HelperError>;
|
||||
|
||||
/// Trait for types that can provide API keys dynamically
|
||||
pub trait ApiKeyHelper: Send + Sync {
|
||||
/// Get the API key, potentially executing a command or reading from cache
|
||||
fn get_key(&self) -> Result<String>;
|
||||
|
||||
/// Check if the cached value (if any) is still valid
|
||||
fn is_cached(&self) -> bool;
|
||||
|
||||
/// Clear any cached value
|
||||
fn clear_cache(&self);
|
||||
|
||||
/// Get the source description (for debugging/logging)
|
||||
fn source(&self) -> &str;
|
||||
}
|
||||
|
||||
/// Cached API key with expiration
|
||||
#[derive(Debug)]
|
||||
struct CachedKey {
|
||||
value: String,
|
||||
fetched_at: Instant,
|
||||
}
|
||||
|
||||
/// API key helper that executes a shell command to retrieve the key
|
||||
pub struct CommandHelper {
|
||||
/// The command to execute
|
||||
command: String,
|
||||
|
||||
/// How long to cache the result (default: 5 minutes)
|
||||
ttl: Duration,
|
||||
|
||||
/// Cached key value
|
||||
cache: RwLock<Option<CachedKey>>,
|
||||
}
|
||||
|
||||
impl CommandHelper {
|
||||
/// Create a new command helper
|
||||
pub fn new(command: impl Into<String>) -> Self {
|
||||
Self {
|
||||
command: command.into(),
|
||||
ttl: Duration::from_secs(5 * 60), // 5 minutes default
|
||||
cache: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the TTL for caching
|
||||
pub fn with_ttl(mut self, ttl: Duration) -> Self {
|
||||
self.ttl = ttl;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set TTL in seconds (convenience method for config parsing)
|
||||
pub fn with_ttl_secs(self, secs: u64) -> Self {
|
||||
self.with_ttl(Duration::from_secs(secs))
|
||||
}
|
||||
|
||||
/// Execute the command and get the API key
|
||||
fn execute_command(&self) -> Result<String> {
|
||||
// Use sh -c for shell expansion and pipes
|
||||
let output = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(&self.command)
|
||||
.output()
|
||||
.map_err(|e| HelperError::CommandFailed(e.to_string()))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
let code = output.status.code().unwrap_or(-1);
|
||||
return Err(HelperError::NonZeroExit { code, stderr });
|
||||
}
|
||||
|
||||
let key = String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if key.is_empty() {
|
||||
return Err(HelperError::EmptyOutput);
|
||||
}
|
||||
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Check if the cache is valid
|
||||
fn is_cache_valid(&self) -> bool {
|
||||
let cache = self.cache.read().ok();
|
||||
match cache.as_ref().and_then(|c| c.as_ref()) {
|
||||
Some(cached) => cached.fetched_at.elapsed() < self.ttl,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ApiKeyHelper for CommandHelper {
|
||||
fn get_key(&self) -> Result<String> {
|
||||
// Check cache first
|
||||
{
|
||||
let cache = self.cache.read().map_err(|e| {
|
||||
HelperError::CacheError(format!("Failed to acquire read lock: {}", e))
|
||||
})?;
|
||||
|
||||
if let Some(cached) = cache.as_ref() {
|
||||
if cached.fetched_at.elapsed() < self.ttl {
|
||||
return Ok(cached.value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss or expired - execute command
|
||||
let key = self.execute_command()?;
|
||||
|
||||
// Update cache
|
||||
{
|
||||
let mut cache = self.cache.write().map_err(|e| {
|
||||
HelperError::CacheError(format!("Failed to acquire write lock: {}", e))
|
||||
})?;
|
||||
|
||||
*cache = Some(CachedKey {
|
||||
value: key.clone(),
|
||||
fetched_at: Instant::now(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
fn is_cached(&self) -> bool {
|
||||
self.is_cache_valid()
|
||||
}
|
||||
|
||||
fn clear_cache(&self) {
|
||||
if let Ok(mut cache) = self.cache.write() {
|
||||
*cache = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn source(&self) -> &str {
|
||||
&self.command
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for an API key helper
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HelperConfig {
|
||||
/// The shell command to execute
|
||||
pub command: String,
|
||||
|
||||
/// Optional TTL in seconds (default: 300 = 5 minutes)
|
||||
pub ttl_secs: Option<u64>,
|
||||
}
|
||||
|
||||
impl HelperConfig {
|
||||
/// Create a CommandHelper from this config
|
||||
pub fn into_helper(self) -> CommandHelper {
|
||||
let helper = CommandHelper::new(self.command);
|
||||
if let Some(ttl) = self.ttl_secs {
|
||||
helper.with_ttl_secs(ttl)
|
||||
} else {
|
||||
helper
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Manager for multiple API key helpers
|
||||
pub struct HelperManager {
|
||||
/// Helpers keyed by provider name
|
||||
helpers: std::collections::HashMap<String, Box<dyn ApiKeyHelper>>,
|
||||
}
|
||||
|
||||
impl HelperManager {
|
||||
/// Create a new empty helper manager
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
helpers: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a helper for a provider
|
||||
pub fn register(&mut self, provider: impl Into<String>, helper: Box<dyn ApiKeyHelper>) {
|
||||
self.helpers.insert(provider.into(), helper);
|
||||
}
|
||||
|
||||
/// Register a command helper for a provider
|
||||
pub fn register_command(&mut self, provider: impl Into<String>, command: impl Into<String>) {
|
||||
self.register(provider, Box::new(CommandHelper::new(command)));
|
||||
}
|
||||
|
||||
/// Get an API key for a provider
|
||||
pub fn get_key(&self, provider: &str) -> Option<Result<String>> {
|
||||
self.helpers.get(provider).map(|h| h.get_key())
|
||||
}
|
||||
|
||||
/// Check if a helper is registered for a provider
|
||||
pub fn has_helper(&self, provider: &str) -> bool {
|
||||
self.helpers.contains_key(provider)
|
||||
}
|
||||
|
||||
/// Clear all caches
|
||||
pub fn clear_all_caches(&self) {
|
||||
for helper in self.helpers.values() {
|
||||
helper.clear_cache();
|
||||
}
|
||||
}
|
||||
|
||||
/// List all registered providers
|
||||
pub fn providers(&self) -> Vec<&str> {
|
||||
self.helpers.keys().map(|s| s.as_str()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HelperManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Common Helper Presets
|
||||
// ============================================================================
|
||||
|
||||
/// Create a 1Password CLI helper
|
||||
pub fn one_password_helper(vault: &str, item: &str, field: &str) -> CommandHelper {
|
||||
let command = format!("op read 'op://{}/{}/{}'", vault, item, field);
|
||||
CommandHelper::new(command)
|
||||
}
|
||||
|
||||
/// Create a Bitwarden CLI helper
|
||||
pub fn bitwarden_helper(item: &str) -> CommandHelper {
|
||||
let command = format!("bw get password '{}'", item);
|
||||
CommandHelper::new(command)
|
||||
}
|
||||
|
||||
/// Create a pass (Unix password manager) helper
|
||||
pub fn pass_helper(path: &str) -> CommandHelper {
|
||||
let command = format!("pass show '{}'", path);
|
||||
CommandHelper::new(command)
|
||||
}
|
||||
|
||||
/// Create a helper for AWS Secrets Manager
|
||||
pub fn aws_secrets_helper(secret_id: &str, region: Option<&str>) -> CommandHelper {
|
||||
let mut command = format!(
|
||||
"aws secretsmanager get-secret-value --secret-id '{}' --query SecretString --output text",
|
||||
secret_id
|
||||
);
|
||||
if let Some(r) = region {
|
||||
command = format!("{} --region '{}'", command, r);
|
||||
}
|
||||
CommandHelper::new(command)
|
||||
}
|
||||
|
||||
/// Create a helper for HashiCorp Vault
|
||||
pub fn vault_helper(path: &str, field: Option<&str>) -> CommandHelper {
|
||||
let command = if let Some(f) = field {
|
||||
format!("vault kv get -field='{}' '{}'", f, path)
|
||||
} else {
|
||||
format!("vault kv get -format=json '{}' | jq -r '.data.data.value'", path)
|
||||
};
|
||||
CommandHelper::new(command)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_command_helper_creation() {
|
||||
let helper = CommandHelper::new("echo test");
|
||||
assert_eq!(helper.source(), "echo test");
|
||||
assert_eq!(helper.ttl, Duration::from_secs(5 * 60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_helper_with_ttl() {
|
||||
let helper = CommandHelper::new("echo test")
|
||||
.with_ttl(Duration::from_secs(60));
|
||||
assert_eq!(helper.ttl, Duration::from_secs(60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_helper_execute() {
|
||||
let helper = CommandHelper::new("echo 'test-api-key'");
|
||||
let key = helper.get_key().unwrap();
|
||||
assert_eq!(key, "test-api-key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_helper_caching() {
|
||||
let helper = CommandHelper::new("echo 'test-key'")
|
||||
.with_ttl(Duration::from_secs(60));
|
||||
|
||||
// First call - executes command
|
||||
let key1 = helper.get_key().unwrap();
|
||||
assert!(helper.is_cached());
|
||||
|
||||
// Second call - uses cache
|
||||
let key2 = helper.get_key().unwrap();
|
||||
assert_eq!(key1, key2);
|
||||
|
||||
// Clear cache
|
||||
helper.clear_cache();
|
||||
assert!(!helper.is_cached());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_helper_empty_output() {
|
||||
let helper = CommandHelper::new("echo -n ''");
|
||||
let result = helper.get_key();
|
||||
assert!(matches!(result, Err(HelperError::EmptyOutput)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_helper_non_zero_exit() {
|
||||
let helper = CommandHelper::new("exit 1");
|
||||
let result = helper.get_key();
|
||||
assert!(matches!(result, Err(HelperError::NonZeroExit { code: 1, .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_helper_manager() {
|
||||
let mut manager = HelperManager::new();
|
||||
manager.register_command("test", "echo 'test-key'");
|
||||
|
||||
assert!(manager.has_helper("test"));
|
||||
assert!(!manager.has_helper("unknown"));
|
||||
|
||||
let key = manager.get_key("test").unwrap().unwrap();
|
||||
assert_eq!(key, "test-key");
|
||||
|
||||
assert!(manager.get_key("unknown").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_helper_config() {
|
||||
let config = HelperConfig {
|
||||
command: "echo 'configured-key'".to_string(),
|
||||
ttl_secs: Some(120),
|
||||
};
|
||||
|
||||
let helper = config.into_helper();
|
||||
assert_eq!(helper.ttl, Duration::from_secs(120));
|
||||
assert_eq!(helper.get_key().unwrap(), "configured-key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preset_helpers() {
|
||||
// Test that preset helpers generate correct commands
|
||||
let op = one_password_helper("Private", "Anthropic", "api_key");
|
||||
assert!(op.source().contains("op read"));
|
||||
assert!(op.source().contains("Private"));
|
||||
|
||||
let bw = bitwarden_helper("my-api-key");
|
||||
assert!(bw.source().contains("bw get password"));
|
||||
|
||||
let pass = pass_helper("secrets/openai");
|
||||
assert!(pass.source().contains("pass show"));
|
||||
|
||||
let aws = aws_secrets_helper("my-secret", Some("us-east-1"));
|
||||
assert!(aws.source().contains("aws secretsmanager"));
|
||||
assert!(aws.source().contains("us-east-1"));
|
||||
|
||||
let vault = vault_helper("secret/data/myapp", Some("api_key"));
|
||||
assert!(vault.source().contains("vault kv get"));
|
||||
assert!(vault.source().contains("-field="));
|
||||
}
|
||||
}
|
||||
256
crates/platform/credentials/src/keyring_store.rs
Normal file
256
crates/platform/credentials/src/keyring_store.rs
Normal file
@@ -0,0 +1,256 @@
|
||||
//! Keyring-based Credential Storage
|
||||
//!
|
||||
//! Uses the OS keychain for secure credential storage:
|
||||
//! - macOS: Keychain
|
||||
//! - Linux: secret-service (GNOME Keyring, KDE Wallet)
|
||||
//! - Windows: Credential Manager
|
||||
|
||||
use crate::{CredentialError, CredentialStore, Result};
|
||||
use keyring::Entry;
|
||||
use llm_core::StoredCredentials;
|
||||
|
||||
/// Service name used in the keyring
|
||||
const SERVICE_NAME: &str = "owlen";
|
||||
|
||||
/// Keyring-based credential storage
|
||||
pub struct KeyringStore {
|
||||
/// Whether the keyring is available on this system
|
||||
available: bool,
|
||||
}
|
||||
|
||||
impl KeyringStore {
|
||||
/// Create a new keyring store
|
||||
pub fn new() -> Self {
|
||||
// Test if keyring is available by trying to create an entry
|
||||
let available = Self::check_availability();
|
||||
Self { available }
|
||||
}
|
||||
|
||||
/// Check if the keyring is available on this system
|
||||
fn check_availability() -> bool {
|
||||
// Try to actually store and delete a test entry
|
||||
// Entry::new() always succeeds on Linux, we need to test set_password()
|
||||
match Entry::new(SERVICE_NAME, "__test_availability__") {
|
||||
Ok(entry) => {
|
||||
// Try to set a test password
|
||||
if entry.set_password("__test__").is_ok() {
|
||||
// Clean up the test entry
|
||||
let _ = entry.delete_credential();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the keyring entry for a provider
|
||||
fn entry(&self, provider: &str) -> Result<Entry> {
|
||||
Entry::new(SERVICE_NAME, provider)
|
||||
.map_err(|e| CredentialError::Keyring(format!("Failed to create entry: {}", e)))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for KeyringStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl CredentialStore for KeyringStore {
|
||||
fn store(&self, provider: &str, credentials: &StoredCredentials) -> Result<()> {
|
||||
if !self.available {
|
||||
return Err(CredentialError::Unavailable(
|
||||
"Keyring not available".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let entry = self.entry(provider)?;
|
||||
let json = serde_json::to_string(credentials)?;
|
||||
|
||||
entry
|
||||
.set_password(&json)
|
||||
.map_err(|e| CredentialError::Keyring(format!("Failed to store credentials: {}", e)))?;
|
||||
|
||||
// Update the provider index
|
||||
self.add_to_index(provider)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn retrieve(&self, provider: &str) -> Result<Option<StoredCredentials>> {
|
||||
if !self.available {
|
||||
return Err(CredentialError::Unavailable(
|
||||
"Keyring not available".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let entry = self.entry(provider)?;
|
||||
|
||||
match entry.get_password() {
|
||||
Ok(json) => {
|
||||
let credentials: StoredCredentials = serde_json::from_str(&json)?;
|
||||
Ok(Some(credentials))
|
||||
}
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(e) => Err(CredentialError::Keyring(format!(
|
||||
"Failed to retrieve credentials: {}",
|
||||
e
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn delete(&self, provider: &str) -> Result<()> {
|
||||
if !self.available {
|
||||
return Err(CredentialError::Unavailable(
|
||||
"Keyring not available".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let entry = self.entry(provider)?;
|
||||
|
||||
match entry.delete_credential() {
|
||||
Ok(()) => {
|
||||
// Update the provider index
|
||||
self.remove_from_index(provider)?;
|
||||
Ok(())
|
||||
}
|
||||
Err(keyring::Error::NoEntry) => Ok(()), // Already deleted, that's fine
|
||||
Err(e) => Err(CredentialError::Keyring(format!(
|
||||
"Failed to delete credentials: {}",
|
||||
e
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_providers(&self) -> Result<Vec<String>> {
|
||||
if !self.available {
|
||||
return Err(CredentialError::Unavailable(
|
||||
"Keyring not available".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// The keyring crate doesn't support listing entries directly.
|
||||
// We maintain a separate index entry that tracks known providers.
|
||||
let index_entry = self.entry("__providers_index__")?;
|
||||
|
||||
match index_entry.get_password() {
|
||||
Ok(json) => {
|
||||
let providers: Vec<String> = serde_json::from_str(&json)?;
|
||||
Ok(providers)
|
||||
}
|
||||
Err(keyring::Error::NoEntry) => Ok(Vec::new()),
|
||||
Err(e) => Err(CredentialError::Keyring(format!(
|
||||
"Failed to list providers: {}",
|
||||
e
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_available(&self) -> bool {
|
||||
self.available
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"keyring"
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the providers index when storing/deleting credentials
|
||||
impl KeyringStore {
|
||||
/// Add a provider to the index
|
||||
pub fn add_to_index(&self, provider: &str) -> Result<()> {
|
||||
let mut providers = self.list_providers().unwrap_or_default();
|
||||
if !providers.contains(&provider.to_string()) {
|
||||
providers.push(provider.to_string());
|
||||
self.store_index(&providers)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a provider from the index
|
||||
pub fn remove_from_index(&self, provider: &str) -> Result<()> {
|
||||
let mut providers = self.list_providers().unwrap_or_default();
|
||||
providers.retain(|p| p != provider);
|
||||
self.store_index(&providers)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Store the providers index
|
||||
fn store_index(&self, providers: &[String]) -> Result<()> {
|
||||
let entry = self.entry("__providers_index__")?;
|
||||
let json = serde_json::to_string(providers)?;
|
||||
entry
|
||||
.set_password(&json)
|
||||
.map_err(|e| CredentialError::Keyring(format!("Failed to update index: {}", e)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_keyring_store_creation() {
|
||||
let store = KeyringStore::new();
|
||||
// Just verify it doesn't panic
|
||||
let _ = store.is_available();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_keyring_store_operations() {
|
||||
let store = KeyringStore::new();
|
||||
|
||||
println!("Keyring available: {}", store.is_available());
|
||||
|
||||
if !store.is_available() {
|
||||
println!("Skipping keyring test - not available");
|
||||
return;
|
||||
}
|
||||
|
||||
let test_provider = format!("test_provider_{}", std::process::id());
|
||||
|
||||
// Create test credentials
|
||||
let creds = StoredCredentials {
|
||||
provider: test_provider.clone(),
|
||||
access_token: "test_token_abc123".to_string(),
|
||||
refresh_token: None,
|
||||
expires_at: None,
|
||||
};
|
||||
|
||||
// Store
|
||||
println!("Storing credentials for {}", test_provider);
|
||||
match store.store(&test_provider, &creds) {
|
||||
Ok(()) => println!("Store succeeded"),
|
||||
Err(e) => {
|
||||
println!("Store failed: {}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve
|
||||
println!("Retrieving credentials for {}", test_provider);
|
||||
match store.retrieve(&test_provider) {
|
||||
Ok(Some(retrieved)) => {
|
||||
println!("Retrieved: token={}", retrieved.access_token);
|
||||
assert_eq!(retrieved.access_token, "test_token_abc123");
|
||||
}
|
||||
Ok(None) => panic!("Credentials not found after storing!"),
|
||||
Err(e) => panic!("Retrieve failed: {}", e),
|
||||
}
|
||||
|
||||
// List providers
|
||||
println!("Listing providers");
|
||||
match store.list_providers() {
|
||||
Ok(providers) => {
|
||||
println!("Providers: {:?}", providers);
|
||||
assert!(providers.contains(&test_provider));
|
||||
}
|
||||
Err(e) => println!("List failed: {}", e),
|
||||
}
|
||||
|
||||
// Clean up
|
||||
let _ = store.delete(&test_provider);
|
||||
}
|
||||
}
|
||||
391
crates/platform/credentials/src/lib.rs
Normal file
391
crates/platform/credentials/src/lib.rs
Normal file
@@ -0,0 +1,391 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -36,12 +36,63 @@ pub struct PluginManifest {
|
||||
/// MCP server configuration in plugin manifest
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServerConfig {
|
||||
/// Unique name for this MCP server
|
||||
pub name: String,
|
||||
pub command: String,
|
||||
|
||||
/// Transport type: "stdio" (default), "sse", or "http"
|
||||
#[serde(default = "default_transport_type")]
|
||||
pub transport: McpTransportType,
|
||||
|
||||
/// Command to spawn (for stdio transport)
|
||||
#[serde(default)]
|
||||
pub command: Option<String>,
|
||||
|
||||
/// Arguments for the command (for stdio transport)
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
|
||||
/// Environment variables (for stdio transport)
|
||||
#[serde(default)]
|
||||
pub env: HashMap<String, String>,
|
||||
|
||||
/// URL endpoint (for sse/http transports)
|
||||
#[serde(default)]
|
||||
pub url: Option<String>,
|
||||
|
||||
/// OAuth configuration (for authenticated servers)
|
||||
#[serde(default)]
|
||||
pub oauth: Option<McpOAuthConfig>,
|
||||
}
|
||||
|
||||
fn default_transport_type() -> McpTransportType {
|
||||
McpTransportType::Stdio
|
||||
}
|
||||
|
||||
/// Transport type for MCP servers
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum McpTransportType {
|
||||
#[default]
|
||||
Stdio,
|
||||
Sse,
|
||||
Http,
|
||||
}
|
||||
|
||||
/// OAuth configuration for MCP servers
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpOAuthConfig {
|
||||
/// OAuth client ID
|
||||
pub client_id: String,
|
||||
|
||||
/// OAuth scopes to request
|
||||
#[serde(default)]
|
||||
pub scopes: Vec<String>,
|
||||
|
||||
/// Custom authorization URL (optional, uses server default)
|
||||
pub auth_url: Option<String>,
|
||||
|
||||
/// Custom token URL (optional, uses server default)
|
||||
pub token_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Plugin hook configuration from hooks/hooks.json
|
||||
|
||||
Reference in New Issue
Block a user