feat: git push authentication via push tokens Hardens the cospan-node against unauthorized pushes. Previously git receive-pack was completely open; anyone could push to any DID/repo. Push tokens are short-lived JWTs (1 hour) issued by the appview at POST /xrpc/dev.cospan.repo.createPushToken. The token contains the authenticated user's DID as `sub` and `scope: "push"`. The appview signs with its ES256 DPoP key (verifiable via /.well-known/jwks.json). The node now requires HTTP Basic Auth on both info/refs (for receive-pack) and git-receive-pack itself: username = user's DID password = push token JWT Verification checks: JWT structure, expiration, scope == "push", sub == DID in URL path. In dev mode (COSPAN_DEV_AUTH=1), any DID is accepted without a token for test ergonomics. upload-pack (clone/fetch) remains public and unauthenticated.
Author: Aaron Steven White
Commit
116511a10623f3331223f819466f7a417e56c865Parent: bcfb5268a0
Structural diff unavailable
These commits were pushed via plain git push, so no pre-parsed
schemas are available. Install git-remote-cospan and re-push via panproto:// to
see scope-level changes, breaking change detection, and semantic diffs.
brew install panproto/tap/git-remote-cospan9 files changed +309 -0
@@ -239,6 +239,15 @@ pub fn compute_code_challenge(verifier: &str) -> String {
239239 // custom header fields like `jwk` (for DPoP) cleanly. ES256 signing with p256 240240 // is straightforward. 241241 242+/// Sign an arbitrary JWT with a DpopKey. Used by push token issuance. 243+pub fn encode_es256_jwt_public<H: Serialize, C: Serialize>( 244+ header: &H, 245+ claims: &C, 246+ key: &DpopKey, 247+) -> anyhow::Result<String> { 248+ encode_es256_jwt(header, claims, &key.signing_key) 249+} 250+ 242251 fn encode_es256_jwt<H: Serialize, C: Serialize>( 243252 header: &H, 244253 claims: &C,
@@ -14,6 +14,10 @@ pub struct AppConfig {
1414 } 1515 1616 impl AppConfig { 17+ pub fn public_url(&self) -> String { 18+ std::env::var("PUBLIC_URL").unwrap_or_else(|_| "https://cospan.dev".to_string()) 19+ } 20+ 1721 pub fn from_env() -> anyhow::Result<Self> { 1822 Ok(Self { 1923 database_url: std::env::var("DATABASE_URL")
@@ -25,6 +25,7 @@ mod pull_get;
2525 mod pull_list; 2626 mod reaction_list; 2727 mod ref_update_list; 28+mod push_token; 2829 mod repo_create; 2930 mod repo_fork; 3031 mod repo_get;
@@ -183,6 +184,10 @@ pub fn router(state: Arc<AppState>) -> Router {
183184 .route("/xrpc/dev.cospan.repo.fork", post(repo_fork::handler)) 184185 .route("/xrpc/dev.cospan.repo.import", post(repo_import::handler)) 185186 .route( 187+ "/xrpc/dev.cospan.repo.createPushToken", 188+ post(push_token::handler), 189+ ) 190+ .route( 186191 "/xrpc/dev.cospan.actor.profile.put", 187192 post(profile_put::handler), 188193 )
@@ -0,0 +1,95 @@
1+//! `POST /xrpc/dev.cospan.repo.createPushToken` 2+//! 3+//! Issues a short-lived JWT that authorizes `git push` to the cospan 4+//! node. The token is signed by the appview's DPoP key (verifiable via 5+//! the JWKS endpoint at `/.well-known/jwks.json`) and contains: 6+//! 7+//! - `sub`: the authenticated user's DID 8+//! - `scope`: `"push"` 9+//! - `iat` / `exp`: issued-at and expiration (default 1 hour) 10+//! 11+//! The user passes this token as their git password (with their DID as 12+//! username) when pushing via git smart HTTP. The node verifies the JWT 13+//! against the appview's JWKS before accepting the push. 14+ 15+use std::sync::Arc; 16+ 17+use axum::Json; 18+use axum::extract::State; 19+use axum::http::HeaderMap; 20+use serde::Serialize; 21+ 22+use crate::auth::oauth::extract_session_id; 23+use crate::error::AppError; 24+use crate::state::AppState; 25+ 26+#[derive(Serialize)] 27+struct PushTokenClaims { 28+ /// Subject: the user's DID. 29+ sub: String, 30+ /// Issuer: the appview's public URL. 31+ iss: String, 32+ /// Scope: always "push" for git push tokens. 33+ scope: String, 34+ /// Issued at (unix seconds). 35+ iat: i64, 36+ /// Expires at (unix seconds). 37+ exp: i64, 38+ /// Unique token ID. 39+ jti: String, 40+} 41+ 42+/// `POST /xrpc/dev.cospan.repo.createPushToken` 43+/// 44+/// Requires an authenticated session. Returns a push token the user 45+/// can use as their git password when pushing to the cospan node. 46+pub async fn handler( 47+ State(state): State<Arc<AppState>>, 48+ headers: HeaderMap, 49+) -> Result<Json<serde_json::Value>, AppError> { 50+ // 1. Require an authenticated session. 51+ let session_id = extract_session_id(&headers) 52+ .ok_or_else(|| AppError::Unauthorized("sign in required".to_string()))?; 53+ let session = state 54+ .session_store 55+ .get_session(&session_id) 56+ .await 57+ .map_err(|e| AppError::Upstream(format!("session lookup: {e}")))? 58+ .ok_or_else(|| AppError::Unauthorized("session not found".to_string()))?; 59+ 60+ // 2. Build the push token JWT. 61+ let now = chrono::Utc::now().timestamp(); 62+ let ttl = 3600; // 1 hour 63+ let claims = PushTokenClaims { 64+ sub: session.did.clone(), 65+ iss: state.config.public_url().to_string(), 66+ scope: "push".to_string(), 67+ iat: now, 68+ exp: now + ttl, 69+ jti: uuid::Uuid::new_v4().to_string(), 70+ }; 71+ 72+ // 3. Sign with the appview's DPoP key (same key served at JWKS). 73+ let header = PushTokenHeader { 74+ alg: "ES256".to_string(), 75+ typ: "jwt".to_string(), 76+ kid: state.dpop_key.kid.clone(), 77+ }; 78+ let token = crate::auth::dpop::encode_es256_jwt_public(&header, &claims, &state.dpop_key) 79+ .map_err(|e| AppError::Upstream(format!("sign push token: {e}")))?; 80+ 81+ Ok(Json(serde_json::json!({ 82+ "token": token, 83+ "did": session.did, 84+ "expiresIn": ttl, 85+ "usage": "Use as git password when pushing. Your DID is the username.", 86+ "example": format!("git push https://node.cospan.dev/{}/REPO main", session.did), 87+ }))) 88+} 89+ 90+#[derive(Serialize)] 91+struct PushTokenHeader { 92+ alg: String, 93+ typ: String, 94+ kid: String, 95+}
@@ -1,5 +1,6 @@
11 mod authz; 22 mod did_auth; 3+pub mod push_auth; 34 45 pub use authz::{AuthzService, SimpleAuthz}; 56 pub use did_auth::DidAuth;