mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-11 22:53:00 +00:00
Resolve provider secrets through lithos conventional credentials
lithos-llm now owns which named secrets each provider reads and how they
shape into its auth scheme, including a derived `<PROVIDER>_API_KEY` for
operator-defined providers. Fabro's job shrinks to supplying the store:
`VaultCredentialSource` hands lithos a lookup that reads the process
environment, then the vault, under the same conventional names.
What Fabro still adds on top: the Codex OAuth credential in the vault,
refreshed and persisted when it expires; `{{ secrets.NAME }}` tokens in a
provider's `default_headers`, resolved against the vault and re-sent as
credential headers; and OpenAI organization and project headers from the
environment.
Deleted with the `metadata.fabro.credentials` list: `CredentialRef`,
`CredentialResolver`, `EnvCredentialSource` (now
`VaultCredentialSource::environment_only`), and the `env_var_names` /
`expected_vault_secret_name` helpers, replaced by `secret_names` and
`expected_secret_name` over the lithos table. `openai-codex` joins the
first-party provider id constants.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
0319b24585
commit
6dfc96d3fd
23 changed files with 919 additions and 1237 deletions
|
|
@ -10,8 +10,7 @@ This document defines how Fabro resolves LLM credentials and constructs `fabro-l
|
|||
- Standalone setup and tests that use default settings build a default `Arc<Catalog>` locally, then pass it explicitly.
|
||||
- `GenerateParams::new(model, client)` always receives an explicit `Arc<Client>`.
|
||||
- When a caller needs diagnostics in runtime request-serving paths, call `source.resolve(catalog)` directly and consume both `credentials` and `auth_issues`.
|
||||
- `EnvCredentialSource` is the env-backed source for env-only or no-vault contexts.
|
||||
- `VaultCredentialSource` is the normal source for vault-backed runtime contexts.
|
||||
- `VaultCredentialSource` is the normal source for vault-backed runtime contexts; `VaultCredentialSource::environment_only()` serves env-only or no-vault contexts.
|
||||
|
||||
## Why
|
||||
|
||||
|
|
|
|||
|
|
@ -43,11 +43,10 @@ the vault:
|
|||
|
||||
`FABRO_JWT_PRIVATE_KEY` and `FABRO_JWT_PUBLIC_KEY` are removed. `SESSION_SECRET` is the single auth root.
|
||||
|
||||
Provisioning into the vault is not the same as the resolver being vault-only. `CredentialResolver`
|
||||
owns a documented process-env fallback that runs after the vault lookup
|
||||
(`lib/foundation/fabro-auth/src/resolve.rs:198-204`), and `CredentialRef::Env(name)` is a
|
||||
first-class credential source (`resolve.rs:350`). Which paths that fallback is live on is a
|
||||
per-process question:
|
||||
Provisioning into the vault is not the same as the resolver being vault-only. `VaultCredentialSource`
|
||||
(`lib/foundation/fabro-auth/src/vault_source.rs`) reads each secret name lithos-llm asks for from
|
||||
the process environment first and the vault second, under the same conventional names. Which paths
|
||||
that environment lookup is live on is a per-process question:
|
||||
|
||||
- **Server process** — inert. `lib/apps/fabro-server/src/server.rs:2453` builds
|
||||
`SqlVaultCredentialSource::vault_only(...)`, so the env lookup always returns `None`.
|
||||
|
|
@ -85,11 +84,12 @@ consumption time) and `vars` (non-sensitive run variables, substituted early at
|
|||
`{{ env.NAME }}` tokens still parse but never resolve; they fail loudly with a migration message. A
|
||||
token whose namespace is unavailable in the resolution context also fails loudly.
|
||||
|
||||
The reference implementation is LLM provider `extra_headers`, resolved against the vault at
|
||||
`lib/foundation/fabro-auth/src/resolve.rs:376-378`:
|
||||
The reference implementation is LLM provider `default_headers`, whose `{{ secrets.* }}` values are
|
||||
resolved against the vault in `lib/foundation/fabro-auth/src/vault_source.rs`
|
||||
(`interpolated_headers`) and re-sent as credential headers:
|
||||
|
||||
```toml
|
||||
[llm.providers.example.extra_headers]
|
||||
[llm.providers.example.default_headers]
|
||||
authorization = "Bearer {{ secrets.EXAMPLE_TOKEN }}"
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ digraph Example {
|
|||
|
||||
## Direct SDK environment credentials
|
||||
|
||||
The built-in Modal provider authenticates with two headers, `Modal-Key` and `Modal-Secret`, read from the vault secrets `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET`. `EnvCredentialSource` does not configure Modal automatically because Modal uses two headers instead of one API-key reference.
|
||||
The built-in Modal provider authenticates with two headers, `Modal-Key` and `Modal-Secret`, read from the secrets `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET`. Direct SDK use reads the same two names from the process environment.
|
||||
|
||||
For direct SDK use, enable Modal and set its endpoint URL in the `[llm]` overlay, then build the client with `fabro_llm::build_client` over a `VaultCredentialSource` whose vault holds both secrets. The catalog you pass to the client must be built from the same settings file with `fabro_llm::build_catalog`.
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ use std::path::PathBuf;
|
|||
use std::sync::Arc;
|
||||
|
||||
use fabro_agent::{AgentProfile, AgentProfileBuilder, LocalSandbox, Session, SessionOptions};
|
||||
use fabro_auth::EnvCredentialSource;
|
||||
use fabro_auth::VaultCredentialSource;
|
||||
use fabro_llm::ClientOptions;
|
||||
use fabro_types::{AgentProfileKind, provider_ids};
|
||||
|
||||
|
|
@ -39,7 +39,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
let catalog = Arc::new(fabro_llm::default_catalog());
|
||||
let client = fabro_llm::build_client(
|
||||
(*catalog).clone(),
|
||||
Arc::new(EnvCredentialSource::new()),
|
||||
Arc::new(VaultCredentialSource::environment_only()),
|
||||
ClientOptions::standard(),
|
||||
)
|
||||
.await?
|
||||
|
|
@ -325,12 +325,12 @@ serde_json = "1"
|
|||
|
||||
### Quick start
|
||||
|
||||
Build a catalog, build a client over a credential source, then send a lithos `Request`. `EnvCredentialSource` reads provider keys such as `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and `GEMINI_API_KEY` from the process environment.
|
||||
Build a catalog, build a client over a credential source, then send a lithos `Request`. `VaultCredentialSource::environment_only()` reads provider keys such as `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and `GEMINI_API_KEY` from the process environment.
|
||||
|
||||
```rust
|
||||
use std::sync::Arc;
|
||||
|
||||
use fabro_auth::EnvCredentialSource;
|
||||
use fabro_auth::VaultCredentialSource;
|
||||
use fabro_llm::{ClientOptions, Request};
|
||||
|
||||
#[tokio::main]
|
||||
|
|
@ -338,7 +338,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
let catalog = fabro_llm::default_catalog();
|
||||
let built = fabro_llm::build_client(
|
||||
catalog,
|
||||
Arc::new(EnvCredentialSource::new()),
|
||||
Arc::new(VaultCredentialSource::environment_only()),
|
||||
ClientOptions::standard(),
|
||||
)
|
||||
.await?;
|
||||
|
|
@ -378,7 +378,7 @@ The `fabro_llm::catalog` module reads Fabro policy from the catalog: `enabled_pr
|
|||
|
||||
`ClientOptions::standard()` turns on the lithos retry middleware (three attempts with short exponential backoff) and local attachment inlining. Add middleware with `with_middleware`, replace a provider's adapter with `with_adapter`, or set `http` to inject a configured HTTP client. `fabro_llm::build_offline_client(catalog, options)` builds a client whose only providers are custom adapters, which is how `fabro exec --server` routes every call through a Fabro server.
|
||||
|
||||
Credential sources live in `fabro-auth`: `EnvCredentialSource` for the process environment, `VaultCredentialSource` for a Fabro vault with optional environment fallback, and `SqlVaultCredentialSource` for the server's secret store. Fabro looks up a provider's secret through the `metadata.fabro.credentials` refs on its catalog entry.
|
||||
Credential sources live in `fabro-auth`: `VaultCredentialSource` reads a Fabro vault with an optional process-environment fallback (`VaultCredentialSource::environment_only()` for SDK callers with no vault), and `SqlVaultCredentialSource` reads the server's secret store. lithos-llm decides which secret names a provider reads (`OPENAI_API_KEY`, `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET`, or `<PROVIDER>_API_KEY` for an operator-defined provider); Fabro's vault is keyed by those same names.
|
||||
|
||||
#### Requests and responses
|
||||
|
||||
|
|
|
|||
|
|
@ -91,14 +91,14 @@ fn install_llm_provider_ids(catalog: &Catalog) -> Vec<ProviderId> {
|
|||
|
||||
fn provider_env_var_label(provider: &ProviderId, catalog: &Catalog) -> String {
|
||||
catalog::provider(catalog, provider.as_str())
|
||||
.map(|entry| fabro_auth::env_var_names(entry.provider).join(" / "))
|
||||
.map(|entry| fabro_auth::secret_names(entry.provider).join(" / "))
|
||||
.filter(|label| !label.is_empty())
|
||||
.unwrap_or_else(|| "API_KEY".to_string())
|
||||
}
|
||||
|
||||
fn provider_vault_secret_name(provider: &ProviderId, catalog: &Catalog) -> String {
|
||||
catalog::provider(catalog, provider.as_str())
|
||||
.and_then(|entry| fabro_auth::expected_vault_secret_name(entry.provider))
|
||||
.and_then(|entry| fabro_auth::expected_secret_name(entry.provider))
|
||||
.unwrap_or_else(|| format!("{}_API_KEY", provider.to_string().to_uppercase()))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -860,7 +860,7 @@ fn install_catalog_provider(provider: &ProviderId) -> Result<&'static CatalogPro
|
|||
|
||||
fn provider_secret_name(provider: &ProviderId) -> Result<String, String> {
|
||||
let catalog_provider = install_catalog_provider(provider)?;
|
||||
fabro_auth::expected_vault_secret_name(catalog_provider)
|
||||
fabro_auth::expected_secret_name(catalog_provider)
|
||||
.ok_or_else(|| format!("provider '{provider}' does not define a vault credential path"))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use fabro_agent::{
|
|||
AgentEvent, AgentProfile, AgentProfileBuilder, LocalSandbox, OpenAiProfile, Session,
|
||||
SessionOptions, SubAgentSupervisor, ToolSecrets, WebFetchSummarizer,
|
||||
};
|
||||
use fabro_auth::EnvCredentialSource;
|
||||
use fabro_auth::VaultCredentialSource;
|
||||
use fabro_config::LlmLayer;
|
||||
use fabro_llm::lithos_catalog::Catalog;
|
||||
use fabro_llm::test_support::client_from_env;
|
||||
|
|
@ -146,7 +146,7 @@ async fn make_client(provider: &Provider, twin: Option<&OpenAiTwinOptions>) -> C
|
|||
return make_twin_client(twin.expect("openai twin config should be provided")).await;
|
||||
}
|
||||
|
||||
let source = Arc::new(EnvCredentialSource::new());
|
||||
let source = Arc::new(VaultCredentialSource::environment_only());
|
||||
fabro_llm::build_client(live_catalog(), source, ClientOptions::standard())
|
||||
.await
|
||||
.expect("LLM client should build")
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ fn provider_view(entry: &ProviderEntry<'_>, configured: bool) -> Provider {
|
|||
model_count: u32::try_from(catalog::provider_models(provider).len()).unwrap_or(u32::MAX),
|
||||
default_model: provider.default_model().map(str::to_string),
|
||||
configured,
|
||||
expected_secret_name: fabro_auth::expected_vault_secret_name(provider),
|
||||
expected_secret_name: fabro_auth::expected_secret_name(provider),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7303,7 +7303,7 @@ mod real_llm {
|
|||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use fabro_auth::EnvCredentialSource;
|
||||
use fabro_auth::VaultCredentialSource;
|
||||
use fabro_graphviz::graph::Node;
|
||||
use fabro_llm::{Client, ClientOptions, Request};
|
||||
use fabro_types::WorkflowSettings;
|
||||
|
|
@ -7398,7 +7398,8 @@ mod real_llm {
|
|||
}
|
||||
|
||||
fabro_test::require_env("ANTHROPIC_API_KEY")?;
|
||||
let source: Arc<dyn fabro_auth::CredentialSource> = Arc::new(EnvCredentialSource::new());
|
||||
let source: Arc<dyn fabro_auth::CredentialSource> =
|
||||
Arc::new(VaultCredentialSource::environment_only());
|
||||
Some(Arc::new(
|
||||
fabro_llm::build_client(
|
||||
Catalog::clone(&super::default_catalog()),
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ fabro-redact.workspace = true
|
|||
fabro-static.workspace = true
|
||||
fabro-types = { path = "../fabro-types" }
|
||||
fabro-vault = { path = "../fabro-vault" }
|
||||
lithos-llm = { workspace = true, features = ["runtime"] }
|
||||
lithos-llm = { workspace = true, features = ["runtime", "environment-credentials", "bedrock-aws"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
//! A credential source holding one operator-supplied API key.
|
||||
//!
|
||||
//! Used to validate a pasted key before it is stored: the key is shaped into
|
||||
//! the provider's declared auth scheme and offered for that provider only.
|
||||
//! Used to validate a pasted key before it is stored: the key stands in for
|
||||
//! the first secret the provider conventionally reads, so lithos shapes it
|
||||
//! into the provider's auth scheme exactly as a stored secret would be.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
|
@ -9,11 +10,13 @@ use std::sync::Arc;
|
|||
use async_trait::async_trait;
|
||||
use fabro_vault::Vault;
|
||||
use lithos_llm::catalog::{Catalog, CatalogProvider, ProviderId};
|
||||
use lithos_llm::credentials::Credentials;
|
||||
use lithos_llm::credentials::{ConventionalCredentials, CredentialProvider, Credentials};
|
||||
use tokio::sync::RwLock as AsyncRwLock;
|
||||
|
||||
use crate::credential_source::CredentialSource;
|
||||
use crate::resolve::{ResolveError, credentials_for_api_key};
|
||||
use crate::error::ResolveError;
|
||||
use crate::secrets::expected_secret_name;
|
||||
use crate::vault_source::{auth_scheme_name, interpolated_headers, resolve_error};
|
||||
|
||||
pub struct ApiKeyCredentialSource {
|
||||
provider: ProviderId,
|
||||
|
|
@ -22,8 +25,8 @@ pub struct ApiKeyCredentialSource {
|
|||
}
|
||||
|
||||
impl ApiKeyCredentialSource {
|
||||
/// A source for `provider` with no vault behind it, so extra headers that
|
||||
/// interpolate vault secrets fail to resolve.
|
||||
/// A source for `provider` with no vault behind it, so header secrets the
|
||||
/// provider interpolates from the vault fail to resolve.
|
||||
#[must_use]
|
||||
pub fn new(provider: ProviderId, key: String) -> Self {
|
||||
Self::with_vault(
|
||||
|
|
@ -33,7 +36,8 @@ impl ApiKeyCredentialSource {
|
|||
)
|
||||
}
|
||||
|
||||
/// A source for `provider` whose extra headers resolve against `vault`.
|
||||
/// A source for `provider` whose interpolated headers resolve against
|
||||
/// `vault`.
|
||||
#[must_use]
|
||||
pub fn with_vault(provider: ProviderId, key: String, vault: Arc<AsyncRwLock<Vault>>) -> Self {
|
||||
Self {
|
||||
|
|
@ -52,14 +56,38 @@ impl std::fmt::Debug for ApiKeyCredentialSource {
|
|||
}
|
||||
}
|
||||
|
||||
/// Shapes a caller-supplied API key into the provider's credentials.
|
||||
pub(crate) async fn credentials_for_api_key(
|
||||
provider: &CatalogProvider,
|
||||
key: String,
|
||||
vault: &Vault,
|
||||
) -> Result<Credentials, ResolveError> {
|
||||
let Some(name) = expected_secret_name(provider) else {
|
||||
return Err(ResolveError::SchemeMismatch {
|
||||
provider: provider.id().clone(),
|
||||
scheme: auth_scheme_name(provider.auth()).to_string(),
|
||||
});
|
||||
};
|
||||
let interpolated = interpolated_headers(vault, provider)?;
|
||||
let mut credentials = ConventionalCredentials::new()
|
||||
.with_lookup(move |candidate| (candidate == name).then(|| key.clone()))
|
||||
.credentials(provider)
|
||||
.await
|
||||
.map_err(|err| resolve_error(provider, &err))?;
|
||||
if let Credentials::Http(http) = &mut credentials {
|
||||
http.extra_headers.extend(interpolated);
|
||||
}
|
||||
Ok(credentials)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CredentialSource for ApiKeyCredentialSource {
|
||||
async fn credentials(&self, provider: &CatalogProvider) -> Result<Credentials, ResolveError> {
|
||||
if provider.id() != &self.provider {
|
||||
return Err(ResolveError::NotConfigured(provider.id().clone()));
|
||||
}
|
||||
let vault = self.vault.read().await;
|
||||
credentials_for_api_key(provider, self.key.clone(), &vault)
|
||||
let vault = self.vault.read().await.clone();
|
||||
credentials_for_api_key(provider, self.key.clone(), &vault).await
|
||||
}
|
||||
|
||||
async fn configured_providers(&self, catalog: &Catalog) -> Vec<ProviderId> {
|
||||
|
|
@ -70,3 +98,53 @@ impl CredentialSource for ApiKeyCredentialSource {
|
|||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use lithos_llm::credentials::{HttpAuthentication, HttpCredentials};
|
||||
|
||||
use super::*;
|
||||
use crate::secrets::accepts_api_key;
|
||||
use crate::test_support::test_catalog;
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_key_credentials_follow_the_provider_scheme() {
|
||||
let catalog = test_catalog();
|
||||
let vault = Vault::from_entries(HashMap::new());
|
||||
let openai = credentials_for_api_key(
|
||||
catalog.provider("openai").unwrap(),
|
||||
"sk-test".to_string(),
|
||||
&vault,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
openai,
|
||||
Credentials::Http(HttpCredentials {
|
||||
auth: HttpAuthentication::Bearer(secret),
|
||||
..
|
||||
}) if secret.expose_secret() == "sk-test"
|
||||
));
|
||||
let bedrock = credentials_for_api_key(
|
||||
catalog.provider("bedrock").unwrap(),
|
||||
"sk-test".to_string(),
|
||||
&vault,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(bedrock, Credentials::BedrockBearer(_)));
|
||||
let modal = credentials_for_api_key(
|
||||
catalog.provider("modal").unwrap(),
|
||||
"sk-test".to_string(),
|
||||
&vault,
|
||||
)
|
||||
.await;
|
||||
assert!(modal.is_err(), "modal has no single-key scheme");
|
||||
assert!(!accepts_api_key(catalog.provider("modal").unwrap()));
|
||||
assert!(accepts_api_key(catalog.provider("openai").unwrap()));
|
||||
assert!(accepts_api_key(catalog.provider("bedrock").unwrap()));
|
||||
assert!(!accepts_api_key(catalog.provider("ollama").unwrap()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,124 +0,0 @@
|
|||
//! Credential references declared in `metadata.fabro.credentials`.
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, de};
|
||||
|
||||
/// Where one provider secret comes from.
|
||||
///
|
||||
/// A provider lists these in order; the first that resolves wins.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CredentialRef {
|
||||
/// A token or OAuth entry in the Fabro vault.
|
||||
Vault(String),
|
||||
/// A process environment variable.
|
||||
Env(String),
|
||||
/// The AWS default credential chain. Resolves without a secret; the
|
||||
/// Bedrock adapter signs each request.
|
||||
AwsSigv4,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CredentialRef {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Vault(name) => write!(f, "vault:{name}"),
|
||||
Self::Env(name) => write!(f, "env:{name}"),
|
||||
Self::AwsSigv4 => f.write_str("aws_sigv4"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for CredentialRef {
|
||||
type Err = CredentialRefParseError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
if value == "aws_sigv4" {
|
||||
return Ok(Self::AwsSigv4);
|
||||
}
|
||||
if let Some(name) = value.strip_prefix("vault:") {
|
||||
return if name.is_empty() {
|
||||
Err(CredentialRefParseError::EmptyVault)
|
||||
} else {
|
||||
Ok(Self::Vault(name.to_string()))
|
||||
};
|
||||
}
|
||||
if let Some(name) = value.strip_prefix("env:") {
|
||||
return if name.is_empty() {
|
||||
Err(CredentialRefParseError::EmptyEnv)
|
||||
} else {
|
||||
Ok(Self::Env(name.to_string()))
|
||||
};
|
||||
}
|
||||
Err(CredentialRefParseError::Invalid)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for CredentialRef {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for CredentialRef {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let value = String::deserialize(deserializer)?;
|
||||
value.parse().map_err(de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum CredentialRefParseError {
|
||||
#[error("credential reference must be `vault:<name>`, `env:<NAME>`, or `aws_sigv4`")]
|
||||
Invalid,
|
||||
#[error("credential reference is missing a name after `vault:`")]
|
||||
EmptyVault,
|
||||
#[error("credential reference is missing a name after `env:`")]
|
||||
EmptyEnv,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_each_form() {
|
||||
assert_eq!(
|
||||
"vault:OPENAI_CODEX".parse::<CredentialRef>().unwrap(),
|
||||
CredentialRef::Vault("OPENAI_CODEX".into())
|
||||
);
|
||||
assert_eq!(
|
||||
"env:KIMI_API_KEY".parse::<CredentialRef>().unwrap(),
|
||||
CredentialRef::Env("KIMI_API_KEY".into())
|
||||
);
|
||||
assert_eq!(
|
||||
"aws_sigv4".parse::<CredentialRef>().unwrap(),
|
||||
CredentialRef::AwsSigv4
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_literal_secrets_without_echoing_them() {
|
||||
let err = "sk-ant-1234".parse::<CredentialRef>().unwrap_err();
|
||||
assert_eq!(err, CredentialRefParseError::Invalid);
|
||||
assert!(!err.to_string().contains("sk-ant"));
|
||||
assert_eq!(
|
||||
"vault:".parse::<CredentialRef>().unwrap_err(),
|
||||
CredentialRefParseError::EmptyVault
|
||||
);
|
||||
assert_eq!(
|
||||
"env:".parse::<CredentialRef>().unwrap_err(),
|
||||
CredentialRefParseError::EmptyEnv
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_serde_strings() {
|
||||
let value: Vec<CredentialRef> =
|
||||
serde_json::from_str(r#"["env:A", "vault:b", "aws_sigv4"]"#).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_string(&value).unwrap(),
|
||||
r#"["env:A","vault:b","aws_sigv4"]"#
|
||||
);
|
||||
assert!(serde_json::from_str::<Vec<CredentialRef>>(r#"["sk-literal"]"#).is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,6 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use fabro_types::catalog_policy;
|
||||
use lithos_llm::catalog::{Catalog, CatalogProvider, ProviderId};
|
||||
use lithos_llm::credentials::{CredentialError, CredentialProvider, Credentials};
|
||||
|
||||
|
|
@ -50,10 +49,7 @@ pub trait CredentialSource: Send + Sync {
|
|||
/// the providers that have material but cannot use it.
|
||||
async fn resolve_all(&self, catalog: &Catalog) -> ResolvedCredentials {
|
||||
let mut resolved = ResolvedCredentials::default();
|
||||
for provider in catalog.providers() {
|
||||
if !catalog_policy::provider_policy(provider).is_enabled() {
|
||||
continue;
|
||||
}
|
||||
for provider in catalog.providers().filter(|provider| provider.is_enabled()) {
|
||||
match self.credentials(provider).await {
|
||||
Ok(_) => resolved.ready.push(provider.id().clone()),
|
||||
Err(ResolveError::NotConfigured(_)) => {}
|
||||
|
|
|
|||
|
|
@ -1,106 +0,0 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use fabro_vault::Vault;
|
||||
use lithos_llm::catalog::{Catalog, CatalogProvider, ProviderId};
|
||||
use lithos_llm::credentials::Credentials;
|
||||
use tokio::sync::RwLock as AsyncRwLock;
|
||||
|
||||
use crate::{CredentialSource, EnvLookup, ResolveError, VaultCredentialSource};
|
||||
|
||||
/// A credential source for provider credentials declared as `env:<NAME>`.
|
||||
///
|
||||
/// This public SDK facade does not resolve `{{ secrets.NAME }}` header
|
||||
/// interpolation, so providers whose headers come from the vault stay
|
||||
/// unconfigured here.
|
||||
#[derive(Clone)]
|
||||
pub struct EnvCredentialSource {
|
||||
inner: VaultCredentialSource,
|
||||
}
|
||||
|
||||
impl EnvCredentialSource {
|
||||
#[must_use]
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "EnvCredentialSource is the provider credential process-env facade."
|
||||
)]
|
||||
pub fn new() -> Self {
|
||||
Self::with_env_lookup(Arc::new(|name| std::env::var(name).ok()))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_env_lookup(env_lookup: EnvLookup) -> Self {
|
||||
let vault = Arc::new(AsyncRwLock::new(Vault::from_entries(HashMap::new())));
|
||||
let inner = VaultCredentialSource::with_env_lookup(vault, move |name| env_lookup(name));
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for EnvCredentialSource {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("EnvCredentialSource")
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EnvCredentialSource {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CredentialSource for EnvCredentialSource {
|
||||
async fn credentials(&self, provider: &CatalogProvider) -> Result<Credentials, ResolveError> {
|
||||
self.inner.credentials(provider).await
|
||||
}
|
||||
|
||||
async fn configured_providers(&self, catalog: &Catalog) -> Vec<ProviderId> {
|
||||
self.inner.configured_providers(catalog).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use lithos_llm::catalog::ProviderId;
|
||||
|
||||
use super::EnvCredentialSource;
|
||||
use crate::CredentialSource;
|
||||
use crate::test_support::test_catalog;
|
||||
|
||||
fn test_source(entries: &[(&str, &str)]) -> EnvCredentialSource {
|
||||
let entries: HashMap<String, String> = entries
|
||||
.iter()
|
||||
.map(|(key, value)| ((*key).to_string(), (*value).to_string()))
|
||||
.collect();
|
||||
EnvCredentialSource::with_env_lookup(Arc::new(move |name| entries.get(name).cloned()))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configured_providers_reads_injected_provider_env() {
|
||||
let source = test_source(&[("ANTHROPIC_API_KEY", "anthropic-key")]);
|
||||
assert_eq!(source.configured_providers(&test_catalog()).await, vec![
|
||||
ProviderId::new("anthropic")
|
||||
]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn modal_env_vars_do_not_replace_vault_secrets() {
|
||||
let source = test_source(&[
|
||||
("MODAL_TOKEN_ID", "wk-test"),
|
||||
("MODAL_TOKEN_SECRET", "ws-test"),
|
||||
]);
|
||||
let catalog = test_catalog();
|
||||
let modal = ProviderId::new("modal");
|
||||
assert!(!source.configured_providers(&catalog).await.contains(&modal));
|
||||
let err = source
|
||||
.credentials(catalog.provider("modal").unwrap())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, crate::ResolveError::Interpolation { .. }));
|
||||
}
|
||||
}
|
||||
83
lib/foundation/fabro-auth/src/error.rs
Normal file
83
lib/foundation/fabro-auth/src/error.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
//! Why a provider's credentials could not be resolved.
|
||||
|
||||
use fabro_types::settings::ResolveError as InterpResolveError;
|
||||
use fabro_vault::SecretType;
|
||||
use lithos_llm::catalog::ProviderId;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ResolveError {
|
||||
#[error("{0} is not configured")]
|
||||
NotConfigured(ProviderId),
|
||||
#[error("{provider} header interpolation failed: {source}")]
|
||||
Interpolation {
|
||||
provider: ProviderId,
|
||||
#[source]
|
||||
source: InterpResolveError,
|
||||
},
|
||||
#[error("{provider} vault credential '{name}' is not valid Oauth JSON: {source}")]
|
||||
VaultDecodeFailed {
|
||||
provider: ProviderId,
|
||||
name: String,
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
#[error("{provider} vault credential '{name}' has schema {actual:?}, expected Token or Oauth")]
|
||||
VaultSchemaMismatch {
|
||||
provider: ProviderId,
|
||||
name: String,
|
||||
actual: SecretType,
|
||||
},
|
||||
#[error("{provider} requires re-authentication: {source}")]
|
||||
RefreshFailed {
|
||||
provider: ProviderId,
|
||||
#[source]
|
||||
source: anyhow::Error,
|
||||
},
|
||||
#[error("{0} requires re-authentication: missing refresh token")]
|
||||
RefreshTokenMissing(ProviderId),
|
||||
#[error("{provider} resolved a secret its `{scheme}` auth scheme cannot use")]
|
||||
SchemeMismatch {
|
||||
provider: ProviderId,
|
||||
scheme: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl ResolveError {
|
||||
#[must_use]
|
||||
pub fn provider(&self) -> &ProviderId {
|
||||
match self {
|
||||
Self::NotConfigured(provider)
|
||||
| Self::RefreshTokenMissing(provider)
|
||||
| Self::Interpolation { provider, .. }
|
||||
| Self::VaultDecodeFailed { provider, .. }
|
||||
| Self::VaultSchemaMismatch { provider, .. }
|
||||
| Self::RefreshFailed { provider, .. }
|
||||
| Self::SchemeMismatch { provider, .. } => provider,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn auth_issue_message(provider: &ProviderId, err: &ResolveError) -> String {
|
||||
match err {
|
||||
ResolveError::NotConfigured(_) => format!("{provider} is not configured"),
|
||||
ResolveError::Interpolation { source, .. } => {
|
||||
format!("{provider} header interpolation failed: {source}")
|
||||
}
|
||||
ResolveError::VaultDecodeFailed { name, source, .. } => {
|
||||
format!("{provider} vault credential '{name}' is not valid OAuth JSON: {source}")
|
||||
}
|
||||
ResolveError::VaultSchemaMismatch { name, actual, .. } => format!(
|
||||
"{provider} vault credential '{name}' has schema {actual:?}, expected Token or Oauth"
|
||||
),
|
||||
ResolveError::RefreshFailed { source, .. } => {
|
||||
format!("{provider} requires re-authentication: {source}")
|
||||
}
|
||||
ResolveError::RefreshTokenMissing(_) => {
|
||||
format!("{provider} requires re-authentication: refresh token missing")
|
||||
}
|
||||
ResolveError::SchemeMismatch { scheme, .. } => {
|
||||
format!("{provider} resolved a secret its `{scheme}` auth scheme cannot use")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,11 @@
|
|||
mod api_key_source;
|
||||
mod context;
|
||||
mod credential;
|
||||
mod credential_ref;
|
||||
mod credential_source;
|
||||
mod env_source;
|
||||
mod error;
|
||||
mod extra_headers_source;
|
||||
mod refresh;
|
||||
mod resolve;
|
||||
mod secrets;
|
||||
mod sql_vault_source;
|
||||
mod strategy;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
|
|
@ -19,15 +18,11 @@ pub mod strategies;
|
|||
pub use api_key_source::ApiKeyCredentialSource;
|
||||
pub use context::{AuthContextRequest, AuthContextResponse};
|
||||
pub use credential::{OAuthConfig, OAuthCredential, OAuthTokens};
|
||||
pub use credential_ref::{CredentialRef, CredentialRefParseError};
|
||||
pub use credential_source::{CredentialSource, ResolvedCredentials, lithos_credentials};
|
||||
pub use env_source::EnvCredentialSource;
|
||||
pub use error::{ResolveError, auth_issue_message};
|
||||
pub use extra_headers_source::ExtraHeadersCredentialSource;
|
||||
pub use refresh::refresh_oauth_credential;
|
||||
pub use resolve::{
|
||||
CredentialResolver, EnvLookup, ResolveError, accepts_api_key, auth_issue_message,
|
||||
credential_refs, credentials_for_api_key, env_var_names, expected_vault_secret_name,
|
||||
};
|
||||
pub use secrets::{accepts_api_key, expected_secret_name, secret_names};
|
||||
pub use sql_vault_source::SqlVaultCredentialSource;
|
||||
pub use strategy::{
|
||||
AuthMethod, AuthStrategy, CODEX_AUTH_URL, CODEX_CLIENT_ID, CODEX_TOKEN_URL, LoginResult,
|
||||
|
|
@ -36,6 +31,8 @@ pub use strategy::{
|
|||
pub use vault_ext::{
|
||||
VaultLookupError, vault_get_oauth, vault_get_token, vault_set_oauth, vault_set_token,
|
||||
};
|
||||
pub use vault_source::VaultCredentialSource;
|
||||
pub use vault_source::{EnvLookup, VaultCredentialSource};
|
||||
|
||||
/// The vault entry holding the Codex OAuth credential that serves the
|
||||
/// `openai-codex` provider.
|
||||
pub const OPENAI_CODEX_VAULT_SECRET_NAME: &str = "OPENAI_CODEX";
|
||||
|
|
|
|||
|
|
@ -1,871 +0,0 @@
|
|||
//! Secret resolution for one catalog provider.
|
||||
//!
|
||||
//! A provider's `metadata.fabro.credentials` names where its secret lives.
|
||||
//! [`CredentialResolver`] walks that list against the vault and the process
|
||||
//! environment, refreshes an expired OAuth credential, and shapes the result
|
||||
//! into the lithos [`Credentials`] the provider's declared auth scheme
|
||||
//! expects.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_types::catalog_policy::{self, ProviderPolicy};
|
||||
use fabro_types::provider_ids;
|
||||
use fabro_types::settings::{InterpString, ResolveCtx, ResolveError as InterpResolveError};
|
||||
use fabro_vault::{SecretType, Vault};
|
||||
use lithos_llm::catalog::{AuthScheme, Catalog, CatalogProvider, ProviderId};
|
||||
use lithos_llm::credentials::{
|
||||
CredentialHeader, Credentials, HttpAuthentication, HttpCredentials, SecretValue,
|
||||
};
|
||||
use tokio::sync::RwLock as AsyncRwLock;
|
||||
use tokio::task::spawn_blocking;
|
||||
|
||||
use crate::credential::OAuthCredential;
|
||||
use crate::credential_ref::{CredentialRef, CredentialRefParseError};
|
||||
use crate::refresh::refresh_oauth_credential;
|
||||
use crate::vault_ext::{
|
||||
VaultLookupError, vault_get_oauth, vault_get_token, vault_set_oauth, vault_token_lookup,
|
||||
};
|
||||
|
||||
pub type EnvLookup = Arc<dyn Fn(&str) -> Option<String> + Send + Sync>;
|
||||
|
||||
const CHATGPT_ACCOUNT_ID_HEADER: &str = "ChatGPT-Account-Id";
|
||||
const OPENAI_ORGANIZATION_HEADER: &str = "OpenAI-Organization";
|
||||
const OPENAI_PROJECT_HEADER: &str = "OpenAI-Project";
|
||||
|
||||
/// A secret found for a provider, before it is shaped into [`Credentials`].
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub(crate) enum ResolvedSecret {
|
||||
ApiKey(String),
|
||||
OAuth {
|
||||
credential: Box<OAuthCredential>,
|
||||
vault_name: String,
|
||||
},
|
||||
/// No static secret: the adapter signs with the AWS default chain.
|
||||
AwsDefaultChain,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ResolvedSecret {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::ApiKey(_) => f.write_str("ApiKey(<redacted>)"),
|
||||
Self::OAuth { vault_name, .. } => f
|
||||
.debug_struct("OAuth")
|
||||
.field("vault_name", vault_name)
|
||||
.finish_non_exhaustive(),
|
||||
Self::AwsDefaultChain => f.write_str("AwsDefaultChain"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ResolveError {
|
||||
#[error("{0} is not configured")]
|
||||
NotConfigured(ProviderId),
|
||||
#[error("{provider} declares an invalid credential reference `{reference}`: {source}")]
|
||||
InvalidCredentialRef {
|
||||
provider: ProviderId,
|
||||
reference: String,
|
||||
#[source]
|
||||
source: CredentialRefParseError,
|
||||
},
|
||||
#[error("{provider} header interpolation failed: {source}")]
|
||||
Interpolation {
|
||||
provider: ProviderId,
|
||||
#[source]
|
||||
source: InterpResolveError,
|
||||
},
|
||||
#[error("{provider} vault credential '{name}' has schema {actual:?}, expected Token or Oauth")]
|
||||
VaultSchemaMismatch {
|
||||
provider: ProviderId,
|
||||
name: String,
|
||||
actual: SecretType,
|
||||
},
|
||||
#[error("{provider} vault credential '{name}' is not valid Oauth JSON: {source}")]
|
||||
VaultDecodeFailed {
|
||||
provider: ProviderId,
|
||||
name: String,
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
#[error("{provider} requires re-authentication: {source}")]
|
||||
RefreshFailed {
|
||||
provider: ProviderId,
|
||||
#[source]
|
||||
source: anyhow::Error,
|
||||
},
|
||||
#[error("{0} requires re-authentication: missing refresh token")]
|
||||
RefreshTokenMissing(ProviderId),
|
||||
#[error("{provider} resolved a secret its `{scheme}` auth scheme cannot use")]
|
||||
SchemeMismatch {
|
||||
provider: ProviderId,
|
||||
scheme: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl ResolveError {
|
||||
#[must_use]
|
||||
pub fn provider(&self) -> &ProviderId {
|
||||
match self {
|
||||
Self::NotConfigured(provider)
|
||||
| Self::RefreshTokenMissing(provider)
|
||||
| Self::InvalidCredentialRef { provider, .. }
|
||||
| Self::Interpolation { provider, .. }
|
||||
| Self::VaultSchemaMismatch { provider, .. }
|
||||
| Self::VaultDecodeFailed { provider, .. }
|
||||
| Self::RefreshFailed { provider, .. }
|
||||
| Self::SchemeMismatch { provider, .. } => provider,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn auth_issue_message(provider: &ProviderId, err: &ResolveError) -> String {
|
||||
match err {
|
||||
ResolveError::NotConfigured(_) => format!("{provider} is not configured"),
|
||||
ResolveError::InvalidCredentialRef {
|
||||
reference, source, ..
|
||||
} => format!("{provider} declares an invalid credential reference `{reference}`: {source}"),
|
||||
ResolveError::Interpolation { source, .. } => {
|
||||
format!("{provider} header interpolation failed: {source}")
|
||||
}
|
||||
ResolveError::VaultSchemaMismatch { name, actual, .. } => format!(
|
||||
"{provider} vault credential '{name}' has schema {actual:?}, expected Token or Oauth"
|
||||
),
|
||||
ResolveError::VaultDecodeFailed { name, source, .. } => {
|
||||
format!("{provider} vault credential '{name}' is not valid OAuth JSON: {source}")
|
||||
}
|
||||
ResolveError::RefreshFailed { source, .. } => {
|
||||
format!("{provider} requires re-authentication: {source}")
|
||||
}
|
||||
ResolveError::RefreshTokenMissing(_) => {
|
||||
format!("{provider} requires re-authentication: refresh token missing")
|
||||
}
|
||||
ResolveError::SchemeMismatch { scheme, .. } => {
|
||||
format!("{provider} resolved a secret its `{scheme}` auth scheme cannot use")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The credential references a provider declares, in resolution order.
|
||||
pub fn credential_refs(provider: &CatalogProvider) -> Result<Vec<CredentialRef>, ResolveError> {
|
||||
credential_refs_from_policy(provider.id(), &catalog_policy::provider_policy(provider))
|
||||
}
|
||||
|
||||
fn credential_refs_from_policy(
|
||||
provider: &ProviderId,
|
||||
policy: &ProviderPolicy,
|
||||
) -> Result<Vec<CredentialRef>, ResolveError> {
|
||||
policy
|
||||
.credentials
|
||||
.iter()
|
||||
.map(|reference| {
|
||||
reference
|
||||
.parse()
|
||||
.map_err(|source| ResolveError::InvalidCredentialRef {
|
||||
provider: provider.clone(),
|
||||
reference: reference.clone(),
|
||||
source,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The vault entry an operator should create to configure `provider`, when
|
||||
/// the provider reads a vault secret.
|
||||
#[must_use]
|
||||
pub fn expected_vault_secret_name(provider: &CatalogProvider) -> Option<String> {
|
||||
credential_refs(provider)
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.find_map(|reference| match reference {
|
||||
CredentialRef::Vault(name) => Some(name),
|
||||
CredentialRef::Env(_) | CredentialRef::AwsSigv4 => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The environment variables an operator can set to configure `provider`.
|
||||
#[must_use]
|
||||
pub fn env_var_names(provider: &CatalogProvider) -> Vec<String> {
|
||||
credential_refs(provider)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|reference| match reference {
|
||||
CredentialRef::Env(name) => Some(name),
|
||||
CredentialRef::Vault(_) | CredentialRef::AwsSigv4 => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether the provider takes a single API key an operator can paste in.
|
||||
#[must_use]
|
||||
pub fn accepts_api_key(provider: &CatalogProvider) -> bool {
|
||||
matches!(
|
||||
provider.auth(),
|
||||
AuthScheme::Bearer { .. } | AuthScheme::Header { .. } | AuthScheme::BedrockBearer
|
||||
) && credential_refs(provider).is_ok_and(|refs| {
|
||||
refs.iter()
|
||||
.any(|reference| matches!(reference, CredentialRef::Vault(_) | CredentialRef::Env(_)))
|
||||
})
|
||||
}
|
||||
|
||||
fn auth_scheme_name(scheme: &AuthScheme) -> &'static str {
|
||||
match scheme {
|
||||
AuthScheme::None => "none",
|
||||
AuthScheme::Bearer { .. } => "bearer",
|
||||
AuthScheme::Header { .. } => "header",
|
||||
AuthScheme::Headers => "headers",
|
||||
AuthScheme::Aws { .. } => "aws",
|
||||
AuthScheme::BedrockBearer => "bedrock_bearer",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// Shapes a caller-supplied API key into the provider's credentials.
|
||||
///
|
||||
/// Used to validate a key before it is stored. Extra headers that need vault
|
||||
/// secrets are resolved against `vault`.
|
||||
pub fn credentials_for_api_key(
|
||||
provider: &CatalogProvider,
|
||||
key: String,
|
||||
vault: &Vault,
|
||||
) -> Result<Credentials, ResolveError> {
|
||||
let extra_headers = resolved_extra_headers(vault, provider)?;
|
||||
shape_secret(provider, ResolvedSecret::ApiKey(key), extra_headers, None)
|
||||
}
|
||||
|
||||
/// Resolves a provider's `extra_headers` interpolation against the vault.
|
||||
///
|
||||
/// Resolved header values may contain secrets; keep this path free of value
|
||||
/// logging.
|
||||
fn resolved_extra_headers(
|
||||
vault: &Vault,
|
||||
provider: &CatalogProvider,
|
||||
) -> Result<Vec<CredentialHeader>, ResolveError> {
|
||||
let policy = catalog_policy::provider_policy(provider);
|
||||
let mut ctx =
|
||||
ResolveCtx::new().with_secrets(|secret_name| vault_token_lookup(vault, secret_name));
|
||||
resolve_extra_headers(provider.id(), &policy.extra_headers, &mut ctx)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_extra_headers(
|
||||
provider: &ProviderId,
|
||||
headers: &BTreeMap<String, String>,
|
||||
ctx: &mut ResolveCtx<'_>,
|
||||
) -> Result<Vec<CredentialHeader>, ResolveError> {
|
||||
headers
|
||||
.iter()
|
||||
.map(|(name, source)| {
|
||||
let value = InterpString::parse(source)
|
||||
.resolve_with(ctx)
|
||||
.map_err(|source| ResolveError::Interpolation {
|
||||
provider: provider.clone(),
|
||||
source,
|
||||
})?;
|
||||
Ok(CredentialHeader::new(name.clone(), SecretValue::new(value)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn shape_secret(
|
||||
provider: &CatalogProvider,
|
||||
secret: ResolvedSecret,
|
||||
mut extra_headers: Vec<CredentialHeader>,
|
||||
env_lookup: Option<&EnvLookup>,
|
||||
) -> Result<Credentials, ResolveError> {
|
||||
let scheme = provider.auth();
|
||||
let mismatch = || ResolveError::SchemeMismatch {
|
||||
provider: provider.id().clone(),
|
||||
scheme: auth_scheme_name(scheme).to_string(),
|
||||
};
|
||||
if let Some(env_lookup) = env_lookup.filter(|_| provider.id().as_str() == provider_ids::OPENAI)
|
||||
{
|
||||
for (variable, header) in [
|
||||
(EnvVars::OPENAI_ORG_ID, OPENAI_ORGANIZATION_HEADER),
|
||||
(EnvVars::OPENAI_PROJECT_ID, OPENAI_PROJECT_HEADER),
|
||||
] {
|
||||
if let Some(value) = env_lookup(variable) {
|
||||
extra_headers.push(CredentialHeader::new(header, SecretValue::new(value)));
|
||||
}
|
||||
}
|
||||
}
|
||||
match (scheme, secret) {
|
||||
(AuthScheme::Bearer { .. }, ResolvedSecret::ApiKey(key)) => Ok(http_credentials(
|
||||
HttpAuthentication::Bearer(SecretValue::new(key)),
|
||||
extra_headers,
|
||||
)),
|
||||
(AuthScheme::Bearer { .. }, ResolvedSecret::OAuth { credential, .. }) => {
|
||||
if let Some(account_id) = &credential.account_id {
|
||||
extra_headers.push(CredentialHeader::new(
|
||||
CHATGPT_ACCOUNT_ID_HEADER,
|
||||
SecretValue::new(account_id.clone()),
|
||||
));
|
||||
}
|
||||
Ok(http_credentials(
|
||||
HttpAuthentication::Bearer(SecretValue::new(
|
||||
credential.tokens.access_token.clone(),
|
||||
)),
|
||||
extra_headers,
|
||||
))
|
||||
}
|
||||
(AuthScheme::Header { name }, ResolvedSecret::ApiKey(key)) => Ok(http_credentials(
|
||||
HttpAuthentication::Header(CredentialHeader::new(name.clone(), SecretValue::new(key))),
|
||||
extra_headers,
|
||||
)),
|
||||
(AuthScheme::BedrockBearer | AuthScheme::Aws { .. }, ResolvedSecret::ApiKey(key)) => {
|
||||
Ok(Credentials::BedrockBearer(SecretValue::new(key)))
|
||||
}
|
||||
(AuthScheme::Aws { region }, ResolvedSecret::AwsDefaultChain) => {
|
||||
Ok(Credentials::AwsDefaultChain {
|
||||
region: region.clone(),
|
||||
})
|
||||
}
|
||||
_ => Err(mismatch()),
|
||||
}
|
||||
}
|
||||
|
||||
fn http_credentials(auth: HttpAuthentication, extra_headers: Vec<CredentialHeader>) -> Credentials {
|
||||
let mut credentials = HttpCredentials::new(auth);
|
||||
credentials.extra_headers = extra_headers;
|
||||
Credentials::Http(credentials)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CredentialResolver {
|
||||
vault: Arc<AsyncRwLock<Vault>>,
|
||||
env_lookup: EnvLookup,
|
||||
}
|
||||
|
||||
impl CredentialResolver {
|
||||
#[must_use]
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "CredentialResolver owns the process-env fallback used after vault lookup."
|
||||
)]
|
||||
pub fn new(vault: Arc<AsyncRwLock<Vault>>) -> Self {
|
||||
Self::with_env_lookup(vault, Arc::new(|name| std::env::var(name).ok()))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_env_lookup(vault: Arc<AsyncRwLock<Vault>>, env_lookup: EnvLookup) -> Self {
|
||||
Self { vault, env_lookup }
|
||||
}
|
||||
|
||||
/// Resolves `provider`'s credentials for one request attempt.
|
||||
///
|
||||
/// An expired OAuth credential is refreshed and the refreshed tokens are
|
||||
/// written back to the vault before the credentials are returned.
|
||||
pub async fn resolve(&self, provider: &CatalogProvider) -> Result<Credentials, ResolveError> {
|
||||
let provider_id = provider.id().clone();
|
||||
match provider.auth() {
|
||||
AuthScheme::None => {
|
||||
let vault = self.vault.read().await;
|
||||
let headers = resolved_extra_headers(&vault, provider)?;
|
||||
return Ok(Credentials::headers(headers));
|
||||
}
|
||||
AuthScheme::Headers => {
|
||||
let vault = self.vault.read().await;
|
||||
let headers = resolved_extra_headers(&vault, provider)?;
|
||||
if headers.is_empty() {
|
||||
return Err(ResolveError::NotConfigured(provider_id));
|
||||
}
|
||||
return Ok(Credentials::headers(headers));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let (initial_secret, extra_headers) = {
|
||||
let vault = self.vault.read().await;
|
||||
(
|
||||
self.find_secret(&vault, provider)?,
|
||||
resolved_extra_headers(&vault, provider)?,
|
||||
)
|
||||
};
|
||||
|
||||
let secret = match initial_secret {
|
||||
ResolvedSecret::OAuth {
|
||||
credential,
|
||||
vault_name,
|
||||
} if credential.needs_refresh() => {
|
||||
if credential.tokens.refresh_token.is_none() {
|
||||
return Err(ResolveError::RefreshTokenMissing(provider_id));
|
||||
}
|
||||
let refreshed = refresh_oauth_credential(&credential)
|
||||
.await
|
||||
.map_err(|source| ResolveError::RefreshFailed {
|
||||
provider: provider_id.clone(),
|
||||
source,
|
||||
})?;
|
||||
self.persist_oauth(&provider_id, &vault_name, &refreshed)
|
||||
.await?;
|
||||
ResolvedSecret::OAuth {
|
||||
credential: Box::new(refreshed),
|
||||
vault_name,
|
||||
}
|
||||
}
|
||||
secret => secret,
|
||||
};
|
||||
|
||||
shape_secret(provider, secret, extra_headers, Some(&self.env_lookup))
|
||||
}
|
||||
|
||||
async fn persist_oauth(
|
||||
&self,
|
||||
provider: &ProviderId,
|
||||
vault_name: &str,
|
||||
refreshed: &OAuthCredential,
|
||||
) -> Result<(), ResolveError> {
|
||||
let refreshed = refreshed.clone();
|
||||
let vault_name = vault_name.to_string();
|
||||
let vault = Arc::clone(&self.vault);
|
||||
spawn_blocking(move || {
|
||||
let mut vault = vault.blocking_write();
|
||||
vault_set_oauth(&mut vault, &vault_name, &refreshed)
|
||||
.map(|_| ())
|
||||
.map_err(anyhow::Error::from)
|
||||
})
|
||||
.await
|
||||
.map_err(|join_err| ResolveError::RefreshFailed {
|
||||
provider: provider.clone(),
|
||||
source: anyhow::Error::from(join_err),
|
||||
})?
|
||||
.map_err(|source| ResolveError::RefreshFailed {
|
||||
provider: provider.clone(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
/// Providers with credential material present, without refreshing
|
||||
/// anything. Disabled providers are skipped.
|
||||
#[must_use]
|
||||
pub fn configured_providers(&self, vault: &Vault, catalog: &Catalog) -> Vec<ProviderId> {
|
||||
catalog
|
||||
.providers()
|
||||
.filter(|provider| catalog_policy::provider_policy(provider).is_enabled())
|
||||
.filter(|provider| self.has_credential_material(vault, provider))
|
||||
.map(|provider| provider.id().clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn has_credential_material(&self, vault: &Vault, provider: &CatalogProvider) -> bool {
|
||||
match provider.auth() {
|
||||
AuthScheme::None => resolved_extra_headers(vault, provider).is_ok(),
|
||||
AuthScheme::Headers => {
|
||||
resolved_extra_headers(vault, provider).is_ok_and(|headers| !headers.is_empty())
|
||||
}
|
||||
_ => self.find_secret(vault, provider).is_ok(),
|
||||
}
|
||||
}
|
||||
|
||||
fn find_secret(
|
||||
&self,
|
||||
vault: &Vault,
|
||||
provider: &CatalogProvider,
|
||||
) -> Result<ResolvedSecret, ResolveError> {
|
||||
for reference in credential_refs(provider)? {
|
||||
if let Some(secret) = self.secret_from_ref(vault, provider.id(), &reference)? {
|
||||
return Ok(secret);
|
||||
}
|
||||
}
|
||||
Err(ResolveError::NotConfigured(provider.id().clone()))
|
||||
}
|
||||
|
||||
fn secret_from_ref(
|
||||
&self,
|
||||
vault: &Vault,
|
||||
provider: &ProviderId,
|
||||
reference: &CredentialRef,
|
||||
) -> Result<Option<ResolvedSecret>, ResolveError> {
|
||||
match reference {
|
||||
CredentialRef::Vault(name) => match vault_get_token(vault, name) {
|
||||
Ok(Some(token)) => Ok(Some(ResolvedSecret::ApiKey(token))),
|
||||
Ok(None) => Ok(None),
|
||||
Err(VaultLookupError::SchemaMismatch {
|
||||
actual: SecretType::Oauth,
|
||||
..
|
||||
}) => vault_get_oauth(vault, name)
|
||||
.map(|credential| {
|
||||
credential.map(|credential| ResolvedSecret::OAuth {
|
||||
credential: Box::new(credential),
|
||||
vault_name: name.clone(),
|
||||
})
|
||||
})
|
||||
.map_err(|err| vault_lookup_error(provider, name, err)),
|
||||
Err(err) => Err(vault_lookup_error(provider, name, err)),
|
||||
},
|
||||
CredentialRef::Env(name) => Ok((self.env_lookup)(name).map(ResolvedSecret::ApiKey)),
|
||||
CredentialRef::AwsSigv4 => Ok(Some(ResolvedSecret::AwsDefaultChain)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn vault_lookup_error(provider: &ProviderId, name: &str, err: VaultLookupError) -> ResolveError {
|
||||
match err {
|
||||
VaultLookupError::SchemaMismatch { actual, .. } => ResolveError::VaultSchemaMismatch {
|
||||
provider: provider.clone(),
|
||||
name: name.to_string(),
|
||||
actual,
|
||||
},
|
||||
VaultLookupError::DecodeFailed { source, .. } => ResolveError::VaultDecodeFailed {
|
||||
provider: provider.clone(),
|
||||
name: name.to_string(),
|
||||
source,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::{Duration, Utc};
|
||||
use httpmock::Method::POST;
|
||||
use httpmock::MockServer;
|
||||
|
||||
use super::*;
|
||||
use crate::credential::{OAuthConfig, OAuthCredential, OAuthTokens};
|
||||
use crate::test_support::test_catalog;
|
||||
use crate::vault_ext::{vault_get_oauth, vault_set_oauth, vault_set_token};
|
||||
|
||||
fn oauth_credential(token_url: String, expires_at: chrono::DateTime<Utc>) -> OAuthCredential {
|
||||
OAuthCredential {
|
||||
tokens: OAuthTokens {
|
||||
access_token: "expired-access".to_string(),
|
||||
refresh_token: Some("refresh-token".to_string()),
|
||||
expires_at,
|
||||
},
|
||||
config: OAuthConfig {
|
||||
auth_url: "https://auth.openai.com".to_string(),
|
||||
token_url,
|
||||
client_id: "test-client".to_string(),
|
||||
scopes: vec!["openid".to_string()],
|
||||
redirect_uri: Some("https://auth.openai.com/deviceauth/callback".to_string()),
|
||||
use_pkce: true,
|
||||
},
|
||||
account_id: Some("acct_123".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_resolver(vault: Vault, env_lookup: EnvLookup) -> CredentialResolver {
|
||||
CredentialResolver::with_env_lookup(Arc::new(AsyncRwLock::new(vault)), env_lookup)
|
||||
}
|
||||
|
||||
fn empty_vault() -> Vault {
|
||||
Vault::from_entries(std::collections::HashMap::new())
|
||||
}
|
||||
|
||||
fn bearer_secret(credentials: &Credentials) -> &str {
|
||||
match credentials {
|
||||
Credentials::Http(HttpCredentials {
|
||||
auth: HttpAuthentication::Bearer(secret),
|
||||
..
|
||||
}) => secret.expose_secret(),
|
||||
_ => panic!("expected bearer credentials"),
|
||||
}
|
||||
}
|
||||
|
||||
fn header_value<'a>(credentials: &'a Credentials, name: &str) -> Option<&'a str> {
|
||||
match credentials {
|
||||
Credentials::Http(http) => http
|
||||
.extra_headers
|
||||
.iter()
|
||||
.find(|header| header.name.eq_ignore_ascii_case(name))
|
||||
.map(|header| header.value.expose_secret()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn env_listed_first_wins_over_vault() {
|
||||
let mut vault = empty_vault();
|
||||
vault_set_token(&mut vault, "OPENAI_API_KEY", "vault-key").unwrap();
|
||||
let resolver = test_resolver(
|
||||
vault,
|
||||
Arc::new(|name| (name == "OPENAI_API_KEY").then(|| "env-key".to_string())),
|
||||
);
|
||||
let catalog = test_catalog();
|
||||
let credentials = resolver
|
||||
.resolve(catalog.provider("openai").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(bearer_secret(&credentials), "env-key");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn moonshot_falls_back_to_kimi_env_key() {
|
||||
let resolver = test_resolver(
|
||||
empty_vault(),
|
||||
Arc::new(|name| (name == EnvVars::KIMI_API_KEY).then(|| "kimi-key".to_string())),
|
||||
);
|
||||
let catalog = test_catalog();
|
||||
let credentials = resolver
|
||||
.resolve(catalog.provider("moonshot").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(bearer_secret(&credentials), "kimi-key");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn anthropic_uses_its_header_scheme() {
|
||||
let mut vault = empty_vault();
|
||||
vault_set_token(&mut vault, "ANTHROPIC_API_KEY", "anthropic-key").unwrap();
|
||||
let resolver = test_resolver(vault, Arc::new(|_| None));
|
||||
let catalog = test_catalog();
|
||||
let credentials = resolver
|
||||
.resolve(catalog.provider("anthropic").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
match credentials {
|
||||
Credentials::Http(HttpCredentials {
|
||||
auth: HttpAuthentication::Header(header),
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(header.name, "x-api-key");
|
||||
assert_eq!(header.value.expose_secret(), "anthropic-key");
|
||||
}
|
||||
_ => panic!("expected header credentials"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_oauth_becomes_a_bearer_with_account_header() {
|
||||
let mut vault = empty_vault();
|
||||
vault_set_oauth(
|
||||
&mut vault,
|
||||
crate::OPENAI_CODEX_VAULT_SECRET_NAME,
|
||||
&oauth_credential(
|
||||
"https://auth.openai.com/oauth/token".to_string(),
|
||||
Utc::now() + Duration::hours(1),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let resolver = test_resolver(vault, Arc::new(|_| None));
|
||||
let catalog = test_catalog();
|
||||
let credentials = resolver
|
||||
.resolve(catalog.provider("openai-codex").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(bearer_secret(&credentials), "expired-access");
|
||||
assert_eq!(
|
||||
header_value(&credentials, CHATGPT_ACCOUNT_ID_HEADER),
|
||||
Some("acct_123")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_api_key_attaches_org_and_project_from_env() {
|
||||
let resolver = test_resolver(
|
||||
empty_vault(),
|
||||
Arc::new(|name| match name {
|
||||
"OPENAI_API_KEY" => Some("key".to_string()),
|
||||
"OPENAI_ORG_ID" => Some("org".to_string()),
|
||||
"OPENAI_PROJECT_ID" => Some("proj".to_string()),
|
||||
_ => None,
|
||||
}),
|
||||
);
|
||||
let catalog = test_catalog();
|
||||
let credentials = resolver
|
||||
.resolve(catalog.provider("openai").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
header_value(&credentials, "OpenAI-Organization"),
|
||||
Some("org")
|
||||
);
|
||||
assert_eq!(header_value(&credentials, "OpenAI-Project"), Some("proj"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bedrock_falls_back_to_the_aws_default_chain() {
|
||||
let resolver = test_resolver(empty_vault(), Arc::new(|_| None));
|
||||
let catalog = test_catalog();
|
||||
let credentials = resolver
|
||||
.resolve(catalog.provider("bedrock").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(credentials, Credentials::AwsDefaultChain { .. }));
|
||||
|
||||
let resolver = test_resolver(
|
||||
empty_vault(),
|
||||
Arc::new(|name| (name == "BEDROCK_API_KEY").then(|| "bearer".to_string())),
|
||||
);
|
||||
let credentials = resolver
|
||||
.resolve(catalog.provider("bedrock").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(credentials, Credentials::BedrockBearer(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_provider_material_is_not_configured() {
|
||||
let resolver = test_resolver(empty_vault(), Arc::new(|_| None));
|
||||
let catalog = test_catalog();
|
||||
let err = resolver
|
||||
.resolve(catalog.provider("anthropic").unwrap())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
ResolveError::NotConfigured(provider) if provider.as_str() == "anthropic"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn modal_resolves_both_vault_proxy_headers_without_authorization() {
|
||||
let mut vault = empty_vault();
|
||||
vault_set_token(&mut vault, "MODAL_TOKEN_ID", "wk-test").unwrap();
|
||||
vault_set_token(&mut vault, "MODAL_TOKEN_SECRET", "ws-test").unwrap();
|
||||
let resolver = test_resolver(vault, Arc::new(|_| None));
|
||||
let catalog = test_catalog();
|
||||
let modal = catalog.provider("modal").unwrap();
|
||||
{
|
||||
let vault = resolver.vault.read().await;
|
||||
assert!(resolver.has_credential_material(&vault, modal));
|
||||
}
|
||||
let credentials = resolver.resolve(modal).await.unwrap();
|
||||
match &credentials {
|
||||
Credentials::Http(http) => assert!(matches!(http.auth, HttpAuthentication::None)),
|
||||
_ => panic!("expected header-only credentials"),
|
||||
}
|
||||
assert_eq!(header_value(&credentials, "Modal-Key"), Some("wk-test"));
|
||||
assert_eq!(header_value(&credentials, "Modal-Secret"), Some("ws-test"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn modal_is_not_configured_with_only_one_vault_proxy_token() {
|
||||
let mut vault = empty_vault();
|
||||
vault_set_token(&mut vault, "MODAL_TOKEN_ID", "wk-test").unwrap();
|
||||
let resolver = test_resolver(vault, Arc::new(|_| None));
|
||||
let catalog = test_catalog();
|
||||
let modal = catalog.provider("modal").unwrap();
|
||||
let err = resolver.resolve(modal).await.unwrap_err();
|
||||
assert!(matches!(err, ResolveError::Interpolation { .. }), "{err}");
|
||||
assert!(!err.to_string().contains("wk-test"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configured_providers_reads_vault_and_env_without_refreshing() {
|
||||
let mut vault = empty_vault();
|
||||
vault_set_token(&mut vault, "OPENAI_API_KEY", "vault-key").unwrap();
|
||||
let resolver = test_resolver(
|
||||
vault,
|
||||
Arc::new(|name| (name == "ANTHROPIC_API_KEY").then(|| "env".to_string())),
|
||||
);
|
||||
let catalog = test_catalog();
|
||||
let vault = resolver.vault.read().await;
|
||||
let configured = resolver.configured_providers(&vault, &catalog);
|
||||
assert!(configured.contains(&ProviderId::new("openai")));
|
||||
assert!(configured.contains(&ProviderId::new("anthropic")));
|
||||
// Bedrock always resolves through the AWS chain but ships disabled.
|
||||
assert!(!configured.contains(&ProviderId::new("bedrock")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refreshes_expired_oauth_credentials_and_persists_them() {
|
||||
let server = MockServer::start_async().await;
|
||||
let refresh_mock = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/oauth/token")
|
||||
.form_urlencoded_tuple("grant_type", "refresh_token")
|
||||
.form_urlencoded_tuple("client_id", "test-client")
|
||||
.form_urlencoded_tuple("refresh_token", "refresh-token");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"access_token": "new-access",
|
||||
"refresh_token": "new-refresh",
|
||||
"expires_in": 3600
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
})
|
||||
.await;
|
||||
|
||||
let mut vault = empty_vault();
|
||||
vault_set_oauth(
|
||||
&mut vault,
|
||||
crate::OPENAI_CODEX_VAULT_SECRET_NAME,
|
||||
&oauth_credential(
|
||||
server.url("/oauth/token"),
|
||||
Utc::now() - Duration::minutes(1),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let vault = Arc::new(AsyncRwLock::new(vault));
|
||||
let resolver = CredentialResolver::with_env_lookup(Arc::clone(&vault), Arc::new(|_| None));
|
||||
let catalog = test_catalog();
|
||||
|
||||
let credentials = resolver
|
||||
.resolve(catalog.provider("openai-codex").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(bearer_secret(&credentials), "new-access");
|
||||
|
||||
let stored = {
|
||||
let vault = vault.read().await;
|
||||
vault_get_oauth(&vault, crate::OPENAI_CODEX_VAULT_SECRET_NAME)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(stored.tokens.access_token, "new-access");
|
||||
assert_eq!(stored.tokens.refresh_token.as_deref(), Some("new-refresh"));
|
||||
assert_eq!(stored.account_id.as_deref(), Some("acct_123"));
|
||||
refresh_mock.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_oauth_without_refresh_token_requires_reauthentication() {
|
||||
let mut vault = empty_vault();
|
||||
let mut credential = oauth_credential(
|
||||
"https://auth.openai.com/oauth/token".to_string(),
|
||||
Utc::now() - Duration::minutes(1),
|
||||
);
|
||||
credential.tokens.refresh_token = None;
|
||||
vault_set_oauth(
|
||||
&mut vault,
|
||||
crate::OPENAI_CODEX_VAULT_SECRET_NAME,
|
||||
&credential,
|
||||
)
|
||||
.unwrap();
|
||||
let resolver = test_resolver(vault, Arc::new(|_| None));
|
||||
let catalog = test_catalog();
|
||||
let err = resolver
|
||||
.resolve(catalog.provider("openai-codex").unwrap())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ResolveError::RefreshTokenMissing(_)));
|
||||
assert_eq!(
|
||||
auth_issue_message(&ProviderId::new("openai-codex"), &err),
|
||||
"openai-codex requires re-authentication: refresh token missing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_credentials_follow_the_provider_scheme() {
|
||||
let catalog = test_catalog();
|
||||
let vault = empty_vault();
|
||||
let openai = credentials_for_api_key(
|
||||
catalog.provider("openai").unwrap(),
|
||||
"sk-test".to_string(),
|
||||
&vault,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(bearer_secret(&openai), "sk-test");
|
||||
let modal = credentials_for_api_key(
|
||||
catalog.provider("modal").unwrap(),
|
||||
"sk-test".to_string(),
|
||||
&vault,
|
||||
);
|
||||
assert!(modal.is_err(), "modal has no single-key scheme");
|
||||
assert!(!accepts_api_key(catalog.provider("modal").unwrap()));
|
||||
assert!(accepts_api_key(catalog.provider("openai").unwrap()));
|
||||
assert!(!accepts_api_key(catalog.provider("ollama").unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_secret_debug_redacts_material() {
|
||||
let debug = format!("{:?}", ResolvedSecret::ApiKey("sk-test".to_string()));
|
||||
assert!(!debug.contains("sk-test"));
|
||||
}
|
||||
}
|
||||
47
lib/foundation/fabro-auth/src/secrets.rs
Normal file
47
lib/foundation/fabro-auth/src/secrets.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
//! Which secrets a provider reads.
|
||||
//!
|
||||
//! lithos-llm owns the convention: `openai` reads `OPENAI_API_KEY`, `gemini`
|
||||
//! reads `GEMINI_API_KEY` then `GOOGLE_API_KEY`, and an operator-defined
|
||||
//! provider reads a name derived from its id. Fabro stores secrets in its
|
||||
//! vault under those same names, so the vault entry an operator creates and
|
||||
//! the environment variable a shell exports are spelled alike.
|
||||
|
||||
use fabro_types::provider_ids;
|
||||
use lithos_llm::catalog::{AuthScheme, CatalogProvider, ProviderId};
|
||||
use lithos_llm::credentials::ConventionalCredentials;
|
||||
|
||||
use crate::OPENAI_CODEX_VAULT_SECRET_NAME;
|
||||
|
||||
/// The secret names `provider` reads, preferred name first.
|
||||
#[must_use]
|
||||
pub fn secret_names(provider: &CatalogProvider) -> Vec<String> {
|
||||
ConventionalCredentials::new().secret_names(provider)
|
||||
}
|
||||
|
||||
/// The secret an operator creates to configure `provider`, when the provider
|
||||
/// reads one.
|
||||
#[must_use]
|
||||
pub fn expected_secret_name(provider: &CatalogProvider) -> Option<String> {
|
||||
secret_names(provider).into_iter().next()
|
||||
}
|
||||
|
||||
/// Whether the provider takes a single API key an operator can paste in.
|
||||
///
|
||||
/// Providers that read several secrets (Modal's two proxy-token headers) or
|
||||
/// none at all (Ollama) do not.
|
||||
#[must_use]
|
||||
pub fn accepts_api_key(provider: &CatalogProvider) -> bool {
|
||||
matches!(
|
||||
provider.auth(),
|
||||
AuthScheme::Bearer { .. }
|
||||
| AuthScheme::Header { .. }
|
||||
| AuthScheme::BedrockBearer
|
||||
| AuthScheme::Aws { .. }
|
||||
) && !secret_names(provider).is_empty()
|
||||
}
|
||||
|
||||
/// The vault entry holding `provider`'s OAuth credential, for the providers
|
||||
/// Fabro can log into with a browser flow.
|
||||
pub(crate) fn oauth_secret_name(provider: &ProviderId) -> Option<&'static str> {
|
||||
(provider.as_str() == provider_ids::OPENAI_CODEX).then_some(OPENAI_CODEX_VAULT_SECRET_NAME)
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
use async_trait::async_trait;
|
||||
use fabro_types::catalog_policy;
|
||||
use lithos_llm::catalog::{CatalogProvider, ProviderId};
|
||||
|
||||
use crate::context::{AuthContextRequest, AuthContextResponse};
|
||||
|
|
@ -18,8 +17,8 @@ impl ApiKeyStrategy {
|
|||
Self {
|
||||
provider_id: provider.id().clone(),
|
||||
display_name: provider.display_name().to_string(),
|
||||
env_var_names: crate::env_var_names(provider),
|
||||
api_key_url: catalog_policy::provider_policy(provider).api_key_url,
|
||||
env_var_names: crate::secret_names(provider),
|
||||
api_key_url: provider.api_key_url().map(str::to_string),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_key_strategy_uses_provider_env_names() {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -14,20 +14,24 @@ use tokio::sync::RwLock as AsyncRwLock;
|
|||
use crate::credential_source::CredentialSource;
|
||||
use crate::vault_source::VaultCredentialSource;
|
||||
|
||||
/// Fabro's policy layer, checked in under `fabro-llm`. Tests in this crate
|
||||
/// need the built-in catalog with `metadata.fabro.credentials` attached.
|
||||
pub const FABRO_POLICY_TOML: &str =
|
||||
include_str!("../../../components/fabro-llm/catalog/fabro-policy.toml");
|
||||
|
||||
/// The lithos built-in catalog with Fabro's policy layer applied.
|
||||
/// The lithos built-in catalog.
|
||||
#[must_use]
|
||||
pub fn test_catalog() -> Catalog {
|
||||
Catalog::builder()
|
||||
.with_builtin()
|
||||
.toml_layer("fabro-policy.toml", FABRO_POLICY_TOML)
|
||||
.expect("fabro policy layer should parse")
|
||||
.build()
|
||||
.expect("built-in catalog with fabro policy should build")
|
||||
.expect("built-in catalog should build")
|
||||
}
|
||||
|
||||
/// The built-in catalog with an operator overlay applied.
|
||||
#[must_use]
|
||||
pub fn test_catalog_with_overlay(overlay: &str) -> Catalog {
|
||||
Catalog::builder()
|
||||
.with_builtin()
|
||||
.overlay_toml(&format!("schema_version = 1\n{overlay}"))
|
||||
.expect("overlay should parse")
|
||||
.build()
|
||||
.expect("built-in catalog with overlay should build")
|
||||
}
|
||||
|
||||
/// A detached in-memory vault holding no secrets.
|
||||
|
|
|
|||
|
|
@ -1,26 +1,63 @@
|
|||
//! Credentials from Fabro's vault, with the process environment as a second
|
||||
//! store when the caller allows it.
|
||||
//!
|
||||
//! lithos-llm knows which named secrets each provider reads and how they shape
|
||||
//! into the provider's authentication scheme. This source supplies the store:
|
||||
//! a name is looked up in the environment first, then in the vault, under the
|
||||
//! same conventional spelling (`OPENAI_API_KEY`, `MODAL_TOKEN_ID`). On top of
|
||||
//! that lithos table, Fabro adds what only it knows about:
|
||||
//!
|
||||
//! - an OAuth credential in the vault (the Codex login), refreshed when it
|
||||
//! expires and written back;
|
||||
//! - `{{ secrets.NAME }}` tokens in a provider's `default_headers`, resolved
|
||||
//! against the vault and re-sent as credential headers so the literal token
|
||||
//! never reaches the wire;
|
||||
//! - OpenAI organization and project headers from the environment.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use fabro_vault::Vault;
|
||||
use lithos_llm::catalog::{Catalog, CatalogProvider, ProviderId};
|
||||
use lithos_llm::credentials::Credentials;
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_types::provider_ids;
|
||||
use fabro_types::settings::{InterpString, ResolveCtx};
|
||||
use fabro_vault::{SecretType, Vault};
|
||||
use lithos_llm::catalog::{AuthScheme, Catalog, CatalogProvider, ProviderId};
|
||||
use lithos_llm::credentials::{
|
||||
ConventionalCredentials, CredentialError, CredentialHeader, CredentialProvider, Credentials,
|
||||
HttpAuthentication, HttpCredentials, SecretValue,
|
||||
};
|
||||
use tokio::sync::RwLock as AsyncRwLock;
|
||||
use tokio::task::spawn_blocking;
|
||||
|
||||
use crate::credential::OAuthCredential;
|
||||
use crate::credential_source::CredentialSource;
|
||||
use crate::{CredentialResolver, EnvLookup, ResolveError};
|
||||
use crate::error::ResolveError;
|
||||
use crate::refresh::refresh_oauth_credential;
|
||||
use crate::secrets::oauth_secret_name;
|
||||
use crate::vault_ext::{VaultLookupError, vault_get_oauth, vault_set_oauth, vault_token_lookup};
|
||||
|
||||
pub type EnvLookup = Arc<dyn Fn(&str) -> Option<String> + Send + Sync>;
|
||||
|
||||
const CHATGPT_ACCOUNT_ID_HEADER: &str = "ChatGPT-Account-Id";
|
||||
const OPENAI_ORGANIZATION_HEADER: &str = "OpenAI-Organization";
|
||||
const OPENAI_PROJECT_HEADER: &str = "OpenAI-Project";
|
||||
|
||||
/// Credentials backed by an in-memory [`Vault`] plus an environment lookup.
|
||||
#[derive(Clone)]
|
||||
pub struct VaultCredentialSource {
|
||||
vault: Arc<AsyncRwLock<Vault>>,
|
||||
resolver: CredentialResolver,
|
||||
vault: Arc<AsyncRwLock<Vault>>,
|
||||
env_lookup: EnvLookup,
|
||||
}
|
||||
|
||||
impl VaultCredentialSource {
|
||||
/// A source over `vault` that falls back to the process environment.
|
||||
#[must_use]
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "VaultCredentialSource::new owns the process-env fallback used after vault lookup."
|
||||
)]
|
||||
pub fn new(vault: Arc<AsyncRwLock<Vault>>) -> Self {
|
||||
let resolver = CredentialResolver::new(Arc::clone(&vault));
|
||||
Self { vault, resolver }
|
||||
Self::with_env_lookup(vault, |name| std::env::var(name).ok())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
|
|
@ -28,19 +65,135 @@ impl VaultCredentialSource {
|
|||
where
|
||||
F: Fn(&str) -> Option<String> + Send + Sync + 'static,
|
||||
{
|
||||
let env_lookup: EnvLookup = Arc::new(env_lookup);
|
||||
let resolver = CredentialResolver::with_env_lookup(Arc::clone(&vault), env_lookup);
|
||||
Self { vault, resolver }
|
||||
Self {
|
||||
vault,
|
||||
env_lookup: Arc::new(env_lookup),
|
||||
}
|
||||
}
|
||||
|
||||
/// A source that reads the vault and nothing else.
|
||||
#[must_use]
|
||||
pub fn vault_only(vault: Arc<AsyncRwLock<Vault>>) -> Self {
|
||||
Self::with_env_lookup(vault, |_| None)
|
||||
}
|
||||
|
||||
/// A source over an empty vault, so every secret comes from the process
|
||||
/// environment. For SDK callers and tools that have no Fabro vault.
|
||||
#[must_use]
|
||||
pub fn environment_only() -> Self {
|
||||
Self::new(Arc::new(AsyncRwLock::new(Vault::from_entries(
|
||||
std::collections::HashMap::new(),
|
||||
))))
|
||||
}
|
||||
|
||||
pub(crate) async fn snapshot(&self) -> Vault {
|
||||
self.vault.read().await.clone()
|
||||
}
|
||||
|
||||
/// The lithos conventional table reading from the environment, then the
|
||||
/// vault.
|
||||
fn conventional(&self, vault: &Vault) -> ConventionalCredentials {
|
||||
let vault = vault.clone();
|
||||
let env_lookup = Arc::clone(&self.env_lookup);
|
||||
ConventionalCredentials::new()
|
||||
.with_lookup(move |name| env_lookup(name).or_else(|| vault_token_lookup(&vault, name)))
|
||||
}
|
||||
|
||||
/// The vault's OAuth credential for `provider`, refreshed and persisted
|
||||
/// when it has expired. `None` when the provider has no OAuth path or the
|
||||
/// vault holds nothing under its name.
|
||||
async fn oauth_credentials(
|
||||
&self,
|
||||
provider: &CatalogProvider,
|
||||
vault: &Vault,
|
||||
) -> Result<Option<Credentials>, ResolveError> {
|
||||
let Some(name) = oauth_secret_name(provider.id()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(entry) = vault.get_entry(name) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if entry.secret_type == SecretType::Token {
|
||||
// A pasted API key stored under the OAuth name still works.
|
||||
return Ok(Some(Credentials::bearer(SecretValue::new(
|
||||
entry.value.clone(),
|
||||
))));
|
||||
}
|
||||
let credential = vault_get_oauth(vault, name)
|
||||
.map_err(|err| vault_lookup_error(provider.id(), name, err))?
|
||||
.expect("entry is present");
|
||||
let credential = if credential.needs_refresh() {
|
||||
if credential.tokens.refresh_token.is_none() {
|
||||
return Err(ResolveError::RefreshTokenMissing(provider.id().clone()));
|
||||
}
|
||||
let refreshed = refresh_oauth_credential(&credential)
|
||||
.await
|
||||
.map_err(|source| ResolveError::RefreshFailed {
|
||||
provider: provider.id().clone(),
|
||||
source,
|
||||
})?;
|
||||
self.persist_oauth(provider.id(), name, &refreshed).await?;
|
||||
refreshed
|
||||
} else {
|
||||
credential
|
||||
};
|
||||
Ok(Some(oauth_bearer(&credential)))
|
||||
}
|
||||
|
||||
async fn persist_oauth(
|
||||
&self,
|
||||
provider: &ProviderId,
|
||||
name: &str,
|
||||
refreshed: &OAuthCredential,
|
||||
) -> Result<(), ResolveError> {
|
||||
let refreshed = refreshed.clone();
|
||||
let name = name.to_string();
|
||||
let vault = Arc::clone(&self.vault);
|
||||
let failed = |source| ResolveError::RefreshFailed {
|
||||
provider: provider.clone(),
|
||||
source,
|
||||
};
|
||||
spawn_blocking(move || {
|
||||
let mut vault = vault.blocking_write();
|
||||
vault_set_oauth(&mut vault, &name, &refreshed)
|
||||
.map(|_| ())
|
||||
.map_err(anyhow::Error::from)
|
||||
})
|
||||
.await
|
||||
.map_err(|join_err| failed(anyhow::Error::from(join_err)))?
|
||||
.map_err(failed)
|
||||
}
|
||||
|
||||
/// Headers Fabro adds on top of what lithos shaped: interpolated
|
||||
/// `default_headers` and, for OpenAI, the organization and project ids
|
||||
/// from the environment.
|
||||
fn decorate(
|
||||
&self,
|
||||
provider: &CatalogProvider,
|
||||
mut credentials: Credentials,
|
||||
interpolated: Vec<CredentialHeader>,
|
||||
) -> Credentials {
|
||||
if let Credentials::Http(http) = &mut credentials {
|
||||
http.extra_headers.extend(interpolated);
|
||||
if provider.id().as_str() == provider_ids::OPENAI {
|
||||
for (variable, header) in [
|
||||
(EnvVars::OPENAI_ORG_ID, OPENAI_ORGANIZATION_HEADER),
|
||||
(EnvVars::OPENAI_PROJECT_ID, OPENAI_PROJECT_HEADER),
|
||||
] {
|
||||
if let Some(value) = (self.env_lookup)(variable) {
|
||||
http.extra_headers
|
||||
.push(CredentialHeader::new(header, SecretValue::new(value)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
credentials
|
||||
}
|
||||
|
||||
async fn has_credential_material(&self, vault: &Vault, provider: &CatalogProvider) -> bool {
|
||||
oauth_secret_name(provider.id()).is_some_and(|name| vault.get_entry(name).is_some())
|
||||
|| self.conventional(vault).credentials(provider).await.is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for VaultCredentialSource {
|
||||
|
|
@ -53,67 +206,433 @@ impl std::fmt::Debug for VaultCredentialSource {
|
|||
#[async_trait]
|
||||
impl CredentialSource for VaultCredentialSource {
|
||||
async fn credentials(&self, provider: &CatalogProvider) -> Result<Credentials, ResolveError> {
|
||||
self.resolver.resolve(provider).await
|
||||
let vault = self.snapshot().await;
|
||||
let interpolated = interpolated_headers(&vault, provider)?;
|
||||
if let Some(oauth) = self.oauth_credentials(provider, &vault).await? {
|
||||
return Ok(self.decorate(provider, oauth, interpolated));
|
||||
}
|
||||
let credentials = self
|
||||
.conventional(&vault)
|
||||
.credentials(provider)
|
||||
.await
|
||||
.map_err(|err| resolve_error(provider, &err))?;
|
||||
Ok(self.decorate(provider, credentials, interpolated))
|
||||
}
|
||||
|
||||
async fn configured_providers(&self, catalog: &Catalog) -> Vec<ProviderId> {
|
||||
let vault = self.vault.read().await;
|
||||
self.resolver.configured_providers(&vault, catalog)
|
||||
let vault = self.snapshot().await;
|
||||
let mut configured = Vec::new();
|
||||
for provider in catalog.providers().filter(|provider| provider.is_enabled()) {
|
||||
if self.has_credential_material(&vault, provider).await {
|
||||
configured.push(provider.id().clone());
|
||||
}
|
||||
}
|
||||
configured
|
||||
}
|
||||
}
|
||||
|
||||
/// Shapes a Codex OAuth credential into the bearer the deployment expects.
|
||||
fn oauth_bearer(credential: &OAuthCredential) -> Credentials {
|
||||
let mut http = HttpCredentials::new(HttpAuthentication::Bearer(SecretValue::new(
|
||||
credential.tokens.access_token.clone(),
|
||||
)));
|
||||
if let Some(account_id) = &credential.account_id {
|
||||
http.extra_headers.push(CredentialHeader::new(
|
||||
CHATGPT_ACCOUNT_ID_HEADER,
|
||||
SecretValue::new(account_id.clone()),
|
||||
));
|
||||
}
|
||||
Credentials::Http(http)
|
||||
}
|
||||
|
||||
/// The provider's `default_headers` whose values reference `{{ secrets.* }}`,
|
||||
/// resolved against the vault.
|
||||
///
|
||||
/// lithos sends `default_headers` verbatim and lets credential headers of the
|
||||
/// same name win, so only the interpolated ones are re-sent here. Resolved
|
||||
/// values may contain secrets; keep this path free of value logging.
|
||||
pub(crate) fn interpolated_headers(
|
||||
vault: &Vault,
|
||||
provider: &CatalogProvider,
|
||||
) -> Result<Vec<CredentialHeader>, ResolveError> {
|
||||
let mut ctx =
|
||||
ResolveCtx::new().with_secrets(|secret_name| vault_token_lookup(vault, secret_name));
|
||||
provider
|
||||
.default_headers()
|
||||
.iter()
|
||||
.map(|(name, source)| (name, InterpString::parse(source)))
|
||||
.filter(|(_, template)| !template.is_literal())
|
||||
.map(|(name, template)| {
|
||||
let value =
|
||||
template
|
||||
.resolve_with(&mut ctx)
|
||||
.map_err(|source| ResolveError::Interpolation {
|
||||
provider: provider.id().clone(),
|
||||
source,
|
||||
})?;
|
||||
Ok(CredentialHeader::new(name.clone(), SecretValue::new(value)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn auth_scheme_name(scheme: &AuthScheme) -> &'static str {
|
||||
match scheme {
|
||||
AuthScheme::None => "none",
|
||||
AuthScheme::Bearer { .. } => "bearer",
|
||||
AuthScheme::Header { .. } => "header",
|
||||
AuthScheme::Headers => "headers",
|
||||
AuthScheme::Aws { .. } => "aws",
|
||||
AuthScheme::BedrockBearer => "bedrock_bearer",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a lithos lookup failure onto Fabro's vocabulary. A missing secret is
|
||||
/// not an issue to report; the provider is simply not configured.
|
||||
pub(crate) fn resolve_error(provider: &CatalogProvider, err: &CredentialError) -> ResolveError {
|
||||
match err {
|
||||
CredentialError::SchemeMismatch { .. } => ResolveError::SchemeMismatch {
|
||||
provider: provider.id().clone(),
|
||||
scheme: auth_scheme_name(provider.auth()).to_string(),
|
||||
},
|
||||
_ => ResolveError::NotConfigured(provider.id().clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn vault_lookup_error(provider: &ProviderId, name: &str, err: VaultLookupError) -> ResolveError {
|
||||
match err {
|
||||
VaultLookupError::SchemaMismatch { actual, .. } => ResolveError::VaultSchemaMismatch {
|
||||
provider: provider.clone(),
|
||||
name: name.to_string(),
|
||||
actual,
|
||||
},
|
||||
VaultLookupError::DecodeFailed { source, .. } => ResolveError::VaultDecodeFailed {
|
||||
provider: provider.clone(),
|
||||
name: name.to_string(),
|
||||
source,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
use fabro_vault::Vault;
|
||||
use lithos_llm::catalog::ProviderId;
|
||||
use tokio::sync::RwLock as AsyncRwLock;
|
||||
use httpmock::Method::POST;
|
||||
use httpmock::MockServer;
|
||||
use lithos_llm::catalog::Catalog;
|
||||
|
||||
use super::VaultCredentialSource;
|
||||
use crate::credential::{OAuthConfig, OAuthCredential, OAuthTokens};
|
||||
use crate::test_support::test_catalog;
|
||||
use crate::vault_ext::{vault_set_oauth, vault_set_token};
|
||||
use crate::{CredentialSource, ResolveError};
|
||||
use super::*;
|
||||
use crate::credential::{OAuthConfig, OAuthTokens};
|
||||
use crate::test_support::{test_catalog, test_catalog_with_overlay};
|
||||
use crate::vault_ext::vault_set_token;
|
||||
use crate::{OPENAI_CODEX_VAULT_SECRET_NAME, auth_issue_message};
|
||||
|
||||
fn expired_openai_credential() -> OAuthCredential {
|
||||
fn oauth_credential(token_url: String, expires_at: chrono::DateTime<Utc>) -> OAuthCredential {
|
||||
OAuthCredential {
|
||||
tokens: OAuthTokens {
|
||||
access_token: "expired-access".to_string(),
|
||||
access_token: "expired-access".to_string(),
|
||||
refresh_token: Some("refresh-token".to_string()),
|
||||
expires_at: Utc::now() - Duration::hours(1),
|
||||
expires_at,
|
||||
},
|
||||
config: OAuthConfig {
|
||||
auth_url: "https://auth.openai.com".to_string(),
|
||||
token_url: "http://127.0.0.1:9/oauth/token".to_string(),
|
||||
client_id: "client".to_string(),
|
||||
scopes: vec!["openid".to_string()],
|
||||
redirect_uri: Some("https://example.com/callback".to_string()),
|
||||
use_pkce: true,
|
||||
auth_url: "https://auth.openai.com".to_string(),
|
||||
token_url,
|
||||
client_id: "test-client".to_string(),
|
||||
scopes: vec!["openid".to_string()],
|
||||
redirect_uri: Some("https://auth.openai.com/deviceauth/callback".to_string()),
|
||||
use_pkce: true,
|
||||
},
|
||||
account_id: Some("acct_123".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_vault() -> Vault {
|
||||
Vault::from_entries(HashMap::new())
|
||||
}
|
||||
|
||||
fn source_with(
|
||||
vault: Vault,
|
||||
env: impl Fn(&str) -> Option<String> + Send + Sync + 'static,
|
||||
) -> VaultCredentialSource {
|
||||
VaultCredentialSource::with_env_lookup(Arc::new(AsyncRwLock::new(vault)), env)
|
||||
}
|
||||
|
||||
fn bearer_secret(credentials: &Credentials) -> &str {
|
||||
match credentials {
|
||||
Credentials::Http(HttpCredentials {
|
||||
auth: HttpAuthentication::Bearer(secret),
|
||||
..
|
||||
}) => secret.expose_secret(),
|
||||
_ => panic!("expected bearer credentials"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A header the credentials carry, whether as the primary `auth` header
|
||||
/// or as an extra header.
|
||||
fn header_value<'a>(credentials: &'a Credentials, name: &str) -> Option<&'a str> {
|
||||
let Credentials::Http(http) = credentials else {
|
||||
return None;
|
||||
};
|
||||
let primary = match &http.auth {
|
||||
HttpAuthentication::Header(header) => Some(header),
|
||||
_ => None,
|
||||
};
|
||||
primary
|
||||
.into_iter()
|
||||
.chain(http.extra_headers.iter())
|
||||
.find(|header| header.name.eq_ignore_ascii_case(name))
|
||||
.map(|header| header.value.expose_secret())
|
||||
}
|
||||
|
||||
/// An operator-defined gateway whose header secret lives in the vault.
|
||||
fn gateway_catalog() -> Catalog {
|
||||
test_catalog_with_overlay(
|
||||
r#"
|
||||
[providers.gateway]
|
||||
display_name = "Gateway"
|
||||
adapter = "openai-compatible"
|
||||
codec = "openai-chat"
|
||||
base_url = "https://gateway.test/v1"
|
||||
auth = { type = "bearer" }
|
||||
default_headers = { "x-portkey-api-key" = "{{ secrets.PORTKEY_API_KEY }}", "x-portkey-config" = "@prod" }
|
||||
|
||||
[providers.gateway.models.large]
|
||||
display_name = "Large"
|
||||
api_model = "large"
|
||||
"#,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_all_separates_ready_providers_from_auth_issues() {
|
||||
let mut vault = Vault::from_entries(HashMap::new());
|
||||
async fn environment_wins_over_the_vault() {
|
||||
let mut vault = empty_vault();
|
||||
vault_set_token(&mut vault, "OPENAI_API_KEY", "vault-key").unwrap();
|
||||
let source = source_with(vault, |name| {
|
||||
(name == "OPENAI_API_KEY").then(|| "env-key".to_string())
|
||||
});
|
||||
let catalog = test_catalog();
|
||||
let credentials = source
|
||||
.credentials(catalog.provider("openai").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(bearer_secret(&credentials), "env-key");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn conventional_fallback_names_apply_to_the_vault() {
|
||||
let mut vault = empty_vault();
|
||||
vault_set_token(&mut vault, EnvVars::KIMI_API_KEY, "kimi-key").unwrap();
|
||||
let source = source_with(vault, |_| None);
|
||||
let catalog = test_catalog();
|
||||
let credentials = source
|
||||
.credentials(catalog.provider("moonshot").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(bearer_secret(&credentials), "kimi-key");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn anthropic_uses_its_header_scheme() {
|
||||
let mut vault = empty_vault();
|
||||
vault_set_token(&mut vault, "ANTHROPIC_API_KEY", "anthropic-key").unwrap();
|
||||
let source = source_with(vault, |_| None);
|
||||
let catalog = test_catalog();
|
||||
let credentials = source
|
||||
.credentials(catalog.provider("anthropic").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
match credentials {
|
||||
Credentials::Http(HttpCredentials {
|
||||
auth: HttpAuthentication::Header(header),
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(header.name, "x-api-key");
|
||||
assert_eq!(header.value.expose_secret(), "anthropic-key");
|
||||
}
|
||||
_ => panic!("expected header credentials"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unlisted_provider_reads_its_derived_secret_name() {
|
||||
let mut vault = empty_vault();
|
||||
vault_set_token(&mut vault, "GATEWAY_API_KEY", "gw-key").unwrap();
|
||||
vault_set_token(&mut vault, "PORTKEY_API_KEY", "pk-key").unwrap();
|
||||
let source = source_with(vault, |_| None);
|
||||
let catalog = gateway_catalog();
|
||||
let credentials = source
|
||||
.credentials(catalog.provider("gateway").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(bearer_secret(&credentials), "gw-key");
|
||||
assert_eq!(
|
||||
header_value(&credentials, "x-portkey-api-key"),
|
||||
Some("pk-key")
|
||||
);
|
||||
assert_eq!(
|
||||
header_value(&credentials, "x-portkey-config"),
|
||||
None,
|
||||
"literal default headers are lithos's to send"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_missing_header_secret_is_an_interpolation_issue() {
|
||||
let mut vault = empty_vault();
|
||||
vault_set_token(&mut vault, "GATEWAY_API_KEY", "gw-key").unwrap();
|
||||
let source = source_with(vault, |_| None);
|
||||
let catalog = gateway_catalog();
|
||||
let err = source
|
||||
.credentials(catalog.provider("gateway").unwrap())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ResolveError::Interpolation { .. }), "{err}");
|
||||
assert!(!err.to_string().contains("gw-key"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_oauth_becomes_a_bearer_with_account_header() {
|
||||
let mut vault = empty_vault();
|
||||
vault_set_oauth(
|
||||
&mut vault,
|
||||
crate::OPENAI_CODEX_VAULT_SECRET_NAME,
|
||||
&expired_openai_credential(),
|
||||
OPENAI_CODEX_VAULT_SECRET_NAME,
|
||||
&oauth_credential(
|
||||
"https://auth.openai.com/oauth/token".to_string(),
|
||||
Utc::now() + Duration::hours(1),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let source = source_with(vault, |_| None);
|
||||
let catalog = test_catalog();
|
||||
let credentials = source
|
||||
.credentials(catalog.provider("openai-codex").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(bearer_secret(&credentials), "expired-access");
|
||||
assert_eq!(
|
||||
header_value(&credentials, CHATGPT_ACCOUNT_ID_HEADER),
|
||||
Some("acct_123")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_api_key_attaches_org_and_project_from_env() {
|
||||
let source = source_with(empty_vault(), |name| match name {
|
||||
"OPENAI_API_KEY" => Some("key".to_string()),
|
||||
"OPENAI_ORG_ID" => Some("org".to_string()),
|
||||
"OPENAI_PROJECT_ID" => Some("proj".to_string()),
|
||||
_ => None,
|
||||
});
|
||||
let catalog = test_catalog();
|
||||
let credentials = source
|
||||
.credentials(catalog.provider("openai").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
header_value(&credentials, "OpenAI-Organization"),
|
||||
Some("org")
|
||||
);
|
||||
assert_eq!(header_value(&credentials, "OpenAI-Project"), Some("proj"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bedrock_takes_a_bearer_and_falls_back_to_the_aws_default_chain() {
|
||||
let catalog = test_catalog();
|
||||
let source = source_with(empty_vault(), |_| None);
|
||||
let credentials = source
|
||||
.credentials(catalog.provider("bedrock").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(credentials, Credentials::AwsDefaultChain { .. }));
|
||||
|
||||
let source = source_with(empty_vault(), |name| {
|
||||
(name == "BEDROCK_API_KEY").then(|| "bearer".to_string())
|
||||
});
|
||||
let credentials = source
|
||||
.credentials(catalog.provider("bedrock").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(credentials, Credentials::BedrockBearer(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_provider_material_is_not_configured() {
|
||||
let source = source_with(empty_vault(), |_| None);
|
||||
let catalog = test_catalog();
|
||||
let err = source
|
||||
.credentials(catalog.provider("anthropic").unwrap())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
ResolveError::NotConfigured(provider) if provider.as_str() == "anthropic"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn modal_needs_both_proxy_tokens() {
|
||||
let mut vault = empty_vault();
|
||||
vault_set_token(&mut vault, "MODAL_TOKEN_ID", "wk-test").unwrap();
|
||||
let source = source_with(vault, |_| None);
|
||||
let catalog = test_catalog();
|
||||
let modal = catalog.provider("modal").unwrap();
|
||||
assert!(matches!(
|
||||
source.credentials(modal).await.unwrap_err(),
|
||||
ResolveError::NotConfigured(_)
|
||||
));
|
||||
|
||||
let mut vault = empty_vault();
|
||||
vault_set_token(&mut vault, "MODAL_TOKEN_ID", "wk-test").unwrap();
|
||||
vault_set_token(&mut vault, "MODAL_TOKEN_SECRET", "ws-test").unwrap();
|
||||
let source = VaultCredentialSource::vault_only(Arc::new(AsyncRwLock::new(vault)));
|
||||
let credentials = source.credentials(modal).await.unwrap();
|
||||
assert_eq!(header_value(&credentials, "Modal-Key"), Some("wk-test"));
|
||||
assert_eq!(header_value(&credentials, "Modal-Secret"), Some("ws-test"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configured_providers_reads_vault_and_env_without_refreshing() {
|
||||
let mut vault = empty_vault();
|
||||
vault_set_token(&mut vault, "OPENAI_API_KEY", "vault-key").unwrap();
|
||||
vault_set_oauth(
|
||||
&mut vault,
|
||||
OPENAI_CODEX_VAULT_SECRET_NAME,
|
||||
&oauth_credential(
|
||||
"http://127.0.0.1:9/oauth/token".to_string(),
|
||||
Utc::now() - Duration::hours(1),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let source = source_with(vault, |name| {
|
||||
(name == "ANTHROPIC_API_KEY").then(|| "env".to_string())
|
||||
});
|
||||
let catalog = test_catalog();
|
||||
let configured = source.configured_providers(&catalog).await;
|
||||
assert!(configured.contains(&ProviderId::new("openai")));
|
||||
assert!(configured.contains(&ProviderId::new("anthropic")));
|
||||
assert!(configured.contains(&ProviderId::new("openai-codex")));
|
||||
// Bedrock always resolves through the AWS chain but ships disabled.
|
||||
assert!(!configured.contains(&ProviderId::new("bedrock")));
|
||||
assert!(!configured.contains(&ProviderId::new("gemini")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_all_separates_ready_providers_from_auth_issues() {
|
||||
let mut vault = empty_vault();
|
||||
vault_set_oauth(
|
||||
&mut vault,
|
||||
OPENAI_CODEX_VAULT_SECRET_NAME,
|
||||
&oauth_credential(
|
||||
"http://127.0.0.1:9/oauth/token".to_string(),
|
||||
Utc::now() - Duration::hours(1),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
vault_set_token(&mut vault, "ANTHROPIC_API_KEY", "anthropic-key").unwrap();
|
||||
|
||||
let source =
|
||||
VaultCredentialSource::with_env_lookup(Arc::new(AsyncRwLock::new(vault)), |_| None);
|
||||
let catalog = test_catalog();
|
||||
|
||||
let resolved = source.resolve_all(&catalog).await;
|
||||
|
||||
let source = source_with(vault, |_| None);
|
||||
let resolved = source.resolve_all(&test_catalog()).await;
|
||||
assert_eq!(resolved.ready, vec![ProviderId::new("anthropic")]);
|
||||
assert_eq!(resolved.auth_issues.len(), 1);
|
||||
assert!(matches!(
|
||||
|
|
@ -123,37 +642,90 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configured_providers_reads_from_vault_without_refreshing() {
|
||||
let mut vault = Vault::from_entries(HashMap::new());
|
||||
vault_set_token(&mut vault, "OPENAI_API_KEY", "openai-key").unwrap();
|
||||
vault_set_token(&mut vault, "ANTHROPIC_API_KEY", "anthropic-key").unwrap();
|
||||
let source =
|
||||
VaultCredentialSource::with_env_lookup(Arc::new(AsyncRwLock::new(vault)), |_| None);
|
||||
async fn vault_only_ignores_the_environment() {
|
||||
let catalog = test_catalog();
|
||||
|
||||
assert_eq!(source.configured_providers(&catalog).await, vec![
|
||||
ProviderId::new("anthropic"),
|
||||
ProviderId::new("openai")
|
||||
]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vault_only_ignores_env_lookup_values() {
|
||||
let catalog = test_catalog();
|
||||
let env_backed = VaultCredentialSource::with_env_lookup(
|
||||
Arc::new(AsyncRwLock::new(Vault::from_entries(HashMap::new()))),
|
||||
|name| (name == "OPENAI_API_KEY").then(|| "env-openai-key".to_string()),
|
||||
);
|
||||
assert_eq!(env_backed.configured_providers(&catalog).await, vec![
|
||||
ProviderId::new("openai")
|
||||
]);
|
||||
|
||||
let vault_only = VaultCredentialSource::vault_only(Arc::new(AsyncRwLock::new(
|
||||
Vault::from_entries(HashMap::new()),
|
||||
)));
|
||||
let vault_only =
|
||||
VaultCredentialSource::vault_only(Arc::new(AsyncRwLock::new(empty_vault())));
|
||||
assert!(vault_only.configured_providers(&catalog).await.is_empty());
|
||||
let resolved = vault_only.resolve_all(&catalog).await;
|
||||
assert!(resolved.ready.is_empty());
|
||||
assert!(resolved.auth_issues.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refreshes_expired_oauth_credentials_and_persists_them() {
|
||||
let server = MockServer::start_async().await;
|
||||
let refresh_mock = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/oauth/token")
|
||||
.form_urlencoded_tuple("grant_type", "refresh_token")
|
||||
.form_urlencoded_tuple("client_id", "test-client")
|
||||
.form_urlencoded_tuple("refresh_token", "refresh-token");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"access_token": "new-access",
|
||||
"refresh_token": "new-refresh",
|
||||
"expires_in": 3600
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
})
|
||||
.await;
|
||||
|
||||
let mut vault = empty_vault();
|
||||
vault_set_oauth(
|
||||
&mut vault,
|
||||
OPENAI_CODEX_VAULT_SECRET_NAME,
|
||||
&oauth_credential(
|
||||
server.url("/oauth/token"),
|
||||
Utc::now() - Duration::minutes(1),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let vault = Arc::new(AsyncRwLock::new(vault));
|
||||
let source = VaultCredentialSource::vault_only(Arc::clone(&vault));
|
||||
let catalog = test_catalog();
|
||||
|
||||
let credentials = source
|
||||
.credentials(catalog.provider("openai-codex").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(bearer_secret(&credentials), "new-access");
|
||||
|
||||
let stored = {
|
||||
let vault = vault.read().await;
|
||||
vault_get_oauth(&vault, OPENAI_CODEX_VAULT_SECRET_NAME)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(stored.tokens.access_token, "new-access");
|
||||
assert_eq!(stored.tokens.refresh_token.as_deref(), Some("new-refresh"));
|
||||
assert_eq!(stored.account_id.as_deref(), Some("acct_123"));
|
||||
refresh_mock.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_oauth_without_refresh_token_requires_reauthentication() {
|
||||
let mut vault = empty_vault();
|
||||
let mut credential = oauth_credential(
|
||||
"https://auth.openai.com/oauth/token".to_string(),
|
||||
Utc::now() - Duration::minutes(1),
|
||||
);
|
||||
credential.tokens.refresh_token = None;
|
||||
vault_set_oauth(&mut vault, OPENAI_CODEX_VAULT_SECRET_NAME, &credential).unwrap();
|
||||
let source = source_with(vault, |_| None);
|
||||
let catalog = test_catalog();
|
||||
let err = source
|
||||
.credentials(catalog.provider("openai-codex").unwrap())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ResolveError::RefreshTokenMissing(_)));
|
||||
assert_eq!(
|
||||
auth_issue_message(&ProviderId::new("openai-codex"), &err),
|
||||
"openai-codex requires re-authentication: refresh token missing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
//! Well-known provider identifiers.
|
||||
//!
|
||||
//! Provider identity is open-ended catalog data, so [`ProviderId`] is a plain
|
||||
//! string newtype. The three first-party providers are named here because
|
||||
//! code paths such as Codex login and the install flow refer to them
|
||||
//! directly.
|
||||
//! string newtype. The first-party providers are named here because code
|
||||
//! paths such as Codex login and the install flow refer to them directly.
|
||||
|
||||
use lithos_llm::catalog::ProviderId;
|
||||
|
||||
pub const ANTHROPIC: &str = "anthropic";
|
||||
pub const OPENAI: &str = "openai";
|
||||
/// The ChatGPT-subscription deployment that stands in for [`OPENAI`] when a
|
||||
/// Codex OAuth credential is present.
|
||||
pub const OPENAI_CODEX: &str = "openai-codex";
|
||||
pub const GEMINI: &str = "gemini";
|
||||
|
||||
#[must_use]
|
||||
|
|
@ -21,6 +23,11 @@ pub fn openai() -> ProviderId {
|
|||
ProviderId::new(OPENAI)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn openai_codex() -> ProviderId {
|
||||
ProviderId::new(OPENAI_CODEX)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn gemini() -> ProviderId {
|
||||
ProviderId::new(GEMINI)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue