Skip to main content

tuff_console/
oidc.rs

1//! Publishing from GitHub Actions without a secret (RFC-108 D5).
2//!
3//! A job asks the runner for an OIDC token whose audience is the console's
4//! URL and sends it as `Authorization: Bearer`. [`Verifier::verify`] checks
5//! the token's signature against the issuer's published keys, its issuer,
6//! audience, and validity window, and that the repository's owner is one the
7//! console trusts. The caller then binds the report to the token's
8//! `repository` claim.
9
10use std::str::FromStr;
11use std::time::{Duration, Instant};
12
13use jsonwebtoken::jwk::JwkSet;
14use jsonwebtoken::{Algorithm, DecodingKey, Validation};
15use serde::{Deserialize, Serialize};
16use tuff_core::error::{Result, TuffError};
17
18/// `iss` of a GitHub Actions token.
19pub const GITHUB_ISSUER: &str = "https://token.actions.githubusercontent.com";
20
21/// Where GitHub publishes the keys that sign its tokens.
22pub const GITHUB_JWKS_URL: &str = "https://token.actions.githubusercontent.com/.well-known/jwks";
23
24/// Allowed clock difference in either direction for `exp` and `nbf`.
25const LEEWAY_SECONDS: u64 = 60;
26
27/// How long the cached keys are trusted to be complete before an unknown
28/// `kid` triggers another fetch. Bounds the requests a forged `kid` can cause.
29const DEFAULT_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
30
31/// A source of tokens the console accepts: `github:<owner>`. The provider
32/// prefix is kept so GitHub Enterprise Server and GitLab can be added
33/// without changing how trusts are stored or shown.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
35pub struct Trust {
36    pub provider: String,
37    pub owner: String,
38}
39
40impl Trust {
41    /// The repository host a provider's `repository` claim belongs to.
42    pub fn host(&self) -> &'static str {
43        "github.com"
44    }
45}
46
47impl FromStr for Trust {
48    type Err = TuffError;
49
50    fn from_str(text: &str) -> Result<Self> {
51        let Some((provider, owner)) = text.split_once(':') else {
52            return Err(TuffError::usage(format!(
53                "'{text}' is not a trust: expected <provider>:<owner>"
54            ))
55            .with_hint("for example --trust github:acme"));
56        };
57        if provider != "github" {
58            return Err(TuffError::unsupported(format!(
59                "trust provider '{provider}' is not supported"
60            ))
61            .with_hint("only 'github' is supported, as --trust github:<owner>"));
62        }
63        let valid = !owner.is_empty()
64            && owner.len() <= 100
65            && owner
66                .chars()
67                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
68        if !valid {
69            return Err(
70                TuffError::usage(format!("'{owner}' is not a valid GitHub owner name"))
71                    .with_hint("use the organisation or user name, such as --trust github:acme"),
72            );
73        }
74        Ok(Self {
75            provider: provider.to_string(),
76            owner: owner.to_string(),
77        })
78    }
79}
80
81impl std::fmt::Display for Trust {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        write!(f, "{}:{}", self.provider, self.owner)
84    }
85}
86
87/// Why a token was not accepted.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum OidcError {
90    /// The token is malformed, unsigned by the issuer, expired, or meant for
91    /// another audience.
92    Invalid(String),
93    /// The token is genuine, but its repository owner is not trusted.
94    Untrusted(String),
95    /// The issuer's keys could not be fetched.
96    Unavailable(String),
97}
98
99/// The claims of an accepted token the console uses.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct VerifiedToken {
102    /// `owner/name`, as GitHub writes the `repository` claim.
103    pub repository: String,
104    pub owner: String,
105    /// The repository as a report names it: host, owner, and name.
106    pub report_repository: String,
107}
108
109#[derive(Debug, Deserialize)]
110struct Claims {
111    repository: String,
112    repository_owner: String,
113}
114
115#[derive(Default)]
116struct KeyCache {
117    set: Option<JwkSet>,
118    fetched_at: Option<Instant>,
119}
120
121/// Verifies GitHub Actions tokens for one console.
122pub struct Verifier {
123    trusts: Vec<Trust>,
124    issuer: String,
125    jwks_url: String,
126    audiences: [String; 2],
127    refresh_interval: Duration,
128    client: reqwest::Client,
129    cache: tokio::sync::Mutex<KeyCache>,
130}
131
132impl Verifier {
133    /// Trust `trusts` for tokens whose audience is `audience`, the console's
134    /// public URL. A trailing slash on the URL does not matter.
135    pub fn new(trusts: Vec<Trust>, audience: &str) -> Result<Self> {
136        let audience = audience.trim_end_matches('/');
137        let client = reqwest::Client::builder()
138            .timeout(Duration::from_secs(10))
139            .build()
140            .map_err(|error| {
141                TuffError::source_failed(format!("cannot build an HTTP client: {error}"))
142            })?;
143        Ok(Self {
144            trusts,
145            issuer: GITHUB_ISSUER.to_string(),
146            jwks_url: GITHUB_JWKS_URL.to_string(),
147            audiences: [audience.to_string(), format!("{audience}/")],
148            refresh_interval: DEFAULT_REFRESH_INTERVAL,
149            client,
150            cache: tokio::sync::Mutex::new(KeyCache::default()),
151        })
152    }
153
154    /// Accept tokens from another issuer, such as GitHub Enterprise Server
155    /// or a test double.
156    pub fn with_issuer(mut self, issuer: &str, jwks_url: &str) -> Self {
157        self.issuer = issuer.to_string();
158        self.jwks_url = jwks_url.to_string();
159        self
160    }
161
162    /// Change how long fetched keys are kept before an unknown `kid`
163    /// fetches them again.
164    pub fn with_refresh_interval(mut self, interval: Duration) -> Self {
165        self.refresh_interval = interval;
166        self
167    }
168
169    pub fn trusts(&self) -> &[Trust] {
170        &self.trusts
171    }
172
173    pub fn audience(&self) -> &str {
174        &self.audiences[0]
175    }
176
177    pub async fn verify(&self, token: &str) -> std::result::Result<VerifiedToken, OidcError> {
178        let header = jsonwebtoken::decode_header(token)
179            .map_err(|error| OidcError::Invalid(format!("the token is not a JWT: {error}")))?;
180        if header.alg != Algorithm::RS256 {
181            return Err(OidcError::Invalid(format!(
182                "the token is signed with {:?}, and only RS256 is accepted",
183                header.alg
184            )));
185        }
186        let kid = header
187            .kid
188            .ok_or_else(|| OidcError::Invalid("the token names no signing key".to_string()))?;
189        let key = self.key_for(&kid).await?;
190
191        let mut validation = Validation::new(Algorithm::RS256);
192        validation.leeway = LEEWAY_SECONDS;
193        validation.validate_nbf = true;
194        validation.set_issuer(&[self.issuer.as_str()]);
195        validation.set_audience(&self.audiences);
196        validation.set_required_spec_claims(&["exp", "iss", "aud"]);
197        let data = jsonwebtoken::decode::<Claims>(token, &key, &validation)
198            .map_err(|error| OidcError::Invalid(format!("the token is not valid: {error}")))?;
199        let claims = data.claims;
200
201        let trust = self
202            .trusts
203            .iter()
204            .find(|trust| trust.owner.eq_ignore_ascii_case(&claims.repository_owner))
205            .ok_or_else(|| {
206                OidcError::Untrusted(format!(
207                    "this console does not trust GitHub owner '{}'",
208                    claims.repository_owner
209                ))
210            })?;
211        Ok(VerifiedToken {
212            report_repository: format!("{}/{}", trust.host(), claims.repository),
213            repository: claims.repository,
214            owner: claims.repository_owner,
215        })
216    }
217
218    async fn key_for(&self, kid: &str) -> std::result::Result<DecodingKey, OidcError> {
219        let mut cache = self.cache.lock().await;
220        if let Some(key) = find_key(cache.set.as_ref(), kid)? {
221            return Ok(key);
222        }
223        let stale = cache
224            .fetched_at
225            .is_none_or(|fetched| fetched.elapsed() >= self.refresh_interval);
226        if stale {
227            let set = self.fetch().await?;
228            cache.set = Some(set);
229            cache.fetched_at = Some(Instant::now());
230        }
231        find_key(cache.set.as_ref(), kid)?.ok_or_else(|| {
232            OidcError::Invalid(format!("the issuer publishes no signing key '{kid}'"))
233        })
234    }
235
236    async fn fetch(&self) -> std::result::Result<JwkSet, OidcError> {
237        let unavailable = |reason: String| {
238            OidcError::Unavailable(format!(
239                "cannot fetch the signing keys from {}: {reason}",
240                self.jwks_url
241            ))
242        };
243        let response = self
244            .client
245            .get(&self.jwks_url)
246            .send()
247            .await
248            .map_err(|error| unavailable(error.to_string()))?;
249        if !response.status().is_success() {
250            return Err(unavailable(format!("HTTP {}", response.status())));
251        }
252        response
253            .json::<JwkSet>()
254            .await
255            .map_err(|error| unavailable(error.to_string()))
256    }
257}
258
259fn find_key(
260    set: Option<&JwkSet>,
261    kid: &str,
262) -> std::result::Result<Option<DecodingKey>, OidcError> {
263    let Some(jwk) = set.and_then(|set| set.find(kid)) else {
264        return Ok(None);
265    };
266    DecodingKey::from_jwk(jwk)
267        .map(Some)
268        .map_err(|error| OidcError::Invalid(format!("signing key '{kid}' is unusable: {error}")))
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use tuff_core::error::ErrorKind;
275
276    #[test]
277    fn trusts_parse_with_a_provider_prefix() {
278        let trust: Trust = "github:acme".parse().unwrap();
279        assert_eq!(trust.provider, "github");
280        assert_eq!(trust.owner, "acme");
281        assert_eq!(trust.to_string(), "github:acme");
282        assert_eq!(trust.host(), "github.com");
283    }
284
285    #[test]
286    fn a_malformed_trust_is_refused_with_a_hint() {
287        for bad in ["acme", "github:", "github:a/b", "github:has space"] {
288            let error = bad.parse::<Trust>().unwrap_err();
289            assert_eq!(error.kind(), ErrorKind::Usage, "{bad}");
290            assert!(error.hint().unwrap().contains("github:"), "{bad}");
291        }
292        let error = "gitlab:acme".parse::<Trust>().unwrap_err();
293        assert_eq!(error.kind(), ErrorKind::Unsupported);
294    }
295}