Skip to main content

rusmes_imap/
authenticate.rs

1//! IMAP AUTHENTICATE command implementation with SASL integration
2//!
3//! This module implements the AUTHENTICATE command as specified in RFC 3501 Section 6.2.2,
4//! integrating the SASL framework from rusmes-auth.
5//!
6//! Supported SASL mechanisms:
7//! - PLAIN (RFC 4616)
8//! - LOGIN (obsolete but widely used)
9//! - CRAM-MD5 (RFC 2195)
10//! - SCRAM-SHA-256 (RFC 5802, RFC 7677)
11//! - XOAUTH2 (RFC 7628)
12//!
13//! # Authentication Flow
14//!
15//! ## Basic Flow (PLAIN, single-step)
16//! ```text
17//! C: A001 AUTHENTICATE PLAIN
18//! S: +
19//! C: <base64-encoded credentials>
20//! S: A001 OK AUTHENTICATE completed
21//! ```
22//!
23//! ## Challenge-Response Flow (CRAM-MD5, SCRAM-SHA-256)
24//! ```text
25//! C: A001 AUTHENTICATE CRAM-MD5
26//! S: + <base64-encoded challenge>
27//! C: <base64-encoded response>
28//! S: A001 OK AUTHENTICATE completed
29//! ```
30//!
31//! ## Initial Response Optimization (RFC 4959)
32//! ```text
33//! C: A001 AUTHENTICATE PLAIN <base64-encoded credentials>
34//! S: A001 OK AUTHENTICATE completed
35//! ```
36
37use 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/// Authentication state for multi-step SASL authentication
43#[derive(Debug)]
44pub enum AuthenticateState {
45    /// Initial state - mechanism selected, waiting for client data
46    Initial,
47    /// Challenge sent, waiting for response
48    Challenge,
49    /// Completed (success or failure)
50    Completed,
51}
52
53/// AUTHENTICATE command context for tracking multi-step authentication
54pub struct AuthenticateContext {
55    /// SASL mechanism instance
56    mechanism: Box<dyn rusmes_auth::sasl::SaslMechanism>,
57    /// Current authentication state
58    #[allow(dead_code)]
59    state: AuthenticateState,
60    /// Tag from original AUTHENTICATE command
61    tag: String,
62}
63
64impl AuthenticateContext {
65    /// Create a new authentication context
66    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    /// Get the tag
75    pub fn tag(&self) -> &str {
76        &self.tag
77    }
78
79    /// Get the mechanism name
80    pub fn mechanism_name(&self) -> &str {
81        self.mechanism.name()
82    }
83}
84
85/// Handle AUTHENTICATE command
86///
87/// # Arguments
88/// * `session` - Current IMAP session
89/// * `tag` - Command tag
90/// * `mechanism_name` - SASL mechanism name (e.g., "PLAIN", "CRAM-MD5")
91/// * `initial_response` - Optional initial response (RFC 4959 SASL-IR)
92/// * `sasl_server` - SASL server for mechanism creation
93/// * `auth_backend` - Authentication backend
94///
95/// # Returns
96/// Returns an IMAP response and optionally an authentication context for multi-step auth
97pub 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    // Must be in NotAuthenticated state
106    if !matches!(session.state(), ImapState::NotAuthenticated) {
107        return Ok((ImapResponse::bad(tag, "Already authenticated"), None));
108    }
109
110    // Check if mechanism is supported
111    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    // Create mechanism instance
125    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    // Handle initial response if provided (SASL-IR, RFC 4959)
136    if let Some(initial_resp) = initial_response {
137        // Decode the base64-encoded initial response
138        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    // No initial response - send continuation or challenge based on mechanism
154    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            // Mechanism needs to send a challenge
159            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            // Mechanism needs more data from client (no challenge)
169            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            // Authentication completed in first step (shouldn't happen without initial response)
178            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
196/// Continue multi-step authentication with client response
197///
198/// # Arguments
199/// * `session` - Current IMAP session
200/// * `ctx` - Authentication context from previous step
201/// * `client_data` - Base64-encoded client response
202/// * `auth_backend` - Authentication backend
203///
204/// # Returns
205/// Returns an IMAP response and optionally an updated authentication context
206pub 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    // Check for cancellation (client sends "*")
213    if client_data.trim() == "*" {
214        return Ok((ImapResponse::bad(&ctx.tag, "AUTHENTICATE cancelled"), None));
215    }
216
217    // Decode client response
218    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    // Process the step
229    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
239/// Handle a single authentication step
240async 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            // Send another challenge
255            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            // Need more data from client
265            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            // Authentication completed
274            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
298/// Parse AUTHENTICATE command
299///
300/// Syntax: AUTHENTICATE `<mechanism>` \[`<initial-response>`\]
301///
302/// Returns (mechanism_name, optional_initial_response)
303pub 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        // Handle "=" as empty initial response (RFC 4959)
313        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
325/// Helper to create a SASL server with default configuration
326pub 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    // Mock auth backend for testing
349    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        // PLAIN credentials: \0testuser\0testpass encoded in base64
443        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()); // Should complete in one step
457        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        // Wrong password
476        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; // Already authenticated
539
540        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        // Step 1: Start authentication
565        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        // Step 2: Send username
576        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        // Step 3: Send password
588        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        // Start authentication
614        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        // Cancel with "*"
622        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}