nostr_web/
nip98.rs

1use base64::{engine::general_purpose::STANDARD, Engine};
2use nostr::{key::XOnlyPublicKey, nips::nip98::HttpData, Event, HttpMethod, Kind};
3use std::{fmt::Display, str::FromStr};
4use time::OffsetDateTime;
5use url::Url;
6
7#[cfg(feature = "actix")]
8use actix_web::{error, FromRequest, HttpRequest};
9#[cfg(feature = "actix")]
10use futures::future::{ready, Ready};
11
12#[cfg(feature = "axum")]
13use async_trait::async_trait;
14#[cfg(feature = "axum")]
15use axum::http::{header::AUTHORIZATION, request::Parts, StatusCode};
16#[cfg(feature = "axum")]
17use axum_core::extract::FromRequestParts;
18
19const SCHEME: &str = "Nostr";
20
21pub struct Nip98PubKey(pub XOnlyPublicKey);
22
23impl From<XOnlyPublicKey> for Nip98PubKey {
24    fn from(value: XOnlyPublicKey) -> Self {
25        Self(value)
26    }
27}
28
29#[derive(Debug)]
30pub enum Error {
31    InvalidAuthHeader,
32    InvalidScheme,
33    InvalidBase64,
34    InvalidEvent,
35    InvalidTimestamp,
36    TimestampOutOfRange,
37    UrlMismatch,
38    MethodMismatch,
39}
40
41impl Display for Error {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        match self {
44            Self::InvalidAuthHeader => write!(f, "invalid authorization header"),
45            Self::InvalidScheme => write!(f, "invalid scheme"),
46            Self::InvalidBase64 => write!(f, "invalid base64 string"),
47            Self::InvalidEvent => write!(f, "invalid nostr event"),
48            Self::InvalidTimestamp => write!(f, "invalid timestamp"),
49            Self::TimestampOutOfRange => write!(f, "timestamp out of range"),
50            Self::UrlMismatch => write!(f, "url in event does not match"),
51            Self::MethodMismatch => write!(f, "method in event does not match"),
52        }
53    }
54}
55
56#[cfg(feature = "axum")]
57impl From<Error> for StatusCode {
58    fn from(_value: Error) -> Self {
59        StatusCode::UNAUTHORIZED
60    }
61}
62
63#[cfg(feature = "actix")]
64impl From<Error> for actix_web::Error {
65    fn from(value: Error) -> Self {
66        error::ErrorUnauthorized(value.to_string())
67    }
68}
69
70pub fn validate_nip98(auth: &str, url: Url, method: &str) -> Result<Event, Error> {
71    if !auth.starts_with(SCHEME) {
72        return Err(Error::InvalidScheme);
73    }
74
75    let token = auth[SCHEME.len()..].trim().to_string();
76    let token = STANDARD.decode(&token).map_err(|_| Error::InvalidBase64)?;
77    let event = serde_json::from_slice::<Event>(&token).map_err(|_| Error::InvalidEvent)?;
78
79    if event.kind != Kind::HttpAuth {
80        return Err(Error::InvalidEvent);
81    }
82
83    let created_at = OffsetDateTime::from_unix_timestamp(event.created_at.as_i64())
84        .map_err(|_| Error::InvalidTimestamp)?;
85    let diff = OffsetDateTime::now_utc() - created_at;
86
87    if diff.whole_seconds() > 10_i64 {
88        return Err(Error::TimestampOutOfRange);
89    }
90
91    let http_data = HttpData::try_from(event.tags.clone()).map_err(|_| Error::InvalidEvent)?;
92
93    let req_method = HttpMethod::from_str(method).map_err(|_| Error::MethodMismatch)?;
94    if http_data.method != req_method {
95        return Err(Error::MethodMismatch);
96    }
97
98    let event_url = Url::parse(&http_data.url.to_string()).map_err(|_| Error::UrlMismatch)?;
99    if event_url != url {
100        return Err(Error::UrlMismatch);
101    }
102
103    Ok(event)
104}
105
106#[cfg(feature = "axum")]
107#[async_trait]
108impl<S> FromRequestParts<S> for Nip98PubKey
109where
110    S: Send + Sync,
111{
112    type Rejection = StatusCode;
113
114    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
115        if let Some(auth) = parts.headers.get(AUTHORIZATION) {
116            let auth = auth.to_str().map_err(|_| StatusCode::UNAUTHORIZED)?.trim();
117            let url = Url::parse(&parts.uri.to_string()).map_err(|_| StatusCode::UNAUTHORIZED)?;
118            let event = validate_nip98(auth, url, parts.method.as_str())?;
119            Ok(Nip98PubKey(event.pubkey))
120        } else {
121            Err(StatusCode::UNAUTHORIZED)
122        }
123    }
124}
125
126#[cfg(feature = "actix")]
127impl FromRequest for Nip98PubKey {
128    type Error = actix_web::Error;
129    type Future = Ready<Result<Self, Self::Error>>;
130
131    fn from_request(req: &HttpRequest, _: &mut actix_web::dev::Payload) -> Self::Future {
132        let auth = match req.headers().get("Authorization") {
133            Some(auth) => match auth.to_str() {
134                Ok(s) => s.trim(),
135                Err(_) => {
136                    return ready(Err(error::ErrorUnauthorized(
137                        "invalid authorization header",
138                    )))
139                }
140            },
141            None => return ready(Err(error::ErrorUnauthorized("no authorization header"))),
142        };
143        let url = match Url::parse(&req.uri().to_string()) {
144            Ok(u) => u,
145            Err(_) => return ready(Err(error::ErrorUnauthorized("no authorization header"))),
146        };
147        let event = match validate_nip98(&auth, url, req.method().as_str()) {
148            Ok(e) => e,
149            Err(_) => return ready(Err(error::ErrorUnauthorized("no authorization header"))),
150        };
151
152        ready(Ok(Nip98PubKey(event.pubkey)))
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use base64::{engine::general_purpose::STANDARD, Engine};
160    use nostr::{nips::nip98, EventBuilder, HttpMethod, Keys, Kind, UncheckedUrl};
161
162    #[test]
163    fn correctly_validates_nip98() {
164        let keys = Keys::generate();
165        let url = UncheckedUrl::from("https://example.com/");
166        let tags = nip98::HttpData::new(url.clone(), HttpMethod::POST);
167        let expected = EventBuilder::new(Kind::HttpAuth, "", &Vec::from(tags))
168            .to_event(&keys)
169            .unwrap()
170            .as_json();
171        let event = validate_nip98(
172            &format!("Nostr {}", STANDARD.encode(&expected)),
173            Url::parse(&url.to_string()).unwrap(),
174            "POST",
175        )
176        .unwrap();
177
178        assert_eq!(event.kind, Kind::HttpAuth);
179        assert_eq!(event.as_json(), expected);
180    }
181
182    #[test]
183    fn rejects_invalid_scheme() {
184        let url = Url::parse("https://example.com").unwrap();
185        let received = validate_nip98("Basic {}", url, "POST");
186
187        assert!(
188            matches!(received, Err(Error::InvalidScheme)),
189            "received: {:?}",
190            received
191        );
192    }
193
194    #[test]
195    fn rejects_empty_token() {
196        let url = Url::parse("https://example.com").unwrap();
197        let received = validate_nip98("Nostr ", url, "POST");
198
199        assert!(
200            matches!(received, Err(Error::InvalidEvent)),
201            "received: {:?}",
202            received
203        );
204    }
205
206    #[test]
207    fn rejects_invalid_base64_token() {
208        let url = Url::parse("https://example.com").unwrap();
209        let received = validate_nip98("Nostr zzz", url, "POST");
210
211        assert!(
212            matches!(received, Err(Error::InvalidBase64)),
213            "received: {:?}",
214            received
215        );
216    }
217
218    #[test]
219    fn rejects_non_nip98_kind() {
220        let keys = Keys::generate();
221        let url = UncheckedUrl::from("https://example.com");
222        let tags = nip98::HttpData::new(url.clone(), HttpMethod::POST);
223        let expected = EventBuilder::new(Kind::Metadata, "", &Vec::from(tags))
224            .to_event(&keys)
225            .unwrap()
226            .as_json();
227        let received = validate_nip98(
228            &format!("Nostr {}", STANDARD.encode(&expected)),
229            Url::parse(&url.to_string()).unwrap(),
230            "POST",
231        );
232
233        assert!(
234            matches!(received, Err(Error::InvalidEvent)),
235            "received: {:?}",
236            received
237        );
238    }
239
240    #[test]
241    fn rejects_created_at_over_10s() {
242        let keys = Keys::generate();
243        let url = UncheckedUrl::from("https://example.com");
244        let tags = nip98::HttpData::new(url.clone(), HttpMethod::POST);
245
246        let mut expected = EventBuilder::new(Kind::HttpAuth, "", &Vec::from(tags))
247            .to_event(&keys)
248            .unwrap();
249        expected.created_at = expected.created_at - 60_i64;
250
251        let expected = expected.as_json();
252        let received = validate_nip98(
253            &format!("Nostr {}", STANDARD.encode(&expected)),
254            Url::parse(&url.to_string()).unwrap(),
255            "POST",
256        );
257
258        assert!(
259            matches!(received, Err(Error::TimestampOutOfRange)),
260            "received: {:?}",
261            received
262        );
263    }
264
265    #[test]
266    fn rejects_url_mismatch() {
267        let keys = Keys::generate();
268        let url = UncheckedUrl::from("https://example.com");
269        let tags = nip98::HttpData::new(url, HttpMethod::POST);
270        let expected = EventBuilder::new(Kind::HttpAuth, "", &Vec::from(tags))
271            .to_event(&keys)
272            .unwrap()
273            .as_json();
274        let url2 = Url::parse("https://anotherexample.com").unwrap();
275        let received = validate_nip98(
276            &format!("Nostr {}", STANDARD.encode(&expected)),
277            url2,
278            "POST",
279        );
280
281        assert!(
282            matches!(received, Err(Error::UrlMismatch)),
283            "received: {:?}",
284            received
285        );
286    }
287
288    #[test]
289    fn rejects_method_mismatch() {
290        let keys = Keys::generate();
291        let url = UncheckedUrl::from("https://example.com");
292        let tags = nip98::HttpData::new(url.clone(), HttpMethod::POST);
293        let expected = EventBuilder::new(Kind::HttpAuth, "", &Vec::from(tags))
294            .to_event(&keys)
295            .unwrap()
296            .as_json();
297        let received = validate_nip98(
298            &format!("Nostr {}", STANDARD.encode(&expected)),
299            Url::parse(&url.to_string()).unwrap(),
300            "GET",
301        );
302
303        assert!(
304            matches!(received, Err(Error::MethodMismatch)),
305            "received: {:?}",
306            received
307        );
308    }
309}