use core::fmt;
use crate::json::JsonValue;
use crate::util::log::{info, warn};
use crate::util::validation::is_https_url;
#[doc(alias = "openid_configuration")]
#[derive(Debug, Clone)]
pub struct OidcDiscovery {
issuer: String,
authorization_endpoint: String,
token_endpoint: String,
userinfo_endpoint: Option<String>,
jwks_uri: String,
scopes_supported: Vec<String>,
response_types_supported: Vec<String>,
id_token_signing_alg_values_supported: Vec<String>,
}
impl OidcDiscovery {
#[must_use]
#[inline]
pub fn issuer(&self) -> &str {
&self.issuer
}
#[must_use]
#[inline]
pub fn authorization_endpoint(&self) -> &str {
&self.authorization_endpoint
}
#[must_use]
#[inline]
pub fn token_endpoint(&self) -> &str {
&self.token_endpoint
}
#[must_use]
#[inline]
pub fn userinfo_endpoint(&self) -> Option<&str> {
self.userinfo_endpoint.as_deref()
}
#[must_use]
#[inline]
pub fn jwks_uri(&self) -> &str {
&self.jwks_uri
}
#[must_use]
#[inline]
pub fn scopes_supported(&self) -> &[String] {
&self.scopes_supported
}
#[must_use]
#[inline]
pub fn response_types_supported(&self) -> &[String] {
&self.response_types_supported
}
#[must_use]
#[inline]
pub fn id_token_signing_alg_values_supported(&self) -> &[String] {
&self.id_token_signing_alg_values_supported
}
}
impl OidcDiscovery {
pub fn parse(json: &str) -> Result<Self, OidcDiscoveryError> {
let value = JsonValue::parse(json).map_err(|_| {
warn!("oidc: discovery parse failed");
OidcDiscoveryError {
kind: OidcDiscoveryErrorKind::InvalidJson,
}
})?;
let log_parse_failure = |_: &OidcDiscoveryError| {
warn!("oidc: discovery parse failed");
};
let issuer = required_https_url(&value, "issuer").inspect_err(log_parse_failure)?;
if issuer.contains(['?', '#']) {
let err = OidcDiscoveryError {
kind: OidcDiscoveryErrorKind::MalformedIssuer,
};
log_parse_failure(&err);
return Err(err);
}
let authorization_endpoint =
required_https_url(&value, "authorization_endpoint").inspect_err(log_parse_failure)?;
let token_endpoint =
required_https_url(&value, "token_endpoint").inspect_err(log_parse_failure)?;
let jwks_uri = required_https_url(&value, "jwks_uri").inspect_err(log_parse_failure)?;
let userinfo_endpoint = match value.get_str("userinfo_endpoint") {
Some(url) if !is_https_url(url) => {
let err = OidcDiscoveryError {
kind: OidcDiscoveryErrorKind::InsecureEndpoint("userinfo_endpoint".to_string()),
};
log_parse_failure(&err);
return Err(err);
}
Some(url) => Some(String::from(url)),
None => None,
};
let scopes_supported = string_array(&value, "scopes_supported");
let response_types_supported = string_array(&value, "response_types_supported");
let id_token_signing_alg_values_supported =
string_array(&value, "id_token_signing_alg_values_supported");
info!(issuer = %issuer, "oidc: discovery parsed");
Ok(Self {
issuer,
authorization_endpoint,
token_endpoint,
userinfo_endpoint,
jwks_uri,
scopes_supported,
response_types_supported,
id_token_signing_alg_values_supported,
})
}
}
fn required_str(value: &JsonValue, key: &str) -> Result<String, OidcDiscoveryError> {
value
.get_str(key)
.map(String::from)
.ok_or_else(|| OidcDiscoveryError {
kind: OidcDiscoveryErrorKind::MissingField(key.to_string()),
})
}
fn required_https_url(value: &JsonValue, key: &str) -> Result<String, OidcDiscoveryError> {
let url = required_str(value, key)?;
if !is_https_url(&url) {
return Err(OidcDiscoveryError {
kind: OidcDiscoveryErrorKind::InsecureEndpoint(key.to_string()),
});
}
Ok(url)
}
fn string_array(value: &JsonValue, key: &str) -> Vec<String> {
value
.get(key)
.and_then(JsonValue::as_array)
.map(|arr| {
arr.iter()
.filter_map(JsonValue::as_str)
.map(String::from)
.collect()
})
.unwrap_or_default()
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum OidcDiscoveryErrorKind {
InvalidJson,
MissingField(String),
InsecureEndpoint(String),
MalformedIssuer,
}
#[doc(alias = "discovery_error")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OidcDiscoveryError {
kind: OidcDiscoveryErrorKind,
}
impl fmt::Display for OidcDiscoveryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.kind {
OidcDiscoveryErrorKind::InvalidJson => {
write!(f, "oidc discovery: invalid JSON")
}
OidcDiscoveryErrorKind::MissingField(field) => {
write!(f, "oidc discovery: missing required field '{field}'")
}
OidcDiscoveryErrorKind::InsecureEndpoint(field) => {
write!(f, "oidc discovery: endpoint '{field}' must use HTTPS")
}
OidcDiscoveryErrorKind::MalformedIssuer => {
write!(
f,
"oidc discovery: issuer must not contain a query or fragment"
)
}
}
}
}
impl std::error::Error for OidcDiscoveryError {}
#[cfg(test)]
mod tests {
use super::*;
const VALID_DISCOVERY: &str = r#"{
"issuer": "https://accounts.example.com",
"authorization_endpoint": "https://accounts.example.com/authorize",
"token_endpoint": "https://accounts.example.com/token",
"userinfo_endpoint": "https://accounts.example.com/userinfo",
"jwks_uri": "https://accounts.example.com/.well-known/jwks.json",
"scopes_supported": ["openid", "profile", "email"],
"response_types_supported": ["code", "id_token", "code id_token"],
"id_token_signing_alg_values_supported": ["HS256", "RS256"]
}"#;
#[test]
fn parse_valid_discovery_doc() {
let doc = OidcDiscovery::parse(VALID_DISCOVERY).unwrap();
assert_eq!(doc.issuer(), "https://accounts.example.com");
assert_eq!(
doc.authorization_endpoint(),
"https://accounts.example.com/authorize",
);
assert_eq!(doc.token_endpoint(), "https://accounts.example.com/token");
assert_eq!(
doc.userinfo_endpoint(),
Some("https://accounts.example.com/userinfo"),
);
assert_eq!(
doc.jwks_uri(),
"https://accounts.example.com/.well-known/jwks.json",
);
assert_eq!(doc.scopes_supported(), ["openid", "profile", "email"]);
assert_eq!(
doc.response_types_supported(),
["code", "id_token", "code id_token"],
);
assert_eq!(
doc.id_token_signing_alg_values_supported(),
["HS256", "RS256"],
);
}
#[test]
fn parse_missing_issuer() {
let json = r#"{
"authorization_endpoint": "https://example.com/authorize",
"token_endpoint": "https://example.com/token",
"jwks_uri": "https://example.com/jwks"
}"#;
let err = OidcDiscovery::parse(json).unwrap_err();
assert!(err.to_string().contains("issuer"), "got: {err}");
}
#[test]
fn parse_missing_authorization_endpoint() {
let json = r#"{
"issuer": "https://example.com",
"token_endpoint": "https://example.com/token",
"jwks_uri": "https://example.com/jwks"
}"#;
let err = OidcDiscovery::parse(json).unwrap_err();
assert!(
err.to_string().contains("authorization_endpoint"),
"got: {err}",
);
}
#[test]
fn parse_missing_token_endpoint() {
let json = r#"{
"issuer": "https://example.com",
"authorization_endpoint": "https://example.com/authorize",
"jwks_uri": "https://example.com/jwks"
}"#;
let err = OidcDiscovery::parse(json).unwrap_err();
assert!(err.to_string().contains("token_endpoint"), "got: {err}");
}
#[test]
fn parse_missing_jwks_uri() {
let json = r#"{
"issuer": "https://example.com",
"authorization_endpoint": "https://example.com/authorize",
"token_endpoint": "https://example.com/token"
}"#;
let err = OidcDiscovery::parse(json).unwrap_err();
assert!(err.to_string().contains("jwks_uri"), "got: {err}");
}
#[test]
fn parse_optional_userinfo_absent() {
let json = r#"{
"issuer": "https://example.com",
"authorization_endpoint": "https://example.com/authorize",
"token_endpoint": "https://example.com/token",
"jwks_uri": "https://example.com/jwks"
}"#;
let doc = OidcDiscovery::parse(json).unwrap();
assert_eq!(doc.userinfo_endpoint(), None);
assert!(doc.scopes_supported().is_empty());
assert!(doc.response_types_supported().is_empty());
assert!(doc.id_token_signing_alg_values_supported().is_empty());
}
#[test]
fn parse_invalid_json() {
let err = OidcDiscovery::parse("not json at all").unwrap_err();
assert!(err.to_string().contains("JSON"), "got: {err}");
}
#[test]
fn parse_rejects_issuer_with_query_or_fragment() {
for issuer in [
"https://accounts.example.com/?x=https://evil.example.com",
"https://accounts.example.com/#frag",
] {
let json = format!(
r#"{{
"issuer": "{issuer}",
"authorization_endpoint": "https://accounts.example.com/authorize",
"token_endpoint": "https://accounts.example.com/token",
"jwks_uri": "https://accounts.example.com/jwks"
}}"#,
);
let err = OidcDiscovery::parse(&json).unwrap_err();
assert!(err.to_string().contains("query or fragment"), "got: {err}");
}
}
#[test]
fn error_implements_std_error() {
let err: Box<dyn std::error::Error> = Box::new(OidcDiscoveryError {
kind: OidcDiscoveryErrorKind::InvalidJson,
});
let _ = err.to_string();
}
#[test]
fn reject_http_authorization_endpoint() {
let json = r#"{
"issuer": "https://example.com",
"authorization_endpoint": "http://example.com/authorize",
"token_endpoint": "https://example.com/token",
"jwks_uri": "https://example.com/jwks"
}"#;
let err = OidcDiscovery::parse(json).unwrap_err();
assert!(err.to_string().contains("HTTPS"), "got: {err}");
}
#[test]
fn reject_http_token_endpoint() {
let json = r#"{
"issuer": "https://example.com",
"authorization_endpoint": "https://example.com/authorize",
"token_endpoint": "http://example.com/token",
"jwks_uri": "https://example.com/jwks"
}"#;
let err = OidcDiscovery::parse(json).unwrap_err();
assert!(err.to_string().contains("HTTPS"), "got: {err}");
}
#[test]
fn reject_http_jwks_uri() {
let json = r#"{
"issuer": "https://example.com",
"authorization_endpoint": "https://example.com/authorize",
"token_endpoint": "https://example.com/token",
"jwks_uri": "http://example.com/jwks"
}"#;
let err = OidcDiscovery::parse(json).unwrap_err();
assert!(err.to_string().contains("HTTPS"), "got: {err}");
}
#[test]
fn reject_http_userinfo_endpoint() {
let json = r#"{
"issuer": "https://example.com",
"authorization_endpoint": "https://example.com/authorize",
"token_endpoint": "https://example.com/token",
"jwks_uri": "https://example.com/jwks",
"userinfo_endpoint": "http://example.com/userinfo"
}"#;
let err = OidcDiscovery::parse(json).unwrap_err();
assert!(err.to_string().contains("HTTPS"), "got: {err}");
}
#[test]
fn insecure_endpoint_error_display() {
let err = OidcDiscoveryError {
kind: OidcDiscoveryErrorKind::InsecureEndpoint("token_endpoint".to_string()),
};
assert_eq!(
err.to_string(),
"oidc discovery: endpoint 'token_endpoint' must use HTTPS",
);
}
}