github_oidc/lib.rs
1mod errors;
2pub use errors::GitHubOIDCError;
3use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
4use log::{debug, error, info, warn};
5use serde::{Deserialize, Serialize};
6
7/// Represents a JSON Web Key (JWK) used for token validation.
8///
9/// A JWK is a digital secure key used in secure web communications.
10/// It contains all the important details about the key, such as what it's for
11/// and how it works. This information helps websites verify users.
12#[derive(Debug, Serialize, Deserialize)]
13pub struct JWK {
14 /// Key type (e.g., "RSA")
15 pub kty: String,
16 /// Intended use of the key (e.g., "sig" for signature)
17 pub use_: Option<String>,
18 /// Unique identifier for the key
19 pub kid: String,
20 /// Algorithm used with this key (e.g., "RS256")
21 pub alg: Option<String>,
22 /// RSA public key modulus (base64url-encoded)
23 pub n: String,
24 /// RSA public key exponent (base64url-encoded)
25 pub e: String,
26 /// X.509 certificate chain (optional)
27 pub x5c: Option<Vec<String>>,
28 /// X.509 certificate SHA-1 thumbprint (optional)
29 pub x5t: Option<String>,
30 /// X.509 certificate SHA-256 thumbprint (optional)
31 pub x5t_s256: Option<String>,
32}
33
34/// Represents a set of JSON Web Keys (JWKS) used for GitHub token validation.
35///
36/// This structure is crucial for GitHub Actions authentication because:
37///
38/// 1. GitHub Key Rotation: GitHub rotates its keys for security,
39/// and having multiple keys allows your application to validate
40/// tokens continuously during these changes.
41///
42/// 2. Multiple Environments: Different GitHub environments (like production and development)
43/// might use different keys. A set of keys allows your app to work across these environments.
44///
45/// 3. Fallback Mechanism: If one key fails for any reason, your app can try others in the set.
46///
47/// Think of it like a key ring for a building manager. They don't just carry one key,
48/// but a set of keys for different doors or areas.
49#[derive(Debug, Serialize, Deserialize)]
50pub struct GithubJWKS {
51 /// Vector of JSON Web Keys
52 pub keys: Vec<JWK>,
53}
54
55/// Represents the claims contained in a GitHub Actions JWT (JSON Web Token).
56///
57/// When a GitHub Actions workflow runs, it receives a token with these claims.
58/// This struct helps decode and access the information from that token.
59#[derive(Debug, Serialize, Deserialize)]
60pub struct GitHubClaims {
61 /// The subject of the token (e.g. the GitHub Actions runner ID).
62 pub sub: String,
63
64 /// The full name of the repository.
65 pub repository: String,
66
67 /// The owner of the repository.
68 pub repository_owner: String,
69
70 /// A reference to the specific job and workflow.
71 pub job_workflow_ref: String,
72
73 /// The timestamp when the token was issued.
74 pub iat: u64,
75}
76
77/// Default URL for fetching GitHub OIDC tokens
78pub const DEFAULT_GITHUB_OIDC_URL: &str = "https://token.actions.githubusercontent.com";
79
80/// Fetches the JSON Web Key Set (JWKS) from the specified OIDC URL.
81///
82/// # Arguments
83///
84/// * `oidc_url` - The base URL of the OpenID Connect provider (GitHub by default)
85///
86/// # Returns
87///
88/// * `Result<GithubJWKS, GitHubOIDCError>` - A Result containing the fetched JWKS if successful,
89/// or an error if the fetch or parsing fails
90///
91/// # Example
92///
93/// ```
94/// use github_oidc::{fetch_jwks, DEFAULT_GITHUB_OIDC_URL};
95///
96/// #[tokio::main]
97/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
98/// let jwks = fetch_jwks(DEFAULT_GITHUB_OIDC_URL).await?;
99/// println!("JWKS: {:?}", jwks);
100/// Ok(())
101/// }
102/// ```
103pub async fn fetch_jwks(oidc_url: &str) -> Result<GithubJWKS, GitHubOIDCError> {
104 info!("Fetching JWKS from {}", oidc_url);
105 let client = reqwest::Client::new();
106 let jwks_url = format!("{}/.well-known/jwks", oidc_url);
107 match client.get(&jwks_url).send().await {
108 Ok(response) => match response.json::<GithubJWKS>().await {
109 Ok(jwks) => {
110 info!("JWKS fetched successfully");
111 Ok(jwks)
112 }
113 Err(e) => {
114 error!("Failed to parse JWKS response: {:?}", e);
115 Err(GitHubOIDCError::JWKSParseError(e.to_string()))
116 }
117 },
118 Err(e) => {
119 error!("Failed to fetch JWKS: {:?}", e);
120 Err(GitHubOIDCError::JWKSFetchError(e.to_string()))
121 }
122 }
123}
124
125/// Configuration options for GitHub OIDC token validation
126#[derive(Debug, Clone, Default)]
127pub struct GitHubOIDCConfig {
128 /// Expected audience for the token
129 pub audience: Option<String>,
130 /// Expected repository for the token
131 pub repository: Option<String>,
132 /// Expected repository owner for the token
133 pub repository_owner: Option<String>,
134}
135
136impl GithubJWKS {
137 /// Validates a GitHub OIDC token against the provided JSON Web Key Set (JWKS).
138 ///
139 /// This method performs several checks:
140 /// 1. Verifies the token format.
141 /// 2. Decodes the token header to find the key ID (kid).
142 /// 3. Locates the corresponding key in the JWKS.
143 /// 4. Validates the token signature and claims.
144 /// 5. Optionally checks the token's audience.
145 /// 6. Verifies the token's organization and repository claims against environment variables.
146 ///
147 /// # Arguments
148 ///
149 /// * `token` - The GitHub OIDC token to validate.
150 /// * `jwks` - An `Arc<RwLock<GithubJWKS>>` containing the JSON Web Key Set.
151 /// * `config` - A `GitHubOIDCConfig` struct containing validation options.
152 /// * `expected_audience` - An optional expected audience for the token.
153 ///
154 /// # Returns
155 ///
156 /// Returns a `Result<GitHubClaims, GitHubOIDCError>` containing the validated claims if successful,
157 /// or an error if validation fails.
158 ///
159 pub fn validate_github_token(
160 &self,
161 token: &str,
162 config: &GitHubOIDCConfig,
163 ) -> Result<GitHubClaims, GitHubOIDCError> {
164 debug!("Starting token validation");
165 if !token.starts_with("eyJ") {
166 warn!("Invalid token format received");
167 return Err(GitHubOIDCError::InvalidTokenFormat);
168 }
169
170 debug!("JWKS loaded");
171
172 let header = jsonwebtoken::decode_header(token).map_err(|e| {
173 GitHubOIDCError::HeaderDecodingError(format!(
174 "Failed to decode header: {}. Make sure you're using a valid JWT, not a PAT.",
175 e
176 ))
177 })?;
178
179 let decoding_key = if let Some(kid) = header.kid {
180 let key = self
181 .keys
182 .iter()
183 .find(|k| k.kid == kid)
184 .ok_or(GitHubOIDCError::KeyNotFound)?;
185
186 let modulus = key.n.as_str();
187 let exponent = key.e.as_str();
188
189 DecodingKey::from_rsa_components(modulus, exponent)
190 .map_err(|e| GitHubOIDCError::DecodingKeyCreationError(e.to_string()))?
191 } else {
192 DecodingKey::from_secret("your_secret_key".as_ref())
193 };
194
195 let mut validation = Validation::new(Algorithm::RS256);
196 if let Some(audience) = &config.audience {
197 validation.set_audience(&[audience]);
198 }
199
200 let token_data = decode::<GitHubClaims>(token, &decoding_key, &validation)
201 .map_err(|e| GitHubOIDCError::TokenDecodingError(e.to_string()))?;
202
203 let claims = token_data.claims;
204
205 if let Some(expected_owner) = &config.repository_owner {
206 if claims.repository_owner != *expected_owner {
207 warn!(
208 "Token organization mismatch. Expected: {}, Found: {}",
209 expected_owner, claims.repository_owner
210 );
211 return Err(GitHubOIDCError::OrganizationMismatch);
212 }
213 }
214
215 if let Some(expected_repo) = &config.repository {
216 debug!(
217 "Comparing repositories - Expected: {}, Found: {}",
218 expected_repo, claims.repository
219 );
220 if claims.repository != *expected_repo {
221 warn!(
222 "Token repository mismatch. Expected: {}, Found: {}",
223 expected_repo, claims.repository
224 );
225 return Err(GitHubOIDCError::RepositoryMismatch);
226 }
227 }
228
229 debug!("Token validation completed successfully");
230 Ok(claims)
231 }
232}