use async_trait::async_trait;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use crate::error::{Error, ErrorCode, Result};
#[cfg(feature = "jwt-auth")]
use crate::server::auth::jwt_validator::{JwtValidator, ValidationConfig};
use crate::server::auth::provider::{
AuthorizationParams, DcrRequest, DcrResponse, IdentityProvider, OidcDiscovery,
ProviderCapabilities, TokenExchangeParams, TokenResponse,
};
use crate::server::auth::traits::{AuthContext, ClaimMappings};
#[cfg(not(target_arch = "wasm32"))]
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,
};
#[cfg(not(target_arch = "wasm32"))]
use crate::shared::oauth_validation::{
classify_discovery_failure, discovery_url_candidates, issuer_matches_metadata,
DiscoveryFailure, DiscoveryOutcome,
};
#[cfg(not(target_arch = "wasm32"))]
const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(10);
#[cfg(not(target_arch = "wasm32"))]
const DISCOVERY_MAX_ATTEMPTS: usize = 3;
#[cfg(not(target_arch = "wasm32"))]
const DISCOVERY_RETRY_DELAY: Duration = Duration::from_millis(200);
#[cfg(not(target_arch = "wasm32"))]
const MAX_ECHOED_DOCUMENT_ISSUER: usize = 256;
struct CachedData<T: std::fmt::Debug> {
data: T,
fetched_at: Instant,
ttl: Duration,
}
impl<T: std::fmt::Debug> std::fmt::Debug for CachedData<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CachedData")
.field("data", &self.data)
.field("fetched_at", &self.fetched_at)
.field("ttl", &self.ttl)
.finish()
}
}
impl<T: std::fmt::Debug> CachedData<T> {
fn new(data: T, ttl: Duration) -> Self {
Self {
data,
fetched_at: Instant::now(),
ttl,
}
}
fn is_expired(&self) -> bool {
self.fetched_at.elapsed() > self.ttl
}
}
#[derive(Debug, Clone)]
pub struct GenericOidcConfig {
pub id: String,
pub display_name: String,
pub issuer: String,
pub client_id: String,
pub client_secret: Option<String>,
pub claim_mappings: ClaimMappings,
pub cache_ttl: Duration,
pub leeway_seconds: u64,
}
impl GenericOidcConfig {
pub fn new(
id: impl Into<String>,
display_name: impl Into<String>,
issuer: impl Into<String>,
client_id: impl Into<String>,
) -> Self {
Self {
id: id.into(),
display_name: display_name.into(),
issuer: issuer.into(),
client_id: client_id.into(),
client_secret: None,
claim_mappings: ClaimMappings::default(),
cache_ttl: Duration::from_hours(1),
leeway_seconds: 60,
}
}
pub fn with_client_secret(mut self, secret: impl Into<String>) -> Self {
self.client_secret = Some(secret.into());
self
}
pub fn with_claim_mappings(mut self, mappings: ClaimMappings) -> Self {
self.claim_mappings = mappings;
self
}
pub fn google(client_id: impl Into<String>) -> Self {
Self {
id: "google".to_string(),
display_name: "Google Identity".to_string(),
issuer: "https://accounts.google.com".to_string(),
client_id: client_id.into(),
client_secret: None,
claim_mappings: ClaimMappings::google(),
cache_ttl: Duration::from_hours(1),
leeway_seconds: 60,
}
}
pub fn auth0(domain: impl Into<String>, client_id: impl Into<String>) -> Self {
let domain = domain.into();
Self {
id: "auth0".to_string(),
display_name: "Auth0".to_string(),
issuer: format!("https://{}/", domain),
client_id: client_id.into(),
client_secret: None,
claim_mappings: ClaimMappings::auth0(),
cache_ttl: Duration::from_hours(1),
leeway_seconds: 60,
}
}
pub fn okta(domain: impl Into<String>, client_id: impl Into<String>) -> Self {
let domain = domain.into();
Self {
id: "okta".to_string(),
display_name: "Okta".to_string(),
issuer: format!("https://{}", domain),
client_id: client_id.into(),
client_secret: None,
claim_mappings: ClaimMappings::okta(),
cache_ttl: Duration::from_hours(1),
leeway_seconds: 60,
}
}
pub fn entra(tenant_id: impl Into<String>, client_id: impl Into<String>) -> Self {
let tenant_id = tenant_id.into();
Self {
id: "entra".to_string(),
display_name: "Microsoft Entra ID".to_string(),
issuer: format!("https://login.microsoftonline.com/{}/v2.0", tenant_id),
client_id: client_id.into(),
client_secret: None,
claim_mappings: ClaimMappings::entra(),
cache_ttl: Duration::from_hours(1),
leeway_seconds: 60,
}
}
}
#[cfg(not(target_arch = "wasm32"))]
type DiscoveryCache = Arc<RwLock<Option<CachedData<OidcDiscovery>>>>;
pub struct GenericOidcProvider {
config: GenericOidcConfig,
id: &'static str,
display_name: &'static str,
#[cfg(all(not(target_arch = "wasm32"), feature = "jwt-auth"))]
jwt_validator: JwtValidator,
#[cfg(all(not(target_arch = "wasm32"), feature = "jwt-auth"))]
validation_config: ValidationConfig,
#[cfg(not(target_arch = "wasm32"))]
discovery_cache: DiscoveryCache,
#[cfg(not(target_arch = "wasm32"))]
http_client: reqwest::Client,
#[cfg(not(target_arch = "wasm32"))]
discovery_client: reqwest::Client,
}
impl std::fmt::Debug for GenericOidcProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GenericOidcProvider")
.field("id", &self.id)
.field("display_name", &self.display_name)
.field("issuer", &self.config.issuer)
.field("client_id", &self.config.client_id)
.finish()
}
}
impl GenericOidcProvider {
#[cfg(not(target_arch = "wasm32"))]
pub async fn new(config: GenericOidcConfig) -> Result<Self> {
let http_client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| Error::internal(format!("Failed to create HTTP client: {}", e)))?;
let discovery_client = hardened_discovery_client(DISCOVERY_TIMEOUT)?;
let id: &'static str = Box::leak(config.id.clone().into_boxed_str());
let display_name: &'static str = Box::leak(config.display_name.clone().into_boxed_str());
let discovery = fetch_discovery_doc(&discovery_client, &config.issuer).await?;
let discovery_cache = Arc::new(RwLock::new(Some(CachedData::new(
discovery.clone(),
config.cache_ttl,
))));
let provider = Self {
#[cfg(feature = "jwt-auth")]
jwt_validator: JwtValidator::new(),
#[cfg(feature = "jwt-auth")]
validation_config: ValidationConfig::new(
&config.issuer,
&discovery.jwks_uri,
&config.client_id,
)
.with_leeway(config.leeway_seconds)
.with_claim_mappings(config.claim_mappings.clone()),
config,
id,
display_name,
discovery_cache,
http_client,
discovery_client,
};
Ok(provider)
}
#[cfg(all(not(target_arch = "wasm32"), feature = "jwt-auth"))]
pub async fn with_validator(
config: GenericOidcConfig,
jwt_validator: JwtValidator,
) -> Result<Self> {
let http_client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| Error::internal(format!("Failed to create HTTP client: {}", e)))?;
let discovery_client = hardened_discovery_client(DISCOVERY_TIMEOUT)?;
let id: &'static str = Box::leak(config.id.clone().into_boxed_str());
let display_name: &'static str = Box::leak(config.display_name.clone().into_boxed_str());
let discovery = fetch_discovery_doc(&discovery_client, &config.issuer).await?;
let discovery_cache = Arc::new(RwLock::new(Some(CachedData::new(
discovery.clone(),
config.cache_ttl,
))));
Ok(Self {
jwt_validator,
validation_config: ValidationConfig::new(
&config.issuer,
&discovery.jwks_uri,
&config.client_id,
)
.with_leeway(config.leeway_seconds)
.with_claim_mappings(config.claim_mappings.clone()),
config,
id,
display_name,
discovery_cache,
http_client,
discovery_client,
})
}
pub fn client_id(&self) -> &str {
&self.config.client_id
}
#[cfg(not(target_arch = "wasm32"))]
async fn fetch_discovery(&self) -> Result<OidcDiscovery> {
{
let cache = self.discovery_cache.read().await;
if let Some(ref cached) = *cache {
if !cached.is_expired() {
return Ok(cached.data.clone());
}
}
}
let discovery = fetch_discovery_doc(&self.discovery_client, &self.config.issuer).await?;
{
let mut cache = self.discovery_cache.write().await;
*cache = Some(CachedData::new(discovery.clone(), self.config.cache_ttl));
}
Ok(discovery)
}
#[cfg(not(target_arch = "wasm32"))]
#[allow(dead_code)]
async fn detect_capabilities(&self) -> ProviderCapabilities {
let Ok(discovery) = self.fetch_discovery().await else {
return ProviderCapabilities::basic_oidc();
};
ProviderCapabilities {
oidc: true,
dcr: discovery.registration_endpoint.is_some(),
pkce: discovery
.code_challenge_methods_supported
.iter()
.any(|m| m == "S256"),
refresh_tokens: discovery
.grant_types_supported
.iter()
.any(|g| g == "refresh_token"),
revocation: discovery.revocation_endpoint.is_some(),
introspection: discovery.introspection_endpoint.is_some(),
custom_scopes: !discovery.scopes_supported.is_empty(),
device_flow: discovery
.grant_types_supported
.iter()
.any(|g| g == "urn:ietf:params:oauth:grant-type:device_code"),
}
}
}
#[cfg(not(target_arch = "wasm32"))]
async fn fetch_discovery_doc(http_client: &reqwest::Client, issuer: &str) -> Result<OidcDiscovery> {
let candidates = discovery_url_candidates(issuer)?;
let mut attempted: Vec<String> = Vec::new();
for url in &candidates {
tracing::debug!("Fetching OIDC discovery from {}", url);
match probe_discovery_candidate(http_client, url, issuer).await {
Ok(document) => return Ok(document),
Err((DiscoveryOutcome::Terminal, error)) => return Err(error),
Err((_, error)) => attempted.push(format!("{url}: {error}")),
}
}
Err(every_candidate_failed(issuer, &attempted))
}
#[cfg(not(target_arch = "wasm32"))]
async fn probe_discovery_candidate(
http_client: &reqwest::Client,
url: &url::Url,
expected_issuer: &str,
) -> std::result::Result<OidcDiscovery, (DiscoveryOutcome, Error)> {
let mut attempts: usize = 0;
loop {
let (failure, error) =
match fetch_discovery_candidate(http_client, url, expected_issuer).await {
Ok(document) => return Ok(document),
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 >= DISCOVERY_MAX_ATTEMPTS {
return Err((DiscoveryOutcome::Fallback, error));
}
tokio::time::sleep(DISCOVERY_RETRY_DELAY).await;
},
}
}
}
#[cfg(not(target_arch = "wasm32"))]
async fn fetch_discovery_candidate(
http_client: &reqwest::Client,
url: &url::Url,
expected_issuer: &str,
) -> std::result::Result<OidcDiscovery, (DiscoveryFailure, Error)> {
let response = http_client
.get(url.as_str())
.header("Accept", "application/json")
.send()
.await
.map_err(|e| discovery_request_failure(url, &e))?;
let status = response.status();
if !status.is_success() {
return Err(discovery_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: serde_json::Value =
serde_json::from_slice(&bytes).map_err(|e| unparseable_discovery_document(url, &e))?;
let document_issuer = discovery_document_issuer(url, &document)?;
if !issuer_matches_metadata(expected_issuer, document_issuer) {
return Err(discovery_issuer_mismatch(
url,
expected_issuer,
document_issuer,
));
}
serde_json::from_slice(&bytes).map_err(|e| unparseable_discovery_document(url, &e))
}
#[cfg(not(target_arch = "wasm32"))]
fn discovery_document_issuer<'a>(
url: &url::Url,
document: &'a serde_json::Value,
) -> std::result::Result<&'a str, (DiscoveryFailure, Error)> {
match document.get("issuer") {
Some(serde_json::Value::String(issuer)) => Ok(issuer),
Some(_) => Err(malformed_discovery_metadata(
url,
"`issuer` is present but is not a JSON string. It is the value the RFC 8414 \
section 3.3 anchor comparison is made against, so a wrongly-typed issuer cannot be \
tolerated",
)),
None => Err(malformed_discovery_metadata(
url,
"`issuer` is absent. RFC 8414 section 3.3 requires it, and an absent anchor must not \
read as `nothing to check`",
)),
}
}
#[cfg(not(target_arch = "wasm32"))]
fn discovery_request_failure(url: &url::Url, source: &reqwest::Error) -> (DiscoveryFailure, Error) {
let failure = if is_redirect_refusal(source) {
DiscoveryFailure::MalformedSecurityMetadata
} else {
DiscoveryFailure::Transport
};
(
failure,
Error::internal(format!(
"Failed to fetch discovery document from {url}: {}",
rendered_source_chain(source)
)),
)
}
#[cfg(not(target_arch = "wasm32"))]
fn discovery_status_failure(
url: &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::internal(format!("Discovery endpoint {url} returned status {status}")),
)
}
#[cfg(not(target_arch = "wasm32"))]
fn unparseable_discovery_document(
url: &url::Url,
source: &serde_json::Error,
) -> (DiscoveryFailure, Error) {
(
DiscoveryFailure::InvalidJson,
Error::internal(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()
)),
)
}
#[cfg(not(target_arch = "wasm32"))]
fn discovery_issuer_mismatch(
url: &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)
),
),
)
}
#[cfg(not(target_arch = "wasm32"))]
fn malformed_discovery_metadata(url: &url::Url, detail: &str) -> (DiscoveryFailure, Error) {
(
DiscoveryFailure::MalformedSecurityMetadata,
Error::protocol(
ErrorCode::INVALID_REQUEST,
format!("Discovery document from {url} carries malformed security metadata: {detail}"),
),
)
}
#[cfg(not(target_arch = "wasm32"))]
fn every_candidate_failed(issuer: &str, attempted: &[String]) -> Error {
Error::internal(format!(
"Failed to discover OIDC configuration for issuer `{issuer}`. Every candidate endpoint \
was tried and none served a usable document:\n - {}",
attempted.join("\n - ")
))
}
#[cfg(not(target_arch = "wasm32"))]
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)")
}
#[cfg(not(target_arch = "wasm32"))]
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
}
#[cfg(not(target_arch = "wasm32"))]
async fn read_json_within_cap<T: serde::de::DeserializeOwned>(
response: reqwest::Response,
what: &str,
) -> Result<T> {
let bytes = collect_reqwest_body_within_cap(response, DEFAULT_AUTH_RESPONSE_BYTES)
.await
.map_err(|e| Error::internal(format!("Failed to read {what} body: {e}")))?;
serde_json::from_slice(&bytes).map_err(|e| {
Error::internal(format!(
"Failed to parse {what} ({:?} error at line {}, column {}). The parser's own message \
is not reproduced here because a data error echoes the offending input",
e.classify(),
e.line(),
e.column()
))
})
}
#[cfg(not(target_arch = "wasm32"))]
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}>"),
}
}
#[async_trait]
impl IdentityProvider for GenericOidcProvider {
fn id(&self) -> &'static str {
self.id
}
fn display_name(&self) -> &'static str {
self.display_name
}
#[cfg(not(target_arch = "wasm32"))]
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities::basic_oidc()
}
#[cfg(target_arch = "wasm32")]
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities::basic_oidc()
}
fn issuer(&self) -> &str {
&self.config.issuer
}
#[cfg(all(not(target_arch = "wasm32"), feature = "jwt-auth"))]
async fn validate_token(&self, token: &str) -> Result<AuthContext> {
self.jwt_validator
.validate(token, &self.validation_config)
.await
}
#[cfg(any(target_arch = "wasm32", not(feature = "jwt-auth")))]
async fn validate_token(&self, _token: &str) -> Result<AuthContext> {
Err(Error::protocol(
ErrorCode::METHOD_NOT_FOUND,
"JWT validation requires the 'jwt-auth' feature and non-WASM target",
))
}
#[cfg(not(target_arch = "wasm32"))]
async fn discovery(&self) -> Result<OidcDiscovery> {
self.fetch_discovery().await
}
#[cfg(target_arch = "wasm32")]
async fn discovery(&self) -> Result<OidcDiscovery> {
Err(Error::protocol(
ErrorCode::METHOD_NOT_FOUND,
"Discovery not available on WASM target",
))
}
#[cfg(not(target_arch = "wasm32"))]
async fn jwks(&self) -> Result<serde_json::Value> {
let discovery = self.fetch_discovery().await?;
let response = self
.http_client
.get(&discovery.jwks_uri)
.send()
.await
.map_err(|e| Error::internal(format!("Failed to fetch JWKS: {}", e)))?;
if !response.status().is_success() {
return Err(Error::internal(format!(
"JWKS endpoint returned status {}",
response.status()
)));
}
read_json_within_cap(response, "JWKS response").await
}
#[cfg(target_arch = "wasm32")]
async fn jwks(&self) -> Result<serde_json::Value> {
Err(Error::protocol(
ErrorCode::METHOD_NOT_FOUND,
"JWKS not available on WASM target",
))
}
#[cfg(not(target_arch = "wasm32"))]
async fn authorization_url(&self, params: AuthorizationParams) -> Result<String> {
let discovery = self.fetch_discovery().await?;
let mut url = format!(
"{}?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}",
discovery.authorization_endpoint,
urlencoding::encode(&self.config.client_id),
urlencoding::encode(¶ms.redirect_uri),
urlencoding::encode(¶ms.scopes.join(" ")),
urlencoding::encode(¶ms.state),
);
if let Some(nonce) = ¶ms.nonce {
url.push_str(&format!("&nonce={}", urlencoding::encode(nonce)));
}
if let Some(challenge) = ¶ms.code_challenge {
url.push_str(&format!(
"&code_challenge={}&code_challenge_method={}",
urlencoding::encode(challenge),
params.code_challenge_method.as_deref().unwrap_or("S256")
));
}
for (key, value) in ¶ms.extra {
url.push_str(&format!(
"&{}={}",
urlencoding::encode(key),
urlencoding::encode(value)
));
}
Ok(url)
}
#[cfg(target_arch = "wasm32")]
async fn authorization_url(&self, _params: AuthorizationParams) -> Result<String> {
Err(Error::protocol(
ErrorCode::METHOD_NOT_FOUND,
"Authorization URL not available on WASM target",
))
}
#[cfg(not(target_arch = "wasm32"))]
async fn exchange_code(&self, params: TokenExchangeParams) -> Result<TokenResponse> {
let discovery = self.fetch_discovery().await?;
let mut form = vec![
("grant_type", "authorization_code".to_string()),
("client_id", self.config.client_id.clone()),
("code", params.code),
("redirect_uri", params.redirect_uri),
];
if let Some(verifier) = params.code_verifier {
form.push(("code_verifier", verifier));
}
let mut request = self.http_client.post(&discovery.token_endpoint).form(&form);
if let Some(ref secret) = self.config.client_secret {
request = request.basic_auth(&self.config.client_id, Some(secret));
}
let response = request
.send()
.await
.map_err(|e| Error::internal(format!("Token exchange failed: {}", 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),
));
}
read_json_within_cap(response, "token exchange response").await
}
#[cfg(target_arch = "wasm32")]
async fn exchange_code(&self, _params: TokenExchangeParams) -> Result<TokenResponse> {
Err(Error::protocol(
ErrorCode::METHOD_NOT_FOUND,
"Code exchange not available on WASM target",
))
}
#[cfg(not(target_arch = "wasm32"))]
async fn refresh_token(&self, refresh_token: &str) -> Result<TokenResponse> {
let discovery = self.fetch_discovery().await?;
let form = vec![
("grant_type", "refresh_token"),
("client_id", &self.config.client_id),
("refresh_token", refresh_token),
];
let mut request = self.http_client.post(&discovery.token_endpoint).form(&form);
if let Some(ref secret) = self.config.client_secret {
request = request.basic_auth(&self.config.client_id, Some(secret));
}
let response = request
.send()
.await
.map_err(|e| Error::internal(format!("Token refresh failed: {}", 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),
));
}
read_json_within_cap(response, "token refresh response").await
}
#[cfg(target_arch = "wasm32")]
async fn refresh_token(&self, _refresh_token: &str) -> Result<TokenResponse> {
Err(Error::protocol(
ErrorCode::METHOD_NOT_FOUND,
"Token refresh not available on WASM target",
))
}
#[cfg(not(target_arch = "wasm32"))]
async fn register_client(&self, request: DcrRequest) -> Result<DcrResponse> {
let discovery = self.fetch_discovery().await?;
let registration_endpoint = discovery.registration_endpoint.ok_or_else(|| {
Error::protocol(
ErrorCode::INVALID_REQUEST,
format!(
"Provider '{}' does not support Dynamic Client Registration",
self.display_name
),
)
})?;
let response = self
.http_client
.post(®istration_endpoint)
.json(&request)
.send()
.await
.map_err(|e| Error::internal(format!("DCR request failed: {}", e)))?;
if !response.status().is_success() {
let error_text = read_error_body_within_cap(response).await;
return Err(Error::protocol(
ErrorCode::INVALID_REQUEST,
format!("DCR failed: {}", error_text),
));
}
read_json_within_cap(response, "DCR response").await
}
#[cfg(target_arch = "wasm32")]
async fn register_client(&self, _request: DcrRequest) -> Result<DcrResponse> {
Err(Error::protocol(
ErrorCode::METHOD_NOT_FOUND,
"DCR not available on WASM target",
))
}
#[cfg(not(target_arch = "wasm32"))]
async fn revoke_token(&self, token: &str) -> Result<()> {
let discovery = self.fetch_discovery().await?;
let Some(revocation_endpoint) = discovery.revocation_endpoint else {
return Ok(()); };
let form = vec![("token", token), ("client_id", &self.config.client_id)];
let mut request = self.http_client.post(&revocation_endpoint).form(&form);
if let Some(ref secret) = self.config.client_secret {
request = request.basic_auth(&self.config.client_id, Some(secret));
}
let response = request
.send()
.await
.map_err(|e| Error::internal(format!("Token revocation failed: {}", 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 revocation failed: {}", error_text),
));
}
Ok(())
}
#[cfg(target_arch = "wasm32")]
async fn revoke_token(&self, _token: &str) -> Result<()> {
Ok(()) }
#[cfg(not(target_arch = "wasm32"))]
async fn user_info(&self, access_token: &str) -> Result<serde_json::Value> {
let discovery = self.fetch_discovery().await?;
let userinfo_endpoint = discovery.userinfo_endpoint.ok_or_else(|| {
Error::protocol(
ErrorCode::INVALID_REQUEST,
format!(
"Provider '{}' does not support UserInfo endpoint",
self.display_name
),
)
})?;
let response = self
.http_client
.get(&userinfo_endpoint)
.bearer_auth(access_token)
.send()
.await
.map_err(|e| Error::internal(format!("UserInfo request failed: {}", e)))?;
if !response.status().is_success() {
let error_text = read_error_body_within_cap(response).await;
return Err(Error::protocol(
ErrorCode::INVALID_REQUEST,
format!("UserInfo request failed: {}", error_text),
));
}
read_json_within_cap(response, "UserInfo response").await
}
#[cfg(target_arch = "wasm32")]
async fn user_info(&self, _access_token: &str) -> Result<serde_json::Value> {
Err(Error::protocol(
ErrorCode::METHOD_NOT_FOUND,
"UserInfo not available on WASM target",
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_google_config() {
let config = GenericOidcConfig::google("test-client");
assert_eq!(config.id, "google");
assert_eq!(config.display_name, "Google Identity");
assert_eq!(config.issuer, "https://accounts.google.com");
assert_eq!(config.client_id, "test-client");
assert!(config.client_secret.is_none());
}
#[test]
fn test_auth0_config() {
let config = GenericOidcConfig::auth0("example.auth0.com", "test-client");
assert_eq!(config.id, "auth0");
assert_eq!(config.display_name, "Auth0");
assert_eq!(config.issuer, "https://example.auth0.com/");
assert_eq!(config.client_id, "test-client");
}
#[test]
fn test_okta_config() {
let config = GenericOidcConfig::okta("example.okta.com", "test-client");
assert_eq!(config.id, "okta");
assert_eq!(config.display_name, "Okta");
assert_eq!(config.issuer, "https://example.okta.com");
assert_eq!(config.client_id, "test-client");
}
#[test]
fn test_entra_config() {
let config = GenericOidcConfig::entra("tenant-id", "test-client");
assert_eq!(config.id, "entra");
assert_eq!(config.display_name, "Microsoft Entra ID");
assert_eq!(
config.issuer,
"https://login.microsoftonline.com/tenant-id/v2.0"
);
assert_eq!(config.client_id, "test-client");
}
#[test]
fn test_config_new() {
let config = GenericOidcConfig::new(
"custom",
"Custom Provider",
"https://auth.example.com",
"my-client-id",
);
assert_eq!(config.id, "custom");
assert_eq!(config.display_name, "Custom Provider");
assert_eq!(config.issuer, "https://auth.example.com");
assert_eq!(config.client_id, "my-client-id");
assert!(config.client_secret.is_none());
assert_eq!(config.cache_ttl, Duration::from_hours(1));
assert_eq!(config.leeway_seconds, 60);
}
#[test]
fn test_config_with_client_secret() {
let config = GenericOidcConfig::new("test", "Test", "https://test.com", "client")
.with_client_secret("my-secret");
assert_eq!(config.client_secret, Some("my-secret".to_string()));
}
#[test]
fn test_config_with_claim_mappings() {
let config = GenericOidcConfig::new("test", "Test", "https://test.com", "client")
.with_claim_mappings(ClaimMappings::google());
assert!(config.claim_mappings.tenant_id.is_none()); }
#[test]
fn test_config_clone() {
let config = GenericOidcConfig::google("test-client").with_client_secret("secret");
let cloned = config.clone();
assert_eq!(config.id, cloned.id);
assert_eq!(config.client_secret, cloned.client_secret);
}
#[test]
fn test_config_debug() {
let config = GenericOidcConfig::google("test-client");
let debug_str = format!("{:?}", config);
assert!(debug_str.contains("GenericOidcConfig"));
assert!(debug_str.contains("google"));
}
#[test]
fn test_google_claim_mappings() {
let mappings = ClaimMappings::google();
assert_eq!(mappings.user_id, "sub");
assert!(mappings.tenant_id.is_none()); assert_eq!(mappings.email, Some("email".to_string()));
}
#[test]
fn test_auth0_claim_mappings() {
let mappings = ClaimMappings::auth0();
assert_eq!(mappings.user_id, "sub");
assert_eq!(mappings.tenant_id, Some("org_id".to_string()));
assert_eq!(mappings.groups, Some("roles".to_string()));
}
#[test]
fn test_okta_claim_mappings() {
let mappings = ClaimMappings::okta();
assert_eq!(mappings.user_id, "uid");
assert_eq!(mappings.tenant_id, Some("org_id".to_string()));
assert_eq!(mappings.groups, Some("groups".to_string()));
}
#[test]
fn test_entra_claim_mappings() {
let mappings = ClaimMappings::entra();
assert_eq!(mappings.user_id, "oid");
assert_eq!(mappings.tenant_id, Some("tid".to_string()));
assert_eq!(mappings.email, Some("preferred_username".to_string()));
assert_eq!(mappings.groups, Some("groups".to_string()));
}
#[test]
fn test_cached_data_creation() {
let data: CachedData<String> = CachedData::new("test".to_string(), Duration::from_mins(1));
assert_eq!(data.data, "test");
assert!(!data.is_expired());
}
#[test]
fn test_cached_data_expiration() {
let data: CachedData<String> =
CachedData::new("test".to_string(), Duration::from_millis(1));
std::thread::sleep(Duration::from_millis(10));
assert!(data.is_expired());
}
#[test]
fn test_cached_data_debug() {
let data: CachedData<String> = CachedData::new("test".to_string(), Duration::from_mins(1));
let debug_str = format!("{:?}", data);
assert!(debug_str.contains("CachedData"));
}
#[test]
fn test_discovery_url_candidates_for_a_path_less_issuer() {
let rendered: Vec<String> = discovery_url_candidates("https://accounts.google.com")
.unwrap()
.iter()
.map(std::string::ToString::to_string)
.collect();
assert_eq!(
rendered,
vec![
"https://accounts.google.com/.well-known/oauth-authorization-server",
"https://accounts.google.com/.well-known/openid-configuration",
]
);
}
#[test]
fn test_discovery_url_candidates_with_trailing_slash() {
let with_slash: Vec<String> = discovery_url_candidates("https://example.auth0.com/")
.unwrap()
.iter()
.map(std::string::ToString::to_string)
.collect();
let without: Vec<String> = discovery_url_candidates("https://example.auth0.com")
.unwrap()
.iter()
.map(std::string::ToString::to_string)
.collect();
assert_eq!(with_slash, without);
assert_eq!(
with_slash,
vec![
"https://example.auth0.com/.well-known/oauth-authorization-server",
"https://example.auth0.com/.well-known/openid-configuration",
]
);
}
#[test]
fn test_discovery_refusals_name_the_rule_and_bound_the_peer_value() {
let url = url::Url::parse("https://as.example/.well-known/openid-configuration").unwrap();
let (failure, error) =
discovery_issuer_mismatch(&url, "https://as.example", "https://honest.example");
assert_eq!(failure, DiscoveryFailure::IssuerMismatch);
assert_eq!(
classify_discovery_failure(failure),
DiscoveryOutcome::Terminal
);
let message = error.to_string();
assert!(message.contains("https://as.example"), "{message}");
assert!(message.contains("https://honest.example"), "{message}");
let flood = "z".repeat(10_000);
let (_, error) = discovery_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_discovery_document_issuer_rows() {
let url = url::Url::parse("https://as.example/.well-known/openid-configuration").unwrap();
let good = serde_json::json!({ "issuer": "https://as.example" });
assert_eq!(
discovery_document_issuer(&url, &good).unwrap(),
"https://as.example"
);
for hostile in [
serde_json::json!({}),
serde_json::json!({ "issuer": 7 }),
serde_json::json!({ "issuer": null }),
] {
let (failure, error) = discovery_document_issuer(&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_discovery_status_classification_rows() {
let url = url::Url::parse("https://as.example/.well-known/openid-configuration").unwrap();
let (failure, _) = discovery_status_failure(&url, reqwest::StatusCode::NOT_FOUND);
assert_eq!(failure, DiscoveryFailure::NotFound);
assert_eq!(
classify_discovery_failure(failure),
DiscoveryOutcome::Fallback
);
let (failure, _) = discovery_status_failure(&url, reqwest::StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(failure, DiscoveryFailure::HttpStatus(503));
assert_eq!(classify_discovery_failure(failure), DiscoveryOutcome::Retry);
let (failure, _) = discovery_status_failure(&url, reqwest::StatusCode::UNAUTHORIZED);
assert_eq!(failure, DiscoveryFailure::HttpStatus(401));
assert_eq!(
classify_discovery_failure(failure),
DiscoveryOutcome::Fallback
);
}
#[test]
fn test_authorization_url_components() {
let authorization_endpoint = "https://accounts.google.com/o/oauth2/v2/auth";
let client_id = "test-client-id";
let redirect_uri = "https://example.com/callback";
let scopes = ["openid", "email", "profile"];
let state = "random-state";
let url = format!(
"{}?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}",
authorization_endpoint,
urlencoding::encode(client_id),
urlencoding::encode(redirect_uri),
urlencoding::encode(&scopes.join(" ")),
urlencoding::encode(state),
);
assert!(url.starts_with("https://accounts.google.com/o/oauth2/v2/auth"));
assert!(url.contains("client_id=test-client-id"));
assert!(url.contains("redirect_uri=https%3A%2F%2Fexample.com%2Fcallback"));
assert!(url.contains("response_type=code"));
assert!(url.contains("scope=openid%20email%20profile"));
assert!(url.contains("state=random-state"));
}
#[test]
fn test_authorization_url_with_pkce() {
let base_url = "https://auth.example.com/authorize?client_id=test";
let code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
let code_challenge_method = "S256";
let url = format!(
"{}&code_challenge={}&code_challenge_method={}",
base_url,
urlencoding::encode(code_challenge),
code_challenge_method
);
assert!(url.contains("code_challenge="));
assert!(url.contains("code_challenge_method=S256"));
}
#[test]
fn test_authorization_url_with_nonce() {
let base_url = "https://auth.example.com/authorize?client_id=test";
let nonce = "n-0S6_WzA2Mj";
let url = format!("{}&nonce={}", base_url, urlencoding::encode(nonce));
assert!(url.contains("nonce=n-0S6_WzA2Mj"));
}
#[test]
fn test_basic_oidc_capabilities() {
let caps = ProviderCapabilities::basic_oidc();
assert!(caps.oidc);
assert!(!caps.dcr);
assert!(caps.pkce);
assert!(caps.refresh_tokens);
assert!(!caps.revocation);
assert!(!caps.introspection);
}
#[test]
fn test_config_chain() {
let config = GenericOidcConfig::new(
"custom-provider",
"Custom Identity Provider",
"https://identity.example.com",
"client-123",
)
.with_client_secret("secret-456")
.with_claim_mappings(ClaimMappings::default());
assert_eq!(config.id, "custom-provider");
assert_eq!(config.display_name, "Custom Identity Provider");
assert_eq!(config.issuer, "https://identity.example.com");
assert_eq!(config.client_id, "client-123");
assert_eq!(config.client_secret, Some("secret-456".to_string()));
}
#[test]
fn test_claim_normalization_google() {
let mappings = ClaimMappings::google();
let claims = serde_json::json!({
"sub": "google-user-123",
"email": "user@gmail.com",
"name": "Test User",
"picture": "https://example.com/photo.jpg"
});
let normalized = mappings.normalize_claims(&claims);
assert_eq!(
normalized.get("sub").and_then(|v| v.as_str()),
Some("google-user-123")
);
assert_eq!(
normalized.get("email").and_then(|v| v.as_str()),
Some("user@gmail.com")
);
assert_eq!(
normalized.get("name").and_then(|v| v.as_str()),
Some("Test User")
);
}
#[test]
fn test_claim_normalization_entra() {
let mappings = ClaimMappings::entra();
let claims = serde_json::json!({
"oid": "entra-user-456",
"tid": "tenant-789",
"preferred_username": "user@contoso.com",
"name": "Enterprise User",
"groups": ["group1", "group2"]
});
let normalized = mappings.normalize_claims(&claims);
assert_eq!(
normalized.get("sub").and_then(|v| v.as_str()),
Some("entra-user-456")
);
assert_eq!(
normalized.get("tenant_id").and_then(|v| v.as_str()),
Some("tenant-789")
);
assert_eq!(
normalized.get("email").and_then(|v| v.as_str()),
Some("user@contoso.com")
);
assert!(normalized.contains_key("groups"));
}
#[test]
fn test_claim_normalization_auth0() {
let mappings = ClaimMappings::auth0();
let claims = serde_json::json!({
"sub": "auth0|user123",
"org_id": "org_ABC123",
"email": "user@example.com",
"roles": ["admin", "user"]
});
let normalized = mappings.normalize_claims(&claims);
assert_eq!(
normalized.get("sub").and_then(|v| v.as_str()),
Some("auth0|user123")
);
assert_eq!(
normalized.get("tenant_id").and_then(|v| v.as_str()),
Some("org_ABC123")
);
assert!(normalized.contains_key("groups"));
}
}