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 116511a10623f3331223f819466f7a417e56c865
Parent: 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-cospan
9 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;
cospan · schematic version control on atproto built on AT Protocol