1use crate::error::{AuthError, Result};
7use crate::jwt::{JwtConfig, JwtValidator};
8use agentguard_core::auth_keys::Algorithm;
9use serde::Deserialize;
10
11#[derive(Debug, Clone)]
13pub struct OidcConfig {
14 pub issuer: String,
15 pub audience: String,
16 pub algorithms: Vec<Algorithm>,
17}
18
19impl OidcConfig {
20 pub fn new(issuer: impl Into<String>, audience: impl Into<String>) -> Self {
21 Self {
22 issuer: issuer.into(),
23 audience: audience.into(),
24 algorithms: vec![Algorithm::EdDSA, Algorithm::RS256],
25 }
26 }
27}
28
29#[derive(Debug, Clone, Deserialize)]
31pub struct OidcMetadata {
32 pub issuer: String,
33 pub jwks_uri: String,
34 #[serde(default)]
35 pub authorization_endpoint: Option<String>,
36 #[serde(default)]
37 pub token_endpoint: Option<String>,
38 #[serde(default)]
39 pub introspection_endpoint: Option<String>,
40}
41
42impl OidcConfig {
43 pub async fn discover(&self) -> Result<(JwtValidator, OidcMetadata)> {
51 let url = format!(
52 "{}/.well-known/openid-configuration",
53 self.issuer.trim_end_matches('/')
54 );
55 let client = reqwest::Client::builder()
57 .timeout(std::time::Duration::from_secs(10))
58 .connect_timeout(std::time::Duration::from_secs(5))
59 .redirect(reqwest::redirect::Policy::none())
60 .build()
61 .map_err(|e| AuthError::OidcDiscovery(format!("client build: {}", e)))?;
62 let mut attempt = 0u32;
66 let resp = loop {
67 attempt += 1;
68 match client.get(&url).send().await {
69 Ok(r) if r.status().is_success() => break r,
70 Ok(r) if r.status().is_server_error() && attempt < 3 => {
71 let backoff_ms = 250u64 * (1u64 << (attempt - 1));
72 tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await;
73 continue;
74 }
75 Ok(r) => {
76 return Err(AuthError::OidcDiscovery(format!(
77 "HTTP {} after {} attempt(s)",
78 r.status(),
79 attempt
80 )));
81 }
82 Err(e) if (e.is_timeout() || e.is_connect()) && attempt < 3 => {
83 let backoff_ms = 250u64 * (1u64 << (attempt - 1));
84 tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await;
85 continue;
86 }
87 Err(e) => return Err(AuthError::OidcDiscovery(e.to_string())),
88 }
89 };
90 let body = resp
91 .text()
92 .await
93 .map_err(|e| AuthError::OidcDiscovery(e.to_string()))?;
94 if body.len() > 1_048_576 {
95 return Err(AuthError::OidcDiscovery(
96 "discovery document exceeds 1 MiB".into(),
97 ));
98 }
99 let meta: OidcMetadata = serde_json::from_str(&body)
100 .map_err(|e| AuthError::OidcDiscovery(format!("parse: {}", e)))?;
101 if meta.issuer != self.issuer {
104 return Err(AuthError::OidcDiscovery(format!(
105 "issuer mismatch: configured={} discovered={}",
106 self.issuer, meta.issuer
107 )));
108 }
109 let mut cfg = JwtConfig::new(self.issuer.clone(), self.audience.clone())
110 .with_algorithms_lossy(self.algorithms.clone())
111 .with_jwks_uri(meta.jwks_uri.clone());
112 cfg.clock_skew = std::time::Duration::from_secs(60);
113 let validator = JwtValidator::new(cfg);
114 validator.refresh_jwks().await?;
115 Ok((validator, meta))
116 }
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122
123 #[test]
128 fn issuer_mismatch_rejected_at_config_parse() {
129 let meta: OidcMetadata = serde_json::from_str(
134 r#"{"issuer":"https://attacker.example.com","jwks_uri":"https://x/jwks"}"#,
135 )
136 .unwrap();
137 let configured = "https://real-idp.example.com";
138 assert_ne!(meta.issuer, configured);
139 }
140}