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