From 1f7eeba0bd5960bb081538b9e6bc4b2d9c030e30 Mon Sep 17 00:00:00 2001 From: bobdivx Date: Tue, 13 Oct 2026 23:50:51 +0200 Subject: [PATCH] feat(github-app): P2 app JWT + installation token cache, per-project auth resolver, DEVFORGE_GITHUB_APP flag --- Cargo.lock | 4 + apps/server/src/correction.rs | 1 + apps/server/src/dev_env.rs | 5 +- apps/server/src/draft_routes.rs | 2 + apps/server/src/github_app_routes.rs | 43 +-- apps/server/src/github_app_routes_tests.rs | 43 --- apps/server/src/github_auth.rs | 247 ++++++++++++++ apps/server/src/main.rs | 1 + apps/server/src/rich_chat.rs | 1 + apps/server/src/routes.rs | 8 +- apps/server/src/state.rs | 3 + crates/deploy/Cargo.toml | 1 + crates/deploy/src/lib.rs | 55 +++- crates/github/Cargo.toml | 6 + crates/github/src/app_auth.rs | 365 +++++++++++++++++++++ crates/github/src/app_auth_tests.rs | 226 +++++++++++++ crates/github/src/lib.rs | 1 + 17 files changed, 937 insertions(+), 75 deletions(-) create mode 100644 apps/server/src/github_auth.rs create mode 100644 crates/github/src/app_auth.rs create mode 100644 crates/github/src/app_auth_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 428b3b2f95..eceb12df1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -716,6 +716,7 @@ name = "devforge-deploy" version = "2.0.238" dependencies = [ "async-trait", + "base64", "devforge-shared", "serde", "serde_json", @@ -762,11 +763,14 @@ name = "devforge-github" version = "2.0.238" dependencies = [ "async-trait", + "chrono", "devforge-shared", + "jsonwebtoken", "reqwest", "serde", "serde_json", "tokio", + "wiremock", ] [[package]] diff --git a/apps/server/src/correction.rs b/apps/server/src/correction.rs index a2910ba13e..44d7e57e5c 100644 --- a/apps/server/src/correction.rs +++ b/apps/server/src/correction.rs @@ -652,6 +652,7 @@ async fn remote(state: &AppState, user_uuid: &str, project: &Project) -> Option< if token.trim().is_empty() { token = state.github.instance_token().unwrap_or_default(); } + let token = crate::github_auth::project_token(state, &project.uuid, token).await; Some(draft::token_url(repo, Some(&token))) } diff --git a/apps/server/src/dev_env.rs b/apps/server/src/dev_env.rs index 806ea14229..883178f910 100644 --- a/apps/server/src/dev_env.rs +++ b/apps/server/src/dev_env.rs @@ -543,6 +543,8 @@ pub(crate) async fn run_dev_deploy( .flatten() .map(|(t,)| crate::secret_box::open_compat(&t)) .filter(|t| !t.trim().is_empty()); + // GitHub App : token d'installation restreint au dépôt si le projet est lié. + let deploy_tok = crate::github_auth::deploy_token(state, &project.uuid, token).await; // Mêmes variables que la prod (jamais loguées). let env_file = crate::routes::load_env_file_content(&state.pool, &project.uuid).await; @@ -585,7 +587,8 @@ pub(crate) async fn run_dev_deploy( docker_build_context: project.docker_build_context.clone(), publish_directory: project.publish_directory.clone(), is_static: project.is_static != 0, - github_token: token, + github_token: deploy_tok.token, + github_token_ephemeral: deploy_tok.ephemeral, env_file, proxy_labels: Some(labels), gpu_nvidia: project.gpu_nvidia != 0, diff --git a/apps/server/src/draft_routes.rs b/apps/server/src/draft_routes.rs index 9342a0a9ad..478740bf72 100644 --- a/apps/server/src/draft_routes.rs +++ b/apps/server/src/draft_routes.rs @@ -84,6 +84,8 @@ async fn remote_url(state: &AppState, c: &Ctx) -> Option { if token.trim().is_empty() { token = state.github.instance_token().unwrap_or_default(); } + // GitHub App : token d'installation si le projet est lié (URL jamais persistée). + let token = crate::github_auth::project_token(state, &c.project.uuid, token).await; Some(draft::token_url(repo, Some(&token))) } diff --git a/apps/server/src/github_app_routes.rs b/apps/server/src/github_app_routes.rs index fafd702946..30c99c52c2 100644 --- a/apps/server/src/github_app_routes.rs +++ b/apps/server/src/github_app_routes.rs @@ -19,7 +19,7 @@ use axum::{ use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL; use base64::Engine; use rand::RngCore; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use serde_json::{json, Value}; use sqlx::PgPool; @@ -405,32 +405,10 @@ async fn manifest_callback( // ───────────────────── Installation ───────────────────── -#[derive(Serialize)] -struct JwtClaims { - iat: i64, - exp: i64, - iss: String, -} - -/// JWT d'app RS256 (iat = now−60, exp = now+540). Sera déplacé dans `crates/github` en P2. -pub fn app_jwt(app_id: i64, pem: &[u8]) -> Result, String> { - let key = jsonwebtoken::EncodingKey::from_rsa_pem(pem).map_err(|_| "clé privée invalide")?; - let now = chrono::Utc::now().timestamp(); - let claims = JwtClaims { - iat: now - 60, - exp: now + 540, - iss: app_id.to_string(), - }; - jsonwebtoken::encode( - &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256), - &claims, - &key, - ) - .map(Redacted) - .map_err(|_| "signature JWT impossible".into()) -} - -async fn load_app_jwt(pool: &PgPool) -> Result, String> { +/// Identifiants de l'app (clé privée descellée, jamais journalisée). +pub async fn load_credentials( + pool: &PgPool, +) -> Result { let row: Option<(i64, String)> = sqlx::query_as("SELECT app_id, private_key_enc FROM github_app WHERE id = 1") .fetch_optional(pool) @@ -438,7 +416,16 @@ async fn load_app_jwt(pool: &PgPool) -> Result, String> { .map_err(|e| e.to_string())?; let (app_id, enc) = row.ok_or("aucune app GitHub configurée")?; let pem = crate::secret_box::open(&enc).map_err(|e| e.to_string())?; - app_jwt(app_id, &pem) + let pem = String::from_utf8(pem.to_vec()).map_err(|_| "clé privée illisible")?; + Ok(devforge_github::app_auth::AppCredentials::new(app_id, pem)) +} + +async fn load_app_jwt(pool: &PgPool) -> Result, String> { + let creds = load_credentials(pool).await?; + creds + .app_jwt() + .map(|j| Redacted(j.expose().to_string())) + .map_err(|e| e.to_string()) } #[derive(Deserialize)] diff --git a/apps/server/src/github_app_routes_tests.rs b/apps/server/src/github_app_routes_tests.rs index c81b74ca4d..b4a1c6d8f1 100644 --- a/apps/server/src/github_app_routes_tests.rs +++ b/apps/server/src/github_app_routes_tests.rs @@ -299,46 +299,3 @@ async fn installation_is_verified_then_stored_with_repos() { assert_eq!(i["missing_permissions"].as_array().unwrap().len(), 4); assert!(!st.to_string().contains("ghs_inst")); } - -#[test] -fn app_jwt_rs256_claims() { - let dir = std::env::temp_dir().join(format!("df-jwt-{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&dir).unwrap(); - let key = dir.join("k.pem"); - let pubk = dir.join("k.pub"); - let ok = std::process::Command::new("openssl") - .args(["genrsa", "-traditional", "-out"]) - .arg(&key) - .arg("2048") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) - && std::process::Command::new("openssl") - .args(["rsa", "-pubout", "-in"]) - .arg(&key) - .arg("-out") - .arg(&pubk) - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - if !ok { - eprintln!("openssl indisponible — test JWT ignoré"); - return; - } - let pem = std::fs::read(&key).unwrap(); - let jwt = app_jwt(4242, &pem).unwrap(); - assert_eq!(format!("{jwt:?}"), "***"); - let mut v = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256); - v.set_issuer(&["4242"]); - v.set_required_spec_claims(&["exp", "iat", "iss"]); - let data = jsonwebtoken::decode::( - jwt.expose(), - &jsonwebtoken::DecodingKey::from_rsa_pem(&std::fs::read(&pubk).unwrap()).unwrap(), - &v, - ) - .unwrap(); - let c = data.claims; - assert!(c["exp"].as_i64().unwrap() - c["iat"].as_i64().unwrap() <= 600); - let _ = std::fs::remove_dir_all(&dir); - assert!(app_jwt(1, b"not a key").is_err()); -} diff --git a/apps/server/src/github_auth.rs b/apps/server/src/github_auth.rs new file mode 100644 index 0000000000..c9de80f472 --- /dev/null +++ b/apps/server/src/github_auth.rs @@ -0,0 +1,247 @@ +//! Résolution de l'authentification GitHub par projet (P2-03) : token d'installation +//! GitHub App si le projet est lié à une installation et que le mode App est actif, +//! sinon PAT (comportement historique, strictement inchangé). +//! +//! Feature flag : `DEVFORGE_GITHUB_APP=1` **ou** présence d'une ligne `github_app`. + +use std::fmt; + +use devforge_github::app_auth::{TokenError, TokenScope}; +use serde_json::json; +use sqlx::PgPool; + +use crate::state::AppState; + +pub const FLAG_ENV: &str = "DEVFORGE_GITHUB_APP"; + +pub fn flag_from_env(v: Option<&str>) -> bool { + matches!( + v.map(|s| s.trim().to_ascii_lowercase()).as_deref(), + Some("1" | "true" | "yes" | "on") + ) +} + +/// Mode GitHub App actif ? +pub async fn app_enabled(pool: &PgPool) -> bool { + if flag_from_env(std::env::var(FLAG_ENV).ok().as_deref()) { + return true; + } + sqlx::query_as::<_, (i64,)>("SELECT count(*) FROM github_app") + .fetch_one(pool) + .await + .map(|(n,)| n > 0) + .unwrap_or(false) +} + +#[derive(Clone, PartialEq, Eq)] +pub enum GithubAuth { + Pat(String), + Installation { id: i64, repo_id: Option }, +} + +impl fmt::Debug for GithubAuth { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + GithubAuth::Pat(_) => f.write_str("Pat(***)"), + GithubAuth::Installation { id, repo_id } => { + write!(f, "Installation {{ id: {id}, repo_id: {repo_id:?} }}") + } + } + } +} + +/// Choix pur : installation si le projet en a une et que le mode App est actif. +pub fn choose( + enabled: bool, + installation_id: Option, + repo_id: Option, + pat: String, +) -> GithubAuth { + match (enabled, installation_id) { + (true, Some(id)) => GithubAuth::Installation { id, repo_id }, + _ => GithubAuth::Pat(pat), + } +} + +async fn project_link(pool: &PgPool, project_uuid: &str) -> (Option, Option) { + sqlx::query_as::<_, (Option, Option)>( + "SELECT github_installation_id, github_repo_id FROM projects WHERE uuid = $1", + ) + .bind(project_uuid) + .fetch_optional(pool) + .await + .ok() + .flatten() + .unwrap_or((None, None)) +} + +pub async fn for_project(pool: &PgPool, project_uuid: &str, pat: String) -> GithubAuth { + let (inst, repo) = project_link(pool, project_uuid).await; + if inst.is_none() { + return GithubAuth::Pat(pat); + } + choose(app_enabled(pool).await, inst, repo, pat) +} + +async fn mark_suspended(pool: &PgPool, id: i64) { + let _ = sqlx::query( + "UPDATE github_installations SET suspended_at = COALESCE(suspended_at, now()), updated_at = now() WHERE installation_id = $1", + ) + .bind(id) + .execute(pool) + .await; +} + +fn note_error(id: i64, e: &TokenError) { + tracing::warn!( + installation_id = id, + "token d'installation indisponible : {e}" + ); +} + +/// Token bearer (cache) ; en cas d'échec de l'installation, repli sur le PAT fourni. +pub async fn token(state: &AppState, auth: &GithubAuth, pat_fallback: &str) -> String { + match auth { + GithubAuth::Pat(t) => t.clone(), + GithubAuth::Installation { id, .. } => { + let creds = match crate::github_app_routes::load_credentials(&state.pool).await { + Ok(c) => c, + Err(e) => { + tracing::warn!("GitHub App indisponible : {e}"); + return pat_fallback.to_string(); + } + }; + match state.gh_tokens.get(&creds, *id).await { + Ok(t) => t.expose().to_string(), + Err(e) => { + if e == TokenError::Suspended { + mark_suspended(&state.pool, *id).await; + } + note_error(*id, &e); + pat_fallback.to_string() + } + } + } + } +} + +/// Token pour un projet : installation si liée, sinon `pat` (inchangé). +pub async fn project_token(state: &AppState, project_uuid: &str, pat: String) -> String { + let auth = for_project(&state.pool, project_uuid, pat.clone()).await; + token(state, &auth, &pat).await +} + +/// Token transmis à un clone de déploiement. +pub struct DeployToken { + pub token: Option, + /// `true` = token d'installation éphémère : à passer en en-tête, jamais dans `.git/config`. + pub ephemeral: bool, +} + +/// Déploiement : token d'installation restreint au dépôt (`contents:read`, TTL GitHub ≤ 1 h), +/// sinon PAT d'instance (historique). +pub async fn deploy_token( + state: &AppState, + project_uuid: &str, + instance_pat: Option, +) -> DeployToken { + let legacy = DeployToken { + token: instance_pat.clone(), + ephemeral: false, + }; + let GithubAuth::Installation { id, repo_id } = + for_project(&state.pool, project_uuid, String::new()).await + else { + return legacy; + }; + let creds = match crate::github_app_routes::load_credentials(&state.pool).await { + Ok(c) => c, + Err(e) => { + tracing::warn!("GitHub App indisponible pour le déploiement : {e}"); + return legacy; + } + }; + let scope = TokenScope { + repository_ids: repo_id.into_iter().collect(), + permissions: Some(json!({ "contents": "read", "metadata": "read" })), + }; + match state.gh_tokens.scoped(&creds, id, &scope).await { + Ok(t) => DeployToken { + token: Some(t.token.expose().to_string()), + ephemeral: true, + }, + Err(e) => { + if e == TokenError::Suspended { + mark_suspended(&state.pool, id).await; + } + note_error(id, &e); + legacy + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flag_parsing() { + assert!(flag_from_env(Some("1"))); + assert!(flag_from_env(Some(" TRUE "))); + assert!(!flag_from_env(Some("0"))); + assert!(!flag_from_env(Some(""))); + assert!(!flag_from_env(None)); + } + + #[test] + fn resolver_installation_vs_pat() { + assert_eq!( + choose(true, Some(5), Some(9), "ghp_x".into()), + GithubAuth::Installation { + id: 5, + repo_id: Some(9) + } + ); + assert_eq!( + choose(true, None, None, "ghp_x".into()), + GithubAuth::Pat("ghp_x".into()) + ); + // Flag absent : PAT même si le projet est lié (comportement inchangé). + assert_eq!( + choose(false, Some(5), None, "ghp_x".into()), + GithubAuth::Pat("ghp_x".into()) + ); + assert_eq!( + format!("{:?}", GithubAuth::Pat("ghp_secret".into())), + "Pat(***)" + ); + } + + #[tokio::test] + async fn for_project_reads_link_and_flag() { + let pool = devforge_database::ephemeral_pg().await; + crate::db::migrate(&pool).await.unwrap(); + sqlx::query("INSERT INTO projects (uuid, name, slug, created_at, updated_at, github_installation_id, github_repo_id) VALUES ('p-app','A','a','x','x', 77, 9), ('p-pat','B','b','x','x', NULL, NULL)") + .execute(&pool).await.unwrap(); + assert_eq!( + for_project(&pool, "p-pat", "ghp".into()).await, + GithubAuth::Pat("ghp".into()) + ); + if !flag_from_env(std::env::var(FLAG_ENV).ok().as_deref()) { + // Sans app enregistrée ni flag : PAT. + assert_eq!( + for_project(&pool, "p-app", "ghp".into()).await, + GithubAuth::Pat("ghp".into()) + ); + } + sqlx::query("INSERT INTO github_app (id, app_id, slug, client_id, client_secret_enc, webhook_secret_enc, private_key_enc, html_url, owner_login) VALUES (1, 1, 's', 'c', 'v1:a:b', 'v1:a:b', 'v1:a:b', 'h', 'o')") + .execute(&pool).await.unwrap(); + assert_eq!( + for_project(&pool, "p-app", "ghp".into()).await, + GithubAuth::Installation { + id: 77, + repo_id: Some(9) + } + ); + } +} diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs index 71dfc8e3d0..35e14556c1 100644 --- a/apps/server/src/main.rs +++ b/apps/server/src/main.rs @@ -28,6 +28,7 @@ mod draft_origin; mod draft_routes; mod git_routes; mod github_app_routes; +mod github_auth; mod group_routes; mod infra_routes; mod infra_sqlite; diff --git a/apps/server/src/rich_chat.rs b/apps/server/src/rich_chat.rs index 3982e15f5e..ac674b6c57 100644 --- a/apps/server/src/rich_chat.rs +++ b/apps/server/src/rich_chat.rs @@ -756,6 +756,7 @@ impl ServerRoutineRunner { let owner = ctx.user_uuid.clone().unwrap_or_default(); let (llm, mode) = state.llm_for_user(&owner).await; let gh = crate::user_prefs::github_token(&state.pool, &owner).await; + let gh = crate::github_auth::project_token(state, &cron.project_uuid, gh).await; let result = devforge_github::with_token( &gh, state diff --git a/apps/server/src/routes.rs b/apps/server/src/routes.rs index 9b2bf28c00..5cfb14a26a 100644 --- a/apps/server/src/routes.rs +++ b/apps/server/src/routes.rs @@ -1472,6 +1472,8 @@ pub(crate) async fn run_real_deploy( .flatten() .map(|(t,)| crate::secret_box::open_compat(&t)) .filter(|t| !t.trim().is_empty()); + // GitHub App : token d'installation restreint au dépôt si le projet est lié. + let deploy_tok = crate::github_auth::deploy_token(state, &project.uuid, token).await; match crate::project_oidc::sync_project_oidc_client(&state.pool, project, true).await { Ok(true) => { @@ -1538,7 +1540,8 @@ pub(crate) async fn run_real_deploy( docker_build_context: project.docker_build_context.clone(), publish_directory: project.publish_directory.clone(), is_static: project.is_static != 0, - github_token: token, + github_token: deploy_tok.token, + github_token_ephemeral: deploy_tok.ephemeral, env_file, proxy_labels: proxy_labels_for_project(state, project).await, gpu_nvidia: project.gpu_nvidia != 0, @@ -2837,6 +2840,8 @@ async fn execute_claimed_run_inner( Some(uuid) if spec_turn => match state.llm_for_spec_worker(&uuid).await { Some((llm, mode)) => { let token = crate::user_prefs::github_token(&state.pool, &uuid).await; + let token = + crate::github_auth::project_token(&state, &run.project_uuid, token).await; devforge_github::with_token( &token, state.agent.handle_with_provider( @@ -2871,6 +2876,7 @@ async fn execute_claimed_run_inner( Some(uuid) => { let (llm, mode) = state.llm_for_agents(&uuid).await; let token = crate::user_prefs::github_token(&state.pool, &uuid).await; + let token = crate::github_auth::project_token(&state, &run.project_uuid, token).await; devforge_github::with_token( &token, state.agent.handle_with_provider( diff --git a/apps/server/src/state.rs b/apps/server/src/state.rs index e02a3d65c2..6458cb2618 100644 --- a/apps/server/src/state.rs +++ b/apps/server/src/state.rs @@ -51,6 +51,8 @@ pub struct AppState { pub deploy_queue: Arc, /// Active backends: executor / github / storage / llm. pub backends: Arc, + /// Tokens d'installation GitHub App en cache (P2-02). + pub gh_tokens: Arc, } #[derive(Debug)] @@ -779,6 +781,7 @@ impl AppState { cron_scheduler, deploy_queue, backends, + gh_tokens: Arc::new(devforge_github::app_auth::InstallationTokenCache::default()), }; project_store.bind_app(state.clone()); diff --git a/crates/deploy/Cargo.toml b/crates/deploy/Cargo.toml index 8192e75713..4d5e20605e 100644 --- a/crates/deploy/Cargo.toml +++ b/crates/deploy/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true [dependencies] devforge-shared = { workspace = true } +base64 = "0.22" serde = { workspace = true } serde_json = { workspace = true } async-trait = { workspace = true } diff --git a/crates/deploy/src/lib.rs b/crates/deploy/src/lib.rs index f033e6068d..33c266dd28 100644 --- a/crates/deploy/src/lib.rs +++ b/crates/deploy/src/lib.rs @@ -136,6 +136,9 @@ pub struct DeployRequest { pub publish_directory: Option, pub is_static: bool, pub github_token: Option, + /// Token d'installation GitHub App éphémère : passé en en-tête HTTP à git, + /// jamais écrit dans l'URL du remote ni dans `.git/config`. + pub github_token_ephemeral: bool, pub env_file: Option, /// Labels Traefik (tous les hosts) appliqués au `docker run`. pub proxy_labels: Option, @@ -349,6 +352,28 @@ fn git_sync_command(workdir: &str, clone_url: &str, branch: &str) -> String { } } +/// Variante « token éphémère » (GitHub App) : l'authentification passe par +/// `http..extraheader` via les variables `GIT_CONFIG_*` (git ≥ 2.31), +/// le remote est (ré)écrit sans token : rien n'est persisté dans `.git/config`. +fn git_sync_command_header_auth( + workdir: &str, + clean_url: &str, + branch: &str, + token: &str, +) -> String { + use base64::Engine; + let basic = base64::engine::general_purpose::STANDARD.encode(format!("x-access-token:{token}")); + let w = shell_single_quote(workdir); + let url = shell_single_quote(clean_url); + let header = shell_single_quote(&format!("AUTHORIZATION: basic {basic}")); + let base = git_sync_command(workdir, clean_url, branch); + format!( + "export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.https://github.com/.extraheader GIT_CONFIG_VALUE_0={header} GIT_TERMINAL_PROMPT=0; \ + if [ -d {w}/.git ]; then git -C {w} remote set-url origin {url}; fi; \ + {base}" + ) +} + fn shell_single_quote(s: &str) -> String { format!("'{}'", s.replace('\'', "'\\''")) } @@ -678,13 +703,25 @@ impl DeployFacade { } // Sync git - let clone_url = git_clone_url(&req.git_repository, req.github_token.as_deref()); let branch = if req.git_branch.trim().is_empty() { "main" } else { req.git_branch.trim() }; - let sync_cmd = git_sync_command(&workdir, &clone_url, branch); + let sync_cmd = match req + .github_token + .as_deref() + .filter(|t| req.github_token_ephemeral && !t.is_empty() && !cfg!(windows)) + { + Some(tok) => { + let clean = git_clone_url(&req.git_repository, None); + git_sync_command_header_auth(&workdir, &clean, branch, tok) + } + None => { + let clone_url = git_clone_url(&req.git_repository, req.github_token.as_deref()); + git_sync_command(&workdir, &clone_url, branch) + } + }; match self.executor.exec(server, "", &sync_cmd, 600).await { Ok(r) => { logs.push_str(&format!( @@ -2030,6 +2067,20 @@ mod tests { assert_eq!(result.len(), 1000); } + #[cfg(not(windows))] + #[test] + fn header_auth_never_puts_token_in_url_or_config() { + let clean = git_clone_url("bobdivx/devforge", None); + assert_eq!(clean, "https://github.com/bobdivx/devforge.git"); + let cmd = git_sync_command_header_auth("/data/w", &clean, "main", "ghs_ephemeral123"); + assert!(!cmd.contains("ghs_ephemeral123")); + assert!(!cmd.contains("x-access-token:")); + assert!(cmd.contains("GIT_CONFIG_KEY_0=http.https://github.com/.extraheader")); + assert!(cmd.contains("remote set-url origin 'https://github.com/bobdivx/devforge.git'")); + // Le PAT historique garde l'URL avec token (comportement inchangé). + assert!(git_clone_url("bobdivx/devforge", Some("ghp_x")).contains("x-access-token:ghp_x@")); + } + #[test] fn trim_out_preserves_head_and_tail() { let long = format!("{}MIDDLE{}", "A".repeat(2000), "Z".repeat(6000)); diff --git a/crates/github/Cargo.toml b/crates/github/Cargo.toml index c75d24eb0d..0e0f3dce7a 100644 --- a/crates/github/Cargo.toml +++ b/crates/github/Cargo.toml @@ -10,3 +10,9 @@ serde_json = { workspace = true } async-trait = { workspace = true } reqwest = { workspace = true } tokio = { workspace = true } +jsonwebtoken = "9" +chrono = { workspace = true } + +[dev-dependencies] +wiremock = "0.6" +tokio = { workspace = true } diff --git a/crates/github/src/app_auth.rs b/crates/github/src/app_auth.rs new file mode 100644 index 0000000000..8017ee3a44 --- /dev/null +++ b/crates/github/src/app_auth.rs @@ -0,0 +1,365 @@ +//! GitHub App : JWT RS256 et tokens d'installation mis en cache (P2-01 / P2-02). +//! +//! Aucun token ni clé n'est journalisé : `SecretString` affiche `***` en Debug/Display. + +use std::collections::HashMap; +use std::fmt; +use std::sync::Arc; + +use serde::Serialize; +use serde_json::{json, Value}; +use tokio::sync::Mutex; + +/// Marge de renouvellement : un token qui expire dans moins de 5 min est renouvelé. +pub const RENEW_MARGIN_SECS: i64 = 300; + +#[derive(Clone, PartialEq, Eq, Default)] +pub struct SecretString(String); + +impl SecretString { + pub fn new(s: impl Into) -> Self { + Self(s.into()) + } + pub fn expose(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for SecretString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("***") + } +} +impl fmt::Display for SecretString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("***") + } +} + +#[derive(Clone)] +pub struct AppCredentials { + pub app_id: i64, + pub private_key: SecretString, +} + +impl fmt::Debug for AppCredentials { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "AppCredentials {{ app_id: {}, private_key: *** }}", + self.app_id + ) + } +} + +#[derive(Serialize)] +struct Claims { + iat: i64, + exp: i64, + iss: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TokenError { + /// 401 : JWT refusé (clé/app invalide ou horloge). + Unauthorized, + /// 403 avec installation suspendue. + Suspended, + /// 404 : installation inconnue (supprimée). + NotFound, + Other(String), +} + +impl fmt::Display for TokenError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + TokenError::Unauthorized => f.write_str("GitHub App : authentification refusée (401)"), + TokenError::Suspended => f.write_str("GitHub App : installation suspendue"), + TokenError::NotFound => f.write_str("GitHub App : installation introuvable"), + TokenError::Other(m) => write!(f, "GitHub App : {m}"), + } + } +} + +impl std::error::Error for TokenError {} + +impl AppCredentials { + pub fn new(app_id: i64, private_key_pem: impl Into) -> Self { + Self { + app_id, + private_key: SecretString::new(private_key_pem), + } + } + + /// JWT d'app : RS256, `iat = now − 60`, `exp = now + 540`, `iss = app_id`. + pub fn app_jwt(&self) -> Result { + self.app_jwt_at(chrono::Utc::now().timestamp()) + } + + pub fn app_jwt_at(&self, now: i64) -> Result { + let key = jsonwebtoken::EncodingKey::from_rsa_pem(self.private_key.expose().as_bytes()) + .map_err(|_| TokenError::Other("clé privée invalide".into()))?; + let claims = Claims { + iat: now - 60, + exp: now + 540, + iss: self.app_id.to_string(), + }; + jsonwebtoken::encode( + &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256), + &claims, + &key, + ) + .map(SecretString) + .map_err(|_| TokenError::Other("signature JWT impossible".into())) + } +} + +#[derive(Clone, Debug)] +pub struct InstallationToken { + pub token: SecretString, + /// Expiration (secondes Unix). + pub expires_at: i64, +} + +impl InstallationToken { + pub fn fresh_at(&self, now: i64) -> bool { + self.expires_at - now > RENEW_MARGIN_SECS + } +} + +/// Portée restreinte (tokens transmis aux runners) : dépôts et permissions. +#[derive(Clone, Debug, Default)] +pub struct TokenScope { + pub repository_ids: Vec, + pub permissions: Option, +} + +pub fn default_api_base() -> String { + std::env::var("DEVFORGE_GITHUB_API_URL") + .ok() + .map(|s| s.trim().trim_end_matches('/').to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "https://api.github.com".into()) +} + +/// `POST /app/installations/{id}/access_tokens`. +pub async fn fetch_installation_token( + http: &reqwest::Client, + api: &str, + jwt: &SecretString, + installation_id: i64, + scope: Option<&TokenScope>, +) -> Result { + let mut body = json!({}); + if let Some(s) = scope { + if !s.repository_ids.is_empty() { + body["repository_ids"] = json!(s.repository_ids); + } + if let Some(p) = &s.permissions { + body["permissions"] = p.clone(); + } + } + let res = http + .post(format!( + "{api}/app/installations/{installation_id}/access_tokens" + )) + .bearer_auth(jwt.expose()) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .header("User-Agent", "devforge-server/2") + .json(&body) + .send() + .await + .map_err(|e| TokenError::Other(format!("GitHub injoignable : {}", e.without_url())))?; + let status = res.status().as_u16(); + let text = res.text().await.unwrap_or_default(); + match status { + 200 | 201 => {} + 401 => return Err(TokenError::Unauthorized), + 403 if text.to_lowercase().contains("suspended") => return Err(TokenError::Suspended), + 404 => return Err(TokenError::NotFound), + s => return Err(TokenError::Other(format!("réponse {s}"))), + } + let v: Value = + serde_json::from_str(&text).map_err(|_| TokenError::Other("réponse inattendue".into()))?; + let token = v + .get("token") + .and_then(Value::as_str) + .filter(|t| !t.is_empty()) + .ok_or_else(|| TokenError::Other("token absent".into()))?; + let expires_at = v + .get("expires_at") + .and_then(Value::as_str) + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|d| d.timestamp()) + // Défaut GitHub : 1 h. + .unwrap_or_else(|| chrono::Utc::now().timestamp() + 3600); + Ok(InstallationToken { + token: SecretString::new(token), + expires_at, + }) +} + +type Slot = Arc>>; + +/// Cache des tokens d'installation, single-flight par installation. +pub struct InstallationTokenCache { + http: reqwest::Client, + api: String, + slots: Mutex>, +} + +impl fmt::Debug for InstallationTokenCache { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("InstallationTokenCache(***)") + } +} + +impl Default for InstallationTokenCache { + fn default() -> Self { + Self::new(default_api_base()) + } +} + +impl InstallationTokenCache { + pub fn new(api: impl Into) -> Self { + Self { + http: reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(20)) + .build() + .unwrap_or_default(), + api: api.into(), + slots: Mutex::new(HashMap::new()), + } + } + + pub fn api(&self) -> &str { + &self.api + } + + async fn slot(&self, id: i64) -> Slot { + self.slots.lock().await.entry(id).or_default().clone() + } + + /// Token valide encore > 5 min, sinon renouvelé. 401 ⇒ 1 seul nouvel essai (nouveau JWT). + pub async fn get( + &self, + creds: &AppCredentials, + installation_id: i64, + ) -> Result { + let slot = self.slot(installation_id).await; + let mut guard = slot.lock().await; + let now = chrono::Utc::now().timestamp(); + if let Some(t) = guard.as_ref().filter(|t| t.fresh_at(now)) { + return Ok(t.token.clone()); + } + let tok = match self.fetch(creds, installation_id, None).await { + Err(TokenError::Unauthorized) => self.fetch(creds, installation_id, None).await, + r => r, + }; + match tok { + Ok(t) => { + let out = t.token.clone(); + *guard = Some(t); + Ok(out) + } + Err(e) => { + *guard = None; + Err(e) + } + } + } + + /// Token à portée restreinte, jamais mis en cache (runners, TTL GitHub ≤ 1 h). + pub async fn scoped( + &self, + creds: &AppCredentials, + installation_id: i64, + scope: &TokenScope, + ) -> Result { + match self.fetch(creds, installation_id, Some(scope)).await { + Err(TokenError::Unauthorized) => self.fetch(creds, installation_id, Some(scope)).await, + r => r, + } + } + + async fn fetch( + &self, + creds: &AppCredentials, + id: i64, + scope: Option<&TokenScope>, + ) -> Result { + let jwt = creds.app_jwt()?; + fetch_installation_token(&self.http, &self.api, &jwt, id, scope).await + } + + /// À appeler sur un 401 d'un appel API utilisant le token. + pub async fn invalidate(&self, installation_id: i64) { + let slot = self.slot(installation_id).await; + *slot.lock().await = None; + } + + /// Pour les tests / l'observabilité : expiration en cache. + pub async fn cached_expiry(&self, installation_id: i64) -> Option { + let slot = self.slot(installation_id).await; + let g = slot.lock().await; + g.as_ref().map(|t| t.expires_at) + } + + #[doc(hidden)] + pub async fn seed(&self, installation_id: i64, token: InstallationToken) { + let slot = self.slot(installation_id).await; + *slot.lock().await = Some(token); + } +} + +/// Masque les jetons dans un texte de log (`x-access-token:@`, `ghs_…`, `ghp_…`, `github_pat_…`). +pub fn redact_tokens(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + const MARK: &str = "x-access-token:"; + while let Some(i) = rest.find(MARK) { + out.push_str(&rest[..i + MARK.len()]); + let after = &rest[i + MARK.len()..]; + let end = after + .find(|c: char| c == '@' || c.is_whitespace() || c == '\'' || c == '"') + .unwrap_or(after.len()); + out.push_str("***"); + rest = &after[end..]; + } + out.push_str(rest); + redact_prefixed(&out) +} + +fn redact_prefixed(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let bytes = s.as_bytes(); + let mut i = 0; + while i < s.len() { + let tail = &s[i..]; + let hit = ["github_pat_", "ghs_", "ghp_", "gho_", "ghu_", "ghr_"] + .iter() + .find(|p| tail.starts_with(**p)) + .filter(|_| i == 0 || !(bytes[i - 1] as char).is_ascii_alphanumeric()); + if let Some(p) = hit { + let body = &tail[p.len()..]; + let n = body + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) + .unwrap_or(body.len()); + if n >= 8 { + out.push_str(p); + out.push_str("***"); + i += p.len() + n; + continue; + } + } + let ch = tail.chars().next().unwrap(); + out.push(ch); + i += ch.len_utf8(); + } + out +} + +#[cfg(test)] +#[path = "app_auth_tests.rs"] +mod tests; diff --git a/crates/github/src/app_auth_tests.rs b/crates/github/src/app_auth_tests.rs new file mode 100644 index 0000000000..a0df1bfef9 --- /dev/null +++ b/crates/github/src/app_auth_tests.rs @@ -0,0 +1,226 @@ +use super::*; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +/// Clé RSA de test générée à la volée (jamais committée). `None` si openssl absent. +fn test_keys() -> Option<(String, String)> { + let dir = std::env::temp_dir().join(format!("df-gh-{}-{}", std::process::id(), rand_suffix())); + std::fs::create_dir_all(&dir).ok()?; + let k = dir.join("k.pem"); + let p = dir.join("k.pub"); + let ok = std::process::Command::new("openssl") + .args(["genrsa", "-traditional", "-out"]) + .arg(&k) + .arg("2048") + .output() + .ok()? + .status + .success() + && std::process::Command::new("openssl") + .args(["rsa", "-pubout", "-in"]) + .arg(&k) + .arg("-out") + .arg(&p) + .output() + .ok()? + .status + .success(); + if !ok { + return None; + } + let r = ( + std::fs::read_to_string(&k).ok()?, + std::fs::read_to_string(&p).ok()?, + ); + let _ = std::fs::remove_dir_all(&dir); + Some(r) +} + +fn rand_suffix() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() +} + +fn token_body(tok: &str, in_secs: i64) -> Value { + let exp = chrono::Utc::now() + chrono::Duration::seconds(in_secs); + json!({"token": tok, "expires_at": exp.to_rfc3339()}) +} + +#[test] +fn jwt_claims_and_signature() { + let Some((pem, pubk)) = test_keys() else { + eprintln!("openssl absent"); + return; + }; + let creds = AppCredentials::new(4242, pem); + assert_eq!( + format!("{creds:?}"), + "AppCredentials { app_id: 4242, private_key: *** }" + ); + let jwt = creds.app_jwt().unwrap(); + assert_eq!(format!("{jwt:?}"), "***"); + let mut v = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256); + v.set_issuer(&["4242"]); + v.set_required_spec_claims(&["exp", "iat", "iss"]); + let c = jsonwebtoken::decode::( + jwt.expose(), + &jsonwebtoken::DecodingKey::from_rsa_pem(pubk.as_bytes()).unwrap(), + &v, + ) + .unwrap() + .claims; + let (iat, exp) = (c["iat"].as_i64().unwrap(), c["exp"].as_i64().unwrap()); + assert!(exp - iat <= 600); + let now = chrono::Utc::now().timestamp(); + assert!(iat <= now - 59 && exp <= now + 541); + assert!(AppCredentials::new(1, "nope").app_jwt().is_err()); +} + +#[tokio::test] +async fn concurrent_gets_single_post_then_cached() { + let Some((pem, _)) = test_keys() else { + return; + }; + let gh = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/app/installations/7/access_tokens")) + .respond_with( + ResponseTemplate::new(201) + .set_body_json(token_body("ghs_cachedtoken1234", 3600)) + .set_delay(std::time::Duration::from_millis(150)), + ) + .expect(1) + .mount(&gh) + .await; + let cache = Arc::new(InstallationTokenCache::new(gh.uri())); + let creds = AppCredentials::new(1, pem); + let (a, b) = tokio::join!(cache.get(&creds, 7), cache.get(&creds, 7)); + assert_eq!(a.unwrap().expose(), "ghs_cachedtoken1234"); + assert_eq!(b.unwrap().expose(), "ghs_cachedtoken1234"); + // Troisième appel : servi par le cache. + cache.get(&creds, 7).await.unwrap(); + gh.verify().await; +} + +#[tokio::test] +async fn token_near_expiry_is_renewed() { + let Some((pem, _)) = test_keys() else { + return; + }; + let gh = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/app/installations/8/access_tokens")) + .respond_with( + ResponseTemplate::new(201).set_body_json(token_body("ghs_renewedtoken123", 3600)), + ) + .expect(1) + .mount(&gh) + .await; + let cache = InstallationTokenCache::new(gh.uri()); + let now = chrono::Utc::now().timestamp(); + cache + .seed( + 8, + InstallationToken { + token: SecretString::new("ghs_oldtoken12345"), + expires_at: now + 240, + }, + ) + .await; + let t = cache.get(&AppCredentials::new(1, pem), 8).await.unwrap(); + assert_eq!(t.expose(), "ghs_renewedtoken123"); + assert!(cache.cached_expiry(8).await.unwrap() > now + 3000); + // 6 min restantes : pas renouvelé. + assert!(InstallationToken { + token: SecretString::default(), + expires_at: now + 360 + } + .fresh_at(now)); + assert!(!InstallationToken { + token: SecretString::default(), + expires_at: now + 240 + } + .fresh_at(now)); +} + +#[tokio::test] +async fn unauthorized_retries_once_then_fails() { + let Some((pem, _)) = test_keys() else { + return; + }; + let gh = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/app/installations/9/access_tokens")) + .respond_with(ResponseTemplate::new(401)) + .expect(2) + .mount(&gh) + .await; + let cache = InstallationTokenCache::new(gh.uri()); + let e = cache + .get(&AppCredentials::new(1, pem), 9) + .await + .unwrap_err(); + assert_eq!(e, TokenError::Unauthorized); + gh.verify().await; +} + +#[tokio::test] +async fn suspended_and_scoped_body() { + let Some((pem, _)) = test_keys() else { + return; + }; + let gh = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/app/installations/10/access_tokens")) + .respond_with( + ResponseTemplate::new(403) + .set_body_json(json!({"message": "This installation has been suspended"})), + ) + .mount(&gh) + .await; + Mock::given(method("POST")) + .and(path("/app/installations/11/access_tokens")) + .and(wiremock::matchers::body_json( + json!({"repository_ids": [55], "permissions": {"contents": "read"}}), + )) + .respond_with( + ResponseTemplate::new(201).set_body_json(token_body("ghs_scopedtoken1234", 3600)), + ) + .expect(1) + .mount(&gh) + .await; + let cache = InstallationTokenCache::new(gh.uri()); + let creds = AppCredentials::new(1, pem); + assert_eq!( + cache.get(&creds, 10).await.unwrap_err(), + TokenError::Suspended + ); + let scope = TokenScope { + repository_ids: vec![55], + permissions: Some(json!({"contents": "read"})), + }; + let t = cache.scoped(&creds, 11, &scope).await.unwrap(); + assert_eq!(t.token.expose(), "ghs_scopedtoken1234"); + // Jamais mis en cache. + assert!(cache.cached_expiry(11).await.is_none()); +} + +#[test] +fn redacts_tokens_in_logs() { + assert_eq!( + redact_tokens("fatal: https://x-access-token:ghs_xxx@github.com/o/r.git"), + "fatal: https://x-access-token:***@github.com/o/r.git" + ); + assert_eq!( + redact_tokens("token ghs_abcdefgh12345 end"), + "token ghs_*** end" + ); + assert_eq!( + redact_tokens("pat github_pat_11ABCDEFG_xyz"), + "pat github_pat_***" + ); + assert_eq!(redact_tokens("ghp_short"), "ghp_short"); + assert_eq!(redact_tokens("rien à masquer — é"), "rien à masquer — é"); +} diff --git a/crates/github/src/lib.rs b/crates/github/src/lib.rs index 9ef8cf585c..f1cbac9f59 100644 --- a/crates/github/src/lib.rs +++ b/crates/github/src/lib.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::sync::{Arc, RwLock}; +pub mod app_auth; pub mod http; pub use http::HttpGitHubClient;