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(
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        )
155        .map_err(|e| AuthError::Verification(format!("{e:?}")))?;
156
157        if let Some(allowed) = &self.allowed_pubkeys {
158            let pk = verified.pubkey.to_lowercase();
159            if !allowed.contains(&pk) {
160                return Err(AuthError::Verification(format!(
161                    "pubkey not in allow-list: {pk}"
162                )));
163            }
164        }
165
166        Ok(verified.pubkey)
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn extract_rejects_non_basic_scheme() {
176        let err = BasicNostrExtractor::extract_nostr_token("Bearer abc").unwrap_err();
177        assert!(matches!(err, AuthError::Malformed(_)));
178    }
179
180    #[test]
181    fn extract_rejects_bad_base64() {
182        let err = BasicNostrExtractor::extract_nostr_token("Basic !!!not-base64!!!").unwrap_err();
183        assert!(matches!(err, AuthError::Malformed(_)));
184    }
185
186    #[test]
187    fn extract_rejects_missing_colon() {
188        // base64("nostronlynocolon")
189        let b64 = BASE64.encode(b"nostronlynocolon");
190        let err = BasicNostrExtractor::extract_nostr_token(&format!("Basic {b64}")).unwrap_err();
191        assert!(matches!(err, AuthError::Malformed(_)));
192    }
193
194    #[test]
195    fn extract_rejects_wrong_user() {
196        let b64 = BASE64.encode(b"alice:sometoken");
197        let err = BasicNostrExtractor::extract_nostr_token(&format!("Basic {b64}")).unwrap_err();
198        assert!(matches!(err, AuthError::Malformed(_)));
199    }
200
201    #[test]
202    fn extract_rejects_empty_token() {
203        let b64 = BASE64.encode(b"nostr:");
204        let err = BasicNostrExtractor::extract_nostr_token(&format!("Basic {b64}")).unwrap_err();
205        assert!(matches!(err, AuthError::Malformed(_)));
206    }
207
208    #[test]
209    fn extract_accepts_valid_shape() {
210        let b64 = BASE64.encode(b"nostr:someopaquetoken");
211        let tok = BasicNostrExtractor::extract_nostr_token(&format!("Basic {b64}")).unwrap();
212        assert_eq!(tok, "someopaquetoken");
213    }
214}