diff --git a/.cirrus.yml b/.cirrus.yml index db9523ce..f1dc6787 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -6,15 +6,12 @@ freebsd_task: name: FreeBSD matrix: - - name: FreeBSD 13.1 + - name: FreeBSD 13.2 freebsd_instance: - image_family: freebsd-13-1 + image_family: freebsd-13-2 - name: FreeBSD 12.4 freebsd_instance: image_family: freebsd-12-4 - - name: FreeBSD 12.3 - freebsd_instance: - image_family: freebsd-12-3 env: TEST_ALLOW_SEND: 0 diff --git a/auth.go b/auth.go index ad3b698c..e7477a21 100644 --- a/auth.go +++ b/auth.go @@ -19,6 +19,10 @@ const ( // SMTPAuthCramMD5 is the "CRAM-MD5" SASL authentication mechanism as described in RFC 4954 SMTPAuthCramMD5 SMTPAuthType = "CRAM-MD5" + + // SMTPAuthXOAUTH2 is the "XOAUTH2" SASL authentication mechanism. + // https://developers.google.com/gmail/imap/xoauth2-protocol + SMTPAuthXOAUTH2 SMTPAuthType = "XOAUTH2" ) // SMTP Auth related static errors @@ -31,4 +35,7 @@ var ( // ErrCramMD5AuthNotSupported should be used if the target server does not support the "CRAM-MD5" schema ErrCramMD5AuthNotSupported = errors.New("server does not support SMTP AUTH type: CRAM-MD5") + + // ErrXOauth2AuthNotSupported should be used if the target server does not support the "XOAUTH2" schema + ErrXOauth2AuthNotSupported = errors.New("server does not support SMTP AUTH type: XOAUTH2") ) diff --git a/client.go b/client.go index ae54fd49..6b9baf1d 100644 --- a/client.go +++ b/client.go @@ -658,6 +658,11 @@ func (c *Client) auth() error { return ErrCramMD5AuthNotSupported } c.sa = smtp.CRAMMD5Auth(c.user, c.pass) + case SMTPAuthXOAUTH2: + if !strings.Contains(sat, string(SMTPAuthXOAUTH2)) { + return ErrXOauth2AuthNotSupported + } + c.sa = smtp.XOAuth2Auth(c.user, c.pass) default: return fmt.Errorf("unsupported SMTP AUTH type %q", c.satype) } diff --git a/smtp/auth_xoauth2.go b/smtp/auth_xoauth2.go new file mode 100644 index 00000000..dade0486 --- /dev/null +++ b/smtp/auth_xoauth2.go @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright (c) 2023 The go-mail Authors +// +// SPDX-License-Identifier: MIT + +package smtp + +type xoauth2Auth struct { + username, token string +} + +func XOAuth2Auth(username, token string) Auth { + return &xoauth2Auth{username, token} +} + +func (a *xoauth2Auth) Start(_ *ServerInfo) (string, []byte, error) { + return "XOAUTH2", []byte("user=" + a.username + "\x01" + "auth=Bearer " + a.token + "\x01\x01"), nil +} + +func (a *xoauth2Auth) Next(_ []byte, more bool) ([]byte, error) { + if more { + return []byte(""), nil + } + return nil, nil +} diff --git a/smtp/smtp.go b/smtp/smtp.go index 316bcb22..a11aba41 100644 --- a/smtp/smtp.go +++ b/smtp/smtp.go @@ -234,8 +234,10 @@ func (c *Client) Auth(a Auth) error { resp, err = a.Next(msg, code == 334) } if err != nil { - // abort the AUTH - _, _, _ = c.cmd(501, "*") + if mech != "XOAUTH2" { + // abort the AUTH. Not required for XOAUTH2 + _, _, _ = c.cmd(501, "*") + } _ = c.Quit() break } diff --git a/smtp/smtp_test.go b/smtp/smtp_test.go index 00efc152..e09cdf30 100644 --- a/smtp/smtp_test.go +++ b/smtp/smtp_test.go @@ -69,6 +69,13 @@ var authTests = []authTest{ []string{"", "user 287eb355114cf5c471c26a875f1ca4ae"}, []bool{false, false}, }, + { + XOAuth2Auth("username", "token"), + []string{""}, + "XOAUTH2", + []string{"user=username\x01auth=Bearer token\x01\x01", ""}, + []bool{false}, + }, } func TestAuth(t *testing.T) { @@ -193,6 +200,86 @@ func TestAuthLogin(t *testing.T) { } } +func TestXOAuthOK(t *testing.T) { + server := []string{ + "220 Fake server ready ESMTP", + "250-fake.server", + "250-AUTH XOAUTH2", + "250 8BITMIME", + "235 2.7.0 Accepted", + } + var wrote strings.Builder + var fake faker + fake.ReadWriter = struct { + io.Reader + io.Writer + }{ + strings.NewReader(strings.Join(server, "\r\n")), + &wrote, + } + + c, err := NewClient(fake, "fake.host") + if err != nil { + t.Fatalf("NewClient: %v", err) + } + defer c.Close() + + auth := XOAuth2Auth("user", "token") + err = c.Auth(auth) + if err != nil { + t.Fatalf("XOAuth2 error: %v", err) + } + // the Next method returns a nil response. It must not be sent. + // The client request must end with the authentication. + if !strings.HasSuffix(wrote.String(), "AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIHRva2VuAQE=\r\n") { + t.Fatalf("got %q; want AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIHRva2VuAQE=\r\n", wrote.String()) + } +} + +func TestXOAuth2Error(t *testing.T) { + serverResp := []string{ + "220 Fake server ready ESMTP", + "250-fake.server", + "250-AUTH XOAUTH2", + "250 8BITMIME", + "334 eyJzdGF0dXMiOiI0MDAiLCJzY2hlbWVzIjoiQmVhcmVyIiwic2NvcGUiOiJodHRwczovL21haWwuZ29vZ2xlLmNvbS8ifQ==", + "535 5.7.8 Username and Password not accepted", + "221 2.0.0 closing connection", + } + var wrote strings.Builder + var fake faker + fake.ReadWriter = struct { + io.Reader + io.Writer + }{ + strings.NewReader(strings.Join(serverResp, "\r\n")), + &wrote, + } + + c, err := NewClient(fake, "fake.host") + if err != nil { + t.Fatalf("NewClient: %v", err) + } + defer c.Close() + + auth := XOAuth2Auth("user", "token") + err = c.Auth(auth) + if err == nil { + t.Fatal("expected auth error, got nil") + } + client := strings.Split(wrote.String(), "\r\n") + if len(client) != 5 { + t.Fatalf("unexpected number of client requests got %d; want 5", len(client)) + } + if client[1] != "AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIHRva2VuAQE=" { + t.Fatalf("got %q; want AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIHRva2VuAQE=", client[1]) + } + // the Next method returns an empty response. It must be sent + if client[2] != "" { + t.Fatalf("got %q; want empty response", client[2]) + } +} + // Issue 17794: don't send a trailing space on AUTH command when there's no password. func TestClientAuthTrimSpace(t *testing.T) { server := "220 hello world\r\n" +