git_oidc/
lib.rs

1//! # git-oidc
2//!
3//! `git-oidc` is a library for validating GitHub OIDC tokens.
4//!
5//! ## Features
6//!
7//! - Fetch JWKS from GitHub's OIDC provider
8//! - Validate GitHub OIDC tokens
9//! - Check token claims against expected values
10//!
11//! ## Usage
12//!
13//! ```rust
14//! use git_oidc::{fetch_jwks, validate_github_token};
15//! use std::sync::Arc;
16//! use tokio::sync::RwLock;
17//!
18//! #[tokio::main]
19//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
20//!     let jwks = fetch_jwks("https://token.actions.githubusercontent.com").await?;
21//!     let jwks = Arc::new(RwLock::new(jwks));
22//!     
23//!     let token = "your_github_oidc_token";
24//!     let expected_audience = "your_expected_audience";
25//!     
26//!     let claims = validate_github_token(token, jwks, expected_audience).await?;
27//!     println!("Validated claims: {:?}", claims);
28//!     
29//!     Ok(())
30//! }
31//! ```
32
33use color_eyre::eyre::{eyre, Result};
34use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
35use reqwest;
36use serde::{Serialize, Deserialize};
37use serde_json::Value;
38use std::sync::Arc;
39use tokio::sync::RwLock;
40use log::{error, info, warn, debug};
41
42#[derive(Debug, Serialize, Deserialize)]
43pub struct GitHubClaims {
44    sub: String,
45    repository: String,
46    repository_owner: String,
47    job_workflow_ref: String,
48    iat: u64,
49}
50
51/// Fetches the JSON Web Key Set (JWKS) from the specified OIDC provider URL.
52///
53/// This function sends a GET request to the `.well-known/jwks` endpoint of the provided OIDC URL
54/// to retrieve the JSON Web Key Set (JWKS), which contains the public keys used to verify OIDC tokens.
55///
56/// # Arguments
57///
58/// * `oidc_url` - A string slice that holds the base URL of the OIDC provider.
59///
60/// # Returns
61///
62/// * `Result<Value>` - A Result containing the JWKS as a serde_json::Value if successful,
63///   or an error if the fetch or parsing fails.
64///
65/// # Errors
66///
67/// This function will return an error if:
68/// * The HTTP request to fetch the JWKS fails
69/// * The response cannot be parsed as valid JSON
70///
71/// # Example
72///
73/// ```
74/// use git_oidc::fetch_jwks;
75/// use color_eyre::eyre::Result;
76///
77/// #[tokio::main]
78/// async fn main() -> Result<()> {
79///     let jwks = fetch_jwks("https://token.actions.githubusercontent.com").await?;
80///     println!("Fetched JWKS: {:?}", jwks);
81///     Ok(())
82/// }
83/// ```
84pub async fn fetch_jwks(oidc_url: &str) -> Result<Value> {
85    info!("Fetching JWKS from {}", oidc_url);
86    let client = reqwest::Client::new();
87    let jwks_url = format!("{}/.well-known/jwks", oidc_url);
88    match client.get(&jwks_url).send().await {
89        Ok(response) => {
90            match response.json().await {
91                Ok(jwks) => {
92                    info!("JWKS fetched successfully");
93                    Ok(jwks)
94                }
95                Err(e) => {
96                    error!("Failed to parse JWKS response: {:?}", e);
97                    Err(eyre!("Failed to parse JWKS"))
98                }
99            }
100        }
101        Err(e) => {
102            error!("Failed to fetch JWKS: {:?}", e);
103            Err(eyre!("Failed to fetch JWKS"))
104        }
105    }
106}
107
108/// Validates a GitHub OIDC token against the provided JSON Web Key Set (JWKS) and expected audience.
109///
110/// This function decodes and verifies the JSON Web Token (JWT), checks its claims against expected values,
111/// and ensures it was issued by the expected GitHub OIDC provider.
112///
113/// # Arguments
114///
115/// * `token` - A string slice that holds the GitHub OIDC token to validate.
116/// * `jwks` - An Arc<RwLock<Value>> containing the JSON Web Key Set (JWKS) used to verify the token's signature.
117/// * `expected_audience` - A string slice specifying the expected audience claim value.
118///
119/// # Returns
120///
121/// * `Result<GitHubClaims>` - A Result containing the validated GitHubClaims if successful,
122///   or an error if validation fails.
123///
124/// # Errors
125///
126/// This function will return an error if:
127/// * The token is not in a valid JWT format
128/// * The token's signature cannot be verified using the provided JWKS
129/// * The token's claims do not match the expected values (e.g., audience, issuer)
130/// * The token is not from the expected GitHub organization or repository (if set in environment)
131///
132/// # Example
133///
134/// ```
135/// use git_oidc::{fetch_jwks, validate_github_token};
136/// use std::sync::Arc;
137/// use tokio::sync::RwLock;
138/// use color_eyre::eyre::Result;
139///
140/// #[tokio::main]
141/// async fn main() -> Result<()> {
142///     let jwks = fetch_jwks("https://token.actions.githubusercontent.com").await?;
143///     let jwks = Arc::new(RwLock::new(jwks));
144///     
145///     let token = "your_github_oidc_token";
146///     let expected_audience = "your_expected_audience";
147///     
148///     let claims = validate_github_token(token, jwks, expected_audience).await?;
149///     println!("Validated claims: {:?}", claims);
150///     
151///     Ok(())
152/// }
153/// ```
154pub async fn validate_github_token(token: &str, jwks: Arc<RwLock<Value>>, expected_audience: &str) -> Result<GitHubClaims> {
155    debug!("Starting token validation");
156    if !token.starts_with("eyJ") {
157        warn!("Invalid token format received");
158        return Err(eyre!("Invalid token format. Expected a JWT."));
159    }
160
161    let jwks = jwks.read().await;
162    debug!("JWKS loaded");
163
164    let header = jsonwebtoken::decode_header(token).map_err(|e| {
165        eyre!(
166            "Failed to decode header: {}. Make sure you're using a valid JWT, not a PAT.",
167            e
168        )
169    })?;
170
171    let decoding_key = if let Some(kid) = header.kid {
172        let key = jwks["keys"]
173            .as_array()
174            .ok_or_else(|| eyre!("Invalid JWKS format"))?
175            .iter()
176            .find(|k| k["kid"].as_str() == Some(&kid))
177            .ok_or_else(|| eyre!("Matching key not found in JWKS"))?;
178
179        let modulus = key["n"].as_str().ok_or_else(|| eyre!("No 'n' in JWK"))?;
180        let exponent = key["e"].as_str().ok_or_else(|| eyre!("No 'e' in JWK"))?;
181
182        DecodingKey::from_rsa_components(modulus, exponent)
183            .map_err(|e| eyre!("Failed to create decoding key: {}", e))?
184    } else {
185        DecodingKey::from_secret("your_secret_key".as_ref())
186    };
187
188    let mut validation = Validation::new(Algorithm::RS256);
189    validation.set_audience(&[expected_audience]);
190
191    let token_data = decode::<GitHubClaims>(token, &decoding_key, &validation)
192        .map_err(|e| eyre!("Failed to decode token: {}", e))?;
193
194    let claims = token_data.claims;
195
196    if let Ok(org) = std::env::var("GITHUB_ORG") {
197        if claims.repository_owner != org {
198            warn!("Token organization mismatch. Expected: {}, Found: {}", org, claims.repository_owner);
199            return Err(eyre!("Token is not from the expected organization"));
200        }
201    }
202
203    if let Ok(repo) = std::env::var("GITHUB_REPO") {
204        debug!("Comparing repositories - Expected: {}, Found: {}", repo, claims.repository);
205        if claims.repository != repo {
206            warn!("Token repository mismatch. Expected: {}, Found: {}", repo, claims.repository);
207            return Err(eyre!("Token is not from the expected repository"));
208        }
209    }
210
211    debug!("Token validation completed successfully");
212    Ok(claims)
213}