Skip to main content

mj_controller/controller/
github_app.rs

1//! GitHub App JWTs and installation access tokens owned by the controller.
2
3use 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
32/// One controller process shares this cache between provisioning, resume,
33/// periodic worker reconciliation, and the token API.
34pub(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    /// Reuse one provider for the current App configuration so token calls
109    /// made by separate controller operations still share a cache.
110    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        // SAFETY: geteuid reads the effective UID of the current daemon process.
637        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// Unix file ownership and mode bits; only the Unix key reader has them.
651#[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/// Why a bundle's GitHub repositories could not select one App installation.
668#[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    /// Reject a new bundle before admitting its session when its repositories
699    /// resolve to more than one App installation.
700    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    /// Resolve this session's GitHub credential, using the App installation
729    /// selected by its accepted repository bundle.
730    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    /// Repository source preflight only needs a token when the accepted bundle
741    /// contains a GitHub repository. Preserve the legacy path's lazy lookup so
742    /// unrelated resumes do not spawn `gh auth token`.
743    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        // The fake key path is replaced with the test fixture by seeding the
1159        // cell, so no key file is read from the developer's machine.
1160        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}