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 Ok(controller_github_token().await);
736 }
737 self.github_app_token_for_session(session_id).await
738 }
739
740 pub(crate) async fn github_token_for_repository_preflight(
744 &self,
745 session_id: &str,
746 ) -> Result<Option<String>> {
747 let has_github_repository = self
748 .state
749 .sessions
750 .get(session_id)
751 .and_then(|session| session.project_bundle(&self.config))
752 .is_some_and(|bundle| {
753 bundle
754 .repositories
755 .iter()
756 .any(|repository| repository.github.is_some())
757 });
758 if !has_github_repository {
759 return Ok(None);
760 }
761 self.github_token_for_session(session_id).await
762 }
763
764 pub(crate) async fn github_app_token_for_session(
765 &self,
766 session_id: &str,
767 ) -> Result<Option<String>> {
768 let Some(app) = self.config.github.app.as_ref() else {
769 return Ok(None);
770 };
771 let session = self
772 .state
773 .sessions
774 .get(session_id)
775 .with_context(|| format!("unknown session {session_id}"))?;
776 let Some(bundle) = session.project_bundle(&self.config).cloned() else {
777 return Ok(None);
778 };
779 let bundle_id = session.bundle_id.clone();
780 let network_sources = session
781 .project
782 .as_ref()
783 .map(|project| project.network_sources.clone());
784 let repositories = tokio::task::spawn_blocking(move || {
785 github_repositories(&bundle, network_sources.as_ref(), &ProcessExecutor)
786 })
787 .await
788 .context("GitHub repository source task failed")??;
789 let provider = GithubAppTokenProvider::shared(app)?;
790 let Some(scope) = provider
791 .token_for_owner_repo_pairs(&bundle_id, &repositories)
792 .await
793 .map_err(GithubBundleSelectionError::into_anyhow)?
794 else {
795 return Ok(None);
796 };
797 provider
798 .token_for_installation(
799 scope.installation_id,
800 &scope.repositories,
801 app.session_permissions.as_ref(),
802 )
803 .await
804 .map(Some)
805 }
806}
807
808pub(crate) async fn github_token_for_session(
809 session_id: String,
810 github_app_configured: bool,
811) -> Result<Option<String>> {
812 if !github_app_configured {
813 return Ok(controller_github_token().await);
814 }
815 let controller = tokio::task::spawn_blocking(Controller::load)
816 .await
817 .context("load controller for GitHub credential sync")??;
818 if controller.config.github.app.is_none() {
819 return Ok(controller_github_token().await);
820 }
821 controller.github_token_for_session(&session_id).await
822}
823
824pub(crate) async fn github_app_token_for_session(session_id: String) -> Result<Option<String>> {
825 let controller = tokio::task::spawn_blocking(Controller::load)
826 .await
827 .context("load controller for GitHub App export token")??;
828 controller.github_app_token_for_session(&session_id).await
829}
830
831pub(super) fn github_repositories(
832 bundle: &ProjectBundle,
833 network_sources: Option<&BTreeMap<String, mj_core::remote_git::NetworkGitSource>>,
834 executor: &impl CommandExecutor,
835) -> Result<Vec<(String, String)>> {
836 let mut repositories = Vec::new();
837 for repository in &bundle.repositories {
838 let source = repository_source(repository, network_sources, executor)?;
839 if let Some((owner, name)) = source.as_deref().and_then(github_owner_repo) {
840 repositories.push((owner, name));
841 }
842 }
843 repositories.sort();
844 repositories.dedup();
845 Ok(repositories)
846}
847
848fn repository_source(
849 repository: &ProjectRepository,
850 network_sources: Option<&BTreeMap<String, mj_core::remote_git::NetworkGitSource>>,
851 executor: &impl CommandExecutor,
852) -> Result<Option<String>> {
853 if let Some(source) = &repository.github {
854 return Ok(Some(source.clone()));
855 }
856 if let Some(source) = network_sources
857 .and_then(|sources| sources.get(&repository.id))
858 .map(|source| source.fetch_url.clone())
859 {
860 return Ok(Some(source));
861 }
862 if repository.local.is_some() {
863 return resolve_repository(repository, executor).map(|source| Some(source.fetch_url));
864 }
865 Ok(None)
866}
867
868enum InstallationLookupError {
869 NotFound,
870 Other(anyhow::Error),
871}
872
873impl InstallationLookupError {
874 fn into_anyhow(self) -> anyhow::Error {
875 match self {
876 Self::NotFound => {
877 anyhow!("no GitHub App installation was found for this owner or repository")
878 }
879 Self::Other(error) => error,
880 }
881 }
882}
883
884fn valid_repository(repository: &str) -> bool {
885 !repository.is_empty()
886 && repository.len() <= 100
887 && repository != "."
888 && repository != ".."
889 && repository
890 .bytes()
891 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
892}
893
894fn parse_private_key(pem: &[u8]) -> Result<RsaKeyPair> {
895 let pem = std::str::from_utf8(pem).context("GitHub App private key is not UTF-8 PEM")?;
896 let (label, body) = [
897 (
898 "RSA PRIVATE KEY",
899 "-----BEGIN RSA PRIVATE KEY-----",
900 "-----END RSA PRIVATE KEY-----",
901 ),
902 (
903 "PRIVATE KEY",
904 "-----BEGIN PRIVATE KEY-----",
905 "-----END PRIVATE KEY-----",
906 ),
907 ]
908 .into_iter()
909 .find_map(|(label, begin, end)| {
910 let body = pem.strip_prefix(begin)?.split_once(end)?.0;
911 Some((label, body))
912 })
913 .ok_or_else(|| anyhow!("GitHub App key must be an RSA PKCS#1 or PKCS#8 PEM private key"))?;
914 let der = STANDARD
915 .decode(
916 body.bytes()
917 .filter(|byte| !byte.is_ascii_whitespace())
918 .collect::<Vec<_>>(),
919 )
920 .context("decode GitHub App PEM private key")?;
921 let key = match label {
922 "PRIVATE KEY" => RsaKeyPair::from_pkcs8(&der),
923 "RSA PRIVATE KEY" => RsaKeyPair::from_der(&der),
924 _ => unreachable!("PEM label comes from the fixed list above"),
925 };
926 key.map_err(|_| anyhow!("GitHub App PEM does not contain a valid RSA private key"))
927}
928
929fn mint_app_jwt(config: &GithubAppConfig, key: &RsaKeyPair, now: SystemTime) -> Result<String> {
930 let now: i64 = now
931 .duration_since(UNIX_EPOCH)
932 .context("system clock is before the Unix epoch")?
933 .as_secs()
934 .try_into()
935 .context("system time is outside the supported JWT range")?;
936 let header = serde_json::to_vec(&JwtHeader {
937 alg: "RS256",
938 typ: "JWT",
939 })?;
940 let claims = serde_json::to_vec(&JwtClaims {
941 iss: config.app_id.to_string(),
942 iat: now - JWT_BACKDATE_SECS,
943 exp: now + JWT_LIFETIME_SECS,
944 })?;
945 let signing_input = format!(
946 "{}.{}",
947 URL_SAFE_NO_PAD.encode(header),
948 URL_SAFE_NO_PAD.encode(claims)
949 );
950 let mut signature = vec![0; key.public().modulus_len()];
951 key.sign(
952 &RSA_PKCS1_SHA256,
953 &SystemRandom::new(),
954 signing_input.as_bytes(),
955 &mut signature,
956 )
957 .map_err(|_| anyhow!("sign GitHub App JWT"))?;
958 Ok(format!(
959 "{signing_input}.{}",
960 URL_SAFE_NO_PAD.encode(signature)
961 ))
962}
963
964#[cfg(test)]
965mod tests {
966 use std::path::PathBuf;
967 use std::sync::atomic::{AtomicU64, Ordering};
968
969 use axum::extract::Path;
970 use axum::extract::State;
971 use axum::routing::{get, post};
972 use axum::{Json, Router};
973 use ring::signature::{KeyPair, RSA_PKCS1_2048_8192_SHA256, RsaPublicKeyComponents};
974 use serde_json::Value;
975 use tokio::net::TcpListener;
976
977 use super::*;
978
979 const TEST_KEY: &[u8] = include_bytes!("testdata/github-app-test.pem");
980
981 #[derive(Clone)]
982 struct FakeGithub {
983 now: Arc<AtomicU64>,
984 lookups: Arc<AtomicU64>,
985 exchanges: Arc<AtomicU64>,
986 scopes: Arc<Mutex<Vec<Vec<String>>>>,
987 bodies: Arc<Mutex<Vec<Value>>>,
988 installation_permissions: Arc<Mutex<BTreeMap<String, String>>>,
989 }
990
991 async fn installation(State(fake): State<FakeGithub>) -> Json<Value> {
992 fake.lookups.fetch_add(1, Ordering::SeqCst);
993 Json(serde_json::json!({ "id": 77331 }))
994 }
995
996 async fn installation_details(
997 State(fake): State<FakeGithub>,
998 Path(installation_id): Path<u64>,
999 ) -> Json<Value> {
1000 Json(serde_json::json!({
1001 "id": installation_id,
1002 "permissions": fake.installation_permissions.lock().unwrap().clone(),
1003 }))
1004 }
1005
1006 async fn access_token(State(fake): State<FakeGithub>, Json(body): Json<Value>) -> Json<Value> {
1007 let exchange = fake.exchanges.fetch_add(1, Ordering::SeqCst) + 1;
1008 fake.bodies.lock().unwrap().push(body.clone());
1009 let repositories = body
1010 .get("repositories")
1011 .and_then(Value::as_array)
1012 .map(|repositories| {
1013 repositories
1014 .iter()
1015 .map(|repository| repository.as_str().unwrap().to_owned())
1016 .collect()
1017 })
1018 .unwrap_or_default();
1019 fake.scopes.lock().unwrap().push(repositories);
1020 let permissions = body.get("permissions").cloned().unwrap_or_else(|| {
1021 serde_json::to_value(fake.installation_permissions.lock().unwrap().clone()).unwrap()
1022 });
1023 Json(serde_json::json!({
1024 "token": format!("test-installation-token-{exchange}"),
1025 "permissions": permissions,
1026 "expires_at": chrono::DateTime::from_timestamp(
1027 fake.now.load(Ordering::SeqCst) as i64 + 3600,
1028 0,
1029 ).unwrap().to_rfc3339(),
1030 }))
1031 }
1032
1033 async fn missing_installation() -> axum::http::StatusCode {
1034 axum::http::StatusCode::NOT_FOUND
1035 }
1036
1037 async fn test_server(fake: FakeGithub) -> (Url, tokio::task::JoinHandle<()>) {
1038 let app = Router::new()
1039 .route("/repos/{owner}/{repo}/installation", get(installation))
1040 .route("/orgs/{owner}/installation", get(installation))
1041 .route("/users/{owner}/installation", get(missing_installation))
1042 .route("/app/installations/{id}", get(installation_details))
1043 .route("/app/installations/{id}/access_tokens", post(access_token))
1044 .with_state(fake);
1045 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1046 let address = listener.local_addr().unwrap();
1047 let task = tokio::spawn(async move {
1048 axum::serve(listener, app).await.unwrap();
1049 });
1050 (Url::parse(&format!("http://{address}/")).unwrap(), task)
1051 }
1052
1053 fn test_config() -> GithubAppConfig {
1054 GithubAppConfig {
1055 app_id: 12_345,
1056 private_key_path: PathBuf::from("test.pem"),
1057 installations: BTreeMap::new(),
1058 session_permissions: None,
1059 token_permissions: None,
1060 }
1061 }
1062
1063 fn fake_github(now: u64) -> FakeGithub {
1064 FakeGithub {
1065 now: Arc::new(AtomicU64::new(now)),
1066 lookups: Arc::new(AtomicU64::new(0)),
1067 exchanges: Arc::new(AtomicU64::new(0)),
1068 scopes: Arc::new(Mutex::new(Vec::new())),
1069 bodies: Arc::new(Mutex::new(Vec::new())),
1070 installation_permissions: Arc::new(Mutex::new(BTreeMap::from([
1071 ("contents".into(), "write".into()),
1072 ("metadata".into(), "read".into()),
1073 ("statuses".into(), "read".into()),
1074 ]))),
1075 }
1076 }
1077
1078 #[test]
1079 fn jwt_has_expected_claims_and_a_valid_rs256_signature() {
1080 let key = parse_private_key(TEST_KEY).unwrap();
1081 let config = test_config();
1082 let now = UNIX_EPOCH + Duration::from_secs(1_800_000_000);
1083 let jwt = mint_app_jwt(&config, &key, now).unwrap();
1084 let mut parts = jwt.split('.');
1085 let header_part = parts.next().unwrap();
1086 let claims_part = parts.next().unwrap();
1087 let signature_part = parts.next().unwrap();
1088 let header = URL_SAFE_NO_PAD.decode(header_part).unwrap();
1089 let claims = URL_SAFE_NO_PAD.decode(claims_part).unwrap();
1090 let signature = URL_SAFE_NO_PAD.decode(signature_part).unwrap();
1091 assert!(parts.next().is_none());
1092 assert_eq!(
1093 serde_json::from_slice::<Value>(&header).unwrap()["alg"],
1094 "RS256"
1095 );
1096 let claims_json: Value = serde_json::from_slice(&claims).unwrap();
1097 assert_eq!(claims_json["iss"], config.app_id.to_string());
1098 assert_eq!(claims_json["iat"], 1_800_000_000 - JWT_BACKDATE_SECS);
1099 assert_eq!(claims_json["exp"], 1_800_000_000 + JWT_LIFETIME_SECS);
1100
1101 let signing_input = format!("{header_part}.{claims_part}");
1102 let (n, e) = rsa_public_components(key.public_key().as_ref());
1103 RsaPublicKeyComponents { n, e }
1104 .verify(
1105 &RSA_PKCS1_2048_8192_SHA256,
1106 signing_input.as_bytes(),
1107 &signature,
1108 )
1109 .unwrap();
1110 }
1111
1112 #[cfg(unix)]
1113 #[test]
1114 fn private_key_permissions_require_daemon_ownership_and_private_mode() {
1115 let path = std::path::Path::new("/var/lib/mj/github-app.pem");
1116 assert!(validate_private_key_metadata(path, 1000, 0o100600, 1000).is_ok());
1117
1118 for (uid, mode) in [(1000, 0o100640), (1001, 0o100600)] {
1119 let error = validate_private_key_metadata(path, uid, mode, 1000)
1120 .unwrap_err()
1121 .to_string();
1122 assert!(error.contains(path.to_str().unwrap()));
1123 assert!(error.contains("chmod 600"));
1124 }
1125 }
1126
1127 #[cfg(unix)]
1128 #[test]
1129 fn private_key_file_check_rejects_group_read_permission() {
1130 use std::os::unix::fs::PermissionsExt as _;
1131
1132 let directory = tempfile::tempdir().unwrap();
1133 let path = directory.path().join("github-app.pem");
1134 fs::write(&path, TEST_KEY).unwrap();
1135 fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
1136
1137 let error = read_private_key_file(&path).unwrap_err();
1138 let message = error.to_string();
1139 assert!(message.contains(path.to_str().unwrap()));
1140 assert!(message.contains("chmod 600"));
1141 }
1142
1143 #[tokio::test]
1144 async fn installation_lookup_and_token_cache_refresh_below_ten_minutes() {
1145 let fake = fake_github(1_800_000_000);
1146 let (api_base, server) = test_server(fake.clone()).await;
1147 let now_for_clock = Arc::clone(&fake.now);
1148 let config = test_config();
1149 let provider = GithubAppTokenProvider::with_test_transport(
1150 config,
1151 reqwest::Client::new(),
1152 api_base,
1153 Arc::new(move || {
1154 UNIX_EPOCH + Duration::from_secs(now_for_clock.load(Ordering::SeqCst))
1155 }),
1156 );
1157
1158 let seeded = provider
1161 .signing_key
1162 .set(Arc::new(parse_private_key(TEST_KEY).unwrap()));
1163 assert!(seeded.is_ok());
1164 assert_eq!(
1165 provider
1166 .token_for_repositories(&[("Acme".into(), "widget".into())])
1167 .await
1168 .unwrap(),
1169 "test-installation-token-1"
1170 );
1171 assert_eq!(
1172 provider
1173 .token_for_repositories(&[("acme".into(), "widget".into())])
1174 .await
1175 .unwrap(),
1176 "test-installation-token-1"
1177 );
1178 assert_eq!(fake.lookups.load(Ordering::SeqCst), 1);
1179 assert_eq!(fake.exchanges.load(Ordering::SeqCst), 1);
1180 assert_eq!(*fake.scopes.lock().unwrap(), [vec!["widget".to_owned()]]);
1181 assert!(fake.bodies.lock().unwrap()[0].get("permissions").is_none());
1182
1183 fake.now.store(1_800_000_000 + 3600 - 601, Ordering::SeqCst);
1184 assert_eq!(
1185 provider
1186 .token_for_repositories(&[("acme".into(), "widget".into())])
1187 .await
1188 .unwrap(),
1189 "test-installation-token-1"
1190 );
1191 assert_eq!(fake.exchanges.load(Ordering::SeqCst), 1);
1192
1193 fake.now.store(1_800_000_000 + 3600 - 599, Ordering::SeqCst);
1194 assert_eq!(
1195 provider
1196 .token_for_repositories(&[("acme".into(), "widget".into())])
1197 .await
1198 .unwrap(),
1199 "test-installation-token-2"
1200 );
1201 assert_eq!(fake.exchanges.load(Ordering::SeqCst), 2);
1202 server.abort();
1203 }
1204
1205 #[tokio::test]
1206 async fn token_cache_separates_installation_wide_and_sorted_repository_scopes() {
1207 let fake = fake_github(1_800_000_000);
1208 let (api_base, server) = test_server(fake.clone()).await;
1209 let mut config = test_config();
1210 config.installations.insert("acme".into(), 77331);
1211 let provider = GithubAppTokenProvider::with_test_transport(
1212 config,
1213 reqwest::Client::new(),
1214 api_base,
1215 Arc::new(|| UNIX_EPOCH + Duration::from_secs(1_800_000_000)),
1216 );
1217 let seeded = provider
1218 .signing_key
1219 .set(Arc::new(parse_private_key(TEST_KEY).unwrap()));
1220 assert!(seeded.is_ok());
1221
1222 let first_scope = vec!["Zebra".to_owned(), "Alpha".to_owned()];
1223 let reordered_scope = vec!["alpha".to_owned(), "zebra".to_owned()];
1224 let write_contents = BTreeMap::from([("contents".into(), GithubPermissionLevel::Write)]);
1225 let read_contents = BTreeMap::from([("contents".into(), GithubPermissionLevel::Read)]);
1226 let first = provider
1227 .token_for_installation(77331, &first_scope, Some(&write_contents))
1228 .await
1229 .unwrap();
1230 let cached = provider
1231 .token_for_installation(77331, &reordered_scope, Some(&write_contents))
1232 .await
1233 .unwrap();
1234 let different_permissions = provider
1235 .token_for_installation(77331, &first_scope, Some(&read_contents))
1236 .await
1237 .unwrap();
1238 let narrower = provider
1239 .token_for_installation(77331, &["alpha".to_owned()], None)
1240 .await
1241 .unwrap();
1242 let installation_wide = provider
1243 .token_for_installation(77331, &[], None)
1244 .await
1245 .unwrap();
1246
1247 assert_eq!(first, cached);
1248 assert_ne!(first, narrower);
1249 assert_ne!(first, different_permissions);
1250 assert_ne!(narrower, installation_wide);
1251 assert_eq!(fake.exchanges.load(Ordering::SeqCst), 4);
1252 assert_eq!(
1253 *fake.scopes.lock().unwrap(),
1254 [
1255 vec!["alpha".to_owned(), "zebra".to_owned()],
1256 vec!["alpha".to_owned(), "zebra".to_owned()],
1257 vec!["alpha".to_owned()],
1258 vec![]
1259 ]
1260 );
1261 assert_eq!(
1262 fake.bodies.lock().unwrap()[0]["permissions"]["contents"],
1263 "write"
1264 );
1265 assert_eq!(
1266 fake.bodies.lock().unwrap()[1]["permissions"]["contents"],
1267 "read"
1268 );
1269 server.abort();
1270 }
1271
1272 #[tokio::test]
1273 async fn permission_subset_is_minted_with_repository_scope() {
1274 let fake = fake_github(1_800_000_000);
1275 let (api_base, server) = test_server(fake.clone()).await;
1276 let mut config = test_config();
1277 config.installations.insert("acme".into(), 77331);
1278 config.session_permissions = Some(BTreeMap::from([
1279 ("contents".into(), GithubPermissionLevel::Write),
1280 ("statuses".into(), GithubPermissionLevel::Read),
1281 ]));
1282 let provider = GithubAppTokenProvider::with_test_transport(
1283 config.clone(),
1284 reqwest::Client::new(),
1285 api_base,
1286 Arc::new(|| UNIX_EPOCH + Duration::from_secs(1_800_000_000)),
1287 );
1288 let seeded = provider
1289 .signing_key
1290 .set(Arc::new(parse_private_key(TEST_KEY).unwrap()));
1291 assert!(seeded.is_ok());
1292
1293 provider
1294 .token_for_installation(
1295 77331,
1296 &["widget".to_owned()],
1297 config.session_permissions.as_ref(),
1298 )
1299 .await
1300 .unwrap();
1301
1302 assert_eq!(
1303 fake.bodies.lock().unwrap()[0],
1304 serde_json::json!({
1305 "repositories": ["widget"],
1306 "permissions": {"contents": "write", "statuses": "read"}
1307 })
1308 );
1309 server.abort();
1310 }
1311
1312 #[tokio::test]
1313 async fn permission_request_above_installation_grant_is_refused_before_minting() {
1314 let fake = fake_github(1_800_000_000);
1315 let (api_base, server) = test_server(fake.clone()).await;
1316 let mut config = test_config();
1317 config.installations.insert("acme".into(), 77331);
1318 config.token_permissions = Some(BTreeMap::from([(
1319 "statuses".into(),
1320 GithubPermissionLevel::Write,
1321 )]));
1322 let provider = GithubAppTokenProvider::with_test_transport(
1323 config,
1324 reqwest::Client::new(),
1325 api_base,
1326 Arc::new(|| UNIX_EPOCH + Duration::from_secs(1_800_000_000)),
1327 );
1328 let seeded = provider
1329 .signing_key
1330 .set(Arc::new(parse_private_key(TEST_KEY).unwrap()));
1331 assert!(seeded.is_ok());
1332
1333 let error = provider.token_for_owner("acme").await.unwrap_err();
1334 let message = format!("{error:#}");
1335 assert!(message.contains("statuses"), "{message}");
1336 assert!(
1337 message.contains("read") && message.contains("write"),
1338 "{message}"
1339 );
1340 assert_eq!(fake.exchanges.load(Ordering::SeqCst), 0);
1341 assert!(fake.bodies.lock().unwrap().is_empty());
1342 server.abort();
1343 }
1344
1345 #[tokio::test]
1346 async fn cached_token_does_not_hide_a_newly_insufficient_permission_grant() {
1347 let fake = fake_github(1_800_000_000);
1348 let (api_base, server) = test_server(fake.clone()).await;
1349 let mut config = test_config();
1350 config.installations.insert("acme".into(), 77331);
1351 config.token_permissions = Some(BTreeMap::from([(
1352 "contents".into(),
1353 GithubPermissionLevel::Write,
1354 )]));
1355 let now = Arc::clone(&fake.now);
1356 let provider = GithubAppTokenProvider::with_test_transport(
1357 config,
1358 reqwest::Client::new(),
1359 api_base,
1360 Arc::new(move || UNIX_EPOCH + Duration::from_secs(now.load(Ordering::SeqCst))),
1361 );
1362 let seeded = provider
1363 .signing_key
1364 .set(Arc::new(parse_private_key(TEST_KEY).unwrap()));
1365 assert!(seeded.is_ok());
1366 provider.token_for_owner("acme").await.unwrap();
1367
1368 fake.installation_permissions
1369 .lock()
1370 .unwrap()
1371 .insert("contents".into(), "read".into());
1372 fake.now.store(1_800_000_000 + 3600 - 599, Ordering::SeqCst);
1373 let error = provider.token_for_owner("acme").await.unwrap_err();
1374 let message = format!("{error:#}");
1375 assert!(message.contains("contents"), "{message}");
1376 assert!(
1377 message.contains("read") && message.contains("write"),
1378 "{message}"
1379 );
1380 assert_eq!(fake.exchanges.load(Ordering::SeqCst), 1);
1381 server.abort();
1382 }
1383
1384 #[tokio::test]
1385 async fn token_command_uses_its_configured_permission_table() {
1386 let fake = fake_github(1_800_000_000);
1387 let (api_base, server) = test_server(fake.clone()).await;
1388 let mut config = test_config();
1389 config.installations.insert("acme".into(), 77331);
1390 config.session_permissions = Some(BTreeMap::from([(
1391 "contents".into(),
1392 GithubPermissionLevel::Read,
1393 )]));
1394 config.token_permissions = Some(BTreeMap::from([(
1395 "statuses".into(),
1396 GithubPermissionLevel::Read,
1397 )]));
1398 let provider = GithubAppTokenProvider::with_test_transport(
1399 config,
1400 reqwest::Client::new(),
1401 api_base,
1402 Arc::new(|| UNIX_EPOCH + Duration::from_secs(1_800_000_000)),
1403 );
1404 let seeded = provider
1405 .signing_key
1406 .set(Arc::new(parse_private_key(TEST_KEY).unwrap()));
1407 assert!(seeded.is_ok());
1408
1409 provider.token_for_owner("acme").await.unwrap();
1410
1411 assert_eq!(
1412 fake.bodies.lock().unwrap()[0]["permissions"],
1413 serde_json::json!({"statuses": "read"})
1414 );
1415 server.abort();
1416 }
1417
1418 #[tokio::test]
1419 async fn configured_installation_avoids_network_discovery() {
1420 let fake = fake_github(1_800_000_000);
1421 let (api_base, server) = test_server(fake.clone()).await;
1422 let now = Arc::clone(&fake.now);
1423 let mut config = test_config();
1424 config.installations.insert("acme".into(), 77331);
1425 let provider = GithubAppTokenProvider::with_test_transport(
1426 config,
1427 reqwest::Client::new(),
1428 api_base,
1429 Arc::new(move || UNIX_EPOCH + Duration::from_secs(now.load(Ordering::SeqCst))),
1430 );
1431 let seeded = provider
1432 .signing_key
1433 .set(Arc::new(parse_private_key(TEST_KEY).unwrap()));
1434 assert!(seeded.is_ok());
1435 assert_eq!(
1436 provider
1437 .installation_for_repo("ACME", "repo")
1438 .await
1439 .unwrap(),
1440 77331
1441 );
1442 assert_eq!(fake.lookups.load(Ordering::SeqCst), 0);
1443 server.abort();
1444 }
1445
1446 #[tokio::test]
1447 async fn bundle_selection_rejects_repositories_from_multiple_installations() {
1448 let mut config = mj_core::config::Config::default();
1449 config.github.app = Some(GithubAppConfig {
1450 installations: BTreeMap::from([("acme".into(), 11), ("widgets".into(), 22)]),
1451 ..test_config()
1452 });
1453 config.bundles.insert(
1454 "multi".into(),
1455 ProjectBundle {
1456 primary_repo: "app".into(),
1457 repositories: vec![
1458 ProjectRepository {
1459 id: "app".into(),
1460 github: Some("acme/app".into()),
1461 destination: "app".into(),
1462 ..ProjectRepository::default()
1463 },
1464 ProjectRepository {
1465 id: "shared".into(),
1466 github: Some("git@github.com:widgets/shared.git".into()),
1467 destination: "shared".into(),
1468 ..ProjectRepository::default()
1469 },
1470 ],
1471 },
1472 );
1473 let controller = Controller {
1474 config,
1475 state: mj_core::state::State::default(),
1476 };
1477
1478 let error = controller
1479 .validate_github_bundle_installations("multi")
1480 .await
1481 .unwrap_err();
1482 let GithubBundleSelectionError::MultipleInstallations(message) = error else {
1483 panic!("expected the one-installation limit, got {error}");
1484 };
1485 assert!(message.contains("one installation per session"));
1486 assert!(message.contains("acme/app") && message.contains("widgets/shared"));
1487
1488 let provider =
1489 GithubAppTokenProvider::shared(controller.config.github.app.as_ref().unwrap()).unwrap();
1490 let cli_error = provider
1491 .token_for_repositories(&[
1492 ("acme".into(), "app".into()),
1493 ("widgets".into(), "shared".into()),
1494 ])
1495 .await
1496 .unwrap_err();
1497 assert!(format!("{cli_error:#}").contains("span more than one GitHub App installation"));
1498 }
1499
1500 fn rsa_public_components(der: &[u8]) -> (&[u8], &[u8]) {
1501 let (sequence, sequence_len, mut rest) = der_tlv(der);
1502 assert_eq!(sequence, 0x30);
1503 assert_eq!(sequence_len, rest.len());
1504 let (modulus_tag, _, modulus) = der_tlv(rest);
1505 assert_eq!(modulus_tag, 0x02);
1506 rest = &rest[1 + der_length_size(rest[1]) + modulus.len()..];
1507 let (exponent_tag, _, exponent) = der_tlv(rest);
1508 assert_eq!(exponent_tag, 0x02);
1509 (modulus.strip_prefix(&[0]).unwrap_or(modulus), exponent)
1510 }
1511
1512 fn der_tlv(bytes: &[u8]) -> (u8, usize, &[u8]) {
1513 let tag = bytes[0];
1514 let first_len = bytes[1];
1515 let (length, length_size) = if first_len & 0x80 == 0 {
1516 (usize::from(first_len), 1)
1517 } else {
1518 let count = usize::from(first_len & 0x7f);
1519 let mut length = 0usize;
1520 for byte in &bytes[2..2 + count] {
1521 length = (length << 8) | usize::from(*byte);
1522 }
1523 (length, count + 1)
1524 };
1525 let start = 1 + length_size;
1526 (tag, length, &bytes[start..start + length])
1527 }
1528
1529 fn der_length_size(first_len: u8) -> usize {
1530 if first_len & 0x80 == 0 {
1531 1
1532 } else {
1533 usize::from(first_len & 0x7f) + 1
1534 }
1535 }
1536}