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("unknown Authorization scheme".into()));
134 };
135
136 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 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 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}