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}