Skip to main content

perfgate_server/
oidc.rs

1//! OIDC authentication support for perfgate-server.
2//!
3//! Supports multiple OIDC providers:
4//! - **GitHub Actions**: Maps `repository` claim to project/role.
5//! - **GitLab CI**: Maps `project_path` claim to project/role.
6//! - **Custom providers**: Configurable issuer, JWKS URL, and claim field.
7
8use 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// ---------------------------------------------------------------------------
19// Provider types
20// ---------------------------------------------------------------------------
21
22/// Identifies which OIDC provider issued the token.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum OidcProviderType {
25    /// GitHub Actions (`https://token.actions.githubusercontent.com`)
26    GitHub,
27    /// GitLab CI (`https://gitlab.com` or a self-managed instance)
28    GitLab,
29    /// A custom OIDC provider with a user-specified claim field.
30    Custom {
31        /// The JWT claim field that identifies the subject for mapping
32        /// (e.g. `"project_path"`, `"repository"`, `"sub"`).
33        claim_field: String,
34    },
35}
36
37// ---------------------------------------------------------------------------
38// Configuration
39// ---------------------------------------------------------------------------
40
41/// OIDC configuration for a single provider instance.
42#[derive(Debug, Clone)]
43pub struct OidcConfig {
44    /// URL to fetch JWKS from
45    /// (e.g. `https://token.actions.githubusercontent.com/.well-known/jwks`)
46    pub jwks_url: String,
47
48    /// Expected issuer
49    /// (e.g. `https://token.actions.githubusercontent.com`)
50    pub issuer: String,
51
52    /// Expected audience
53    pub audience: String,
54
55    /// Mapping from the identity claim value to (project_id, Role).
56    ///
57    /// For GitHub the key is `org/repo`, for GitLab it is `group/project`,
58    /// for custom providers it is whatever the configured claim field yields.
59    pub repo_mappings: HashMap<String, (String, Role)>,
60
61    /// The provider type driving claim extraction.
62    pub provider_type: OidcProviderType,
63}
64
65impl OidcConfig {
66    /// Creates a GitHub Actions OIDC configuration.
67    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    /// Creates a GitLab CI OIDC configuration for `gitlab.com`.
78    pub fn gitlab(audience: impl Into<String>) -> Self {
79        Self::gitlab_custom("https://gitlab.com", audience)
80    }
81
82    /// Creates a GitLab CI OIDC configuration for a self-managed instance.
83    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    /// Creates a custom provider OIDC configuration.
96    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    /// Adds a mapping entry.
114    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// ---------------------------------------------------------------------------
127// Provider runtime
128// ---------------------------------------------------------------------------
129
130/// A provider that fetches JWKS and validates tokens from a single issuer.
131#[derive(Clone)]
132pub struct OidcProvider {
133    config: OidcConfig,
134    jwks: Arc<RwLock<Option<JwkSet>>>,
135    client: Client,
136}
137
138/// GitHub Actions OIDC claims.
139#[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/// GitLab CI OIDC claims.
151#[allow(dead_code)]
152#[derive(Debug, Deserialize)]
153struct GitLabClaims {
154    iss: String,
155    aud: StringOrVec,
156    sub: String,
157    /// Full path of the project (e.g. `mygroup/myproject`).
158    project_path: String,
159    /// Namespace path (e.g. `mygroup`).
160    #[serde(default)]
161    namespace_path: Option<String>,
162    /// Git ref (e.g. `main`).
163    #[serde(rename = "ref")]
164    #[serde(default)]
165    git_ref: Option<String>,
166    /// Pipeline source (e.g. `push`, `web`, `schedule`).
167    #[serde(default)]
168    pipeline_source: Option<String>,
169    exp: u64,
170    iat: Option<u64>,
171}
172
173/// Generic claims used by custom providers.
174/// We deserialize the full payload as a map and then extract the configured claim.
175#[allow(dead_code)]
176#[derive(Debug, Deserialize)]
177struct GenericClaims {
178    iss: String,
179    sub: String,
180    exp: u64,
181    iat: Option<u64>,
182    /// All remaining fields captured for claim extraction.
183    #[serde(flatten)]
184    extra: HashMap<String, serde_json::Value>,
185}
186
187/// Helper: some OIDC providers encode `aud` as a string, others as an array.
188#[allow(dead_code)]
189#[derive(Debug, Deserialize)]
190#[serde(untagged)]
191enum StringOrVec {
192    Single(String),
193    Multiple(Vec<String>),
194}
195
196impl OidcProvider {
197    /// Creates a new OIDC provider and immediately attempts to fetch the JWKS.
198    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        // Initial fetch
211        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    /// Creates a provider with pre-loaded JWKS (for testing).
222    #[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    /// Returns the issuer URL for this provider.
232    pub fn issuer(&self) -> &str {
233        &self.config.issuer
234    }
235
236    /// Returns the provider type.
237    pub fn provider_type(&self) -> &OidcProviderType {
238        &self.config.provider_type
239    }
240
241    /// Refreshes the JWKS from the configured URL.
242    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    /// Validates an OIDC token and returns the corresponding mapped [`ApiKey`].
271    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        // Extract decoding key from JWKS cache
279        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        // Extract the mapping key from the configured claim field.
402        // First check `sub` (standard claim), then look in `extra`.
403        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// ---------------------------------------------------------------------------
442// Multi-provider registry
443// ---------------------------------------------------------------------------
444
445/// A registry of multiple [`OidcProvider`]s that tries each one in turn.
446#[derive(Clone, Default)]
447pub struct OidcRegistry {
448    providers: Vec<OidcProvider>,
449}
450
451impl OidcRegistry {
452    /// Creates an empty registry.
453    pub fn new() -> Self {
454        Self {
455            providers: Vec::new(),
456        }
457    }
458
459    /// Adds a provider to the registry.
460    pub fn add(&mut self, provider: OidcProvider) {
461        self.providers.push(provider);
462    }
463
464    /// Returns `true` when at least one provider is registered.
465    pub fn has_providers(&self) -> bool {
466        !self.providers.is_empty()
467    }
468
469    /// Validates a token against all registered providers, returning the first
470    /// successful result. If no provider succeeds, returns the last error.
471    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
492// ---------------------------------------------------------------------------
493// Helpers
494// ---------------------------------------------------------------------------
495
496fn 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// ---------------------------------------------------------------------------
525// Tests
526// ---------------------------------------------------------------------------
527
528#[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    // A pre-generated 2048-bit RSA PKCS#8 private key for tests.
537    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    // The corresponding public key modulus (base64url, no padding).
567    const TEST_RSA_N: &str = "2cm53_ep7DQYBcVem5PyqFdbiaKW1x1-FWHmIKe5ZUvFqb5Coxc8qFqMLJpIXZYfd-JSNXBxCT7XZVJXM3RIJIcq6PGikTtX-nHYPHLB_VY_mcn9YGrxg6R1yzn7I8pCw6Q36W9hmfuqa7hrmrbRWBf71g68FMeJVkcAn0awi-IFtwNovroVE_8HieW78znv5l5RdVQMOvgcWxY47AaLqZDJPSVxEEZef8jJLK1SDdhPFneyu5nLHFLl1eNoAYkNhCRgt18-46s5cSwG-BCPhHlF4RKORANpkvPe48xlN0A8TDsPEXRoEC6qVcds1KM7GL2HXLp3-cmYaR64Pl7QTQ";
568
569    // Public exponent (65537 in base64url).
570    const TEST_RSA_E: &str = "AQAB";
571
572    /// Helper: return (encoding_key, jwk_set) for test token signing and verification.
573    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    // -----------------------------------------------------------------------
665    // GitHub provider tests
666    // -----------------------------------------------------------------------
667
668    #[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        // Use a different kid to create the JWKS
721        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    // -----------------------------------------------------------------------
735    // GitLab provider tests
736    // -----------------------------------------------------------------------
737
738    #[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    // -----------------------------------------------------------------------
804    // Custom provider tests
805    // -----------------------------------------------------------------------
806
807    #[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        // The token has "team_slug" but not "nonexistent_claim"
850        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    // -----------------------------------------------------------------------
889    // Registry tests
890    // -----------------------------------------------------------------------
891
892    #[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        // Provider 1: GitHub (won't match a GitLab token)
898        let github_config =
899            OidcConfig::github("perfgate").add_mapping("org/repo", "gh-proj", Role::Viewer);
900
901        // Provider 2: GitLab (will match)
902        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        // A GitLab-style token should be picked up by the second provider
913        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        // Both providers will reject because the repo/project doesn't match
940        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    // -----------------------------------------------------------------------
956    // Config builder tests
957    // -----------------------------------------------------------------------
958
959    #[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}