diff --git a/config/config.go b/config/config.go index 890592ca90..c9314b5431 100644 --- a/config/config.go +++ b/config/config.go @@ -412,6 +412,9 @@ func (c *Config) UnmarshalYAML(unmarshal func(interface{}) error) error { ec.AuthPassword = c.Global.SMTPAuthPassword ec.AuthPasswordFile = c.Global.SMTPAuthPasswordFile } + if ec.AuthXOAuth2 == nil { + ec.AuthXOAuth2 = c.Global.SMTPAuthXOAuth2 + } if ec.AuthSecret == "" { ec.AuthSecret = c.Global.SMTPAuthSecret } @@ -820,6 +823,7 @@ type GlobalConfig struct { SMTPAuthPasswordFile string `yaml:"smtp_auth_password_file,omitempty" json:"smtp_auth_password_file,omitempty"` SMTPAuthSecret Secret `yaml:"smtp_auth_secret,omitempty" json:"smtp_auth_secret,omitempty"` SMTPAuthIdentity string `yaml:"smtp_auth_identity,omitempty" json:"smtp_auth_identity,omitempty"` + SMTPAuthXOAuth2 *commoncfg.OAuth2 `yaml:"smtp_auth_xoauth2,omitempty" json:"smtp_auth_xoauth2,omitempty"` SMTPRequireTLS bool `yaml:"smtp_require_tls" json:"smtp_require_tls,omitempty"` SMTPTLSConfig *commoncfg.TLSConfig `yaml:"smtp_tls_config,omitempty" json:"smtp_tls_config,omitempty"` SlackAPIURL *SecretURL `yaml:"slack_api_url,omitempty" json:"slack_api_url,omitempty"` diff --git a/config/notifiers.go b/config/notifiers.go index fe28ca05c4..7dcee1c1f9 100644 --- a/config/notifiers.go +++ b/config/notifiers.go @@ -291,6 +291,7 @@ type EmailConfig struct { AuthPasswordFile string `yaml:"auth_password_file,omitempty" json:"auth_password_file,omitempty"` AuthSecret Secret `yaml:"auth_secret,omitempty" json:"auth_secret,omitempty"` AuthIdentity string `yaml:"auth_identity,omitempty" json:"auth_identity,omitempty"` + AuthXOAuth2 *commoncfg.OAuth2 `yaml:"auth_xoauth2,omitempty" json:"auth_xoauth2,omitempty"` Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"` HTML string `yaml:"html,omitempty" json:"html,omitempty"` Text string `yaml:"text,omitempty" json:"text,omitempty"` diff --git a/docs/configuration.md b/docs/configuration.md index 89403ca8f9..5b077fa2bf 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -91,6 +91,8 @@ global: [ smtp_auth_identity: ] # SMTP Auth using CRAM-MD5. [ smtp_auth_secret: ] + # SMTP Auth using SASL-XOAUTH2. + [ smtp_auth_xoauth2: { } ] # The default SMTP TLS requirement. # Note that Go does not support unencrypted connections to remote SMTP endpoints. [ smtp_require_tls: | default = true ] @@ -920,6 +922,7 @@ to: [ auth_username: | default = global.smtp_auth_username ] [ auth_password: | default = global.smtp_auth_password ] [ auth_password_file: | default = global.smtp_auth_password_file ] +[ auth_xoauth2: { | default = global.smtp_auth_xoauth2 } ] [ auth_secret: | default = global.smtp_auth_secret ] [ auth_identity: | default = global.smtp_auth_identity ] diff --git a/go.mod b/go.mod index 4fbacff71e..32e5216324 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/cenkalti/backoff/v4 v4.3.0 github.com/cespare/xxhash/v2 v2.3.0 github.com/coder/quartz v0.1.2 + github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 github.com/emersion/go-smtp v0.21.3 github.com/go-openapi/analysis v0.23.0 github.com/go-openapi/errors v0.22.0 @@ -44,6 +45,7 @@ require ( go.uber.org/automaxprocs v1.6.0 golang.org/x/mod v0.20.0 golang.org/x/net v0.30.0 + golang.org/x/oauth2 v0.23.0 golang.org/x/text v0.19.0 golang.org/x/tools v0.24.0 gopkg.in/telebot.v3 v3.3.8 @@ -59,7 +61,6 @@ require ( github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/docker/go-units v0.5.0 // indirect - github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect @@ -99,7 +100,6 @@ require ( go.opentelemetry.io/otel/metric v1.24.0 // indirect go.opentelemetry.io/otel/trace v1.24.0 // indirect golang.org/x/crypto v0.28.0 // indirect - golang.org/x/oauth2 v0.23.0 // indirect golang.org/x/sync v0.8.0 // indirect golang.org/x/sys v0.26.0 // indirect google.golang.org/protobuf v1.34.2 // indirect diff --git a/notify/email/email.go b/notify/email/email.go index 7bd910ca6b..9ec6068f2b 100644 --- a/notify/email/email.go +++ b/notify/email/email.go @@ -17,6 +17,7 @@ import ( "bytes" "context" "crypto/tls" + "encoding/base64" "errors" "fmt" "log/slog" @@ -28,12 +29,15 @@ import ( "net/mail" "net/smtp" "net/textproto" + "net/url" "os" "strings" "sync" "time" commoncfg "github.com/prometheus/common/config" + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" @@ -82,6 +86,12 @@ func (n *Email) auth(mechs string) (smtp.Auth, error) { err := &types.MultiError{} for _, mech := range strings.Split(mechs, " ") { switch mech { + case "XOAUTH2": + if n.conf.AuthXOAuth2 == nil { + err.Add(errors.New("missing OAuth2 configuration")) + continue + } + return XOAuth2Auth(n.conf.AuthUsername, n.conf.AuthXOAuth2) case "CRAM-MD5": secret := string(n.conf.AuthSecret) if secret == "" { @@ -375,6 +385,76 @@ func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { return nil, nil } +type xOAuth2Auth struct { + smtpFrom string + ts oauth2.TokenSource +} + +func XOAuth2Auth(authUsername string, cfg *commoncfg.OAuth2) (smtp.Auth, error) { + if cfg == nil { + return nil, errors.New("missing OAuth2 configuration") + } + + var clientSecret string + + switch { + case cfg.ClientSecret != "": + clientSecret = string(cfg.ClientSecret) + case cfg.ClientSecretFile != "": + fileBytes, err := os.ReadFile(cfg.ClientSecretFile) + if err != nil { + return nil, fmt.Errorf("unable to read file %s: %w", cfg.ClientSecretFile, err) + } + + clientSecret = strings.TrimSpace(string(fileBytes)) + default: + return nil, errors.New("no client secret provided") + } + + oauth2cfg := &clientcredentials.Config{ + ClientID: cfg.ClientID, + ClientSecret: clientSecret, + TokenURL: cfg.TokenURL, + Scopes: cfg.Scopes, + EndpointParams: mapToValues(cfg.EndpointParams), + } + + ts := oauth2cfg.TokenSource(context.Background()) + if _, err := ts.Token(); err != nil { + return nil, fmt.Errorf("unable to get token: %w", err) + } + + return &xOAuth2Auth{authUsername, ts}, nil +} + +// Start implements the [smtp.Auth] interface. +func (*xOAuth2Auth) Start(_ *smtp.ServerInfo) (string, []byte, error) { + return "XOAUTH2", []byte{}, nil +} + +// Next implements the [smtp.Auth] interface. +func (x *xOAuth2Auth) Next(_ []byte, more bool) ([]byte, error) { + if more { + accessToken, err := x.ts.Token() + if err != nil { + return nil, fmt.Errorf("unable to get token: %w", err) + } + + // Generates an unencoded XOAuth2 string of the form + // "user=" {User} "^Aauth=Bearer " {Access Token} "^A^A" + // as defined at https://developers.google.com/google-apps/gmail/xoauth2_protocol#the_sasl_xoauth2_mechanism. + // as well as https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth#sasl-xoauth2 + saslOAuth2String := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", x.smtpFrom, accessToken.AccessToken) + + saslOAuth2Encoded := make([]byte, base64.RawStdEncoding.EncodedLen(len(saslOAuth2String))) + base64.RawStdEncoding.Encode(saslOAuth2Encoded, []byte(saslOAuth2String)) + + return saslOAuth2Encoded, nil + } + + return nil, nil +} + func (n *Email) getPassword() (string, error) { if len(n.conf.AuthPasswordFile) > 0 { content, err := os.ReadFile(n.conf.AuthPasswordFile) @@ -385,3 +465,12 @@ func (n *Email) getPassword() (string, error) { } return string(n.conf.AuthPassword), nil } + +func mapToValues(m map[string]string) url.Values { + v := url.Values{} + for name, value := range m { + v.Set(name, value) + } + + return v +} diff --git a/notify/email/email_oauth2_test.go b/notify/email/email_oauth2_test.go new file mode 100644 index 0000000000..bd80fdef0b --- /dev/null +++ b/notify/email/email_oauth2_test.go @@ -0,0 +1,320 @@ +// Copyright 2019 Prometheus Team +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Some tests require a working OAUTH2 smtp server. +// At the time of writing, the only available server are Google's and Microsoft's. +// Follow the instructions on the respective pages to set up the client configuration: +// * https://learn.microsoft.com/de-de/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth +// * https://developers.google.com/gmail/imap/xoauth2-protocol +// +// To run the tests locally, run: +// $ EMAIL_AUTH_XOAUTH2_CONFIG=testdata/auth_xoauth2.yml make +package email + +import ( + "context" + "encoding/base64" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "strconv" + "testing" + "time" + + "github.com/emersion/go-sasl" + "github.com/emersion/go-smtp" + commoncfg "github.com/prometheus/common/config" + "github.com/prometheus/common/promslog" + + // nolint:depguard // require cannot be called outside the main goroutine: https://pkg.go.dev/testing#T.FailNow + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/prometheus/alertmanager/config" +) + +const ( + emailAuthXOAuth2ConfigVar = "EMAIL_AUTH_XOAUTH2_CONFIG" + + TestBearerUsername = "fxcp" + TestBearerToken = "VkIvciKi9ijpiKNWrQmYCJrzgd9QYCMB" +) + +func TestEmail_OAuth2(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + t.Cleanup(cancel) + + // Setup mock SMTP server which will reject at the DATA stage. + srv, l, err := mockSMTPServer(t, &xOAuth2Backend{}) + require.NoError(t, err) + t.Cleanup(func() { + // We expect that the server has already been closed in the test. + require.ErrorIs(t, srv.Shutdown(ctx), smtp.ErrServerClosed) + }) + + done := make(chan any, 1) + go func() { + // nolint:testifylint // require cannot be called outside the main goroutine: https://pkg.go.dev/testing#T.FailNow + assert.NoError(t, srv.Serve(l)) + close(done) + }() + + oidcServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "application/json") + fmt.Fprintf(w, `{"access_token":"%s","token_type":"Bearer","expires_in":3600}`, TestBearerToken) + })) + + // Wait for mock SMTP server to become ready. + require.Eventuallyf(t, func() bool { + c, err := smtp.Dial(srv.Addr) + if err != nil { + t.Logf("dial failed to %q: %s", srv.Addr, err) + return false + } + + // Ping. + if err = c.Noop(); err != nil { + t.Logf("ping failed to %q: %s", srv.Addr, err) + return false + } + + // Ensure we close the connection to not prevent server from shutting down cleanly. + if err = c.Close(); err != nil { + t.Logf("close failed to %q: %s", srv.Addr, err) + return false + } + + return true + }, time.Second*10, time.Millisecond*100, "mock SMTP server failed to start") + + // Use mock SMTP server and prepare alert to be sent. + require.IsType(t, &net.TCPAddr{}, l.Addr()) + addr := l.Addr().(*net.TCPAddr) + cfg := &config.EmailConfig{ + Smarthost: config.HostPort{Host: addr.IP.String(), Port: strconv.Itoa(addr.Port)}, + Hello: "localhost", + Headers: make(map[string]string), + From: "alertmanager@system", + To: "sre@company", + AuthUsername: TestBearerUsername, + AuthXOAuth2: &commoncfg.OAuth2{ + ClientID: "client_id", + ClientSecret: "client_secret", + TokenURL: oidcServer.URL, + Scopes: []string{"email"}, + }, + } + + tmpl, firingAlert, err := prepare(cfg) + require.NoError(t, err) + + e := New(cfg, tmpl, promslog.NewNopLogger()) + + // Send the alert to mock SMTP server. + retry, err := e.Notify(context.Background(), firingAlert) + require.ErrorContains(t, err, "501 5.5.4 Rejected!") + require.True(t, retry) + require.NoError(t, srv.Shutdown(ctx)) + + require.Eventuallyf(t, func() bool { + <-done + return true + }, time.Second*10, time.Millisecond*100, "mock SMTP server goroutine failed to close in time") +} + +// xOAuth2Backend will reject submission at the DATA stage. +type xOAuth2Backend struct{} + +func (b *xOAuth2Backend) NewSession(c *smtp.Conn) (smtp.Session, error) { + return &mockSMTPxOAuth2Session{ + conn: c, + backend: b, + }, nil +} + +type mockSMTPxOAuth2Session struct { + conn *smtp.Conn + backend smtp.Backend +} + +func (s *mockSMTPxOAuth2Session) AuthMechanisms() []string { + return []string{sasl.Plain, sasl.Login, "XOAUTH2"} +} + +func (s *mockSMTPxOAuth2Session) Auth(string) (sasl.Server, error) { + return &xOAuth2BackendAuth{}, nil +} + +func (s *mockSMTPxOAuth2Session) Mail(string, *smtp.MailOptions) error { + return nil +} + +func (s *mockSMTPxOAuth2Session) Rcpt(string, *smtp.RcptOptions) error { + return nil +} + +func (s *mockSMTPxOAuth2Session) Data(io.Reader) error { + return &smtp.SMTPError{Code: 501, EnhancedCode: smtp.EnhancedCode{5, 5, 4}, Message: "Rejected!"} +} + +func (*mockSMTPxOAuth2Session) Reset() {} + +func (*mockSMTPxOAuth2Session) Logout() error { return nil } + +type xOAuth2BackendAuth struct{} + +func (*xOAuth2BackendAuth) Next(response []byte) ([]byte, bool, error) { + // Generate empty challenge. + if response == nil { + return []byte{}, false, nil + } + + token := make([]byte, base64.RawStdEncoding.DecodedLen(len(response))) + + _, err := base64.RawStdEncoding.Decode(token, response) + if err != nil { + return nil, true, err + } + + expectedToken := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", TestBearerUsername, TestBearerToken) + if expectedToken == string(token) { + return nil, true, nil + } + + return nil, true, fmt.Errorf("unexpected token: %s, expected: %s", token, expectedToken) +} + +// TestEmailNotifyWithXOAuth2Authentication sends emails to an instance of MailDev +// configured with authentication. +func TestEmailNotifyWithXOAuth2Authentication(t *testing.T) { + cfgFile := os.Getenv(emailAuthXOAuth2ConfigVar) + if len(cfgFile) == 0 { + t.Skipf("%s not set", emailAuthXOAuth2ConfigVar) + } + + c, err := loadEmailTestConfiguration(cfgFile) + if err != nil { + t.Fatal(err) + } + + fileWithCorrectClientSecret, err := os.CreateTemp("", "client-secret-correct") + require.NoError(t, err, "creating temp file failed") + _, err = fileWithCorrectClientSecret.WriteString(string(c.XOAuth2.ClientSecret)) + require.NoError(t, err, "writing to temp file failed") + + fileWithIncorrectClientSecret, err := os.CreateTemp("", "client-secret-incorrect") + require.NoError(t, err, "creating temp file failed") + _, err = fileWithIncorrectClientSecret.WriteString(string(c.XOAuth2.ClientSecret) + "wrong") + require.NoError(t, err, "writing to temp file failed") + + for _, tc := range []struct { + title string + updateCfg func(*config.EmailConfig) + + errMsg string + retry bool + }{ + { + title: "email with authentication", + updateCfg: func(cfg *config.EmailConfig) { + cfg.AuthUsername = c.Username + cfg.AuthXOAuth2 = c.XOAuth2 + }, + }, + { + title: "email with authentication (password from file)", + updateCfg: func(cfg *config.EmailConfig) { + cfg.AuthUsername = c.Username + cfg.AuthXOAuth2 = c.XOAuth2 + cfg.AuthXOAuth2.ClientSecret = "" + cfg.AuthXOAuth2.ClientSecretFile = fileWithCorrectClientSecret.Name() + }, + }, + { + title: "wrong credentials", + updateCfg: func(cfg *config.EmailConfig) { + cfg.AuthUsername = c.Username + cfg.AuthXOAuth2 = c.XOAuth2 + cfg.AuthXOAuth2.ClientSecret = cfg.AuthXOAuth2.ClientSecret + "wrong" + }, + + errMsg: "Invalid username or password", + retry: true, + }, + { + title: "wrong credentials (password from file)", + updateCfg: func(cfg *config.EmailConfig) { + cfg.AuthUsername = c.Username + cfg.AuthXOAuth2 = c.XOAuth2 + cfg.AuthXOAuth2.ClientSecret = "" + cfg.AuthXOAuth2.ClientSecretFile = fileWithIncorrectClientSecret.Name() + }, + + errMsg: "Invalid username or password", + retry: true, + }, + { + title: "wrong credentials (missing password file)", + updateCfg: func(cfg *config.EmailConfig) { + cfg.AuthUsername = c.Username + cfg.AuthXOAuth2 = c.XOAuth2 + cfg.AuthXOAuth2.ClientSecret = "" + cfg.AuthXOAuth2.ClientSecretFile = "/does/not/exist" + }, + + errMsg: "could not read", + retry: true, + }, + { + title: "no credentials", + errMsg: "authentication Required", + retry: true, + }, + } { + tc := tc + t.Run(tc.title, func(t *testing.T) { + emailCfg := &config.EmailConfig{ + TLSConfig: &commoncfg.TLSConfig{}, + Smarthost: c.Smarthost, + To: emailTo, + From: emailFrom, + HTML: "HTML body", + Text: "Text body", + Headers: map[string]string{ + "Subject": "{{ len .Alerts }} {{ .Status }} alert(s)", + }, + } + + if c.Smarthost.Port == "587" { + requireTLS := true + emailCfg.RequireTLS = &requireTLS + } + + if tc.updateCfg != nil { + tc.updateCfg(emailCfg) + } + + tmpl, firingAlert, err := prepare(emailCfg) + require.NoError(t, err) + + email := New(emailCfg, tmpl, promslog.NewNopLogger()) + + retry, err := email.Notify(context.Background(), firingAlert) + require.NoError(t, err) + require.Equal(t, tc.retry, retry) + }) + } +} diff --git a/notify/email/email_test.go b/notify/email/email_test.go index 0eec7159ec..53e72bd930 100644 --- a/notify/email/email_test.go +++ b/notify/email/email_test.go @@ -143,10 +143,11 @@ func (m *mailDev) doEmailRequest(method, path string) (int, []byte, error) { // emailTestConfig is the configuration for the tests. type emailTestConfig struct { - Smarthost config.HostPort `yaml:"smarthost"` - Username string `yaml:"username"` - Password string `yaml:"password"` - Server *mailDev `yaml:"server"` + Smarthost config.HostPort `yaml:"smarthost"` + Username string `yaml:"username"` + Password string `yaml:"password"` + Server *mailDev `yaml:"server"` + XOAuth2 *commoncfg.OAuth2 `yaml:"xoauth2,omitempty"` } func loadEmailTestConfiguration(f string) (emailTestConfig, error) { @@ -654,6 +655,10 @@ func TestEmailConfigMissingAuthParam(t *testing.T) { _, err = email.auth("PLAIN LOGIN") require.Error(t, err) require.Equal(t, "missing password for PLAIN auth mechanism; missing password for LOGIN auth mechanism", err.Error()) + + _, err = email.auth("XOAUTH2") + require.Error(t, err) + require.Equal(t, "missing OAuth2 configuration", err.Error()) } func TestEmailNoUsernameStillOk(t *testing.T) { @@ -672,7 +677,7 @@ func TestEmailRejected(t *testing.T) { t.Cleanup(cancel) // Setup mock SMTP server which will reject at the DATA stage. - srv, l, err := mockSMTPServer(t) + srv, l, err := mockSMTPServer(t, &rejectingBackend{}) require.NoError(t, err) t.Cleanup(func() { // We expect that the server has already been closed in the test. @@ -736,7 +741,7 @@ func TestEmailRejected(t *testing.T) { }, time.Second*10, time.Millisecond*100, "mock SMTP server goroutine failed to close in time") } -func mockSMTPServer(t *testing.T) (*smtp.Server, net.Listener, error) { +func mockSMTPServer(t *testing.T, backend smtp.Backend) (*smtp.Server, net.Listener, error) { t.Helper() // Listen on the next available high port. @@ -750,7 +755,7 @@ func mockSMTPServer(t *testing.T) (*smtp.Server, net.Listener, error) { return nil, nil, fmt.Errorf("unexpected address type: %T", l.Addr()) } - s := smtp.NewServer(&rejectingBackend{}) + s := smtp.NewServer(backend) s.Addr = addr.String() s.WriteTimeout = 10 * time.Second s.ReadTimeout = 10 * time.Second diff --git a/notify/email/testdata/auth_xoauth2.yml b/notify/email/testdata/auth_xoauth2.yml new file mode 100644 index 0000000000..086884e90b --- /dev/null +++ b/notify/email/testdata/auth_xoauth2.yml @@ -0,0 +1,7 @@ +smarthost: smtp.gmail.com:587 +username: "" +xoauth2: + client_id: + client_secret: + token_url: "https://oauth2.googleapis.com/token" + scopes: [ "https://mail.google.com/" ]