use crate::error::{Error, ErrorCode, Result};
use crate::server::auth::oauth2::OidcDiscoveryMetadata;
use crate::shared::http_body_cap::{
collect_reqwest_body_within_cap, hardened_discovery_client, is_body_over_cap,
is_redirect_refusal, DEFAULT_AUTH_RESPONSE_BYTES,
};
use crate::shared::oauth_validation::{
classify_discovery_failure, discovery_url_candidates, issuer_matches_metadata,
DiscoveryFailure, DiscoveryOutcome,
};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::time::Duration;
use url::Url;
const DEFAULT_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_ECHOED_DOCUMENT_ISSUER: usize = 256;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct AuthorizationServerExtras {
iss_parameter_supported: Option<bool>,
}
impl AuthorizationServerExtras {
#[must_use]
pub const fn iss_parameter_supported(&self) -> Option<bool> {
self.iss_parameter_supported
}
}
#[derive(Debug)]
pub struct OidcDiscoveryClient {
client: std::result::Result<reqwest::Client, String>,
max_retries: usize,
retry_delay: Duration,
candidate_cache: RwLock<HashMap<String, usize>>,
}
impl Default for OidcDiscoveryClient {
fn default() -> Self {
Self::new()
}
}
impl OidcDiscoveryClient {
pub fn new() -> Self {
Self::with_settings(3, Duration::from_millis(500))
}
pub fn with_settings(max_retries: usize, retry_delay: Duration) -> Self {
Self {
client: hardened_discovery_client(DEFAULT_DISCOVERY_TIMEOUT).map_err(|e| e.to_string()),
max_retries,
retry_delay,
candidate_cache: RwLock::new(HashMap::new()),
}
}
pub async fn discover(&self, issuer_url: &str) -> Result<OidcDiscoveryMetadata> {
self.discover_with_extras(issuer_url)
.await
.map(|(metadata, _)| metadata)
}
pub async fn discover_with_extras(
&self,
issuer_url: &str,
) -> Result<(OidcDiscoveryMetadata, AuthorizationServerExtras)> {
let candidates = discovery_url_candidates(issuer_url)?;
let client = self.http_client()?;
let mut attempted: Vec<String> = Vec::new();
for index in self.probe_order(issuer_url, candidates.len()) {
let url = &candidates[index];
match self.probe_candidate(client, url, issuer_url).await {
Ok(found) => {
self.remember_candidate(issuer_url, index);
return Ok(found);
},
Err((DiscoveryOutcome::Terminal, error)) => return Err(error),
Err((_, error)) => attempted.push(format!("{url}: {error}")),
}
}
Err(every_candidate_failed(issuer_url, &attempted))
}
fn http_client(&self) -> Result<&reqwest::Client> {
self.client
.as_ref()
.map_err(|message| Error::internal(message.clone()))
}
fn probe_order(&self, issuer_url: &str, candidate_count: usize) -> Vec<usize> {
let remembered = self
.candidate_cache
.read()
.get(issuer_url)
.copied()
.filter(|index| *index < candidate_count);
remembered.map_or_else(
|| (0..candidate_count).collect(),
|first| {
std::iter::once(first)
.chain((0..candidate_count).filter(move |index| *index != first))
.collect()
},
)
}
fn remember_candidate(&self, issuer_url: &str, index: usize) {
self.candidate_cache
.write()
.insert(issuer_url.to_owned(), index);
}
async fn probe_candidate(
&self,
client: &reqwest::Client,
url: &Url,
expected_issuer: &str,
) -> std::result::Result<
(OidcDiscoveryMetadata, AuthorizationServerExtras),
(DiscoveryOutcome, Error),
> {
let mut attempts: usize = 0;
loop {
let (failure, error) = match fetch_discovery(client, url, expected_issuer).await {
Ok(found) => return Ok(found),
Err(pair) => pair,
};
match classify_discovery_failure(failure) {
DiscoveryOutcome::Terminal => return Err((DiscoveryOutcome::Terminal, error)),
DiscoveryOutcome::Fallback => return Err((DiscoveryOutcome::Fallback, error)),
DiscoveryOutcome::Retry => {
attempts += 1;
if attempts >= self.max_retries {
return Err((DiscoveryOutcome::Fallback, error));
}
tokio::time::sleep(self.retry_delay).await;
},
}
}
}
}
async fn fetch_discovery(
client: &reqwest::Client,
url: &Url,
expected_issuer: &str,
) -> std::result::Result<
(OidcDiscoveryMetadata, AuthorizationServerExtras),
(DiscoveryFailure, Error),
> {
let response = client
.get(url.as_str())
.header("Accept", "application/json")
.send()
.await
.map_err(|e| request_failure(url, &e))?;
let status = response.status();
if !status.is_success() {
return Err(status_failure(url, status));
}
let bytes = collect_reqwest_body_within_cap(response, DEFAULT_AUTH_RESPONSE_BYTES)
.await
.map_err(|e| {
let failure = if is_body_over_cap(&e) {
DiscoveryFailure::BodyOverCap
} else {
DiscoveryFailure::Transport
};
(failure, e)
})?;
let document: Value =
serde_json::from_slice(&bytes).map_err(|e| unparseable_document(url, &e))?;
let document_issuer = document_issuer_field(url, &document)?;
if !issuer_matches_metadata(expected_issuer, document_issuer) {
return Err(issuer_mismatch(url, expected_issuer, document_issuer));
}
let iss_parameter_supported = iss_parameter_flag(url, &document)?;
let metadata: OidcDiscoveryMetadata =
serde_json::from_slice(&bytes).map_err(|e| unparseable_document(url, &e))?;
Ok((
metadata,
AuthorizationServerExtras {
iss_parameter_supported,
},
))
}
fn document_issuer_field<'a>(
url: &Url,
document: &'a Value,
) -> std::result::Result<&'a str, (DiscoveryFailure, Error)> {
match document.get("issuer") {
Some(Value::String(issuer)) => Ok(issuer),
Some(_) => Err(malformed_security_metadata(
url,
"`issuer` is present but is not a JSON string. It is the value RFC 9207's `iss` \
comparison is anchored on, so a wrongly-typed issuer cannot be tolerated",
)),
None => Err(malformed_security_metadata(
url,
"`issuer` is absent. RFC 8414 section 3.3 requires it, and it is the value RFC 9207's \
`iss` comparison is anchored on",
)),
}
}
fn iss_parameter_flag(
url: &Url,
document: &Value,
) -> std::result::Result<Option<bool>, (DiscoveryFailure, Error)> {
match document.get("authorization_response_iss_parameter_supported") {
None => Ok(None),
Some(Value::Bool(flag)) => Ok(Some(*flag)),
Some(_) => Err(malformed_security_metadata(
url,
"`authorization_response_iss_parameter_supported` is present but is not a JSON \
boolean. Treating it as absent would relax strictness — an absent `iss` on the \
callback would become acceptable — so a malformed value aborts discovery instead",
)),
}
}
fn request_failure(url: &Url, source: &reqwest::Error) -> (DiscoveryFailure, Error) {
let failure = if is_redirect_refusal(source) {
DiscoveryFailure::MalformedSecurityMetadata
} else {
DiscoveryFailure::Transport
};
(
failure,
Error::protocol(
ErrorCode::INTERNAL_ERROR,
format!(
"Failed to fetch discovery document from {url}: {}",
rendered_source_chain(source)
),
),
)
}
fn status_failure(url: &Url, status: reqwest::StatusCode) -> (DiscoveryFailure, Error) {
let failure = if status == reqwest::StatusCode::NOT_FOUND {
DiscoveryFailure::NotFound
} else {
DiscoveryFailure::HttpStatus(status.as_u16())
};
(
failure,
Error::protocol(
ErrorCode::INTERNAL_ERROR,
format!("Discovery endpoint {url} returned status: {status}"),
),
)
}
fn unparseable_document(url: &Url, source: &serde_json::Error) -> (DiscoveryFailure, Error) {
(
DiscoveryFailure::InvalidJson,
Error::protocol(
ErrorCode::PARSE_ERROR,
format!(
"Discovery document from {url} is not the JSON document this endpoint serves \
({:?} error at line {}, column {}). The parser's own message is not reproduced \
here because a data error echoes the offending input",
source.classify(),
source.line(),
source.column()
),
),
)
}
fn issuer_mismatch(
url: &Url,
expected_issuer: &str,
document_issuer: &str,
) -> (DiscoveryFailure, Error) {
(
DiscoveryFailure::IssuerMismatch,
Error::protocol(
ErrorCode::INVALID_REQUEST,
format!(
"Discovery document fetched from {url} declares issuer `{}`, but the URL was \
built from issuer `{expected_issuer}`. RFC 8414 section 3.3 and OpenID Connect \
Discovery section 4.3 require these to be identical, so the metadata is NOT \
used. The document's value is peer-controlled and is truncated at \
{MAX_ECHOED_DOCUMENT_ISSUER} characters here",
truncate_for_message(document_issuer)
),
),
)
}
fn malformed_security_metadata(url: &Url, detail: &str) -> (DiscoveryFailure, Error) {
(
DiscoveryFailure::MalformedSecurityMetadata,
Error::protocol(
ErrorCode::INVALID_REQUEST,
format!("Discovery document from {url} carries malformed security metadata: {detail}"),
),
)
}
fn every_candidate_failed(issuer_url: &str, attempted: &[String]) -> Error {
Error::protocol(
ErrorCode::INTERNAL_ERROR,
format!(
"Failed to discover OIDC configuration for issuer `{issuer_url}`. Every candidate \
endpoint was tried and none served a usable document:\n - {}",
attempted.join("\n - ")
),
)
}
fn truncate_for_message(value: &str) -> String {
if value.chars().count() <= MAX_ECHOED_DOCUMENT_ISSUER {
return value.to_owned();
}
let head: String = value.chars().take(MAX_ECHOED_DOCUMENT_ISSUER).collect();
format!("{head}… (truncated)")
}
fn rendered_source_chain(error: &dyn std::error::Error) -> String {
let mut rendered = error.to_string();
let mut current = error.source();
while let Some(cause) = current {
rendered.push_str(" <- ");
rendered.push_str(&cause.to_string());
current = cause.source();
}
rendered
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenResponse {
pub access_token: String,
pub token_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_in: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub refresh_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
}
#[derive(Debug)]
pub struct TokenExchangeClient {
client: reqwest::Client,
}
impl Default for TokenExchangeClient {
fn default() -> Self {
Self::new()
}
}
impl TokenExchangeClient {
pub fn new() -> Self {
Self {
client: reqwest::Client::new(),
}
}
pub async fn exchange_code(
&self,
token_endpoint: &str,
code: &str,
client_id: &str,
client_secret: Option<&str>,
redirect_uri: &str,
code_verifier: Option<&str>,
) -> Result<TokenResponse> {
let mut params = vec![
("grant_type", "authorization_code"),
("code", code),
("client_id", client_id),
("redirect_uri", redirect_uri),
];
if let Some(verifier) = code_verifier {
params.push(("code_verifier", verifier));
}
let mut request = self
.client
.post(token_endpoint)
.header("Accept", "application/json") .form(¶ms);
if let Some(secret) = client_secret {
request = request.basic_auth(client_id, Some(secret));
}
let response = request.send().await.map_err(|e| {
Error::protocol(
ErrorCode::INTERNAL_ERROR,
format!("Failed to exchange authorization code: {}", e),
)
})?;
if !response.status().is_success() {
let error_text = read_error_body_within_cap(response).await;
return Err(Error::protocol(
ErrorCode::INVALID_REQUEST,
format!("Token exchange failed: {}", error_text),
));
}
parse_token_response(&read_token_body(response, "token exchange").await?)
}
pub async fn refresh_token(
&self,
token_endpoint: &str,
refresh_token: &str,
client_id: &str,
client_secret: Option<&str>,
scope: Option<&str>,
) -> Result<TokenResponse> {
let mut params = vec![
("grant_type", "refresh_token"),
("refresh_token", refresh_token),
("client_id", client_id),
];
if let Some(s) = scope {
params.push(("scope", s));
}
let mut request = self
.client
.post(token_endpoint)
.header("Accept", "application/json") .form(¶ms);
if let Some(secret) = client_secret {
request = request.basic_auth(client_id, Some(secret));
}
let response = request.send().await.map_err(|e| {
Error::protocol(
ErrorCode::INTERNAL_ERROR,
format!("Failed to refresh token: {}", e),
)
})?;
if !response.status().is_success() {
let error_text = read_error_body_within_cap(response).await;
return Err(Error::protocol(
ErrorCode::INVALID_REQUEST,
format!("Token refresh failed: {}", error_text),
));
}
parse_token_response(&read_token_body(response, "token refresh").await?)
}
}
async fn read_token_body(response: reqwest::Response, what: &str) -> Result<Vec<u8>> {
collect_reqwest_body_within_cap(response, DEFAULT_AUTH_RESPONSE_BYTES)
.await
.map_err(|e| {
Error::protocol(
ErrorCode::INTERNAL_ERROR,
format!("Failed to read {what} response body: {e}"),
)
})
}
async fn read_error_body_within_cap(response: reqwest::Response) -> String {
match collect_reqwest_body_within_cap(response, DEFAULT_AUTH_RESPONSE_BYTES).await {
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
Err(e) => format!("<error body not read: {e}>"),
}
}
fn parse_token_response(bytes: &[u8]) -> Result<TokenResponse> {
serde_json::from_slice::<TokenResponse>(bytes).map_err(|e| {
Error::protocol(
ErrorCode::PARSE_ERROR,
format!(
"Failed to parse token response ({:?} error at line {}, column {}). The parser's \
own message is not reproduced here because a data error echoes the offending \
input, and a token response body carries credentials",
e.classify(),
e.line(),
e.column()
),
)
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_discovery_url_construction() {
let test_cases: Vec<(&str, Vec<&str>)> = vec![
(
"https://example.com",
vec![
"https://example.com/.well-known/oauth-authorization-server",
"https://example.com/.well-known/openid-configuration",
],
),
(
"https://example.com/",
vec![
"https://example.com/.well-known/oauth-authorization-server",
"https://example.com/.well-known/openid-configuration",
],
),
(
"https://auth.example.com/oauth",
vec![
"https://auth.example.com/.well-known/oauth-authorization-server/oauth",
"https://auth.example.com/.well-known/openid-configuration/oauth",
"https://auth.example.com/oauth/.well-known/openid-configuration",
],
),
];
for (issuer, expected) in test_cases {
let rendered: Vec<String> = discovery_url_candidates(issuer)
.unwrap()
.iter()
.map(std::string::ToString::to_string)
.collect();
assert_eq!(rendered, expected, "issuer {issuer}");
}
}
#[test]
fn test_failure_classification_replaces_the_old_string_sniffing_retry() {
let url = Url::parse("https://as.example/.well-known/openid-configuration").unwrap();
let (failure, _) = status_failure(&url, reqwest::StatusCode::NOT_FOUND);
assert_eq!(failure, DiscoveryFailure::NotFound);
assert_eq!(
classify_discovery_failure(failure),
DiscoveryOutcome::Fallback
);
let (failure, _) = status_failure(&url, reqwest::StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(failure, DiscoveryFailure::HttpStatus(503));
assert_eq!(classify_discovery_failure(failure), DiscoveryOutcome::Retry);
let (failure, _) = status_failure(&url, reqwest::StatusCode::UNAUTHORIZED);
assert_eq!(failure, DiscoveryFailure::HttpStatus(401));
assert_eq!(
classify_discovery_failure(failure),
DiscoveryOutcome::Fallback
);
}
#[test]
fn test_document_issuer_field_rows() {
let url = Url::parse("https://as.example/.well-known/openid-configuration").unwrap();
let good = json!({ "issuer": "https://as.example" });
assert_eq!(
document_issuer_field(&url, &good).unwrap(),
"https://as.example"
);
for hostile in [json!({}), json!({ "issuer": 7 }), json!({ "issuer": null })] {
let (failure, error) = document_issuer_field(&url, &hostile).unwrap_err();
assert_eq!(
failure,
DiscoveryFailure::MalformedSecurityMetadata,
"issuer {hostile} must be malformed security metadata"
);
assert_eq!(
classify_discovery_failure(failure),
DiscoveryOutcome::Terminal
);
assert!(error.to_string().contains("issuer"));
}
}
#[test]
fn test_iss_parameter_flag_rows() {
let url = Url::parse("https://as.example/.well-known/openid-configuration").unwrap();
let key = "authorization_response_iss_parameter_supported";
assert_eq!(iss_parameter_flag(&url, &json!({})).unwrap(), None);
assert_eq!(
iss_parameter_flag(&url, &json!({ key: true })).unwrap(),
Some(true)
);
assert_eq!(
iss_parameter_flag(&url, &json!({ key: false })).unwrap(),
Some(false)
);
for hostile in [json!("true"), json!(1), json!(null), json!({})] {
let document = json!({ key: hostile });
let (failure, _) = iss_parameter_flag(&url, &document).unwrap_err();
assert_eq!(
failure,
DiscoveryFailure::MalformedSecurityMetadata,
"flag value {hostile} must abort rather than read as None"
);
assert_eq!(
classify_discovery_failure(failure),
DiscoveryOutcome::Terminal
);
}
}
#[test]
fn test_issuer_mismatch_names_both_values_and_bounds_the_peer_one() {
let url =
Url::parse("https://attacker.example/.well-known/oauth-authorization-server").unwrap();
let (failure, error) =
issuer_mismatch(&url, "https://attacker.example", "https://honest.example");
assert_eq!(failure, DiscoveryFailure::IssuerMismatch);
let message = error.to_string();
assert!(message.contains("https://attacker.example"));
assert!(message.contains("https://honest.example"));
let flood = "z".repeat(10_000);
let (_, error) = issuer_mismatch(&url, "https://as.example", &flood);
assert!(
error.to_string().len() < 2_000,
"a peer-chosen issuer must not flood the message"
);
}
#[test]
fn test_probe_order_puts_a_remembered_candidate_first_then_the_full_sequence() {
let client = OidcDiscoveryClient::new();
assert_eq!(client.probe_order("https://as.example", 3), vec![0, 1, 2]);
client.remember_candidate("https://as.example", 2);
assert_eq!(client.probe_order("https://as.example", 3), vec![2, 0, 1]);
client.remember_candidate("https://as.example", 2);
assert_eq!(client.probe_order("https://as.example", 2), vec![0, 1]);
}
#[test]
fn test_discovery_client_with_settings() {
let client = OidcDiscoveryClient::with_settings(5, Duration::from_secs(2));
assert_eq!(client.max_retries, 5);
assert_eq!(client.retry_delay, Duration::from_secs(2));
assert!(client.client.is_ok(), "the hardened client must build");
}
#[test]
fn test_authorization_server_extras_is_read_only_and_optional() {
let extras = AuthorizationServerExtras {
iss_parameter_supported: Some(true),
};
assert_eq!(extras.iss_parameter_supported(), Some(true));
let unadvertised = AuthorizationServerExtras {
iss_parameter_supported: None,
};
assert_eq!(unadvertised.iss_parameter_supported(), None);
}
#[test]
fn test_token_response_serialization() {
let token_response = TokenResponse {
access_token: "test_token".to_string(),
token_type: "Bearer".to_string(),
expires_in: Some(3600),
refresh_token: Some("refresh_token".to_string()),
scope: Some("openid profile".to_string()),
};
let json = serde_json::to_string(&token_response).unwrap();
assert!(json.contains("test_token"));
assert!(json.contains("Bearer"));
let deserialized: TokenResponse = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.access_token, "test_token");
assert_eq!(deserialized.expires_in, Some(3600));
}
#[test]
fn test_token_response_parse_failure_echoes_no_input() {
let error = parse_token_response(br#"{"access_token": 7}"#).unwrap_err();
let message = error.to_string();
assert!(message.contains("line 1"), "{message}");
assert!(
!message.contains("access_token"),
"a token body carries credentials; the parser message must not be reproduced: \
{message}"
);
}
#[test]
fn test_oidc_discovery_metadata_defaults() {
let json = r#"{
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code"],
"scopes_supported": ["openid", "profile"],
"token_endpoint_auth_methods_supported": ["client_secret_basic"],
"code_challenge_methods_supported": ["S256"]
}"#;
let metadata: OidcDiscoveryMetadata = serde_json::from_str(json).unwrap();
assert_eq!(metadata.issuer, "https://auth.example.com");
assert_eq!(metadata.jwks_uri, None);
assert_eq!(metadata.userinfo_endpoint, None);
}
}