Skip to main content

nula_core/nips/
nip98.rs

1//! [NIP-98] HTTP Auth.
2//!
3//! `kind: 27235` is an *ephemeral* event used as an
4//! `Authorization: Nostr <base64>` token for HTTP requests served
5//! by Nostr-aware backends. The body is empty (`.content == ""`)
6//! and the request shape lives entirely in two MUST-tags:
7//!
8//! - `["u", "<absolute URL>"]` — the exact request target,
9//!   including query string;
10//! - `["method", "<HTTP method>"]` — the verb of the request being
11//!   authorised.
12//!
13//! When the request carries a body, NIP-98 §"Nostr event" tells
14//! clients to add `["payload", "<sha256-hex>"]` and servers MAY
15//! cross-check it before accepting the request.
16//!
17//! # Why a typed module
18//!
19//! Upstream `rust-nostr` ships only a thin `EventBuilder::http_auth`
20//! that takes a [`Url`] and a method string. We model the whole
21//! flow:
22//!
23//! - [`HttpMethod`] — strongly-typed enum with `Other(String)` for
24//!   forward compatibility;
25//! - [`HttpAuthRequest`] — typed bundle that round-trips through
26//!   [`HttpAuthRequest::to_tags`] / [`HttpAuthRequest::from_event`];
27//! - [`HttpAuthRequest::validate`] — the four-step server-side
28//!   validation NIP-98 §"validate" mandates (kind, timestamp
29//!   skew, exact URL match, exact method match) plus the optional
30//!   `payload` body-hash cross-check;
31//! - [`authorization_header`] / [`parse_authorization_header`] —
32//!   the `Nostr <base64>` HTTP `Authorization` header
33//!   encoder/decoder that the spec describes but no upstream
34//!   crate ships.
35//!
36//! [NIP-98]: https://github.com/nostr-protocol/nips/blob/master/98.md
37
38use base64::Engine;
39use base64::engine::general_purpose::STANDARD as BASE64;
40use sha2::{Digest, Sha256};
41use thiserror::Error;
42
43use crate::event::{Event, EventBuilder, EventError, Kind, Tag, TagKind, Tags};
44use crate::types::{Timestamp, Url, UrlError};
45use crate::util::JsonUtil;
46use crate::util::hex::{self, HexError};
47
48/// `kind: 27235` — NIP-98 HTTP authorization event.
49pub const KIND_HTTP_AUTH: Kind = Kind::new(27_235);
50
51/// `u` tag wire head.
52pub const URL_TAG: &str = "u";
53/// `method` tag wire head.
54pub const METHOD_TAG: &str = "method";
55/// `payload` tag wire head.
56pub const PAYLOAD_TAG: &str = "payload";
57
58/// Default acceptance window for [`HttpAuthRequest::validate`] —
59/// **60 seconds** per NIP-98 §"validate" suggestion.
60pub const DEFAULT_TIMESTAMP_SKEW_SECS: u64 = 60;
61
62/// HTTP request method, with [`Self::Other`] preserving any verb the
63/// IANA registry adds in the future (or any per-app extension).
64#[derive(Debug, Clone, PartialEq, Eq, Hash)]
65#[non_exhaustive]
66pub enum HttpMethod {
67    /// `GET`.
68    Get,
69    /// `POST`.
70    Post,
71    /// `PUT`.
72    Put,
73    /// `PATCH`.
74    Patch,
75    /// `DELETE`.
76    Delete,
77    /// `HEAD`.
78    Head,
79    /// `OPTIONS`.
80    Options,
81    /// `CONNECT`.
82    Connect,
83    /// `TRACE`.
84    Trace,
85    /// Forward-compatible passthrough. `Other(String)` always
86    /// stores the **uppercase** verb so two equal verbs compare
87    /// equal regardless of the wire casing.
88    Other(String),
89}
90
91impl HttpMethod {
92    /// Render to wire form. RFC 9110 §9 uses uppercase verbs;
93    /// callers that must follow another convention can post-process
94    /// the returned string.
95    #[must_use]
96    pub const fn as_str(&self) -> &str {
97        match self {
98            Self::Get => "GET",
99            Self::Post => "POST",
100            Self::Put => "PUT",
101            Self::Patch => "PATCH",
102            Self::Delete => "DELETE",
103            Self::Head => "HEAD",
104            Self::Options => "OPTIONS",
105            Self::Connect => "CONNECT",
106            Self::Trace => "TRACE",
107            Self::Other(s) => s.as_str(),
108        }
109    }
110
111    /// Parse a wire token. Always succeeds: unknown verbs become
112    /// `Other(uppercased)` for forward compatibility.
113    #[must_use]
114    pub fn parse(s: &str) -> Self {
115        let upper = s.trim().to_ascii_uppercase();
116        match upper.as_str() {
117            "GET" => Self::Get,
118            "POST" => Self::Post,
119            "PUT" => Self::Put,
120            "PATCH" => Self::Patch,
121            "DELETE" => Self::Delete,
122            "HEAD" => Self::Head,
123            "OPTIONS" => Self::Options,
124            "CONNECT" => Self::Connect,
125            "TRACE" => Self::Trace,
126            _ => Self::Other(upper),
127        }
128    }
129}
130
131impl std::fmt::Display for HttpMethod {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        f.write_str(self.as_str())
134    }
135}
136
137/// Typed bundle for a `kind: 27235` HTTP-auth event.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct HttpAuthRequest {
140    /// Absolute request URL (the `u` tag).
141    pub url: Url,
142    /// HTTP method (the `method` tag).
143    pub method: HttpMethod,
144    /// Optional SHA-256 over the request body (the `payload` tag).
145    /// `None` matches a body-less request.
146    pub payload_hash: Option<[u8; 32]>,
147}
148
149impl HttpAuthRequest {
150    /// Construct a body-less request bundle.
151    #[must_use]
152    pub const fn new(url: Url, method: HttpMethod) -> Self {
153        Self {
154            url,
155            method,
156            payload_hash: None,
157        }
158    }
159
160    /// Attach a SHA-256 hash of the request body.
161    #[must_use]
162    pub const fn payload_hash(mut self, hash: [u8; 32]) -> Self {
163        self.payload_hash = Some(hash);
164        self
165    }
166
167    /// Convenience: hash `body` with SHA-256 and attach the digest.
168    ///
169    /// Use this when the caller already has the body bytes in hand.
170    #[must_use]
171    pub fn payload(self, body: &[u8]) -> Self {
172        self.payload_hash(sha256_hash(body))
173    }
174
175    /// Render to the tag list of a `kind: 27235` event.
176    #[must_use]
177    pub fn to_tags(&self) -> Vec<Tag> {
178        let mut tags: Vec<Tag> = Vec::with_capacity(3);
179        tags.push(custom_tag(URL_TAG, [self.url.as_str().to_owned()]));
180        tags.push(custom_tag(METHOD_TAG, [self.method.as_str().to_owned()]));
181        if let Some(hash) = self.payload_hash {
182            tags.push(custom_tag(PAYLOAD_TAG, [hex::encode(hash)]));
183        }
184        tags
185    }
186
187    /// Parse a `kind: 27235` event back into a typed bundle.
188    ///
189    /// # Errors
190    ///
191    /// - [`HttpAuthError::WrongKind`] for any other kind.
192    /// - [`HttpAuthError::MissingUrl`] / `MissingMethod` when a
193    ///   required tag is absent.
194    /// - [`HttpAuthError::InvalidUrl`] / `InvalidPayloadHash` when
195    ///   a tag value is malformed.
196    pub fn from_event(event: &Event) -> Result<Self, HttpAuthError> {
197        if event.kind != KIND_HTTP_AUTH {
198            return Err(HttpAuthError::WrongKind(event.kind));
199        }
200        Self::from_tags(&event.tags)
201    }
202
203    /// Parse from a tag list (without enforcing the wrapping kind).
204    ///
205    /// # Errors
206    ///
207    /// See [`Self::from_event`].
208    pub fn from_tags(tags: &Tags) -> Result<Self, HttpAuthError> {
209        let url_str = custom_value(tags, URL_TAG).ok_or(HttpAuthError::MissingUrl)?;
210        let url = Url::parse(url_str).map_err(HttpAuthError::InvalidUrl)?;
211        let method_str = custom_value(tags, METHOD_TAG).ok_or(HttpAuthError::MissingMethod)?;
212        let method = HttpMethod::parse(method_str);
213
214        let payload_hash = if let Some(hex_str) = custom_value(tags, PAYLOAD_TAG) {
215            Some(parse_sha256_hex(hex_str)?)
216        } else {
217            None
218        };
219        Ok(Self {
220            url,
221            method,
222            payload_hash,
223        })
224    }
225
226    /// Server-side validation per NIP-98 §"Servers MUST perform the
227    /// following checks":
228    ///
229    /// 1. Event kind is `27235` (already enforced when the bundle
230    ///    came from [`Self::from_event`]).
231    /// 2. `created_at` is within `±skew` of `now`. Default skew is
232    ///    [`DEFAULT_TIMESTAMP_SKEW_SECS`].
233    /// 3. The bundle's [`Self::url`] equals `request_url`
234    ///    byte-for-byte.
235    /// 4. The bundle's [`Self::method`] equals `request_method`.
236    ///
237    /// When `body` is `Some`, the SHA-256 over those bytes must
238    /// equal the bundle's [`Self::payload_hash`].
239    ///
240    /// A `body == None` + `payload_hash == Some(_)` mismatch fails
241    /// validation; a `body == Some` + `payload_hash == None` is
242    /// *allowed* (spec uses "SHOULD include", not "MUST"), but
243    /// most servers will want to reject it themselves with a
244    /// higher-level rule.
245    ///
246    /// # Errors
247    ///
248    /// One of the variants of [`HttpAuthError`] tagged with
249    /// `Validation*`.
250    pub fn validate(
251        &self,
252        signed_at: Timestamp,
253        now: Timestamp,
254        skew_secs: u64,
255        request_url: &Url,
256        request_method: &HttpMethod,
257        body: Option<&[u8]>,
258    ) -> Result<(), HttpAuthError> {
259        let signed = signed_at.as_secs();
260        let current = now.as_secs();
261        let delta = signed.abs_diff(current);
262        if delta > skew_secs {
263            return Err(HttpAuthError::ValidationTimestampSkew {
264                delta_secs: delta,
265                allowed_secs: skew_secs,
266            });
267        }
268        if self.url != *request_url {
269            return Err(HttpAuthError::ValidationUrlMismatch {
270                expected: request_url.as_str().to_owned(),
271                got: self.url.as_str().to_owned(),
272            });
273        }
274        if self.method != *request_method {
275            return Err(HttpAuthError::ValidationMethodMismatch {
276                expected: request_method.to_string(),
277                got: self.method.to_string(),
278            });
279        }
280        if let Some(body_bytes) = body
281            && let Some(expected_hash) = self.payload_hash
282        {
283            let actual_hash = sha256_hash(body_bytes);
284            if actual_hash != expected_hash {
285                return Err(HttpAuthError::ValidationPayloadMismatch);
286            }
287        }
288        Ok(())
289    }
290}
291
292fn parse_sha256_hex(input: &str) -> Result<[u8; 32], HttpAuthError> {
293    if input.len() != 64 {
294        return Err(HttpAuthError::InvalidPayloadHashLength(input.len()));
295    }
296    let mut bytes = [0_u8; 32];
297    hex::decode_to_slice(input, &mut bytes).map_err(HttpAuthError::InvalidPayloadHash)?;
298    Ok(bytes)
299}
300
301fn sha256_hash(body: &[u8]) -> [u8; 32] {
302    let mut hasher = Sha256::new();
303    hasher.update(body);
304    hasher.finalize().into()
305}
306
307fn custom_tag<I, S>(name: &str, args: I) -> Tag
308where
309    I: IntoIterator<Item = S>,
310    S: Into<String>,
311{
312    Tag::with(&TagKind::from_wire(name), args)
313}
314
315fn custom_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
316    tags.iter()
317        .find(|tag| tag.name() == name)
318        .and_then(|tag| tag.get(1))
319}
320
321/// Errors raised while building, parsing, or validating an HTTP-auth
322/// event.
323#[derive(Debug, Error)]
324#[non_exhaustive]
325pub enum HttpAuthError {
326    /// The wrapping event was not `kind: 27235`.
327    #[error("expected kind 27235 (HTTP auth), got kind {}", .0.as_u16())]
328    WrongKind(Kind),
329    /// `u` tag was absent.
330    #[error("NIP-98 event must carry a `u` tag")]
331    MissingUrl,
332    /// `method` tag was absent.
333    #[error("NIP-98 event must carry a `method` tag")]
334    MissingMethod,
335    /// `u` tag value did not parse.
336    #[error("invalid URL: {0}")]
337    InvalidUrl(#[source] UrlError),
338    /// `payload` hex was the wrong length.
339    #[error("`payload` hash must be 64 hex chars, got {0}")]
340    InvalidPayloadHashLength(usize),
341    /// `payload` hex did not decode.
342    #[error("invalid `payload` hash: {0}")]
343    InvalidPayloadHash(#[source] HexError),
344    /// `Authorization` header did not start with `Nostr `.
345    #[error("`Authorization` header must use the `Nostr` scheme")]
346    HeaderWrongScheme,
347    /// `Authorization` header body was not parseable base64.
348    #[error("`Authorization` body is not valid base64: {0}")]
349    HeaderInvalidBase64(#[source] base64::DecodeError),
350    /// `Authorization` body decoded to non-UTF-8.
351    #[error("`Authorization` body is not UTF-8: {0}")]
352    HeaderInvalidUtf8(#[source] std::str::Utf8Error),
353    /// `Authorization` body did not deserialise as a Nostr event.
354    #[error("`Authorization` body is not a valid Nostr event: {0}")]
355    HeaderInvalidEvent(#[source] EventError),
356    /// `Authorization` body JSON was malformed.
357    #[error("`Authorization` body is not valid JSON: {0}")]
358    HeaderInvalidJson(#[source] serde_json::Error),
359    /// `created_at` skew exceeded `skew_secs`.
360    #[error("`created_at` is {delta_secs}s away from `now`; max allowed is {allowed_secs}s")]
361    ValidationTimestampSkew {
362        /// Observed `|signed_at - now|` in seconds.
363        delta_secs: u64,
364        /// Configured limit in seconds.
365        allowed_secs: u64,
366    },
367    /// `u` tag did not match the request URL.
368    #[error("`u` mismatch: expected `{expected}`, got `{got}`")]
369    ValidationUrlMismatch {
370        /// URL the server saw on the wire.
371        expected: String,
372        /// URL the bundle attests to.
373        got: String,
374    },
375    /// `method` tag did not match the request method.
376    #[error("`method` mismatch: expected `{expected}`, got `{got}`")]
377    ValidationMethodMismatch {
378        /// HTTP method the server saw.
379        expected: String,
380        /// HTTP method the bundle attests to.
381        got: String,
382    },
383    /// SHA-256 of the request body did not match `payload`.
384    #[error("`payload` SHA-256 does not match the request body")]
385    ValidationPayloadMismatch,
386}
387
388impl EventBuilder {
389    /// Author a NIP-98 HTTP-auth event from a typed bundle.
390    #[must_use]
391    pub fn http_auth(request: &HttpAuthRequest) -> Self {
392        let mut builder = Self::new(KIND_HTTP_AUTH, "");
393        for tag in request.to_tags() {
394            builder = builder.tag(tag);
395        }
396        builder
397    }
398}
399
400/// HTTP authentication scheme prefix.
401pub const AUTH_SCHEME_PREFIX: &str = "Nostr ";
402
403/// Encode `event` (a signed `kind: 27235` event) as the body of an
404/// `Authorization: Nostr <base64>` HTTP header.
405///
406/// # Errors
407///
408/// Forwarded from [`JsonUtil::try_to_json`] (effectively unreachable
409/// for spec-conforming inputs but propagated for completeness).
410pub fn authorization_header(event: &Event) -> Result<String, HttpAuthError> {
411    let json = event
412        .try_to_json()
413        .map_err(HttpAuthError::HeaderInvalidJson)?;
414    Ok(format!("{AUTH_SCHEME_PREFIX}{}", BASE64.encode(json)))
415}
416
417/// Decode an `Authorization: Nostr <base64>` header into the
418/// underlying [`Event`].
419///
420/// The caller is responsible for re-running [`Event::verify`] and
421/// [`HttpAuthRequest::validate`].
422///
423/// # Errors
424///
425/// - [`HttpAuthError::HeaderWrongScheme`] when the header does not
426///   start with `Nostr ` (case-sensitive per NIP-98 §"Request
427///   Flow").
428/// - [`HttpAuthError::HeaderInvalidBase64`] when the body is not
429///   valid base64.
430/// - [`HttpAuthError::HeaderInvalidUtf8`] when the decoded bytes
431///   are not UTF-8.
432/// - [`HttpAuthError::HeaderInvalidJson`] when the JSON does not
433///   deserialise.
434pub fn parse_authorization_header(header: &str) -> Result<Event, HttpAuthError> {
435    let body = header
436        .strip_prefix(AUTH_SCHEME_PREFIX)
437        .ok_or(HttpAuthError::HeaderWrongScheme)?;
438    let bytes = BASE64
439        .decode(body.trim())
440        .map_err(HttpAuthError::HeaderInvalidBase64)?;
441    let json = std::str::from_utf8(&bytes).map_err(HttpAuthError::HeaderInvalidUtf8)?;
442    let event = Event::from_json(json).map_err(HttpAuthError::HeaderInvalidJson)?;
443    Ok(event)
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449    use crate::Keys;
450
451    fn keys() -> Keys {
452        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
453    }
454
455    fn fixture_url() -> Url {
456        Url::parse("https://api.example.com/api/v1/n5sp/list").unwrap()
457    }
458
459    #[test]
460    fn round_trip_through_event_for_get_request() {
461        let req = HttpAuthRequest::new(fixture_url(), HttpMethod::Get);
462        let event = EventBuilder::http_auth(&req)
463            .sign_with_keys(&keys())
464            .unwrap();
465        assert_eq!(event.kind, KIND_HTTP_AUTH);
466        assert_eq!(event.content, "");
467        let parsed = HttpAuthRequest::from_event(&event).unwrap();
468        assert_eq!(parsed, req);
469    }
470
471    #[test]
472    fn round_trip_includes_payload_hash_for_post() {
473        let body = b"{\"hello\":\"world\"}";
474        let req = HttpAuthRequest::new(fixture_url(), HttpMethod::Post).payload(body);
475        let event = EventBuilder::http_auth(&req)
476            .sign_with_keys(&keys())
477            .unwrap();
478        let parsed = HttpAuthRequest::from_event(&event).unwrap();
479        assert_eq!(parsed, req);
480        let expected = sha256_hash(body);
481        assert_eq!(parsed.payload_hash, Some(expected));
482    }
483
484    #[test]
485    fn missing_url_is_rejected_when_parsing() {
486        let event = EventBuilder::new(KIND_HTTP_AUTH, "")
487            .tag(custom_tag(METHOD_TAG, ["GET"]))
488            .sign_with_keys(&keys())
489            .unwrap();
490        assert!(matches!(
491            HttpAuthRequest::from_event(&event),
492            Err(HttpAuthError::MissingUrl)
493        ));
494    }
495
496    #[test]
497    fn missing_method_is_rejected_when_parsing() {
498        let event = EventBuilder::new(KIND_HTTP_AUTH, "")
499            .tag(custom_tag(URL_TAG, [fixture_url().as_str()]))
500            .sign_with_keys(&keys())
501            .unwrap();
502        assert!(matches!(
503            HttpAuthRequest::from_event(&event),
504            Err(HttpAuthError::MissingMethod)
505        ));
506    }
507
508    #[test]
509    fn unknown_method_round_trips_as_other() {
510        let m = HttpMethod::parse("MOVE");
511        assert_eq!(m, HttpMethod::Other("MOVE".to_owned()));
512        assert_eq!(m.as_str(), "MOVE");
513    }
514
515    #[test]
516    fn lowercase_method_is_normalised() {
517        let m = HttpMethod::parse("get");
518        assert_eq!(m, HttpMethod::Get);
519    }
520
521    #[test]
522    fn validate_passes_for_correct_request() {
523        let req = HttpAuthRequest::new(fixture_url(), HttpMethod::Get);
524        let signed_at = Timestamp::from_secs(1_700_000_000);
525        let now = Timestamp::from_secs(1_700_000_010); // 10s later, within window
526        req.validate(
527            signed_at,
528            now,
529            DEFAULT_TIMESTAMP_SKEW_SECS,
530            &fixture_url(),
531            &HttpMethod::Get,
532            None,
533        )
534        .unwrap();
535    }
536
537    #[test]
538    fn validate_rejects_timestamp_skew() {
539        let req = HttpAuthRequest::new(fixture_url(), HttpMethod::Get);
540        let err = req
541            .validate(
542                Timestamp::from_secs(1_700_000_000),
543                Timestamp::from_secs(1_700_000_120), // 2 minutes later, > 60s
544                DEFAULT_TIMESTAMP_SKEW_SECS,
545                &fixture_url(),
546                &HttpMethod::Get,
547                None,
548            )
549            .unwrap_err();
550        assert!(matches!(err, HttpAuthError::ValidationTimestampSkew { .. }));
551    }
552
553    #[test]
554    fn validate_rejects_url_mismatch() {
555        let req = HttpAuthRequest::new(fixture_url(), HttpMethod::Get);
556        let err = req
557            .validate(
558                Timestamp::from_secs(0),
559                Timestamp::from_secs(0),
560                DEFAULT_TIMESTAMP_SKEW_SECS,
561                &Url::parse("https://other.example/foo").unwrap(),
562                &HttpMethod::Get,
563                None,
564            )
565            .unwrap_err();
566        assert!(matches!(err, HttpAuthError::ValidationUrlMismatch { .. }));
567    }
568
569    #[test]
570    fn validate_rejects_method_mismatch() {
571        let req = HttpAuthRequest::new(fixture_url(), HttpMethod::Get);
572        let err = req
573            .validate(
574                Timestamp::from_secs(0),
575                Timestamp::from_secs(0),
576                DEFAULT_TIMESTAMP_SKEW_SECS,
577                &fixture_url(),
578                &HttpMethod::Post,
579                None,
580            )
581            .unwrap_err();
582        assert!(matches!(
583            err,
584            HttpAuthError::ValidationMethodMismatch { .. }
585        ));
586    }
587
588    #[test]
589    fn validate_rejects_payload_mismatch() {
590        let req = HttpAuthRequest::new(fixture_url(), HttpMethod::Post).payload(b"original");
591        let err = req
592            .validate(
593                Timestamp::from_secs(0),
594                Timestamp::from_secs(0),
595                DEFAULT_TIMESTAMP_SKEW_SECS,
596                &fixture_url(),
597                &HttpMethod::Post,
598                Some(b"tampered"),
599            )
600            .unwrap_err();
601        assert!(matches!(err, HttpAuthError::ValidationPayloadMismatch));
602    }
603
604    #[test]
605    fn authorization_header_round_trips() {
606        let req = HttpAuthRequest::new(fixture_url(), HttpMethod::Get);
607        let event = EventBuilder::http_auth(&req)
608            .sign_with_keys(&keys())
609            .unwrap();
610        let header = authorization_header(&event).unwrap();
611        assert!(header.starts_with("Nostr "));
612        let parsed = parse_authorization_header(&header).unwrap();
613        assert_eq!(parsed.id, event.id);
614    }
615
616    #[test]
617    fn parse_authorization_rejects_wrong_scheme() {
618        let err = parse_authorization_header("Bearer xxx").unwrap_err();
619        assert!(matches!(err, HttpAuthError::HeaderWrongScheme));
620    }
621
622    #[test]
623    fn parse_authorization_rejects_bad_base64() {
624        let err = parse_authorization_header("Nostr !!!!!").unwrap_err();
625        assert!(matches!(err, HttpAuthError::HeaderInvalidBase64(_)));
626    }
627
628    #[test]
629    fn malformed_payload_hash_surfaces_typed_error() {
630        let event = EventBuilder::new(KIND_HTTP_AUTH, "")
631            .tag(custom_tag(URL_TAG, [fixture_url().as_str()]))
632            .tag(custom_tag(METHOD_TAG, ["POST"]))
633            .tag(custom_tag(PAYLOAD_TAG, ["not-hex"]))
634            .sign_with_keys(&keys())
635            .unwrap();
636        assert!(matches!(
637            HttpAuthRequest::from_event(&event),
638            Err(HttpAuthError::InvalidPayloadHashLength(_))
639        ));
640    }
641
642    #[test]
643    fn wrong_kind_is_rejected_when_parsing() {
644        let event = EventBuilder::text_note("nope")
645            .sign_with_keys(&keys())
646            .unwrap();
647        assert!(matches!(
648            HttpAuthRequest::from_event(&event),
649            Err(HttpAuthError::WrongKind(_))
650        ));
651    }
652}