1use 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#[derive(Debug, Error)]
22pub enum AuthError {
23 #[error("missing Authorization header")]
25 Missing,
26
27 #[error("malformed Authorization header: {0}")]
29 Malformed(String),
30
31 #[error("NIP-98 verification failed: {0}")]
35 Verification(String),
36}
37
38#[async_trait]
44pub trait GitAuth: Send + Sync {
45 async fn authorise(&self, req: &GitRequest) -> Result<String, AuthError>;
49}
50
51#[derive(Clone, Debug, Default)]
61pub struct BasicNostrExtractor {
62 allowed_pubkeys: Option<Arc<Vec<String>>>,
65}
66
67impl BasicNostrExtractor {
68 #[must_use]
70 pub fn new() -> Self {
71 Self::default()
72 }
73
74 #[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 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 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 Self::extract_nostr_token(&format!("Basic {stripped}"))?
127 } else if let Some(stripped) = auth_header.strip_prefix("Nostr ") {
128 stripped.trim().to_string()
132 } else {
133 return Err(AuthError::Malformed(
134 "unknown Authorization scheme".into(),
135 ));
136 };
137
138 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 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 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}