use std::fmt;
use crate::algorithms::{Algorithm, DEFAULT_ALGORITHMS, parse_algorithm};
use crate::validator::plain_http_non_loopback;
pub const DEFAULT_SCOPE_CLAIMS: &[&str] = &["scope", "scp"];
pub const DEFAULT_PRINCIPAL_CLAIMS: &[&str] = &["preferred_username", "sub"];
pub const DEFAULT_LEEWAY_SECS: u64 = 60;
pub const MAX_LEEWAY_SECS: u64 = 300;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum KeyNaming<'a> {
Dotted(&'a str),
Env(&'a str),
}
impl KeyNaming<'_> {
pub fn key(&self, field: &str) -> String {
match self {
KeyNaming::Dotted("") => field.to_string(),
KeyNaming::Dotted(prefix) => format!("{prefix}.{field}"),
KeyNaming::Env(prefix) => format!("{prefix}{}", field.to_ascii_uppercase()),
}
}
pub fn section(&self) -> String {
match self {
KeyNaming::Dotted("") => "OAuth config".to_string(),
KeyNaming::Dotted(prefix) => prefix.to_string(),
KeyNaming::Env(prefix) => format!("{prefix}*"),
}
}
pub fn to_buf(&self) -> KeyNamingBuf {
match self {
KeyNaming::Dotted(p) => KeyNamingBuf::Dotted((*p).to_string()),
KeyNaming::Env(p) => KeyNamingBuf::Env((*p).to_string()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum KeyNamingBuf {
Dotted(String),
Env(String),
}
impl KeyNamingBuf {
pub fn as_naming(&self) -> KeyNaming<'_> {
match self {
KeyNamingBuf::Dotted(p) => KeyNaming::Dotted(p),
KeyNamingBuf::Env(p) => KeyNaming::Env(p),
}
}
pub fn key(&self, field: &str) -> String {
self.as_naming().key(field)
}
pub fn section(&self) -> String {
self.as_naming().section()
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub struct ConfigError {
pub problems: Vec<String>,
naming: KeyNamingBuf,
}
impl ConfigError {
pub fn new(naming: KeyNaming<'_>, problems: Vec<String>) -> Self {
debug_assert!(
!problems.is_empty(),
"ConfigError::new called with no problems"
);
Self {
problems,
naming: naming.to_buf(),
}
}
pub fn naming(&self) -> KeyNaming<'_> {
self.naming.as_naming()
}
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let list = self.problems.join("\n - ");
match &self.naming {
KeyNamingBuf::Dotted(_) => {
let enabled = self.naming.key("enabled");
write!(
f,
"{enabled} is true but the OAuth config is not usable:\n - {list}\n\
Fix these, or set {enabled}: false."
)
}
KeyNamingBuf::Env(_) => {
let section = self.naming.section();
write!(
f,
"OAuth is configured through {section} but the config is not usable:\n \
- {list}\nFix these, or unset every {section} variable."
)
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
pub struct OAuthConfig {
#[cfg_attr(feature = "serde", serde(default))]
pub enabled: bool,
#[cfg_attr(feature = "serde", serde(default))]
pub issuer: String,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub jwks_uri: Option<String>,
#[cfg_attr(feature = "serde", serde(default))]
pub audience: String,
#[cfg_attr(feature = "serde", serde(default))]
pub audiences: Vec<String>,
#[cfg_attr(feature = "serde", serde(default))]
pub resource: String,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub required_scope: Option<String>,
#[cfg_attr(feature = "serde", serde(default))]
pub required_scopes: Vec<String>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub scopes_supported: Option<Vec<String>>,
#[cfg_attr(feature = "serde", serde(default = "default_scope_claims"))]
pub scope_claims: Vec<String>,
#[cfg_attr(feature = "serde", serde(default = "default_principal_claims"))]
pub principal_claims: Vec<String>,
#[cfg_attr(feature = "serde", serde(default = "default_algorithms"))]
pub algorithms: Vec<String>,
#[cfg_attr(feature = "serde", serde(default = "default_leeway_secs"))]
pub leeway_secs: u64,
#[cfg_attr(feature = "serde", serde(default))]
pub require_at_jwt: bool,
#[cfg_attr(feature = "serde", serde(default))]
pub allow_unscoped_tokens: bool,
#[cfg_attr(feature = "serde", serde(default))]
pub allow_insecure_http: bool,
#[cfg_attr(feature = "serde", serde(default = "default_true"))]
pub accept_static_bearer: bool,
}
impl Default for OAuthConfig {
fn default() -> Self {
Self {
enabled: false,
issuer: String::new(),
jwks_uri: None,
audience: String::new(),
audiences: Vec::new(),
resource: String::new(),
required_scope: None,
required_scopes: Vec::new(),
scopes_supported: None,
scope_claims: default_scope_claims(),
principal_claims: default_principal_claims(),
algorithms: default_algorithms(),
leeway_secs: default_leeway_secs(),
require_at_jwt: false,
allow_unscoped_tokens: false,
allow_insecure_http: false,
accept_static_bearer: true,
}
}
}
fn strings(list: &[&str]) -> Vec<String> {
list.iter().map(|s| s.to_string()).collect()
}
fn default_scope_claims() -> Vec<String> {
strings(DEFAULT_SCOPE_CLAIMS)
}
fn default_principal_claims() -> Vec<String> {
strings(DEFAULT_PRINCIPAL_CLAIMS)
}
fn default_algorithms() -> Vec<String> {
strings(DEFAULT_ALGORITHMS)
}
fn default_leeway_secs() -> u64 {
DEFAULT_LEEWAY_SECS
}
#[cfg(feature = "serde")]
fn default_true() -> bool {
true
}
impl OAuthConfig {
pub fn resolve(
self,
naming: KeyNaming<'_>,
) -> Result<Option<ResolvedOAuthConfig>, ConfigError> {
if !self.enabled {
return Ok(None);
}
let key = |field: &str| naming.key(field);
let mut problems: Vec<String> = Vec::new();
let mut blank = Vec::new();
for (name, value) in [("issuer", &self.issuer), ("resource", &self.resource)] {
if value.trim().is_empty() {
blank.push(key(name));
}
}
if self.audience.trim().is_empty() && self.audiences.is_empty() {
blank.push(format!("{} (or {})", key("audience"), key("audiences")));
}
if !blank.is_empty() {
problems.push(format!(
"these required settings are empty: {}. Set issuer to the authorization \
server's issuer (byte-exact, including any trailing slash), resource to \
this server's public URL, and audience to what that server puts in \
an access token's `aud` — the resource URL if it honours RFC 8707 or \
lets you configure an audience (e.g. Authelia), or the OAuth client_id \
if it stamps that (e.g. Authentik, Kanidm)",
blank.join(", ")
));
}
let urls = [
("issuer", Some(&self.issuer), true),
("resource", Some(&self.resource), true),
("jwks_uri", self.jwks_uri.as_ref(), false),
];
for (name, value, identifier) in urls {
let Some(value) = value.filter(|v| !v.trim().is_empty()) else {
continue;
};
match check_url(&key(name), value, identifier) {
Err(e) => problems.push(e),
Ok(()) if !self.allow_insecure_http && plain_http_non_loopback(value) => {
problems.push(format!(
"{} {value:?} uses plain http on a non-loopback host — {}. Use https, \
or set {} if this address is on a network you trust",
key(name),
if name == "resource" {
"bearer tokens sent to it can be read in transit (RFC 9728 §1.2 \
requires https)"
} else {
"signing keys fetched over it can be substituted by anyone on the \
path (RFC 8414 §2 requires https)"
},
key("allow_insecure_http")
));
}
Ok(()) => {}
}
}
if self.audiences.iter().any(|a| a.trim().is_empty()) {
problems.push(format!("{} contains an empty entry", key("audiences")));
}
if let Some(required_scope) = &self.required_scope {
if required_scope.trim().is_empty() {
problems.push(format!(
"{} must not be empty — a blank required scope would let any signed \
token through unscoped. Use a scope your authorization server \
actually issues",
key("required_scope")
));
} else if required_scope.split_whitespace().count() != 1 {
problems.push(format!(
"{} {:?} must be a single scope (no spaces) — scopes are matched one \
token at a time",
key("required_scope"),
required_scope
));
} else if !is_scope_token(required_scope.trim()) {
problems.push(scope_token_problem(&key("required_scope"), required_scope));
}
}
for scope in &self.required_scopes {
if scope.trim().is_empty() {
problems.push(format!(
"{} contains an empty entry — a blank required scope would let a \
token through without it. Remove the entry or name a scope your \
authorization server actually issues",
key("required_scopes")
));
} else if scope.split_whitespace().count() != 1 {
problems.push(format!(
"{} entry {:?} must be a single scope (no spaces) — scopes are matched \
one token at a time; list each one as its own entry",
key("required_scopes"),
scope
));
} else if !is_scope_token(scope.trim()) {
problems.push(scope_token_problem(
&format!("{} entry", key("required_scopes")),
scope,
));
}
}
if let Some(supported) = &self.scopes_supported {
if supported.iter().any(|s| s.trim().is_empty()) {
problems.push(format!(
"{} contains an empty entry",
key("scopes_supported")
));
}
for scope in supported.iter().filter(|s| !s.trim().is_empty()) {
if !is_scope_token(scope.trim()) {
problems.push(scope_token_problem(
&format!("{} entry", key("scopes_supported")),
scope,
));
}
}
}
if self.required_scope.is_none()
&& self.required_scopes.is_empty()
&& !self.require_at_jwt
&& !self.allow_unscoped_tokens
{
problems.push(format!(
"no required scope is configured ({} and {} are unset) and {} is off — \
nothing would tell an access token from an OIDC ID token minted for the \
same client, so any token this issuer signs for the audience would be \
accepted. Set {} to a scope only access tokens carry, turn on {} if the \
authorization server emits typ at+jwt, or set {} to accept that",
key("required_scope"),
key("required_scopes"),
key("require_at_jwt"),
key("required_scope"),
key("require_at_jwt"),
key("allow_unscoped_tokens")
));
}
if self.scope_claims.is_empty() || self.scope_claims.iter().any(|c| c.trim().is_empty()) {
problems.push(format!(
"{} must list at least one non-empty claim name (default: [\"scope\", \
\"scp\"])",
key("scope_claims")
));
}
if self.principal_claims.iter().any(|c| c.trim().is_empty()) {
problems.push(format!(
"{} contains an empty entry",
key("principal_claims")
));
}
let mut algorithms = Vec::new();
let mut bad_algorithms = Vec::new();
for name in &self.algorithms {
match parse_algorithm(name) {
Ok(alg) if !algorithms.contains(&alg) => algorithms.push(alg),
Ok(_) => {}
Err(reason) => bad_algorithms.push(reason.to_string()),
}
}
if !bad_algorithms.is_empty() {
problems.push(format!(
"{} has unacceptable entries: {}",
key("algorithms"),
bad_algorithms.join("; ")
));
} else if algorithms.is_empty() {
problems.push(format!(
"{} must list at least one algorithm",
key("algorithms")
));
}
if self.leeway_secs > MAX_LEEWAY_SECS {
problems.push(format!(
"{} {} is over the {}-second cap — leeway is for clock drift, not for \
extending token lifetimes",
key("leeway_secs"),
self.leeway_secs,
MAX_LEEWAY_SECS
));
}
if !problems.is_empty() {
return Err(ConfigError::new(naming, problems));
}
let mut required_scopes: Vec<String> = Vec::new();
for scope in self
.required_scope
.iter()
.chain(self.required_scopes.iter())
{
let scope = scope.trim();
if !required_scopes.iter().any(|s| s == scope) {
required_scopes.push(scope.to_string());
}
}
let scopes_supported = match self.scopes_supported {
Some(listed) => listed.iter().map(|s| s.trim().to_string()).collect(),
None => required_scopes.clone(),
};
Ok(Some(ResolvedOAuthConfig {
issuer: self.issuer,
jwks_uri: self.jwks_uri.filter(|u| !u.trim().is_empty()),
audience: self.audience,
audiences: self.audiences,
resource: self.resource,
required_scopes,
scopes_supported,
scope_claims: self.scope_claims,
principal_claims: self.principal_claims,
algorithms,
leeway_secs: self.leeway_secs,
require_at_jwt: self.require_at_jwt,
allow_unscoped_tokens: self.allow_unscoped_tokens,
allow_insecure_http: self.allow_insecure_http,
accept_static_bearer: self.accept_static_bearer,
resource_name: None,
key_naming: naming.to_buf(),
}))
}
}
fn check_url(key: &str, value: &str, identifier: bool) -> Result<(), String> {
let parsed = reqwest::Url::parse(value.trim())
.map_err(|e| format!("{key} {value:?} is not an absolute URL ({e})"))?;
if !matches!(parsed.scheme(), "https" | "http") {
return Err(format!("{key} {value:?} must be an http(s) URL"));
}
if identifier && (parsed.fragment().is_some() || parsed.query().is_some()) {
return Err(format!(
"{key} {value:?} must not contain a query or fragment"
));
}
if value != value.trim() {
return Err(format!(
"{key} {value:?} has leading/trailing whitespace — it is compared byte-for-byte"
));
}
if value.chars().any(|c| !c.is_ascii_graphic()) {
return Err(format!(
"{key} {value:?} contains a space, a control character or a non-ASCII \
character — write it percent-encoded (and an internationalized host in its \
punycode form)"
));
}
Ok(())
}
fn is_scope_token(scope: &str) -> bool {
!scope.is_empty()
&& scope
.bytes()
.all(|b| b == 0x21 || (0x23..=0x5B).contains(&b) || (0x5D..=0x7E).contains(&b))
}
fn scope_token_problem(what: &str, scope: &str) -> String {
format!(
"{what} {scope:?} is not a valid scope — a scope is printable ASCII with no space, \
'\"' or '\\' (RFC 6749 §3.3)"
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ResolvedOAuthConfig {
pub issuer: String,
pub jwks_uri: Option<String>,
pub audience: String,
pub audiences: Vec<String>,
pub resource: String,
pub required_scopes: Vec<String>,
pub scopes_supported: Vec<String>,
pub scope_claims: Vec<String>,
pub principal_claims: Vec<String>,
pub algorithms: Vec<Algorithm>,
pub leeway_secs: u64,
pub require_at_jwt: bool,
pub allow_unscoped_tokens: bool,
pub allow_insecure_http: bool,
pub accept_static_bearer: bool,
pub resource_name: Option<String>,
pub key_naming: KeyNamingBuf,
}
impl ResolvedOAuthConfig {
pub fn accepted_audiences(&self) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for a in std::iter::once(&self.audience).chain(self.audiences.iter()) {
if !a.trim().is_empty() && !out.contains(a) {
out.push(a.clone());
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
const WIKI: KeyNaming<'static> = KeyNaming::Dotted("mcp.oauth");
fn enabled(edit: impl FnOnce(&mut OAuthConfig)) -> OAuthConfig {
let mut cfg = OAuthConfig {
enabled: true,
issuer: "https://idp.example.test/".into(),
resource: "https://kb.example.test/mcp".into(),
audience: "c".into(),
allow_unscoped_tokens: true,
..OAuthConfig::default()
};
edit(&mut cfg);
cfg
}
fn resolve_err(cfg: OAuthConfig) -> String {
cfg.resolve(WIKI).unwrap_err().to_string()
}
#[test]
fn oauth_is_disabled_by_default_and_has_no_default_scope() {
let cfg = OAuthConfig::default();
assert!(!cfg.enabled);
assert_eq!(cfg.required_scope, None);
assert!(cfg.required_scopes.is_empty());
assert_eq!(cfg.scopes_supported, None);
assert_eq!(cfg.jwks_uri, None);
assert_eq!(cfg.scope_claims, ["scope", "scp"]);
assert_eq!(cfg.principal_claims, ["preferred_username", "sub"]);
assert_eq!(cfg.algorithms, DEFAULT_ALGORITHMS);
assert_eq!(cfg.leeway_secs, 60);
assert!(!cfg.require_at_jwt);
assert!(!cfg.allow_unscoped_tokens);
assert!(!cfg.allow_insecure_http);
assert!(cfg.accept_static_bearer);
}
#[test]
fn disabled_resolves_to_none() {
assert_eq!(OAuthConfig::default().resolve(WIKI), Ok(None));
let cfg = OAuthConfig {
algorithms: vec!["HS256".into()],
..OAuthConfig::default()
};
assert_eq!(cfg.resolve(WIKI), Ok(None));
}
#[test]
fn a_minimal_enabled_block_resolves_with_every_default() {
let cfg = OAuthConfig {
enabled: true,
issuer: "https://authentik.example.test/application/o/example-app/".into(),
jwks_uri: Some("https://authentik.example.test/application/o/example-app/jwks/".into()),
audience: "some-client-id".into(),
resource: "https://kb.example.test/mcp".into(),
required_scope: Some("api:read".into()),
..OAuthConfig::default()
};
let oauth = cfg.resolve(WIKI).unwrap().expect("enabled");
assert_eq!(
oauth.issuer,
"https://authentik.example.test/application/o/example-app/"
);
assert_eq!(oauth.audience, "some-client-id");
assert_eq!(oauth.resource, "https://kb.example.test/mcp");
assert_eq!(oauth.required_scopes, ["api:read"]);
assert_eq!(oauth.scopes_supported, ["api:read"]);
assert_eq!(oauth.accepted_audiences(), ["some-client-id"]);
assert_eq!(oauth.scope_claims, ["scope", "scp"]);
assert_eq!(oauth.principal_claims, ["preferred_username", "sub"]);
assert!(oauth.algorithms.contains(&Algorithm::RS256));
assert_eq!(oauth.leeway_secs, 60);
assert!(!oauth.require_at_jwt);
assert!(!oauth.allow_unscoped_tokens);
assert!(!oauth.allow_insecure_http);
assert!(oauth.accept_static_bearer);
assert_eq!(oauth.resource_name, None);
assert_eq!(oauth.key_naming, KeyNamingBuf::Dotted("mcp.oauth".into()));
}
#[test]
fn enabled_with_blank_required_settings_is_rejected_naming_all_of_them() {
let cfg = OAuthConfig {
enabled: true,
..OAuthConfig::default()
};
let err = resolve_err(cfg);
for setting in [
"mcp.oauth.issuer",
"mcp.oauth.audience",
"mcp.oauth.resource",
] {
assert!(err.contains(setting), "{setting} missing from: {err}");
}
assert!(!err.contains("mcp.oauth.jwks_uri"), "{err}");
}
const WIKI_REWRITES: [(&str, &str); 2] = [
("this server's public URL,", "this server's public MCP URL,"),
(
"unscoped. Use a scope your",
"unscoped. Use \"mcp:read\" (the default) or a scope your",
),
];
fn as_wiki_text(problem: &str) -> String {
WIKI_REWRITES
.iter()
.fold(problem.to_string(), |p, (generic, wiki)| {
p.replace(generic, wiki)
})
}
#[test]
fn config_error_display_for_dotted_naming_is_generic_and_maps_to_mcp_md_wiki_text() {
let cfg = OAuthConfig {
enabled: true,
required_scope: Some("mcp:read".into()),
..OAuthConfig::default()
};
let expected = "mcp.oauth.enabled is true but the OAuth config is not usable:\n - \
these required settings are empty: mcp.oauth.issuer, mcp.oauth.resource, mcp.oauth.audience \
(or mcp.oauth.audiences). Set issuer to the authorization server's issuer (byte-exact, \
including any trailing slash), resource to this server's public URL, and audience to \
what that server puts in an access token's `aud` — the resource URL if it honours RFC 8707 \
or lets you configure an audience (e.g. Authelia), or the OAuth client_id if it stamps that \
(e.g. Authentik, Kanidm)\nFix these, or set mcp.oauth.enabled: false.";
let mut err = cfg.resolve(WIKI).unwrap_err();
assert_eq!(err.to_string(), expected);
assert_eq!(err.problems[0].matches(WIKI_REWRITES[0].0).count(), 1);
let wiki_expected = "mcp.oauth.enabled is true but the OAuth config is not usable:\n - \
these required settings are empty: mcp.oauth.issuer, mcp.oauth.resource, mcp.oauth.audience \
(or mcp.oauth.audiences). Set issuer to the authorization server's issuer (byte-exact, \
including any trailing slash), resource to this server's public MCP URL, and audience to \
what that server puts in an access token's `aud` — the resource URL if it honours RFC 8707 \
or lets you configure an audience (e.g. Authelia), or the OAuth client_id if it stamps that \
(e.g. Authentik, Kanidm)\nFix these, or set mcp.oauth.enabled: false.";
err.problems = err.problems.iter().map(|p| as_wiki_text(p)).collect();
assert_eq!(err.to_string(), wiki_expected);
}
#[test]
fn scope_problem_messages_are_generic_and_map_to_mcp_md_wiki_text() {
let err = enabled(|c| c.required_scope = Some(String::new()))
.resolve(WIKI)
.unwrap_err();
assert_eq!(
err.problems,
[
"mcp.oauth.required_scope must not be empty — a blank required scope would \
let any signed token through unscoped. Use a scope your authorization server \
actually issues"
]
);
assert_eq!(err.problems[0].matches(WIKI_REWRITES[1].0).count(), 1);
assert_eq!(
as_wiki_text(&err.problems[0]),
"mcp.oauth.required_scope must not be empty — a blank required scope would \
let any signed token through unscoped. Use \"mcp:read\" (the default) or a \
scope your authorization server actually issues"
);
let text = OAuthConfig {
enabled: true,
required_scope: Some(String::new()),
..OAuthConfig::default()
}
.resolve(KeyNaming::Env("APP_OAUTH_"))
.unwrap_err()
.to_string();
assert!(!text.contains("MCP") && !text.contains("mcp"), "{text}");
let err = enabled(|c| c.required_scope = Some("mcp:read mcp:write".into()))
.resolve(WIKI)
.unwrap_err();
assert_eq!(
err.problems,
[
"mcp.oauth.required_scope \"mcp:read mcp:write\" must be a single scope (no \
spaces) — scopes are matched one token at a time"
]
);
let err = enabled(|c| c.leeway_secs = 3600).resolve(WIKI).unwrap_err();
assert_eq!(
err.to_string(),
"mcp.oauth.enabled is true but the OAuth config is not usable:\n - \
mcp.oauth.leeway_secs 3600 is over the 300-second cap — leeway is for clock \
drift, not for extending token lifetimes\nFix these, or set mcp.oauth.enabled: \
false."
);
}
#[test]
fn env_naming_uppercases_and_appends_the_field() {
let naming = KeyNaming::Env("MYAPP_OAUTH_");
assert_eq!(naming.key("issuer"), "MYAPP_OAUTH_ISSUER");
assert_eq!(naming.key("required_scopes"), "MYAPP_OAUTH_REQUIRED_SCOPES");
assert_eq!(naming.section(), "MYAPP_OAUTH_*");
assert_eq!(
KeyNaming::Dotted("mcp.oauth").key("issuer"),
"mcp.oauth.issuer"
);
assert_eq!(KeyNaming::Dotted("").key("issuer"), "issuer");
assert_eq!(KeyNaming::Dotted("").section(), "OAuth config");
assert_eq!(KeyNaming::Dotted("mcp.oauth").section(), "mcp.oauth");
let cfg = OAuthConfig {
enabled: true,
required_scope: Some(String::new()),
..OAuthConfig::default()
};
let err = cfg.resolve(naming).unwrap_err();
let text = err.to_string();
for key in [
"MYAPP_OAUTH_ISSUER",
"MYAPP_OAUTH_RESOURCE",
"MYAPP_OAUTH_AUDIENCE (or MYAPP_OAUTH_AUDIENCES)",
"MYAPP_OAUTH_REQUIRED_SCOPE must not be empty",
] {
assert!(text.contains(key), "{key} missing from: {text}");
}
assert!(!text.contains("mcp.oauth"), "{text}");
assert!(
text.starts_with(
"OAuth is configured through MYAPP_OAUTH_* but the config is not \
usable:\n - "
),
"{text}"
);
assert!(
text.ends_with("\nFix these, or unset every MYAPP_OAUTH_* variable."),
"{text}"
);
assert_eq!(err.naming(), naming);
}
#[test]
fn accepts_audiences_without_audience_and_an_omitted_or_blank_jwks_uri() {
for jwks_uri in [None, Some(String::new()), Some(" ".to_string())] {
let oauth = enabled(|c| {
c.audience = String::new();
c.audiences = vec!["https://kb.example.test/mcp".into()];
c.jwks_uri = jwks_uri.clone();
})
.resolve(WIKI)
.unwrap()
.unwrap();
assert_eq!(oauth.accepted_audiences(), ["https://kb.example.test/mcp"]);
assert_eq!(oauth.jwks_uri, None, "blank means discover: {jwks_uri:?}");
}
}
#[test]
fn refuses_hmac_and_none_algorithms() {
for alg in ["HS256", "none"] {
let err = resolve_err(enabled(|c| {
c.algorithms = vec!["RS256".into(), alg.into()];
}));
assert!(err.contains("mcp.oauth.algorithms"), "{err}");
assert!(err.contains(alg), "{err}");
}
let err = resolve_err(enabled(|c| c.algorithms = vec![]));
assert!(err.contains("at least one algorithm"), "{err}");
}
#[test]
fn refuses_malformed_urls_naming_the_key() {
for jwks_uri in ["idp.example.test/jwks", "ftp://idp.example.test/jwks"] {
let err = resolve_err(enabled(|c| c.jwks_uri = Some(jwks_uri.into())));
assert!(err.contains("mcp.oauth.jwks_uri"), "{err}");
}
let err = resolve_err(enabled(|c| {
c.issuer = "https://idp.example.test/?x=1".into();
c.resource = "kb.example.test/mcp".into();
}));
assert!(err.contains("mcp.oauth.issuer"), "{err}");
assert!(err.contains("mcp.oauth.resource"), "{err}");
let err = resolve_err(enabled(|c| c.issuer = " https://idp.example.test/".into()));
assert!(err.contains("leading/trailing whitespace"), "{err}");
}
#[test]
fn refuses_bad_scope_and_leeway_settings() {
type Edit = fn(&mut OAuthConfig);
let cases: [(Edit, &str); 5] = [
(
|c| c.required_scope = Some("mcp:read mcp:write".into()),
"single scope",
),
(|c| c.scope_claims = vec![], "mcp.oauth.scope_claims"),
(
|c| c.principal_claims = vec![String::new()],
"mcp.oauth.principal_claims",
),
(|c| c.leeway_secs = 3600, "mcp.oauth.leeway_secs"),
(|c| c.audiences = vec![String::new()], "mcp.oauth.audiences"),
];
for (edit, needle) in cases {
let err = resolve_err(enabled(edit));
assert!(err.contains(needle), "{needle} not in: {err}");
}
}
#[test]
fn a_blank_required_scope_is_rejected_not_treated_as_absent() {
for blank in ["", " "] {
let err = resolve_err(enabled(|c| c.required_scope = Some(blank.into())));
assert!(
err.contains("mcp.oauth.required_scope"),
"an empty required scope must not silently mean 'no scope': {err}"
);
}
}
#[test]
fn every_problem_is_reported_at_once() {
let err = enabled(|c| {
c.issuer = String::new();
c.required_scope = Some(String::new());
c.required_scopes = vec!["a b".into()];
c.leeway_secs = 9999;
c.algorithms = vec!["HS256".into()];
})
.resolve(WIKI)
.unwrap_err();
assert_eq!(err.problems.len(), 5, "{err}");
}
#[test]
fn required_scopes_are_a_trimmed_deduplicated_order_stable_union() {
let oauth = enabled(|c| {
c.required_scope = Some(" a ".into());
c.required_scopes = vec!["b".into(), "a".into(), " c".into(), "b".into()];
})
.resolve(WIKI)
.unwrap()
.unwrap();
assert_eq!(oauth.required_scopes, ["a", "b", "c"]);
let only_list = enabled(|c| c.required_scopes = vec!["x".into(), "y".into()])
.resolve(WIKI)
.unwrap()
.unwrap();
assert_eq!(only_list.required_scopes, ["x", "y"]);
let only_single = enabled(|c| c.required_scope = Some("mcp:read".into()))
.resolve(WIKI)
.unwrap()
.unwrap();
assert_eq!(only_single.required_scopes, ["mcp:read"]);
}
#[test]
fn an_empty_required_scope_union_is_valid_and_means_no_scope_check() {
let oauth = enabled(|_| {}).resolve(WIKI).unwrap().unwrap();
assert!(oauth.required_scopes.is_empty());
}
#[test]
fn required_scopes_entries_follow_the_single_scope_rules() {
let err = enabled(|c| c.required_scopes = vec!["ok".into(), " ".into()])
.resolve(WIKI)
.unwrap_err();
assert_eq!(err.problems.len(), 1, "{err}");
assert!(
err.problems[0].starts_with("mcp.oauth.required_scopes contains an empty entry"),
"{err}"
);
let err = enabled(|c| c.required_scopes = vec!["a b".into()])
.resolve(WIKI)
.unwrap_err();
assert!(
err.problems[0]
.starts_with("mcp.oauth.required_scopes entry \"a b\" must be a single scope"),
"{err}"
);
}
#[cfg(feature = "serde")]
#[test]
fn round_trips_from_yaml_with_every_default() {
let yaml = "enabled: true
issuer: \"https://authentik.example.test/application/o/example-app/\"
jwks_uri: \"https://authentik.example.test/application/o/example-app/jwks/\"
audience: \"some-client-id\"
resource: \"https://kb.example.test/mcp\"
";
let parsed: OAuthConfig = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(
parsed,
OAuthConfig {
enabled: true,
issuer: "https://authentik.example.test/application/o/example-app/".into(),
jwks_uri: Some(
"https://authentik.example.test/application/o/example-app/jwks/".into()
),
audience: "some-client-id".into(),
resource: "https://kb.example.test/mcp".into(),
..OAuthConfig::default()
}
);
let back: OAuthConfig =
serde_yaml_ng::from_str(&serde_yaml_ng::to_string(&parsed).unwrap()).unwrap();
assert_eq!(back, parsed);
let empty: OAuthConfig = serde_yaml_ng::from_str("{}").unwrap();
assert_eq!(empty, OAuthConfig::default());
}
#[cfg(feature = "serde")]
#[test]
fn serde_reads_both_scope_keys_and_refuses_unknown_keys() {
let parsed: OAuthConfig =
serde_yaml_ng::from_str("required_scope: \"a\"\nrequired_scopes: [\"b\", \"c\"]\n")
.unwrap();
assert_eq!(parsed.required_scope.as_deref(), Some("a"));
assert_eq!(parsed.required_scopes, ["b", "c"]);
let parsed: OAuthConfig = serde_yaml_ng::from_str("required_scope: \"\"\n").unwrap();
assert_eq!(parsed.required_scope.as_deref(), Some(""));
assert!(serde_yaml_ng::from_str::<OAuthConfig>("bogus: true\n").is_err());
assert!(serde_yaml_ng::from_str::<OAuthConfig>("resource_name: \"x\"\n").is_err());
let parsed: OAuthConfig =
serde_yaml_ng::from_str("allow_unscoped_tokens: true\nallow_insecure_http: true\n")
.unwrap();
assert!(parsed.allow_unscoped_tokens && parsed.allow_insecure_http);
}
#[cfg(feature = "serde")]
#[test]
fn serde_distinguishes_an_omitted_scopes_supported_from_an_explicit_empty_list() {
let omitted: OAuthConfig = serde_yaml_ng::from_str("enabled: true\n").unwrap();
assert_eq!(omitted.scopes_supported, None);
let empty: OAuthConfig = serde_yaml_ng::from_str("scopes_supported: []\n").unwrap();
assert_eq!(empty.scopes_supported, Some(vec![]));
let listed: OAuthConfig =
serde_yaml_ng::from_str("scopes_supported: [\"a\", \"b\"]\n").unwrap();
assert_eq!(listed.scopes_supported, Some(vec!["a".into(), "b".into()]));
for cfg in [omitted, empty, listed] {
let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
let back: OAuthConfig = serde_yaml_ng::from_str(&yaml).unwrap();
assert_eq!(back, cfg, "{yaml}");
}
}
#[test]
fn an_omitted_scopes_supported_advertises_the_required_scopes_and_an_explicit_list_wins() {
let with_required = |required: Option<&str>, scopes: Option<Vec<String>>| {
enabled(|c| {
c.required_scope = required.map(str::to_string);
c.required_scopes = vec!["b".into(), "a".into()];
c.scopes_supported = scopes;
})
.resolve(WIKI)
.unwrap()
.expect("enabled")
.scopes_supported
};
assert_eq!(with_required(Some("a"), None), ["a", "b"]);
assert_eq!(with_required(None, None), ["b", "a"]);
assert!(with_required(Some("a"), Some(vec![])).is_empty());
assert_eq!(with_required(Some("a"), Some(vec![" c ".into()])), ["c"]);
let resolve = |scopes: Option<Vec<String>>| {
enabled(|c| c.scopes_supported = scopes)
.resolve(WIKI)
.unwrap()
.expect("enabled")
.scopes_supported
};
assert!(resolve(None).is_empty());
assert!(resolve(Some(vec![])).is_empty());
assert_eq!(
resolve(Some(vec!["mcp:read".into(), "mcp:write".into()])),
["mcp:read", "mcp:write"]
);
let app_default = |mut c: OAuthConfig| {
c.scopes_supported
.get_or_insert_with(|| vec!["mcp:read".into(), "mcp:write".into()]);
c.resolve(WIKI).unwrap().expect("enabled").scopes_supported
};
assert_eq!(
app_default(enabled(|c| c.scopes_supported = None)),
["mcp:read", "mcp:write"]
);
assert!(app_default(enabled(|c| c.scopes_supported = Some(vec![]))).is_empty());
}
#[test]
fn every_required_and_advertised_scope_must_be_a_scope_token() {
for bad in ["a\"b", "a\\b", "caf\u{e9}", "a\u{7f}"] {
let err = enabled(|c| c.required_scope = Some(bad.into()))
.resolve(WIKI)
.unwrap_err();
assert_eq!(err.problems.len(), 1, "{err}");
assert!(
err.problems[0].starts_with("mcp.oauth.required_scope ")
&& err.problems[0].contains("is not a valid scope"),
"{err}"
);
let err = enabled(|c| c.required_scopes = vec!["ok".into(), bad.into()])
.resolve(WIKI)
.unwrap_err();
assert!(
err.problems[0].starts_with("mcp.oauth.required_scopes entry "),
"{err}"
);
let err = enabled(|c| c.scopes_supported = Some(vec![bad.into()]))
.resolve(WIKI)
.unwrap_err();
assert!(
err.problems[0].starts_with("mcp.oauth.scopes_supported entry "),
"{err}"
);
}
let err = enabled(|c| c.scopes_supported = Some(vec!["".into(), "two words".into()]))
.resolve(WIKI)
.unwrap_err();
assert_eq!(err.problems.len(), 2, "{err}");
assert_eq!(
err.problems[0],
"mcp.oauth.scopes_supported contains an empty entry"
);
assert!(err.problems[1].contains("\"two words\""), "{err}");
let oauth = enabled(|c| {
c.required_scope =
Some("https://api.example.test/things.read!#$%&'()*+,-./:;<=>?@[]^_`{|}~".into());
})
.resolve(WIKI)
.unwrap()
.unwrap();
assert_eq!(oauth.required_scopes.len(), 1);
}
#[test]
fn a_url_with_a_space_control_or_non_ascii_character_is_refused() {
for bad in [
"https://kb.example.test/m\ncp",
"https://kb.example.test/m\tcp",
"https://kb.example.test/m cp",
"https://kb.example.test/caf\u{e9}",
] {
let err = resolve_err(enabled(|c| c.resource = bad.into()));
assert!(
err.contains("mcp.oauth.resource") && err.contains("percent-encoded"),
"{bad:?}: {err}"
);
let err = resolve_err(enabled(|c| c.issuer = bad.into()));
assert!(err.contains("mcp.oauth.issuer"), "{bad:?}: {err}");
let err = resolve_err(enabled(|c| c.jwks_uri = Some(bad.into())));
assert!(err.contains("mcp.oauth.jwks_uri"), "{bad:?}: {err}");
}
enabled(|c| {
c.resource = "https://kb.example.test/caf%C3%A9".into();
c.jwks_uri = Some("https://idp.example.test/keys?tenant=a".into());
})
.resolve(WIKI)
.unwrap()
.unwrap();
}
#[test]
fn plain_http_off_loopback_is_refused_unless_explicitly_allowed() {
let err = enabled(|c| {
c.issuer = "http://idp.internal.test/app/".into();
c.jwks_uri = Some("http://idp.internal.test/app/jwks/".into());
c.resource = "http://kb.internal.test/mcp".into();
})
.resolve(WIKI)
.unwrap_err();
assert_eq!(err.problems.len(), 3, "{err}");
assert!(
err.problems[0].starts_with(
"mcp.oauth.issuer \"http://idp.internal.test/app/\" uses plain http on a \
non-loopback host — signing keys"
),
"{err}"
);
assert!(err.problems[0].contains("RFC 8414 §2"), "{err}");
assert!(err.problems[1].starts_with("mcp.oauth.resource "), "{err}");
assert!(err.problems[1].contains("RFC 9728 §1.2"), "{err}");
assert!(err.problems[2].starts_with("mcp.oauth.jwks_uri "), "{err}");
assert!(
err.problems[2].contains("set mcp.oauth.allow_insecure_http"),
"{err}"
);
let oauth = enabled(|c| {
c.issuer = "http://idp.internal.test/app/".into();
c.jwks_uri = Some("http://idp.internal.test/app/jwks/".into());
c.resource = "http://kb.internal.test/mcp".into();
c.allow_insecure_http = true;
})
.resolve(WIKI)
.unwrap()
.unwrap();
assert!(oauth.allow_insecure_http);
enabled(|c| {
c.issuer = "http://127.0.0.1:9000/app/".into();
c.jwks_uri = Some("http://[::1]:9000/jwks".into());
c.resource = "http://localhost:8001/mcp".into();
})
.resolve(WIKI)
.unwrap()
.unwrap();
}
#[test]
fn no_required_scope_and_no_typ_check_is_refused_unless_explicitly_allowed() {
let bare = |edit: fn(&mut OAuthConfig)| {
let mut cfg = enabled(|c| c.allow_unscoped_tokens = false);
edit(&mut cfg);
cfg.resolve(WIKI)
};
let err = bare(|_| {}).unwrap_err();
assert_eq!(err.problems.len(), 1, "{err}");
assert_eq!(
err.problems[0],
"no required scope is configured (mcp.oauth.required_scope and \
mcp.oauth.required_scopes are unset) and mcp.oauth.require_at_jwt is off — \
nothing would tell an access token from an OIDC ID token minted for the same \
client, so any token this issuer signs for the audience would be accepted. Set \
mcp.oauth.required_scope to a scope only access tokens carry, turn on \
mcp.oauth.require_at_jwt if the authorization server emits typ at+jwt, or set \
mcp.oauth.allow_unscoped_tokens to accept that"
);
assert!(bare(|c| c.required_scope = Some("a".into())).is_ok());
assert!(bare(|c| c.required_scopes = vec!["a".into()]).is_ok());
assert!(bare(|c| c.require_at_jwt = true).is_ok());
let allowed = bare(|c| c.allow_unscoped_tokens = true).unwrap().unwrap();
assert!(allowed.required_scopes.is_empty());
assert!(allowed.allow_unscoped_tokens);
let err = bare(|c| c.required_scope = Some(String::new())).unwrap_err();
assert_eq!(err.problems.len(), 1, "{err}");
let err = OAuthConfig {
enabled: true,
..OAuthConfig::default()
}
.resolve(KeyNaming::Env("APP_OAUTH_"))
.unwrap_err();
assert!(
err.problems
.iter()
.any(|p| p.contains("set APP_OAUTH_ALLOW_UNSCOPED_TOKENS")),
"{err}"
);
}
}