1use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header, jwk::JwkSet};
9use perfgate_auth::{ApiKey, Role};
10use perfgate_error::AuthError;
11use reqwest::Client;
12use serde::Deserialize;
13use std::collections::HashMap;
14use std::sync::Arc;
15use tokio::sync::RwLock;
16use tracing::{debug, info, warn};
17
18#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum OidcProviderType {
25 GitHub,
27 GitLab,
29 Custom {
31 claim_field: String,
34 },
35}
36
37#[derive(Debug, Clone)]
43pub struct OidcConfig {
44 pub jwks_url: String,
47
48 pub issuer: String,
51
52 pub audience: String,
54
55 pub repo_mappings: HashMap<String, (String, Role)>,
60
61 pub provider_type: OidcProviderType,
63}
64
65impl OidcConfig {
66 pub fn github(audience: impl Into<String>) -> Self {
68 Self {
69 jwks_url: "https://token.actions.githubusercontent.com/.well-known/jwks".to_string(),
70 issuer: "https://token.actions.githubusercontent.com".to_string(),
71 audience: audience.into(),
72 repo_mappings: HashMap::new(),
73 provider_type: OidcProviderType::GitHub,
74 }
75 }
76
77 pub fn gitlab(audience: impl Into<String>) -> Self {
79 Self::gitlab_custom("https://gitlab.com", audience)
80 }
81
82 pub fn gitlab_custom(issuer: impl Into<String>, audience: impl Into<String>) -> Self {
84 let issuer = issuer.into();
85 let jwks_url = format!("{}/-/jwks", issuer.trim_end_matches('/'));
86 Self {
87 jwks_url,
88 issuer,
89 audience: audience.into(),
90 repo_mappings: HashMap::new(),
91 provider_type: OidcProviderType::GitLab,
92 }
93 }
94
95 pub fn custom(
97 issuer: impl Into<String>,
98 jwks_url: impl Into<String>,
99 audience: impl Into<String>,
100 claim_field: impl Into<String>,
101 ) -> Self {
102 Self {
103 jwks_url: jwks_url.into(),
104 issuer: issuer.into(),
105 audience: audience.into(),
106 repo_mappings: HashMap::new(),
107 provider_type: OidcProviderType::Custom {
108 claim_field: claim_field.into(),
109 },
110 }
111 }
112
113 pub fn add_mapping(
115 mut self,
116 identity: impl Into<String>,
117 project_id: impl Into<String>,
118 role: Role,
119 ) -> Self {
120 self.repo_mappings
121 .insert(identity.into(), (project_id.into(), role));
122 self
123 }
124}
125
126#[derive(Clone)]
132pub struct OidcProvider {
133 config: OidcConfig,
134 jwks: Arc<RwLock<Option<JwkSet>>>,
135 client: Client,
136}
137
138#[allow(dead_code)]
140#[derive(Debug, Deserialize)]
141struct GithubClaims {
142 iss: String,
143 aud: StringOrVec,
144 sub: String,
145 repository: String,
146 exp: u64,
147 iat: Option<u64>,
148}
149
150#[allow(dead_code)]
152#[derive(Debug, Deserialize)]
153struct GitLabClaims {
154 iss: String,
155 aud: StringOrVec,
156 sub: String,
157 project_path: String,
159 #[serde(default)]
161 namespace_path: Option<String>,
162 #[serde(rename = "ref")]
164 #[serde(default)]
165 git_ref: Option<String>,
166 #[serde(default)]
168 pipeline_source: Option<String>,
169 exp: u64,
170 iat: Option<u64>,
171}
172
173#[allow(dead_code)]
176#[derive(Debug, Deserialize)]
177struct GenericClaims {
178 iss: String,
179 sub: String,
180 exp: u64,
181 iat: Option<u64>,
182 #[serde(flatten)]
184 extra: HashMap<String, serde_json::Value>,
185}
186
187#[allow(dead_code)]
189#[derive(Debug, Deserialize)]
190#[serde(untagged)]
191enum StringOrVec {
192 Single(String),
193 Multiple(Vec<String>),
194}
195
196impl OidcProvider {
197 pub async fn new(config: OidcConfig) -> Result<Self, AuthError> {
199 let client = Client::builder()
200 .timeout(std::time::Duration::from_secs(10))
201 .build()
202 .map_err(|e| AuthError::InvalidToken(format!("Failed to build HTTP client: {}", e)))?;
203
204 let provider = Self {
205 config,
206 jwks: Arc::new(RwLock::new(None)),
207 client,
208 };
209
210 if let Err(e) = provider.refresh_jwks().await {
212 warn!(
213 "Failed initial JWKS fetch from {}: {}",
214 provider.config.jwks_url, e
215 );
216 }
217
218 Ok(provider)
219 }
220
221 #[cfg(test)]
223 pub(crate) fn with_jwks(config: OidcConfig, jwks: JwkSet) -> Self {
224 Self {
225 config,
226 jwks: Arc::new(RwLock::new(Some(jwks))),
227 client: Client::new(),
228 }
229 }
230
231 pub fn issuer(&self) -> &str {
233 &self.config.issuer
234 }
235
236 pub fn provider_type(&self) -> &OidcProviderType {
238 &self.config.provider_type
239 }
240
241 pub async fn refresh_jwks(&self) -> Result<(), AuthError> {
243 debug!("Fetching JWKS from {}", self.config.jwks_url);
244 let res = self
245 .client
246 .get(&self.config.jwks_url)
247 .send()
248 .await
249 .map_err(|e| AuthError::InvalidToken(format!("JWKS fetch error: {}", e)))?;
250
251 if !res.status().is_success() {
252 return Err(AuthError::InvalidToken(format!(
253 "JWKS endpoint returned status {}",
254 res.status()
255 )));
256 }
257
258 let jwks: JwkSet = res
259 .json()
260 .await
261 .map_err(|e| AuthError::InvalidToken(format!("Failed to parse JWKS: {}", e)))?;
262
263 info!("Successfully loaded JWKS ({} keys)", jwks.keys.len());
264 let mut cache = self.jwks.write().await;
265 *cache = Some(jwks);
266
267 Ok(())
268 }
269
270 pub async fn validate_token(&self, token: &str) -> Result<ApiKey, AuthError> {
272 let header = decode_header(token).map_err(|e| AuthError::InvalidToken(e.to_string()))?;
273
274 let kid = header
275 .kid
276 .ok_or_else(|| AuthError::InvalidToken("Missing 'kid' in token header".to_string()))?;
277
278 let decoding_key = {
280 let cache = self.jwks.read().await;
281 let jwks = cache
282 .as_ref()
283 .ok_or_else(|| AuthError::InvalidToken("JWKS not loaded yet".to_string()))?;
284
285 let jwk = jwks.find(&kid).ok_or_else(|| {
286 AuthError::InvalidToken(format!("Key '{}' not found in JWKS", kid))
287 })?;
288
289 match &jwk.algorithm {
290 jsonwebtoken::jwk::AlgorithmParameters::RSA(rsa) => {
291 DecodingKey::from_rsa_components(&rsa.n, &rsa.e)
292 .map_err(|e| AuthError::InvalidToken(format!("Invalid RSA key: {}", e)))?
293 }
294 _ => {
295 return Err(AuthError::InvalidToken(
296 "Unsupported key algorithm (expected RSA)".to_string(),
297 ));
298 }
299 }
300 };
301
302 let mut validation = Validation::new(Algorithm::RS256);
303 validation.set_issuer(&[&self.config.issuer]);
304 validation.set_audience(&[&self.config.audience]);
305
306 match &self.config.provider_type {
307 OidcProviderType::GitHub => self.validate_github(token, &decoding_key, &validation),
308 OidcProviderType::GitLab => self.validate_gitlab(token, &decoding_key, &validation),
309 OidcProviderType::Custom { claim_field } => {
310 let field = claim_field.clone();
311 self.validate_custom(token, &decoding_key, &validation, &field)
312 }
313 }
314 }
315
316 fn validate_github(
317 &self,
318 token: &str,
319 key: &DecodingKey,
320 validation: &Validation,
321 ) -> Result<ApiKey, AuthError> {
322 let token_data =
323 decode::<GithubClaims>(token, key, validation).map_err(|e| match e.kind() {
324 jsonwebtoken::errors::ErrorKind::ExpiredSignature => AuthError::ExpiredToken,
325 _ => AuthError::InvalidToken(e.to_string()),
326 })?;
327
328 let claims = token_data.claims;
329
330 let (project_id, role) = self
331 .config
332 .repo_mappings
333 .get(&claims.repository)
334 .ok_or_else(|| {
335 AuthError::InvalidToken(format!(
336 "Repository '{}' is not authorized",
337 claims.repository
338 ))
339 })?;
340
341 Ok(build_api_key(
342 &claims.sub,
343 &format!("GitHub Actions ({})", claims.repository),
344 project_id,
345 *role,
346 claims.exp,
347 claims.iat,
348 ))
349 }
350
351 fn validate_gitlab(
352 &self,
353 token: &str,
354 key: &DecodingKey,
355 validation: &Validation,
356 ) -> Result<ApiKey, AuthError> {
357 let token_data =
358 decode::<GitLabClaims>(token, key, validation).map_err(|e| match e.kind() {
359 jsonwebtoken::errors::ErrorKind::ExpiredSignature => AuthError::ExpiredToken,
360 _ => AuthError::InvalidToken(e.to_string()),
361 })?;
362
363 let claims = token_data.claims;
364
365 let (project_id, role) = self
366 .config
367 .repo_mappings
368 .get(&claims.project_path)
369 .ok_or_else(|| {
370 AuthError::InvalidToken(format!(
371 "Project '{}' is not authorized",
372 claims.project_path
373 ))
374 })?;
375
376 Ok(build_api_key(
377 &claims.sub,
378 &format!("GitLab CI ({})", claims.project_path),
379 project_id,
380 *role,
381 claims.exp,
382 claims.iat,
383 ))
384 }
385
386 fn validate_custom(
387 &self,
388 token: &str,
389 key: &DecodingKey,
390 validation: &Validation,
391 claim_field: &str,
392 ) -> Result<ApiKey, AuthError> {
393 let token_data =
394 decode::<GenericClaims>(token, key, validation).map_err(|e| match e.kind() {
395 jsonwebtoken::errors::ErrorKind::ExpiredSignature => AuthError::ExpiredToken,
396 _ => AuthError::InvalidToken(e.to_string()),
397 })?;
398
399 let claims = token_data.claims;
400
401 let identity = if claim_field == "sub" {
404 claims.sub.clone()
405 } else {
406 claims
407 .extra
408 .get(claim_field)
409 .and_then(|v| v.as_str())
410 .map(|s| s.to_string())
411 .ok_or_else(|| {
412 AuthError::InvalidToken(format!("Claim '{}' not found in token", claim_field))
413 })?
414 };
415
416 let (project_id, role) = self.config.repo_mappings.get(&identity).ok_or_else(|| {
417 AuthError::InvalidToken(format!(
418 "Identity '{}' (from claim '{}') is not authorized",
419 identity, claim_field
420 ))
421 })?;
422
423 let provider_name = self
424 .config
425 .issuer
426 .split("//")
427 .nth(1)
428 .unwrap_or(&self.config.issuer);
429
430 Ok(build_api_key(
431 &claims.sub,
432 &format!("OIDC {} ({})", provider_name, identity),
433 project_id,
434 *role,
435 claims.exp,
436 claims.iat,
437 ))
438 }
439}
440
441#[derive(Clone, Default)]
447pub struct OidcRegistry {
448 providers: Vec<OidcProvider>,
449}
450
451impl OidcRegistry {
452 pub fn new() -> Self {
454 Self {
455 providers: Vec::new(),
456 }
457 }
458
459 pub fn add(&mut self, provider: OidcProvider) {
461 self.providers.push(provider);
462 }
463
464 pub fn has_providers(&self) -> bool {
466 !self.providers.is_empty()
467 }
468
469 pub async fn validate_token(&self, token: &str) -> Result<ApiKey, AuthError> {
472 let mut last_err = AuthError::InvalidToken("No OIDC providers configured".to_string());
473
474 for provider in &self.providers {
475 match provider.validate_token(token).await {
476 Ok(api_key) => return Ok(api_key),
477 Err(e) => {
478 debug!(
479 issuer = %provider.issuer(),
480 error = %e,
481 "OIDC provider did not accept token"
482 );
483 last_err = e;
484 }
485 }
486 }
487
488 Err(last_err)
489 }
490}
491
492fn build_api_key(
497 sub: &str,
498 name: &str,
499 project_id: &str,
500 role: Role,
501 exp: u64,
502 iat: Option<u64>,
503) -> ApiKey {
504 let expires_at = chrono::DateTime::<chrono::Utc>::from_timestamp(exp as i64, 0)
505 .unwrap_or_else(chrono::Utc::now);
506
507 let created_at = iat
508 .and_then(|iat| chrono::DateTime::<chrono::Utc>::from_timestamp(iat as i64, 0))
509 .unwrap_or_else(chrono::Utc::now);
510
511 ApiKey {
512 id: format!("oidc:{}", sub),
513 name: name.to_string(),
514 project_id: project_id.to_string(),
515 scopes: role.allowed_scopes(),
516 role,
517 benchmark_regex: None,
518 expires_at: Some(expires_at),
519 created_at,
520 last_used_at: None,
521 }
522}
523
524#[cfg(test)]
529mod tests {
530 use super::*;
531 use jsonwebtoken::jwk::{
532 AlgorithmParameters, CommonParameters, Jwk, KeyAlgorithm, PublicKeyUse, RSAKeyParameters,
533 };
534 use jsonwebtoken::{EncodingKey, Header, encode};
535
536 const TEST_RSA_PRIVATE_PEM: &[u8] = b"-----BEGIN PRIVATE KEY-----
538MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDZybnf96nsNBgF
539xV6bk/KoV1uJopbXHX4VYeYgp7llS8WpvkKjFzyoWowsmkhdlh934lI1cHEJPtdl
540UlczdEgkhyro8aKRO1f6cdg8csH9Vj+Zyf1gavGDpHXLOfsjykLDpDfpb2GZ+6pr
541uGuattFYF/vWDrwUx4lWRwCfRrCL4gW3A2i+uhUT/weJ5bvzOe/mXlF1VAw6+Bxb
542FjjsBoupkMk9JXEQRl5/yMksrVIN2E8Wd7K7mcscUuXV42gBiQ2EJGC3Xz7jqzlx
543LAb4EI+EeUXhEo5EA2mS897jzGU3QDxMOw8RdGgQLqpVx2zUozsYvYdcunf5yZhp
544Hrg+XtBNAgMBAAECggEAAbTiU7cNBEZcl0hwvRLPn+DNLrxIPiCPIHXEYiZllWxB
545lCwdOWFlgaYJFYXYVmnyGhcGvJ1flWGZf8PYxuZZ6UddgkJUpskNcSmYfKL02DGh
546pFFRsw39qNv3JQ+I+oiLe2L7Z7mCtdO7HVI/0ISjfmd/hrHKUYHpUMYIYx/alza7
547fFfBLxgjqwIM5wYL8WOrM7E6axsA7eFjj5Uad2nhgpRImTG0oLR49R0ldN8lYKZ6
5484cbD27JS7vw716PgHGT2S+JTh3+6dFyty/DkL6S3+pUVPbQUbhCQLQMVzf5QhI0J
549fDbdWXzF3fgBk9+uXHLIv9Spa9h+1/dv/2v8UMcMdQKBgQD9PivmY/Tcu8Sz3Rij
550blJ8b41jcmzlgx3bLh55b//3iY/GBMT67rRqOGFEEoC+EGrfQ72voWYr+XFb/qf7
551DoX6jfncGL1LSCDaS5BCc9Ekf9VX0Q56otF/12mpu8g5aYBDhJSd8JUcFtck+Dxz
5521Y6dTwtGG0720NTRE7Xy/N+wSwKBgQDcKLx2vOdIFkuAaXv+YUvxZ0jJDAcscNEm
553/wwGpcV+u3TBlqrfEEymfkca9YdoFoOO9u4g32EIe1k3ya/VxUuZxJhjiMBtpgh1
554CymYHEp7i4U/Sa5zRMulmuq8NZj3ZJANi8rSqHJ+UJiv+ofRu2Tdve9xuBpzMskz
555ZV6RqpaSxwKBgDc71itb5c43DgIE2RjcORV25ymnjWTJojtp5a+q4/NDh54y8Bui
5568KqyPVSxjG7n+cdUaQzjcPtqXnUoJ880LbimOrbslmzTAIdcL8yuohEJ6KhMqpHI
5577VSq0Rr6IAOVpSoUwq1oCb2kpawkkFrbW02oLddOoXxns+MeH3MuAEPdAoGAVlIi
558kuu+QyV6tP6m/zZm8F/uyeVNar9RQlj9/h1BMk+Nl9nbZVqesykP+CIM1WL+ci+f
559boQnJ4w1jwolR0v0OHY8ycn0qQlQh5O420s8aPRrakUZgViYAHadUu4w688iLC2D
560eNVTDvPK6jTwy+sNwWOXXp8wv7pJ6Tz1t2eLYkECgYB4tKuGpV4i2f2Ve5BNfAwQ
561Pct5tSWlUCbHRgaZ3hno6pR/WVs4HP6LmaA1pdwLL+3qG84OP3ARUzELEGRnNVT5
562+/xobF/tDl7gdKvRSFhOF08mxg7evm5yRt+GGkX1+SA3St3queXDAVG6NtrKju5j
563ggQxRhTX+ObL3zkIJahzUA==
564-----END PRIVATE KEY-----";
565
566 const TEST_RSA_N: &str = "2cm53_ep7DQYBcVem5PyqFdbiaKW1x1-FWHmIKe5ZUvFqb5Coxc8qFqMLJpIXZYfd-JSNXBxCT7XZVJXM3RIJIcq6PGikTtX-nHYPHLB_VY_mcn9YGrxg6R1yzn7I8pCw6Q36W9hmfuqa7hrmrbRWBf71g68FMeJVkcAn0awi-IFtwNovroVE_8HieW78znv5l5RdVQMOvgcWxY47AaLqZDJPSVxEEZef8jJLK1SDdhPFneyu5nLHFLl1eNoAYkNhCRgt18-46s5cSwG-BCPhHlF4RKORANpkvPe48xlN0A8TDsPEXRoEC6qVcds1KM7GL2HXLp3-cmYaR64Pl7QTQ";
568
569 const TEST_RSA_E: &str = "AQAB";
571
572 fn test_rsa_keys(kid: &str) -> (EncodingKey, JwkSet) {
574 let encoding_key = EncodingKey::from_rsa_pem(TEST_RSA_PRIVATE_PEM).unwrap();
575
576 let jwk = Jwk {
577 common: CommonParameters {
578 public_key_use: Some(PublicKeyUse::Signature),
579 key_operations: None,
580 key_algorithm: Some(KeyAlgorithm::RS256),
581 key_id: Some(kid.to_string()),
582 x509_url: None,
583 x509_chain: None,
584 x509_sha1_fingerprint: None,
585 x509_sha256_fingerprint: None,
586 },
587 algorithm: AlgorithmParameters::RSA(RSAKeyParameters {
588 key_type: Default::default(),
589 n: TEST_RSA_N.to_string(),
590 e: TEST_RSA_E.to_string(),
591 }),
592 };
593
594 let jwks = JwkSet { keys: vec![jwk] };
595
596 (encoding_key, jwks)
597 }
598
599 fn encode_github_token(encoding_key: &EncodingKey, kid: &str, repo: &str) -> String {
600 let mut header = Header::new(Algorithm::RS256);
601 header.kid = Some(kid.to_string());
602
603 let now = chrono::Utc::now().timestamp() as u64;
604 let claims = serde_json::json!({
605 "iss": "https://token.actions.githubusercontent.com",
606 "aud": "perfgate",
607 "sub": "repo:org/repo:ref:refs/heads/main",
608 "repository": repo,
609 "exp": now + 300,
610 "iat": now,
611 });
612
613 encode(&header, &claims, encoding_key).unwrap()
614 }
615
616 fn encode_gitlab_token(
617 encoding_key: &EncodingKey,
618 kid: &str,
619 project_path: &str,
620 issuer: &str,
621 ) -> String {
622 let mut header = Header::new(Algorithm::RS256);
623 header.kid = Some(kid.to_string());
624
625 let now = chrono::Utc::now().timestamp() as u64;
626 let claims = serde_json::json!({
627 "iss": issuer,
628 "aud": "perfgate",
629 "sub": format!("project_path:{}:ref_type:branch:ref:main", project_path),
630 "project_path": project_path,
631 "namespace_path": project_path.split('/').next().unwrap_or(""),
632 "ref": "main",
633 "pipeline_source": "push",
634 "exp": now + 300,
635 "iat": now,
636 });
637
638 encode(&header, &claims, encoding_key).unwrap()
639 }
640
641 fn encode_custom_token(
642 encoding_key: &EncodingKey,
643 kid: &str,
644 issuer: &str,
645 claim_field: &str,
646 claim_value: &str,
647 ) -> String {
648 let mut header = Header::new(Algorithm::RS256);
649 header.kid = Some(kid.to_string());
650
651 let now = chrono::Utc::now().timestamp() as u64;
652 let claims = serde_json::json!({
653 "iss": issuer,
654 "aud": "perfgate",
655 "sub": format!("custom:{}", claim_value),
656 claim_field: claim_value,
657 "exp": now + 300,
658 "iat": now,
659 });
660
661 encode(&header, &claims, encoding_key).unwrap()
662 }
663
664 #[tokio::test]
669 async fn test_github_oidc_valid_token() {
670 let kid = "test-key-1";
671 let (enc, jwks) = test_rsa_keys(kid);
672
673 let config = OidcConfig::github("perfgate").add_mapping(
674 "EffortlessMetrics/perfgate",
675 "perfgate-oss",
676 Role::Contributor,
677 );
678
679 let provider = OidcProvider::with_jwks(config, jwks);
680 let token = encode_github_token(&enc, kid, "EffortlessMetrics/perfgate");
681
682 let api_key = provider.validate_token(&token).await.unwrap();
683 assert_eq!(api_key.project_id, "perfgate-oss");
684 assert_eq!(api_key.role, Role::Contributor);
685 assert!(api_key.id.starts_with("oidc:"));
686 assert!(api_key.name.contains("GitHub Actions"));
687 assert!(api_key.name.contains("EffortlessMetrics/perfgate"));
688 }
689
690 #[tokio::test]
691 async fn test_github_oidc_unauthorized_repo() {
692 let kid = "test-key-1";
693 let (enc, jwks) = test_rsa_keys(kid);
694
695 let config = OidcConfig::github("perfgate").add_mapping(
696 "EffortlessMetrics/perfgate",
697 "perfgate-oss",
698 Role::Contributor,
699 );
700
701 let provider = OidcProvider::with_jwks(config, jwks);
702 let token = encode_github_token(&enc, kid, "evil-org/evil-repo");
703
704 let err = provider.validate_token(&token).await.unwrap_err();
705 match err {
706 AuthError::InvalidToken(msg) => {
707 assert!(msg.contains("not authorized"), "got: {}", msg);
708 }
709 other => panic!("Expected InvalidToken, got: {:?}", other),
710 }
711 }
712
713 #[tokio::test]
714 async fn test_github_oidc_unknown_kid() {
715 let kid = "test-key-1";
716 let (enc, _jwks) = test_rsa_keys(kid);
717
718 let config = OidcConfig::github("perfgate").add_mapping("org/repo", "proj", Role::Viewer);
719
720 let (_enc2, other_jwks) = test_rsa_keys("different-key");
722 let provider = OidcProvider::with_jwks(config, other_jwks);
723 let token = encode_github_token(&enc, kid, "org/repo");
724
725 let err = provider.validate_token(&token).await.unwrap_err();
726 match err {
727 AuthError::InvalidToken(msg) => {
728 assert!(msg.contains("not found in JWKS"), "got: {}", msg);
729 }
730 other => panic!("Expected InvalidToken, got: {:?}", other),
731 }
732 }
733
734 #[tokio::test]
739 async fn test_gitlab_oidc_valid_token() {
740 let kid = "test-key-1";
741 let (enc, jwks) = test_rsa_keys(kid);
742
743 let config = OidcConfig::gitlab("perfgate").add_mapping(
744 "mygroup/myproject",
745 "myproject-prod",
746 Role::Promoter,
747 );
748
749 let provider = OidcProvider::with_jwks(config, jwks);
750 let token = encode_gitlab_token(&enc, kid, "mygroup/myproject", "https://gitlab.com");
751
752 let api_key = provider.validate_token(&token).await.unwrap();
753 assert_eq!(api_key.project_id, "myproject-prod");
754 assert_eq!(api_key.role, Role::Promoter);
755 assert!(api_key.id.starts_with("oidc:"));
756 assert!(api_key.name.contains("GitLab CI"));
757 assert!(api_key.name.contains("mygroup/myproject"));
758 }
759
760 #[tokio::test]
761 async fn test_gitlab_oidc_self_managed() {
762 let kid = "test-key-1";
763 let (enc, jwks) = test_rsa_keys(kid);
764
765 let issuer = "https://gitlab.example.com";
766 let config = OidcConfig::gitlab_custom(issuer, "perfgate").add_mapping(
767 "team/repo",
768 "internal-proj",
769 Role::Admin,
770 );
771
772 assert_eq!(config.jwks_url, "https://gitlab.example.com/-/jwks");
773 assert_eq!(config.issuer, issuer);
774
775 let provider = OidcProvider::with_jwks(config, jwks);
776 let token = encode_gitlab_token(&enc, kid, "team/repo", issuer);
777
778 let api_key = provider.validate_token(&token).await.unwrap();
779 assert_eq!(api_key.project_id, "internal-proj");
780 assert_eq!(api_key.role, Role::Admin);
781 }
782
783 #[tokio::test]
784 async fn test_gitlab_oidc_unauthorized_project() {
785 let kid = "test-key-1";
786 let (enc, jwks) = test_rsa_keys(kid);
787
788 let config =
789 OidcConfig::gitlab("perfgate").add_mapping("mygroup/myproject", "proj", Role::Viewer);
790
791 let provider = OidcProvider::with_jwks(config, jwks);
792 let token = encode_gitlab_token(&enc, kid, "evil/project", "https://gitlab.com");
793
794 let err = provider.validate_token(&token).await.unwrap_err();
795 match err {
796 AuthError::InvalidToken(msg) => {
797 assert!(msg.contains("not authorized"), "got: {}", msg);
798 }
799 other => panic!("Expected InvalidToken, got: {:?}", other),
800 }
801 }
802
803 #[tokio::test]
808 async fn test_custom_oidc_valid_token() {
809 let kid = "test-key-1";
810 let (enc, jwks) = test_rsa_keys(kid);
811
812 let config = OidcConfig::custom(
813 "https://auth.example.com",
814 "https://auth.example.com/.well-known/jwks.json",
815 "perfgate",
816 "team_slug",
817 )
818 .add_mapping("platform-team", "platform-proj", Role::Contributor);
819
820 let provider = OidcProvider::with_jwks(config, jwks);
821 let token = encode_custom_token(
822 &enc,
823 kid,
824 "https://auth.example.com",
825 "team_slug",
826 "platform-team",
827 );
828
829 let api_key = provider.validate_token(&token).await.unwrap();
830 assert_eq!(api_key.project_id, "platform-proj");
831 assert_eq!(api_key.role, Role::Contributor);
832 assert!(api_key.name.contains("auth.example.com"));
833 }
834
835 #[tokio::test]
836 async fn test_custom_oidc_missing_claim() {
837 let kid = "test-key-1";
838 let (enc, jwks) = test_rsa_keys(kid);
839
840 let config = OidcConfig::custom(
841 "https://auth.example.com",
842 "https://auth.example.com/.well-known/jwks.json",
843 "perfgate",
844 "nonexistent_claim",
845 )
846 .add_mapping("val", "proj", Role::Viewer);
847
848 let provider = OidcProvider::with_jwks(config, jwks);
849 let token = encode_custom_token(&enc, kid, "https://auth.example.com", "team_slug", "val");
851
852 let err = provider.validate_token(&token).await.unwrap_err();
853 match err {
854 AuthError::InvalidToken(msg) => {
855 assert!(msg.contains("not found in token"), "got: {}", msg);
856 }
857 other => panic!("Expected InvalidToken, got: {:?}", other),
858 }
859 }
860
861 #[tokio::test]
862 async fn test_custom_oidc_sub_claim() {
863 let kid = "test-key-1";
864 let (enc, jwks) = test_rsa_keys(kid);
865
866 let config = OidcConfig::custom(
867 "https://auth.example.com",
868 "https://auth.example.com/.well-known/jwks.json",
869 "perfgate",
870 "sub",
871 )
872 .add_mapping("custom:my-identity", "sub-proj", Role::Viewer);
873
874 let provider = OidcProvider::with_jwks(config, jwks);
875 let token = encode_custom_token(
876 &enc,
877 kid,
878 "https://auth.example.com",
879 "team_slug",
880 "my-identity",
881 );
882
883 let api_key = provider.validate_token(&token).await.unwrap();
884 assert_eq!(api_key.project_id, "sub-proj");
885 assert_eq!(api_key.role, Role::Viewer);
886 }
887
888 #[tokio::test]
893 async fn test_registry_tries_all_providers() {
894 let kid = "test-key-1";
895 let (enc, jwks) = test_rsa_keys(kid);
896
897 let github_config =
899 OidcConfig::github("perfgate").add_mapping("org/repo", "gh-proj", Role::Viewer);
900
901 let gitlab_config = OidcConfig::gitlab("perfgate").add_mapping(
903 "mygroup/myproject",
904 "gl-proj",
905 Role::Contributor,
906 );
907
908 let mut registry = OidcRegistry::new();
909 registry.add(OidcProvider::with_jwks(github_config, jwks.clone()));
910 registry.add(OidcProvider::with_jwks(gitlab_config, jwks));
911
912 let token = encode_gitlab_token(&enc, kid, "mygroup/myproject", "https://gitlab.com");
914
915 let api_key = registry.validate_token(&token).await.unwrap();
916 assert_eq!(api_key.project_id, "gl-proj");
917 assert_eq!(api_key.role, Role::Contributor);
918 }
919
920 #[tokio::test]
921 async fn test_registry_no_providers() {
922 let registry = OidcRegistry::new();
923 assert!(!registry.has_providers());
924
925 let err = registry.validate_token("some.jwt.token").await.unwrap_err();
926 match err {
927 AuthError::InvalidToken(msg) => {
928 assert!(msg.contains("No OIDC providers"), "got: {}", msg);
929 }
930 other => panic!("Expected InvalidToken, got: {:?}", other),
931 }
932 }
933
934 #[tokio::test]
935 async fn test_registry_returns_last_error_when_all_fail() {
936 let kid = "test-key-1";
937 let (enc, jwks) = test_rsa_keys(kid);
938
939 let github_config =
941 OidcConfig::github("perfgate").add_mapping("org/repo", "proj", Role::Viewer);
942 let gitlab_config =
943 OidcConfig::gitlab("perfgate").add_mapping("group/project", "proj", Role::Viewer);
944
945 let mut registry = OidcRegistry::new();
946 registry.add(OidcProvider::with_jwks(github_config, jwks.clone()));
947 registry.add(OidcProvider::with_jwks(gitlab_config, jwks));
948
949 let token = encode_github_token(&enc, kid, "unknown/repo");
950
951 let err = registry.validate_token(&token).await.unwrap_err();
952 assert!(matches!(err, AuthError::InvalidToken(_)));
953 }
954
955 #[test]
960 fn test_github_config_defaults() {
961 let config = OidcConfig::github("perfgate");
962 assert_eq!(
963 config.jwks_url,
964 "https://token.actions.githubusercontent.com/.well-known/jwks"
965 );
966 assert_eq!(config.issuer, "https://token.actions.githubusercontent.com");
967 assert_eq!(config.audience, "perfgate");
968 assert_eq!(config.provider_type, OidcProviderType::GitHub);
969 assert!(config.repo_mappings.is_empty());
970 }
971
972 #[test]
973 fn test_gitlab_config_defaults() {
974 let config = OidcConfig::gitlab("perfgate");
975 assert_eq!(config.jwks_url, "https://gitlab.com/-/jwks");
976 assert_eq!(config.issuer, "https://gitlab.com");
977 assert_eq!(config.audience, "perfgate");
978 assert_eq!(config.provider_type, OidcProviderType::GitLab);
979 }
980
981 #[test]
982 fn test_gitlab_custom_config_trailing_slash() {
983 let config = OidcConfig::gitlab_custom("https://gitlab.example.com/", "perfgate");
984 assert_eq!(config.jwks_url, "https://gitlab.example.com/-/jwks");
985 assert_eq!(config.issuer, "https://gitlab.example.com/");
986 }
987
988 #[test]
989 fn test_custom_config() {
990 let config = OidcConfig::custom(
991 "https://auth.example.com",
992 "https://auth.example.com/jwks",
993 "my-audience",
994 "org_id",
995 );
996 assert_eq!(config.issuer, "https://auth.example.com");
997 assert_eq!(config.jwks_url, "https://auth.example.com/jwks");
998 assert_eq!(config.audience, "my-audience");
999 assert_eq!(
1000 config.provider_type,
1001 OidcProviderType::Custom {
1002 claim_field: "org_id".to_string()
1003 }
1004 );
1005 }
1006
1007 #[test]
1008 fn test_config_add_mapping() {
1009 let config = OidcConfig::github("aud")
1010 .add_mapping("org/repo1", "proj1", Role::Viewer)
1011 .add_mapping("org/repo2", "proj2", Role::Admin);
1012
1013 assert_eq!(config.repo_mappings.len(), 2);
1014 assert_eq!(
1015 config.repo_mappings.get("org/repo1"),
1016 Some(&("proj1".to_string(), Role::Viewer))
1017 );
1018 assert_eq!(
1019 config.repo_mappings.get("org/repo2"),
1020 Some(&("proj2".to_string(), Role::Admin))
1021 );
1022 }
1023}