1use std::collections::BTreeMap;
4use std::fs;
5use std::sync::{Arc, Mutex, OnceLock};
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7
8use anyhow::{Context, Result, anyhow, bail, ensure};
9use base64::Engine as _;
10use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
11use ring::rand::SystemRandom;
12use ring::signature::{RSA_PKCS1_SHA256, RsaKeyPair};
13use serde::{Deserialize, Serialize};
14use url::Url;
15
16use mj_core::config::{
17 GithubAppConfig, GithubPermissionLevel, GithubPermissionSet, ProjectBundle, ProjectRepository,
18 valid_github_owner_login,
19};
20use mj_core::remote_git::{github_owner_repo, resolve_repository};
21
22use super::{Controller, controller_github_token};
23use crate::targets::{CommandExecutor, ProcessExecutor};
24
25const TOKEN_REFRESH_THRESHOLD: Duration = Duration::from_secs(10 * 60);
26const JWT_BACKDATE_SECS: i64 = 60;
27const JWT_LIFETIME_SECS: i64 = 9 * 60;
28const GITHUB_API_VERSION: &str = "2022-11-28";
29
30type Clock = Arc<dyn Fn() -> SystemTime + Send + Sync>;
31
32pub(crate) struct GithubAppTokenProvider {
35 config: GithubAppConfig,
36 http: reqwest::Client,
37 api_base: Url,
38 now: Clock,
39 signing_key: tokio::sync::OnceCell<Arc<RsaKeyPair>>,
40 installations: Mutex<BTreeMap<String, Arc<tokio::sync::Mutex<Option<u64>>>>>,
41 tokens: Mutex<BTreeMap<TokenCacheKey, Arc<tokio::sync::Mutex<Option<TokenEntry>>>>>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
45struct TokenCacheKey {
46 installation_id: u64,
47 repositories: Vec<String>,
48 permissions: Option<GithubPermissionSet>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub(super) struct InstallationScope {
53 pub(super) installation_id: u64,
54 pub(super) repositories: Vec<String>,
55}
56
57#[derive(Clone)]
58struct TokenEntry {
59 token: String,
60 expires_at: SystemTime,
61}
62
63#[derive(Serialize)]
64struct JwtHeader {
65 alg: &'static str,
66 typ: &'static str,
67}
68
69#[derive(Serialize)]
70struct JwtClaims {
71 iss: String,
72 iat: i64,
73 exp: i64,
74}
75
76#[derive(Deserialize)]
77struct InstallationResponse {
78 id: u64,
79 permissions: Option<BTreeMap<String, String>>,
80}
81
82#[derive(Deserialize)]
83struct AccessTokenResponse {
84 token: String,
85 expires_at: String,
86 permissions: Option<BTreeMap<String, String>>,
87}
88
89impl GithubAppTokenProvider {
90 fn new(config: GithubAppConfig) -> Result<Self> {
91 config.validate()?;
92 let http = reqwest::Client::builder()
93 .user_agent(concat!("Mjolnir/", env!("CARGO_PKG_VERSION")))
94 .timeout(Duration::from_secs(30))
95 .build()
96 .context("build GitHub App HTTP client")?;
97 Ok(Self {
98 config,
99 http,
100 api_base: Url::parse("https://api.github.com/").context("parse GitHub API URL")?,
101 now: Arc::new(SystemTime::now),
102 signing_key: tokio::sync::OnceCell::new(),
103 installations: Mutex::new(BTreeMap::new()),
104 tokens: Mutex::new(BTreeMap::new()),
105 })
106 }
107
108 pub(crate) fn shared(config: &GithubAppConfig) -> Result<Arc<Self>> {
111 static PROVIDERS: OnceLock<Mutex<BTreeMap<GithubAppConfig, Arc<GithubAppTokenProvider>>>> =
112 OnceLock::new();
113 let providers = PROVIDERS.get_or_init(|| Mutex::new(BTreeMap::new()));
114 let mut providers = providers
115 .lock()
116 .unwrap_or_else(std::sync::PoisonError::into_inner);
117 if let Some(provider) = providers.get(config) {
118 return Ok(Arc::clone(provider));
119 }
120 let provider = Arc::new(Self::new(config.clone())?);
121 providers.insert(config.clone(), Arc::clone(&provider));
122 Ok(provider)
123 }
124
125 #[cfg(test)]
126 fn with_test_transport(
127 config: GithubAppConfig,
128 http: reqwest::Client,
129 api_base: Url,
130 now: Clock,
131 ) -> Self {
132 Self {
133 config,
134 http,
135 api_base,
136 now,
137 signing_key: tokio::sync::OnceCell::new(),
138 installations: Mutex::new(BTreeMap::new()),
139 tokens: Mutex::new(BTreeMap::new()),
140 }
141 }
142
143 pub(crate) async fn token_for_owner(&self, owner: &str) -> Result<String> {
144 let installation_id = self.installation_for_owner(owner, None).await?;
145 self.token_for_installation(installation_id, &[], self.config.token_permissions.as_ref())
146 .await
147 }
148
149 pub(crate) async fn token_for_repositories(
150 &self,
151 repositories: &[(String, String)],
152 ) -> Result<String> {
153 let scope = self
154 .resolve_repository_scope(None, repositories)
155 .await
156 .map_err(GithubBundleSelectionError::into_anyhow)?
157 .ok_or_else(|| anyhow!("at least one repository is required"))?;
158 self.token_for_installation(
159 scope.installation_id,
160 &scope.repositories,
161 self.config.token_permissions.as_ref(),
162 )
163 .await
164 }
165
166 pub(crate) async fn installation_for_repo(&self, owner: &str, repository: &str) -> Result<u64> {
167 self.installation_for_owner(owner, Some(repository)).await
168 }
169
170 pub(super) async fn token_for_owner_repo_pairs(
171 &self,
172 bundle_id: &str,
173 repositories: &[(String, String)],
174 ) -> std::result::Result<Option<InstallationScope>, GithubBundleSelectionError> {
175 self.resolve_repository_scope(Some(bundle_id), repositories)
176 .await
177 }
178
179 async fn resolve_repository_scope(
180 &self,
181 bundle_id: Option<&str>,
182 repositories: &[(String, String)],
183 ) -> std::result::Result<Option<InstallationScope>, GithubBundleSelectionError> {
184 let mut selected = BTreeMap::<u64, Vec<String>>::new();
185 for (owner, repository) in repositories {
186 let installation_id = self
187 .installation_for_repo(owner, repository)
188 .await
189 .map_err(GithubBundleSelectionError::Provider)?;
190 selected
191 .entry(installation_id)
192 .or_default()
193 .push(format!("{owner}/{repository}"));
194 }
195 if selected.len() > 1 {
196 let detail = selected
197 .iter()
198 .map(|(id, repositories)| format!("installation {id}: {}", repositories.join(", ")))
199 .collect::<Vec<_>>()
200 .join("; ");
201 let message = if let Some(bundle_id) = bundle_id {
202 format!(
203 "bundle {bundle_id:?} requires more than one GitHub App installation; v1 supports one installation per session ({detail})"
204 )
205 } else {
206 format!(
207 "selected repositories span more than one GitHub App installation ({detail})"
208 )
209 };
210 return Err(GithubBundleSelectionError::MultipleInstallations(message));
211 }
212 let Some((installation_id, repositories)) = selected.into_iter().next() else {
213 return Ok(None);
214 };
215 let mut repository_names = repositories
216 .into_iter()
217 .filter_map(|repository| repository.split_once('/').map(|(_, name)| name.to_owned()))
218 .map(|repository| repository.to_ascii_lowercase())
219 .collect::<Vec<_>>();
220 repository_names.sort();
221 repository_names.dedup();
222 Ok(Some(InstallationScope {
223 installation_id,
224 repositories: repository_names,
225 }))
226 }
227
228 async fn installation_for_owner(&self, owner: &str, repo: Option<&str>) -> Result<u64> {
229 ensure!(
230 valid_github_owner_login(owner),
231 "{owner:?} is not a valid GitHub owner login"
232 );
233 if let Some(repo) = repo {
234 ensure!(
235 valid_repository(repo),
236 "{repo:?} is not a valid GitHub repository name"
237 );
238 }
239 let owner_key = owner.to_ascii_lowercase();
240 if let Some(id) = self
241 .config
242 .installations
243 .iter()
244 .find_map(|(configured_owner, id)| {
245 configured_owner.eq_ignore_ascii_case(owner).then_some(*id)
246 })
247 {
248 return Ok(id);
249 }
250 let installation = {
251 let mut installations = self
252 .installations
253 .lock()
254 .unwrap_or_else(std::sync::PoisonError::into_inner);
255 Arc::clone(
256 installations
257 .entry(owner_key.clone())
258 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(None))),
259 )
260 };
261 let mut installation = installation.lock().await;
262 if let Some(id) = *installation {
263 return Ok(id);
264 }
265
266 let id = if let Some(repo) = repo {
267 self.lookup_installation(&["repos", owner, repo, "installation"])
268 .await
269 .map_err(InstallationLookupError::into_anyhow)?
270 } else {
271 match self
272 .lookup_installation(&["orgs", owner, "installation"])
273 .await
274 {
275 Ok(id) => id,
276 Err(InstallationLookupError::NotFound) => self
277 .lookup_installation(&["users", owner, "installation"])
278 .await
279 .map_err(InstallationLookupError::into_anyhow)?,
280 Err(error) => return Err(error.into_anyhow()),
281 }
282 };
283 *installation = Some(id);
284 Ok(id)
285 }
286
287 async fn lookup_installation(
288 &self,
289 path: &[&str],
290 ) -> std::result::Result<u64, InstallationLookupError> {
291 let jwt = self
292 .app_jwt()
293 .await
294 .map_err(InstallationLookupError::Other)?;
295 let response = self
296 .http
297 .get(self.api_url(path).map_err(InstallationLookupError::Other)?)
298 .bearer_auth(jwt)
299 .header("Accept", "application/vnd.github+json")
300 .header("X-GitHub-Api-Version", GITHUB_API_VERSION)
301 .send()
302 .await
303 .map_err(|error| {
304 InstallationLookupError::Other(anyhow!(
305 "reach the GitHub App installation API: {error}"
306 ))
307 })?;
308 if response.status() == reqwest::StatusCode::NOT_FOUND {
309 return Err(InstallationLookupError::NotFound);
310 }
311 if !response.status().is_success() {
312 return Err(InstallationLookupError::Other(anyhow!(
313 "GitHub App installation lookup failed with HTTP {}",
314 response.status().as_u16()
315 )));
316 }
317 let installation = response
318 .json::<InstallationResponse>()
319 .await
320 .context("decode GitHub App installation response")
321 .map_err(InstallationLookupError::Other)?;
322 if installation.id == 0 {
323 return Err(InstallationLookupError::Other(anyhow!(
324 "GitHub returned an invalid installation ID"
325 )));
326 }
327 Ok(installation.id)
328 }
329
330 pub(crate) async fn token_for_installation(
331 &self,
332 installation_id: u64,
333 repositories: &[String],
334 permissions: Option<&GithubPermissionSet>,
335 ) -> Result<String> {
336 ensure!(installation_id != 0, "installation ID must be positive");
337 let mut repositories = repositories
338 .iter()
339 .map(|repository| repository.to_ascii_lowercase())
340 .collect::<Vec<_>>();
341 repositories.sort();
342 repositories.dedup();
343 let key = TokenCacheKey {
344 installation_id,
345 repositories: repositories.clone(),
346 permissions: permissions.cloned(),
347 };
348 let cache = {
349 let mut tokens = self
350 .tokens
351 .lock()
352 .unwrap_or_else(std::sync::PoisonError::into_inner);
353 Arc::clone(
354 tokens
355 .entry(key)
356 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(None))),
357 )
358 };
359 let mut cached = cache.lock().await;
360 let now = (self.now)();
361 if let Some(entry) = cached.as_ref()
362 && entry
363 .expires_at
364 .duration_since(now)
365 .is_ok_and(|remaining| remaining >= TOKEN_REFRESH_THRESHOLD)
366 {
367 return Ok(entry.token.clone());
368 }
369
370 match self
371 .mint_installation_token(installation_id, &repositories, permissions)
372 .await
373 {
374 Ok(entry) => {
375 let token = entry.token.clone();
376 *cached = Some(entry);
377 Ok(token)
378 }
379 Err(error) => {
380 if error.downcast_ref::<PermissionGrantError>().is_some() {
381 return Err(error);
382 }
383 if let Some(entry) = cached.as_ref()
384 && entry.expires_at > now
385 {
386 tracing::warn!(
387 installation_id,
388 error = %error,
389 "could not refresh GitHub App installation token; using its still-valid cached token"
390 );
391 return Ok(entry.token.clone());
392 }
393 Err(error)
394 }
395 }
396 }
397
398 async fn mint_installation_token(
399 &self,
400 installation_id: u64,
401 repositories: &[String],
402 permissions: Option<&GithubPermissionSet>,
403 ) -> Result<TokenEntry> {
404 let jwt = self.app_jwt().await?;
405 if let Some(permissions) = permissions {
406 let installation = self.installation_details(installation_id, &jwt).await?;
407 let installed_permissions = installation.permissions.as_ref().ok_or_else(|| {
408 permission_grant_error(format!(
409 "GitHub installation {installation_id} response omitted permissions"
410 ))
411 })?;
412 validate_requested_permissions(installation_id, permissions, installed_permissions)?;
413 }
414 let mut body = serde_json::Map::new();
415 if !repositories.is_empty() {
416 body.insert("repositories".to_owned(), serde_json::json!(repositories));
417 }
418 if let Some(permissions) = permissions {
419 body.insert("permissions".to_owned(), serde_json::to_value(permissions)?);
420 }
421 let response = self
422 .http
423 .post(self.api_url(&[
424 "app",
425 "installations",
426 &installation_id.to_string(),
427 "access_tokens",
428 ])?)
429 .bearer_auth(jwt)
430 .header("Accept", "application/vnd.github+json")
431 .header("X-GitHub-Api-Version", GITHUB_API_VERSION)
432 .json(&body)
433 .send()
434 .await
435 .context("reach the GitHub App access-token API")?;
436 if !response.status().is_success() {
437 bail!(
438 "GitHub App access-token exchange failed with HTTP {}",
439 response.status().as_u16()
440 );
441 }
442 let response = response
443 .json::<AccessTokenResponse>()
444 .await
445 .context("decode GitHub App access-token response")?;
446 if let Some(permissions) = permissions {
447 let granted_permissions = response.permissions.as_ref().ok_or_else(|| {
448 permission_grant_error(
449 "GitHub installation token response omitted permissions".to_owned(),
450 )
451 })?;
452 validate_minted_permissions(installation_id, permissions, granted_permissions)?;
453 }
454 ensure!(
455 !response.token.is_empty(),
456 "GitHub returned an empty installation token"
457 );
458 let expires_at = chrono::DateTime::parse_from_rfc3339(&response.expires_at)
459 .context("GitHub returned an invalid installation-token expiry")?
460 .with_timezone(&chrono::Utc)
461 .timestamp();
462 let expires_at = UNIX_EPOCH
463 .checked_add(Duration::from_secs(
464 expires_at
465 .try_into()
466 .context("GitHub returned an invalid installation-token expiry")?,
467 ))
468 .context("GitHub returned an invalid installation-token expiry")?;
469 ensure!(
470 expires_at > (self.now)(),
471 "GitHub returned an expired installation token"
472 );
473 Ok(TokenEntry {
474 token: response.token,
475 expires_at,
476 })
477 }
478
479 async fn installation_details(
480 &self,
481 installation_id: u64,
482 jwt: &str,
483 ) -> Result<InstallationResponse> {
484 let response = self
485 .http
486 .get(self.api_url(&["app", "installations", &installation_id.to_string()])?)
487 .bearer_auth(jwt)
488 .header("Accept", "application/vnd.github+json")
489 .header("X-GitHub-Api-Version", GITHUB_API_VERSION)
490 .send()
491 .await
492 .context("reach the GitHub App installation details API")?;
493 if !response.status().is_success() {
494 bail!(
495 "GitHub App installation details lookup failed with HTTP {}",
496 response.status().as_u16()
497 );
498 }
499 let installation = response
500 .json::<InstallationResponse>()
501 .await
502 .context("decode GitHub App installation details")?;
503 ensure!(
504 installation.id == installation_id,
505 "GitHub returned installation {} while checking installation {installation_id}",
506 installation.id
507 );
508 Ok(installation)
509 }
510
511 async fn app_jwt(&self) -> Result<String> {
512 let config = self.config.clone();
513 let key = self
514 .signing_key
515 .get_or_try_init(|| async move {
516 let path = config.private_key_path.clone();
517 let bytes = tokio::task::spawn_blocking(move || read_private_key_file(&path))
518 .await
519 .context("GitHub App private-key read task failed")??;
520 parse_private_key(&bytes).map(Arc::new)
521 })
522 .await?;
523 mint_app_jwt(&self.config, key, (self.now)())
524 }
525
526 fn api_url(&self, segments: &[&str]) -> Result<Url> {
527 let mut url = self.api_base.clone();
528 url.path_segments_mut()
529 .map_err(|_| anyhow!("GitHub API base URL cannot accept path segments"))?
530 .clear()
531 .extend(segments.iter().copied());
532 Ok(url)
533 }
534}
535
536fn validate_requested_permissions(
537 installation_id: u64,
538 requested: &GithubPermissionSet,
539 installed: &BTreeMap<String, String>,
540) -> Result<()> {
541 for (permission, requested_level) in requested {
542 let actual_level = installed.get(permission).map(String::as_str);
543 let actual_rank = match actual_level {
544 None | Some("none") => 0,
545 Some("read") => 1,
546 Some("write") => 2,
547 Some("admin") => 3,
548 Some(other) => {
549 return Err(permission_grant_error(format!(
550 "GitHub installation {installation_id} returned unsupported level {other:?} for permission {permission:?}"
551 )));
552 }
553 };
554 let requested_rank = match requested_level {
555 GithubPermissionLevel::Read => 1,
556 GithubPermissionLevel::Write => 2,
557 };
558 if actual_rank < requested_rank {
559 return Err(permission_grant_error(format!(
560 "GitHub installation {installation_id} grants {} for permission {permission:?}, but {} was requested",
561 actual_level.unwrap_or("none"),
562 requested_level.as_str()
563 )));
564 }
565 }
566 Ok(())
567}
568
569fn validate_minted_permissions(
570 installation_id: u64,
571 requested: &GithubPermissionSet,
572 granted: &BTreeMap<String, String>,
573) -> Result<()> {
574 validate_requested_permissions(installation_id, requested, granted)?;
575 for (permission, actual_level) in granted {
576 let Some(requested_level) = requested.get(permission) else {
577 return Err(permission_grant_error(format!(
578 "GitHub installation token for installation {installation_id} includes unrequested permission {permission:?}"
579 )));
580 };
581 let requested_rank = match requested_level {
582 GithubPermissionLevel::Read => 1,
583 GithubPermissionLevel::Write => 2,
584 };
585 let actual_rank = match actual_level.as_str() {
586 "none" => 0,
587 "read" => 1,
588 "write" => 2,
589 "admin" => 3,
590 other => {
591 return Err(permission_grant_error(format!(
592 "GitHub installation token for installation {installation_id} returned unsupported level {other:?} for permission {permission:?}"
593 )));
594 }
595 };
596 if actual_rank > requested_rank {
597 return Err(permission_grant_error(format!(
598 "GitHub installation token for installation {installation_id} has {} for permission {permission:?}, but {} was requested",
599 actual_level,
600 requested_level.as_str()
601 )));
602 }
603 }
604 Ok(())
605}
606
607#[derive(Debug)]
608struct PermissionGrantError(String);
609
610impl std::fmt::Display for PermissionGrantError {
611 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
612 formatter.write_str(&self.0)
613 }
614}
615
616impl std::error::Error for PermissionGrantError {}
617
618fn permission_grant_error(message: String) -> anyhow::Error {
619 anyhow::Error::new(PermissionGrantError(message))
620}
621
622#[cfg(unix)]
623fn read_private_key_file(path: &std::path::Path) -> Result<Vec<u8>> {
624 use std::io::Read as _;
625 use std::os::unix::fs::MetadataExt as _;
626
627 let mut file = fs::File::open(path)
628 .with_context(|| format!("open GitHub App private key {}", path.display()))?;
629 let metadata = file
630 .metadata()
631 .with_context(|| format!("inspect GitHub App private key {}", path.display()))?;
632 validate_private_key_metadata(
633 path,
634 metadata.uid(),
635 metadata.mode(),
636 unsafe { libc::geteuid() },
638 )?;
639 let mut bytes = Vec::new();
640 file.read_to_end(&mut bytes)
641 .with_context(|| format!("read GitHub App private key {}", path.display()))?;
642 Ok(bytes)
643}
644
645#[cfg(not(unix))]
646fn read_private_key_file(path: &std::path::Path) -> Result<Vec<u8>> {
647 fs::read(path).with_context(|| format!("read GitHub App private key {}", path.display()))
648}
649
650#[cfg(unix)]
652fn validate_private_key_metadata(
653 path: &std::path::Path,
654 file_uid: u32,
655 mode: u32,
656 daemon_uid: u32,
657) -> Result<()> {
658 ensure!(
659 file_uid == daemon_uid && mode & 0o044 == 0,
660 "GitHub App private key {} must be owned by daemon UID {daemon_uid} and not readable by group or others; fix its ownership and run `chmod 600 -- {}`",
661 path.display(),
662 path.display(),
663 );
664 Ok(())
665}
666
667#[derive(Debug)]
669pub enum GithubBundleSelectionError {
670 UnknownBundle(String),
671 MultipleInstallations(String),
672 Provider(anyhow::Error),
673}
674
675impl std::fmt::Display for GithubBundleSelectionError {
676 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
677 match self {
678 Self::UnknownBundle(message) => formatter.write_str(message),
679 Self::MultipleInstallations(message) => formatter.write_str(message),
680 Self::Provider(error) => write!(formatter, "{error:#}"),
681 }
682 }
683}
684
685impl std::error::Error for GithubBundleSelectionError {}
686
687impl GithubBundleSelectionError {
688 pub(crate) fn into_anyhow(self) -> anyhow::Error {
689 match self {
690 Self::UnknownBundle(message) => anyhow!("{message}"),
691 Self::MultipleInstallations(message) => anyhow!("{message}"),
692 Self::Provider(error) => error,
693 }
694 }
695}
696
697impl Controller {
698 pub(crate) async fn validate_github_bundle_installations(
701 &self,
702 bundle_id: &str,
703 ) -> std::result::Result<(), GithubBundleSelectionError> {
704 let Some(app) = self.config.github.app.as_ref() else {
705 return Ok(());
706 };
707 let bundle = self.config.bundles.get(bundle_id).cloned().ok_or_else(|| {
708 GithubBundleSelectionError::UnknownBundle(format!("unknown bundle {bundle_id:?}"))
709 })?;
710 let provider =
711 GithubAppTokenProvider::shared(app).map_err(GithubBundleSelectionError::Provider)?;
712 let repositories = tokio::task::spawn_blocking(move || {
713 github_repositories(&bundle, None, &ProcessExecutor)
714 })
715 .await
716 .map_err(|error| {
717 GithubBundleSelectionError::Provider(anyhow!(
718 "GitHub repository source task failed: {error}"
719 ))
720 })?
721 .map_err(GithubBundleSelectionError::Provider)?;
722 provider
723 .token_for_owner_repo_pairs(bundle_id, &repositories)
724 .await?;
725 Ok(())
726 }
727
728 pub(crate) async fn github_token_for_session(
731 &self,
732 session_id: &str,
733 ) -> Result<Option<String>> {
734 if self.config.github.app.is_none() {
735 return tokio::task::spawn_blocking(controller_github_token)
736 .await
737 .context("GitHub token lookup task failed");
738 }
739 self.github_app_token_for_session(session_id).await
740 }
741
742 pub(crate) async fn github_token_for_repository_preflight(
746 &self,
747 session_id: &str,
748 ) -> Result<Option<String>> {
749 let has_github_repository = self
750 .state
751 .sessions
752 .get(session_id)
753 .and_then(|session| session.project_bundle(&self.config))
754 .is_some_and(|bundle| {
755 bundle
756 .repositories
757 .iter()
758 .any(|repository| repository.github.is_some())
759 });
760 if !has_github_repository {
761 return Ok(None);
762 }
763 self.github_token_for_session(session_id).await
764 }
765
766 pub(crate) async fn github_app_token_for_session(
767 &self,
768 session_id: &str,
769 ) -> Result<Option<String>> {
770 let Some(app) = self.config.github.app.as_ref() else {
771 return Ok(None);
772 };
773 let session = self
774 .state
775 .sessions
776 .get(session_id)
777 .with_context(|| format!("unknown session {session_id}"))?;
778 let Some(bundle) = session.project_bundle(&self.config).cloned() else {
779 return Ok(None);
780 };
781 let bundle_id = session.bundle_id.clone();
782 let network_sources = session
783 .project
784 .as_ref()
785 .map(|project| project.network_sources.clone());
786 let repositories = tokio::task::spawn_blocking(move || {
787 github_repositories(&bundle, network_sources.as_ref(), &ProcessExecutor)
788 })
789 .await
790 .context("GitHub repository source task failed")??;
791 let provider = GithubAppTokenProvider::shared(app)?;
792 let Some(scope) = provider
793 .token_for_owner_repo_pairs(&bundle_id, &repositories)
794 .await
795 .map_err(GithubBundleSelectionError::into_anyhow)?
796 else {
797 return Ok(None);
798 };
799 provider
800 .token_for_installation(
801 scope.installation_id,
802 &scope.repositories,
803 app.session_permissions.as_ref(),
804 )
805 .await
806 .map(Some)
807 }
808}
809
810pub(crate) async fn github_token_for_session(
811 session_id: String,
812 github_app_configured: bool,
813) -> Result<Option<String>> {
814 if !github_app_configured {
815 return tokio::task::spawn_blocking(controller_github_token)
816 .await
817 .context("GitHub token lookup task failed");
818 }
819 let controller = tokio::task::spawn_blocking(Controller::load)
820 .await
821 .context("load controller for GitHub credential sync")??;
822 if controller.config.github.app.is_none() {
823 return tokio::task::spawn_blocking(controller_github_token)
824 .await
825 .context("GitHub token lookup task failed");
826 }
827 controller.github_token_for_session(&session_id).await
828}
829
830pub(crate) async fn github_app_token_for_session(session_id: String) -> Result<Option<String>> {
831 let controller = tokio::task::spawn_blocking(Controller::load)
832 .await
833 .context("load controller for GitHub App export token")??;
834 controller.github_app_token_for_session(&session_id).await
835}
836
837pub(super) fn github_repositories(
838 bundle: &ProjectBundle,
839 network_sources: Option<&BTreeMap<String, mj_core::remote_git::NetworkGitSource>>,
840 executor: &impl CommandExecutor,
841) -> Result<Vec<(String, String)>> {
842 let mut repositories = Vec::new();
843 for repository in &bundle.repositories {
844 let source = repository_source(repository, network_sources, executor)?;
845 if let Some((owner, name)) = source.as_deref().and_then(github_owner_repo) {
846 repositories.push((owner, name));
847 }
848 }
849 repositories.sort();
850 repositories.dedup();
851 Ok(repositories)
852}
853
854fn repository_source(
855 repository: &ProjectRepository,
856 network_sources: Option<&BTreeMap<String, mj_core::remote_git::NetworkGitSource>>,
857 executor: &impl CommandExecutor,
858) -> Result<Option<String>> {
859 if let Some(source) = &repository.github {
860 return Ok(Some(source.clone()));
861 }
862 if let Some(source) = network_sources
863 .and_then(|sources| sources.get(&repository.id))
864 .map(|source| source.fetch_url.clone())
865 {
866 return Ok(Some(source));
867 }
868 if repository.local.is_some() {
869 return resolve_repository(repository, executor).map(|source| Some(source.fetch_url));
870 }
871 Ok(None)
872}
873
874enum InstallationLookupError {
875 NotFound,
876 Other(anyhow::Error),
877}
878
879impl InstallationLookupError {
880 fn into_anyhow(self) -> anyhow::Error {
881 match self {
882 Self::NotFound => {
883 anyhow!("no GitHub App installation was found for this owner or repository")
884 }
885 Self::Other(error) => error,
886 }
887 }
888}
889
890fn valid_repository(repository: &str) -> bool {
891 !repository.is_empty()
892 && repository.len() <= 100
893 && repository != "."
894 && repository != ".."
895 && repository
896 .bytes()
897 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
898}
899
900fn parse_private_key(pem: &[u8]) -> Result<RsaKeyPair> {
901 let pem = std::str::from_utf8(pem).context("GitHub App private key is not UTF-8 PEM")?;
902 let (label, body) = [
903 (
904 "RSA PRIVATE KEY",
905 "-----BEGIN RSA PRIVATE KEY-----",
906 "-----END RSA PRIVATE KEY-----",
907 ),
908 (
909 "PRIVATE KEY",
910 "-----BEGIN PRIVATE KEY-----",
911 "-----END PRIVATE KEY-----",
912 ),
913 ]
914 .into_iter()
915 .find_map(|(label, begin, end)| {
916 let body = pem.strip_prefix(begin)?.split_once(end)?.0;
917 Some((label, body))
918 })
919 .ok_or_else(|| anyhow!("GitHub App key must be an RSA PKCS#1 or PKCS#8 PEM private key"))?;
920 let der = STANDARD
921 .decode(
922 body.bytes()
923 .filter(|byte| !byte.is_ascii_whitespace())
924 .collect::<Vec<_>>(),
925 )
926 .context("decode GitHub App PEM private key")?;
927 let key = match label {
928 "PRIVATE KEY" => RsaKeyPair::from_pkcs8(&der),
929 "RSA PRIVATE KEY" => RsaKeyPair::from_der(&der),
930 _ => unreachable!("PEM label comes from the fixed list above"),
931 };
932 key.map_err(|_| anyhow!("GitHub App PEM does not contain a valid RSA private key"))
933}
934
935fn mint_app_jwt(config: &GithubAppConfig, key: &RsaKeyPair, now: SystemTime) -> Result<String> {
936 let now: i64 = now
937 .duration_since(UNIX_EPOCH)
938 .context("system clock is before the Unix epoch")?
939 .as_secs()
940 .try_into()
941 .context("system time is outside the supported JWT range")?;
942 let header = serde_json::to_vec(&JwtHeader {
943 alg: "RS256",
944 typ: "JWT",
945 })?;
946 let claims = serde_json::to_vec(&JwtClaims {
947 iss: config.app_id.to_string(),
948 iat: now - JWT_BACKDATE_SECS,
949 exp: now + JWT_LIFETIME_SECS,
950 })?;
951 let signing_input = format!(
952 "{}.{}",
953 URL_SAFE_NO_PAD.encode(header),
954 URL_SAFE_NO_PAD.encode(claims)
955 );
956 let mut signature = vec![0; key.public().modulus_len()];
957 key.sign(
958 &RSA_PKCS1_SHA256,
959 &SystemRandom::new(),
960 signing_input.as_bytes(),
961 &mut signature,
962 )
963 .map_err(|_| anyhow!("sign GitHub App JWT"))?;
964 Ok(format!(
965 "{signing_input}.{}",
966 URL_SAFE_NO_PAD.encode(signature)
967 ))
968}
969
970#[cfg(test)]
971mod tests {
972 use std::path::PathBuf;
973 use std::sync::atomic::{AtomicU64, Ordering};
974
975 use axum::extract::Path;
976 use axum::extract::State;
977 use axum::routing::{get, post};
978 use axum::{Json, Router};
979 use ring::signature::{KeyPair, RSA_PKCS1_2048_8192_SHA256, RsaPublicKeyComponents};
980 use serde_json::Value;
981 use tokio::net::TcpListener;
982
983 use super::*;
984
985 const TEST_KEY: &[u8] = include_bytes!("testdata/github-app-test.pem");
986
987 #[derive(Clone)]
988 struct FakeGithub {
989 now: Arc<AtomicU64>,
990 lookups: Arc<AtomicU64>,
991 exchanges: Arc<AtomicU64>,
992 scopes: Arc<Mutex<Vec<Vec<String>>>>,
993 bodies: Arc<Mutex<Vec<Value>>>,
994 installation_permissions: Arc<Mutex<BTreeMap<String, String>>>,
995 }
996
997 async fn installation(State(fake): State<FakeGithub>) -> Json<Value> {
998 fake.lookups.fetch_add(1, Ordering::SeqCst);
999 Json(serde_json::json!({ "id": 77331 }))
1000 }
1001
1002 async fn installation_details(
1003 State(fake): State<FakeGithub>,
1004 Path(installation_id): Path<u64>,
1005 ) -> Json<Value> {
1006 Json(serde_json::json!({
1007 "id": installation_id,
1008 "permissions": fake.installation_permissions.lock().unwrap().clone(),
1009 }))
1010 }
1011
1012 async fn access_token(State(fake): State<FakeGithub>, Json(body): Json<Value>) -> Json<Value> {
1013 let exchange = fake.exchanges.fetch_add(1, Ordering::SeqCst) + 1;
1014 fake.bodies.lock().unwrap().push(body.clone());
1015 let repositories = body
1016 .get("repositories")
1017 .and_then(Value::as_array)
1018 .map(|repositories| {
1019 repositories
1020 .iter()
1021 .map(|repository| repository.as_str().unwrap().to_owned())
1022 .collect()
1023 })
1024 .unwrap_or_default();
1025 fake.scopes.lock().unwrap().push(repositories);
1026 let permissions = body.get("permissions").cloned().unwrap_or_else(|| {
1027 serde_json::to_value(fake.installation_permissions.lock().unwrap().clone()).unwrap()
1028 });
1029 Json(serde_json::json!({
1030 "token": format!("test-installation-token-{exchange}"),
1031 "permissions": permissions,
1032 "expires_at": chrono::DateTime::from_timestamp(
1033 fake.now.load(Ordering::SeqCst) as i64 + 3600,
1034 0,
1035 ).unwrap().to_rfc3339(),
1036 }))
1037 }
1038
1039 async fn missing_installation() -> axum::http::StatusCode {
1040 axum::http::StatusCode::NOT_FOUND
1041 }
1042
1043 async fn test_server(fake: FakeGithub) -> (Url, tokio::task::JoinHandle<()>) {
1044 let app = Router::new()
1045 .route("/repos/{owner}/{repo}/installation", get(installation))
1046 .route("/orgs/{owner}/installation", get(installation))
1047 .route("/users/{owner}/installation", get(missing_installation))
1048 .route("/app/installations/{id}", get(installation_details))
1049 .route("/app/installations/{id}/access_tokens", post(access_token))
1050 .with_state(fake);
1051 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1052 let address = listener.local_addr().unwrap();
1053 let task = tokio::spawn(async move {
1054 axum::serve(listener, app).await.unwrap();
1055 });
1056 (Url::parse(&format!("http://{address}/")).unwrap(), task)
1057 }
1058
1059 fn test_config() -> GithubAppConfig {
1060 GithubAppConfig {
1061 app_id: 12_345,
1062 private_key_path: PathBuf::from("test.pem"),
1063 installations: BTreeMap::new(),
1064 session_permissions: None,
1065 token_permissions: None,
1066 }
1067 }
1068
1069 fn fake_github(now: u64) -> FakeGithub {
1070 FakeGithub {
1071 now: Arc::new(AtomicU64::new(now)),
1072 lookups: Arc::new(AtomicU64::new(0)),
1073 exchanges: Arc::new(AtomicU64::new(0)),
1074 scopes: Arc::new(Mutex::new(Vec::new())),
1075 bodies: Arc::new(Mutex::new(Vec::new())),
1076 installation_permissions: Arc::new(Mutex::new(BTreeMap::from([
1077 ("contents".into(), "write".into()),
1078 ("metadata".into(), "read".into()),
1079 ("statuses".into(), "read".into()),
1080 ]))),
1081 }
1082 }
1083
1084 #[test]
1085 fn jwt_has_expected_claims_and_a_valid_rs256_signature() {
1086 let key = parse_private_key(TEST_KEY).unwrap();
1087 let config = test_config();
1088 let now = UNIX_EPOCH + Duration::from_secs(1_800_000_000);
1089 let jwt = mint_app_jwt(&config, &key, now).unwrap();
1090 let mut parts = jwt.split('.');
1091 let header_part = parts.next().unwrap();
1092 let claims_part = parts.next().unwrap();
1093 let signature_part = parts.next().unwrap();
1094 let header = URL_SAFE_NO_PAD.decode(header_part).unwrap();
1095 let claims = URL_SAFE_NO_PAD.decode(claims_part).unwrap();
1096 let signature = URL_SAFE_NO_PAD.decode(signature_part).unwrap();
1097 assert!(parts.next().is_none());
1098 assert_eq!(
1099 serde_json::from_slice::<Value>(&header).unwrap()["alg"],
1100 "RS256"
1101 );
1102 let claims_json: Value = serde_json::from_slice(&claims).unwrap();
1103 assert_eq!(claims_json["iss"], config.app_id.to_string());
1104 assert_eq!(claims_json["iat"], 1_800_000_000 - JWT_BACKDATE_SECS);
1105 assert_eq!(claims_json["exp"], 1_800_000_000 + JWT_LIFETIME_SECS);
1106
1107 let signing_input = format!("{header_part}.{claims_part}");
1108 let (n, e) = rsa_public_components(key.public_key().as_ref());
1109 RsaPublicKeyComponents { n, e }
1110 .verify(
1111 &RSA_PKCS1_2048_8192_SHA256,
1112 signing_input.as_bytes(),
1113 &signature,
1114 )
1115 .unwrap();
1116 }
1117
1118 #[cfg(unix)]
1119 #[test]
1120 fn private_key_permissions_require_daemon_ownership_and_private_mode() {
1121 let path = std::path::Path::new("/var/lib/mj/github-app.pem");
1122 assert!(validate_private_key_metadata(path, 1000, 0o100600, 1000).is_ok());
1123
1124 for (uid, mode) in [(1000, 0o100640), (1001, 0o100600)] {
1125 let error = validate_private_key_metadata(path, uid, mode, 1000)
1126 .unwrap_err()
1127 .to_string();
1128 assert!(error.contains(path.to_str().unwrap()));
1129 assert!(error.contains("chmod 600"));
1130 }
1131 }
1132
1133 #[cfg(unix)]
1134 #[test]
1135 fn private_key_file_check_rejects_group_read_permission() {
1136 use std::os::unix::fs::PermissionsExt as _;
1137
1138 let directory = tempfile::tempdir().unwrap();
1139 let path = directory.path().join("github-app.pem");
1140 fs::write(&path, TEST_KEY).unwrap();
1141 fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
1142
1143 let error = read_private_key_file(&path).unwrap_err();
1144 let message = error.to_string();
1145 assert!(message.contains(path.to_str().unwrap()));
1146 assert!(message.contains("chmod 600"));
1147 }
1148
1149 #[tokio::test]
1150 async fn installation_lookup_and_token_cache_refresh_below_ten_minutes() {
1151 let fake = fake_github(1_800_000_000);
1152 let (api_base, server) = test_server(fake.clone()).await;
1153 let now_for_clock = Arc::clone(&fake.now);
1154 let config = test_config();
1155 let provider = GithubAppTokenProvider::with_test_transport(
1156 config,
1157 reqwest::Client::new(),
1158 api_base,
1159 Arc::new(move || {
1160 UNIX_EPOCH + Duration::from_secs(now_for_clock.load(Ordering::SeqCst))
1161 }),
1162 );
1163
1164 let seeded = provider
1167 .signing_key
1168 .set(Arc::new(parse_private_key(TEST_KEY).unwrap()));
1169 assert!(seeded.is_ok());
1170 assert_eq!(
1171 provider
1172 .token_for_repositories(&[("Acme".into(), "widget".into())])
1173 .await
1174 .unwrap(),
1175 "test-installation-token-1"
1176 );
1177 assert_eq!(
1178 provider
1179 .token_for_repositories(&[("acme".into(), "widget".into())])
1180 .await
1181 .unwrap(),
1182 "test-installation-token-1"
1183 );
1184 assert_eq!(fake.lookups.load(Ordering::SeqCst), 1);
1185 assert_eq!(fake.exchanges.load(Ordering::SeqCst), 1);
1186 assert_eq!(*fake.scopes.lock().unwrap(), [vec!["widget".to_owned()]]);
1187 assert!(fake.bodies.lock().unwrap()[0].get("permissions").is_none());
1188
1189 fake.now.store(1_800_000_000 + 3600 - 601, Ordering::SeqCst);
1190 assert_eq!(
1191 provider
1192 .token_for_repositories(&[("acme".into(), "widget".into())])
1193 .await
1194 .unwrap(),
1195 "test-installation-token-1"
1196 );
1197 assert_eq!(fake.exchanges.load(Ordering::SeqCst), 1);
1198
1199 fake.now.store(1_800_000_000 + 3600 - 599, Ordering::SeqCst);
1200 assert_eq!(
1201 provider
1202 .token_for_repositories(&[("acme".into(), "widget".into())])
1203 .await
1204 .unwrap(),
1205 "test-installation-token-2"
1206 );
1207 assert_eq!(fake.exchanges.load(Ordering::SeqCst), 2);
1208 server.abort();
1209 }
1210
1211 #[tokio::test]
1212 async fn token_cache_separates_installation_wide_and_sorted_repository_scopes() {
1213 let fake = fake_github(1_800_000_000);
1214 let (api_base, server) = test_server(fake.clone()).await;
1215 let mut config = test_config();
1216 config.installations.insert("acme".into(), 77331);
1217 let provider = GithubAppTokenProvider::with_test_transport(
1218 config,
1219 reqwest::Client::new(),
1220 api_base,
1221 Arc::new(|| UNIX_EPOCH + Duration::from_secs(1_800_000_000)),
1222 );
1223 let seeded = provider
1224 .signing_key
1225 .set(Arc::new(parse_private_key(TEST_KEY).unwrap()));
1226 assert!(seeded.is_ok());
1227
1228 let first_scope = vec!["Zebra".to_owned(), "Alpha".to_owned()];
1229 let reordered_scope = vec!["alpha".to_owned(), "zebra".to_owned()];
1230 let write_contents = BTreeMap::from([("contents".into(), GithubPermissionLevel::Write)]);
1231 let read_contents = BTreeMap::from([("contents".into(), GithubPermissionLevel::Read)]);
1232 let first = provider
1233 .token_for_installation(77331, &first_scope, Some(&write_contents))
1234 .await
1235 .unwrap();
1236 let cached = provider
1237 .token_for_installation(77331, &reordered_scope, Some(&write_contents))
1238 .await
1239 .unwrap();
1240 let different_permissions = provider
1241 .token_for_installation(77331, &first_scope, Some(&read_contents))
1242 .await
1243 .unwrap();
1244 let narrower = provider
1245 .token_for_installation(77331, &["alpha".to_owned()], None)
1246 .await
1247 .unwrap();
1248 let installation_wide = provider
1249 .token_for_installation(77331, &[], None)
1250 .await
1251 .unwrap();
1252
1253 assert_eq!(first, cached);
1254 assert_ne!(first, narrower);
1255 assert_ne!(first, different_permissions);
1256 assert_ne!(narrower, installation_wide);
1257 assert_eq!(fake.exchanges.load(Ordering::SeqCst), 4);
1258 assert_eq!(
1259 *fake.scopes.lock().unwrap(),
1260 [
1261 vec!["alpha".to_owned(), "zebra".to_owned()],
1262 vec!["alpha".to_owned(), "zebra".to_owned()],
1263 vec!["alpha".to_owned()],
1264 vec![]
1265 ]
1266 );
1267 assert_eq!(
1268 fake.bodies.lock().unwrap()[0]["permissions"]["contents"],
1269 "write"
1270 );
1271 assert_eq!(
1272 fake.bodies.lock().unwrap()[1]["permissions"]["contents"],
1273 "read"
1274 );
1275 server.abort();
1276 }
1277
1278 #[tokio::test]
1279 async fn permission_subset_is_minted_with_repository_scope() {
1280 let fake = fake_github(1_800_000_000);
1281 let (api_base, server) = test_server(fake.clone()).await;
1282 let mut config = test_config();
1283 config.installations.insert("acme".into(), 77331);
1284 config.session_permissions = Some(BTreeMap::from([
1285 ("contents".into(), GithubPermissionLevel::Write),
1286 ("statuses".into(), GithubPermissionLevel::Read),
1287 ]));
1288 let provider = GithubAppTokenProvider::with_test_transport(
1289 config.clone(),
1290 reqwest::Client::new(),
1291 api_base,
1292 Arc::new(|| UNIX_EPOCH + Duration::from_secs(1_800_000_000)),
1293 );
1294 let seeded = provider
1295 .signing_key
1296 .set(Arc::new(parse_private_key(TEST_KEY).unwrap()));
1297 assert!(seeded.is_ok());
1298
1299 provider
1300 .token_for_installation(
1301 77331,
1302 &["widget".to_owned()],
1303 config.session_permissions.as_ref(),
1304 )
1305 .await
1306 .unwrap();
1307
1308 assert_eq!(
1309 fake.bodies.lock().unwrap()[0],
1310 serde_json::json!({
1311 "repositories": ["widget"],
1312 "permissions": {"contents": "write", "statuses": "read"}
1313 })
1314 );
1315 server.abort();
1316 }
1317
1318 #[tokio::test]
1319 async fn permission_request_above_installation_grant_is_refused_before_minting() {
1320 let fake = fake_github(1_800_000_000);
1321 let (api_base, server) = test_server(fake.clone()).await;
1322 let mut config = test_config();
1323 config.installations.insert("acme".into(), 77331);
1324 config.token_permissions = Some(BTreeMap::from([(
1325 "statuses".into(),
1326 GithubPermissionLevel::Write,
1327 )]));
1328 let provider = GithubAppTokenProvider::with_test_transport(
1329 config,
1330 reqwest::Client::new(),
1331 api_base,
1332 Arc::new(|| UNIX_EPOCH + Duration::from_secs(1_800_000_000)),
1333 );
1334 let seeded = provider
1335 .signing_key
1336 .set(Arc::new(parse_private_key(TEST_KEY).unwrap()));
1337 assert!(seeded.is_ok());
1338
1339 let error = provider.token_for_owner("acme").await.unwrap_err();
1340 let message = format!("{error:#}");
1341 assert!(message.contains("statuses"), "{message}");
1342 assert!(
1343 message.contains("read") && message.contains("write"),
1344 "{message}"
1345 );
1346 assert_eq!(fake.exchanges.load(Ordering::SeqCst), 0);
1347 assert!(fake.bodies.lock().unwrap().is_empty());
1348 server.abort();
1349 }
1350
1351 #[tokio::test]
1352 async fn cached_token_does_not_hide_a_newly_insufficient_permission_grant() {
1353 let fake = fake_github(1_800_000_000);
1354 let (api_base, server) = test_server(fake.clone()).await;
1355 let mut config = test_config();
1356 config.installations.insert("acme".into(), 77331);
1357 config.token_permissions = Some(BTreeMap::from([(
1358 "contents".into(),
1359 GithubPermissionLevel::Write,
1360 )]));
1361 let now = Arc::clone(&fake.now);
1362 let provider = GithubAppTokenProvider::with_test_transport(
1363 config,
1364 reqwest::Client::new(),
1365 api_base,
1366 Arc::new(move || UNIX_EPOCH + Duration::from_secs(now.load(Ordering::SeqCst))),
1367 );
1368 let seeded = provider
1369 .signing_key
1370 .set(Arc::new(parse_private_key(TEST_KEY).unwrap()));
1371 assert!(seeded.is_ok());
1372 provider.token_for_owner("acme").await.unwrap();
1373
1374 fake.installation_permissions
1375 .lock()
1376 .unwrap()
1377 .insert("contents".into(), "read".into());
1378 fake.now.store(1_800_000_000 + 3600 - 599, Ordering::SeqCst);
1379 let error = provider.token_for_owner("acme").await.unwrap_err();
1380 let message = format!("{error:#}");
1381 assert!(message.contains("contents"), "{message}");
1382 assert!(
1383 message.contains("read") && message.contains("write"),
1384 "{message}"
1385 );
1386 assert_eq!(fake.exchanges.load(Ordering::SeqCst), 1);
1387 server.abort();
1388 }
1389
1390 #[tokio::test]
1391 async fn token_command_uses_its_configured_permission_table() {
1392 let fake = fake_github(1_800_000_000);
1393 let (api_base, server) = test_server(fake.clone()).await;
1394 let mut config = test_config();
1395 config.installations.insert("acme".into(), 77331);
1396 config.session_permissions = Some(BTreeMap::from([(
1397 "contents".into(),
1398 GithubPermissionLevel::Read,
1399 )]));
1400 config.token_permissions = Some(BTreeMap::from([(
1401 "statuses".into(),
1402 GithubPermissionLevel::Read,
1403 )]));
1404 let provider = GithubAppTokenProvider::with_test_transport(
1405 config,
1406 reqwest::Client::new(),
1407 api_base,
1408 Arc::new(|| UNIX_EPOCH + Duration::from_secs(1_800_000_000)),
1409 );
1410 let seeded = provider
1411 .signing_key
1412 .set(Arc::new(parse_private_key(TEST_KEY).unwrap()));
1413 assert!(seeded.is_ok());
1414
1415 provider.token_for_owner("acme").await.unwrap();
1416
1417 assert_eq!(
1418 fake.bodies.lock().unwrap()[0]["permissions"],
1419 serde_json::json!({"statuses": "read"})
1420 );
1421 server.abort();
1422 }
1423
1424 #[tokio::test]
1425 async fn configured_installation_avoids_network_discovery() {
1426 let fake = fake_github(1_800_000_000);
1427 let (api_base, server) = test_server(fake.clone()).await;
1428 let now = Arc::clone(&fake.now);
1429 let mut config = test_config();
1430 config.installations.insert("acme".into(), 77331);
1431 let provider = GithubAppTokenProvider::with_test_transport(
1432 config,
1433 reqwest::Client::new(),
1434 api_base,
1435 Arc::new(move || UNIX_EPOCH + Duration::from_secs(now.load(Ordering::SeqCst))),
1436 );
1437 let seeded = provider
1438 .signing_key
1439 .set(Arc::new(parse_private_key(TEST_KEY).unwrap()));
1440 assert!(seeded.is_ok());
1441 assert_eq!(
1442 provider
1443 .installation_for_repo("ACME", "repo")
1444 .await
1445 .unwrap(),
1446 77331
1447 );
1448 assert_eq!(fake.lookups.load(Ordering::SeqCst), 0);
1449 server.abort();
1450 }
1451
1452 #[tokio::test]
1453 async fn bundle_selection_rejects_repositories_from_multiple_installations() {
1454 let mut config = mj_core::config::Config::default();
1455 config.github.app = Some(GithubAppConfig {
1456 installations: BTreeMap::from([("acme".into(), 11), ("widgets".into(), 22)]),
1457 ..test_config()
1458 });
1459 config.bundles.insert(
1460 "multi".into(),
1461 ProjectBundle {
1462 primary_repo: "app".into(),
1463 repositories: vec![
1464 ProjectRepository {
1465 id: "app".into(),
1466 github: Some("acme/app".into()),
1467 destination: "app".into(),
1468 ..ProjectRepository::default()
1469 },
1470 ProjectRepository {
1471 id: "shared".into(),
1472 github: Some("git@github.com:widgets/shared.git".into()),
1473 destination: "shared".into(),
1474 ..ProjectRepository::default()
1475 },
1476 ],
1477 },
1478 );
1479 let controller = Controller {
1480 config,
1481 state: mj_core::state::State::default(),
1482 };
1483
1484 let error = controller
1485 .validate_github_bundle_installations("multi")
1486 .await
1487 .unwrap_err();
1488 let GithubBundleSelectionError::MultipleInstallations(message) = error else {
1489 panic!("expected the one-installation limit, got {error}");
1490 };
1491 assert!(message.contains("one installation per session"));
1492 assert!(message.contains("acme/app") && message.contains("widgets/shared"));
1493
1494 let provider =
1495 GithubAppTokenProvider::shared(controller.config.github.app.as_ref().unwrap()).unwrap();
1496 let cli_error = provider
1497 .token_for_repositories(&[
1498 ("acme".into(), "app".into()),
1499 ("widgets".into(), "shared".into()),
1500 ])
1501 .await
1502 .unwrap_err();
1503 assert!(format!("{cli_error:#}").contains("span more than one GitHub App installation"));
1504 }
1505
1506 fn rsa_public_components(der: &[u8]) -> (&[u8], &[u8]) {
1507 let (sequence, sequence_len, mut rest) = der_tlv(der);
1508 assert_eq!(sequence, 0x30);
1509 assert_eq!(sequence_len, rest.len());
1510 let (modulus_tag, _, modulus) = der_tlv(rest);
1511 assert_eq!(modulus_tag, 0x02);
1512 rest = &rest[1 + der_length_size(rest[1]) + modulus.len()..];
1513 let (exponent_tag, _, exponent) = der_tlv(rest);
1514 assert_eq!(exponent_tag, 0x02);
1515 (modulus.strip_prefix(&[0]).unwrap_or(modulus), exponent)
1516 }
1517
1518 fn der_tlv(bytes: &[u8]) -> (u8, usize, &[u8]) {
1519 let tag = bytes[0];
1520 let first_len = bytes[1];
1521 let (length, length_size) = if first_len & 0x80 == 0 {
1522 (usize::from(first_len), 1)
1523 } else {
1524 let count = usize::from(first_len & 0x7f);
1525 let mut length = 0usize;
1526 for byte in &bytes[2..2 + count] {
1527 length = (length << 8) | usize::from(*byte);
1528 }
1529 (length, count + 1)
1530 };
1531 let start = 1 + length_size;
1532 (tag, length, &bytes[start..start + length])
1533 }
1534
1535 fn der_length_size(first_len: u8) -> usize {
1536 if first_len & 0x80 == 0 {
1537 1
1538 } else {
1539 usize::from(first_len & 0x7f) + 1
1540 }
1541 }
1542}