Skip to main content

cratefield_adapter_github_app/
lib.rs

1//! `cratefield-adapter-github-app`: GitHub App authentication (issue #623).
2//!
3//! A GitHub App authenticates in two steps. The app proves who it is with a
4//! short-lived **RS256 JWT** signed by its private key (`iss` = the App ID,
5//! `iat`/`exp` minutes apart); it exchanges that JWT for an **installation
6//! token** (`ghs_…`) scoped to one installation, its permissions and its
7//! repositories. A venture that acts *for a user* adds a third: the
8//! **user-to-server** OAuth code exchange (`ghu_…`), and
9//! [`GithubApp::user_can_access_installation`] is the check it runs before
10//! linking an installation to an account.
11//!
12//! Everything goes through the runtime's [`HttpClient`] and [`Clock`] ports —
13//! no vendor SDK, no `reqwest`, no `tokio`, no `std::time` — so the same
14//! client runs on Cloudflare Workers and natively (ADR 0002: the ports are
15//! core's, the vendor client is this crate's). Signing reuses
16//! `cratefield-push-auth`'s [`Rs256Signer`] and its keyed [`CachedToken`],
17//! because it is the same construction the push adapters already ship.
18//!
19//! **Nothing here logs a secret.** The `Debug` impls redact the key and every
20//! token, the error messages carry no key material, and the only `tracing`
21//! calls emit metadata (the installation id, the status) and never a token.
22//!
23//! [`HttpClient`]: cratefield_core::HttpClient
24//! [`Clock`]: cratefield_core::Clock
25//! [`Rs256Signer`]: cratefield_push_auth::Rs256Signer
26//! [`CachedToken`]: cratefield_push_auth::CachedToken
27
28#![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
43/// The REST API root GitHub App requests default to.
44pub const DEFAULT_API_BASE: &str = "https://api.github.com";
45/// The web root the user-to-server OAuth endpoint lives on. It is *not* the
46/// API host: `/login/oauth/access_token` is served by github.com (or the
47/// GitHub Enterprise Server web host).
48pub const DEFAULT_WEB_BASE: &str = "https://github.com";
49
50/// GitHub's current REST API version, pinned the way their docs say to.
51const API_VERSION: &str = "2022-11-28";
52/// The media type GitHub's App endpoints answer with.
53const ACCEPT_VALUE: &str = "application/vnd.github+json";
54/// GitHub rejects requests without a `User-Agent`.
55const USER_AGENT_VALUE: &str = "cratefield-adapter-github-app";
56/// The header that pins the REST API version.
57const API_VERSION_HEADER: &str = "x-github-api-version";
58
59/// How long a minted installation token is reused. A GitHub installation
60/// token lives one hour; re-minting at 55 minutes leaves margin without
61/// churning.
62const TOKEN_TTL: Duration = Duration::from_mins(55);
63/// How far before an installation token's own expiry it is treated as dead:
64/// a token that expires while a request is in flight is worse than one extra
65/// exchange.
66const TOKEN_SAFETY_MARGIN: time::Duration = time::Duration::minutes(5);
67/// The JWT is backdated this far, so a clock a minute fast on the app's host
68/// still has it accepted.
69const JWT_BACKDATE_SECS: i64 = 60;
70/// The JWT lives this long. GitHub caps an App JWT at 10 minutes.
71const JWT_LIFETIME_SECS: i64 = 540;
72/// Pages of 100 installations followed before `user_can_access_installation`
73/// gives up. Ten pages is a thousand installations — past that, "not on the
74/// list" is the honest answer for one page cap rather than an unbounded walk.
75const INSTALLATIONS_PAGE_CAP: usize = 10;
76
77/// GitHub App auth over the [`HttpClient`] and [`Clock`] ports.
78///
79/// The `private_key_pem` is optional on purpose: a deployment without a key
80/// (a preview environment, a test) constructs the client and every operation
81/// **that needs the key** — [`GithubApp::app_jwt`],
82/// [`GithubApp::installation_token`], [`GithubApp::request`],
83/// [`GithubApp::paginate`] — answers [`GithubAppError::NotConfigured`] without
84/// touching the network, so a venture can wire it unconditionally and let the
85/// absence decide. The user-to-server pair,
86/// [`GithubApp::exchange_user_code`] and
87/// [`GithubApp::user_can_access_installation`], carries the caller's own
88/// credential and runs without one.
89///
90/// [`HttpClient`]: cratefield_core::HttpClient
91/// [`Clock`]: cratefield_core::Clock
92pub struct GithubApp {
93    /// The App ID, kept as the string `iss` reads. GitHub's docs show the
94    /// numeric id for an app; both the number and its decimal string are
95    /// accepted, and the string is what is sent.
96    app_id: u64,
97    /// The parsed key, or why there is not one.
98    key: AppKey,
99    http: Arc<dyn HttpClient>,
100    clock: Arc<dyn Clock>,
101    /// The REST API root, without a trailing slash.
102    api_base: String,
103    /// The web root the OAuth endpoint lives on, without a trailing slash.
104    web_base: String,
105    /// Minted installation tokens, keyed by `(installation id, scope)`.
106    tokens: CachedToken<(u64, String)>,
107    /// Single-flight guard around "check cache → exchange → store": held
108    /// across the exchange so two concurrent cold calls make one request.
109    /// A single app-wide lock is the whole optimisation here — re-minting a
110    /// token is rare, so per-key locks would buy nothing for the extra state.
111    exchange: futures_util::lock::Mutex<()>,
112}
113
114/// What a constructor's `private_key_pem` argument resolved to. Parsed once
115/// so every operation is a cheap match, and so a malformed PEM is a stable
116/// [`GithubAppError::InvalidKey`] rather than a re-parse per call.
117enum AppKey {
118    /// No key was supplied, or it was empty.
119    Absent,
120    /// A key was supplied and did not parse. The underlying parser message is
121    /// deliberately dropped: it is not worth the risk that one build of the
122    /// `rsa` crate echoes input.
123    Invalid,
124    /// A usable signer.
125    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    /// Never prints the key or a token. The `http`, `clock`, cache and lock
136    /// fields are omitted wholesale rather than summarised.
137    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    /// A client for `app_id`, signing with `private_key_pem` (PKCS#1 or
149    /// PKCS#8 PEM; `None` or empty means "not configured"). `api_base` is the
150    /// REST root — pass [`DEFAULT_API_BASE`] unless targeting GitHub
151    /// Enterprise Server or a fake in tests. The OAuth endpoint's host
152    /// defaults to [`DEFAULT_WEB_BASE`]; override it with
153    /// [`GithubApp::with_web_base`].
154    #[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    /// Points the user-to-server OAuth exchange at another web host — GitHub
183    /// Enterprise Server, or a fake in tests — instead of
184    /// `https://github.com`.
185    #[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    /// The App ID this client signs for.
193    #[must_use]
194    pub fn app_id(&self) -> u64 {
195        self.app_id
196    }
197
198    /// Whether a usable private key was supplied.
199    #[must_use]
200    pub fn is_configured(&self) -> bool {
201        self.key.is_signer()
202    }
203
204    /// The signing key, or the reason there is none. The single gate every
205    /// operation passes first, so an unconfigured client never reaches the
206    /// network.
207    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    /// A signed app JWT: header `{"alg":"RS256","typ":"JWT"}`, claims
216    /// `iat` = now − 60s, `exp` = now + 540s (nine minutes, inside GitHub's
217    /// ten-minute cap), and `iss` = the App ID as a string.
218    ///
219    /// # Errors
220    ///
221    /// [`GithubAppError::NotConfigured`] when no key was supplied,
222    /// [`GithubAppError::InvalidKey`] when the key did not parse. Neither
223    /// message contains key material.
224    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    /// An installation token, minted once and cached per installation and
237    /// scope.
238    ///
239    /// `permissions` and `repositories` narrow the token the way GitHub's
240    /// `POST /app/installations/{id}/access_tokens` does; both are omitted
241    /// from the request body when `None`, and the pair forms the cache key, so
242    /// a token scoped to one repository is never handed to a call scoped to
243    /// another. `Some` of an empty map or list is *not* `None`: GitHub reads
244    /// the first as "grant nothing" and the second as "grant everything", and
245    /// the two get separate cache entries.
246    ///
247    /// Concurrent calls for the same installation and scope make **one**
248    /// exchange for the whole client: one app-wide async mutex is held across
249    /// check-cache, exchange and store, so a second caller arriving mid-flight
250    /// waits and then reads the token the first one stored. A token minted
251    /// already inside the five-minute safety margin (GitHub's answer minus
252    /// five minutes is not in the future) is returned but not cached — there
253    /// is nothing worth storing.
254    ///
255    /// # Errors
256    ///
257    /// [`GithubAppError::NotConfigured`] / [`GithubAppError::InvalidKey`]
258    /// before any network call; [`GithubAppError::Http`] on a transport
259    /// failure; [`GithubAppError::Status`] when GitHub refuses the exchange
260    /// (carrying the status and the rate-limit headers); and
261    /// [`GithubAppError::Decode`] when a 2xx body cannot be read. No message
262    /// or logged field contains the token or the key.
263    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        // Held across the exchange: the whole point is that a second caller
273        // arriving mid-exchange waits and then reads the token the first one
274        // stored, rather than presenting GitHub a second JWT.
275        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            // A cached value we cannot read is worse than none: drop it and
282            // mint.
283            self.tokens.invalidate(&key);
284        }
285
286        let token = self
287            .exchange_installation_token(installation_id, permissions, repositories)
288            .await?;
289
290        // Reuse it until five minutes before GitHub says it dies; below that
291        // margin there is nothing worth caching, so store nothing.
292        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    /// One installation-token exchange, with no cache in front of it.
310    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    /// Sends an API request authenticated with an installation token.
360    ///
361    /// The request's URI may be absolute (`https://api.github.com/repos/...`)
362    /// or relative (`/repos/...`); a relative one is resolved against the
363    /// client's API base. An absolute URI on another origin than the API base
364    /// is refused with [`GithubAppError::ForeignOrigin`] — before any token is
365    /// minted — whenever this method would attach the installation token: the
366    /// token never leaves the API's origin, whichever path the URL came by
367    /// (a caller-built one, [`GithubApp::paginate`]'s first page, a URL read
368    /// out of a payload). `Authorization`, `Accept`,
369    /// `X-GitHub-Api-Version` and `User-Agent` are set unless the caller
370    /// already set them — so a caller can pass their own credential and this
371    /// method will not overwrite it (and will not mint an installation token
372    /// at all).
373    ///
374    /// A `401` on a request **this method** authenticated means the
375    /// installation token went stale: the cached one is dropped, a fresh one
376    /// is minted, and the request is replayed exactly once. A second `401`
377    /// (or any other non-2xx, `304` included) is returned as an ordinary
378    /// [`GithubResponse`] for the caller to read — this method does not
379    /// classify those.
380    ///
381    /// # Errors
382    ///
383    /// As [`GithubApp::installation_token`], plus
384    /// [`GithubAppError::InvalidHeader`] if a token cannot be put on the wire
385    /// as a header value, and [`GithubAppError::ForeignOrigin`] for an
386    /// off-origin URI this method would have authenticated.
387    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        // The token this method minted was refused. Drop it, mint a fresh one
424        // and replay exactly once — a loop here would turn a permanent 401
425        // into an unbounded one.
426        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    /// Resolves a relative URI, and fills in the default headers.
442    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    /// Follows `Link: …; rel="next"` from `first_url`, returning at most
467    /// `max_pages` responses. The walk stops when a page carries no `next`
468    /// link or the cap is reached; every page after the first is requested
469    /// through [`GithubApp::request`], so it carries the same installation
470    /// token and the same `401` replay.
471    ///
472    /// A `next` link is followed only when it is on `api_base`'s origin
473    /// (scheme, host and port). A cross-origin link ends the walk instead: the
474    /// next page would be fetched with the installation token in
475    /// `Authorization`, and a `Link` header is the one part of a response an
476    /// upstream gets to choose.
477    ///
478    /// # Errors
479    ///
480    /// As [`GithubApp::request`] — so a `first_url` on another origin is
481    /// [`GithubAppError::ForeignOrigin`].
482    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    /// Exchanges a user-to-server authorization `code` for a user access
508    /// token (`ghu_…`).
509    ///
510    /// GitHub answers `200` with `{"error": …}` for both success and failure,
511    /// so the body decides: an `error` field becomes
512    /// [`GithubAppError::OAuth`] carrying only GitHub's error *code*, never
513    /// the body — which would echo the client secret back. A non-success
514    /// status without an `error` field is a plain
515    /// [`GithubAppError::Status`], whether or not the body is JSON (an HTML
516    /// error page must not be mistaken for a decode failure), and a
517    /// successful response whose body is not JSON is
518    /// [`GithubAppError::Decode`].
519    ///
520    /// This flow authenticates with the client id and secret, not the app's
521    /// private key, so it works on a client constructed without a key.
522    ///
523    /// # Errors
524    ///
525    /// [`GithubAppError::Http`] on transport failure,
526    /// [`GithubAppError::OAuth`] on a GitHub-reported error,
527    /// [`GithubAppError::Status`] on a non-2xx without one, and
528    /// [`GithubAppError::Decode`] when a 2xx body cannot be read.
529    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        // Parse the body once, but tolerate it not being JSON: GitHub's OAuth
560        // endpoint answers `200` with `{"error": …}` on the "these are not the
561        // codes" path, so an `error` field wins over the status — while a
562        // non-2xx body that is not JSON at all (an HTML error page) is a
563        // status, not a decode failure.
564        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    /// Whether `user_token` can reach `installation_id`, by walking
589    /// `GET {api_base}/user/installations?per_page=100` and looking for the id
590    /// (following `Link` pages, up to a fixed page cap).
591    ///
592    /// This is the check a venture runs before linking an installation to an
593    /// account: a user token can only see the installations that user
594    /// authorised.
595    ///
596    /// A `next` link is followed only when it is on `api_base`'s origin. A
597    /// cross-origin one ends the walk and the answer is `false` — the call
598    /// exists to grant access, so an unverifiable walk fails closed rather
599    /// than granting it.
600    ///
601    /// This flow presents the caller's own user token, not the app's private
602    /// key, so it works on a client constructed without a key.
603    ///
604    /// # Errors
605    ///
606    /// [`GithubAppError::Http`] on transport failure, and
607    /// [`GithubAppError::Status`] / [`GithubAppError::Decode`] on a bad page.
608    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/// A shortcut for adding `If-None-Match: <etag>` to a request, so a caller
655/// can do a conditional `GET` and recognise [`GithubResponse::not_modified`].
656#[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
664/// An installation token: the credential (`ghs_…`), when GitHub says it dies,
665/// and the permissions it carries. `Debug` never prints the token.
666pub struct InstallationToken {
667    /// The `ghs_…` token.
668    pub token: String,
669    /// GitHub's stated expiry.
670    pub expires_at: OffsetDateTime,
671    /// The permissions GitHub granted this token.
672    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    /// The token, for a caller about to present it somewhere other than this
687    /// client.
688    #[must_use]
689    pub fn expose(&self) -> &str {
690        &self.token
691    }
692}
693
694/// A user-to-server access token (`ghu_…`) and its refresh material. `Debug`
695/// never prints a token.
696pub struct UserToken {
697    /// The `ghu_…` token.
698    pub access_token: String,
699    /// Seconds until `access_token` expires, when GitHub says.
700    pub expires_in: Option<u64>,
701    /// The refresh token (`ghr_…`), when this app issues expiring tokens.
702    pub refresh_token: Option<String>,
703    /// Seconds until `refresh_token` expires, when present.
704    pub refresh_token_expires_in: Option<u64>,
705    /// The token type GitHub reported (`bearer`).
706    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    /// The token, for a caller about to present it somewhere other than this
726    /// client.
727    #[must_use]
728    pub fn expose(&self) -> &str {
729        &self.access_token
730    }
731}
732
733/// GitHub's rate-limit headers, read off any response.
734#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
735pub struct RateLimit {
736    /// `X-RateLimit-Remaining`.
737    pub remaining: Option<u32>,
738    /// `X-RateLimit-Reset`, a UNIX timestamp.
739    pub reset: Option<OffsetDateTime>,
740    /// `Retry-After`, in seconds.
741    pub retry_after: Option<Duration>,
742}
743
744impl RateLimit {
745    /// Reads the three headers, ignoring any that is absent or malformed.
746    #[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
770/// A GitHub API response and the rate-limit information it carried. This
771/// crate does not classify a non-2xx status (other than a replayed `401`):
772/// the caller reads [`GithubResponse::status`] and decides.
773pub struct GithubResponse {
774    /// The response, body and all.
775    pub response: Response<Bytes>,
776    /// The rate-limit headers that came with it.
777    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    /// The status.
790    #[must_use]
791    pub fn status(&self) -> StatusCode {
792        self.response.status()
793    }
794
795    /// Whether GitHub answered `304 Not Modified` — the recognisable outcome
796    /// of a conditional `GET` sent with [`with_etag`].
797    #[must_use]
798    pub fn not_modified(&self) -> bool {
799        self.response.status() == StatusCode::NOT_MODIFIED
800    }
801
802    /// The response headers.
803    #[must_use]
804    pub fn headers(&self) -> &HeaderMap {
805        self.response.headers()
806    }
807
808    /// The body.
809    #[must_use]
810    pub fn body(&self) -> &Bytes {
811        self.response.body()
812    }
813
814    /// The `ETag` header, for a caller to feed back through [`with_etag`].
815    #[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/// The URL of the `rel="next"` link in a `Link` header, if there is one.
825/// Handles several comma-separated links and the quoted `rel` they carry;
826/// commas inside a `<…>` URL are not separators.
827///
828/// The URL is returned as it appears in the header, whatever host it names.
829/// A caller about to send it somewhere must check the origin first —
830/// [`GithubApp::paginate`] and
831/// [`GithubApp::user_can_access_installation`] do.
832#[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
844/// Splits a `Link` header into its links, on the commas that are not inside a
845/// `<…>` URL. An unmatched `<` swallows the rest of the header, which is what
846/// a malformed one gets.
847fn 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
864/// Whether a link's parameter list (everything after `>`) names `rel="next"`.
865fn 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
874/// Whether `url` names the same origin — scheme, host and port — as
875/// `api_base`. A `Link` page is followed only within that origin, because it
876/// is fetched with a credential in `Authorization`.
877fn 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
884/// A URI's `(scheme, host, port)` with the default port filled in, or `None`
885/// when it is not an absolute `http`/`https` URL.
886fn 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
896/// The cache key for a scope: permissions sorted (a `BTreeMap` already is)
897/// then repositories sorted, so the same set in any order is one cache entry.
898///
899/// `None` and `Some`-of-nothing are different keys. GitHub reads an omitted
900/// field as "no narrowing" and a present empty one as "narrow to nothing", so
901/// an absent side renders as `-` — which no permission pair or repository
902/// name contains — and a present-but-empty side renders as the empty string.
903fn 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
927/// The scope key a plain [`GithubApp::request`] uses — no permissions, no
928/// repositories — so its `401` invalidation names the same entry
929/// [`GithubApp::installation_token`] stored.
930fn default_scope_key() -> String {
931    scope_key(None, None)
932}
933
934/// Strips a trailing slash so `{base}/path` never doubles it.
935fn trim_base(base: &str) -> String {
936    base.trim_end_matches('/').to_owned()
937}
938
939/// Resolves a request URI against `api_base` when it has no scheme; an
940/// absolute URI is left alone (so a `Link` URL, which is absolute, is
941/// followed as-is).
942fn 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
953/// Inserts `name: value` unless the caller already set that header.
954fn 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/// The JSON body of an installation-token exchange: only the fields the
961/// caller supplied.
962#[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/// The JSON body of the user-to-server code exchange.
971#[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/// GitHub's installation-token response.
981#[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/// The in-cache form: a [`InstallationToken`] with the expiry as a UNIX
1002/// timestamp, because `time`'s serde support is off by workspace policy.
1003#[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/// GitHub's user-to-server token response.
1033#[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/// One page of `GET /user/installations`.
1059#[derive(serde::Deserialize)]
1060struct InstallationsPage {
1061    #[serde(default)]
1062    installations: Vec<InstallationRef>,
1063}
1064
1065/// The only field of an installation this check reads.
1066#[derive(serde::Deserialize)]
1067struct InstallationRef {
1068    id: u64,
1069}
1070
1071/// Everything that can go wrong. Every message this crate builds carries no
1072/// token, client secret, JWT or part of the private key — with one exception,
1073/// [`GithubAppError::Decode`], which carries the JSON parser's own words.
1074#[derive(Debug, thiserror::Error)]
1075pub enum GithubAppError {
1076    /// No private key was configured, so there is nothing to sign with.
1077    #[error("the GitHub App is not configured: no private key")]
1078    NotConfigured,
1079    /// A private key was configured but did not parse. The underlying parser
1080    /// message is deliberately not carried.
1081    #[error("the GitHub App private key is not a valid RSA PEM")]
1082    InvalidKey,
1083    /// A value could not be put on the wire as an HTTP header.
1084    #[error("a request header value is not valid")]
1085    InvalidHeader,
1086    /// [`GithubApp::request`] was asked to send the installation token to a
1087    /// URI outside the API base's origin (scheme, host and port). Nothing was
1088    /// minted or sent.
1089    #[error("refusing to send the installation token outside the GitHub API origin")]
1090    ForeignOrigin,
1091    /// The transport failed.
1092    #[error("github app request failed: {0}")]
1093    Http(#[from] HttpError),
1094    /// GitHub answered a token or API call with a non-success status.
1095    #[error("github app request was refused with {status}")]
1096    Status {
1097        /// The status GitHub returned.
1098        status: StatusCode,
1099        /// The rate-limit headers that came with it.
1100        rate_limit: RateLimit,
1101    },
1102    /// A body could not be read. The detail is the JSON parser's own message
1103    /// (a field name and a position), and a parser that meets an unexpected
1104    /// value may quote it — so this is diagnostic text, not a guarantee that
1105    /// no part of the body appears. It is never logged by this crate; a caller
1106    /// that prints it is printing something a response chose.
1107    #[error("github app answer could not be decoded: {0}")]
1108    Decode(String),
1109    /// GitHub's OAuth endpoint reported an error. Carries GitHub's error
1110    /// *code* only.
1111    #[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        // A narrow scope never shares a key with the default one.
1138        assert_ne!(scope_key(None, None), scope_key(Some(&a), None));
1139        assert_eq!(scope_key(None, None), default_scope_key());
1140
1141        // `None` and `Some`-of-nothing are different requests — GitHub reads
1142        // the first as "no narrowing", the second as "narrow to nothing" — so
1143        // they must be different cache entries.
1144        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        // A pathological URL carrying a comma must not be split in two.
1185        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        // A defaulted port is the same origin as an explicit one.
1207        assert!(same_origin(
1208            "https://api.github.test",
1209            "https://api.github.test:443/x"
1210        ));
1211        // Scheme, host and port each break it.
1212        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        // A relative or unparseable URL is not followed either.
1225        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}