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(
134                "unknown Authorization scheme".into(),
135            ));
136        };
137
138        // Wrap the raw token back into the `Nostr ` header shape that
139        // the core verifier expects.
140        let nostr_header = format!("Nostr {token}");
141        let now = std::time::SystemTime::now()
142            .duration_since(std::time::UNIX_EPOCH)
143            .map(|d| d.as_secs())
144            .unwrap_or(0);
145
146        let verified = solid_pod_rs::auth::nip98::verify_at(
147            &nostr_header,
148            &req.auth_url(),
149            &req.method,
150            // body hashing: git push bodies are large & binary; the
151            // JSS bridge verifies structure + URL + method only for
152            // compatibility with stock git clients that cannot sign
153            // the body (they have no Nostr keypair during push).
154            None,
155            now,
156        )
157        .map_err(|e| AuthError::Verification(format!("{e:?}")))?;
158
159        if let Some(allowed) = &self.allowed_pubkeys {
160            let pk = verified.pubkey.to_lowercase();
161            if !allowed.contains(&pk) {
162                return Err(AuthError::Verification(format!(
163                    "pubkey not in allow-list: {pk}"
164                )));
165            }
166        }
167
168        Ok(verified.pubkey)
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn extract_rejects_non_basic_scheme() {
178        let err = BasicNostrExtractor::extract_nostr_token("Bearer abc").unwrap_err();
179        assert!(matches!(err, AuthError::Malformed(_)));
180    }
181
182    #[test]
183    fn extract_rejects_bad_base64() {
184        let err = BasicNostrExtractor::extract_nostr_token("Basic !!!not-base64!!!").unwrap_err();
185        assert!(matches!(err, AuthError::Malformed(_)));
186    }
187
188    #[test]
189    fn extract_rejects_missing_colon() {
190        // base64("nostronlynocolon")
191        let b64 = BASE64.encode(b"nostronlynocolon");
192        let err = BasicNostrExtractor::extract_nostr_token(&format!("Basic {b64}")).unwrap_err();
193        assert!(matches!(err, AuthError::Malformed(_)));
194    }
195
196    #[test]
197    fn extract_rejects_wrong_user() {
198        let b64 = BASE64.encode(b"alice:sometoken");
199        let err = BasicNostrExtractor::extract_nostr_token(&format!("Basic {b64}")).unwrap_err();
200        assert!(matches!(err, AuthError::Malformed(_)));
201    }
202
203    #[test]
204    fn extract_rejects_empty_token() {
205        let b64 = BASE64.encode(b"nostr:");
206        let err = BasicNostrExtractor::extract_nostr_token(&format!("Basic {b64}")).unwrap_err();
207        assert!(matches!(err, AuthError::Malformed(_)));
208    }
209
210    #[test]
211    fn extract_accepts_valid_shape() {
212        let b64 = BASE64.encode(b"nostr:someopaquetoken");
213        let tok = BasicNostrExtractor::extract_nostr_token(&format!("Basic {b64}")).unwrap();
214        assert_eq!(tok, "someopaquetoken");
215    }
216}