use anyhow::{bail, Context as _, Result};
use serde_json::{json, Value};
pub struct ClientConfig {
public_url: String,
scope: String,
dev: bool,
}
impl ClientConfig {
pub fn new(public_url: &str, scope: &str, dev: bool) -> Result<Self> {
let parsed = url::Url::parse(public_url)
.with_context(|| format!("public_url {public_url:?} is not an absolute URL"))?;
match parsed.scheme() {
"https" => {}
"http" if dev => {}
"http" => bail!("public_url must be https outside dev, got {public_url:?}"),
other => bail!("public_url must be http(s), got scheme {other:?}"),
}
if !parsed.has_host() {
bail!("public_url {public_url:?} has no host");
}
if parsed.path() != "/" && !parsed.path().is_empty() {
bail!(
"public_url must be an origin with no path, got {public_url:?} \
(path {:?}) — the /oauth prefix is added by this module, so \
including it would publish a doubled client_id",
parsed.path()
);
}
if parsed.query().is_some() {
bail!("public_url must not carry a query string, got {public_url:?}");
}
if parsed.fragment().is_some() {
bail!("public_url must not carry a fragment, got {public_url:?}");
}
if !parsed.username().is_empty() || parsed.password().is_some() {
bail!("public_url must not carry credentials, got {public_url:?}");
}
let origin = parsed[..url::Position::AfterPort].to_string();
Ok(Self {
public_url: origin,
scope: scope.to_string(),
dev,
})
}
pub fn scope_str(&self) -> &str {
&self.scope
}
fn base(&self) -> &str {
self.public_url.trim_end_matches('/')
}
}
pub fn redirect_uri(cfg: &ClientConfig) -> String {
format!("{}/oauth/callback", cfg.base())
}
pub fn jwks_uri(cfg: &ClientConfig) -> String {
format!("{}/oauth/jwks.json", cfg.base())
}
pub fn client_id(cfg: &ClientConfig) -> String {
if !cfg.dev {
return format!("{}/oauth/client-metadata.json", cfg.base());
}
let query = url::form_urlencoded::Serializer::new(String::new())
.append_pair("redirect_uri", &redirect_uri(cfg))
.append_pair("scope", &cfg.scope)
.finish();
format!("http://localhost?{query}")
}
pub fn client_metadata(cfg: &ClientConfig) -> Value {
let mut doc = json!({
"client_id": client_id(cfg),
"client_name": if cfg.dev { "FeatherReader (dev)" } else { "FeatherReader" },
"redirect_uris": [redirect_uri(cfg)],
"scope": cfg.scope,
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"application_type": "web",
"dpop_bound_access_tokens": true,
});
let obj = doc.as_object_mut().expect("built from a json! object");
if cfg.dev {
obj.insert("token_endpoint_auth_method".into(), json!("none"));
} else {
obj.insert("client_uri".into(), json!(cfg.base()));
obj.insert(
"token_endpoint_auth_method".into(),
json!("private_key_jwt"),
);
obj.insert("token_endpoint_auth_signing_alg".into(), json!("ES256"));
obj.insert("jwks_uri".into(), json!(jwks_uri(cfg)));
}
doc
}
#[cfg(test)]
mod tests {
use super::*;
fn prod() -> ClientConfig {
ClientConfig::new(
"https://feather-reader.com",
"atproto transition:generic",
false,
)
.unwrap()
}
fn dev() -> ClientConfig {
ClientConfig::new("http://127.0.0.1:8080", "atproto transition:generic", true).unwrap()
}
#[test]
fn a_public_url_carrying_a_path_is_rejected() {
for url in [
"https://feather-reader.com/oauth",
"https://feather-reader.com/a/b",
"https://feather-reader.com/oauth/",
] {
let cfg = ClientConfig::new(url, "atproto", false);
assert!(cfg.is_err(), "accepted a public_url with a path: {url}");
}
}
#[test]
fn a_public_url_carrying_a_query_fragment_or_credentials_is_rejected() {
for url in [
"https://feather-reader.com?x=1",
"https://feather-reader.com#frag",
"https://u:p@feather-reader.com",
"https://u@feather-reader.com",
] {
assert!(
ClientConfig::new(url, "atproto", false).is_err(),
"accepted {url}"
);
}
}
#[test]
fn the_origin_is_normalized_rather_than_echoed_back() {
let cfg = ClientConfig::new("HTTPS://Feather-Reader.COM", "atproto", false).unwrap();
assert_eq!(
client_id(&cfg),
"https://feather-reader.com/oauth/client-metadata.json"
);
}
#[test]
fn a_public_url_must_be_an_absolute_http_url() {
for url in [
"feather-reader.com",
"",
"/////",
"not a url",
"ftp://x.example",
] {
assert!(
ClientConfig::new(url, "atproto", false).is_err(),
"accepted {url:?}"
);
}
}
#[test]
fn plain_http_is_rejected_in_production_but_allowed_in_dev() {
assert!(ClientConfig::new("http://feather-reader.com", "atproto", false).is_err());
assert!(ClientConfig::new("http://127.0.0.1:8080", "atproto", true).is_ok());
}
#[test]
fn a_valid_public_url_is_accepted_with_or_without_a_trailing_slash() {
assert!(ClientConfig::new("https://feather-reader.com", "atproto", false).is_ok());
assert!(ClientConfig::new("https://feather-reader.com/", "atproto", false).is_ok());
}
#[test]
fn the_production_urls_match_the_paths_the_sidecar_serves_today() {
let cfg = prod();
assert_eq!(
client_id(&cfg),
"https://feather-reader.com/oauth/client-metadata.json"
);
assert_eq!(
redirect_uri(&cfg),
"https://feather-reader.com/oauth/callback"
);
assert_eq!(jwks_uri(&cfg), "https://feather-reader.com/oauth/jwks.json");
}
#[test]
fn a_trailing_slash_on_the_public_url_is_normalized_away() {
let cfg = ClientConfig {
public_url: "https://feather-reader.com/".into(),
..prod()
};
assert_eq!(
client_id(&cfg),
"https://feather-reader.com/oauth/client-metadata.json"
);
assert_eq!(
redirect_uri(&cfg),
"https://feather-reader.com/oauth/callback"
);
}
#[test]
fn the_production_document_is_a_dpop_bound_confidential_client() {
let doc = client_metadata(&prod());
assert_eq!(doc["client_id"], client_id(&prod()));
assert_eq!(doc["redirect_uris"][0], redirect_uri(&prod()));
assert_eq!(doc["jwks_uri"], jwks_uri(&prod()));
assert_eq!(doc["token_endpoint_auth_method"], "private_key_jwt");
assert_eq!(doc["token_endpoint_auth_signing_alg"], "ES256");
assert_eq!(doc["dpop_bound_access_tokens"], true);
assert_eq!(doc["application_type"], "web");
assert_eq!(doc["response_types"][0], "code");
let grants: Vec<&str> = doc["grant_types"]
.as_array()
.unwrap()
.iter()
.map(|g| g.as_str().unwrap())
.collect();
assert!(grants.contains(&"authorization_code"));
assert!(
grants.contains(&"refresh_token"),
"without refresh_token the client cannot refresh and sessions die at token expiry"
);
}
#[test]
fn the_dev_document_declares_no_jwks_and_no_client_authentication() {
let doc = client_metadata(&dev());
assert!(doc.get("jwks_uri").is_none());
assert_eq!(doc["token_endpoint_auth_method"], "none");
assert_eq!(doc["dpop_bound_access_tokens"], true);
}
#[test]
fn the_dev_client_id_percent_encodes_its_query_parameters() {
let id = client_id(&dev());
assert!(id.starts_with("http://localhost?"), "got {id}");
assert!(
id.contains("redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Foauth%2Fcallback"),
"redirect_uri was not percent-encoded: {id}"
);
assert!(
id.contains("scope=atproto+transition%3Ageneric")
|| id.contains("scope=atproto%20transition%3Ageneric"),
"scope was not percent-encoded: {id}"
);
assert!(
!id.contains(' '),
"a raw space would make an invalid URL: {id}"
);
}
#[test]
fn a_query_delimiter_in_the_scope_cannot_inject_extra_parameters() {
let cfg = ClientConfig {
scope: "atproto&redirect_uri=https://evil.example".into(),
..dev()
};
let id = client_id(&cfg);
assert_eq!(
id.matches("redirect_uri=").count(),
1,
"scope injected a second redirect_uri: {id}"
);
assert!(id.contains("%26"), "the `&` was not encoded: {id}");
}
#[test]
fn the_dev_document_and_client_id_agree_on_the_redirect_uri() {
let doc = client_metadata(&dev());
assert_eq!(doc["redirect_uris"][0], redirect_uri(&dev()));
assert_eq!(doc["client_id"], client_id(&dev()));
}
}