1use 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
18pub const GITHUB_ISSUER: &str = "https://token.actions.githubusercontent.com";
20
21pub const GITHUB_JWKS_URL: &str = "https://token.actions.githubusercontent.com/.well-known/jwks";
23
24const LEEWAY_SECONDS: u64 = 60;
26
27const DEFAULT_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
35pub struct Trust {
36 pub provider: String,
37 pub owner: String,
38}
39
40impl Trust {
41 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#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum OidcError {
90 Invalid(String),
93 Untrusted(String),
95 Unavailable(String),
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct VerifiedToken {
102 pub repository: String,
104 pub owner: String,
105 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
121pub 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 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 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 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}