Skip to content

Commit 3381fa2

Browse files
authored
github token provider (#19)
* github token provider * log tool calls
1 parent ba7a60b commit 3381fa2

17 files changed

Lines changed: 588 additions & 17 deletions

File tree

Cargo.lock

Lines changed: 63 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ members = [
2323
"crates/secrets_k8s",
2424
"crates/rook_proto",
2525
"crates/rook",
26+
"crates/github_token_provider",
2627
]
2728
resolver = "3"
2829

@@ -49,6 +50,7 @@ sandcastle-sandbox-provider-docker = { path = "crates/sandbox_provider_docker" }
4950
sandcastle-sandbox-provider-daytona = { path = "crates/sandbox_provider_daytona" }
5051
sandcastle-sandbox-provider-k8s = { path = "crates/sandbox_provider_k8s" }
5152
sandcastle-rook-proto = { path = "crates/rook_proto" }
53+
sandcastle-github-token-provider = { path = "crates/github_token_provider" }
5254

5355
# External dependencies
5456
anyhow = "1"
@@ -72,3 +74,5 @@ tracing = "0.1"
7274
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
7375
uuid = "1"
7476
walkdir = "2"
77+
jsonwebtoken = "9"
78+
humantime = "2"
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[package]
2+
name = "sandcastle-github-token-provider"
3+
version = "0.1.0"
4+
edition = "2024"
5+
license = "MIT"
6+
7+
[dependencies]
8+
anyhow = { workspace = true }
9+
reqwest = { workspace = true }
10+
serde = { workspace = true }
11+
serde_json = { workspace = true }
12+
tracing = { workspace = true }
13+
jsonwebtoken = { workspace = true }
14+
humantime = { workspace = true }
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
use std::time::{SystemTime, UNIX_EPOCH};
2+
3+
use anyhow::{Context, anyhow};
4+
use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
5+
use reqwest::Client;
6+
use serde::Serialize;
7+
8+
pub struct GitHubAppTokenProvider {
9+
app_id: u64,
10+
private_key_pem: String,
11+
installation_id: u64,
12+
http: Client,
13+
}
14+
15+
pub struct GitHubToken {
16+
pub token: String,
17+
pub expires_at: SystemTime,
18+
}
19+
20+
#[derive(Serialize)]
21+
struct AppClaims {
22+
iat: i64,
23+
exp: i64,
24+
iss: String,
25+
}
26+
27+
impl GitHubAppTokenProvider {
28+
pub fn new(app_id: u64, private_key_pem: String, installation_id: u64) -> Self {
29+
Self {
30+
app_id,
31+
private_key_pem,
32+
installation_id,
33+
http: Client::new(),
34+
}
35+
}
36+
37+
/// Construct from environment variables. Returns `None` if `GITHUB_APP_ID` is not set
38+
/// (feature is disabled). Returns `Err` if any required variable is present but malformed.
39+
pub fn from_env() -> anyhow::Result<Option<Self>> {
40+
let app_id_str = match std::env::var("GITHUB_APP_ID") {
41+
Ok(v) => v,
42+
Err(_) => return Ok(None),
43+
};
44+
45+
let app_id: u64 = app_id_str
46+
.parse()
47+
.context("GITHUB_APP_ID must be a numeric value")?;
48+
49+
let raw_key =
50+
std::env::var("GITHUB_APP_PRIVATE_KEY").context("GITHUB_APP_PRIVATE_KEY not set")?;
51+
// Support environments where the PEM is stored with literal \n instead of real newlines.
52+
let private_key_pem = raw_key.replace("\\n", "\n");
53+
54+
let installation_id_str = std::env::var("GITHUB_APP_INSTALLATION_ID")
55+
.context("GITHUB_APP_INSTALLATION_ID not set")?;
56+
let installation_id: u64 = installation_id_str
57+
.parse()
58+
.context("GITHUB_APP_INSTALLATION_ID must be a numeric value")?;
59+
60+
Ok(Some(Self::new(app_id, private_key_pem, installation_id)))
61+
}
62+
63+
pub fn app_id(&self) -> u64 {
64+
self.app_id
65+
}
66+
67+
/// Generate a scoped GitHub installation token for the given repositories.
68+
///
69+
/// `repos` may be bare names (`"my-repo"`) or prefixed (`"owner/my-repo"`).
70+
/// Any `owner/` prefix is stripped — GitHub's API only accepts bare names scoped to the
71+
/// configured installation.
72+
pub async fn get_token(&self, repos: &[String]) -> anyhow::Result<GitHubToken> {
73+
let jwt = self.make_jwt()?;
74+
75+
let bare_names: Vec<&str> = repos
76+
.iter()
77+
.map(|r| {
78+
if let Some((_owner, name)) = r.split_once('/') {
79+
tracing::warn!(
80+
repo = %r,
81+
"get_github_token: stripping owner prefix from repository name"
82+
);
83+
name
84+
} else {
85+
r.as_str()
86+
}
87+
})
88+
.collect();
89+
90+
let body = serde_json::json!({
91+
"repositories": bare_names,
92+
"permissions": {
93+
"contents": "write",
94+
"pull_requests": "write"
95+
}
96+
});
97+
98+
let url = format!(
99+
"https://api.github.com/app/installations/{}/access_tokens",
100+
self.installation_id
101+
);
102+
103+
let resp = self
104+
.http
105+
.post(&url)
106+
.bearer_auth(&jwt)
107+
.header("Accept", "application/vnd.github+json")
108+
.header("X-GitHub-Api-Version", "2022-11-28")
109+
.header("User-Agent", "sandcastle")
110+
.json(&body)
111+
.send()
112+
.await
113+
.context("failed to contact GitHub API")?;
114+
115+
let status = resp.status();
116+
if !status.is_success() {
117+
let body_text = resp.text().await.unwrap_or_default();
118+
return Err(anyhow!(
119+
"GitHub API returned {}: {}",
120+
status.as_u16(),
121+
body_text
122+
));
123+
}
124+
125+
let data: serde_json::Value = resp
126+
.json()
127+
.await
128+
.context("failed to parse GitHub API response")?;
129+
130+
let token = data["token"]
131+
.as_str()
132+
.ok_or_else(|| anyhow!("GitHub API response missing 'token' field"))?
133+
.to_string();
134+
135+
let expires_at_str = data["expires_at"]
136+
.as_str()
137+
.ok_or_else(|| anyhow!("GitHub API response missing 'expires_at' field"))?;
138+
139+
let expires_at = humantime::parse_rfc3339(expires_at_str)
140+
.with_context(|| format!("invalid expires_at from GitHub API: {expires_at_str}"))?;
141+
142+
Ok(GitHubToken { token, expires_at })
143+
}
144+
145+
fn make_jwt(&self) -> anyhow::Result<String> {
146+
let now = SystemTime::now()
147+
.duration_since(UNIX_EPOCH)
148+
.context("system clock is before UNIX epoch")?
149+
.as_secs() as i64;
150+
151+
let claims = AppClaims {
152+
iat: now - 60,
153+
exp: now + 600,
154+
iss: self.app_id.to_string(),
155+
};
156+
157+
let key = EncodingKey::from_rsa_pem(self.private_key_pem.as_bytes())
158+
.context("failed to parse GITHUB_APP_PRIVATE_KEY as RSA PEM")?;
159+
160+
encode(&Header::new(Algorithm::RS256), &claims, &key)
161+
.context("failed to sign GitHub App JWT")
162+
}
163+
}

crates/sandcastle/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ sandcastle-sandbox-providers = { workspace = true }
1111
sandcastle-store = { workspace = true }
1212
sandcastle-secrets = { workspace = true }
1313
sandcastle-rook-proto = { workspace = true }
14+
sandcastle-github-token-provider = { workspace = true }
1415
anyhow = { workspace = true }
16+
humantime = { workspace = true }
1517
futures-util = { workspace = true }
1618
axum = { workspace = true }
1719
rmcp = { workspace = true }

0 commit comments

Comments
 (0)