SMTP: Support SASL XOAUTH2

Signed-off-by: Jan-Otto Kröpke <joe@cloudeteer.de>
This commit is contained in:
Jan-Otto Kröpke 2024-11-15 15:55:55 +01:00
parent f6b942cf9b
commit b4117666d5
No known key found for this signature in database
7 changed files with 307 additions and 4 deletions

View File

@ -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"`

View File

@ -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"`

View File

@ -91,6 +91,8 @@ global:
[ smtp_auth_identity: <string> ]
# SMTP Auth using CRAM-MD5.
[ smtp_auth_secret: <secret> ]
# SMTP Auth using SASL-XOAUTH2.
[ smtp_auth_xoauth2: { <oauth2> } ]
# The default SMTP TLS requirement.
# Note that Go does not support unencrypted connections to remote SMTP endpoints.
[ smtp_require_tls: <bool> | default = true ]
@ -920,6 +922,7 @@ to: <tmpl_string>
[ auth_username: <string> | default = global.smtp_auth_username ]
[ auth_password: <secret> | default = global.smtp_auth_password ]
[ auth_password_file: <string> | default = global.smtp_auth_password_file ]
[ auth_xoauth2: { <oauth2> | default = global.smtp_auth_xoauth2 } ]
[ auth_secret: <secret> | default = global.smtp_auth_secret ]
[ auth_identity: <string> | default = global.smtp_auth_identity ]

2
go.mod
View File

@ -44,6 +44,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
@ -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

View File

@ -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
}

View File

@ -0,0 +1,202 @@
// 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 running mail catcher. We use MailDev for this purpose,
// it can work without or with authentication (LOGIN only). It exposes a REST
// API which we use to retrieve and check the sent emails.
//
// Those tests are only executed when specific environment variables are set,
// otherwise they are skipped. The tests must be run by the CI.
//
// To run the tests locally, you should start 2 MailDev containers:
//
// $ docker run --rm -p 1080:1080 -p 1025:1025 --entrypoint bin/maildev djfarrelly/maildev@sha256:624e0ec781e11c3531da83d9448f5861f258ee008c1b2da63b3248bfd680acfa -v
// $ docker run --rm -p 1081:1080 -p 1026:1025 --entrypoint bin/maildev djfarrelly/maildev@sha256:624e0ec781e11c3531da83d9448f5861f258ee008c1b2da63b3248bfd680acfa --incoming-user user --incoming-pass pass -v
//
// $ EMAIL_NO_AUTH_CONFIG=testdata/noauth.yml EMAIL_AUTH_CONFIG=testdata/auth.yml make
//
// See also https://github.com/djfarrelly/MailDev for more details.
package email
import (
"context"
"encoding/base64"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"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 (
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)
}

View File

@ -654,6 +654,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 +676,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 +740,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 +754,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