use securitydept_oauth_provider::OidcSharedConfig;
use serde::Deserialize;
use super::{
capabilities::FrontendOidcModeCapabilities,
contracts::{FrontendOidcModeClaimsCheckScript, FrontendOidcModeConfigProjection},
};
use crate::orchestration::{BackendConfigError, OidcClientConfig, OidcClientRawConfig};
#[derive(Debug, Clone, Default, Deserialize)]
pub struct NoPendingStoreConfig;
impl securitydept_oidc_client::PendingOauthStoreConfig for NoPendingStoreConfig {}
pub trait FrontendOidcModeConfigSource {
fn oidc_client_raw_config(&self) -> &OidcClientRawConfig<NoPendingStoreConfig>;
fn capabilities(&self) -> &FrontendOidcModeCapabilities;
fn resolve_oidc_client(
&self,
shared: &OidcSharedConfig,
) -> Result<OidcClientConfig<NoPendingStoreConfig>, BackendConfigError> {
self.oidc_client_raw_config()
.clone()
.resolve_config(shared)
.map_err(Into::into)
}
fn resolve_all(
&self,
shared: &OidcSharedConfig,
) -> Result<ResolvedFrontendOidcModeConfig, BackendConfigError> {
Ok(ResolvedFrontendOidcModeConfig {
oidc_client: self.resolve_oidc_client(shared)?,
capabilities: self.capabilities().clone(),
})
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct FrontendOidcModeConfig {
#[serde(default, flatten)]
pub oidc_client: OidcClientRawConfig<NoPendingStoreConfig>,
#[serde(default, flatten)]
pub capabilities: FrontendOidcModeCapabilities,
}
impl FrontendOidcModeConfigSource for FrontendOidcModeConfig {
fn oidc_client_raw_config(&self) -> &OidcClientRawConfig<NoPendingStoreConfig> {
&self.oidc_client
}
fn capabilities(&self) -> &FrontendOidcModeCapabilities {
&self.capabilities
}
}
#[derive(Debug, Clone)]
pub struct ResolvedFrontendOidcModeConfig {
pub oidc_client: OidcClientConfig<NoPendingStoreConfig>,
pub capabilities: FrontendOidcModeCapabilities,
}
impl ResolvedFrontendOidcModeConfig {
pub async fn to_config_projection(&self) -> std::io::Result<FrontendOidcModeConfigProjection> {
let client_secret = if self.capabilities.unsafe_frontend_client_secret.is_enabled() {
self.oidc_client.client_secret.clone()
} else {
None
};
let claims_check_script = match self.oidc_client.claims_check_script.as_deref() {
Some(path) => Some(FrontendOidcModeClaimsCheckScript::from_path(path).await?),
None => None,
};
Ok(FrontendOidcModeConfigProjection {
well_known_url: self.oidc_client.remote.well_known_url.clone(),
issuer_url: self.oidc_client.remote.issuer_url.clone(),
jwks_uri: self.oidc_client.remote.jwks_uri.clone(),
metadata_refresh_interval: self.oidc_client.remote.metadata_refresh_interval,
jwks_refresh_interval: self.oidc_client.remote.jwks_refresh_interval,
authorization_endpoint: self
.oidc_client
.provider_oidc
.authorization_endpoint
.clone(),
token_endpoint: self.oidc_client.provider_oidc.token_endpoint.clone(),
userinfo_endpoint: self.oidc_client.provider_oidc.userinfo_endpoint.clone(),
revocation_endpoint: self.oidc_client.provider_oidc.revocation_endpoint.clone(),
token_endpoint_auth_methods_supported: self
.oidc_client
.provider_oidc
.token_endpoint_auth_methods_supported
.as_ref()
.map(|v| {
v.iter()
.filter_map(|a| serde_json::to_value(a).ok())
.filter_map(|v| v.as_str().map(|s| s.to_owned()))
.collect()
}),
id_token_signing_alg_values_supported: self
.oidc_client
.provider_oidc
.id_token_signing_alg_values_supported
.as_ref()
.map(|v| {
v.iter()
.filter_map(|a| serde_json::to_value(a).ok())
.filter_map(|v| v.as_str().map(|s| s.to_owned()))
.collect()
}),
userinfo_signing_alg_values_supported: self
.oidc_client
.provider_oidc
.userinfo_signing_alg_values_supported
.as_ref()
.map(|v| {
v.iter()
.filter_map(|a| serde_json::to_value(a).ok())
.filter_map(|v| v.as_str().map(|s| s.to_owned()))
.collect()
}),
client_id: self.oidc_client.client_id.clone(),
client_secret,
scopes: self.oidc_client.scopes.clone(),
required_scopes: self.oidc_client.required_scopes.clone(),
redirect_url: self.oidc_client.redirect_url.clone(),
pkce_enabled: self.oidc_client.pkce_enabled,
claims_check_script,
generated_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64,
})
}
}
#[cfg(test)]
mod tests {
use securitydept_oauth_provider::{OAuthProviderRemoteConfig, OidcSharedConfig};
use super::*;
fn shared_config() -> OidcSharedConfig {
OidcSharedConfig {
remote: OAuthProviderRemoteConfig {
well_known_url: Some(
"https://auth.example.com/.well-known/openid-configuration".to_string(),
),
..Default::default()
},
client_id: Some("shared-app".to_string()),
client_secret: Some("shared-secret".to_string()),
..Default::default()
}
}
#[test]
fn resolve_inherits_client_id_from_shared() {
let raw = FrontendOidcModeConfig::default();
let resolved = raw.resolve_all(&shared_config()).expect("should resolve");
assert_eq!(resolved.oidc_client.client_id, "shared-app");
}
#[test]
fn local_client_id_overrides_shared() {
let raw = FrontendOidcModeConfig {
oidc_client: OidcClientRawConfig {
client_id: Some("local-spa".to_string()),
..Default::default()
},
capabilities: Default::default(),
};
let resolved = raw.resolve_all(&shared_config()).expect("should resolve");
assert_eq!(resolved.oidc_client.client_id, "local-spa");
}
#[test]
fn resolve_inherits_well_known_url_from_shared() {
let raw = FrontendOidcModeConfig::default();
let resolved = raw.resolve_all(&shared_config()).expect("should resolve");
assert_eq!(
resolved.oidc_client.remote.well_known_url.as_deref(),
Some("https://auth.example.com/.well-known/openid-configuration"),
);
}
#[test]
fn resolve_fails_without_client_id() {
let shared = OidcSharedConfig::default();
let raw = FrontendOidcModeConfig::default();
let err = raw.resolve_all(&shared).unwrap_err();
assert!(err.to_string().contains("client_id must be set"));
}
#[tokio::test]
async fn projection_reflects_resolved_config() {
let raw = FrontendOidcModeConfig {
oidc_client: OidcClientRawConfig {
redirect_url: Some("https://app.example.com/callback".to_string()),
pkce_enabled: true,
..Default::default()
},
capabilities: Default::default(),
};
let resolved = raw.resolve_all(&shared_config()).expect("should resolve");
let projection = resolved
.to_config_projection()
.await
.expect("projection should succeed");
assert_eq!(
projection.well_known_url.as_deref(),
Some("https://auth.example.com/.well-known/openid-configuration")
);
assert_eq!(projection.client_id, "shared-app");
assert_eq!(projection.redirect_url, "https://app.example.com/callback");
assert!(projection.pkce_enabled);
assert!(projection.client_secret.is_none());
assert!(projection.authorization_endpoint.is_none());
assert!(projection.token_endpoint.is_none());
assert!(projection.userinfo_endpoint.is_none());
}
#[test]
fn default_scopes_applied() {
let raw = FrontendOidcModeConfig::default();
let resolved = raw.resolve_all(&shared_config()).expect("should resolve");
assert_eq!(
resolved.oidc_client.scopes,
vec![
"openid".to_string(),
"profile".to_string(),
"email".to_string()
]
);
}
}