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}