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}