Skip to main content

solid_pod_rs_git/
auth.rs

1//! Authentication bridge — converts a `Basic nostr:<token>` request
2//! header into a NIP-98 verification call, mirroring the JSS behaviour
3//! where a `Basic` auth line whose username is `nostr` and whose
4//! password is a base64-encoded NIP-98 event is accepted by the git
5//! handler (PARITY row 69).
6//!
7//! The JSS server layers this bridge on top of the normal NIP-98
8//! `Authorization: Nostr <b64>` scheme so that off-the-shelf
9//! HTTP-Basic Git clients (e.g. the stock `git` CLI with a credential
10//! helper) can still push/pull against a Nostr-authenticated pod.
11
12use std::sync::Arc;
13
14use async_trait::async_trait;
15use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
16use thiserror::Error;
17
18use crate::service::GitRequest;
19
20/// Auth failures exposed to the HTTP surface.
21#[derive(Debug, Error)]
22pub enum AuthError {
23    /// No `Authorization` header on a write request.
24    #[error("missing Authorization header")]
25    Missing,
26
27    /// The `Authorization` header was present but malformed.
28    #[error("malformed Authorization header: {0}")]
29    Malformed(String),
30
31    /// The credential decoded cleanly but the NIP-98 verifier
32    /// rejected it (bad sig, URL mismatch, stale, …). The inner
33    /// string is the verifier's error text.
34    #[error("NIP-98 verification failed: {0}")]
35    Verification(String),
36}
37
38/// Pluggable authoriser invoked by the service on write operations.
39///
40/// The default implementation ([`BasicNostrExtractor`]) fits the JSS
41/// behaviour. Consumers embedding the service in a server that has
42/// its own richer auth stack can supply their own implementation.
43#[async_trait]
44pub trait GitAuth: Send + Sync {
45    /// Inspect `req` and either return `Ok(webid_or_pubkey)` — the
46    /// identity string the CGI layer will expose in `REMOTE_USER` —
47    /// or `Err(AuthError)`.
48    async fn authorise(&self, req: &GitRequest) -> Result<String, AuthError>;
49}
50
51/// The canonical JSS-parity authoriser.
52///
53/// Parses `Authorization: Basic <b64(nostr:<token>)>` headers,
54/// base64-decodes, splits on the first `:`, verifies the username is
55/// literally `nostr`, then treats the remainder as a NIP-98 event
56/// token and delegates to `solid_pod_rs::auth::nip98::verify_at`.
57///
58/// The URL verified against is reconstructed from the request's
59/// scheme/host/path; see [`GitRequest::auth_url`].
60#[derive(Clone, Debug, Default)]
61pub struct BasicNostrExtractor {
62    /// Allow-list of pubkeys. Empty means accept any valid NIP-98
63    /// signature (the JSS default).
64    allowed_pubkeys: Option<Arc<Vec<String>>>,
65}
66
67impl BasicNostrExtractor {
68    /// Construct a default extractor (no pubkey allow-list).
69    #[must_use]
70    pub fn new() -> Self {
71        Self::default()
72    }
73
74    /// Restrict to the given hex pubkeys (lowercase).
75    #[must_use]
76    pub fn with_allowed(mut self, pubkeys: Vec<String>) -> Self {
77        self.allowed_pubkeys = Some(Arc::new(
78            pubkeys.into_iter().map(|p| p.to_lowercase()).collect(),
79        ));
80        self
81    }
82
83    /// Strip the `Basic ` prefix, base64-decode, split on the first
84    /// colon, and validate the username is `nostr`. Returns the raw
85    /// NIP-98 token (the password half).
86    pub fn extract_nostr_token(header_value: &str) -> Result<String, AuthError> {
87        let b64 = header_value
88            .strip_prefix("Basic ")
89            .ok_or_else(|| AuthError::Malformed("not a Basic scheme".into()))?
90            .trim();
91
92        let decoded = BASE64
93            .decode(b64)
94            .map_err(|e| AuthError::Malformed(format!("base64 decode: {e}")))?;
95        let creds = String::from_utf8(decoded)
96            .map_err(|e| AuthError::Malformed(format!("utf-8 decode: {e}")))?;
97
98        let (user, pass) = creds
99            .split_once(':')
100            .ok_or_else(|| AuthError::Malformed("no colon in credentials".into()))?;
101        if user != "nostr" {
102            return Err(AuthError::Malformed(format!(
103                "expected username 'nostr', got '{user}'"
104            )));
105        }
106        if pass.is_empty() {
107            return Err(AuthError::Malformed("empty token".into()));
108        }
109        Ok(pass.to_string())
110    }
111}
112
113#[async_trait]
114impl GitAuth for BasicNostrExtractor {
115    async fn authorise(&self, req: &GitRequest) -> Result<String, AuthError> {
116        // Pull the Authorization header (case-insensitive match).
117        let auth_header = req
118            .headers
119            .iter()
120            .find(|(k, _)| k.eq_ignore_ascii_case("authorization"))
121            .map(|(_, v)| v.as_str())
122            .ok_or(AuthError::Missing)?;
123
124        let token = if let Some(stripped) = auth_header.strip_prefix("Basic ") {
125            // Re-use the Basic-scheme extractor.
126            Self::extract_nostr_token(&format!("Basic {stripped}"))?
127        } else if let Some(stripped) = auth_header.strip_prefix("Nostr ") {
128            // Also accept a raw Nostr scheme — JSS git.js hands the
129            // request through to the normal NIP-98 middleware which
130            // handles this.
131            stripped.trim().to_string()
132        } else {
133            return Err(AuthError::Malformed("unknown Authorization scheme".into()));
134        };
135
136        // Wrap the raw token back into the `Nostr ` header shape that
137        // the core verifier expects.
138        let nostr_header = format!("Nostr {token}");
139        let now = std::time::SystemTime::now()
140            .duration_since(std::time::UNIX_EPOCH)
141            .map(|d| d.as_secs())
142            .unwrap_or(0);
143
144        let verified = solid_pod_rs::auth::nip98::verify_at_with_policy(
145            &nostr_header,
146            &req.auth_url(),
147            &req.method,
148            // body hashing: git push bodies are large & binary; the
149            // JSS bridge verifies structure + URL + method only for
150            // compatibility with stock git clients that cannot sign
151            // the body (they have no Nostr keypair during push).
152            None,
153            now,
154            // Git push signs method `*` + repo base URL across the
155            // multi-request smart protocol; accept that leniency here
156            // (JSS git.js parity). Schnorr is still fully verified.
157            solid_pod_rs::auth::nip98::MatchPolicy::GitLenient,
158        )
159        .map_err(|e| AuthError::Verification(format!("{e:?}")))?;
160
161        if let Some(allowed) = &self.allowed_pubkeys {
162            let pk = verified.pubkey.to_lowercase();
163            if !allowed.contains(&pk) {
164                return Err(AuthError::Verification(format!(
165                    "pubkey not in allow-list: {pk}"
166                )));
167            }
168        }
169
170        Ok(verified.pubkey)
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn extract_rejects_non_basic_scheme() {
180        let err = BasicNostrExtractor::extract_nostr_token("Bearer abc").unwrap_err();
181        assert!(matches!(err, AuthError::Malformed(_)));
182    }
183
184    #[test]
185    fn extract_rejects_bad_base64() {
186        let err = BasicNostrExtractor::extract_nostr_token("Basic !!!not-base64!!!").unwrap_err();
187        assert!(matches!(err, AuthError::Malformed(_)));
188    }
189
190    #[test]
191    fn extract_rejects_missing_colon() {
192        // base64("nostronlynocolon")
193        let b64 = BASE64.encode(b"nostronlynocolon");
194        let err = BasicNostrExtractor::extract_nostr_token(&format!("Basic {b64}")).unwrap_err();
195        assert!(matches!(err, AuthError::Malformed(_)));
196    }
197
198    #[test]
199    fn extract_rejects_wrong_user() {
200        let b64 = BASE64.encode(b"alice:sometoken");
201        let err = BasicNostrExtractor::extract_nostr_token(&format!("Basic {b64}")).unwrap_err();
202        assert!(matches!(err, AuthError::Malformed(_)));
203    }
204
205    #[test]
206    fn extract_rejects_empty_token() {
207        let b64 = BASE64.encode(b"nostr:");
208        let err = BasicNostrExtractor::extract_nostr_token(&format!("Basic {b64}")).unwrap_err();
209        assert!(matches!(err, AuthError::Malformed(_)));
210    }
211
212    #[test]
213    fn extract_accepts_valid_shape() {
214        let b64 = BASE64.encode(b"nostr:someopaquetoken");
215        let tok = BasicNostrExtractor::extract_nostr_token(&format!("Basic {b64}")).unwrap();
216        assert_eq!(tok, "someopaquetoken");
217    }
218}