From 6e4b348ccf935955c21ac7a937a6b90b23c0170b Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Sun, 28 May 2023 14:31:04 +0200 Subject: [PATCH] add Oauth2 support fixes #129 --- auth.go | 6 ++++ client.go | 35 ++++++++++++++++++---- client_test.go | 52 ++++++++++++++++++++++++++++++++ smtp/auth_xoauth2.go | 71 ++++++++++++++++++++++++++++++++++++++++++++ smtp/smtp_test.go | 45 ++++++++++++++++++++++++++++ 5 files changed, 204 insertions(+), 5 deletions(-) create mode 100644 smtp/auth_xoauth2.go diff --git a/auth.go b/auth.go index ad3b698..66754a3 100644 --- a/auth.go +++ b/auth.go @@ -19,6 +19,9 @@ 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 + SMTPAuthXOAUTH2 SMTPAuthType = "XOAUTH2" ) // SMTP Auth related static errors @@ -31,4 +34,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 ae54fd4..0c3225c 100644 --- a/client.go +++ b/client.go @@ -32,6 +32,9 @@ const ( // DefaultTLSMinVersion is the minimum TLS version required for the connection // Nowadays TLS1.2 should be the sane default DefaultTLSMinVersion = tls.VersionTLS12 + + // DefaultXOAuth2Variant is the default XOAuth2 variant + DefaultXOAuth2Variant = smtp.XOAuth2VariantGoogle ) // DSNMailReturnOption is a type to define which MAIL RET option is used when a DSN @@ -135,6 +138,9 @@ type Client struct { // user is the SMTP AUTH username user string + // xoauthVariant sets the client to use the provided XOAuth2Variant variant + xoauthVariant smtp.XOAuth2Variant + // dl enables the debug logging on the SMTP client dl bool @@ -193,11 +199,12 @@ var ( // NewClient returns a new Session client object func NewClient(h string, o ...Option) (*Client, error) { c := &Client{ - cto: DefaultTimeout, - host: h, - port: DefaultPort, - tlsconfig: &tls.Config{ServerName: h, MinVersion: DefaultTLSMinVersion}, - tlspolicy: DefaultTLSPolicy, + cto: DefaultTimeout, + host: h, + port: DefaultPort, + tlsconfig: &tls.Config{ServerName: h, MinVersion: DefaultTLSMinVersion}, + tlspolicy: DefaultTLSPolicy, + xoauthVariant: DefaultXOAuth2Variant, } // Set default HELO/EHLO hostname @@ -415,6 +422,14 @@ func WithDialContextFunc(f DialContextFunc) Option { } } +// WithXOAuth2Variant tells the client to use the provided XOAuth2 variant +func WithXOAuth2Variant(v smtp.XOAuth2Variant) Option { + return func(c *Client) error { + c.xoauthVariant = v + return nil + } +} + // TLSPolicy returns the currently set TLSPolicy as string func (c *Client) TLSPolicy() string { return c.tlspolicy.String() @@ -430,6 +445,11 @@ func (c *Client) SetTLSPolicy(p TLSPolicy) { c.tlspolicy = p } +// SetXOAuth2Variant overrides the current XOAuth2Variant with the given XOAuth2Variant value +func (c *Client) SetXOAuth2Variant(v smtp.XOAuth2Variant) { + c.xoauthVariant = v +} + // SetSSL tells the Client wether to use SSL or not func (c *Client) SetSSL(s bool) { c.ssl = s @@ -658,6 +678,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, c.xoauthVariant) default: return fmt.Errorf("unsupported SMTP AUTH type %q", c.satype) } diff --git a/client_test.go b/client_test.go index 8bad1e0..846924b 100644 --- a/client_test.go +++ b/client_test.go @@ -101,6 +101,7 @@ func TestNewClientWithOptions(t *testing.T) { }, {"WithUsername()", WithUsername("test"), false}, {"WithPassword()", WithPassword("test"), false}, + {"WithXOAuth2Variant()", WithXOAuth2Variant(smtp.XOAuth2VariantMicrosoft), false}, {"WithDSN()", WithDSN(), false}, {"WithDSNMailReturnType()", WithDSNMailReturnType(DSNMailReturnFull), false}, {"WithDSNMailReturnType() wrong option", WithDSNMailReturnType("FAIL"), true}, @@ -390,6 +391,57 @@ func TestSetSMTPAuth(t *testing.T) { } } +// TestWithXOAuth2Variant tests the WithXOAuth2Variant() option for the NewClient() method +func TestWithXOAuth2Variant(t *testing.T) { + tests := []struct { + name string + value smtp.XOAuth2Variant + want string + sf bool + }{ + {"Variant: Google", smtp.XOAuth2VariantGoogle, smtp.XOAuth2VariantGoogle.String(), false}, + {"Variant: Microsoft", smtp.XOAuth2VariantMicrosoft, smtp.XOAuth2VariantMicrosoft.String(), false}, + {"Variant: Invalid", -1, "Unknown XOAuth2 variant", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c, err := NewClient(DefaultHost, WithXOAuth2Variant(tt.value)) + if err != nil && !tt.sf { + t.Errorf("failed to create new client: %s", err) + return + } + if c.xoauthVariant.String() != tt.want { + t.Errorf("failed to set XOAuth2 variant. Want: %s, got: %s", tt.want, c.xoauthVariant) + } + }) + } +} + +// TestSetXOAuth2Variant tests the SetXOAuth2Variant method for the Client object +func TestSetXOAuth2Variant(t *testing.T) { + tests := []struct { + name string + value smtp.XOAuth2Variant + want smtp.XOAuth2Variant + }{ + {"Google", smtp.XOAuth2VariantGoogle, smtp.XOAuth2VariantGoogle}, + {"Google", smtp.XOAuth2VariantMicrosoft, smtp.XOAuth2VariantMicrosoft}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c, err := NewClient(DefaultHost) + if err != nil { + t.Errorf("failed to create new client: %s", err) + return + } + c.SetXOAuth2Variant(tt.value) + if c.xoauthVariant != tt.want { + t.Errorf("failed to set XOAuthVariant. Expected %s, got: %s", tt.want, c.xoauthVariant) + } + }) + } +} + // TestWithDSN tests the WithDSN method for the Client object func TestWithDSN(t *testing.T) { c, err := NewClient(DefaultHost, WithDSN()) diff --git a/smtp/auth_xoauth2.go b/smtp/auth_xoauth2.go new file mode 100644 index 0000000..f0c12bd --- /dev/null +++ b/smtp/auth_xoauth2.go @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: Copyright (c) 2022-2023 The go-mail Authors +// +// SPDX-License-Identifier: MIT + +package smtp + +import "fmt" + +// XOAuth2Variant describes a int alias for the different XOAuth2 variants we support +type XOAuth2Variant int + +// Supported XOAuth2 variants +const ( + // XOAuth2VariantGoogle is the Google variant for "XOAUTH2" SASL authentication mechanism. + // https://developers.google.com/gmail/imap/xoauth2-protocol + XOAuth2VariantGoogle XOAuth2Variant = iota + + // XOAuth2VariantMicrosoft is the Microsoft variant for "XOAUTH2" SASL authentication mechanism. + // https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth + XOAuth2VariantMicrosoft +) + +// String is a standard method to convert a XOAuth2Variant into a printable format +func (v XOAuth2Variant) String() string { + switch v { + case XOAuth2VariantGoogle: + return "Google" + case XOAuth2VariantMicrosoft: + return "Microsoft" + default: + return "Unknown XOAuth2 variant" + } +} + +type xoauth2Auth struct { + username, token string + variant XOAuth2Variant +} + +func XOAuth2Auth(username, token string, variant XOAuth2Variant) Auth { + return &xoauth2Auth{username, token, variant} +} + +func (a *xoauth2Auth) getToken() []byte { + return []byte("user=" + a.username + "\x01" + "auth=Bearer " + a.token + "\x01\x01") +} + +func (a *xoauth2Auth) Start(_ *ServerInfo) (string, []byte, error) { + switch a.variant { + case XOAuth2VariantGoogle: + return "XOAUTH2", a.getToken(), nil + case XOAuth2VariantMicrosoft: + return "XOAUTH2", nil, nil + default: + return "", nil, fmt.Errorf("unsupported XOAuth2 variant %d", a.variant) + } +} + +func (a *xoauth2Auth) Next(_ []byte, more bool) ([]byte, error) { + if more { + switch a.variant { + case XOAuth2VariantGoogle: + return []byte(""), nil + case XOAuth2VariantMicrosoft: + return a.getToken(), nil + default: + return nil, fmt.Errorf("unsupported XOAuth2 variant %d", a.variant) + } + } + return nil, nil +} diff --git a/smtp/smtp_test.go b/smtp/smtp_test.go index 00efc15..aaea98f 100644 --- a/smtp/smtp_test.go +++ b/smtp/smtp_test.go @@ -69,6 +69,20 @@ var authTests = []authTest{ []string{"", "user 287eb355114cf5c471c26a875f1ca4ae"}, []bool{false, false}, }, + { + XOAuth2Auth("username", "token", XOAuth2VariantGoogle), + []string{""}, + "XOAUTH2", + []string{"user=username\x01auth=Bearer token\x01\x01", ""}, + []bool{false}, + }, + { + XOAuth2Auth("username", "token", XOAuth2VariantMicrosoft), + []string{""}, + "XOAUTH2", + []string{"", "user=username\x01auth=Bearer token\x01\x01"}, + []bool{false}, + }, } func TestAuth(t *testing.T) { @@ -193,6 +207,37 @@ func TestAuthLogin(t *testing.T) { } } +func TestXOAuth2VariantToString(t *testing.T) { + v := XOAuth2VariantGoogle + if val := v.String(); val != "Google" { + t.Errorf("got %q; want Google", val) + } + v = XOAuth2VariantMicrosoft + if val := v.String(); val != "Microsoft" { + t.Errorf("got %q; want Microsoft", val) + } + v = XOAuth2Variant(-1) + if val := v.String(); val != "Unknown XOAuth2 variant" { + t.Errorf("got %q; want Unknown XOAuth2 variant", val) + } +} + +func TestXOAuth2UnsupportendVariant(t *testing.T) { + serverInfo := &ServerInfo{ + Name: "servename", + Auth: []string{"XOAUTH2"}, + } + auth := &xoauth2Auth{"username", "token", XOAuth2Variant(-1)} + _, _, err := auth.Start(serverInfo) + if err == nil { + t.Error("expected error for auth.Start() using an unsupported variant") + } + _, err = auth.Next(nil, true) + if err == nil { + t.Error("expected error for auth.Next() using an unsupported variant") + } +} + // 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" +