use super::credentials::CredentialsStore;
use super::transaction::{AccountTransaction, FileHostedStore};
use bitrouter_ai::auth::store::{CredentialKey, DEFAULT_ACCOUNT, StoreError};
use bitrouter_ai::providers::hosted::credentials::StoredCredential;
use bitrouter_ai::providers::hosted::session::{CredentialError, HostedSession};
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[derive(Clone)]
pub struct CredentialManager {
path: PathBuf,
session: HostedSession,
}
impl CredentialManager {
pub fn new(path: impl Into<PathBuf>) -> Result<Self, CredentialError> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|_| CredentialError::Storage(StoreError::Unavailable))?;
Ok(Self::with_client(path, client))
}
pub fn with_client(path: impl Into<PathBuf>, client: reqwest::Client) -> Self {
let path = path.into();
let key = CredentialKey {
provider: bitrouter_ai::providers::hosted::PROVIDER_ID.into(),
account: DEFAULT_ACCOUNT.into(),
};
let session = HostedSession::new(key, Arc::new(FileHostedStore::new(path.clone())), client);
Self { path, session }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn session(&self) -> &HostedSession {
&self.session
}
pub async fn current(&self) -> Result<Option<StoredCredential>, CredentialError> {
let _lease = AccountTransaction::begin(&self.path)
.await
.map_err(CredentialError::Storage)?;
Ok(Self::load_store(&self.path)?.current().cloned())
}
pub async fn save(&self, credential: StoredCredential) -> Result<(), CredentialError> {
Self::load_store(&self.path)?
.save(credential)
.map_err(store_error)
}
pub async fn clear(&self) -> Result<Option<StoredCredential>, CredentialError> {
Self::load_store(&self.path)?.clear().map_err(store_error)
}
fn load_store(path: &Path) -> Result<CredentialsStore, CredentialError> {
CredentialsStore::load(path).map_err(store_error)
}
}
fn store_error(_error: anyhow::Error) -> CredentialError {
CredentialError::Storage(StoreError::Unavailable)
}
#[cfg(test)]
mod transaction_tests;
#[cfg(test)]
mod tests {
use super::CredentialManager;
use bitrouter_ai::providers::hosted::credentials::{Credentials, StoredCredential};
use bitrouter_ai::providers::hosted::session::{CredentialError, CredentialSource};
#[tokio::test]
async fn explicit_api_key_bypasses_malformed_store() -> anyhow::Result<()> {
let directory = tempfile::tempdir()?;
let path = directory.path().join("account-credentials.json");
std::fs::write(&path, b"not json")?;
let manager = CredentialManager::with_client(path, reqwest::Client::new());
let resolved = manager
.session()
.resolve_bearer(
Some("brk_explicit.secret"),
Some("https://api.bitrouter.ai/v1"),
)
.await?;
assert_eq!(resolved.secret(), "brk_explicit.secret");
assert_eq!(resolved.source(), CredentialSource::ExplicitApiKey);
Ok(())
}
#[tokio::test]
async fn api_key_only_rejects_oauth() -> anyhow::Result<()> {
let directory = tempfile::tempdir()?;
let manager = CredentialManager::with_client(
directory.path().join("account-credentials.json"),
reqwest::Client::new(),
);
let credential = Credentials {
access_token: "access-token".to_owned(),
refresh_token: Some("refresh-token".to_owned()),
expires_at: chrono::Utc::now() + chrono::Duration::minutes(10),
refresh_token_expires_at: None,
token_type: "Bearer".to_owned(),
scope: "inference:invoke".to_owned(),
client_id: "bitrouter-cli".to_owned(),
authorization_server: "https://api.bitrouter.ai".to_owned(),
namespace_id: Some("ns-test".to_owned()),
subject: None,
};
manager.save(StoredCredential::from(credential)).await?;
let error = match manager
.session()
.resolve_api_key(None, Some("https://api.bitrouter.ai/v1"))
.await
{
Ok(_) => anyhow::bail!("OAuth unexpectedly resolved as an API key"),
Err(error) => error,
};
assert!(matches!(error, CredentialError::WrongCredentialKind));
Ok(())
}
#[tokio::test]
async fn api_key_only_rejects_wrong_origin_oauth_as_wrong_kind() -> anyhow::Result<()> {
let directory = tempfile::tempdir()?;
let manager = CredentialManager::with_client(
directory.path().join("account-credentials.json"),
reqwest::Client::new(),
);
manager
.save(StoredCredential::from(Credentials {
access_token: "access-token".to_owned(),
refresh_token: Some("refresh-token".to_owned()),
expires_at: chrono::Utc::now() + chrono::Duration::minutes(10),
refresh_token_expires_at: None,
token_type: "Bearer".to_owned(),
scope: "inference:invoke".to_owned(),
client_id: "bitrouter-cli".to_owned(),
authorization_server: "https://other.example".to_owned(),
namespace_id: Some("ns-test".to_owned()),
subject: None,
}))
.await?;
let error = match manager
.session()
.resolve_api_key(None, Some("https://api.bitrouter.ai/v1"))
.await
{
Ok(_) => anyhow::bail!("OAuth unexpectedly resolved as an API key"),
Err(error) => error,
};
assert!(matches!(error, CredentialError::WrongCredentialKind));
Ok(())
}
#[tokio::test]
async fn stored_credential_is_origin_confined() -> anyhow::Result<()> {
let directory = tempfile::tempdir()?;
let manager = CredentialManager::with_client(
directory.path().join("account-credentials.json"),
reqwest::Client::new(),
);
manager
.save(StoredCredential::api_key(
"brk_stored.secret".to_owned(),
"https://api.bitrouter.ai".to_owned(),
))
.await?;
let error = match manager
.session()
.resolve_bearer(None, Some("https://example.com/v1"))
.await
{
Ok(_) => anyhow::bail!("credential unexpectedly crossed origins"),
Err(error) => error,
};
assert!(matches!(error, CredentialError::OriginMismatch { .. }));
Ok(())
}
}