From a1cef65539a0289c43552a04473dbeba8098a2e1 Mon Sep 17 00:00:00 2001 From: FreakIsTea Date: Fri, 19 Dec 2025 15:24:25 +0100 Subject: [PATCH 1/3] feat: add xoauth2 for smtp with client credentials flow --- app/pkg/env/env.go | 24 +++++++++---- app/services/email/smtp/auth_xoauth2.go | 42 ++++++++++++++++++++++ app/services/email/smtp/oauth2_token.go | 48 +++++++++++++++++++++++++ app/services/email/smtp/smtp.go | 35 +++++++++++++++--- 4 files changed, 137 insertions(+), 12 deletions(-) create mode 100644 app/services/email/smtp/auth_xoauth2.go create mode 100644 app/services/email/smtp/oauth2_token.go diff --git a/app/pkg/env/env.go b/app/pkg/env/env.go index 462810621..f160b0c7b 100644 --- a/app/pkg/env/env.go +++ b/app/pkg/env/env.go @@ -34,6 +34,22 @@ func Version() string { return fmt.Sprintf("%s-%s", v, commithash) } +type SMTPConfig struct { + Host string `env:"EMAIL_SMTP_HOST"` + Port string `env:"EMAIL_SMTP_PORT"` + Username string `env:"EMAIL_SMTP_USERNAME"` + Password string `env:"EMAIL_SMTP_PASSWORD"` + AuthMechanism string `env:"EMAIL_SMTP_AUTH_MECHANISM,default=LOGIN"` + EnableStartTLS bool `env:"EMAIL_SMTP_ENABLE_STARTTLS,default=true"` + + // Specific settings for Auth Mechanism "XOAUTH2" + // Username will be used for XOAUTH2 as well + ClientId string `env:"EMAIL_SMTP_OAUTH_CLIENT_ID"` + ClientSecret string `env:"EMAIL_SMTP_OAUTH_CLIENT_SECRET"` + TokenUrl string `env:"EMAIL_SMTP_OAUTH_TOKEN_URL"` + Scopes string `env:"EMAIL_SMTP_OAUTH_SCOPES"` // comma-separated +} + type config struct { Environment string `env:"GO_ENV,default=production"` SignUpDisabled bool `env:"SIGNUP_DISABLED,default=false"` @@ -118,13 +134,7 @@ type config struct { Domain string `env:"EMAIL_MAILGUN_DOMAIN"` Region string `env:"EMAIL_MAILGUN_REGION,default=US"` // possible values: US or EU } - SMTP struct { - Host string `env:"EMAIL_SMTP_HOST"` - Port string `env:"EMAIL_SMTP_PORT"` - Username string `env:"EMAIL_SMTP_USERNAME"` - Password string `env:"EMAIL_SMTP_PASSWORD"` - EnableStartTLS bool `env:"EMAIL_SMTP_ENABLE_STARTTLS,default=true"` - } + SMTP SMTPConfig } BlobStorage struct { Type string `env:"BLOB_STORAGE,default=sql"` // possible values: sql, fs or s3 diff --git a/app/services/email/smtp/auth_xoauth2.go b/app/services/email/smtp/auth_xoauth2.go new file mode 100644 index 000000000..e5e13a539 --- /dev/null +++ b/app/services/email/smtp/auth_xoauth2.go @@ -0,0 +1,42 @@ +package smtp + +import ( + "fmt" + gosmtp "net/smtp" + + "github.com/getfider/fider/app/pkg/errors" +) + +type xoauth2Auth struct { + user string + token string + host string +} + +func XOAuth2Auth(user, token, host string) gosmtp.Auth { + return &xoauth2Auth{ + user: user, + token: token, + host: host, + } +} + +func (a *xoauth2Auth) Start(server *gosmtp.ServerInfo) (proto string, toServer []byte, err error) { + if server.Name != a.host { + return "", nil, errors.New("smtp: wrong host name") + } + + if !server.TLS { + return "", nil, errors.New("smtp: XOAUTH2 requires TLS") + } + + resp := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", a.user, a.token) + return "XOAUTH2", []byte(resp), nil +} + +func (a *xoauth2Auth) Next(fromServer []byte, more bool) ([]byte, error) { + if more { + return nil, errors.New("smtp: unexpected server challenge for XOAUTH2") + } + return nil, nil +} diff --git a/app/services/email/smtp/oauth2_token.go b/app/services/email/smtp/oauth2_token.go new file mode 100644 index 000000000..a919ecdd2 --- /dev/null +++ b/app/services/email/smtp/oauth2_token.go @@ -0,0 +1,48 @@ +package smtp + +import ( + "context" + "strings" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" + + "github.com/getfider/fider/app/pkg/errors" +) + +func splitCommaScopes(raw string) []string { + if strings.TrimSpace(raw) == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +func getClientCredentialsToken(ctx context.Context, tokenURL, clientID, clientSecret string, scopes []string) (*oauth2.Token, error) { + if tokenURL == "" { + return nil, errors.New("smtp: oauth token url is required") + } + if clientID == "" || clientSecret == "" { + return nil, errors.New("smtp: oauth client id/secret are required") + } + + conf := clientcredentials.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + TokenURL: tokenURL, + Scopes: scopes, + } + + tok, err := conf.Token(ctx) + if err != nil { + return nil, errors.Wrap(err, "smtp: failed to fetch oauth token") + } + return tok, nil +} diff --git a/app/services/email/smtp/smtp.go b/app/services/email/smtp/smtp.go index de727faf6..bf5e1757c 100644 --- a/app/services/email/smtp/smtp.go +++ b/app/services/email/smtp/smtp.go @@ -10,6 +10,7 @@ import ( gosmtp "net/smtp" "net/url" "strconv" + "strings" "time" "github.com/getfider/fider/app/models/cmd" @@ -99,7 +100,11 @@ func sendMail(ctx context.Context, c *cmd.SendMail) { smtpConfig := env.Config.Email.SMTP servername := fmt.Sprintf("%s:%s", smtpConfig.Host, smtpConfig.Port) - auth := authenticate(smtpConfig.Username, smtpConfig.Password, smtpConfig.Host) + auth, err := authenticate(ctx, smtpConfig) + if err != nil { + panic(errors.Wrap(err, "failed to build smtp auth")) + } + err = Send(localname, servername, smtpConfig.EnableStartTLS, auth, email.NoReply, []string{to.Address}, b.Bytes()) if err != nil { panic(errors.Wrap(err, "failed to send email with template %s", c.TemplateName)) @@ -169,11 +174,31 @@ func generateMessageID(localName string) string { return messageID } -func authenticate(username string, password string, host string) gosmtp.Auth { - if username == "" && password == "" { - return nil +func authenticate(ctx context.Context, cfg env.SMTPConfig) (gosmtp.Auth, error) { + mech := strings.ToLower(strings.TrimSpace(cfg.AuthMechanism)) + switch mech { + case "", "login": + if cfg.Username == "" && cfg.Password == "" { + return nil, nil + } + return AgnosticAuth("", cfg.Username, cfg.Password, cfg.Host), nil + + case "xoauth2": + if cfg.Username == "" { + return nil, errors.New("smtp: username is required for XOAUTH2") + } + + scopes := splitCommaScopes(cfg.Scopes) + tok, err := getClientCredentialsToken(ctx, cfg.TokenUrl, cfg.ClientId, cfg.ClientSecret, scopes) + if err != nil { + return nil, err + } + + return XOAuth2Auth(cfg.Username, tok.AccessToken, cfg.Host), nil + + default: + return nil, errors.New("smtp: unsupported auth mechanism") } - return AgnosticAuth("", username, password, host) } type builder struct { From 60068c2748eb24b5bcd22bc30a3da52e17e1ca26 Mon Sep 17 00:00:00 2001 From: FreakIsTea Date: Tue, 23 Dec 2025 14:04:59 +0100 Subject: [PATCH 2/3] fix: change naming to match correct naming for agnostic auth --- app/pkg/env/env.go | 2 +- app/services/email/smtp/smtp.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/pkg/env/env.go b/app/pkg/env/env.go index 3af600ccd..4030f933b 100644 --- a/app/pkg/env/env.go +++ b/app/pkg/env/env.go @@ -39,7 +39,7 @@ type SMTPConfig struct { Port string `env:"EMAIL_SMTP_PORT"` Username string `env:"EMAIL_SMTP_USERNAME"` Password string `env:"EMAIL_SMTP_PASSWORD"` - AuthMechanism string `env:"EMAIL_SMTP_AUTH_MECHANISM,default=LOGIN"` + AuthMechanism string `env:"EMAIL_SMTP_AUTH_MECHANISM,default=AGNOSTIC"` EnableStartTLS bool `env:"EMAIL_SMTP_ENABLE_STARTTLS,default=true"` // Specific settings for Auth Mechanism "XOAUTH2" diff --git a/app/services/email/smtp/smtp.go b/app/services/email/smtp/smtp.go index 0b6e35ab4..2409dd6c7 100644 --- a/app/services/email/smtp/smtp.go +++ b/app/services/email/smtp/smtp.go @@ -177,7 +177,7 @@ func generateMessageID(localName string) string { func authenticate(ctx context.Context, cfg env.SMTPConfig) (gosmtp.Auth, error) { mech := strings.ToLower(strings.TrimSpace(cfg.AuthMechanism)) switch mech { - case "", "login": + case "", "agnostic": if cfg.Username == "" && cfg.Password == "" { return nil, nil } From b6e1e354a9fab6312a8051b5ece2af991b768503 Mon Sep 17 00:00:00 2001 From: FreakIsTea Date: Wed, 31 Dec 2025 14:05:54 +0100 Subject: [PATCH 3/3] feat: add token caching with ReuseTokenSource --- app/services/email/smtp/oauth2_token.go | 36 +++++++++++++------ app/services/email/smtp/oauth2_tokensource.go | 30 ++++++++++++++++ app/services/email/smtp/smtp.go | 4 +-- 3 files changed, 57 insertions(+), 13 deletions(-) create mode 100644 app/services/email/smtp/oauth2_tokensource.go diff --git a/app/services/email/smtp/oauth2_token.go b/app/services/email/smtp/oauth2_token.go index a919ecdd2..715296044 100644 --- a/app/services/email/smtp/oauth2_token.go +++ b/app/services/email/smtp/oauth2_token.go @@ -25,24 +25,38 @@ func splitCommaScopes(raw string) []string { return out } -func getClientCredentialsToken(ctx context.Context, tokenURL, clientID, clientSecret string, scopes []string) (*oauth2.Token, error) { +func getClientCredentialsToken(ctx context.Context, tokenURL, clientID, clientSecret string, scopes []string) (string, error) { if tokenURL == "" { - return nil, errors.New("smtp: oauth token url is required") + return "", errors.New("smtp: oauth token url is required") } if clientID == "" || clientSecret == "" { - return nil, errors.New("smtp: oauth client id/secret are required") + return "", errors.New("smtp: oauth client id/secret are required") } - conf := clientcredentials.Config{ - ClientID: clientID, - ClientSecret: clientSecret, - TokenURL: tokenURL, - Scopes: scopes, + key := tokenSourceKey(tokenURL, clientID, scopes) + + tokenSourceMu.Lock() + tokenSource, ok := tokenSourceByKey[key] + if !ok { + conf := clientcredentials.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + TokenURL: tokenURL, + Scopes: scopes, + } + + base := conf.TokenSource(ctx) + tokenSource = oauth2.ReuseTokenSource(nil, base) + tokenSourceByKey[key] = tokenSource } + tokenSourceMu.Unlock() - tok, err := conf.Token(ctx) + tok, err := tokenSource.Token() if err != nil { - return nil, errors.Wrap(err, "smtp: failed to fetch oauth token") + return "", errors.Wrap(err, "smtp: failed to fetch oauth token") + } + if tok == nil || tok.AccessToken == "" { + return "", errors.New("smtp: oauth returned empty access token") } - return tok, nil + return tok.AccessToken, nil } diff --git a/app/services/email/smtp/oauth2_tokensource.go b/app/services/email/smtp/oauth2_tokensource.go new file mode 100644 index 000000000..629332175 --- /dev/null +++ b/app/services/email/smtp/oauth2_tokensource.go @@ -0,0 +1,30 @@ +package smtp + +import ( + "crypto/sha256" + "encoding/hex" + "sort" + "strings" + "sync" + + "golang.org/x/oauth2" +) + +var ( + tokenSourceMu sync.Mutex + tokenSourceByKey = map[string]oauth2.TokenSource{} +) + +func tokenSourceKey(tokenURL, clientID string, scopes []string) string { + normalized := make([]string, 0, len(scopes)) + for _, scope := range scopes { + scope = strings.TrimSpace(scope) + if scope != "" { + normalized = append(normalized, scope) + } + } + sort.Strings(normalized) + + sum := sha256.Sum256([]byte(tokenURL + "|" + clientID + "|" + strings.Join(normalized, ","))) + return hex.EncodeToString(sum[:]) +} diff --git a/app/services/email/smtp/smtp.go b/app/services/email/smtp/smtp.go index 2409dd6c7..2c86b1746 100644 --- a/app/services/email/smtp/smtp.go +++ b/app/services/email/smtp/smtp.go @@ -189,12 +189,12 @@ func authenticate(ctx context.Context, cfg env.SMTPConfig) (gosmtp.Auth, error) } scopes := splitCommaScopes(cfg.Scopes) - tok, err := getClientCredentialsToken(ctx, cfg.TokenUrl, cfg.ClientId, cfg.ClientSecret, scopes) + token, err := getClientCredentialsToken(ctx, cfg.TokenUrl, cfg.ClientId, cfg.ClientSecret, scopes) if err != nil { return nil, err } - return XOAuth2Auth(cfg.Username, tok.AccessToken, cfg.Host), nil + return XOAuth2Auth(cfg.Username, token, cfg.Host), nil default: return nil, errors.New("smtp: unsupported auth mechanism")