fabro/lib/foundation/fabro-auth/src/strategy.rs
Bryan Helmkamp fa3e485c95
Name built-in providers through lithos catalog::builtin
lithos-llm now ships the built-in provider ids and constructors, so
fabro-types drops its provider_ids module and every caller uses
lithos_llm::catalog::builtin directly. The crates that name a provider
now depend on lithos-llm themselves.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 09:48:37 -06:00

115 lines
3.9 KiB
Rust

use async_trait::async_trait;
use lithos_llm::catalog::{Catalog, ProviderId, builtin};
use crate::context::{AuthContextRequest, AuthContextResponse};
use crate::credential::{OAuthConfig, OAuthCredential};
use crate::strategies::api_key::ApiKeyStrategy;
use crate::strategies::codex_device::CodexDeviceStrategy;
pub const CODEX_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
pub const CODEX_AUTH_URL: &str = "https://auth.openai.com";
pub const CODEX_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
#[derive(Debug, Clone)]
pub enum LoginResult {
ApiKey {
provider: ProviderId,
key: String,
},
OAuth {
provider: ProviderId,
credential: OAuthCredential,
},
}
#[async_trait]
pub trait AuthStrategy: Send {
async fn init(&mut self) -> anyhow::Result<AuthContextRequest>;
async fn complete(&mut self, response: AuthContextResponse) -> anyhow::Result<LoginResult>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthMethod {
ApiKey,
CodexDevice(OAuthConfig),
}
#[must_use]
pub fn codex_oauth_config() -> OAuthConfig {
OAuthConfig {
auth_url: CODEX_AUTH_URL.to_string(),
token_url: CODEX_TOKEN_URL.to_string(),
client_id: CODEX_CLIENT_ID.to_string(),
scopes: vec![
"openid".to_string(),
"profile".to_string(),
"email".to_string(),
"offline_access".to_string(),
],
redirect_uri: Some(format!("{CODEX_AUTH_URL}/deviceauth/callback")),
use_pkce: false,
}
}
#[must_use]
pub fn strategy_for(
provider_id: &ProviderId,
method: AuthMethod,
catalog: &Catalog,
) -> Box<dyn AuthStrategy> {
match method {
AuthMethod::ApiKey => {
let provider = catalog
.provider(provider_id.as_str())
.expect("API key auth requires a catalog provider");
Box::new(ApiKeyStrategy::new(provider))
}
AuthMethod::CodexDevice(config) => {
// Programming invariant: every call site that constructs
// `AuthMethod::CodexDevice` pairs it with `ProviderId::OPENAI`.
// `pick_auth_method` returns CodexDevice only when provider ==
// openai(), and the install flow hard-codes `ProviderId::openai()`.
// This check catches future regressions where a new call site
// forgets the constraint.
assert_eq!(
provider_id.as_str(),
builtin::ids::OPENAI,
"CodexDevice auth is only constructed by CLI code for the \
OpenAI provider; all existing call sites enforce this pairing: \
got provider_id={provider_id}"
);
Box::new(CodexDeviceStrategy::new(config))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::AuthContextRequest;
use crate::test_support::test_catalog;
#[test]
fn codex_oauth_config_has_expected_defaults() {
let config = codex_oauth_config();
assert_eq!(config.auth_url, CODEX_AUTH_URL);
assert_eq!(config.token_url, CODEX_TOKEN_URL);
assert_eq!(config.client_id, CODEX_CLIENT_ID);
assert!(!config.use_pkce);
assert!(config.scopes.contains(&"offline_access".to_string()));
}
#[tokio::test]
async fn api_key_strategy_uses_provider_secret_names() {
let catalog = test_catalog();
let provider = catalog.provider("anthropic").unwrap();
let mut strategy = ApiKeyStrategy::new(provider);
let request = strategy.init().await.unwrap();
assert_eq!(request, AuthContextRequest::ApiKey {
provider_id: ProviderId::new("anthropic"),
display_name: "Anthropic".to_string(),
env_var_names: vec!["ANTHROPIC_API_KEY".to_string()],
api_key_url: Some("https://console.anthropic.com/settings/keys".to_string()),
});
}
}