1use thiserror::Error;
23
24use crate::event::{Event, EventBuilder, Kind, Tag, TagKind};
25use crate::types::{RelayUrl, RelayUrlError, Timestamp};
26
27pub const RELAY_TAG: &str = "relay";
29pub const CHALLENGE_TAG: &str = "challenge";
31pub const DEFAULT_MAX_AGE_SECS: u64 = 10 * 60;
33
34#[derive(Debug, Clone, Error)]
36#[non_exhaustive]
37pub enum AuthError {
38 #[error("expected kind 22242, got {0}")]
40 UnexpectedKind(u16),
41 #[error("`relay` tag is missing or empty")]
43 MissingRelayTag,
44 #[error("`challenge` tag is missing or empty")]
46 MissingChallengeTag,
47 #[error(transparent)]
49 InvalidRelay(#[from] RelayUrlError),
50 #[error("relay mismatch: expected `{expected}`, got `{got}`")]
52 RelayMismatch {
53 expected: String,
55 got: String,
57 },
58 #[error("challenge mismatch")]
60 ChallengeMismatch,
61 #[error("auth event is too old: created_at {created_at} vs now {now} (max age {max_age}s)")]
63 TooOld {
64 created_at: u64,
66 now: u64,
68 max_age: u64,
70 },
71 #[error(
73 "auth event is too far in the future: created_at {created_at} vs now {now} (max skew {max_age}s)"
74 )]
75 TooFuture {
76 created_at: u64,
78 now: u64,
80 max_age: u64,
82 },
83}
84
85#[must_use]
91pub fn auth_event(relay: &RelayUrl, challenge: impl Into<String>) -> EventBuilder {
92 EventBuilder::new(Kind::AUTHENTICATION, "")
93 .tag(Tag::with(
94 &TagKind::from_wire(RELAY_TAG),
95 [relay.as_str().to_owned()],
96 ))
97 .tag(Tag::with(
98 &TagKind::from_wire(CHALLENGE_TAG),
99 [challenge.into()],
100 ))
101}
102
103pub fn verify_auth_event(
119 event: &Event,
120 relay: &RelayUrl,
121 challenge: &str,
122 now: Timestamp,
123 max_age: u64,
124) -> Result<(), AuthError> {
125 verify_auth_event_against(event, relay, &[challenge], now, max_age)
126}
127
128pub fn verify_auth_event_against(
146 event: &Event,
147 relay: &RelayUrl,
148 accepted: &[&str],
149 now: Timestamp,
150 max_age: u64,
151) -> Result<(), AuthError> {
152 if event.kind != Kind::AUTHENTICATION {
153 return Err(AuthError::UnexpectedKind(event.kind.as_u16()));
154 }
155
156 let relay_tag = TagKind::from_wire(RELAY_TAG);
157 let claimed_relay = event
158 .tags
159 .find_first(&relay_tag)
160 .and_then(|t| t.values().get(1))
161 .ok_or(AuthError::MissingRelayTag)?;
162 let claimed_relay = RelayUrl::parse(claimed_relay)?;
163 if claimed_relay != *relay {
164 return Err(AuthError::RelayMismatch {
165 expected: relay.as_str().to_owned(),
166 got: claimed_relay.as_str().to_owned(),
167 });
168 }
169
170 let challenge_tag = TagKind::from_wire(CHALLENGE_TAG);
171 let claimed_challenge = event
172 .tags
173 .find_first(&challenge_tag)
174 .and_then(|t| t.values().get(1))
175 .filter(|s| !s.is_empty())
176 .ok_or(AuthError::MissingChallengeTag)?;
177 if !accepted.contains(&claimed_challenge.as_str()) {
178 return Err(AuthError::ChallengeMismatch);
179 }
180
181 let now_secs = now.as_secs();
182 let created_at = event.created_at.as_secs();
183 if now_secs > created_at && now_secs.saturating_sub(created_at) > max_age {
184 return Err(AuthError::TooOld {
185 created_at,
186 now: now_secs,
187 max_age,
188 });
189 }
190 if created_at > now_secs && created_at.saturating_sub(now_secs) > max_age {
191 return Err(AuthError::TooFuture {
192 created_at,
193 now: now_secs,
194 max_age,
195 });
196 }
197
198 Ok(())
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use crate::Keys;
205
206 fn keys() -> Keys {
207 Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
208 }
209
210 fn relay() -> RelayUrl {
211 RelayUrl::parse("wss://relay.example/").unwrap()
212 }
213
214 fn signed(challenge: &str, ts: Timestamp) -> Event {
215 auth_event(&relay(), challenge)
216 .created_at(ts)
217 .sign_with_keys(&keys())
218 .unwrap()
219 }
220
221 #[test]
222 fn auth_event_builder_sets_kind_and_tags() {
223 let event = signed("c1", Timestamp::from_secs(100));
224 assert_eq!(event.kind, Kind::AUTHENTICATION);
225 let relay_tag = event
226 .tags
227 .find_first(&TagKind::from_wire(RELAY_TAG))
228 .unwrap();
229 assert_eq!(
230 relay_tag.values().get(1).map(String::as_str),
231 Some(relay().as_str())
232 );
233 let challenge_tag = event
234 .tags
235 .find_first(&TagKind::from_wire(CHALLENGE_TAG))
236 .unwrap();
237 assert_eq!(
238 challenge_tag.values().get(1).map(String::as_str),
239 Some("c1")
240 );
241 }
242
243 #[test]
244 fn verify_happy_path() {
245 let event = signed("c1", Timestamp::from_secs(100));
246 verify_auth_event(&event, &relay(), "c1", Timestamp::from_secs(100), 600).unwrap();
247 }
248
249 #[test]
250 fn verify_rejects_wrong_kind() {
251 let event = EventBuilder::text_note("nope")
252 .created_at(Timestamp::from_secs(1))
253 .sign_with_keys(&keys())
254 .unwrap();
255 let err =
256 verify_auth_event(&event, &relay(), "c1", Timestamp::from_secs(1), 600).unwrap_err();
257 assert!(matches!(err, AuthError::UnexpectedKind(1)));
258 }
259
260 #[test]
261 fn verify_rejects_relay_mismatch() {
262 let event = signed("c1", Timestamp::from_secs(1));
263 let other = RelayUrl::parse("wss://other.example/").unwrap();
264 let err =
265 verify_auth_event(&event, &other, "c1", Timestamp::from_secs(1), 600).unwrap_err();
266 assert!(matches!(err, AuthError::RelayMismatch { .. }));
267 }
268
269 #[test]
270 fn verify_rejects_challenge_mismatch() {
271 let event = signed("c1", Timestamp::from_secs(1));
272 let err = verify_auth_event(&event, &relay(), "different", Timestamp::from_secs(1), 600)
273 .unwrap_err();
274 assert!(matches!(err, AuthError::ChallengeMismatch));
275 }
276
277 #[test]
278 fn verify_rejects_old_event() {
279 let event = signed("c1", Timestamp::from_secs(100));
280 let err = verify_auth_event(&event, &relay(), "c1", Timestamp::from_secs(1_000), 100)
281 .unwrap_err();
282 assert!(matches!(err, AuthError::TooOld { .. }));
283 }
284
285 #[test]
286 fn verify_rejects_future_event() {
287 let event = signed("c1", Timestamp::from_secs(2_000));
288 let err = verify_auth_event(&event, &relay(), "c1", Timestamp::from_secs(1_000), 100)
289 .unwrap_err();
290 assert!(matches!(err, AuthError::TooFuture { .. }));
291 }
292
293 #[test]
294 fn verify_rejects_missing_relay_tag() {
295 let event = EventBuilder::new(Kind::AUTHENTICATION, "")
296 .created_at(Timestamp::from_secs(1))
297 .tag(Tag::with(
298 &TagKind::from_wire(CHALLENGE_TAG),
299 ["c1".to_owned()],
300 ))
301 .sign_with_keys(&keys())
302 .unwrap();
303 let err =
304 verify_auth_event(&event, &relay(), "c1", Timestamp::from_secs(1), 600).unwrap_err();
305 assert!(matches!(err, AuthError::MissingRelayTag));
306 }
307
308 #[test]
309 fn verify_rejects_missing_challenge_tag() {
310 let event = EventBuilder::new(Kind::AUTHENTICATION, "")
311 .created_at(Timestamp::from_secs(1))
312 .tag(Tag::with(
313 &TagKind::from_wire(RELAY_TAG),
314 [relay().as_str().to_owned()],
315 ))
316 .sign_with_keys(&keys())
317 .unwrap();
318 let err =
319 verify_auth_event(&event, &relay(), "c1", Timestamp::from_secs(1), 600).unwrap_err();
320 assert!(matches!(err, AuthError::MissingChallengeTag));
321 }
322
323 #[test]
324 fn verify_against_multi_challenge_accepts_any_match() {
325 let event = signed("c1", Timestamp::from_secs(1));
327 verify_auth_event_against(
328 &event,
329 &relay(),
330 &["c2", "c1"],
331 Timestamp::from_secs(1),
332 600,
333 )
334 .unwrap();
335 }
336
337 #[test]
338 fn verify_against_multi_challenge_rejects_when_none_match() {
339 let event = signed("c1", Timestamp::from_secs(1));
340 let err = verify_auth_event_against(
341 &event,
342 &relay(),
343 &["c2", "c3"],
344 Timestamp::from_secs(1),
345 600,
346 )
347 .unwrap_err();
348 assert!(matches!(err, AuthError::ChallengeMismatch));
349 }
350
351 #[test]
352 fn verify_against_empty_challenge_set_rejects() {
353 let event = signed("c1", Timestamp::from_secs(1));
354 let err = verify_auth_event_against(&event, &relay(), &[], Timestamp::from_secs(1), 600)
355 .unwrap_err();
356 assert!(matches!(err, AuthError::ChallengeMismatch));
357 }
358}