1#![forbid(unsafe_code)]
30#![allow(missing_docs)] #![allow(rustdoc::broken_intra_doc_links)]
32#![allow(rustdoc::bare_urls)]
33#![allow(rustdoc::redundant_explicit_links)]
34#![allow(rustdoc::private_intra_doc_links)]
35#![allow(rustdoc::invalid_html_tags)]
36
37use std::collections::HashMap;
38
39use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
40use serde::{Deserialize, Serialize};
41
42#[derive(Debug, thiserror::Error)]
44pub enum OidcError {
45 #[error("unknown OIDC issuer: {0}")]
47 UnknownIssuer(String),
48 #[error("token validation failed: {0}")]
50 Validation(String),
51 #[error("JWKS fetch failed: {0}")]
53 JwksFetch(String),
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
61pub enum OidcIssuer {
62 GitHubActions,
64 Google,
66 GitLab,
68 Okta(String),
70 AzureAd(String),
72 Custom { issuer: String, jwks_url: String },
74}
75
76impl OidcIssuer {
77 pub fn issuer_url(&self) -> &str {
79 match self {
80 OidcIssuer::GitHubActions => "https://token.actions.githubusercontent.com",
81 OidcIssuer::Google => "https://accounts.google.com",
82 OidcIssuer::GitLab => "https://gitlab.com",
83 OidcIssuer::Okta(tenant) => tenant.as_str(),
84 OidcIssuer::AzureAd(tenant_id) => tenant_id.as_str(),
85 OidcIssuer::Custom { issuer, .. } => issuer.as_str(),
86 }
87 }
88
89 pub fn jwks_url(&self) -> String {
91 match self {
92 OidcIssuer::GitHubActions => {
93 "https://token.actions.githubusercontent.com/.well-known/jwks".to_string()
94 }
95 OidcIssuer::Google => "https://www.googleapis.com/oauth2/v3/certs".to_string(),
96 OidcIssuer::GitLab => "https://gitlab.com/-/jwks".to_string(),
97 OidcIssuer::Okta(tenant) => format!("{tenant}/oauth2/default/v1/keys"),
98 OidcIssuer::AzureAd(tenant_id) => {
99 format!("https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys")
100 }
101 OidcIssuer::Custom { jwks_url, .. } => jwks_url.clone(),
102 }
103 }
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct OidcClaims {
111 pub subject: String,
113 pub issuer: String,
115 pub audience: Vec<String>,
117 pub expires_at: i64,
119 pub issued_at: i64,
121 pub email: Option<String>,
123 pub github: Option<GithubClaims>,
125 pub raw: HashMap<String, serde_json::Value>,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize, Default)]
131pub struct GithubClaims {
132 pub repository: String,
134 pub workflow: String,
136 pub ref_: String,
138 pub sha: String,
140 pub actor: String,
142}
143
144pub struct OidcVerifier {
147 http: reqwest::blocking::Client,
148 jwks_cache: std::sync::Mutex<HashMap<String, std::sync::Arc<JwksSet>>>,
149}
150
151impl Default for OidcVerifier {
152 fn default() -> Self {
153 Self::new()
154 }
155}
156
157impl OidcVerifier {
158 pub fn new() -> Self {
160 Self {
161 http: reqwest::blocking::Client::builder()
162 .timeout(std::time::Duration::from_secs(10))
163 .build()
164 .expect("reqwest client"),
165 jwks_cache: std::sync::Mutex::new(HashMap::new()),
166 }
167 }
168
169 pub fn verify(&self, issuer: &OidcIssuer, token: &str) -> Result<OidcClaims, OidcError> {
173 let expected_iss = issuer.issuer_url();
174 let jwks = self.fetch_jwks(issuer)?;
175
176 let header = jsonwebtoken::decode_header(token)
178 .map_err(|e| OidcError::Validation(format!("header decode: {e}")))?;
179 let kid = header
180 .kid
181 .ok_or_else(|| OidcError::Validation("token missing kid header".into()))?;
182
183 let jwk = jwks
184 .get(&kid)
185 .ok_or_else(|| OidcError::Validation(format!("issuer has no key with kid={kid}")))?;
186
187 let decoding_key = DecodingKey::from_rsa_components(&jwk.modulus, &jwk.exponent)
188 .map_err(|e| OidcError::Validation(format!("JWK decode: {e}")))?;
189
190 let mut validation = Validation::new(Algorithm::RS256);
191 validation.set_issuer(&[expected_iss]);
192 validation.validate_aud = false;
196
197 let token_data =
198 decode::<HashMap<String, serde_json::Value>>(token, &decoding_key, &validation)
199 .map_err(|e| OidcError::Validation(format!("token verify: {e}")))?;
200
201 let raw = token_data.claims;
202 let subject = raw
203 .get("sub")
204 .and_then(|v| v.as_str())
205 .ok_or_else(|| OidcError::Validation("missing sub".into()))?
206 .to_string();
207 let email = raw.get("email").and_then(|v| v.as_str()).map(String::from);
208 let audience: Vec<String> = raw
209 .get("aud")
210 .map(|v| match v {
211 serde_json::Value::String(s) => vec![s.clone()],
212 serde_json::Value::Array(arr) => arr
213 .iter()
214 .filter_map(|x| x.as_str().map(String::from))
215 .collect(),
216 _ => vec![],
217 })
218 .unwrap_or_default();
219 let expires_at = raw.get("exp").and_then(|v| v.as_i64()).unwrap_or(0);
220 let issued_at = raw.get("iat").and_then(|v| v.as_i64()).unwrap_or(0);
221
222 let github = if raw.contains_key("repository") {
223 Some(GithubClaims {
224 repository: raw
225 .get("repository")
226 .and_then(|v| v.as_str())
227 .unwrap_or("")
228 .to_string(),
229 workflow: raw
230 .get("workflow")
231 .and_then(|v| v.as_str())
232 .unwrap_or("")
233 .to_string(),
234 ref_: raw
235 .get("ref")
236 .and_then(|v| v.as_str())
237 .unwrap_or("")
238 .to_string(),
239 sha: raw
240 .get("sha")
241 .and_then(|v| v.as_str())
242 .unwrap_or("")
243 .to_string(),
244 actor: raw
245 .get("actor")
246 .and_then(|v| v.as_str())
247 .unwrap_or("")
248 .to_string(),
249 })
250 } else {
251 None
252 };
253
254 Ok(OidcClaims {
255 subject,
256 issuer: expected_iss.to_string(),
257 audience,
258 expires_at,
259 issued_at,
260 email,
261 github,
262 raw,
263 })
264 }
265
266 fn fetch_jwks(&self, issuer: &OidcIssuer) -> Result<std::sync::Arc<JwksSet>, OidcError> {
267 let key = issuer.issuer_url().to_string();
268 {
269 let cache = self.jwks_cache.lock().unwrap();
270 if let Some(jwks) = cache.get(&key) {
271 return Ok(jwks.clone());
272 }
273 }
274 let url = issuer.jwks_url();
275 let resp = self
276 .http
277 .get(&url)
278 .send()
279 .map_err(|e| OidcError::JwksFetch(e.to_string()))?;
280 let body: serde_json::Value = resp
281 .json()
282 .map_err(|e| OidcError::JwksFetch(format!("JWKS parse: {e}")))?;
283 let mut jwks = JwksSet::default();
284 if let Some(keys) = body.get("keys").and_then(|v| v.as_array()) {
285 for k in keys {
286 let kid = k
287 .get("kid")
288 .and_then(|v| v.as_str())
289 .unwrap_or("")
290 .to_string();
291 let modulus = k
292 .get("n")
293 .and_then(|v| v.as_str())
294 .unwrap_or("")
295 .to_string();
296 let exponent = k
297 .get("e")
298 .and_then(|v| v.as_str())
299 .unwrap_or("")
300 .to_string();
301 if !kid.is_empty() {
302 jwks.keys.insert(kid, Jwk { modulus, exponent });
303 }
304 }
305 }
306 let arc = std::sync::Arc::new(jwks);
307 self.jwks_cache.lock().unwrap().insert(key, arc.clone());
308 Ok(arc)
309 }
310}
311
312#[derive(Debug, Default)]
313struct JwksSet {
314 keys: HashMap<String, Jwk>,
315}
316
317impl JwksSet {
318 fn get(&self, kid: &str) -> Option<&Jwk> {
319 self.keys.get(kid)
320 }
321}
322
323#[derive(Debug, Clone)]
324struct Jwk {
325 modulus: String,
326 exponent: String,
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 #[test]
334 fn github_issuer_has_known_jwks_url() {
335 assert_eq!(
336 OidcIssuer::GitHubActions.jwks_url(),
337 "https://token.actions.githubusercontent.com/.well-known/jwks"
338 );
339 }
340
341 #[test]
342 fn google_issuer_uses_googleapis_certs_endpoint() {
343 assert_eq!(
344 OidcIssuer::Google.jwks_url(),
345 "https://www.googleapis.com/oauth2/v3/certs"
346 );
347 }
348
349 #[test]
350 fn okta_issuer_includes_tenant_in_url() {
351 let i = OidcIssuer::Okta("https://example.okta.com".into());
352 assert_eq!(
353 i.jwks_url(),
354 "https://example.okta.com/oauth2/default/v1/keys"
355 );
356 }
357
358 #[test]
359 fn custom_issuer_passes_through() {
360 let i = OidcIssuer::Custom {
361 issuer: "https://custom.example.com".into(),
362 jwks_url: "https://custom.example.com/jwks".into(),
363 };
364 assert_eq!(i.issuer_url(), "https://custom.example.com");
365 assert_eq!(i.jwks_url(), "https://custom.example.com/jwks");
366 }
367
368 #[test]
369 fn verifier_rejects_garbage_token() {
370 let v = OidcVerifier::new();
371 let result = v.verify(&OidcIssuer::Google, "not a token");
372 assert!(result.is_err());
373 }
374}