use serde::{Deserialize, Serialize};
use crate::security::errors::{Result, SecurityError};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct MeEndpointConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub expose_claims: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OidcConfig {
#[serde(default)]
pub issuer: Option<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>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub additional_issuers: Vec<TrustedIssuer>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TrustedIssuer {
pub issuer: String,
#[serde(default)]
pub audience: Option<String>,
#[serde(default)]
pub additional_audiences: Vec<String>,
#[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_scope_claim")]
pub scope_claim: String,
#[serde(default)]
pub require_jti: bool,
}
impl TrustedIssuer {
#[must_use]
pub fn as_config(&self, shared: &OidcConfig) -> OidcConfig {
OidcConfig {
issuer: Some(self.issuer.clone()),
audience: self.audience.clone(),
additional_audiences: self.additional_audiences.clone(),
jwks_cache_ttl_secs: shared.jwks_cache_ttl_secs,
allowed_algorithms: self.allowed_algorithms.clone(),
clock_skew_secs: self.clock_skew_secs,
jwks_uri: self.jwks_uri.clone(),
required: shared.required,
scope_claim: self.scope_claim.clone(),
require_jti: self.require_jti,
me: None,
additional_issuers: Vec::new(),
}
}
}
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: None,
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,
additional_issuers: Vec::new(),
}
}
}
impl OidcConfig {
#[must_use]
pub fn auth0(domain: &str, audience: &str) -> Self {
Self {
issuer: Some(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: Some(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: Some(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: Some(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: Some(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: Some("https://accounts.google.com".to_string()),
audience: Some(client_id.to_string()),
..Default::default()
}
}
pub fn validate(&self) -> Result<()> {
self.validate_issuer_profile()?;
if self.additional_issuers.is_empty() {
return Ok(());
}
let Some(primary) = self.issuer.as_deref() else {
return Err(SecurityError::SecurityConfigError(
"`additional_issuers` requires `issuer` to be set: tokens are routed to an \
issuer by their `iss` claim, so every trusted issuer must be named"
.to_string(),
));
};
let mut seen = std::collections::HashSet::from([primary]);
for extra in &self.additional_issuers {
if !seen.insert(extra.issuer.as_str()) {
return Err(SecurityError::SecurityConfigError(format!(
"OIDC issuer {:?} is configured twice; each trusted issuer appears once, \
or a token's `iss` would not name one set of keys",
extra.issuer
)));
}
extra.as_config(self).validate_issuer_profile().map_err(|e| {
SecurityError::SecurityConfigError(format!(
"additional issuer {:?}: {e}",
extra.issuer
))
})?;
}
Ok(())
}
fn validate_issuer_profile(&self) -> Result<()> {
match &self.issuer {
Some(issuer) => {
if !issuer.starts_with("https://")
&& !issuer.starts_with("http://localhost")
&& !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(),
));
}
},
None => {
if self.jwks_uri.is_none() {
return Err(SecurityError::SecurityConfigError(
"OIDC issuer is unset, so `jwks_uri` must be pinned: without an issuer, \
discovery cannot locate the JWKS endpoint. Set `issuer` to your IdP's \
issuer URL, or set `jwks_uri` to its JWKS endpoint directly."
.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(())
}
}