use serde::{Deserialize, Serialize};
use crate::security::errors::{Result, SecurityError};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MeEndpointConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub expose_claims: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OidcConfig {
pub issuer: String,
#[serde(default)]
pub audience: Option<String>,
#[serde(default)]
pub additional_audiences: Vec<String>,
#[serde(default = "default_jwks_cache_ttl")]
pub jwks_cache_ttl_secs: u64,
#[serde(default = "default_algorithms")]
pub allowed_algorithms: Vec<String>,
#[serde(default = "default_clock_skew")]
pub clock_skew_secs: u64,
#[serde(default)]
pub jwks_uri: Option<String>,
#[serde(default = "default_required")]
pub required: bool,
#[serde(default = "default_scope_claim")]
pub scope_claim: String,
#[serde(default)]
pub require_jti: bool,
#[serde(default)]
pub me: Option<MeEndpointConfig>,
}
pub(super) const fn default_jwks_cache_ttl() -> u64 {
300
}
pub(super) fn default_algorithms() -> Vec<String> {
vec!["RS256".to_string()]
}
pub(super) const MAX_CLOCK_SKEW_SECS: u64 = 300;
pub(super) const fn default_clock_skew() -> u64 {
60
}
pub(super) const fn default_required() -> bool {
true
}
pub(super) fn default_scope_claim() -> String {
"scope".to_string()
}
impl Default for OidcConfig {
fn default() -> Self {
Self {
issuer: String::new(),
audience: None,
additional_audiences: Vec::new(),
jwks_cache_ttl_secs: default_jwks_cache_ttl(),
allowed_algorithms: default_algorithms(),
clock_skew_secs: default_clock_skew(),
jwks_uri: None,
required: default_required(),
scope_claim: default_scope_claim(),
require_jti: false,
me: None,
}
}
}
impl OidcConfig {
#[must_use]
pub fn auth0(domain: &str, audience: &str) -> Self {
Self {
issuer: format!("https://{domain}/"),
audience: Some(audience.to_string()),
..Default::default()
}
}
#[must_use]
pub fn keycloak(base_url: &str, realm: &str, client_id: &str) -> Self {
Self {
issuer: format!("{base_url}/realms/{realm}"),
audience: Some(client_id.to_string()),
..Default::default()
}
}
#[must_use]
pub fn okta(domain: &str, audience: &str) -> Self {
Self {
issuer: format!("https://{domain}"),
audience: Some(audience.to_string()),
..Default::default()
}
}
#[must_use]
pub fn cognito(region: &str, user_pool_id: &str, client_id: &str) -> Self {
Self {
issuer: format!("https://cognito-idp.{region}.amazonaws.com/{user_pool_id}"),
audience: Some(client_id.to_string()),
..Default::default()
}
}
#[must_use]
pub fn azure_ad(tenant_id: &str, client_id: &str) -> Self {
Self {
issuer: format!("https://login.microsoftonline.com/{tenant_id}/v2.0"),
audience: Some(client_id.to_string()),
..Default::default()
}
}
#[must_use]
pub fn google(client_id: &str) -> Self {
Self {
issuer: "https://accounts.google.com".to_string(),
audience: Some(client_id.to_string()),
..Default::default()
}
}
pub fn validate(&self) -> Result<()> {
if self.issuer.is_empty() {
return Err(SecurityError::SecurityConfigError(
"OIDC issuer URL is required".to_string(),
));
}
if !self.issuer.starts_with("https://")
&& !self.issuer.starts_with("http://localhost")
&& !self.issuer.starts_with("http://127.0.0.1")
{
return Err(SecurityError::SecurityConfigError(
"OIDC issuer must use HTTPS (except localhost/127.0.0.1 for development)"
.to_string(),
));
}
if self.audience.is_none() && self.additional_audiences.is_empty() {
return Err(SecurityError::SecurityConfigError(
"OIDC audience is REQUIRED for security. Set 'audience' in auth config to your API identifier. \
This prevents token confusion attacks where tokens from one service can be used in another. \
Example: audience = \"https://api.example.com\" or audience = \"my-api-id\"".to_string(),
));
}
if self.allowed_algorithms.is_empty() {
return Err(SecurityError::SecurityConfigError(
"At least one algorithm must be allowed".to_string(),
));
}
Ok(())
}
}