Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 34 additions & 19 deletions internal/server/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,13 @@ func buildCapabilities(deps ServerDeps, isTLS bool) []string {
capabilities := []string{"IMAP4rev1"}

if isTLS {
capabilities = append(capabilities, "AUTH=PLAIN", "LOGIN")
capabilities = append(capabilities, "AUTH=PLAIN", "LOGIN", "SASL-IR")
} else {
capabilities = append(capabilities, "STARTTLS", "LOGINDISABLED")
}

if oauthSASLReady(deps) {
capabilities = append(capabilities, "AUTH=OAUTHBEARER", "AUTH=XOAUTH2", "SASL-IR")
capabilities = append(capabilities, "AUTH=OAUTHBEARER", "AUTH=XOAUTH2")
}

capabilities = append(capabilities,
Expand Down Expand Up @@ -149,31 +149,46 @@ func HandleAuthenticate(deps ServerDeps, conn net.Conn, tag string, parts []stri
return
}

// Send continuation request
deps.SendResponse(conn, "+ ")
var authData string
if len(parts) >= 4 {
// Client sent the initial response inline, e.g. "AUTHENTICATE PLAIN <base64>".
// No continuation round-trip.
ir := strings.TrimSpace(parts[3])
if ir == "=" {
// "=" denotes an explicit zero-length initial response, distinct from
// omitting the argument entirely.
authData = ""
} else {
authData = ir
}
} else {
// Send continuation request
deps.SendResponse(conn, "+ ")

// Read the authentication data
buf := make([]byte, 8192)
_ = conn.SetReadDeadline(time.Now().Add(30 * time.Second))
n, err := conn.Read(buf)
if err != nil {
deps.SendResponse(conn, fmt.Sprintf("%s NO Authentication failed", tag))
return
}
// Read the authentication data
buf := make([]byte, 8192)
_ = conn.SetReadDeadline(time.Now().Add(30 * time.Second))
n, err := conn.Read(buf)
if err != nil {
deps.SendResponse(conn, fmt.Sprintf("%s NO Authentication failed", tag))
return
}

authData := strings.TrimSpace(string(buf[:n]))
authData = strings.TrimSpace(string(buf[:n]))

// Client may cancel authentication with a single "*"
if authData == "*" {
deps.SendResponse(conn, fmt.Sprintf("%s BAD Authentication exchange cancelled", tag))
return
// Client may cancel authentication with a single "*". This only applies to
// a reply to the "+ " challenge above -- an inline value has no challenge
// to cancel.
if authData == "*" {
deps.SendResponse(conn, fmt.Sprintf("%s BAD Authentication exchange cancelled", tag))
return
}
}

log.Printf("AUTHENTICATE PLAIN: received %d bytes of auth data", len(authData))

// Decode base64 as per SASL challenge/response (PLAIN uses base64 here)
var decoded []byte
decoded, err = base64.StdEncoding.DecodeString(authData)
decoded, err := base64.StdEncoding.DecodeString(authData)
if err != nil {
log.Printf("AUTHENTICATE PLAIN: base64 decode failed: %v, treating as plain", err)
// If decode fails, fall back to treating the input as plain (some test-clients may do this)
Expand Down
43 changes: 43 additions & 0 deletions internal/server/auth/authenticate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,49 @@ func TestAuthenticatePlainEmptyCredentialsResponse(t *testing.T) {
}
}

// TestAuthenticatePlainSASLIRInline covers PLAIN with the initial response sent inline
// instead of via a "+" continuation. No data is queued on the mock conn's read side, so a
// regression back to the two-step flow would hang/timeout instead of failing cleanly.
func TestAuthenticatePlainSASLIRInline(t *testing.T) {
s, cleanup := server.SetupTestServer(t)
defer cleanup()

conn := server.NewMockTLSConn()
state := &models.ClientState{Authenticated: false}
inline := base64.StdEncoding.EncodeToString([]byte("\x00testuser\x00testpass"))

s.HandleAuthenticate(conn, "A006", []string{"A006", "AUTHENTICATE", "PLAIN", inline}, state)

response := conn.GetWrittenData()
if strings.Contains(response, "+ ") {
t.Errorf("Inline SASL-IR response should not send a continuation request, got: %s", response)
}
if !strings.Contains(response, "A006") {
t.Errorf("Expected tagged response for A006, got: %s", response)
}
}

// TestAuthenticatePlainSASLIRExplicitEmpty covers the "=" inline argument, which means an
// explicit empty response rather than "no response given" -- it must not trigger a "+ "
// continuation, and since PLAIN needs real credentials it should fail auth.
func TestAuthenticatePlainSASLIRExplicitEmpty(t *testing.T) {
s, cleanup := server.SetupTestServer(t)
defer cleanup()

conn := server.NewMockTLSConn()
state := &models.ClientState{Authenticated: false}

s.HandleAuthenticate(conn, "A007", []string{"A007", "AUTHENTICATE", "PLAIN", "="}, state)

response := conn.GetWrittenData()
if strings.Contains(response, "+ ") {
t.Errorf("Explicit empty SASL-IR (\"=\") should not send a continuation request, got: %s", response)
}
if !strings.Contains(response, "A007 NO") {
t.Errorf("Expected auth failure for empty credentials, got: %s", response)
}
}

// TestAuthenticatePlainTwoPartFallback tests the non-standard username\x00password fallback path.
func TestAuthenticatePlainTwoPartFallback(t *testing.T) {
s, cleanup := server.SetupTestServer(t)
Expand Down
6 changes: 4 additions & 2 deletions internal/server/auth/capability_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ func TestCapabilityCommand_RFCCompliance(t *testing.T) {
forbidCaps: []string{
"AUTH=PLAIN",
"LOGIN",
"SASL-IR",
},
},
{
Expand All @@ -58,6 +59,7 @@ func TestCapabilityCommand_RFCCompliance(t *testing.T) {
"IMAP4rev1",
"AUTH=PLAIN",
"LOGIN",
"SASL-IR",
"UIDPLUS",
"IDLE",
"NAMESPACE",
Expand Down Expand Up @@ -96,9 +98,9 @@ func TestCapabilityCommand_RFCCompliance(t *testing.T) {
capLine := lines[0]

if oauthReady {
tt.expectCaps = append(tt.expectCaps, "AUTH=OAUTHBEARER", "AUTH=XOAUTH2", "SASL-IR")
tt.expectCaps = append(tt.expectCaps, "AUTH=OAUTHBEARER", "AUTH=XOAUTH2")
} else {
tt.forbidCaps = append(tt.forbidCaps, "AUTH=OAUTHBEARER", "AUTH=XOAUTH2", "SASL-IR")
tt.forbidCaps = append(tt.forbidCaps, "AUTH=OAUTHBEARER", "AUTH=XOAUTH2")
}

// Check required capabilities using exact token matching
Expand Down
4 changes: 2 additions & 2 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,13 @@ func (s *IMAPServer) greetingCapabilities(isTLS bool) string {
capabilities := []string{"IMAP4rev1"}

if isTLS {
capabilities = append(capabilities, "AUTH=PLAIN", "LOGIN")
capabilities = append(capabilities, "AUTH=PLAIN", "LOGIN", "SASL-IR")
} else {
capabilities = append(capabilities, "STARTTLS", "LOGINDISABLED")
}

if s.oauthSASLReady() {
capabilities = append(capabilities, "AUTH=OAUTHBEARER", "AUTH=XOAUTH2", "SASL-IR")
capabilities = append(capabilities, "AUTH=OAUTHBEARER", "AUTH=XOAUTH2")
}

capabilities = append(capabilities, "UIDPLUS", "IDLE", "LITERAL+")
Expand Down
37 changes: 37 additions & 0 deletions test/integration/server/imap_integration_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package server_test

import (
"encoding/base64"
"raven/internal/db"
"strings"
"testing"
Expand Down Expand Up @@ -62,6 +63,42 @@ func TestIMAPServerToClient_Login(t *testing.T) {
_ = client.Logout()
}

// TestIMAPServerToClient_AuthenticatePlainInlineIR tests AUTHENTICATE PLAIN with the
// initial response sent inline on the command line, instead of via a "+ " continuation.
func TestIMAPServerToClient_AuthenticatePlainInlineIR(t *testing.T) {
dbManager := helpers.SetupTestDatabase(t)
defer helpers.TeardownTestDatabase(t, dbManager)

_ = helpers.CreateTestUser(t, dbManager.DBManager, "alice@example.com")

imapServer := helpers.StartTestIMAPServer(t, dbManager.DBManager)
defer imapServer.Stop(t)

client := helpers.ConnectIMAP(t, imapServer.Address)
defer func() { _ = client.Close() }()

creds := "\x00alice@example.com\x00password"
inline := base64.StdEncoding.EncodeToString([]byte(creds))

responses, err := client.SendCommand("AUTHENTICATE PLAIN " + inline)
if err != nil {
t.Fatalf("AUTHENTICATE PLAIN failed: %v", err)
}

for _, line := range responses {
if strings.HasPrefix(line, "+ ") {
t.Errorf("Inline initial response should not trigger a continuation prompt, got: %s", line)
}
}

last := responses[len(responses)-1]
if !strings.Contains(last, "OK") {
t.Errorf("Expected OK response, got: %s", last)
}

_ = client.Logout()
}

// TestIMAPServerToClient_ListMailboxes tests IMAP LIST command
func TestIMAPServerToClient_ListMailboxes(t *testing.T) {
// Setup database
Expand Down
Loading