use anyhow::Result;
pub const DEFAULT_AS: &str = "https://api.bitrouter.ai";
pub const DEFAULT_CLIENT_ID: &str = "bitrouter-cli";
pub const DEFAULT_SCOPE: &str = "inference:invoke usage:read \
keys:read keys:write \
billing:read \
policy:read policy:write \
byok:read byok:write \
namespace:read";
pub const AS_ENV: &str = "BITROUTER_OAUTH_AS";
pub const CLIENT_ID_ENV: &str = "BITROUTER_OAUTH_CLIENT_ID";
pub const SCOPE_ENV: &str = "BITROUTER_OAUTH_SCOPE";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Settings {
pub authorization_server: String,
pub client_id: String,
pub scope: String,
}
pub fn resolve(
flag_authorization_server: Option<&str>,
flag_client_id: Option<&str>,
flag_scope: Option<&str>,
env_authorization_server: Option<&str>,
env_client_id: Option<&str>,
env_scope: Option<&str>,
) -> Result<Settings> {
let authorization_server = first_non_empty(flag_authorization_server, env_authorization_server)
.unwrap_or(DEFAULT_AS)
.trim_end_matches('/')
.to_string();
require_secure_url(&authorization_server)?;
let client_id = first_non_empty(flag_client_id, env_client_id)
.unwrap_or(DEFAULT_CLIENT_ID)
.to_string();
let scope = first_non_empty(flag_scope, env_scope)
.unwrap_or(DEFAULT_SCOPE)
.to_string();
Ok(Settings {
authorization_server,
client_id,
scope,
})
}
pub fn resolve_from_env(
flag_authorization_server: Option<&str>,
flag_client_id: Option<&str>,
flag_scope: Option<&str>,
) -> Result<Settings> {
let as_env = std::env::var(AS_ENV).ok();
let cid_env = std::env::var(CLIENT_ID_ENV).ok();
let scope_env = std::env::var(SCOPE_ENV).ok();
resolve(
flag_authorization_server,
flag_client_id,
flag_scope,
as_env.as_deref().filter(|s| !s.is_empty()),
cid_env.as_deref().filter(|s| !s.is_empty()),
scope_env.as_deref().filter(|s| !s.is_empty()),
)
}
fn first_non_empty<'a>(a: Option<&'a str>, b: Option<&'a str>) -> Option<&'a str> {
a.filter(|v| !v.is_empty()).or(b.filter(|v| !v.is_empty()))
}
pub fn require_secure_url(url: &str) -> Result<()> {
if url.starts_with("https://") {
return Ok(());
}
if let Ok(parsed) = url::Url::parse(url)
&& parsed.scheme() == "http"
{
let is_loopback = match parsed.host() {
Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
None => false,
};
if is_loopback {
return Ok(());
}
}
anyhow::bail!(
"refusing to use insecure URL '{url}' — OAuth endpoints must be https:// \
(only loopback addresses are allowed for local testing per RFC 8252 §8.3)"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn flag_beats_env() -> Result<()> {
let r = resolve(
Some("https://flag.example.com"),
Some("flag-id"),
None,
Some("https://env.example.com"),
Some("env-id"),
None,
)?;
assert_eq!(r.authorization_server, "https://flag.example.com");
assert_eq!(r.client_id, "flag-id");
assert_eq!(r.scope, DEFAULT_SCOPE);
Ok(())
}
#[test]
fn env_used_when_flag_absent() -> Result<()> {
let r = resolve(
None,
None,
None,
Some("https://env.example.com/"),
Some("env-id"),
Some("custom:scope"),
)?;
assert_eq!(r.authorization_server, "https://env.example.com");
assert_eq!(r.client_id, "env-id");
assert_eq!(r.scope, "custom:scope");
Ok(())
}
#[test]
fn no_inputs_fall_back_to_defaults() -> Result<()> {
let r = resolve(None, None, None, None, None, None)?;
assert_eq!(r.authorization_server, DEFAULT_AS);
assert_eq!(r.client_id, DEFAULT_CLIENT_ID);
assert_eq!(r.scope, DEFAULT_SCOPE);
Ok(())
}
#[test]
fn only_as_overridden_keeps_default_client_id() -> Result<()> {
let r = resolve(
Some("https://self-hosted.example.com"),
None,
None,
None,
None,
None,
)?;
assert_eq!(r.authorization_server, "https://self-hosted.example.com");
assert_eq!(r.client_id, DEFAULT_CLIENT_ID);
Ok(())
}
#[test]
fn empty_strings_fall_through_to_defaults() -> Result<()> {
let r = resolve(Some(""), Some(""), Some(""), None, None, None)?;
assert_eq!(r.authorization_server, DEFAULT_AS);
assert_eq!(r.client_id, DEFAULT_CLIENT_ID);
assert_eq!(r.scope, DEFAULT_SCOPE);
Ok(())
}
#[test]
fn rejects_plain_http_for_non_loopback() -> Result<()> {
let error = resolve(
Some("http://as.example.com"),
Some("cid"),
None,
None,
None,
None,
)
.err()
.ok_or_else(|| anyhow::anyhow!("plain HTTP authorization server unexpectedly resolved"))?;
let msg = format!("{error}");
assert!(msg.contains("insecure URL"), "msg: {msg}");
Ok(())
}
#[test]
fn allows_loopback_http_per_rfc_8252() -> Result<()> {
let r = resolve(
Some("http://127.0.0.1:8080"),
Some("cid"),
None,
None,
None,
None,
)?;
assert_eq!(r.authorization_server, "http://127.0.0.1:8080");
Ok(())
}
#[test]
fn rejects_loopback_lookalike_hosts() {
for url in [
"http://localhost.evil.com/token",
"http://127.0.0.1.evil.com/token",
"http://localhost@evil.com/token",
"http://evil.com/?redir=http://localhost",
] {
assert!(
require_secure_url(url).is_err(),
"{url} must be rejected as insecure"
);
}
}
#[test]
fn allows_exact_loopback_and_https() -> Result<()> {
for url in [
"http://localhost/token",
"http://localhost:9000/token",
"http://127.0.0.1:8080/token",
"http://127.0.0.5/token",
"http://[::1]/token",
"https://api.bitrouter.ai/token",
] {
require_secure_url(url)?;
}
Ok(())
}
}