use jsonwebtoken::{
dangerous::insecure_decode, decode, decode_header, jwk::JwkSet, Algorithm, DecodingKey,
TokenData, Validation,
};
use parking_lot::RwLock;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::{Duration, Instant};
use crate::config::{OidcConfig, OidcProvider, ScopeEnforcement};
use crate::tokens::Role;
#[derive(Debug, Clone)]
pub struct OidcIdentity {
pub provider: String,
pub subject: String,
pub issuer: String,
pub role: Role,
pub namespace_scope: Vec<String>,
pub rule_namespace_scope: Option<Vec<String>>,
pub namespace_scope_enforcement: ScopeEnforcement,
}
#[derive(Debug, Deserialize, Serialize)]
struct Claims {
iss: Option<String>,
sub: Option<String>,
aud: Option<Audience>,
exp: Option<u64>,
iat: Option<u64>,
nbf: Option<u64>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(untagged)]
enum Audience {
Single(String),
Multiple(Vec<String>),
}
impl Audience {
#[cfg(test)]
fn contains(&self, expected: &str) -> bool {
match self {
Audience::Single(s) => s == expected,
Audience::Multiple(v) => v.iter().any(|s| s == expected),
}
}
}
struct CachedJwks {
jwks: JwkSet,
fetched_at: Instant,
}
#[derive(Deserialize)]
struct OidcDiscoveryDoc {
#[serde(default)]
jwks_uri: String,
}
pub struct OidcValidator {
config: OidcConfig,
http_client: Client,
jwks_cache: RwLock<HashMap<String, CachedJwks>>,
}
const OIDC_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
impl OidcValidator {
pub fn new(config: OidcConfig, http_client: Client) -> Self {
Self {
config,
http_client,
jwks_cache: RwLock::new(HashMap::new()),
}
}
pub fn is_active(&self) -> bool {
self.config.enabled && !self.config.providers.is_empty()
}
pub async fn validate_token(&self, token: &str) -> Result<OidcIdentity, String> {
if !self.is_active() {
return Err("OIDC not enabled".to_string());
}
let header = decode_header(token).map_err(|e| format!("Invalid JWT header: {}", e))?;
match header.alg {
Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512 => {
return Err("Symmetric algorithms not allowed for OIDC".to_string());
}
_ => {}
}
let unverified: Claims = insecure_decode::<Claims>(token)
.map_err(|e| format!("Cannot decode claims: {}", e))?
.claims;
let issuer = unverified.iss.as_deref().ok_or("Token missing iss claim")?;
let provider = self
.config
.providers
.iter()
.find(|p| p.enabled && p.issuer == issuer)
.ok_or_else(|| format!("No matching OIDC provider for issuer: {}", issuer))?;
let alg_str = format!("{:?}", header.alg);
if !provider.algorithms.iter().any(|a| a == &alg_str) {
return Err(format!(
"Algorithm {} not in provider whitelist: {:?}",
alg_str, provider.algorithms
));
}
let jwks = self.get_jwks(provider).await?;
let kid = header.kid.as_deref().unwrap_or("");
let jwk = jwks
.keys
.iter()
.find(|k| {
k.common.key_id.as_deref() == Some(kid) || (kid.is_empty() && jwks.keys.len() == 1)
})
.ok_or_else(|| {
format!(
"No matching key (kid={}) in JWKS for provider {}",
kid, provider.name
)
})?;
let decoding_key =
DecodingKey::from_jwk(jwk).map_err(|e| format!("Cannot build key from JWK: {}", e))?;
let mut validation = Validation::new(header.alg);
validation.set_issuer(&[&provider.issuer]);
validation.leeway = self.config.leeway_secs;
if !provider.audience.is_empty() {
validation.set_audience(&[&provider.audience]);
} else {
validation.validate_aud = false;
}
let token_data: TokenData<Claims> = decode(token, &decoding_key, &validation)
.map_err(|e| format!("JWT validation failed: {}", e))?;
let claims = token_data.claims;
if let (Some(iat), Some(exp)) = (claims.iat, claims.exp) {
let lifetime = exp.saturating_sub(iat);
if lifetime > provider.max_token_lifetime_secs {
return Err(format!(
"Token lifetime {} exceeds max {} for provider {}",
lifetime, provider.max_token_lifetime_secs, provider.name
));
}
}
let subject = claims.sub.unwrap_or_default();
let (role, rule_scope) = self.match_role(provider, &subject).ok_or_else(|| {
format!(
"No role rule matches sub='{}' for provider {}",
subject, provider.name
)
})?;
Ok(OidcIdentity {
provider: provider.name.clone(),
subject,
issuer: provider.issuer.clone(),
role,
namespace_scope: provider.namespace_scope.clone(),
rule_namespace_scope: rule_scope,
namespace_scope_enforcement: provider.namespace_scope_enforcement,
})
}
async fn get_jwks(&self, provider: &OidcProvider) -> Result<JwkSet, String> {
let cache_ttl = Duration::from_secs(self.config.jwks_cache_secs);
{
let cache = self.jwks_cache.read();
if let Some(cached) = cache.get(&provider.issuer) {
if cached.fetched_at.elapsed() < cache_ttl {
return Ok(cached.jwks.clone());
}
}
}
let jwks_uri = self.resolve_jwks_uri(provider).await?;
match self.fetch_jwks(&jwks_uri).await {
Ok(jwks) => {
let mut cache = self.jwks_cache.write();
cache.insert(
provider.issuer.clone(),
CachedJwks {
jwks: jwks.clone(),
fetched_at: Instant::now(),
},
);
Ok(jwks)
}
Err(e) => {
let cache = self.jwks_cache.read();
if let Some(cached) = cache.get(&provider.issuer) {
tracing::warn!(
provider = %provider.name,
error = %e,
stale_age_secs = cached.fetched_at.elapsed().as_secs(),
"JWKS fetch failed, serving stale keys"
);
return Ok(cached.jwks.clone());
}
Err(format!("JWKS fetch failed for {}: {}", provider.name, e))
}
}
}
async fn resolve_jwks_uri(&self, provider: &OidcProvider) -> Result<String, String> {
if let Some(ref uri) = provider.jwks_uri {
return Ok(uri.clone());
}
let discovery_url = format!(
"{}/.well-known/openid-configuration",
provider.issuer.trim_end_matches('/')
);
if let Ok(resp) = self
.http_client
.get(&discovery_url)
.timeout(OIDC_REQUEST_TIMEOUT)
.send()
.await
{
if resp.status().is_success() {
if let Ok(config) = resp.json::<OidcDiscoveryDoc>().await {
if !config.jwks_uri.is_empty() {
tracing::debug!(
provider = %provider.name,
jwks_uri = %config.jwks_uri,
"Discovered JWKS URI via openid-configuration"
);
return Ok(config.jwks_uri);
}
}
}
}
Ok(format!(
"{}/.well-known/jwks.json",
provider.issuer.trim_end_matches('/')
))
}
async fn fetch_jwks(&self, url: &str) -> Result<JwkSet, String> {
let resp = self
.http_client
.get(url)
.timeout(OIDC_REQUEST_TIMEOUT)
.send()
.await
.map_err(|e| format!("HTTP error: {}", e))?;
if !resp.status().is_success() {
return Err(format!("JWKS endpoint returned {}", resp.status()));
}
resp.json::<JwkSet>()
.await
.map_err(|e| format!("JWKS parse error: {}", e))
}
fn match_role(
&self,
provider: &OidcProvider,
subject: &str,
) -> Option<(Role, Option<Vec<String>>)> {
for rule in &provider.role_rules {
if glob_match(&rule.pattern, subject) {
let role = match rule.role.as_str() {
"admin" => Role::Admin,
"write" => Role::Write,
"read" => Role::Read,
_ => return None,
};
return Some((role, rule.namespace_scope.clone()));
}
}
None
}
}
fn glob_match(pattern: &str, value: &str) -> bool {
if pattern == "*" {
return true;
}
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 1 {
return pattern == value;
}
let mut remaining = value;
for (i, part) in parts.iter().enumerate() {
if part.is_empty() {
continue;
}
if i == 0 {
if !remaining.starts_with(part) {
return false;
}
remaining = &remaining[part.len()..];
} else if i == parts.len() - 1 {
if !remaining.ends_with(part) {
return false;
}
return true;
} else {
match remaining.find(part) {
Some(pos) => remaining = &remaining[pos + part.len()..],
None => return false,
}
}
}
true
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::config::{OidcProvider, OidcRoleRule};
#[test]
fn test_glob_match_exact() {
assert!(glob_match("repo:org/myrepo", "repo:org/myrepo"));
assert!(!glob_match("repo:org/myrepo", "repo:org/other"));
}
#[test]
fn test_glob_match_wildcard() {
assert!(glob_match("repo:org/*", "repo:org/myrepo"));
assert!(glob_match(
"repo:org/*",
"repo:org/myrepo:ref:refs/heads/main"
));
assert!(!glob_match("repo:org/*", "repo:other/myrepo"));
}
#[test]
fn test_glob_match_star_only() {
assert!(glob_match("*", "anything"));
assert!(glob_match("*", ""));
}
#[test]
fn test_glob_match_middle() {
assert!(glob_match(
"repo:*:ref:refs/heads/main",
"repo:org/myrepo:ref:refs/heads/main"
));
assert!(!glob_match(
"repo:*:ref:refs/heads/main",
"repo:org/myrepo:ref:refs/heads/dev"
));
}
#[test]
fn test_match_role_first_wins() {
let config = OidcConfig::default();
let validator = OidcValidator::new(config, Client::new());
let provider = OidcProvider {
name: "test".to_string(),
issuer: "https://test".to_string(),
jwks_uri: None,
audience: "nora".to_string(),
algorithms: vec!["RS256".to_string()],
max_token_lifetime_secs: 900,
namespace_scope: vec!["*".to_string()],
namespace_scope_enforcement: crate::config::ScopeEnforcement::Enforce,
enabled: true,
role_rules: vec![
OidcRoleRule {
pattern: "repo:myorg/*:ref:refs/heads/main".to_string(),
role: "write".to_string(),
namespace_scope: None,
},
OidcRoleRule {
pattern: "repo:myorg/*:pull_request".to_string(),
role: "write".to_string(),
namespace_scope: Some(vec!["ci-transport/**".to_string()]),
},
OidcRoleRule {
pattern: "repo:myorg/*".to_string(),
role: "read".to_string(),
namespace_scope: None,
},
],
};
let m = validator.match_role(&provider, "repo:myorg/app:ref:refs/heads/main");
assert!(matches!(m, Some((Role::Write, None))));
let m = validator.match_role(&provider, "repo:myorg/app:pull_request");
let (role, scope) = m.unwrap();
assert_eq!(role, Role::Write);
assert_eq!(scope.as_deref(), Some(&["ci-transport/**".to_string()][..]));
let m = validator.match_role(&provider, "repo:myorg/app:ref:refs/heads/dev");
assert!(matches!(m, Some((Role::Read, None))));
let m = validator.match_role(&provider, "repo:other/app:ref:refs/heads/main");
assert!(m.is_none());
}
#[test]
fn test_audience_contains() {
let single = Audience::Single("nora".to_string());
assert!(single.contains("nora"));
assert!(!single.contains("other"));
let multi = Audience::Multiple(vec!["nora".to_string(), "api".to_string()]);
assert!(multi.contains("nora"));
assert!(multi.contains("api"));
assert!(!multi.contains("other"));
}
}