mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-05 08:10:39 +00:00
Add the server-side CLI OAuth endpoints and token persistence needed to mint JWT access tokens and rotating refresh tokens from the existing GitHub web auth flow. Add CLI auth storage plus `fabro auth login`, `logout`, and `status`, and prefer stored OAuth access tokens when building target clients.
554 lines
17 KiB
Rust
554 lines
17 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
const TEST_RSA_PRIVATE_PEM: &str = include_str!("testdata/rsa_private.pem");
|
|
const TEST_RSA_PUBLIC_PEM: &str = include_str!("testdata/rsa_public.pem");
|
|
|
|
/// Configuration for a registered GitHub App (user-facing input).
|
|
#[derive(Debug, Clone)]
|
|
pub struct AppOptions {
|
|
pub app_id: String,
|
|
pub slug: String,
|
|
pub owner_login: String,
|
|
pub public: bool,
|
|
pub private_key_pem: String,
|
|
pub webhook_secret: Option<String>,
|
|
}
|
|
|
|
/// Internal enriched app config with derived public key.
|
|
#[derive(Debug, Clone)]
|
|
pub struct RegisteredApp {
|
|
pub config: AppOptions,
|
|
/// Derived from `private_key_pem` during `register_app`. Used for JWT
|
|
/// verification.
|
|
pub public_key_pem: String,
|
|
}
|
|
|
|
/// An installation of a GitHub App on a specific owner.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Installation {
|
|
pub id: u64,
|
|
pub app_id: String,
|
|
pub owner: String,
|
|
pub repositories: Vec<String>,
|
|
pub suspended: bool,
|
|
}
|
|
|
|
/// A repository in the fake.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Repository {
|
|
pub owner: String,
|
|
pub name: String,
|
|
pub branches: Vec<String>,
|
|
pub default_branch: String,
|
|
pub private: bool,
|
|
pub git_dir: Option<std::path::PathBuf>,
|
|
}
|
|
|
|
/// A pull request.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PullRequest {
|
|
pub number: u64,
|
|
pub node_id: String,
|
|
pub title: String,
|
|
pub body: String,
|
|
pub state: String,
|
|
pub draft: bool,
|
|
pub mergeable: bool,
|
|
pub additions: u64,
|
|
pub deletions: u64,
|
|
pub changed_files: u64,
|
|
pub html_url: String,
|
|
pub user_login: String,
|
|
pub head_ref: String,
|
|
pub base_ref: String,
|
|
pub created_at: String,
|
|
pub updated_at: String,
|
|
pub auto_merge: Option<AutoMerge>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AutoMerge {
|
|
pub enabled_at: String,
|
|
pub merge_method: String,
|
|
}
|
|
|
|
/// A GitHub Projects V2 project.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Project {
|
|
pub node_id: String,
|
|
pub number: u64,
|
|
pub owner: String,
|
|
pub owner_type: OwnerType,
|
|
pub status_field_id: String,
|
|
pub status_options: Vec<StatusOption>,
|
|
pub items: Vec<ProjectItem>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum OwnerType {
|
|
Organization,
|
|
User,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct StatusOption {
|
|
pub id: String,
|
|
pub name: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ProjectItem {
|
|
pub id: String,
|
|
pub status: String,
|
|
pub content: IssueContent,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct IssueContent {
|
|
pub id: String,
|
|
pub number: u64,
|
|
pub title: String,
|
|
pub body: String,
|
|
pub url: String,
|
|
pub created_at: String,
|
|
pub updated_at: String,
|
|
pub assignee_ids: Vec<String>,
|
|
pub labels: Vec<String>,
|
|
}
|
|
|
|
/// A release.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Release {
|
|
pub tag_name: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct OauthUser {
|
|
pub id: u64,
|
|
pub login: String,
|
|
pub name: Option<String>,
|
|
pub avatar_url: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct OauthEmail {
|
|
pub email: String,
|
|
pub primary: bool,
|
|
pub verified: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct OauthSubject {
|
|
pub user: OauthUser,
|
|
pub emails: Vec<OauthEmail>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct OauthCode {
|
|
pub client_id: String,
|
|
pub subject: OauthSubject,
|
|
}
|
|
|
|
/// An app manifest conversion record.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ManifestConversion {
|
|
pub code: String,
|
|
pub app_id: i64,
|
|
pub slug: String,
|
|
pub client_id: String,
|
|
pub client_secret: String,
|
|
pub webhook_secret: Option<String>,
|
|
pub pem: String,
|
|
}
|
|
|
|
/// A comment on an issue.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Comment {
|
|
pub issue_node_id: String,
|
|
pub body: String,
|
|
}
|
|
|
|
/// Stores webhook configuration.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct WebhookOptions {
|
|
pub url: Option<String>,
|
|
pub content_type: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
|
|
pub enum PermissionLevel {
|
|
#[default]
|
|
None,
|
|
Read,
|
|
Write,
|
|
}
|
|
|
|
impl PermissionLevel {
|
|
fn from_json(value: Option<&serde_json::Value>) -> Self {
|
|
match value.and_then(|value| value.as_str()) {
|
|
Some("read") => Self::Read,
|
|
Some("write") => Self::Write,
|
|
_ => Self::None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum TokenPermission {
|
|
Contents,
|
|
PullRequests,
|
|
Issues,
|
|
OrganizationProjects,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
pub struct TokenPermissions {
|
|
pub contents: PermissionLevel,
|
|
pub pull_requests: PermissionLevel,
|
|
pub issues: PermissionLevel,
|
|
pub organization_projects: PermissionLevel,
|
|
}
|
|
|
|
impl TokenPermissions {
|
|
pub fn from_json(value: &serde_json::Value) -> Self {
|
|
Self {
|
|
contents: PermissionLevel::from_json(value.get("contents")),
|
|
pull_requests: PermissionLevel::from_json(value.get("pull_requests")),
|
|
issues: PermissionLevel::from_json(value.get("issues")),
|
|
organization_projects: PermissionLevel::from_json(value.get("organization_projects")),
|
|
}
|
|
}
|
|
|
|
pub fn level(&self, permission: TokenPermission) -> PermissionLevel {
|
|
match permission {
|
|
TokenPermission::Contents => self.contents,
|
|
TokenPermission::PullRequests => self.pull_requests,
|
|
TokenPermission::Issues => self.issues,
|
|
TokenPermission::OrganizationProjects => self.organization_projects,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Info about an active installation access token.
|
|
#[derive(Debug, Clone)]
|
|
pub struct TokenInfo {
|
|
pub app_id: String,
|
|
pub installation_id: u64,
|
|
pub repositories: Vec<String>,
|
|
pub permissions: serde_json::Value,
|
|
}
|
|
|
|
impl TokenInfo {
|
|
pub fn allows_repo(&self, repo: &str) -> bool {
|
|
self.repositories.iter().any(|allowed| allowed == repo)
|
|
}
|
|
|
|
pub fn parsed_permissions(&self) -> TokenPermissions {
|
|
TokenPermissions::from_json(&self.permissions)
|
|
}
|
|
|
|
pub fn allows(&self, permission: TokenPermission, required: PermissionLevel) -> bool {
|
|
self.parsed_permissions().level(permission) >= required
|
|
}
|
|
}
|
|
|
|
/// Central in-memory state for the fake GitHub server.
|
|
#[derive(Debug, Clone)]
|
|
pub struct AppState {
|
|
pub apps: HashMap<String, RegisteredApp>,
|
|
pub installations: Vec<Installation>,
|
|
pub repositories: Vec<Repository>,
|
|
pub pull_requests: HashMap<(String, String), Vec<PullRequest>>,
|
|
pub active_tokens: HashMap<String, TokenInfo>,
|
|
pub projects: Vec<Project>,
|
|
pub releases: HashMap<(String, String), Release>,
|
|
pub manifest_conversions: HashMap<String, ManifestConversion>,
|
|
pub oauth_codes: HashMap<String, OauthCode>,
|
|
pub oauth_tokens: HashMap<String, OauthSubject>,
|
|
pub oauth_client_id: String,
|
|
pub oauth_client_secret: String,
|
|
pub seeded_user: OauthUser,
|
|
pub seeded_emails: Vec<OauthEmail>,
|
|
pub allow_authorize: bool,
|
|
pub comments: Vec<Comment>,
|
|
pub webhook_config: WebhookOptions,
|
|
pub next_installation_id: u64,
|
|
pub next_pr_number: u64,
|
|
pub viewer_id: String,
|
|
}
|
|
|
|
/// Derive the RSA public key PEM from a private key PEM using the openssl CLI.
|
|
///
|
|
/// Panics if openssl is not available or the key is invalid. This is acceptable
|
|
/// because the fake server is test infrastructure and openssl is already
|
|
/// required by the test helpers that generate key pairs.
|
|
#[expect(
|
|
clippy::disallowed_types,
|
|
reason = "test harness: piping private key PEM into openssl via sync std::io is intentional"
|
|
)]
|
|
pub fn derive_public_key_pem(private_key_pem: &str) -> String {
|
|
use std::io::Write;
|
|
use std::process::{Command, Stdio};
|
|
|
|
if private_key_pem == TEST_RSA_PRIVATE_PEM {
|
|
return TEST_RSA_PUBLIC_PEM.to_string();
|
|
}
|
|
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "This synchronous test harness helper derives an RSA public key with the host openssl CLI."
|
|
)]
|
|
let mut child = Command::new("openssl")
|
|
.args(["rsa", "-pubout"])
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.expect("openssl must be available");
|
|
child
|
|
.stdin
|
|
.take()
|
|
.expect("openssl child should expose stdin")
|
|
.write_all(private_key_pem.as_bytes())
|
|
.expect("private key should be written to openssl stdin");
|
|
let output = child
|
|
.wait_with_output()
|
|
.expect("openssl child should exit successfully");
|
|
assert!(output.status.success(), "openssl rsa -pubout failed");
|
|
String::from_utf8(output.stdout).expect("openssl public key output should be UTF-8")
|
|
}
|
|
|
|
impl AppState {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
apps: HashMap::new(),
|
|
installations: Vec::new(),
|
|
repositories: Vec::new(),
|
|
pull_requests: HashMap::new(),
|
|
active_tokens: HashMap::new(),
|
|
projects: Vec::new(),
|
|
releases: HashMap::new(),
|
|
manifest_conversions: HashMap::new(),
|
|
oauth_codes: HashMap::new(),
|
|
oauth_tokens: HashMap::new(),
|
|
oauth_client_id: "github-client-id".to_string(),
|
|
oauth_client_secret: "github-client-secret".to_string(),
|
|
seeded_user: OauthUser {
|
|
id: 12345,
|
|
login: "octocat".to_string(),
|
|
name: Some("The Octocat".to_string()),
|
|
avatar_url: "https://avatars.githubusercontent.com/u/12345?v=4".to_string(),
|
|
},
|
|
seeded_emails: vec![OauthEmail {
|
|
email: "octocat@example.com".to_string(),
|
|
primary: true,
|
|
verified: true,
|
|
}],
|
|
allow_authorize: true,
|
|
comments: Vec::new(),
|
|
webhook_config: WebhookOptions::default(),
|
|
next_installation_id: 1,
|
|
next_pr_number: 1,
|
|
viewer_id: "U_fakeviewer".to_string(),
|
|
}
|
|
}
|
|
|
|
pub fn register_app(&mut self, config: AppOptions) {
|
|
let public_key_pem = derive_public_key_pem(&config.private_key_pem);
|
|
let app_id = config.app_id.clone();
|
|
self.apps.insert(app_id, RegisteredApp {
|
|
config,
|
|
public_key_pem,
|
|
});
|
|
}
|
|
|
|
pub fn add_installation(
|
|
&mut self,
|
|
app_id: &str,
|
|
owner: &str,
|
|
repos: Vec<String>,
|
|
suspended: bool,
|
|
) -> u64 {
|
|
let id = self.next_installation_id;
|
|
self.next_installation_id += 1;
|
|
self.installations.push(Installation {
|
|
id,
|
|
app_id: app_id.to_string(),
|
|
owner: owner.to_string(),
|
|
repositories: repos,
|
|
suspended,
|
|
});
|
|
id
|
|
}
|
|
|
|
pub fn add_repository(
|
|
&mut self,
|
|
owner: &str,
|
|
name: &str,
|
|
branches: Vec<String>,
|
|
private: bool,
|
|
) {
|
|
self.repositories.push(Repository {
|
|
owner: owner.to_string(),
|
|
name: name.to_string(),
|
|
branches,
|
|
default_branch: "main".to_string(),
|
|
private,
|
|
git_dir: None,
|
|
});
|
|
}
|
|
|
|
pub fn find_installation(&self, owner: &str, repo: &str) -> Option<&Installation> {
|
|
self.installations
|
|
.iter()
|
|
.find(|i| i.owner == owner && i.repositories.iter().any(|r| r == repo))
|
|
}
|
|
|
|
pub fn find_installation_by_id(&self, id: u64) -> Option<&Installation> {
|
|
self.installations.iter().find(|i| i.id == id)
|
|
}
|
|
|
|
pub fn generate_access_token(
|
|
&mut self,
|
|
app_id: &str,
|
|
installation_id: u64,
|
|
repositories: Vec<String>,
|
|
permissions: serde_json::Value,
|
|
) -> String {
|
|
let token = format!("ghs_{}", uuid::Uuid::new_v4().to_string().replace('-', ""));
|
|
self.active_tokens.insert(token.clone(), TokenInfo {
|
|
app_id: app_id.to_string(),
|
|
installation_id,
|
|
repositories,
|
|
permissions,
|
|
});
|
|
token
|
|
}
|
|
|
|
pub fn validate_token(&self, token: &str) -> Option<&TokenInfo> {
|
|
self.active_tokens.get(token)
|
|
}
|
|
|
|
pub fn oauth_subject(&self) -> OauthSubject {
|
|
OauthSubject {
|
|
user: self.seeded_user.clone(),
|
|
emails: self.seeded_emails.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for AppState {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// Initialize a bare git repository at `{git_root}/{owner}/{repo}.git/`.
|
|
/// Returns the path to the bare repo directory.
|
|
pub fn init_bare_repo(
|
|
git_root: &std::path::Path,
|
|
owner: &str,
|
|
repo: &str,
|
|
) -> Result<std::path::PathBuf, String> {
|
|
let repo_dir = git_root.join(owner).join(format!("{repo}.git"));
|
|
if repo_dir.exists() {
|
|
return Ok(repo_dir);
|
|
}
|
|
std::fs::create_dir_all(&repo_dir)
|
|
.map_err(|e| format!("failed to create git dir {}: {e}", repo_dir.display()))?;
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "This synchronous test harness helper initializes fixture git repositories with the real git CLI."
|
|
)]
|
|
let output = std::process::Command::new("git")
|
|
.args(["init", "--bare"])
|
|
.arg(&repo_dir)
|
|
.output()
|
|
.map_err(|e| format!("failed to run git init: {e}"))?;
|
|
if !output.status.success() {
|
|
return Err(format!(
|
|
"git init --bare failed: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
));
|
|
}
|
|
|
|
// Enable http.receivepack so push works via git-http-backend
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "This synchronous test harness helper configures fixture git repositories with the real git CLI."
|
|
)]
|
|
let output = std::process::Command::new("git")
|
|
.args(["config", "http.receivepack", "true"])
|
|
.current_dir(&repo_dir)
|
|
.output()
|
|
.map_err(|e| format!("failed to configure git repo: {e}"))?;
|
|
if !output.status.success() {
|
|
return Err(format!(
|
|
"git config http.receivepack failed: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
));
|
|
}
|
|
|
|
Ok(repo_dir)
|
|
}
|
|
|
|
/// Initialize bare git repos for all repositories in the state.
|
|
/// Sets each repository's `git_dir` field.
|
|
pub fn init_git_repos(state: &mut AppState, git_root: &std::path::Path) -> Result<(), String> {
|
|
for repo in &mut state.repositories {
|
|
let git_dir = init_bare_repo(git_root, &repo.owner, &repo.name)?;
|
|
repo.git_dir = Some(git_dir);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::test_support::{test_rsa_private_key, test_rsa_public_key};
|
|
|
|
#[test]
|
|
fn empty_state_has_no_apps() {
|
|
let state = AppState::new();
|
|
assert!(state.apps.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn repository_has_private_field() {
|
|
let mut state = AppState::new();
|
|
state.add_repository("owner", "private-repo", vec!["main".to_string()], true);
|
|
let repo = state
|
|
.repositories
|
|
.iter()
|
|
.find(|r| r.name == "private-repo")
|
|
.unwrap();
|
|
assert!(repo.private);
|
|
}
|
|
|
|
#[test]
|
|
fn git_root_initializes_bare_repo() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let git_root = tmp.path().to_path_buf();
|
|
let repo_git_dir = init_bare_repo(&git_root, "acme", "widgets").unwrap();
|
|
assert!(repo_git_dir.join("HEAD").exists());
|
|
assert!(repo_git_dir.join("objects").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn can_register_app() {
|
|
let mut state = AppState::new();
|
|
state.register_app(AppOptions {
|
|
app_id: "12345".to_string(),
|
|
slug: "test-app".to_string(),
|
|
owner_login: "test-owner".to_string(),
|
|
public: true,
|
|
private_key_pem: test_rsa_private_key().to_string(),
|
|
webhook_secret: Some("secret".to_string()),
|
|
});
|
|
assert_eq!(state.apps.len(), 1);
|
|
assert_eq!(state.apps["12345"].config.slug, "test-app");
|
|
assert_eq!(state.apps["12345"].public_key_pem, test_rsa_public_key());
|
|
}
|
|
}
|