use std::sync::Arc;
use oauth_resource_server::{ConfigError, KeyNaming, OAuthConfig, OAuthValidator};
const SECRETS: [&str; 3] = ["alice", "s3cret", "t0ken"];
const ISSUER: &str = "https://alice:s3cret@idp.example.test/app/";
const JWKS_URI: &str = "https://alice:s3cret@idp.example.test/o/jwks?key=t0ken";
const RESOURCE: &str = "https://alice:s3cret@api.example.test/v1";
const SHOWN: [&str; 3] = [
"https://***@idp.example.test/app/",
"https://***@idp.example.test/o/jwks?***",
"https://***@api.example.test/v1",
];
const NAMING: KeyNaming<'static> = KeyNaming::Dotted("app.oauth");
fn assert_no_secret(text: &str) {
for secret in SECRETS {
assert!(!text.contains(secret), "{secret} leaked into: {text}");
}
}
fn assert_shows_endpoints(text: &str) {
for part in SHOWN {
assert!(text.contains(part), "{part} missing from: {text}");
}
}
fn config() -> OAuthConfig {
OAuthConfig {
enabled: true,
issuer: ISSUER.into(),
jwks_uri: Some(JWKS_URI.into()),
audience: "api".into(),
resource: RESOURCE.into(),
required_scope: Some("read".into()),
..OAuthConfig::default()
}
}
fn validator() -> Arc<OAuthValidator> {
let resolved = config().resolve(NAMING).unwrap().unwrap();
Arc::new(OAuthValidator::new(&resolved).unwrap())
}
#[test]
fn config_debug_masks_every_url() {
let cfg = config();
let shown = format!("{cfg:?} {cfg:#?}");
assert_no_secret(&shown);
assert_shows_endpoints(&shown);
assert!(shown.contains("required_scope: Some(\"read\")"), "{shown}");
assert!(shown.contains("audience: \"api\""), "{shown}");
let default = format!("{:?}", OAuthConfig::default());
assert!(default.contains("issuer: \"\""), "{default}");
assert!(default.contains("jwks_uri: None"), "{default}");
let resolved = cfg.resolve(NAMING).unwrap().unwrap();
let shown = format!("{resolved:?} {resolved:#?}");
assert_no_secret(&shown);
assert_shows_endpoints(&shown);
assert!(shown.contains("required_scopes: [\"read\"]"), "{shown}");
}
#[tokio::test]
async fn validator_and_builder_debug_mask_every_url() {
let resolved = config().resolve(NAMING).unwrap().unwrap();
let builder = OAuthValidator::builder(&resolved);
let shown = format!("{builder:?}");
assert_no_secret(&shown);
assert!(shown.contains(SHOWN[0]), "{shown}");
let v = validator();
let shown = format!("{v:?} {v:#?}");
assert_no_secret(&shown);
assert!(
shown.contains(SHOWN[0]) && shown.contains(SHOWN[2]),
"{shown}"
);
}
#[cfg(feature = "axum")]
#[tokio::test]
async fn axum_layer_debug_masks_every_url() {
use oauth_resource_server::axum::AuthLayer;
let builder = AuthLayer::builder().oauth(validator());
let shown = format!("{builder:?}");
let layer = builder.build().unwrap();
let shown = format!("{shown} {layer:?}");
assert_no_secret(&shown);
assert!(
shown.contains(SHOWN[0]) && shown.contains(SHOWN[2]),
"{shown}"
);
}
#[cfg(feature = "tower")]
#[tokio::test]
async fn http_layer_debug_masks_every_url() {
use oauth_resource_server::http_layer::HttpAuthLayer;
let builder = HttpAuthLayer::builder().oauth(validator());
let shown = format!("{builder:?}");
let layer = builder.build().unwrap();
let shown = format!("{shown} {layer:?}");
assert_no_secret(&shown);
assert!(
shown.contains(SHOWN[0]) && shown.contains(SHOWN[2]),
"{shown}"
);
}
fn rendered(err: &ConfigError) -> String {
let details: Vec<&str> = err.problem_details().iter().map(|p| p.message()).collect();
format!("{err}\n{err:?}\n{:?}\n{}", err.problems, details.join("\n"))
}
type Edit = fn(&mut OAuthConfig);
#[test]
fn config_problems_never_echo_a_credential() {
let cases: [(&str, Edit, &str); 6] = [
(
"must not contain a query or fragment",
|c| c.issuer = format!("{ISSUER}?key=t0ken#frag"),
"\"https://***@idp.example.test/app/?***#***\"",
),
(
"uses plain http on a non-loopback host",
|c| c.resource = RESOURCE.replacen("https", "http", 1),
"\"http://***@api.example.test/v1\"",
),
(
"has leading/trailing whitespace",
|c| c.jwks_uri = Some(format!(" {JWKS_URI}")),
"\"https://***@idp.example.test/o/jwks?***\"",
),
(
"must be an http(s) URL",
|c| c.jwks_uri = Some(JWKS_URI.replacen("https", "ftp", 1)),
"\"ftp://***@idp.example.test/o/jwks?***\"",
),
(
"contains a space, a control character or a non-ASCII character",
|c| c.jwks_uri = Some(JWKS_URI.replacen("/o/", "/ö/", 1)),
"idp.example.test",
),
(
"contains a space, a control character or a non-ASCII character",
|c| c.jwks_uri = Some(JWKS_URI.replacen("/o/", "/o\t/", 1)),
"idp.example.test",
),
];
for (problem, edit, shown) in cases {
let mut cfg = config();
edit(&mut cfg);
let err = cfg.resolve(NAMING).unwrap_err();
let text = rendered(&err);
assert_no_secret(&text);
assert!(text.contains(problem), "{problem}: {text}");
assert!(text.contains(shown), "{shown} missing from: {text}");
}
let cases: [(&str, Edit); 3] = [
("app.oauth.issuer is not an absolute URL (", |c| {
c.issuer = "//alice:s3cret@idp.example.test/app/?key=t0ken".into()
}),
("app.oauth.jwks_uri must be an http(s) URL", |c| {
c.jwks_uri = Some("alice:s3cret@idp.example.test/jwks?key=t0ken".into())
}),
(
"app.oauth.resource uses plain http on a non-loopback host",
|c| c.resource = "http://alice:1234/s3cret@api.example.test/v1".into(),
),
];
for (problem, edit) in cases {
let mut cfg = config();
edit(&mut cfg);
let err = cfg.resolve(NAMING).unwrap_err();
let text = rendered(&err);
assert_no_secret(&text);
assert!(text.contains(problem), "{problem}: {text}");
assert!(!text.contains("<unparseable URL"), "{text}");
}
}
fn resource_problem(value: &str) -> String {
let mut cfg = config();
cfg.resource = value.into();
rendered(&cfg.resolve(NAMING).unwrap_err())
}
#[test]
fn a_value_that_cannot_hold_a_credential_is_still_quoted() {
let text = resource_problem("kb.example.test/mcp");
assert!(
text.contains("app.oauth.resource \"kb.example.test/mcp\" is not an absolute URL ("),
"{text}"
);
for value in ["user:pw@host/x", "host/x?key=1", "host/x#frag", "host/ x"] {
let text = resource_problem(value);
assert!(
text.contains("app.oauth.resource is not an absolute URL (")
|| text.contains("app.oauth.resource must be an http(s) URL"),
"{value}: {text}"
);
assert!(!text.contains("app.oauth.resource \""), "{value}: {text}");
for part in ["pw", "key=1", "frag", "host/"] {
assert!(!text.contains(part), "{value}: {part} in {text}");
}
}
let long = format!("kb.example.test/{}", "a".repeat(300));
let text = resource_problem(&long);
assert!(!text.contains(&long) && text.contains('…'), "{text}");
}
#[test]
fn a_redacted_value_says_it_was_changed() {
let text = resource_problem(&format!(" {RESOURCE}"));
assert!(
text.contains(
"app.oauth.resource \"https://***@api.example.test/v1\" (shown normalized, \
credential masked) has leading/trailing whitespace"
),
"{text}"
);
let text = resource_problem(" https://api.example.test/v1");
assert!(
text.contains(
"app.oauth.resource \" https://api.example.test/v1\" has leading/trailing whitespace"
),
"{text}"
);
assert!(!text.contains("shown normalized"), "{text}");
}
#[test]
fn authorized_token_debug_masks_its_issuer() {
let mut token = oauth_resource_server::AuthorizedToken::new(None, None, ["read"]);
token.issuer = ISSUER.into();
let shown = format!("{token:?}");
assert_no_secret(&shown);
assert!(shown.contains(SHOWN[0]), "{shown}");
let shown = format!(
"{:?}",
oauth_resource_server::AuthorizedToken::new(None, None, ["read"])
);
assert!(shown.contains("issuer: \"\""), "{shown}");
}
#[cfg(feature = "env")]
#[test]
fn env_config_debug_and_problems_never_echo_a_credential() {
use oauth_resource_server::env::unresolved_oauth_config_from_lookup;
let vars = [
("MYAPP_OAUTH_ISSUER", ISSUER),
("MYAPP_OAUTH_JWKS_URI", JWKS_URI),
("MYAPP_OAUTH_AUDIENCE", "api"),
("MYAPP_OAUTH_RESOURCE", RESOURCE),
("MYAPP_OAUTH_REQUIRED_SCOPE", "read"),
];
let load = |vars: &[(&str, &str)]| {
let vars: Vec<(String, String)> = vars
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
unresolved_oauth_config_from_lookup(
"MYAPP_OAUTH_",
move |k| vars.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone()),
|_| Err(std::io::Error::other("no files")),
)
.unwrap()
};
let env = load(&vars);
let shown = format!("{env:?}");
assert_no_secret(&shown);
assert_shows_endpoints(&shown);
let mut bad = vars;
bad[0].1 = "https://alice:s3cret@idp.example.test/app/?key=t0ken";
let err = load(&bad).resolve().unwrap_err();
let text = rendered(&err);
assert_no_secret(&text);
assert!(
text.contains("MYAPP_OAUTH_ISSUER \"https://***@idp.example.test/app/?***\""),
"{text}"
);
}