Skip to content

SMTP: Support SASL XOAUTH2 #4123

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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"`
Expand Down
1 change: 1 addition & 0 deletions config/notifiers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
3 changes: 3 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 ]
Expand Down Expand Up @@ -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 ]

Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
89 changes: 89 additions & 0 deletions notify/email/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"bytes"
"context"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"log/slog"
Expand All @@ -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"
Expand Down Expand Up @@ -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 == "" {
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
Loading
Loading