use std::sync::Arc;
use async_trait::async_trait;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use thiserror::Error;
use crate::service::GitRequest;
#[derive(Debug, Error)]
pub enum AuthError {
#[error("missing Authorization header")]
Missing,
#[error("malformed Authorization header: {0}")]
Malformed(String),
#[error("NIP-98 verification failed: {0}")]
Verification(String),
}
#[async_trait]
pub trait GitAuth: Send + Sync {
async fn authorise(&self, req: &GitRequest) -> Result<String, AuthError>;
}
#[derive(Clone, Debug, Default)]
pub struct BasicNostrExtractor {
allowed_pubkeys: Option<Arc<Vec<String>>>,
}
impl BasicNostrExtractor {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_allowed(mut self, pubkeys: Vec<String>) -> Self {
self.allowed_pubkeys = Some(Arc::new(
pubkeys.into_iter().map(|p| p.to_lowercase()).collect(),
));
self
}
pub fn extract_nostr_token(header_value: &str) -> Result<String, AuthError> {
let b64 = header_value
.strip_prefix("Basic ")
.ok_or_else(|| AuthError::Malformed("not a Basic scheme".into()))?
.trim();
let decoded = BASE64
.decode(b64)
.map_err(|e| AuthError::Malformed(format!("base64 decode: {e}")))?;
let creds = String::from_utf8(decoded)
.map_err(|e| AuthError::Malformed(format!("utf-8 decode: {e}")))?;
let (user, pass) = creds
.split_once(':')
.ok_or_else(|| AuthError::Malformed("no colon in credentials".into()))?;
if user != "nostr" {
return Err(AuthError::Malformed(format!(
"expected username 'nostr', got '{user}'"
)));
}
if pass.is_empty() {
return Err(AuthError::Malformed("empty token".into()));
}
Ok(pass.to_string())
}
}
#[async_trait]
impl GitAuth for BasicNostrExtractor {
async fn authorise(&self, req: &GitRequest) -> Result<String, AuthError> {
let auth_header = req
.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("authorization"))
.map(|(_, v)| v.as_str())
.ok_or(AuthError::Missing)?;
let token = if let Some(stripped) = auth_header.strip_prefix("Basic ") {
Self::extract_nostr_token(&format!("Basic {stripped}"))?
} else if let Some(stripped) = auth_header.strip_prefix("Nostr ") {
stripped.trim().to_string()
} else {
return Err(AuthError::Malformed(
"unknown Authorization scheme".into(),
));
};
let nostr_header = format!("Nostr {token}");
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let verified = solid_pod_rs::auth::nip98::verify_at(
&nostr_header,
&req.auth_url(),
&req.method,
None,
now,
)
.map_err(|e| AuthError::Verification(format!("{e:?}")))?;
if let Some(allowed) = &self.allowed_pubkeys {
let pk = verified.pubkey.to_lowercase();
if !allowed.contains(&pk) {
return Err(AuthError::Verification(format!(
"pubkey not in allow-list: {pk}"
)));
}
}
Ok(verified.pubkey)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_rejects_non_basic_scheme() {
let err = BasicNostrExtractor::extract_nostr_token("Bearer abc").unwrap_err();
assert!(matches!(err, AuthError::Malformed(_)));
}
#[test]
fn extract_rejects_bad_base64() {
let err = BasicNostrExtractor::extract_nostr_token("Basic !!!not-base64!!!").unwrap_err();
assert!(matches!(err, AuthError::Malformed(_)));
}
#[test]
fn extract_rejects_missing_colon() {
let b64 = BASE64.encode(b"nostronlynocolon");
let err = BasicNostrExtractor::extract_nostr_token(&format!("Basic {b64}")).unwrap_err();
assert!(matches!(err, AuthError::Malformed(_)));
}
#[test]
fn extract_rejects_wrong_user() {
let b64 = BASE64.encode(b"alice:sometoken");
let err = BasicNostrExtractor::extract_nostr_token(&format!("Basic {b64}")).unwrap_err();
assert!(matches!(err, AuthError::Malformed(_)));
}
#[test]
fn extract_rejects_empty_token() {
let b64 = BASE64.encode(b"nostr:");
let err = BasicNostrExtractor::extract_nostr_token(&format!("Basic {b64}")).unwrap_err();
assert!(matches!(err, AuthError::Malformed(_)));
}
#[test]
fn extract_accepts_valid_shape() {
let b64 = BASE64.encode(b"nostr:someopaquetoken");
let tok = BasicNostrExtractor::extract_nostr_token(&format!("Basic {b64}")).unwrap();
assert_eq!(tok, "someopaquetoken");
}
}