Skip to content

Commit 9137a56

Browse files
committed
SMTP: Support SASL XOAUTH2
Signed-off-by: Jan-Otto Kröpke <[email protected]>
1 parent f6b942c commit 9137a56

File tree

7 files changed

+304
-4
lines changed

7 files changed

+304
-4
lines changed

config/config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,9 @@ func (c *Config) UnmarshalYAML(unmarshal func(interface{}) error) error {
412412
ec.AuthPassword = c.Global.SMTPAuthPassword
413413
ec.AuthPasswordFile = c.Global.SMTPAuthPasswordFile
414414
}
415+
if ec.AuthXOAuth2 == nil {
416+
ec.AuthXOAuth2 = c.Global.SMTPAuthXOAuth2
417+
}
415418
if ec.AuthSecret == "" {
416419
ec.AuthSecret = c.Global.SMTPAuthSecret
417420
}
@@ -820,6 +823,7 @@ type GlobalConfig struct {
820823
SMTPAuthPasswordFile string `yaml:"smtp_auth_password_file,omitempty" json:"smtp_auth_password_file,omitempty"`
821824
SMTPAuthSecret Secret `yaml:"smtp_auth_secret,omitempty" json:"smtp_auth_secret,omitempty"`
822825
SMTPAuthIdentity string `yaml:"smtp_auth_identity,omitempty" json:"smtp_auth_identity,omitempty"`
826+
SMTPAuthXOAuth2 *commoncfg.OAuth2 `yaml:"smtp_auth_xoauth2,omitempty" json:"smtp_auth_xoauth2,omitempty"`
823827
SMTPRequireTLS bool `yaml:"smtp_require_tls" json:"smtp_require_tls,omitempty"`
824828
SMTPTLSConfig *commoncfg.TLSConfig `yaml:"smtp_tls_config,omitempty" json:"smtp_tls_config,omitempty"`
825829
SlackAPIURL *SecretURL `yaml:"slack_api_url,omitempty" json:"slack_api_url,omitempty"`

config/notifiers.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,7 @@ type EmailConfig struct {
291291
AuthPasswordFile string `yaml:"auth_password_file,omitempty" json:"auth_password_file,omitempty"`
292292
AuthSecret Secret `yaml:"auth_secret,omitempty" json:"auth_secret,omitempty"`
293293
AuthIdentity string `yaml:"auth_identity,omitempty" json:"auth_identity,omitempty"`
294+
AuthXOAuth2 *commoncfg.OAuth2 `yaml:"auth_xoauth2,omitempty" json:"auth_xoauth2,omitempty"`
294295
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"`
295296
HTML string `yaml:"html,omitempty" json:"html,omitempty"`
296297
Text string `yaml:"text,omitempty" json:"text,omitempty"`

docs/configuration.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ global:
9191
[ smtp_auth_identity: <string> ]
9292
# SMTP Auth using CRAM-MD5.
9393
[ smtp_auth_secret: <secret> ]
94+
# SMTP Auth using SASL-XOAUTH2.
95+
[ smtp_auth_xoauth2: { <oauth2> } ]
9496
# The default SMTP TLS requirement.
9597
# Note that Go does not support unencrypted connections to remote SMTP endpoints.
9698
[ smtp_require_tls: <bool> | default = true ]
@@ -920,6 +922,7 @@ to: <tmpl_string>
920922
[ auth_username: <string> | default = global.smtp_auth_username ]
921923
[ auth_password: <secret> | default = global.smtp_auth_password ]
922924
[ auth_password_file: <string> | default = global.smtp_auth_password_file ]
925+
[ auth_xoauth2: { <oauth2> | default = global.smtp_auth_xoauth2 } ]
923926
[ auth_secret: <secret> | default = global.smtp_auth_secret ]
924927
[ auth_identity: <string> | default = global.smtp_auth_identity ]
925928

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ require (
4444
go.uber.org/automaxprocs v1.6.0
4545
golang.org/x/mod v0.20.0
4646
golang.org/x/net v0.30.0
47+
golang.org/x/oauth2 v0.23.0
4748
golang.org/x/text v0.19.0
4849
golang.org/x/tools v0.24.0
4950
gopkg.in/telebot.v3 v3.3.8
@@ -99,7 +100,6 @@ require (
99100
go.opentelemetry.io/otel/metric v1.24.0 // indirect
100101
go.opentelemetry.io/otel/trace v1.24.0 // indirect
101102
golang.org/x/crypto v0.28.0 // indirect
102-
golang.org/x/oauth2 v0.23.0 // indirect
103103
golang.org/x/sync v0.8.0 // indirect
104104
golang.org/x/sys v0.26.0 // indirect
105105
google.golang.org/protobuf v1.34.2 // indirect

notify/email/email.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"bytes"
1818
"context"
1919
"crypto/tls"
20+
"encoding/base64"
2021
"errors"
2122
"fmt"
2223
"log/slog"
@@ -28,12 +29,15 @@ import (
2829
"net/mail"
2930
"net/smtp"
3031
"net/textproto"
32+
"net/url"
3133
"os"
3234
"strings"
3335
"sync"
3436
"time"
3537

3638
commoncfg "github.com/prometheus/common/config"
39+
"golang.org/x/oauth2"
40+
"golang.org/x/oauth2/clientcredentials"
3741

3842
"github.com/prometheus/alertmanager/config"
3943
"github.com/prometheus/alertmanager/notify"
@@ -82,6 +86,12 @@ func (n *Email) auth(mechs string) (smtp.Auth, error) {
8286
err := &types.MultiError{}
8387
for _, mech := range strings.Split(mechs, " ") {
8488
switch mech {
89+
case "XOAUTH2":
90+
if n.conf.AuthXOAuth2 == nil {
91+
err.Add(errors.New("missing OAuth2 configuration"))
92+
continue
93+
}
94+
return XOAuth2Auth(n.conf.AuthUsername, n.conf.AuthXOAuth2)
8595
case "CRAM-MD5":
8696
secret := string(n.conf.AuthSecret)
8797
if secret == "" {
@@ -375,6 +385,76 @@ func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
375385
return nil, nil
376386
}
377387

388+
type xOAuth2Auth struct {
389+
smtpFrom string
390+
ts oauth2.TokenSource
391+
}
392+
393+
func XOAuth2Auth(authUsername string, cfg *commoncfg.OAuth2) (smtp.Auth, error) {
394+
if cfg == nil {
395+
return nil, errors.New("missing OAuth2 configuration")
396+
}
397+
398+
var clientSecret string
399+
400+
switch {
401+
case cfg.ClientSecret != "":
402+
clientSecret = string(cfg.ClientSecret)
403+
case cfg.ClientSecretFile != "":
404+
fileBytes, err := os.ReadFile(cfg.ClientSecretFile)
405+
if err != nil {
406+
return nil, fmt.Errorf("unable to read file %s: %w", cfg.ClientSecretFile, err)
407+
}
408+
409+
clientSecret = strings.TrimSpace(string(fileBytes))
410+
default:
411+
return nil, errors.New("no client secret provided")
412+
}
413+
414+
oauth2cfg := &clientcredentials.Config{
415+
ClientID: cfg.ClientID,
416+
ClientSecret: clientSecret,
417+
TokenURL: cfg.TokenURL,
418+
Scopes: cfg.Scopes,
419+
EndpointParams: mapToValues(cfg.EndpointParams),
420+
}
421+
422+
ts := oauth2cfg.TokenSource(context.Background())
423+
if _, err := ts.Token(); err != nil {
424+
return nil, fmt.Errorf("unable to get token: %w", err)
425+
}
426+
427+
return &xOAuth2Auth{authUsername, ts}, nil
428+
}
429+
430+
// Start implements the [smtp.Auth] interface.
431+
func (*xOAuth2Auth) Start(_ *smtp.ServerInfo) (string, []byte, error) {
432+
return "XOAUTH2", []byte{}, nil
433+
}
434+
435+
// Next implements the [smtp.Auth] interface.
436+
func (x *xOAuth2Auth) Next(_ []byte, more bool) ([]byte, error) {
437+
if more {
438+
accessToken, err := x.ts.Token()
439+
if err != nil {
440+
return nil, fmt.Errorf("unable to get token: %w", err)
441+
}
442+
443+
// Generates an unencoded XOAuth2 string of the form
444+
// "user=" {User} "^Aauth=Bearer " {Access Token} "^A^A"
445+
// as defined at https://developers.google.com/google-apps/gmail/xoauth2_protocol#the_sasl_xoauth2_mechanism.
446+
// 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
447+
saslOAuth2String := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", x.smtpFrom, accessToken.AccessToken)
448+
449+
saslOAuth2Encoded := make([]byte, base64.RawStdEncoding.EncodedLen(len(saslOAuth2String)))
450+
base64.RawStdEncoding.Encode(saslOAuth2Encoded, []byte(saslOAuth2String))
451+
452+
return saslOAuth2Encoded, nil
453+
}
454+
455+
return nil, nil
456+
}
457+
378458
func (n *Email) getPassword() (string, error) {
379459
if len(n.conf.AuthPasswordFile) > 0 {
380460
content, err := os.ReadFile(n.conf.AuthPasswordFile)
@@ -385,3 +465,12 @@ func (n *Email) getPassword() (string, error) {
385465
}
386466
return string(n.conf.AuthPassword), nil
387467
}
468+
469+
func mapToValues(m map[string]string) url.Values {
470+
v := url.Values{}
471+
for name, value := range m {
472+
v.Set(name, value)
473+
}
474+
475+
return v
476+
}

notify/email/email_oauth2_test.go

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
// Copyright 2019 Prometheus Team
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
// Some tests require a running mail catcher. We use MailDev for this purpose,
15+
// it can work without or with authentication (LOGIN only). It exposes a REST
16+
// API which we use to retrieve and check the sent emails.
17+
//
18+
// Those tests are only executed when specific environment variables are set,
19+
// otherwise they are skipped. The tests must be run by the CI.
20+
//
21+
// To run the tests locally, you should start 2 MailDev containers:
22+
//
23+
// $ docker run --rm -p 1080:1080 -p 1025:1025 --entrypoint bin/maildev djfarrelly/maildev@sha256:624e0ec781e11c3531da83d9448f5861f258ee008c1b2da63b3248bfd680acfa -v
24+
// $ docker run --rm -p 1081:1080 -p 1026:1025 --entrypoint bin/maildev djfarrelly/maildev@sha256:624e0ec781e11c3531da83d9448f5861f258ee008c1b2da63b3248bfd680acfa --incoming-user user --incoming-pass pass -v
25+
//
26+
// $ EMAIL_NO_AUTH_CONFIG=testdata/noauth.yml EMAIL_AUTH_CONFIG=testdata/auth.yml make
27+
//
28+
// See also https://github.com/djfarrelly/MailDev for more details.
29+
package email
30+
31+
import (
32+
"context"
33+
"encoding/base64"
34+
"fmt"
35+
"io"
36+
"net"
37+
"net/http"
38+
"net/http/httptest"
39+
"strconv"
40+
"testing"
41+
"time"
42+
43+
"github.com/emersion/go-sasl"
44+
"github.com/emersion/go-smtp"
45+
"github.com/prometheus/alertmanager/config"
46+
commoncfg "github.com/prometheus/common/config"
47+
"github.com/prometheus/common/promslog"
48+
"github.com/stretchr/testify/assert"
49+
"github.com/stretchr/testify/require"
50+
)
51+
52+
const (
53+
TestBearerUsername = "fxcp"
54+
TestBearerToken = "VkIvciKi9ijpiKNWrQmYCJrzgd9QYCMB"
55+
)
56+
57+
func TestEmail_OAuth2(t *testing.T) {
58+
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
59+
t.Cleanup(cancel)
60+
61+
// Setup mock SMTP server which will reject at the DATA stage.
62+
srv, l, err := mockSMTPServer(t, &xOAuth2Backend{})
63+
require.NoError(t, err)
64+
t.Cleanup(func() {
65+
// We expect that the server has already been closed in the test.
66+
require.ErrorIs(t, srv.Shutdown(ctx), smtp.ErrServerClosed)
67+
})
68+
69+
done := make(chan any, 1)
70+
go func() {
71+
// nolint:testifylint // require cannot be called outside the main goroutine: https://pkg.go.dev/testing#T.FailNow
72+
assert.NoError(t, srv.Serve(l))
73+
close(done)
74+
}()
75+
76+
oidcServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
77+
w.Header().Add("Content-Type", "application/json")
78+
fmt.Fprintf(w, `{"access_token":"%s","token_type":"Bearer","expires_in":3600}`, TestBearerToken)
79+
}))
80+
81+
// Wait for mock SMTP server to become ready.
82+
require.Eventuallyf(t, func() bool {
83+
c, err := smtp.Dial(srv.Addr)
84+
if err != nil {
85+
t.Logf("dial failed to %q: %s", srv.Addr, err)
86+
return false
87+
}
88+
89+
// Ping.
90+
if err = c.Noop(); err != nil {
91+
t.Logf("ping failed to %q: %s", srv.Addr, err)
92+
return false
93+
}
94+
95+
// Ensure we close the connection to not prevent server from shutting down cleanly.
96+
if err = c.Close(); err != nil {
97+
t.Logf("close failed to %q: %s", srv.Addr, err)
98+
return false
99+
}
100+
101+
return true
102+
}, time.Second*10, time.Millisecond*100, "mock SMTP server failed to start")
103+
104+
// Use mock SMTP server and prepare alert to be sent.
105+
require.IsType(t, &net.TCPAddr{}, l.Addr())
106+
addr := l.Addr().(*net.TCPAddr)
107+
cfg := &config.EmailConfig{
108+
Smarthost: config.HostPort{Host: addr.IP.String(), Port: strconv.Itoa(addr.Port)},
109+
Hello: "localhost",
110+
Headers: make(map[string]string),
111+
From: "alertmanager@system",
112+
To: "sre@company",
113+
AuthUsername: TestBearerUsername,
114+
AuthXOAuth2: &commoncfg.OAuth2{
115+
ClientID: "client_id",
116+
ClientSecret: "client_secret",
117+
TokenURL: oidcServer.URL,
118+
Scopes: []string{"email"},
119+
},
120+
}
121+
122+
tmpl, firingAlert, err := prepare(cfg)
123+
require.NoError(t, err)
124+
125+
e := New(cfg, tmpl, promslog.NewNopLogger())
126+
127+
// Send the alert to mock SMTP server.
128+
retry, err := e.Notify(context.Background(), firingAlert)
129+
require.ErrorContains(t, err, "501 5.5.4 Rejected!")
130+
require.True(t, retry)
131+
require.NoError(t, srv.Shutdown(ctx))
132+
133+
require.Eventuallyf(t, func() bool {
134+
<-done
135+
return true
136+
}, time.Second*10, time.Millisecond*100, "mock SMTP server goroutine failed to close in time")
137+
}
138+
139+
// xOAuth2Backend will reject submission at the DATA stage.
140+
type xOAuth2Backend struct{}
141+
142+
func (b *xOAuth2Backend) NewSession(c *smtp.Conn) (smtp.Session, error) {
143+
return &mockSMTPxOAuth2Session{
144+
conn: c,
145+
backend: b,
146+
}, nil
147+
}
148+
149+
type mockSMTPxOAuth2Session struct {
150+
conn *smtp.Conn
151+
backend smtp.Backend
152+
}
153+
154+
func (s *mockSMTPxOAuth2Session) AuthMechanisms() []string {
155+
return []string{sasl.Plain, sasl.Login, "XOAUTH2"}
156+
}
157+
158+
func (s *mockSMTPxOAuth2Session) Auth(string) (sasl.Server, error) {
159+
return &xOAuth2BackendAuth{}, nil
160+
}
161+
162+
func (s *mockSMTPxOAuth2Session) Mail(string, *smtp.MailOptions) error {
163+
return nil
164+
}
165+
166+
func (s *mockSMTPxOAuth2Session) Rcpt(string, *smtp.RcptOptions) error {
167+
return nil
168+
}
169+
170+
func (s *mockSMTPxOAuth2Session) Data(io.Reader) error {
171+
return &smtp.SMTPError{Code: 501, EnhancedCode: smtp.EnhancedCode{5, 5, 4}, Message: "Rejected!"}
172+
}
173+
174+
func (*mockSMTPxOAuth2Session) Reset() {}
175+
176+
func (*mockSMTPxOAuth2Session) Logout() error { return nil }
177+
178+
type xOAuth2BackendAuth struct{}
179+
180+
func (*xOAuth2BackendAuth) Next(response []byte) ([]byte, bool, error) {
181+
// Generate empty challenge.
182+
if response == nil {
183+
return []byte{}, false, nil
184+
}
185+
186+
token := make([]byte, base64.RawStdEncoding.DecodedLen(len(response)))
187+
188+
_, err := base64.RawStdEncoding.Decode(token, response)
189+
if err != nil {
190+
return nil, true, err
191+
}
192+
193+
expectedToken := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", TestBearerUsername, TestBearerToken)
194+
if expectedToken == string(token) {
195+
return nil, true, nil
196+
}
197+
198+
return nil, true, fmt.Errorf("unexpected token: %s, expected: %s", token, expectedToken)
199+
}

0 commit comments

Comments
 (0)