1use crate::response::ImapResponse;
38use crate::session::{ImapSession, ImapState};
39use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
40use rusmes_auth::{sasl::SaslServer, AuthBackend};
41
42#[derive(Debug)]
44pub enum AuthenticateState {
45 Initial,
47 Challenge,
49 Completed,
51}
52
53pub struct AuthenticateContext {
55 mechanism: Box<dyn rusmes_auth::sasl::SaslMechanism>,
57 #[allow(dead_code)]
59 state: AuthenticateState,
60 tag: String,
62}
63
64impl AuthenticateContext {
65 pub fn new(mechanism: Box<dyn rusmes_auth::sasl::SaslMechanism>, tag: String) -> Self {
67 Self {
68 mechanism,
69 state: AuthenticateState::Initial,
70 tag,
71 }
72 }
73
74 pub fn tag(&self) -> &str {
76 &self.tag
77 }
78
79 pub fn mechanism_name(&self) -> &str {
81 self.mechanism.name()
82 }
83}
84
85pub async fn handle_authenticate(
98 session: &mut ImapSession,
99 tag: &str,
100 mechanism_name: &str,
101 initial_response: Option<&str>,
102 sasl_server: &SaslServer,
103 auth_backend: &dyn AuthBackend,
104) -> anyhow::Result<(ImapResponse, Option<AuthenticateContext>)> {
105 if !matches!(session.state(), ImapState::NotAuthenticated) {
107 return Ok((ImapResponse::bad(tag, "Already authenticated"), None));
108 }
109
110 if !sasl_server.is_mechanism_enabled(mechanism_name) {
112 return Ok((
113 ImapResponse::no(
114 tag,
115 format!(
116 "[AUTHENTICATIONFAILED] Mechanism {} not supported",
117 mechanism_name
118 ),
119 ),
120 None,
121 ));
122 }
123
124 let mut mechanism = match sasl_server.create_mechanism(mechanism_name) {
126 Ok(m) => m,
127 Err(e) => {
128 return Ok((
129 ImapResponse::no(tag, format!("[AUTHENTICATIONFAILED] {}", e)),
130 None,
131 ));
132 }
133 };
134
135 if let Some(initial_resp) = initial_response {
137 let decoded = match BASE64.decode(initial_resp.trim()) {
139 Ok(d) => d,
140 Err(e) => {
141 return Ok((
142 ImapResponse::bad(tag, format!("Invalid Base64 in initial response: {}", e)),
143 None,
144 ));
145 }
146 };
147
148 let decoded_str = std::str::from_utf8(&decoded).unwrap_or("");
149
150 return handle_authenticate_step(session, tag, mechanism, decoded_str, auth_backend).await;
151 }
152
153 let auth_backend_ref: &dyn AuthBackend = auth_backend;
155
156 match mechanism.step(b"", auth_backend_ref).await {
157 Ok(rusmes_auth::sasl::SaslStep::Challenge { data }) => {
158 let encoded = BASE64.encode(&data);
160 let ctx = AuthenticateContext {
161 mechanism,
162 state: AuthenticateState::Challenge,
163 tag: tag.to_string(),
164 };
165 Ok((ImapResponse::new(None, "+", encoded), Some(ctx)))
166 }
167 Ok(rusmes_auth::sasl::SaslStep::Continue) => {
168 let ctx = AuthenticateContext {
170 mechanism,
171 state: AuthenticateState::Initial,
172 tag: tag.to_string(),
173 };
174 Ok((ImapResponse::new(None, "+", ""), Some(ctx)))
175 }
176 Ok(rusmes_auth::sasl::SaslStep::Done { success, username }) => {
177 if success && username.is_some() {
179 session.state = ImapState::Authenticated;
180 session.username = username;
181 Ok((ImapResponse::ok(tag, "AUTHENTICATE completed"), None))
182 } else {
183 Ok((
184 ImapResponse::no(tag, "[AUTHENTICATIONFAILED] Authentication failed"),
185 None,
186 ))
187 }
188 }
189 Err(e) => Ok((
190 ImapResponse::no(tag, format!("[AUTHENTICATIONFAILED] {}", e)),
191 None,
192 )),
193 }
194}
195
196pub async fn handle_authenticate_continue(
207 session: &mut ImapSession,
208 ctx: AuthenticateContext,
209 client_data: &str,
210 auth_backend: &dyn AuthBackend,
211) -> anyhow::Result<(ImapResponse, Option<AuthenticateContext>)> {
212 if client_data.trim() == "*" {
214 return Ok((ImapResponse::bad(&ctx.tag, "AUTHENTICATE cancelled"), None));
215 }
216
217 let decoded = match BASE64.decode(client_data.trim()) {
219 Ok(d) => d,
220 Err(e) => {
221 return Ok((
222 ImapResponse::bad(&ctx.tag, format!("Invalid Base64: {}", e)),
223 None,
224 ));
225 }
226 };
227
228 handle_authenticate_step(
230 session,
231 &ctx.tag,
232 ctx.mechanism,
233 std::str::from_utf8(&decoded).unwrap_or(""),
234 auth_backend,
235 )
236 .await
237}
238
239async fn handle_authenticate_step(
241 session: &mut ImapSession,
242 tag: &str,
243 mut mechanism: Box<dyn rusmes_auth::sasl::SaslMechanism>,
244 client_data: &str,
245 auth_backend: &dyn AuthBackend,
246) -> anyhow::Result<(ImapResponse, Option<AuthenticateContext>)> {
247 let auth_backend_ref: &dyn AuthBackend = auth_backend;
248
249 match mechanism
250 .step(client_data.as_bytes(), auth_backend_ref)
251 .await
252 {
253 Ok(rusmes_auth::sasl::SaslStep::Challenge { data }) => {
254 let encoded = BASE64.encode(&data);
256 let ctx = AuthenticateContext {
257 mechanism,
258 state: AuthenticateState::Challenge,
259 tag: tag.to_string(),
260 };
261 Ok((ImapResponse::new(None, "+", encoded), Some(ctx)))
262 }
263 Ok(rusmes_auth::sasl::SaslStep::Continue) => {
264 let ctx = AuthenticateContext {
266 mechanism,
267 state: AuthenticateState::Challenge,
268 tag: tag.to_string(),
269 };
270 Ok((ImapResponse::new(None, "+", ""), Some(ctx)))
271 }
272 Ok(rusmes_auth::sasl::SaslStep::Done { success, username }) => {
273 if success && username.is_some() {
275 session.state = ImapState::Authenticated;
276 session.username = username.clone();
277 let user_str = username
278 .map(|u| u.to_string())
279 .unwrap_or_else(|| "user".to_string());
280 Ok((
281 ImapResponse::ok(tag, format!("{} authenticated", user_str)),
282 None,
283 ))
284 } else {
285 Ok((
286 ImapResponse::no(tag, "[AUTHENTICATIONFAILED] Authentication failed"),
287 None,
288 ))
289 }
290 }
291 Err(e) => Ok((
292 ImapResponse::no(tag, format!("[AUTHENTICATIONFAILED] {}", e)),
293 None,
294 )),
295 }
296}
297
298pub fn parse_authenticate_args(args: &str) -> anyhow::Result<(String, Option<String>)> {
304 let parts: Vec<&str> = args.split_whitespace().collect();
305
306 if parts.is_empty() {
307 return Err(anyhow::anyhow!("Missing mechanism name"));
308 }
309
310 let mechanism = parts[0].to_uppercase();
311 let initial_response = if parts.len() > 1 {
312 if parts[1] == "=" {
314 Some(String::new())
315 } else {
316 Some(parts[1].to_string())
317 }
318 } else {
319 None
320 };
321
322 Ok((mechanism, initial_response))
323}
324
325pub fn create_default_sasl_server(hostname: String) -> SaslServer {
327 use rusmes_auth::sasl::SaslConfig;
328 let config = SaslConfig {
329 enabled_mechanisms: vec![
330 "PLAIN".to_string(),
331 "LOGIN".to_string(),
332 "CRAM-MD5".to_string(),
333 "SCRAM-SHA-256".to_string(),
334 "XOAUTH2".to_string(),
335 ],
336 hostname,
337 };
338 SaslServer::new(config)
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use async_trait::async_trait;
345 use rusmes_auth::sasl::SaslConfig;
346 use rusmes_proto::Username;
347
348 struct MockAuthBackend {
350 valid_users: Vec<(String, String)>,
351 }
352
353 #[async_trait]
354 impl AuthBackend for MockAuthBackend {
355 async fn authenticate(&self, username: &Username, password: &str) -> anyhow::Result<bool> {
356 Ok(self
357 .valid_users
358 .iter()
359 .any(|(u, p)| u == username.as_str() && p == password))
360 }
361
362 async fn verify_identity(&self, username: &Username) -> anyhow::Result<bool> {
363 Ok(self.valid_users.iter().any(|(u, _)| u == username.as_str()))
364 }
365
366 async fn list_users(&self) -> anyhow::Result<Vec<Username>> {
367 Ok(vec![])
368 }
369
370 async fn create_user(&self, _username: &Username, _password: &str) -> anyhow::Result<()> {
371 Ok(())
372 }
373
374 async fn delete_user(&self, _username: &Username) -> anyhow::Result<()> {
375 Ok(())
376 }
377
378 async fn change_password(
379 &self,
380 _username: &Username,
381 _new_password: &str,
382 ) -> anyhow::Result<()> {
383 Ok(())
384 }
385 }
386
387 #[test]
388 fn test_parse_authenticate_args_basic() {
389 let (mechanism, initial_resp) =
390 parse_authenticate_args("PLAIN").expect("PLAIN mechanism parse should succeed");
391 assert_eq!(mechanism, "PLAIN");
392 assert!(initial_resp.is_none());
393 }
394
395 #[test]
396 fn test_parse_authenticate_args_with_initial_response() {
397 let (mechanism, initial_resp) = parse_authenticate_args("PLAIN AHRlc3R1c2VyAHRlc3RwYXNz")
398 .expect("PLAIN with initial response parse should succeed");
399 assert_eq!(mechanism, "PLAIN");
400 assert_eq!(initial_resp, Some("AHRlc3R1c2VyAHRlc3RwYXNz".to_string()));
401 }
402
403 #[test]
404 fn test_parse_authenticate_args_empty_initial_response() {
405 let (mechanism, initial_resp) = parse_authenticate_args("PLAIN =")
406 .expect("PLAIN with empty initial response (=) parse should succeed");
407 assert_eq!(mechanism, "PLAIN");
408 assert_eq!(initial_resp, Some(String::new()));
409 }
410
411 #[test]
412 fn test_parse_authenticate_args_case_insensitive() {
413 let (mechanism, _) =
414 parse_authenticate_args("plain").expect("lowercase plain parse should succeed");
415 assert_eq!(mechanism, "PLAIN");
416
417 let (mechanism, _) =
418 parse_authenticate_args("Cram-Md5").expect("mixed-case Cram-Md5 parse should succeed");
419 assert_eq!(mechanism, "CRAM-MD5");
420 }
421
422 #[test]
423 fn test_parse_authenticate_args_no_mechanism() {
424 let result = parse_authenticate_args("");
425 assert!(result.is_err());
426 }
427
428 #[tokio::test]
429 async fn test_handle_authenticate_plain_with_initial_response() {
430 let backend = MockAuthBackend {
431 valid_users: vec![("testuser".to_string(), "testpass".to_string())],
432 };
433
434 let config = SaslConfig {
435 enabled_mechanisms: vec!["PLAIN".to_string()],
436 hostname: "localhost".to_string(),
437 };
438 let sasl_server = SaslServer::new(config);
439
440 let mut session = ImapSession::new();
441
442 let initial_response = BASE64.encode(b"\0testuser\0testpass");
444
445 let (response, ctx) = handle_authenticate(
446 &mut session,
447 "A001",
448 "PLAIN",
449 Some(&initial_response),
450 &sasl_server,
451 &backend,
452 )
453 .await
454 .expect("PLAIN auth with valid credentials should succeed");
455
456 assert!(ctx.is_none()); assert!(response.format().contains("OK"));
458 assert!(matches!(session.state(), ImapState::Authenticated));
459 }
460
461 #[tokio::test]
462 async fn test_handle_authenticate_plain_wrong_credentials() {
463 let backend = MockAuthBackend {
464 valid_users: vec![("testuser".to_string(), "testpass".to_string())],
465 };
466
467 let config = SaslConfig {
468 enabled_mechanisms: vec!["PLAIN".to_string()],
469 hostname: "localhost".to_string(),
470 };
471 let sasl_server = SaslServer::new(config);
472
473 let mut session = ImapSession::new();
474
475 let initial_response = BASE64.encode(b"\0testuser\0wrongpass");
477
478 let (response, ctx) = handle_authenticate(
479 &mut session,
480 "A001",
481 "PLAIN",
482 Some(&initial_response),
483 &sasl_server,
484 &backend,
485 )
486 .await
487 .expect("PLAIN auth handler should not error even with wrong credentials");
488
489 assert!(ctx.is_none());
490 assert!(response.format().contains("NO"));
491 assert!(response.format().contains("AUTHENTICATIONFAILED"));
492 assert!(matches!(session.state(), ImapState::NotAuthenticated));
493 }
494
495 #[tokio::test]
496 async fn test_handle_authenticate_unsupported_mechanism() {
497 let backend = MockAuthBackend {
498 valid_users: vec![],
499 };
500
501 let config = SaslConfig {
502 enabled_mechanisms: vec!["PLAIN".to_string()],
503 hostname: "localhost".to_string(),
504 };
505 let sasl_server = SaslServer::new(config);
506
507 let mut session = ImapSession::new();
508
509 let (response, ctx) = handle_authenticate(
510 &mut session,
511 "A001",
512 "UNKNOWN",
513 None,
514 &sasl_server,
515 &backend,
516 )
517 .await
518 .expect("auth handler should not error for unsupported mechanism");
519
520 assert!(ctx.is_none());
521 assert!(response.format().contains("NO"));
522 assert!(response.format().contains("not supported"));
523 }
524
525 #[tokio::test]
526 async fn test_handle_authenticate_already_authenticated() {
527 let backend = MockAuthBackend {
528 valid_users: vec![],
529 };
530
531 let config = SaslConfig {
532 enabled_mechanisms: vec!["PLAIN".to_string()],
533 hostname: "localhost".to_string(),
534 };
535 let sasl_server = SaslServer::new(config);
536
537 let mut session = ImapSession::new();
538 session.state = ImapState::Authenticated; let (response, ctx) =
541 handle_authenticate(&mut session, "A001", "PLAIN", None, &sasl_server, &backend)
542 .await
543 .expect("auth handler should not error for already-authenticated session");
544
545 assert!(ctx.is_none());
546 assert!(response.format().contains("BAD"));
547 assert!(response.format().contains("Already authenticated"));
548 }
549
550 #[tokio::test]
551 async fn test_handle_authenticate_login_multi_step() {
552 let backend = MockAuthBackend {
553 valid_users: vec![("testuser".to_string(), "testpass".to_string())],
554 };
555
556 let config = SaslConfig {
557 enabled_mechanisms: vec!["LOGIN".to_string()],
558 hostname: "localhost".to_string(),
559 };
560 let sasl_server = SaslServer::new(config);
561
562 let mut session = ImapSession::new();
563
564 let (response, ctx) =
566 handle_authenticate(&mut session, "A001", "LOGIN", None, &sasl_server, &backend)
567 .await
568 .expect("LOGIN auth initiation should succeed");
569
570 assert!(ctx.is_some());
571 assert!(response.format().contains("+"));
572
573 let ctx = ctx.expect("LOGIN step 1 should return a continuation context");
574
575 let username_b64 = BASE64.encode(b"testuser");
577 let (response, ctx) =
578 handle_authenticate_continue(&mut session, ctx, &username_b64, &backend)
579 .await
580 .expect("LOGIN step 2 (username) should succeed");
581
582 assert!(ctx.is_some());
583 assert!(response.format().contains("+"));
584
585 let ctx = ctx.expect("LOGIN step 2 should return a continuation context for password");
586
587 let password_b64 = BASE64.encode(b"testpass");
589 let (response, ctx) =
590 handle_authenticate_continue(&mut session, ctx, &password_b64, &backend)
591 .await
592 .expect("LOGIN step 3 (password) should succeed");
593
594 assert!(ctx.is_none());
595 assert!(response.format().contains("OK"));
596 assert!(matches!(session.state(), ImapState::Authenticated));
597 }
598
599 #[tokio::test]
600 async fn test_handle_authenticate_cancel() {
601 let backend = MockAuthBackend {
602 valid_users: vec![],
603 };
604
605 let config = SaslConfig {
606 enabled_mechanisms: vec!["LOGIN".to_string()],
607 hostname: "localhost".to_string(),
608 };
609 let sasl_server = SaslServer::new(config);
610
611 let mut session = ImapSession::new();
612
613 let (_, ctx) =
615 handle_authenticate(&mut session, "A001", "LOGIN", None, &sasl_server, &backend)
616 .await
617 .expect("LOGIN auth initiation should succeed");
618
619 let ctx = ctx.expect("LOGIN auth initiation should return a continuation context");
620
621 let (response, ctx) = handle_authenticate_continue(&mut session, ctx, "*", &backend)
623 .await
624 .expect("auth cancellation via * should not error");
625
626 assert!(ctx.is_none());
627 assert!(response.format().contains("BAD"));
628 assert!(response.format().contains("cancelled"));
629 }
630
631 #[test]
632 fn test_create_default_sasl_server() {
633 let server = create_default_sasl_server("localhost".to_string());
634
635 assert!(server.is_mechanism_enabled("PLAIN"));
636 assert!(server.is_mechanism_enabled("LOGIN"));
637 assert!(server.is_mechanism_enabled("CRAM-MD5"));
638 assert!(server.is_mechanism_enabled("SCRAM-SHA-256"));
639 assert!(server.is_mechanism_enabled("XOAUTH2"));
640 }
641}