fabro/lib/components/fabro-github/src/token_source.rs
Bryan Helmkamp 7bfed23153
feat(github): mint one installation token for the effective repository set
Add `GitHubRepositoryAccess`, the secret-free validated value describing a
run's effective GitHub repository set: the primary origin repository plus
the declared additional repositories with the shared permission map.

- The constructor normalizes HTTPS and both SSH origin spellings to one
  primary slug, rejects a missing or non-GitHub origin when additional
  repositories are declared, rejects primary duplication and cross-owner
  additional repositories, and re-checks that interpolated permissions
  carry `contents = "read"|"write"` — exposing targets in deterministic
  primary-first order.
- `resolve_shared_installation` resolves every target's App installation
  with the App JWT and requires one shared installation ID, naming the
  repository the App cannot see before any mint.
- The installation-token mint now accepts a repository-name list; the
  single-repository entry points delegate to it, and the request body
  lists every projected name with the shared permissions.
- `InstallationTokenSource::for_access` builds a source over the access
  value; caching, refresh margin, and single-flight are unchanged.
- The scripted `MockHttpClient` and test RSA key move to a shared
  crate-internal `tests_mock` module.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 14:24:24 -04:00

743 lines
26 KiB
Rust

//! Cached GitHub installation-token source.
//!
//! One [`InstallationTokenSource`] can serve GitHub-token consumers that share
//! a repository and permission scope. Reusing mature tokens keeps consumers
//! out of GitHub's token-replication lag window, where a token minted
//! milliseconds earlier is rejected with 404 "Repository not found" or an
//! authentication failure.
//!
//! The source also reports *provenance*: when it minted the token it returned,
//! and which mint generation it belongs to. Retry classification, logging, and
//! failure reports all read that one fact instead of threading booleans
//! through call stacks.
use std::fmt;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context as _, bail};
use chrono::{DateTime, Utc};
use tokio::sync::Mutex;
use crate::{GitHubAppCredentials, GitHubCredentials, InstallationToken};
/// How long before expiry a cached installation token stops being reused.
///
/// Must comfortably exceed the longest git operation that pins a resolved
/// token, so a token handed out just above the margin still outlives the
/// operation. GitHub App installation tokens live 60 minutes.
pub const REFRESH_MARGIN: Duration = Duration::from_mins(10);
/// Where the token a resolve returned came from.
///
/// Time metadata exists only for tokens this source minted. Static
/// credentials (a PAT, or a pre-minted installation token) carry no
/// `minted_at`, so token age is undefined for them and they are never
/// treated as freshly minted.
#[derive(
Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, strum::Display,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum TokenProvenance {
/// This resolve minted the token.
Minted {
minted_at: DateTime<Utc>,
expires_at: DateTime<Utc>,
},
/// This resolve returned a token minted by an earlier resolve.
Reused {
minted_at: DateTime<Utc>,
expires_at: DateTime<Utc>,
},
/// A fixed credential the source cannot re-mint.
Static,
}
/// Non-secret description of the token a resolve returned. Shared by the
/// source, refresh outcomes, logs, and events.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TokenSnapshot {
/// Increments per mint; 0 for `Static`.
pub generation: u64,
pub provenance: TokenProvenance,
}
impl TokenSnapshot {
#[must_use]
pub fn minted_at(&self) -> Option<DateTime<Utc>> {
match self.provenance {
TokenProvenance::Minted { minted_at, .. }
| TokenProvenance::Reused { minted_at, .. } => Some(minted_at),
TokenProvenance::Static => None,
}
}
#[must_use]
pub fn expires_at(&self) -> Option<DateTime<Utc>> {
match self.provenance {
TokenProvenance::Minted { expires_at, .. }
| TokenProvenance::Reused { expires_at, .. } => Some(expires_at),
TokenProvenance::Static => None,
}
}
/// Age of the token at `now`. `None` for static credentials, whose age is
/// undefined.
#[must_use]
pub fn age_at(&self, now: DateTime<Utc>) -> Option<Duration> {
let minted_at = self.minted_at()?;
Some((now - minted_at).to_std().unwrap_or(Duration::ZERO))
}
/// Age of the token in milliseconds, measured now.
#[must_use]
pub fn age_ms(&self) -> Option<u64> {
self.age_at(Utc::now())
.map(|age| u64::try_from(age.as_millis()).unwrap_or(u64::MAX))
}
#[must_use]
pub fn is_static(&self) -> bool {
matches!(self.provenance, TokenProvenance::Static)
}
}
/// A token secret that never appears in `Debug` output. Call
/// [`SecretString::expose`] at the point of use (URL embedding, git
/// credentials) — never in a log line.
#[derive(Clone)]
pub struct SecretString(String);
impl SecretString {
#[must_use]
pub fn new(secret: String) -> Self {
Self(secret)
}
#[must_use]
pub fn expose(&self) -> &str {
&self.0
}
}
impl fmt::Debug for SecretString {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("SecretString(<redacted>)")
}
}
/// A token handed out by [`InstallationTokenSource::resolve`]: the secret plus
/// its non-secret snapshot. Only the snapshot may cross logging or event
/// boundaries.
#[derive(Debug, Clone)]
pub struct ResolvedToken {
pub token: SecretString,
pub snapshot: TokenSnapshot,
/// The source tried to refresh an expiring token, but returned the still-
/// valid cached token after the mint failed.
pub refresh_failed: bool,
}
/// Mints installation tokens for [`InstallationTokenSource`]. Abstracted so
/// tests can script mint results without HTTP.
#[async_trait::async_trait]
pub(crate) trait InstallationTokenMinter: Send + Sync {
async fn mint(&self) -> anyhow::Result<InstallationToken>;
}
/// Real minter backed by GitHub App credentials. `repos` lists repository
/// names within the owner's installation, primary first; the minted token is
/// scoped to exactly that set.
struct AppTokenMinter {
creds: GitHubAppCredentials,
http: fabro_http::HttpClient,
owner: String,
repos: Vec<String>,
base_url: String,
permissions: serde_json::Value,
}
#[async_trait::async_trait]
impl InstallationTokenMinter for AppTokenMinter {
async fn mint(&self) -> anyhow::Result<InstallationToken> {
self.creds
.mint_installation_token_for_repositories(
&self.http,
&self.owner,
&self.repos,
&self.base_url,
self.permissions.clone(),
None,
)
.await
}
}
/// A minted token plus the metadata the cache tracks for it.
struct CachedToken {
token: InstallationToken,
minted_at: DateTime<Utc>,
generation: u64,
}
impl CachedToken {
fn resolved(&self, provenance: TokenProvenance) -> ResolvedToken {
ResolvedToken {
token: SecretString::new(self.token.token.clone()),
snapshot: TokenSnapshot {
generation: self.generation,
provenance,
},
refresh_failed: false,
}
}
}
enum SourceState {
/// A fixed personal access token — no expiry metadata.
Pat(SecretString),
/// A pre-minted installation token — fixed, rejected client-side once
/// expired.
Installation(InstallationToken),
/// GitHub App credentials that mint installation tokens on demand.
///
/// The async lock is held across the mint, making `resolve()`
/// single-flight: concurrent near-expiry callers wait and receive the
/// same generation instead of racing to mint.
App {
minter: Box<dyn InstallationTokenMinter>,
cache: Mutex<Option<CachedToken>>,
},
}
/// Cached installation-token source for one origin repository.
///
/// Static credentials pass through unchanged. App credentials mint through
/// the shared cache: a resolve reuses the cached token until it is within
/// [`REFRESH_MARGIN`] of expiry, then mints a new generation.
pub struct InstallationTokenSource {
/// `owner/repo`, for logs only.
repo: String,
state: SourceState,
}
impl InstallationTokenSource {
/// Build a source for `creds` against the repository in `origin_url`.
///
/// `permissions` scopes minted installation tokens; static credentials
/// pass through and ignore it.
pub fn for_origin(
creds: &GitHubCredentials,
origin_url: &str,
permissions: serde_json::Value,
) -> anyhow::Result<Arc<Self>> {
let normalized = crate::normalize_repo_origin_url(origin_url);
let (owner, repo) = crate::parse_github_owner_repo(&normalized)
.context("parsing GitHub origin for token source")?;
Self::for_repository(creds, owner, repo, permissions)
}
/// Build a source for an already parsed GitHub repository.
pub fn for_repository(
creds: &GitHubCredentials,
owner: String,
repo: String,
permissions: serde_json::Value,
) -> anyhow::Result<Arc<Self>> {
Self::for_repositories(creds, owner, vec![repo], permissions)
}
/// Build a source for a validated effective repository set. Minted
/// tokens are scoped to every repository in the set with the shared
/// permissions; caching, refresh margin, and single-flight behavior are
/// identical to the single-repository source.
pub fn for_access(
creds: &GitHubCredentials,
access: &crate::GitHubRepositoryAccess,
) -> anyhow::Result<Arc<Self>> {
Self::for_repositories(
creds,
access.owner().to_string(),
access.repository_names(),
access.permissions_json()?,
)
}
fn for_repositories(
creds: &GitHubCredentials,
owner: String,
repos: Vec<String>,
permissions: serde_json::Value,
) -> anyhow::Result<Arc<Self>> {
let repo_display = match repos.as_slice() {
[primary] => format!("{owner}/{primary}"),
[primary, additional @ ..] => {
format!("{owner}/{primary} (+{} additional)", additional.len())
}
[] => bail!("token source requires at least one repository"),
};
let state = match creds {
GitHubCredentials::Pat(token) => SourceState::Pat(SecretString::new(token.clone())),
GitHubCredentials::Installation(token) => SourceState::Installation(token.clone()),
GitHubCredentials::App(app) => {
let http = fabro_http::http_client()
.map_err(anyhow::Error::new)
.context("building HTTP client for token source")?;
SourceState::App {
minter: Box::new(AppTokenMinter {
creds: app.clone(),
http,
owner,
repos,
base_url: crate::github_api_base_url(),
permissions,
}),
cache: Mutex::new(None),
}
}
};
Ok(Arc::new(Self {
repo: repo_display,
state,
}))
}
/// Build a source for a personal access token.
#[must_use]
pub fn pat(token: String) -> Arc<Self> {
Arc::new(Self {
repo: String::new(),
state: SourceState::Pat(SecretString::new(token)),
})
}
/// Build a source for a pre-minted installation token.
#[must_use]
pub fn installation(token: InstallationToken) -> Arc<Self> {
Arc::new(Self {
repo: String::new(),
state: SourceState::Installation(token),
})
}
/// Build a minting source over a custom minter.
#[cfg(any(test, feature = "test-support"))]
#[must_use]
pub(crate) fn with_minter(repo: String, minter: Box<dyn InstallationTokenMinter>) -> Arc<Self> {
Arc::new(Self {
repo,
state: SourceState::App {
minter,
cache: Mutex::new(None),
},
})
}
/// Whether this source can mint new tokens (GitHub App credentials).
#[must_use]
pub fn mints_installation_tokens(&self) -> bool {
matches!(self.state, SourceState::App { .. })
}
/// Resolve a token, reusing the cached one until it nears expiry.
pub async fn resolve(&self) -> anyhow::Result<ResolvedToken> {
match &self.state {
SourceState::Pat(_) | SourceState::Installation(_) => self.resolve_static(),
SourceState::App { minter, cache } => {
let mut cache = cache.lock().await;
// Re-check under the lock: a waiter queued behind a minter
// finds the fresh token here instead of minting again.
if let Some(cached) = cache.as_ref() {
if !cached.token.near_expiry(REFRESH_MARGIN) {
let resolved = cached.resolved(TokenProvenance::Reused {
minted_at: cached.minted_at,
expires_at: cached.token.expires_at,
});
tracing::debug!(
repo = %self.repo,
generation = cached.generation,
expires_at = %cached.token.expires_at,
"Reusing cached GitHub installation token"
);
return Ok(resolved);
}
}
match self.mint_locked(minter.as_ref(), &mut cache).await {
Ok(resolved) => Ok(resolved),
Err(err) => {
if let Some(cached) = cache.as_ref() {
if cached.token.valid_token().is_ok() {
tracing::warn!(
error = %format!("{err:#}"),
repo = %self.repo,
generation = cached.generation,
expires_at = %cached.token.expires_at,
"GitHub installation token refresh failed; using cached token"
);
let mut resolved = cached.resolved(TokenProvenance::Reused {
minted_at: cached.minted_at,
expires_at: cached.token.expires_at,
});
resolved.refresh_failed = true;
return Ok(resolved);
}
}
Err(err)
}
}
}
}
}
/// Mint a fresh token for the first repository clone and seed the cache
/// with it.
///
/// The clone deliberately never reuses a warm cache: retrying a clone with
/// the token minted for it is the established replication-lag recovery,
/// and reuse of older tokens for clones is a separate follow-up. Seeding
/// makes the clone token generation 1, so later refreshes reuse it until
/// it nears expiry.
pub async fn mint_for_clone(&self) -> anyhow::Result<ResolvedToken> {
match &self.state {
SourceState::Pat(_) | SourceState::Installation(_) => self.resolve_static(),
SourceState::App { minter, cache } => {
let mut cache = cache.lock().await;
self.mint_locked(minter.as_ref(), &mut cache).await
}
}
}
fn resolve_static(&self) -> anyhow::Result<ResolvedToken> {
let secret = match &self.state {
SourceState::Pat(token) => token.clone(),
SourceState::Installation(token) => SecretString::new(token.valid_token()?.to_owned()),
SourceState::App { .. } => unreachable!("resolve_static called for App credentials"),
};
Ok(ResolvedToken {
token: secret,
snapshot: TokenSnapshot {
generation: 0,
provenance: TokenProvenance::Static,
},
refresh_failed: false,
})
}
async fn mint_locked(
&self,
minter: &dyn InstallationTokenMinter,
cache: &mut Option<CachedToken>,
) -> anyhow::Result<ResolvedToken> {
let token = minter
.mint()
.await
.context("minting GitHub installation access token")?;
let generation = cache.as_ref().map_or(0, |cached| cached.generation) + 1;
let minted_at = Utc::now();
tracing::info!(
repo = %self.repo,
generation,
expires_at = %token.expires_at,
"Minted GitHub installation token"
);
let cached = CachedToken {
token,
minted_at,
generation,
};
let resolved = cached.resolved(TokenProvenance::Minted {
minted_at,
expires_at: cached.token.expires_at,
});
*cache = Some(cached);
Ok(resolved)
}
}
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use std::sync::atomic::{AtomicUsize, Ordering};
use anyhow::anyhow;
use super::*;
enum MintAction {
Token(&'static str, DateTime<Utc>),
Error(&'static str),
}
struct MockMinter {
calls: AtomicUsize,
script: Mutex<VecDeque<MintAction>>,
}
impl MockMinter {
fn new(script: Vec<MintAction>) -> Self {
Self {
calls: AtomicUsize::new(0),
script: Mutex::new(script.into()),
}
}
fn calls(&self) -> usize {
self.calls.load(Ordering::SeqCst)
}
}
#[async_trait::async_trait]
impl InstallationTokenMinter for MockMinter {
async fn mint(&self) -> anyhow::Result<InstallationToken> {
self.calls.fetch_add(1, Ordering::SeqCst);
match self.script.lock().await.pop_front().expect("mint script") {
MintAction::Token(token, expires_at) => Ok(InstallationToken {
token: token.to_string(),
expires_at,
}),
MintAction::Error(message) => Err(anyhow!(message)),
}
}
}
struct SharedMinter(Arc<MockMinter>);
#[async_trait::async_trait]
impl InstallationTokenMinter for SharedMinter {
async fn mint(&self) -> anyhow::Result<InstallationToken> {
self.0.mint().await
}
}
fn mintable(script: Vec<MintAction>) -> (Arc<InstallationTokenSource>, Arc<MockMinter>) {
let minter = Arc::new(MockMinter::new(script));
let source = InstallationTokenSource::with_minter(
"owner/repo".to_string(),
Box::new(SharedMinter(Arc::clone(&minter))),
);
(source, minter)
}
#[tokio::test]
async fn pat_resolves_as_static_generation_zero() {
let source = InstallationTokenSource::for_origin(
&GitHubCredentials::Pat("ghp_pat".to_string()),
"https://github.com/owner/repo.git",
serde_json::json!({ "contents": "write" }),
)
.unwrap();
let resolved = source.resolve().await.unwrap();
assert_eq!(resolved.token.expose(), "ghp_pat");
assert_eq!(resolved.snapshot.generation, 0);
assert!(resolved.snapshot.is_static());
assert!(!source.mints_installation_tokens());
}
/// A source built from a validated multi-repository access value uses the
/// same state machine as the single-repository constructor: static
/// credentials pass through, and App credentials share the cache
/// machinery exercised by the `with_minter` tests below.
#[tokio::test]
async fn for_access_source_resolves_like_the_single_repository_source() {
let access = crate::GitHubRepositoryAccess::new(
Some("https://github.com/owner/repo.git"),
&["owner/keystone".parse().unwrap()].into_iter().collect(),
std::collections::HashMap::from([("contents".to_string(), "read".to_string())]),
)
.unwrap()
.expect("origin should produce an access value");
let source = InstallationTokenSource::for_access(
&GitHubCredentials::Pat("ghp_pat".to_string()),
&access,
)
.unwrap();
let resolved = source.resolve().await.unwrap();
assert_eq!(resolved.token.expose(), "ghp_pat");
assert!(resolved.snapshot.is_static());
}
#[tokio::test]
async fn static_installation_token_resolves_until_expiry() {
let valid = InstallationTokenSource::for_origin(
&GitHubCredentials::Installation(InstallationToken {
token: "ghs_static".to_string(),
expires_at: Utc::now() + chrono::Duration::minutes(30),
}),
"https://github.com/owner/repo.git",
serde_json::json!({}),
)
.unwrap();
let resolved = valid.resolve().await.unwrap();
assert_eq!(resolved.token.expose(), "ghs_static");
assert!(resolved.snapshot.is_static());
let expired = InstallationTokenSource::for_origin(
&GitHubCredentials::Installation(InstallationToken {
token: "ghs_expired".to_string(),
expires_at: Utc::now() - chrono::Duration::seconds(1),
}),
"https://github.com/owner/repo.git",
serde_json::json!({}),
)
.unwrap();
assert!(expired.resolve().await.is_err());
}
#[tokio::test]
async fn resolve_reuses_cached_token_before_the_margin() {
let (source, minter) = mintable(vec![MintAction::Token(
"ghs_gen1",
Utc::now() + chrono::Duration::minutes(30),
)]);
let first = source.resolve().await.unwrap();
let second = source.resolve().await.unwrap();
assert_eq!(minter.calls(), 1);
assert_eq!(first.snapshot.generation, 1);
assert_eq!(second.snapshot.generation, 1);
assert!(matches!(
first.snapshot.provenance,
TokenProvenance::Minted { .. }
));
assert!(matches!(
second.snapshot.provenance,
TokenProvenance::Reused { .. }
));
assert_eq!(second.token.expose(), "ghs_gen1");
}
#[tokio::test]
async fn resolve_mints_a_new_generation_inside_the_margin() {
let (source, minter) = mintable(vec![
// Expires inside REFRESH_MARGIN, so the second resolve re-mints.
MintAction::Token("ghs_gen1", Utc::now() + chrono::Duration::minutes(5)),
MintAction::Token("ghs_gen2", Utc::now() + chrono::Duration::minutes(60)),
]);
let first = source.resolve().await.unwrap();
let second = source.resolve().await.unwrap();
assert_eq!(minter.calls(), 2);
assert_eq!(first.snapshot.generation, 1);
assert_eq!(second.snapshot.generation, 2);
assert!(matches!(
second.snapshot.provenance,
TokenProvenance::Minted { .. }
));
assert_eq!(second.token.expose(), "ghs_gen2");
}
#[tokio::test]
async fn resolve_uses_a_valid_cached_token_when_refresh_fails() {
let (source, minter) = mintable(vec![
MintAction::Token("ghs_gen1", Utc::now() + chrono::Duration::minutes(5)),
MintAction::Error("mint failed"),
]);
let first = source.resolve().await.unwrap();
let second = source.resolve().await.unwrap();
assert_eq!(minter.calls(), 2);
assert_eq!(first.snapshot.generation, 1);
assert_eq!(second.snapshot.generation, 1);
assert!(second.refresh_failed);
assert!(matches!(
second.snapshot.provenance,
TokenProvenance::Reused { .. }
));
assert_eq!(second.token.expose(), "ghs_gen1");
}
#[tokio::test]
async fn concurrent_resolves_share_one_generation() {
// Single mint in the script: a second mint would panic on an empty
// script, so success proves single-flight.
let (source, minter) = mintable(vec![MintAction::Token(
"ghs_gen1",
Utc::now() + chrono::Duration::minutes(60),
)]);
let handles: Vec<_> = (0..8)
.map(|_| {
let source = Arc::clone(&source);
tokio::spawn(async move { source.resolve().await })
})
.collect();
for handle in handles {
let resolved = handle.await.unwrap().unwrap();
assert_eq!(resolved.snapshot.generation, 1);
assert_eq!(resolved.token.expose(), "ghs_gen1");
}
assert_eq!(minter.calls(), 1);
}
#[tokio::test]
async fn mint_for_clone_always_mints_and_seeds_the_cache() {
let (source, minter) = mintable(vec![MintAction::Token(
"ghs_clone",
Utc::now() + chrono::Duration::minutes(60),
)]);
let clone_token = source.mint_for_clone().await.unwrap();
assert_eq!(clone_token.snapshot.generation, 1);
assert!(matches!(
clone_token.snapshot.provenance,
TokenProvenance::Minted { .. }
));
// A later resolve reuses the clone token instead of minting again.
let refreshed = source.resolve().await.unwrap();
assert_eq!(refreshed.snapshot.generation, 1);
assert_eq!(refreshed.token.expose(), "ghs_clone");
assert!(matches!(
refreshed.snapshot.provenance,
TokenProvenance::Reused { .. }
));
assert_eq!(minter.calls(), 1);
}
#[tokio::test]
async fn mint_failure_surfaces_with_context() {
let (source, _minter) = mintable(vec![MintAction::Error("mint failed")]);
let err = format!("{:#}", source.resolve().await.unwrap_err());
assert!(err.contains("mint failed"), "got: {err}");
assert!(
err.contains("minting GitHub installation access token"),
"got: {err}"
);
}
#[test]
fn secret_string_debug_never_prints_the_secret() {
let secret = SecretString::new("ghs_super_secret".to_string());
let rendered = format!("{secret:?}");
assert!(!rendered.contains("ghs_super_secret"), "{rendered}");
}
#[test]
fn snapshot_age_is_defined_only_for_minted_tokens() {
let now = Utc::now();
let minted = TokenSnapshot {
generation: 3,
provenance: TokenProvenance::Minted {
minted_at: now - chrono::Duration::seconds(42),
expires_at: now + chrono::Duration::minutes(60),
},
};
assert_eq!(minted.age_at(now), Some(Duration::from_secs(42)));
let fixed = TokenSnapshot {
generation: 0,
provenance: TokenProvenance::Static,
};
assert_eq!(fixed.age_at(now), None);
assert_eq!(fixed.expires_at(), None);
}
}