1#![doc = include_str!("../README.md")]
29#![forbid(unsafe_code)]
30
31use std::collections::BTreeMap;
32use std::sync::Arc;
33use std::time::Duration;
34
35use bytes::Bytes;
36use cratefield_core::{Clock, HttpClient, HttpError};
37use cratefield_push_auth::{CachedToken, Rs256Signer};
38use http::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, ETAG, IF_NONE_MATCH, LINK, USER_AGENT};
39use http::{HeaderMap, HeaderName, HeaderValue, Request, Response, StatusCode, Uri};
40use time::OffsetDateTime;
41use time::format_description::well_known::Rfc3339;
42
43pub const DEFAULT_API_BASE: &str = "https://api.github.com";
45pub const DEFAULT_WEB_BASE: &str = "https://github.com";
49
50const API_VERSION: &str = "2022-11-28";
52const ACCEPT_VALUE: &str = "application/vnd.github+json";
54const USER_AGENT_VALUE: &str = "cratefield-adapter-github-app";
56const API_VERSION_HEADER: &str = "x-github-api-version";
58
59const TOKEN_TTL: Duration = Duration::from_mins(55);
63const TOKEN_SAFETY_MARGIN: time::Duration = time::Duration::minutes(5);
67const JWT_BACKDATE_SECS: i64 = 60;
70const JWT_LIFETIME_SECS: i64 = 540;
72const INSTALLATIONS_PAGE_CAP: usize = 10;
76
77pub struct GithubApp {
93 app_id: u64,
97 key: AppKey,
99 http: Arc<dyn HttpClient>,
100 clock: Arc<dyn Clock>,
101 api_base: String,
103 web_base: String,
105 tokens: CachedToken<(u64, String)>,
107 exchange: futures_util::lock::Mutex<()>,
112}
113
114enum AppKey {
118 Absent,
120 Invalid,
124 Signer(Box<Rs256Signer>),
126}
127
128impl AppKey {
129 fn is_signer(&self) -> bool {
130 matches!(self, AppKey::Signer(_))
131 }
132}
133
134impl std::fmt::Debug for GithubApp {
135 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138 f.debug_struct("GithubApp")
139 .field("app_id", &self.app_id)
140 .field("api_base", &self.api_base)
141 .field("web_base", &self.web_base)
142 .field("configured", &self.key.is_signer())
143 .finish_non_exhaustive()
144 }
145}
146
147impl GithubApp {
148 #[must_use]
155 pub fn new(
156 app_id: u64,
157 private_key_pem: Option<String>,
158 http: Arc<dyn HttpClient>,
159 clock: Arc<dyn Clock>,
160 api_base: impl Into<String>,
161 ) -> Self {
162 let api_base = api_base.into();
163 let key = match private_key_pem {
164 Some(pem) if !pem.trim().is_empty() => match Rs256Signer::from_pem(&pem) {
165 Ok(signer) => AppKey::Signer(Box::new(signer)),
166 Err(_) => AppKey::Invalid,
167 },
168 _ => AppKey::Absent,
169 };
170 Self {
171 app_id,
172 key,
173 http,
174 clock,
175 api_base: trim_base(&api_base),
176 web_base: DEFAULT_WEB_BASE.to_owned(),
177 tokens: CachedToken::new(TOKEN_TTL),
178 exchange: futures_util::lock::Mutex::new(()),
179 }
180 }
181
182 #[must_use]
186 pub fn with_web_base(mut self, web_base: impl Into<String>) -> Self {
187 let web_base = web_base.into();
188 self.web_base = trim_base(&web_base);
189 self
190 }
191
192 #[must_use]
194 pub fn app_id(&self) -> u64 {
195 self.app_id
196 }
197
198 #[must_use]
200 pub fn is_configured(&self) -> bool {
201 self.key.is_signer()
202 }
203
204 fn signer(&self) -> Result<&Rs256Signer, GithubAppError> {
208 match &self.key {
209 AppKey::Signer(signer) => Ok(signer),
210 AppKey::Absent => Err(GithubAppError::NotConfigured),
211 AppKey::Invalid => Err(GithubAppError::InvalidKey),
212 }
213 }
214
215 pub fn app_jwt(&self) -> Result<String, GithubAppError> {
225 let signer = self.signer()?;
226 let now = self.clock.now().unix_timestamp();
227 let header = serde_json::json!({ "alg": "RS256", "typ": "JWT" });
228 let claims = serde_json::json!({
229 "iat": now - JWT_BACKDATE_SECS,
230 "exp": now + JWT_LIFETIME_SECS,
231 "iss": self.app_id.to_string(),
232 });
233 Ok(signer.sign_jwt(&header, &claims))
234 }
235
236 pub async fn installation_token(
264 &self,
265 installation_id: u64,
266 permissions: Option<&BTreeMap<String, String>>,
267 repositories: Option<&[String]>,
268 ) -> Result<InstallationToken, GithubAppError> {
269 self.signer()?;
270 let key = (installation_id, scope_key(permissions, repositories));
271
272 let _single_flight = self.exchange.lock().await;
276
277 if let Some(cached) = self.tokens.cached(self.clock.as_ref(), &key) {
278 if let Ok(wire) = serde_json::from_str::<CachedTokenWire>(&cached) {
279 return Ok(wire.into_token());
280 }
281 self.tokens.invalidate(&key);
284 }
285
286 let token = self
287 .exchange_installation_token(installation_id, permissions, repositories)
288 .await?;
289
290 let now = self.clock.now();
293 let usable = token.expires_at - TOKEN_SAFETY_MARGIN - now;
294 if let Ok(lifetime) = std::time::Duration::try_from(usable)
295 && lifetime > Duration::ZERO
296 && let Ok(json) = serde_json::to_string(&CachedTokenWire::from(&token))
297 {
298 self.tokens.store(self.clock.as_ref(), &key, json, lifetime);
299 }
300 tracing::info!(
301 provider = "github-app",
302 installation = installation_id,
303 outcome = "token-minted",
304 "github app token"
305 );
306 Ok(token)
307 }
308
309 async fn exchange_installation_token(
311 &self,
312 installation_id: u64,
313 permissions: Option<&BTreeMap<String, String>>,
314 repositories: Option<&[String]>,
315 ) -> Result<InstallationToken, GithubAppError> {
316 let jwt = self.app_jwt()?;
317 let body = serde_json::to_vec(&TokenRequest {
318 permissions,
319 repositories,
320 })
321 .map_err(|err| GithubAppError::Decode(err.to_string()))?;
322 let uri: Uri = format!(
323 "{}/app/installations/{installation_id}/access_tokens",
324 self.api_base
325 )
326 .parse()
327 .map_err(|_| GithubAppError::InvalidHeader)?;
328 let auth = HeaderValue::from_str(&format!("Bearer {jwt}"))
329 .map_err(|_| GithubAppError::InvalidHeader)?;
330 let request = Request::builder()
331 .method(http::Method::POST)
332 .uri(uri)
333 .header(AUTHORIZATION, auth)
334 .header(ACCEPT, ACCEPT_VALUE)
335 .header(CONTENT_TYPE, "application/json")
336 .header(API_VERSION_HEADER, API_VERSION)
337 .header(USER_AGENT, USER_AGENT_VALUE)
338 .body(Bytes::from(body))
339 .map_err(|_| GithubAppError::InvalidHeader)?;
340
341 let response = self.http.send(request).await?;
342 let status = response.status();
343 let rate_limit = RateLimit::from_headers(response.headers());
344 if !status.is_success() {
345 tracing::warn!(
346 provider = "github-app",
347 installation = installation_id,
348 code = status.as_u16(),
349 outcome = "token-refused",
350 "github app token"
351 );
352 return Err(GithubAppError::Status { status, rate_limit });
353 }
354 let wire: InstallationTokenWire = serde_json::from_slice(response.body())
355 .map_err(|err| GithubAppError::Decode(err.to_string()))?;
356 wire.into_token()
357 }
358
359 pub async fn request(
388 &self,
389 installation_id: u64,
390 request: Request<Bytes>,
391 ) -> Result<GithubResponse, GithubAppError> {
392 self.signer()?;
393 let caller_authenticated = request.headers().contains_key(AUTHORIZATION);
394 if !caller_authenticated {
395 let target = resolve_uri(&self.api_base, request.uri())?;
396 if !same_origin(&self.api_base, &target.to_string()) {
397 tracing::warn!(
398 provider = "github-app",
399 installation = installation_id,
400 outcome = "foreign-origin-refused",
401 "github app request"
402 );
403 return Err(GithubAppError::ForeignOrigin);
404 }
405 }
406
407 let mut credential = if caller_authenticated {
408 None
409 } else {
410 Some(
411 self.installation_token(installation_id, None, None)
412 .await?
413 .token,
414 )
415 };
416 let first = self.prepare(request.clone(), credential.as_deref())?;
417 let response = self.http.send(first).await?;
418
419 if response.status() != StatusCode::UNAUTHORIZED || credential.is_none() {
420 return Ok(GithubResponse::new(response));
421 }
422
423 let key = (installation_id, default_scope_key());
427 self.tokens.invalidate(&key);
428 let fresh = self.installation_token(installation_id, None, None).await?;
429 credential = Some(fresh.token);
430 tracing::warn!(
431 provider = "github-app",
432 installation = installation_id,
433 status = 401,
434 outcome = "token-refreshed",
435 "github app request"
436 );
437 let replay = self.prepare(request, credential.as_deref())?;
438 Ok(GithubResponse::new(self.http.send(replay).await?))
439 }
440
441 fn prepare(
443 &self,
444 mut request: Request<Bytes>,
445 credential: Option<&str>,
446 ) -> Result<Request<Bytes>, GithubAppError> {
447 *request.uri_mut() = resolve_uri(&self.api_base, request.uri())?;
448 let headers = request.headers_mut();
449 if let Some(token) = credential
450 && !headers.contains_key(AUTHORIZATION)
451 {
452 let value = HeaderValue::from_str(&format!("Bearer {token}"))
453 .map_err(|_| GithubAppError::InvalidHeader)?;
454 headers.insert(AUTHORIZATION, value);
455 }
456 insert_static(headers, ACCEPT, ACCEPT_VALUE);
457 insert_static(
458 headers,
459 HeaderName::from_static(API_VERSION_HEADER),
460 API_VERSION,
461 );
462 insert_static(headers, USER_AGENT, USER_AGENT_VALUE);
463 Ok(request)
464 }
465
466 pub async fn paginate(
483 &self,
484 installation_id: u64,
485 first_url: impl Into<String>,
486 max_pages: usize,
487 ) -> Result<Vec<GithubResponse>, GithubAppError> {
488 let mut pages = Vec::new();
489 let mut next = Some(first_url.into());
490 while let Some(url) = next {
491 if pages.len() >= max_pages {
492 break;
493 }
494 let request = Request::builder()
495 .method(http::Method::GET)
496 .uri(url)
497 .body(Bytes::new())
498 .map_err(|_| GithubAppError::InvalidHeader)?;
499 let response = self.request(installation_id, request).await?;
500 next = next_page(response.response.headers())
501 .filter(|url| same_origin(&self.api_base, url));
502 pages.push(response);
503 }
504 Ok(pages)
505 }
506
507 pub async fn exchange_user_code(
530 &self,
531 client_id: &str,
532 client_secret: &str,
533 code: &str,
534 redirect_uri: Option<&str>,
535 ) -> Result<UserToken, GithubAppError> {
536 let body = serde_json::to_vec(&OAuthRequest {
537 client_id,
538 client_secret,
539 code,
540 redirect_uri,
541 })
542 .map_err(|err| GithubAppError::Decode(err.to_string()))?;
543 let uri: Uri = format!("{}/login/oauth/access_token", self.web_base)
544 .parse()
545 .map_err(|_| GithubAppError::InvalidHeader)?;
546 let request = Request::builder()
547 .method(http::Method::POST)
548 .uri(uri)
549 .header(ACCEPT, "application/json")
550 .header(CONTENT_TYPE, "application/json")
551 .header(USER_AGENT, USER_AGENT_VALUE)
552 .body(Bytes::from(body))
553 .map_err(|_| GithubAppError::InvalidHeader)?;
554
555 let response = self.http.send(request).await?;
556 let status = response.status();
557 let rate_limit = RateLimit::from_headers(response.headers());
558
559 let value = serde_json::from_slice::<serde_json::Value>(response.body()).ok();
565 if let Some(error) = value
566 .as_ref()
567 .and_then(|value| value.get("error"))
568 .and_then(serde_json::Value::as_str)
569 {
570 tracing::warn!(
571 provider = "github-app",
572 code = status.as_u16(),
573 outcome = "oauth-refused",
574 "github app oauth"
575 );
576 return Err(GithubAppError::OAuth(error.to_owned()));
577 }
578 if !status.is_success() {
579 return Err(GithubAppError::Status { status, rate_limit });
580 }
581 let value = value
582 .ok_or_else(|| GithubAppError::Decode("the OAuth answer was not JSON".to_owned()))?;
583 let wire: UserTokenWire =
584 serde_json::from_value(value).map_err(|err| GithubAppError::Decode(err.to_string()))?;
585 Ok(wire.into_token())
586 }
587
588 pub async fn user_can_access_installation(
609 &self,
610 user_token: &str,
611 installation_id: u64,
612 ) -> Result<bool, GithubAppError> {
613 let mut next = Some(format!("{}/user/installations?per_page=100", self.api_base));
614 let mut visited = 0;
615 while let Some(url) = next {
616 if visited >= INSTALLATIONS_PAGE_CAP {
617 break;
618 }
619 visited += 1;
620 let auth = HeaderValue::from_str(&format!("Bearer {user_token}"))
621 .map_err(|_| GithubAppError::InvalidHeader)?;
622 let request = Request::builder()
623 .method(http::Method::GET)
624 .uri(url)
625 .header(AUTHORIZATION, auth)
626 .header(ACCEPT, ACCEPT_VALUE)
627 .header(API_VERSION_HEADER, API_VERSION)
628 .header(USER_AGENT, USER_AGENT_VALUE)
629 .body(Bytes::new())
630 .map_err(|_| GithubAppError::InvalidHeader)?;
631 let response = self.http.send(request).await?;
632 let status = response.status();
633 if !status.is_success() {
634 return Err(GithubAppError::Status {
635 status,
636 rate_limit: RateLimit::from_headers(response.headers()),
637 });
638 }
639 let page: InstallationsPage = serde_json::from_slice(response.body())
640 .map_err(|err| GithubAppError::Decode(err.to_string()))?;
641 if page
642 .installations
643 .iter()
644 .any(|one| one.id == installation_id)
645 {
646 return Ok(true);
647 }
648 next = next_page(response.headers()).filter(|url| same_origin(&self.api_base, url));
649 }
650 Ok(false)
651 }
652}
653
654#[must_use]
657pub fn with_etag(mut request: Request<Bytes>, etag: &str) -> Request<Bytes> {
658 if let Ok(value) = HeaderValue::from_str(etag) {
659 request.headers_mut().insert(IF_NONE_MATCH, value);
660 }
661 request
662}
663
664pub struct InstallationToken {
667 pub token: String,
669 pub expires_at: OffsetDateTime,
671 pub permissions: BTreeMap<String, String>,
673}
674
675impl std::fmt::Debug for InstallationToken {
676 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
677 f.debug_struct("InstallationToken")
678 .field("token", &"<redacted>")
679 .field("expires_at", &self.expires_at)
680 .field("permissions", &self.permissions)
681 .finish()
682 }
683}
684
685impl InstallationToken {
686 #[must_use]
689 pub fn expose(&self) -> &str {
690 &self.token
691 }
692}
693
694pub struct UserToken {
697 pub access_token: String,
699 pub expires_in: Option<u64>,
701 pub refresh_token: Option<String>,
703 pub refresh_token_expires_in: Option<u64>,
705 pub token_type: Option<String>,
707}
708
709impl std::fmt::Debug for UserToken {
710 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
711 f.debug_struct("UserToken")
712 .field("access_token", &"<redacted>")
713 .field("expires_in", &self.expires_in)
714 .field(
715 "refresh_token",
716 &self.refresh_token.as_ref().map(|_| "<redacted>"),
717 )
718 .field("refresh_token_expires_in", &self.refresh_token_expires_in)
719 .field("token_type", &self.token_type)
720 .finish()
721 }
722}
723
724impl UserToken {
725 #[must_use]
728 pub fn expose(&self) -> &str {
729 &self.access_token
730 }
731}
732
733#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
735pub struct RateLimit {
736 pub remaining: Option<u32>,
738 pub reset: Option<OffsetDateTime>,
740 pub retry_after: Option<Duration>,
742}
743
744impl RateLimit {
745 #[must_use]
747 pub fn from_headers(headers: &HeaderMap) -> Self {
748 let remaining = headers
749 .get("x-ratelimit-remaining")
750 .and_then(|value| value.to_str().ok())
751 .and_then(|value| value.parse::<u32>().ok());
752 let reset = headers
753 .get("x-ratelimit-reset")
754 .and_then(|value| value.to_str().ok())
755 .and_then(|value| value.parse::<i64>().ok())
756 .and_then(|secs| OffsetDateTime::from_unix_timestamp(secs).ok());
757 let retry_after = headers
758 .get(http::header::RETRY_AFTER)
759 .and_then(|value| value.to_str().ok())
760 .and_then(|value| value.parse::<u64>().ok())
761 .map(Duration::from_secs);
762 Self {
763 remaining,
764 reset,
765 retry_after,
766 }
767 }
768}
769
770pub struct GithubResponse {
774 pub response: Response<Bytes>,
776 pub rate_limit: RateLimit,
778}
779
780impl GithubResponse {
781 fn new(response: Response<Bytes>) -> Self {
782 let rate_limit = RateLimit::from_headers(response.headers());
783 Self {
784 response,
785 rate_limit,
786 }
787 }
788
789 #[must_use]
791 pub fn status(&self) -> StatusCode {
792 self.response.status()
793 }
794
795 #[must_use]
798 pub fn not_modified(&self) -> bool {
799 self.response.status() == StatusCode::NOT_MODIFIED
800 }
801
802 #[must_use]
804 pub fn headers(&self) -> &HeaderMap {
805 self.response.headers()
806 }
807
808 #[must_use]
810 pub fn body(&self) -> &Bytes {
811 self.response.body()
812 }
813
814 #[must_use]
816 pub fn etag(&self) -> Option<&str> {
817 self.response
818 .headers()
819 .get(ETAG)
820 .and_then(|value| value.to_str().ok())
821 }
822}
823
824#[must_use]
833pub fn next_page(headers: &HeaderMap) -> Option<String> {
834 headers.get_all(LINK).iter().find_map(|value| {
835 links(value.to_str().ok()?).into_iter().find_map(|link| {
836 let open = link.find('<')?;
837 let rest = &link[open + 1..];
838 let close = rest.find('>')?;
839 is_next_link(&rest[close + 1..]).then(|| rest[..close].to_owned())
840 })
841 })
842}
843
844fn links(text: &str) -> Vec<&str> {
848 let (mut links, mut start, mut in_url) = (Vec::new(), 0, false);
849 for (at, ch) in text.char_indices() {
850 match ch {
851 '<' => in_url = true,
852 '>' => in_url = false,
853 ',' if !in_url => {
854 links.push(&text[start..at]);
855 start = at + 1;
856 }
857 _ => {}
858 }
859 }
860 links.push(&text[start..]);
861 links
862}
863
864fn is_next_link(params: &str) -> bool {
866 params.split(';').any(|param| {
867 let Some((name, value)) = param.split_once('=') else {
868 return false;
869 };
870 name.trim().eq_ignore_ascii_case("rel") && value.trim().trim_matches('"') == "next"
871 })
872}
873
874fn same_origin(api_base: &str, url: &str) -> bool {
878 match (api_base.parse::<Uri>(), url.parse::<Uri>()) {
879 (Ok(base), Ok(target)) => origin(&base).is_some() && origin(&base) == origin(&target),
880 _ => false,
881 }
882}
883
884fn origin(uri: &Uri) -> Option<(&str, &str, u16)> {
887 let scheme = uri.scheme_str()?;
888 let default_port = match scheme {
889 "https" => 443,
890 "http" => 80,
891 _ => return None,
892 };
893 Some((scheme, uri.host()?, uri.port_u16().unwrap_or(default_port)))
894}
895
896fn scope_key(
904 permissions: Option<&BTreeMap<String, String>>,
905 repositories: Option<&[String]>,
906) -> String {
907 let permissions = permissions.map_or_else(
908 || "-".to_owned(),
909 |map| {
910 map.iter()
911 .map(|(name, level)| format!("{name}:{level}"))
912 .collect::<Vec<_>>()
913 .join(",")
914 },
915 );
916 let repositories = repositories.map_or_else(
917 || "-".to_owned(),
918 |list| {
919 let mut sorted = list.to_vec();
920 sorted.sort();
921 sorted.join(",")
922 },
923 );
924 format!("p={permissions};r={repositories}")
925}
926
927fn default_scope_key() -> String {
931 scope_key(None, None)
932}
933
934fn trim_base(base: &str) -> String {
936 base.trim_end_matches('/').to_owned()
937}
938
939fn resolve_uri(api_base: &str, uri: &Uri) -> Result<Uri, GithubAppError> {
943 if uri.scheme().is_some() {
944 return Ok(uri.clone());
945 }
946 let path = uri.path_and_query().map_or("/", |pq| pq.as_str());
947 let separator = if path.starts_with('/') { "" } else { "/" };
948 format!("{api_base}{separator}{path}")
949 .parse()
950 .map_err(|_| GithubAppError::InvalidHeader)
951}
952
953fn insert_static(headers: &mut HeaderMap, name: HeaderName, value: &'static str) {
955 if !headers.contains_key(&name) {
956 headers.insert(name, HeaderValue::from_static(value));
957 }
958}
959
960#[derive(serde::Serialize)]
963struct TokenRequest<'a> {
964 #[serde(skip_serializing_if = "Option::is_none")]
965 permissions: Option<&'a BTreeMap<String, String>>,
966 #[serde(skip_serializing_if = "Option::is_none")]
967 repositories: Option<&'a [String]>,
968}
969
970#[derive(serde::Serialize)]
972struct OAuthRequest<'a> {
973 client_id: &'a str,
974 client_secret: &'a str,
975 code: &'a str,
976 #[serde(skip_serializing_if = "Option::is_none")]
977 redirect_uri: Option<&'a str>,
978}
979
980#[derive(serde::Deserialize)]
982struct InstallationTokenWire {
983 token: String,
984 expires_at: String,
985 #[serde(default)]
986 permissions: BTreeMap<String, String>,
987}
988
989impl InstallationTokenWire {
990 fn into_token(self) -> Result<InstallationToken, GithubAppError> {
991 let expires_at = OffsetDateTime::parse(&self.expires_at, &Rfc3339)
992 .map_err(|err| GithubAppError::Decode(err.to_string()))?;
993 Ok(InstallationToken {
994 token: self.token,
995 expires_at,
996 permissions: self.permissions,
997 })
998 }
999}
1000
1001#[derive(serde::Serialize, serde::Deserialize)]
1004struct CachedTokenWire {
1005 token: String,
1006 expires_at_unix: i64,
1007 permissions: BTreeMap<String, String>,
1008}
1009
1010impl From<&InstallationToken> for CachedTokenWire {
1011 fn from(token: &InstallationToken) -> Self {
1012 Self {
1013 token: token.token.clone(),
1014 expires_at_unix: token.expires_at.unix_timestamp(),
1015 permissions: token.permissions.clone(),
1016 }
1017 }
1018}
1019
1020impl CachedTokenWire {
1021 fn into_token(self) -> InstallationToken {
1022 let expires_at = OffsetDateTime::from_unix_timestamp(self.expires_at_unix)
1023 .unwrap_or(OffsetDateTime::UNIX_EPOCH);
1024 InstallationToken {
1025 token: self.token,
1026 expires_at,
1027 permissions: self.permissions,
1028 }
1029 }
1030}
1031
1032#[derive(serde::Deserialize)]
1034struct UserTokenWire {
1035 access_token: String,
1036 #[serde(default)]
1037 expires_in: Option<u64>,
1038 #[serde(default)]
1039 refresh_token: Option<String>,
1040 #[serde(default)]
1041 refresh_token_expires_in: Option<u64>,
1042 #[serde(default)]
1043 token_type: Option<String>,
1044}
1045
1046impl UserTokenWire {
1047 fn into_token(self) -> UserToken {
1048 UserToken {
1049 access_token: self.access_token,
1050 expires_in: self.expires_in,
1051 refresh_token: self.refresh_token,
1052 refresh_token_expires_in: self.refresh_token_expires_in,
1053 token_type: self.token_type,
1054 }
1055 }
1056}
1057
1058#[derive(serde::Deserialize)]
1060struct InstallationsPage {
1061 #[serde(default)]
1062 installations: Vec<InstallationRef>,
1063}
1064
1065#[derive(serde::Deserialize)]
1067struct InstallationRef {
1068 id: u64,
1069}
1070
1071#[derive(Debug, thiserror::Error)]
1075pub enum GithubAppError {
1076 #[error("the GitHub App is not configured: no private key")]
1078 NotConfigured,
1079 #[error("the GitHub App private key is not a valid RSA PEM")]
1082 InvalidKey,
1083 #[error("a request header value is not valid")]
1085 InvalidHeader,
1086 #[error("refusing to send the installation token outside the GitHub API origin")]
1090 ForeignOrigin,
1091 #[error("github app request failed: {0}")]
1093 Http(#[from] HttpError),
1094 #[error("github app request was refused with {status}")]
1096 Status {
1097 status: StatusCode,
1099 rate_limit: RateLimit,
1101 },
1102 #[error("github app answer could not be decoded: {0}")]
1108 Decode(String),
1109 #[error("github oauth error: {0}")]
1112 OAuth(String),
1113}
1114
1115#[cfg(test)]
1116mod tests {
1117 use super::*;
1118
1119 #[test]
1120 fn scope_keys_are_order_independent_and_scope_sensitive() {
1121 let a = BTreeMap::from([
1122 ("contents".to_owned(), "read".to_owned()),
1123 ("issues".to_owned(), "write".to_owned()),
1124 ]);
1125 let b = BTreeMap::from([
1126 ("issues".to_owned(), "write".to_owned()),
1127 ("contents".to_owned(), "read".to_owned()),
1128 ]);
1129 assert_eq!(scope_key(Some(&a), None), scope_key(Some(&b), None));
1130
1131 let unsorted = vec!["acme/zeta".to_owned(), "acme/alpha".to_owned()];
1132 let sorted = vec!["acme/alpha".to_owned(), "acme/zeta".to_owned()];
1133 assert_eq!(
1134 scope_key(None, Some(&unsorted)),
1135 scope_key(None, Some(&sorted))
1136 );
1137 assert_ne!(scope_key(None, None), scope_key(Some(&a), None));
1139 assert_eq!(scope_key(None, None), default_scope_key());
1140
1141 let empty_map = BTreeMap::new();
1145 let empty_list: Vec<String> = Vec::new();
1146 assert_ne!(scope_key(None, None), scope_key(Some(&empty_map), None));
1147 assert_ne!(scope_key(None, None), scope_key(None, Some(&empty_list)));
1148 assert_ne!(
1149 scope_key(None, None),
1150 scope_key(Some(&empty_map), Some(&empty_list))
1151 );
1152 assert_eq!(scope_key(None, None), default_scope_key());
1153 }
1154
1155 #[test]
1156 fn a_link_header_yields_its_next_url() {
1157 let mut headers = HeaderMap::new();
1158 headers.insert(
1159 LINK,
1160 HeaderValue::from_static(
1161 "<https://api.github.test/user/installations?per_page=100&page=2>; rel=\"next\", \
1162 <https://api.github.test/user/installations?per_page=100&page=5>; rel=\"last\"",
1163 ),
1164 );
1165 assert_eq!(
1166 next_page(&headers).as_deref(),
1167 Some("https://api.github.test/user/installations?per_page=100&page=2")
1168 );
1169 }
1170
1171 #[test]
1172 fn a_link_header_without_next_is_none() {
1173 let mut headers = HeaderMap::new();
1174 headers.insert(
1175 LINK,
1176 HeaderValue::from_static("<https://api.github.test/x?page=1>; rel=\"prev\""),
1177 );
1178 assert_eq!(next_page(&headers), None);
1179 assert_eq!(next_page(&HeaderMap::new()), None);
1180 }
1181
1182 #[test]
1183 fn a_comma_inside_a_link_url_is_not_a_separator() {
1184 let mut headers = HeaderMap::new();
1186 headers.insert(
1187 LINK,
1188 HeaderValue::from_static("<https://api.github.test/a,b>; rel=\"next\""),
1189 );
1190 assert_eq!(
1191 next_page(&headers).as_deref(),
1192 Some("https://api.github.test/a,b")
1193 );
1194 assert_eq!(
1195 links("<https://x/a,b>; rel=\"next\""),
1196 vec!["<https://x/a,b>; rel=\"next\""]
1197 );
1198 }
1199
1200 #[test]
1201 fn a_next_link_is_only_same_origin() {
1202 assert!(same_origin(
1203 "https://api.github.test",
1204 "https://api.github.test/user/installations?page=2"
1205 ));
1206 assert!(same_origin(
1208 "https://api.github.test",
1209 "https://api.github.test:443/x"
1210 ));
1211 assert!(!same_origin(
1213 "https://api.github.test",
1214 "http://api.github.test/x"
1215 ));
1216 assert!(!same_origin(
1217 "https://api.github.test",
1218 "https://evil.example.com/x"
1219 ));
1220 assert!(!same_origin(
1221 "https://api.github.test",
1222 "https://api.github.test:8443/x"
1223 ));
1224 assert!(!same_origin(
1226 "https://api.github.test",
1227 "/user/installations?page=2"
1228 ));
1229 assert!(!same_origin(
1230 "https://api.github.test",
1231 "ftp://api.github.test/x"
1232 ));
1233 assert!(!same_origin("not a base", "https://api.github.test/x"));
1234 }
1235
1236 #[test]
1237 fn rate_limit_headers_are_parsed_and_garbage_is_ignored() {
1238 let mut headers = HeaderMap::new();
1239 headers.insert("x-ratelimit-remaining", HeaderValue::from_static("4999"));
1240 headers.insert("x-ratelimit-reset", HeaderValue::from_static("1700000000"));
1241 headers.insert(http::header::RETRY_AFTER, HeaderValue::from_static("42"));
1242 let limit = RateLimit::from_headers(&headers);
1243 assert_eq!(limit.remaining, Some(4999));
1244 assert_eq!(limit.reset.unwrap().unix_timestamp(), 1_700_000_000);
1245 assert_eq!(limit.retry_after, Some(Duration::from_secs(42)));
1246
1247 let mut bad = HeaderMap::new();
1248 bad.insert("x-ratelimit-remaining", HeaderValue::from_static("soon"));
1249 bad.insert("x-ratelimit-reset", HeaderValue::from_static("not-a-time"));
1250 bad.insert(http::header::RETRY_AFTER, HeaderValue::from_static("-1"));
1251 assert_eq!(RateLimit::from_headers(&bad), RateLimit::default());
1252 }
1253
1254 #[test]
1255 fn a_relative_uri_resolves_against_the_api_base() {
1256 let absolute: Uri = "https://elsewhere.test/repos".parse().unwrap();
1257 assert_eq!(
1258 resolve_uri("https://api.github.test", &absolute).unwrap(),
1259 absolute
1260 );
1261 let relative: Uri = "/repos/acme/widgets".parse().unwrap();
1262 assert_eq!(
1263 resolve_uri("https://api.github.test", &relative)
1264 .unwrap()
1265 .to_string(),
1266 "https://api.github.test/repos/acme/widgets"
1267 );
1268 }
1269
1270 #[test]
1271 fn the_debug_of_an_installation_token_hides_the_token() {
1272 let token = InstallationToken {
1273 token: "ghs_secretvalue".to_owned(),
1274 expires_at: OffsetDateTime::UNIX_EPOCH,
1275 permissions: BTreeMap::new(),
1276 };
1277 let printed = format!("{token:?}");
1278 assert!(!printed.contains("ghs_"), "{printed}");
1279 assert!(printed.contains("redacted"), "{printed}");
1280 assert_eq!(token.expose(), "ghs_secretvalue");
1281 }
1282}