use std::path::Path;
use anyhow::{Context as _, Result};
use super::client_auth::AuthMethod;
use super::crypto::Codec;
use super::keys::SigningKey;
use super::metadata::ClientConfig;
use super::session::RefreshLocks;
pub const CLIENT_KID: &str = "featherreader-oauth-1";
pub struct OauthRuntime {
pub codec: Codec,
pub client: ClientConfig,
pub client_id: String,
pub client_key: Option<SigningKey>,
pub auth_method: AuthMethod,
pub locks: RefreshLocks,
pub plc_directory: String,
pub resolver: hickory_resolver::TokioResolver,
}
impl OauthRuntime {
pub fn new(cfg: &crate::config::Config) -> Result<Self> {
let dev = is_loopback_url(&cfg.public_url);
let client = ClientConfig::new(&cfg.public_url, &cfg.oauth.scope, dev)
.context("building the OAuth client identity")?;
let client_id = super::metadata::client_id(&client);
let auth_method = AuthMethod::negotiate(dev);
let codec = Codec::new(cfg.oauth.encryption_key.as_deref())
.context("building the at-rest encryption codec")?;
let creates_key = cfg.repo_backend == crate::metrics::Backend::Rust;
let key_exists = cfg.oauth.key_path.exists();
let client_key = match auth_method {
AuthMethod::PrivateKeyJwt if creates_key || key_exists => Some(
super::keys::load_or_create(Path::new(&cfg.oauth.key_path), &codec, CLIENT_KID)
.with_context(|| {
format!(
"loading the OAuth signing key at {}",
cfg.oauth.key_path.display()
)
})?,
),
AuthMethod::PrivateKeyJwt | AuthMethod::None => None,
};
Ok(Self {
codec,
client,
client_id,
client_key,
auth_method,
locks: RefreshLocks::default(),
plc_directory: cfg.oauth.plc_directory.clone(),
resolver: super::resolve::resolver()?,
})
}
}
impl OauthRuntime {
pub fn without_creating_key(cfg: &crate::config::Config) -> Result<Self> {
let confidential =
AuthMethod::negotiate(is_loopback_url(&cfg.public_url)) == AuthMethod::PrivateKeyJwt;
if confidential && !cfg.oauth.key_path.exists() {
anyhow::bail!(
"the OAuth signing key {} does not exist, and this mode will not create one \
(a new key is one no PDS can verify). Point FEATHERREADER_OAUTH_KEY_PATH at the \
key the running app uses",
cfg.oauth.key_path.display()
);
}
Self::new(cfg)
}
}
fn is_loopback_url(public_url: &str) -> bool {
let Ok(parsed) = url::Url::parse(public_url) else {
return false;
};
match parsed.host() {
Some(url::Host::Domain(host)) => host == "localhost" || host.ends_with(".localhost"),
Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
None => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dev_is_inferred_from_a_loopback_public_url() {
assert!(is_loopback_url("http://localhost:8080"));
assert!(is_loopback_url("http://127.0.0.1:8080"));
assert!(is_loopback_url("http://[::1]:8080"));
assert!(is_loopback_url("http://app.localhost:8080"));
assert!(!is_loopback_url("https://feather-reader.com"));
assert!(!is_loopback_url("https://localhost.evil.com"));
}
#[test]
fn a_hostname_containing_localhost_is_not_loopback() {
assert!(!is_loopback_url("https://localhost.evil.com"));
assert!(!is_loopback_url("https://notlocalhost"));
assert!(!is_loopback_url("https://mylocalhost.net"));
}
#[test]
fn an_unparseable_url_is_not_treated_as_dev() {
assert!(!is_loopback_url("not a url"));
assert!(!is_loopback_url(""));
}
}
#[cfg(test)]
mod key_creation_tests {
use super::*;
fn cfg(
backend: crate::metrics::Backend,
public_url: &str,
key_path: &std::path::Path,
) -> crate::config::Config {
crate::config::Config {
repo_backend: backend,
public_url: public_url.to_string(),
oauth: crate::config::OauthConfig {
key_path: key_path.to_path_buf(),
..crate::config::OauthConfig::default()
},
..crate::config::Config::default()
}
}
fn temp_key_path(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("fr-test-key-{name}-{}.json", std::process::id()))
}
#[test]
fn the_sidecar_backend_writes_no_signing_key() {
let path = temp_key_path("sidecar");
let _ = std::fs::remove_file(&path);
let runtime = OauthRuntime::new(&cfg(
crate::metrics::Backend::Sidecar,
"https://feather-reader.com",
&path,
))
.expect("must build");
assert!(runtime.client_key.is_none());
assert!(
!path.exists(),
"the sidecar backend wrote a signing key it will never use"
);
}
#[test]
fn the_rust_backend_creates_its_signing_key() {
let path = temp_key_path("rust");
let _ = std::fs::remove_file(&path);
let runtime = OauthRuntime::new(&cfg(
crate::metrics::Backend::Rust,
"https://feather-reader.com",
&path,
))
.expect("must build");
assert!(
runtime.client_key.is_some(),
"a confidential client needs its key"
);
assert!(path.exists(), "the key was not persisted");
let _ = std::fs::remove_file(&path);
}
#[test]
fn an_existing_key_is_adopted_on_the_sidecar_backend() {
let path = temp_key_path("adopt");
let _ = std::fs::remove_file(&path);
OauthRuntime::new(&cfg(
crate::metrics::Backend::Rust,
"https://feather-reader.com",
&path,
))
.expect("must build");
assert!(path.exists(), "precondition: the key was created");
let runtime = OauthRuntime::new(&cfg(
crate::metrics::Backend::Sidecar,
"https://feather-reader.com",
&path,
))
.expect("must build");
assert!(
runtime.client_key.is_some(),
"an existing key was ignored, so rust sessions could never be revoked"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_loopback_deployment_is_public_and_keyless() {
let path = temp_key_path("dev");
let _ = std::fs::remove_file(&path);
let runtime = OauthRuntime::new(&cfg(
crate::metrics::Backend::Rust,
"http://localhost:8080",
&path,
))
.expect("must build");
assert_eq!(runtime.auth_method, AuthMethod::None);
assert!(runtime.client_key.is_none());
assert!(!path.exists(), "a public client wrote a signing key");
}
}