use std::collections::BTreeMap;
use std::fmt;
use serde_json::Value;
use crate::algorithms::{Algorithm, DEFAULT_ALGORITHMS, parse_algorithm};
use crate::jwks::{debug_url, try_redact_url};
use crate::token::for_log;
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;
pub const MAX_TOKEN_AGE_SECS: u64 = 30 * 24 * 3600;
pub(crate) const RESERVED_REQUIRED_CLAIMS: &[&str] = &["iss", "aud", "exp", "nbf", "iat", "cnf"];
#[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, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ProblemKind {
MissingRequired,
InvalidUrl,
InsecureHttp,
BlankRequiredScope,
MultiWordScope,
InvalidScopeToken,
EmptyListEntry,
NoRequiredScope,
EmptyScopeClaims,
BadAlgorithm,
NoAlgorithms,
LeewayTooLarge,
TokenAgeOutOfRange,
InvalidRequiredClaim,
EnvLoad,
EnvParse,
Other,
}
impl ProblemKind {
pub fn as_str(self) -> &'static str {
match self {
Self::MissingRequired => "missing_required",
Self::InvalidUrl => "invalid_url",
Self::InsecureHttp => "insecure_http",
Self::BlankRequiredScope => "blank_required_scope",
Self::MultiWordScope => "multi_word_scope",
Self::InvalidScopeToken => "invalid_scope_token",
Self::EmptyListEntry => "empty_list_entry",
Self::NoRequiredScope => "no_required_scope",
Self::EmptyScopeClaims => "empty_scope_claims",
Self::BadAlgorithm => "bad_algorithm",
Self::NoAlgorithms => "no_algorithms",
Self::LeewayTooLarge => "leeway_too_large",
Self::TokenAgeOutOfRange => "token_age_out_of_range",
Self::InvalidRequiredClaim => "invalid_required_claim",
Self::EnvLoad => "env_load",
Self::EnvParse => "env_parse",
Self::Other => "other",
}
}
}
impl fmt::Display for ProblemKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ConfigProblem {
kind: ProblemKind,
keys: Vec<String>,
message: String,
}
impl ConfigProblem {
pub fn new(
kind: ProblemKind,
keys: impl IntoIterator<Item = impl Into<String>>,
message: impl Into<String>,
) -> Self {
Self {
kind,
keys: keys.into_iter().map(Into::into).collect(),
message: message.into(),
}
}
pub fn kind(&self) -> ProblemKind {
self.kind
}
pub fn keys(&self) -> &[String] {
&self.keys
}
pub fn message(&self) -> &str {
&self.message
}
}
impl From<String> for ConfigProblem {
fn from(message: String) -> Self {
Self::new(ProblemKind::Other, Vec::<String>::new(), message)
}
}
impl fmt::Display for ConfigProblem {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
#[derive(Debug, Clone, thiserror::Error)]
pub struct ConfigError {
pub problems: Vec<String>,
details: Vec<ConfigProblem>,
naming: KeyNamingBuf,
}
impl ConfigError {
pub fn new(naming: KeyNaming<'_>, problems: Vec<String>) -> Self {
let details = problems.iter().cloned().map(ConfigProblem::from).collect();
Self::assemble(naming, problems, details)
}
pub fn from_problems(
naming: KeyNaming<'_>,
problems: impl IntoIterator<Item = ConfigProblem>,
) -> Self {
let details: Vec<ConfigProblem> = problems.into_iter().collect();
let text = details.iter().map(|p| p.message.clone()).collect();
Self::assemble(naming, text, details)
}
fn assemble(naming: KeyNaming<'_>, problems: Vec<String>, details: Vec<ConfigProblem>) -> Self {
debug_assert!(!problems.is_empty(), "ConfigError built with no problems");
debug_assert_eq!(problems.len(), details.len());
Self {
problems,
details,
naming: naming.to_buf(),
}
}
pub fn problem_details(&self) -> &[ConfigProblem] {
&self.details
}
pub fn naming(&self) -> KeyNaming<'_> {
self.naming.as_naming()
}
}
impl PartialEq for ConfigError {
fn eq(&self, other: &Self) -> bool {
self.problems == other.problems && self.naming == other.naming
}
}
impl Eq for ConfigError {}
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(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,
#[cfg_attr(feature = "serde", serde(default))]
pub allowed_client_ids: Vec<String>,
#[cfg_attr(feature = "serde", serde(default))]
pub max_token_age_secs: Option<u64>,
#[cfg_attr(
feature = "serde",
serde(default, deserialize_with = "required_claims_without_duplicates")
)]
pub required_claims: BTreeMap<String, Value>,
}
#[cfg(feature = "serde")]
fn required_claims_without_duplicates<'de, D>(
deserializer: D,
) -> Result<BTreeMap<String, Value>, D::Error>
where
D: serde::Deserializer<'de>,
{
struct Claims;
impl<'de> serde::de::Visitor<'de> for Claims {
type Value = BTreeMap<String, Value>;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a map of claim names to required values")
}
fn visit_map<A: serde::de::MapAccess<'de>>(
self,
mut map: A,
) -> Result<Self::Value, A::Error> {
let mut claims = BTreeMap::new();
while let Some((name, value)) = map.next_entry::<String, Value>()? {
if claims.contains_key(&name) {
return Err(serde::de::Error::custom(format!(
"required_claims names {:?} more than once",
crate::token::for_log(&name)
)));
}
claims.insert(name, value);
}
Ok(claims)
}
}
deserializer.deserialize_map(Claims)
}
impl fmt::Debug for OAuthConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Self {
enabled,
issuer,
jwks_uri,
audience,
audiences,
resource,
required_scope,
required_scopes,
scopes_supported,
scope_claims,
principal_claims,
algorithms,
leeway_secs,
require_at_jwt,
allow_unscoped_tokens,
allow_insecure_http,
accept_static_bearer,
allowed_client_ids,
max_token_age_secs,
required_claims,
} = self;
f.debug_struct("OAuthConfig")
.field("enabled", enabled)
.field("issuer", &debug_url(issuer))
.field("jwks_uri", &jwks_uri.as_deref().map(debug_url))
.field("audience", audience)
.field("audiences", audiences)
.field("resource", &debug_url(resource))
.field("required_scope", required_scope)
.field("required_scopes", required_scopes)
.field("scopes_supported", scopes_supported)
.field("scope_claims", scope_claims)
.field("principal_claims", principal_claims)
.field("algorithms", algorithms)
.field("leeway_secs", leeway_secs)
.field("require_at_jwt", require_at_jwt)
.field("allow_unscoped_tokens", allow_unscoped_tokens)
.field("allow_insecure_http", allow_insecure_http)
.field("accept_static_bearer", accept_static_bearer)
.field("allowed_client_ids", allowed_client_ids)
.field("max_token_age_secs", max_token_age_secs)
.field("required_claims", required_claims)
.finish()
}
}
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,
allowed_client_ids: Vec::new(),
max_token_age_secs: None,
required_claims: BTreeMap::new(),
}
}
}
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<ConfigProblem> = Vec::new();
let mut blank_fields: Vec<&str> = Vec::new();
for (name, value) in [("issuer", &self.issuer), ("resource", &self.resource)] {
if value.trim().is_empty() {
blank_fields.push(name);
}
}
if self.audience.trim().is_empty() && self.audiences.is_empty() {
blank_fields.push("audience");
}
if !blank_fields.is_empty() {
let blank: Vec<String> = blank_fields
.iter()
.map(|&f| match f {
"audience" => format!("{} (or {})", key("audience"), key("audiences")),
_ => key(f),
})
.collect();
let blank_keys: Vec<String> = blank_fields
.iter()
.flat_map(|&f| match f {
"audience" => vec![key("audience"), key("audiences")],
_ => vec![key(f)],
})
.collect();
problems.push(ConfigProblem::new(
ProblemKind::MissingRequired,
blank_keys,
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(ConfigProblem::new(ProblemKind::InvalidUrl, [key(name)], e));
}
Ok(()) if !self.allow_insecure_http && plain_http_non_loopback(value) => {
problems.push(ConfigProblem::new(
ProblemKind::InsecureHttp,
[key(name), key("allow_insecure_http")],
format!(
"{} uses plain http on a non-loopback host — {}. Use \
https, or set {} if this address is on a network you trust",
shown(&key(name), value),
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(ConfigProblem::new(
ProblemKind::EmptyListEntry,
[key("audiences")],
format!("{} contains an empty entry", key("audiences")),
));
}
if let Some(required_scope) = &self.required_scope {
let k = key("required_scope");
if required_scope.trim().is_empty() {
problems.push(ConfigProblem::new(
ProblemKind::BlankRequiredScope,
[k.clone()],
format!(
"{k} must not be empty — a blank required scope would let any signed \
token through unscoped. Use a scope your authorization server \
actually issues"
),
));
} else if required_scope.split_whitespace().count() != 1 {
problems.push(ConfigProblem::new(
ProblemKind::MultiWordScope,
[k.clone()],
format!(
"{k} {required_scope:?} must be a single scope (no spaces) — scopes \
are matched one token at a time"
),
));
} else if !is_scope_token(required_scope.trim()) {
problems.push(ConfigProblem::new(
ProblemKind::InvalidScopeToken,
[k.clone()],
scope_token_problem(&k, required_scope),
));
}
}
for scope in &self.required_scopes {
let k = key("required_scopes");
if scope.trim().is_empty() {
problems.push(ConfigProblem::new(
ProblemKind::BlankRequiredScope,
[k.clone()],
format!(
"{k} 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"
),
));
} else if scope.split_whitespace().count() != 1 {
problems.push(ConfigProblem::new(
ProblemKind::MultiWordScope,
[k.clone()],
format!(
"{k} entry {scope:?} must be a single scope (no spaces) — scopes are \
matched one token at a time; list each one as its own entry"
),
));
} else if !is_scope_token(scope.trim()) {
problems.push(ConfigProblem::new(
ProblemKind::InvalidScopeToken,
[k.clone()],
scope_token_problem(&format!("{k} entry"), scope),
));
}
}
if let Some(supported) = &self.scopes_supported {
let k = key("scopes_supported");
if supported.iter().any(|s| s.trim().is_empty()) {
problems.push(ConfigProblem::new(
ProblemKind::EmptyListEntry,
[k.clone()],
format!("{k} contains an empty entry"),
));
}
for scope in supported.iter().filter(|s| !s.trim().is_empty()) {
if !is_scope_token(scope.trim()) {
problems.push(ConfigProblem::new(
ProblemKind::InvalidScopeToken,
[k.clone()],
scope_token_problem(&format!("{k} entry"), scope),
));
}
}
}
if self.required_scope.is_none()
&& self.required_scopes.is_empty()
&& !self.require_at_jwt
&& !self.allow_unscoped_tokens
{
problems.push(ConfigProblem::new(
ProblemKind::NoRequiredScope,
[
key("required_scope"),
key("required_scopes"),
key("require_at_jwt"),
key("allow_unscoped_tokens"),
],
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(ConfigProblem::new(
ProblemKind::EmptyScopeClaims,
[key("scope_claims")],
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(ConfigProblem::new(
ProblemKind::EmptyListEntry,
[key("principal_claims")],
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(ConfigProblem::new(
ProblemKind::BadAlgorithm,
[key("algorithms")],
format!(
"{} has unacceptable entries: {}",
key("algorithms"),
bad_algorithms.join("; ")
),
));
} else if algorithms.is_empty() {
problems.push(ConfigProblem::new(
ProblemKind::NoAlgorithms,
[key("algorithms")],
format!("{} must list at least one algorithm", key("algorithms")),
));
}
if self.leeway_secs > MAX_LEEWAY_SECS {
problems.push(ConfigProblem::new(
ProblemKind::LeewayTooLarge,
[key("leeway_secs")],
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 self.allowed_client_ids.iter().any(|c| c.trim().is_empty()) {
problems.push(ConfigProblem::new(
ProblemKind::EmptyListEntry,
[key("allowed_client_ids")],
format!(
"{} contains an empty entry — list the OAuth client IDs whose tokens \
are accepted",
key("allowed_client_ids")
),
));
}
if let Some(age) = self.max_token_age_secs
&& !(1..=MAX_TOKEN_AGE_SECS).contains(&age)
{
problems.push(ConfigProblem::new(
ProblemKind::TokenAgeOutOfRange,
[key("max_token_age_secs")],
format!(
"{} {age} is outside 1..={MAX_TOKEN_AGE_SECS} seconds (30 days) — leave it \
unset to not bound token age",
key("max_token_age_secs")
),
));
}
for (name, value) in &self.required_claims {
let k = key("required_claims");
let why = if name.trim().is_empty() {
Some("has an entry with a blank claim name".to_string())
} else if RESERVED_REQUIRED_CLAIMS.contains(&name.as_str()) {
Some(format!(
"names {name:?}, which this crate already checks (iss, aud, exp, nbf, iat, \
cnf) — use issuer/audience, leeway_secs or max_token_age_secs instead"
))
} else if !matches!(value, Value::String(_) | Value::Number(_) | Value::Bool(_)) {
let json_type = match value {
Value::Null => "null",
Value::Array(_) => "an array",
_ => "an object",
};
Some(format!(
"entry {name:?} requires {json_type}, but a required value must be a \
string, number or boolean (a token's array claim passes when it contains \
it)"
))
} else {
None
};
if let Some(why) = why {
problems.push(ConfigProblem::new(
ProblemKind::InvalidRequiredClaim,
[k.clone()],
format!("{k} {why}"),
));
}
}
if !problems.is_empty() {
return Err(ConfigError::from_problems(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,
allowed_client_ids: self.allowed_client_ids,
max_token_age_secs: self.max_token_age_secs,
required_claims: self.required_claims,
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!(
"{} is not an absolute URL ({e})",
shown_unparsed(key, value)
)
})?;
if !matches!(parsed.scheme(), "https" | "http") {
return Err(format!("{} must be an http(s) URL", shown(key, value)));
}
if identifier && (parsed.fragment().is_some() || parsed.query().is_some()) {
return Err(format!(
"{} must not contain a query or fragment",
shown(key, value)
));
}
if value != value.trim() {
return Err(format!(
"{} has leading/trailing whitespace — it is compared byte-for-byte",
shown(key, value)
));
}
if value.chars().any(|c| !c.is_ascii_graphic()) {
return Err(format!(
"{} contains a space, a control character or a non-ASCII character — write it \
percent-encoded (and an internationalized host in its punycode form)",
shown(key, value)
));
}
Ok(())
}
fn shown(key: &str, value: &str) -> String {
match try_redact_url(value) {
Some(redacted) if redacted == value => format!("{key} {redacted:?}"),
Some(redacted) => format!("{key} {redacted:?} (shown normalized, credential masked)"),
None => shown_unparsed(key, value),
}
}
fn shown_unparsed(key: &str, value: &str) -> String {
let safe = !value.is_empty()
&& value
.chars()
.all(|c| c.is_ascii_graphic() && !matches!(c, '@' | '?' | '#'));
if safe {
format!("{key} {:?}", for_log(value))
} else {
key.to_string()
}
}
pub(crate) 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(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 allowed_client_ids: Vec<String>,
pub max_token_age_secs: Option<u64>,
pub required_claims: BTreeMap<String, Value>,
pub resource_name: Option<String>,
pub key_naming: KeyNamingBuf,
}
impl fmt::Debug for ResolvedOAuthConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Self {
issuer,
jwks_uri,
audience,
audiences,
resource,
required_scopes,
scopes_supported,
scope_claims,
principal_claims,
algorithms,
leeway_secs,
require_at_jwt,
allow_unscoped_tokens,
allow_insecure_http,
accept_static_bearer,
allowed_client_ids,
max_token_age_secs,
required_claims,
resource_name,
key_naming,
} = self;
f.debug_struct("ResolvedOAuthConfig")
.field("issuer", &debug_url(issuer))
.field("jwks_uri", &jwks_uri.as_deref().map(debug_url))
.field("audience", audience)
.field("audiences", audiences)
.field("resource", &debug_url(resource))
.field("required_scopes", required_scopes)
.field("scopes_supported", scopes_supported)
.field("scope_claims", scope_claims)
.field("principal_claims", principal_claims)
.field("algorithms", algorithms)
.field("leeway_secs", leeway_secs)
.field("require_at_jwt", require_at_jwt)
.field("allow_unscoped_tokens", allow_unscoped_tokens)
.field("allow_insecure_http", allow_insecure_http)
.field("accept_static_bearer", accept_static_bearer)
.field("allowed_client_ids", allowed_client_ids)
.field("max_token_age_secs", max_token_age_secs)
.field("required_claims", required_claims)
.field("resource_name", resource_name)
.field("key_naming", key_naming)
.finish()
}
}
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();
}
const NON_CANONICAL_HTTP: [&str; 4] = [
"http:/idp.example.test/jwks",
"http:idp.example.test/jwks",
"HTTP:\\\\idp.example.test\\jwks",
" http://idp.example.test/jwks",
];
fn with_url(name: &str, url: &str, allow_insecure_http: bool) -> OAuthConfig {
enabled(|c| {
c.allow_insecure_http = allow_insecure_http;
match name {
"issuer" => c.issuer = url.into(),
"jwks_uri" => c.jwks_uri = Some(url.into()),
"resource" => c.resource = url.into(),
other => panic!("no URL setting {other}"),
}
})
}
#[test]
fn non_canonical_plain_http_spellings_are_refused_for_every_url_setting() {
for spelling in NON_CANONICAL_HTTP {
for name in ["issuer", "jwks_uri", "resource"] {
let err = with_url(name, spelling, false)
.resolve(WIKI)
.expect_err(&format!("{name} = {spelling:?} must be refused"));
assert_eq!(err.problems.len(), 1, "{name} = {spelling:?}: {err}");
assert!(
err.problems[0].starts_with(&format!("mcp.oauth.{name} ")),
"{name} = {spelling:?}: {err}"
);
let expected = if spelling.starts_with(' ') {
ProblemKind::InvalidUrl
} else {
ProblemKind::InsecureHttp
};
assert_eq!(
err.problem_details()[0].kind(),
expected,
"{name} = {spelling:?}: {err}"
);
}
}
}
#[test]
fn non_canonical_spellings_that_are_not_a_cleartext_hole_resolve_with_a_warning() {
let cases: [(&str, bool, &str); 8] = [
(
"http:/idp.example.test/jwks",
true,
"http://idp.example.test/jwks",
),
(
"http:idp.example.test/jwks",
true,
"http://idp.example.test/jwks",
),
(
"HTTP:\\\\idp.example.test\\jwks",
true,
"http://idp.example.test/jwks",
),
(
"http:/localhost:9000/jwks",
false,
"http://localhost:9000/jwks",
),
(
"https:/idp.example.test/jwks",
false,
"https://idp.example.test/jwks",
),
(
"https:idp.example.test/jwks",
false,
"https://idp.example.test/jwks",
),
(
"https://idp.example.test\\jwks",
false,
"https://idp.example.test/jwks",
),
(
"https:///idp.example.test/jwks",
false,
"https://idp.example.test/jwks",
),
];
for (spelling, opt_in, canonical) in cases {
for name in ["issuer", "jwks_uri", "resource"] {
let resolved = with_url(name, spelling, opt_in)
.resolve(WIKI)
.unwrap_or_else(|e| panic!("{name} = {spelling:?}: {e}"))
.unwrap();
let warnings = crate::validator::non_canonical_warnings(&resolved);
assert_eq!(warnings.len(), 1, "{name} = {spelling:?}: {warnings:?}");
assert!(
warnings[0].starts_with(&format!(
"mcp.oauth.{name} {spelling:?} is not canonically spelled — it is \
read as {canonical:?}; "
)),
"{name} = {spelling:?}: {warnings:?}"
);
}
}
let resolved = enabled(|c| c.jwks_uri = Some("https://idp.example.test/jwks".into()))
.resolve(WIKI)
.unwrap()
.unwrap();
assert!(crate::validator::non_canonical_warnings(&resolved).is_empty());
}
#[test]
fn canonical_url_spellings_behave_as_before() {
for url in [
"http://idp.example.test/jwks",
"HTTP://idp.example.test/jwks",
] {
for name in ["issuer", "jwks_uri", "resource"] {
let err = with_url(name, url, false).resolve(WIKI).unwrap_err();
assert_eq!(err.problems.len(), 1, "{name} = {url:?}: {err}");
assert!(
err.problems[0].contains("uses plain http on a non-loopback host"),
"{name} = {url:?}: {err}"
);
with_url(name, url, true)
.resolve(WIKI)
.unwrap_or_else(|e| panic!("{name} = {url:?}: {e}"))
.unwrap();
}
}
for url in [
"http://localhost/jwks",
"http://127.0.0.1:9000/jwks",
"http://[::1]:9000/jwks",
"https://idp.example.test/jwks",
"https://IDP.example.test:443",
] {
for name in ["issuer", "jwks_uri", "resource"] {
with_url(name, url, false)
.resolve(WIKI)
.unwrap_or_else(|e| panic!("{name} = {url:?}: {e}"))
.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}"
);
}
fn spelled(naming: KeyNaming<'_>, field: &str) -> String {
match naming {
KeyNaming::Dotted(p) => format!("{p}.{field}"),
KeyNaming::Env(p) => format!("{p}{}", field.to_uppercase()),
}
}
type Edit = fn(&mut OAuthConfig);
fn one_problem_per_kind() -> Vec<(ProblemKind, Edit, &'static [&'static str])> {
vec![
(
ProblemKind::MissingRequired,
|c| c.issuer.clear(),
&["issuer"],
),
(
ProblemKind::MissingRequired,
|c| {
c.audience.clear();
c.audiences.clear();
},
&["audience", "audiences"],
),
(
ProblemKind::InvalidUrl,
|c| c.jwks_uri = Some("not a url".into()),
&["jwks_uri"],
),
(
ProblemKind::InsecureHttp,
|c| c.resource = "http://kb.example.test/mcp".into(),
&["resource", "allow_insecure_http"],
),
(
ProblemKind::BlankRequiredScope,
|c| c.required_scope = Some(" ".into()),
&["required_scope"],
),
(
ProblemKind::BlankRequiredScope,
|c| c.required_scopes = vec!["".into()],
&["required_scopes"],
),
(
ProblemKind::MultiWordScope,
|c| c.required_scope = Some("a b".into()),
&["required_scope"],
),
(
ProblemKind::MultiWordScope,
|c| c.required_scopes = vec!["a b".into()],
&["required_scopes"],
),
(
ProblemKind::InvalidScopeToken,
|c| c.required_scope = Some("a\"b".into()),
&["required_scope"],
),
(
ProblemKind::InvalidScopeToken,
|c| c.required_scopes = vec!["a\\b".into()],
&["required_scopes"],
),
(
ProblemKind::InvalidScopeToken,
|c| c.scopes_supported = Some(vec!["a\"b".into()]),
&["scopes_supported"],
),
(
ProblemKind::EmptyListEntry,
|c| c.audiences = vec!["".into()],
&["audiences"],
),
(
ProblemKind::EmptyListEntry,
|c| c.scopes_supported = Some(vec!["".into()]),
&["scopes_supported"],
),
(
ProblemKind::EmptyListEntry,
|c| c.principal_claims = vec!["".into()],
&["principal_claims"],
),
(
ProblemKind::NoRequiredScope,
|c| c.allow_unscoped_tokens = false,
&[
"required_scope",
"required_scopes",
"require_at_jwt",
"allow_unscoped_tokens",
],
),
(
ProblemKind::EmptyScopeClaims,
|c| c.scope_claims.clear(),
&["scope_claims"],
),
(
ProblemKind::BadAlgorithm,
|c| c.algorithms = vec!["HS256".into()],
&["algorithms"],
),
(
ProblemKind::NoAlgorithms,
|c| c.algorithms.clear(),
&["algorithms"],
),
(
ProblemKind::LeewayTooLarge,
|c| c.leeway_secs = MAX_LEEWAY_SECS + 1,
&["leeway_secs"],
),
(
ProblemKind::EmptyListEntry,
|c| c.allowed_client_ids = vec!["client-a".into(), " ".into()],
&["allowed_client_ids"],
),
(
ProblemKind::TokenAgeOutOfRange,
|c| c.max_token_age_secs = Some(0),
&["max_token_age_secs"],
),
(
ProblemKind::TokenAgeOutOfRange,
|c| c.max_token_age_secs = Some(MAX_TOKEN_AGE_SECS + 1),
&["max_token_age_secs"],
),
(
ProblemKind::InvalidRequiredClaim,
|c| {
c.required_claims.insert(" ".into(), "x".into());
},
&["required_claims"],
),
(
ProblemKind::InvalidRequiredClaim,
|c| {
c.required_claims.insert("aud".into(), "x".into());
},
&["required_claims"],
),
(
ProblemKind::InvalidRequiredClaim,
|c| {
c.required_claims.insert("tid".into(), Value::Null);
},
&["required_claims"],
),
(
ProblemKind::InvalidRequiredClaim,
|c| {
c.required_claims
.insert("groups".into(), serde_json::json!(["a"]));
},
&["required_claims"],
),
(
ProblemKind::InvalidRequiredClaim,
|c| {
c.required_claims
.insert("org".into(), serde_json::json!({"id": 1}));
},
&["required_claims"],
),
]
}
#[test]
fn every_kind_resolve_can_produce_is_produced_and_names_its_settings() {
let mut seen = std::collections::HashSet::new();
for naming in [WIKI, KeyNaming::Env("APP_OAUTH_")] {
for (kind, edit, fields) in one_problem_per_kind() {
let err = enabled(edit).resolve(naming).unwrap_err();
let details = err.problem_details();
assert_eq!(details.len(), 1, "{kind:?}: {err}");
let p = &details[0];
assert_eq!(p.kind(), kind, "{err}");
let want: Vec<String> = fields.iter().map(|f| spelled(naming, f)).collect();
assert_eq!(p.keys(), want.as_slice(), "{kind:?} under {naming:?}");
assert_eq!(p.message(), err.problems[0]);
assert_eq!(p.to_string(), p.message());
seen.insert(kind);
}
}
for kind in [
ProblemKind::MissingRequired,
ProblemKind::InvalidUrl,
ProblemKind::InsecureHttp,
ProblemKind::BlankRequiredScope,
ProblemKind::MultiWordScope,
ProblemKind::InvalidScopeToken,
ProblemKind::EmptyListEntry,
ProblemKind::NoRequiredScope,
ProblemKind::EmptyScopeClaims,
ProblemKind::BadAlgorithm,
ProblemKind::NoAlgorithms,
ProblemKind::LeewayTooLarge,
ProblemKind::TokenAgeOutOfRange,
ProblemKind::InvalidRequiredClaim,
] {
assert!(seen.contains(&kind), "{kind:?} is never produced");
}
for kind in seen {
match kind {
ProblemKind::EnvLoad | ProblemKind::EnvParse | ProblemKind::Other => {
panic!("resolve produced {kind:?}")
}
_ => {}
}
}
}
#[test]
fn a_missing_required_problem_names_every_blank_setting() {
let err = enabled(|c| {
c.issuer.clear();
c.resource.clear();
c.audience.clear();
})
.resolve(KeyNaming::Env("APP_OAUTH_"))
.unwrap_err();
assert_eq!(
err.problem_details()[0].keys(),
[
"APP_OAUTH_ISSUER",
"APP_OAUTH_RESOURCE",
"APP_OAUTH_AUDIENCE",
"APP_OAUTH_AUDIENCES"
]
);
}
#[test]
fn problems_and_problem_details_agree_across_a_multi_problem_config() {
let cfg = OAuthConfig {
enabled: true,
leeway_secs: MAX_LEEWAY_SECS + 1,
required_scope: Some("a b".into()),
algorithms: vec!["none".into()],
principal_claims: vec![" ".into()],
..OAuthConfig::default()
};
for naming in [WIKI, KeyNaming::Env("APP_OAUTH_")] {
let err = cfg.clone().resolve(naming).unwrap_err();
let details = err.problem_details();
assert_eq!(details.len(), 5, "{err}");
let texts: Vec<&str> = details.iter().map(ConfigProblem::message).collect();
assert_eq!(texts, err.problems);
let kinds: Vec<ProblemKind> = details.iter().map(ConfigProblem::kind).collect();
assert_eq!(
kinds,
[
ProblemKind::MissingRequired,
ProblemKind::MultiWordScope,
ProblemKind::EmptyListEntry,
ProblemKind::BadAlgorithm,
ProblemKind::LeewayTooLarge,
]
);
}
}
#[test]
fn config_error_new_is_unchanged_and_wraps_strings_as_other() {
let err = ConfigError::new(WIKI, vec!["first".to_string(), "second".to_string()]);
assert_eq!(err.problems, ["first", "second"]);
let details = err.problem_details();
assert_eq!(details.len(), 2);
assert!(details.iter().all(|p| p.kind() == ProblemKind::Other));
assert!(details.iter().all(|p| p.keys().is_empty()));
assert_eq!(details[1].message(), "second");
assert_eq!(
err.to_string(),
"mcp.oauth.enabled is true but the OAuth config is not usable:\n - first\n - second\n\
Fix these, or set mcp.oauth.enabled: false."
);
let collected = ConfigError::new(WIKI, ["a", "b"].iter().map(|s| s.to_string()).collect());
assert_eq!(collected.problems, ["a", "b"]);
}
#[test]
fn from_problems_and_from_string_mix_structured_and_plain_problems() {
let p = ConfigProblem::from("app problem".to_string());
assert_eq!(p.kind(), ProblemKind::Other);
assert!(p.keys().is_empty());
assert_eq!(p.message(), "app problem");
assert_eq!(p.to_string(), "app problem");
let own = ConfigProblem::new(ProblemKind::InvalidUrl, ["my.key"], "my.key is bad");
assert_eq!(own.keys(), ["my.key"]);
let err = ConfigError::from_problems(WIKI, [own.clone(), p.clone()]);
assert_eq!(err.problems, ["my.key is bad", "app problem"]);
assert_eq!(err.problem_details(), [own, p]);
assert!(
err.to_string()
.contains("\n - my.key is bad\n - app problem\n")
);
assert_eq!(err.naming(), WIKI);
}
#[test]
fn config_error_equality_ignores_the_structured_details() {
let err = enabled(|c| c.leeway_secs = MAX_LEEWAY_SECS + 1)
.resolve(WIKI)
.unwrap_err();
let rebuilt = ConfigError::new(WIKI, err.problems.clone());
assert_eq!(rebuilt, err);
assert_ne!(rebuilt.problem_details(), err.problem_details());
assert_ne!(ConfigError::new(WIKI, vec!["other".into()]), err);
assert_ne!(
ConfigError::new(KeyNaming::Env("APP_OAUTH_"), err.problems.clone()),
err
);
}
#[test]
fn editing_the_public_problems_field_does_not_touch_problem_details() {
let mut err = enabled(|c| c.leeway_secs = MAX_LEEWAY_SECS + 1)
.resolve(WIKI)
.unwrap_err();
let before = err.problem_details().to_vec();
err.problems.push("extra".into());
assert_eq!(err.problem_details(), before);
}
#[test]
fn problem_kind_labels_are_stable_and_distinct() {
let all = [
(ProblemKind::MissingRequired, "missing_required"),
(ProblemKind::InvalidUrl, "invalid_url"),
(ProblemKind::InsecureHttp, "insecure_http"),
(ProblemKind::BlankRequiredScope, "blank_required_scope"),
(ProblemKind::MultiWordScope, "multi_word_scope"),
(ProblemKind::InvalidScopeToken, "invalid_scope_token"),
(ProblemKind::EmptyListEntry, "empty_list_entry"),
(ProblemKind::NoRequiredScope, "no_required_scope"),
(ProblemKind::EmptyScopeClaims, "empty_scope_claims"),
(ProblemKind::BadAlgorithm, "bad_algorithm"),
(ProblemKind::NoAlgorithms, "no_algorithms"),
(ProblemKind::LeewayTooLarge, "leeway_too_large"),
(ProblemKind::TokenAgeOutOfRange, "token_age_out_of_range"),
(ProblemKind::InvalidRequiredClaim, "invalid_required_claim"),
(ProblemKind::EnvLoad, "env_load"),
(ProblemKind::EnvParse, "env_parse"),
(ProblemKind::Other, "other"),
];
for (kind, label) in all {
assert_eq!(kind.as_str(), label);
assert_eq!(kind.to_string(), label);
}
let labels: std::collections::HashSet<_> = all.iter().map(|(_, l)| *l).collect();
assert_eq!(labels.len(), all.len());
}
#[test]
fn a_non_scalar_required_claim_is_described_never_echoed() {
for (value, shown) in [
(serde_json::json!({"nested": "s3cret-object"}), "an object"),
(serde_json::json!(["s3cret-array"]), "an array"),
(Value::Null, "null"),
] {
let mut cfg = OAuthConfig {
enabled: true,
issuer: "https://idp.example.test/".into(),
audience: "client-a".into(),
resource: "https://kb.example.test/".into(),
required_scope: Some("api:read".into()),
..OAuthConfig::default()
};
cfg.required_claims.insert("tid".into(), value.clone());
let err = cfg.resolve(KeyNaming::Dotted("oauth")).unwrap_err();
assert_eq!(
err.problem_details()[0].kind(),
ProblemKind::InvalidRequiredClaim
);
let text = err.to_string();
assert!(text.contains(&format!("requires {shown}")), "{text}");
assert!(!text.contains("s3cret"), "{value}: {text}");
}
}
#[cfg(feature = "serde")]
#[test]
fn a_config_file_naming_a_required_claim_twice_is_refused() {
let err = serde_json::from_str::<OAuthConfig>(
r#"{"required_claims": {"tid": "good", "tid": "evil"}}"#,
)
.unwrap_err()
.to_string();
assert!(err.contains("\"tid\" more than once"), "{err}");
assert!(!err.contains("evil"), "{err}");
let ok: OAuthConfig =
serde_json::from_str(r#"{"required_claims": {"tid": "good", "level": 2}}"#).unwrap();
assert_eq!(ok.required_claims.len(), 2);
}
#[cfg(feature = "serde")]
#[test]
fn the_claim_policy_settings_deserialize_and_default_to_off() {
let yaml = "\
enabled: true
allowed_client_ids: [\"client-a\", \"client-b\"]
max_token_age_secs: 3600
required_claims:
tid: \"tenant-1\"
level: 2
mfa: true
";
let parsed: OAuthConfig = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(parsed.allowed_client_ids, ["client-a", "client-b"]);
assert_eq!(parsed.max_token_age_secs, Some(3600));
assert_eq!(parsed.required_claims["tid"], serde_json::json!("tenant-1"));
assert_eq!(parsed.required_claims["level"], serde_json::json!(2));
assert_eq!(parsed.required_claims["mfa"], serde_json::json!(true));
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());
assert!(empty.allowed_client_ids.is_empty());
assert_eq!(empty.max_token_age_secs, None);
assert!(empty.required_claims.is_empty());
assert!(serde_yaml_ng::from_str::<OAuthConfig>("allowed_clients: [\"a\"]\n").is_err());
assert!(serde_yaml_ng::from_str::<OAuthConfig>("max_token_age: 5\n").is_err());
}
#[test]
fn the_claim_policy_settings_resolve_unchanged_and_default_off() {
let resolved = enabled(|_| {}).resolve(WIKI).unwrap().unwrap();
assert!(resolved.allowed_client_ids.is_empty());
assert_eq!(resolved.max_token_age_secs, None);
assert!(resolved.required_claims.is_empty());
let resolved = enabled(|c| {
c.allowed_client_ids = vec!["client-a".into()];
c.max_token_age_secs = Some(MAX_TOKEN_AGE_SECS);
c.required_claims
.insert("groups".into(), serde_json::json!("api-users"));
c.required_claims
.insert("level".into(), serde_json::json!(2));
c.required_claims
.insert("mfa".into(), serde_json::json!(false));
})
.resolve(WIKI)
.unwrap()
.unwrap();
assert_eq!(resolved.allowed_client_ids, ["client-a"]);
assert_eq!(resolved.max_token_age_secs, Some(MAX_TOKEN_AGE_SECS));
assert_eq!(resolved.required_claims.len(), 3);
let one = enabled(|c| c.max_token_age_secs = Some(1)).resolve(WIKI);
assert!(one.unwrap().is_some());
let err = enabled(|c| {
for name in RESERVED_REQUIRED_CLAIMS {
c.required_claims.insert((*name).into(), "x".into());
}
})
.resolve(KeyNaming::Env("APP_OAUTH_"))
.unwrap_err();
assert_eq!(err.problems.len(), RESERVED_REQUIRED_CLAIMS.len(), "{err}");
for p in err.problem_details() {
assert_eq!(p.kind(), ProblemKind::InvalidRequiredClaim);
assert_eq!(p.keys(), ["APP_OAUTH_REQUIRED_CLAIMS"]);
}
}
#[test]
fn no_problem_message_carries_a_run_of_spaces() {
for naming in [WIKI, KeyNaming::Env("APP_OAUTH_")] {
for (kind, edit, _) in one_problem_per_kind() {
let err = enabled(edit).resolve(naming).unwrap_err();
for problem in &err.problems {
assert!(!problem.contains(" "), "{kind:?}: {problem:?}");
}
}
}
}
#[cfg(feature = "serde")]
#[test]
fn unset_claim_policy_settings_are_still_serialized() {
let yaml = serde_yaml_ng::to_string(&OAuthConfig::default()).unwrap();
for key in [
"allowed_client_ids",
"max_token_age_secs",
"required_claims",
] {
assert!(yaml.contains(key), "{key} missing from {yaml}");
}
}
}