lib: consolidate SASL authentication code

SASL and OAuth2 authentication logic was duplicated across the IMAP
worker and the SMTP sender code. Both implementations handled XOAUTH2,
OAUTHBEARER, and token caching independently, making maintenance
difficult and bug fixes error-prone.

Move this shared logic into lib/auth where it can be reused by all
backends. The IMAP worker no longer needs its own OAuth structs and
configuration parsing since it can now rely on the common
implementation also used by the SMTP sender.

Signed-off-by: Robin Jarry <robin@jarry.cc>
Reviewed-by: Simon Martin <simon@nasilyan.com>
This commit is contained in:
Robin Jarry
2026-02-07 21:21:30 +01:00
parent cf96695042
commit 018e6f81f4
14 changed files with 251 additions and 358 deletions
+1 -1
View File
@@ -206,7 +206,7 @@ func sendHelper(composer *app.Composer, header *mail.Header, uri *url.URL, domai
}
sender, err := send.NewSender(
composer.Worker(), uri, domain,
from, rcpts,
from, rcpts, composer.Account().Name(),
folders, requestDSN,
)
if err != nil {
+1 -1
View File
@@ -159,7 +159,7 @@ func (b Bounce) Execute(args []string) error {
msg.Envelope.MessageId, addresses)
if sender, err = send.NewSender(acct.Worker(), uri,
domain, config.From, rcpts, nil, false); err != nil {
domain, config.From, rcpts, acct.Name(), nil, false); err != nil {
return
}
defer func() {
+76
View File
@@ -0,0 +1,76 @@
package auth
import (
"fmt"
"net/url"
"slices"
"strings"
"github.com/emersion/go-sasl"
"golang.org/x/oauth2"
)
func ParseScheme(uri *url.URL) (protocol string, mech string, err error) {
protocol = ""
mech = "plain"
if uri.Scheme != "" {
parts := strings.Split(uri.Scheme, "+")
if len(parts) == 0 {
return "", "", fmt.Errorf("Unknown scheme %s", uri.Scheme)
}
protocol = parts[0]
parts = slices.Delete(parts, 0, 1)
i := slices.Index(parts, "insecure")
if i != -1 {
protocol += "+insecure"
parts = slices.Delete(parts, i, i+1)
}
if len(parts) > 0 {
mech = strings.Join(parts, "+")
}
}
return protocol, mech, nil
}
func NewSaslClient(mech string, uri *url.URL, acct string) (sasl.Client, error) {
var saslClient sasl.Client
user := uri.User.Username()
password, _ := uri.User.Password()
switch mech {
case "", "none":
saslClient = nil
case "login":
saslClient = sasl.NewLoginClient(user, password)
case "plain":
saslClient = sasl.NewPlainClient("", user, password)
case "oauthbearer", "xoauth2":
q := uri.Query()
o := oauth2.Config{
ClientID: q.Get("client_id"),
ClientSecret: q.Get("client_secret"),
Scopes: strings.Split(q.Get("scope"), " "),
Endpoint: oauth2.Endpoint{
TokenURL: q.Get("token_endpoint"),
},
}
password, err := GetAccessToken(&o, acct, mech, password)
if err != nil {
return nil, err
}
if mech == "xoauth2" {
saslClient = NewXoauth2Client(user, password)
} else {
saslClient = sasl.NewOAuthBearerClient(
&sasl.OAuthBearerOptions{
Username: uri.User.Username(),
Token: password,
},
)
}
default:
return nil, fmt.Errorf("Unsupported auth mechanism %q", mech)
}
return saslClient, nil
}
+64
View File
@@ -0,0 +1,64 @@
package auth
import (
"context"
"fmt"
"os"
"path"
"git.sr.ht/~rjarry/aerc/lib/xdg"
"golang.org/x/oauth2"
)
func exchangeRefreshToken(o *oauth2.Config, refreshToken string) (*oauth2.Token, error) {
token := new(oauth2.Token)
token.RefreshToken = refreshToken
token.TokenType = "Bearer"
return o.TokenSource(context.TODO(), token).Token()
}
func tokenCachePath(account, mech string) string {
return xdg.CachePath("aerc", account+"-"+mech+".token")
}
func saveRefreshToken(refreshToken, account, mech string) error {
p := tokenCachePath(account, mech)
if err := os.MkdirAll(path.Dir(p), 0o700); err != nil {
return err
}
return os.WriteFile(p, []byte(refreshToken), 0o600)
}
func getRefreshToken(account, mech string) (string, error) {
buf, err := os.ReadFile(tokenCachePath(account, mech))
if err != nil {
return "", err
}
return string(buf), nil
}
func GetAccessToken(
o *oauth2.Config, account, mech, password string,
) (string, error) {
if o.Endpoint.TokenURL == "" {
return password, nil
}
usedCache := false
if r, err := getRefreshToken(account, mech); err == nil && len(r) > 0 {
password = string(r)
usedCache = true
}
token, err := exchangeRefreshToken(o, password)
if err != nil {
if usedCache {
return "", fmt.Errorf("%w: try deleting %s",
err, tokenCachePath(account, mech))
}
return "", err
}
if err := saveRefreshToken(token.RefreshToken, account, mech); err != nil {
return "", err
}
return token.AccessToken, nil
}
+57
View File
@@ -0,0 +1,57 @@
//
// This code is derived from the go-sasl library.
//
// Copyright (c) 2016 emersion
// Copyright (c) 2022, Oracle and/or its affiliates.
//
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"fmt"
"github.com/emersion/go-sasl"
)
// An XOAUTH2 error.
type Xoauth2Error struct {
Status string `json:"status"`
Schemes string `json:"schemes"`
Scope string `json:"scope"`
}
// Implements error.
func (err *Xoauth2Error) Error() string {
return fmt.Sprintf("XOAUTH2 authentication error (%v)", err.Status)
}
const XoauthMechanism string = "XOAUTH2"
type xoauth2Client struct {
Username string
Token string
}
func (a *xoauth2Client) Start() (mech string, ir []byte, err error) {
mech = XoauthMechanism
ir = []byte("user=" + a.Username + "\x01auth=Bearer " + a.Token + "\x01\x01")
return
}
func (a *xoauth2Client) Next(challenge []byte) ([]byte, error) {
// Server sent an error response
xoauth2Err := &Xoauth2Error{}
if err := json.Unmarshal(challenge, xoauth2Err); err != nil {
return nil, err
} else {
return nil, xoauth2Err
}
}
// An implementation of the XOAUTH2 authentication mechanism, as
// described in https://developers.google.com/gmail/xoauth2_protocol.
func NewXoauth2Client(username, token string) sasl.Client {
return &xoauth2Client{username, token}
}
-43
View File
@@ -1,43 +0,0 @@
package lib
import (
"context"
"fmt"
"github.com/emersion/go-imap/client"
"github.com/emersion/go-sasl"
"golang.org/x/oauth2"
)
type OAuthBearer struct {
OAuth2 *oauth2.Config
Enabled bool
}
func (c *OAuthBearer) ExchangeRefreshToken(refreshToken string) (*oauth2.Token, error) {
token := new(oauth2.Token)
token.RefreshToken = refreshToken
token.TokenType = "Bearer"
return c.OAuth2.TokenSource(context.TODO(), token).Token()
}
func (c *OAuthBearer) Authenticate(username string, password string, client *client.Client) error {
if ok, err := client.SupportAuth(sasl.OAuthBearer); err != nil || !ok {
return fmt.Errorf("OAuthBearer not supported %w", err)
}
if c.OAuth2.Endpoint.TokenURL != "" {
token, err := c.ExchangeRefreshToken(password)
if err != nil {
return err
}
password = token.AccessToken
}
saslClient := sasl.NewOAuthBearerClient(&sasl.OAuthBearerOptions{
Username: username,
Token: password,
})
return client.Authenticate(saslClient)
}
-26
View File
@@ -3,7 +3,6 @@ package send
import (
"fmt"
"math/rand"
"net/url"
"os"
"strconv"
"strings"
@@ -11,31 +10,6 @@ import (
"github.com/emersion/go-message/mail"
)
func parseScheme(uri *url.URL) (protocol string, auth string, err error) {
protocol = ""
auth = "plain"
if uri.Scheme != "" {
parts := strings.Split(uri.Scheme, "+")
switch len(parts) {
case 1:
protocol = parts[0]
case 2:
if parts[1] == "insecure" {
protocol = uri.Scheme
} else {
protocol = parts[0]
auth = parts[1]
}
case 3:
protocol = parts[0] + "+" + parts[1]
auth = parts[2]
default:
return "", "", fmt.Errorf("Unknown scheme %s", uri.Scheme)
}
}
return protocol, auth, nil
}
func GetMessageIdHostname(sendWithHostname bool, from *mail.Address) (string, error) {
if sendWithHostname {
return os.Hostname()
-77
View File
@@ -1,77 +0,0 @@
package send
import (
"fmt"
"net/url"
"github.com/emersion/go-sasl"
"golang.org/x/oauth2"
"git.sr.ht/~rjarry/aerc/lib"
)
func newSaslClient(auth string, uri *url.URL) (sasl.Client, error) {
var saslClient sasl.Client
switch auth {
case "":
fallthrough
case "none":
saslClient = nil
case "login":
password, _ := uri.User.Password()
saslClient = sasl.NewLoginClient(uri.User.Username(), password)
case "plain":
password, _ := uri.User.Password()
saslClient = sasl.NewPlainClient("", uri.User.Username(), password)
case "oauthbearer":
q := uri.Query()
oauth2 := &oauth2.Config{}
if q.Get("token_endpoint") != "" {
oauth2.ClientID = q.Get("client_id")
oauth2.ClientSecret = q.Get("client_secret")
oauth2.Scopes = []string{q.Get("scope")}
oauth2.Endpoint.TokenURL = q.Get("token_endpoint")
}
password, _ := uri.User.Password()
bearer := lib.OAuthBearer{
OAuth2: oauth2,
Enabled: true,
}
if bearer.OAuth2.Endpoint.TokenURL != "" {
token, err := bearer.ExchangeRefreshToken(password)
if err != nil {
return nil, err
}
password = token.AccessToken
}
saslClient = sasl.NewOAuthBearerClient(&sasl.OAuthBearerOptions{
Username: uri.User.Username(),
Token: password,
})
case "xoauth2":
q := uri.Query()
oauth2 := &oauth2.Config{}
if q.Get("token_endpoint") != "" {
oauth2.ClientID = q.Get("client_id")
oauth2.ClientSecret = q.Get("client_secret")
oauth2.Scopes = []string{q.Get("scope")}
oauth2.Endpoint.TokenURL = q.Get("token_endpoint")
}
password, _ := uri.User.Password()
bearer := lib.Xoauth2{
OAuth2: oauth2,
Enabled: true,
}
if bearer.OAuth2.Endpoint.TokenURL != "" {
token, err := bearer.ExchangeRefreshToken(password)
if err != nil {
return nil, err
}
password = token.AccessToken
}
saslClient = lib.NewXoauth2Client(uri.User.Username(), password)
default:
return nil, fmt.Errorf("Unsupported auth mechanism %s", auth)
}
return saslClient, nil
}
+4 -3
View File
@@ -9,6 +9,7 @@ import (
"github.com/emersion/go-message/mail"
"git.sr.ht/~rjarry/aerc/lib/auth"
"git.sr.ht/~rjarry/aerc/worker/types"
)
@@ -17,10 +18,10 @@ import (
// sender when finished.
func NewSender(
worker *types.Worker, uri *url.URL, domain string,
from *mail.Address, rcpts []*mail.Address,
from *mail.Address, rcpts []*mail.Address, account string,
copyTo []string, requestDSN bool,
) (io.WriteCloser, error) {
protocol, auth, err := parseScheme(uri)
protocol, mech, err := auth.ParseScheme(uri)
if err != nil {
return nil, err
}
@@ -29,7 +30,7 @@ func NewSender(
switch protocol {
case "smtp", "smtp+insecure", "smtps":
w, err = newSmtpSender(protocol, auth, uri, domain, from, rcpts, requestDSN)
w, err = newSmtpSender(protocol, mech, uri, domain, from, rcpts, account, requestDSN)
case "jmap":
w, err = newJmapSender(worker, from, rcpts, copyTo)
case "":
+5 -3
View File
@@ -7,6 +7,7 @@ import (
"net/url"
"strings"
"git.sr.ht/~rjarry/aerc/lib/auth"
"github.com/emersion/go-message/mail"
"github.com/emersion/go-smtp"
"github.com/pkg/errors"
@@ -81,8 +82,9 @@ func (s *smtpSender) Close() error {
}
func newSmtpSender(
protocol string, auth string, uri *url.URL, domain string,
from *mail.Address, rcpts []*mail.Address, requestDSN bool,
protocol string, mech string, uri *url.URL, domain string,
from *mail.Address, rcpts []*mail.Address, account string,
requestDSN bool,
) (io.WriteCloser, error) {
var err error
var conn *smtp.Client
@@ -101,7 +103,7 @@ func newSmtpSender(
return nil, errors.Wrap(err, "Connection failed")
}
saslclient, err := newSaslClient(auth, uri)
saslclient, err := auth.NewSaslClient(mech, uri, account)
if err != nil {
conn.Close()
return nil, err
-123
View File
@@ -1,123 +0,0 @@
//
// This code is derived from the go-sasl library.
//
// Copyright (c) 2016 emersion
// Copyright (c) 2022, Oracle and/or its affiliates.
//
// SPDX-License-Identifier: MIT
package lib
import (
"context"
"encoding/json"
"fmt"
"os"
"git.sr.ht/~rjarry/aerc/lib/xdg"
"github.com/emersion/go-imap/client"
"github.com/emersion/go-sasl"
"golang.org/x/oauth2"
)
// An XOAUTH2 error.
type Xoauth2Error struct {
Status string `json:"status"`
Schemes string `json:"schemes"`
Scope string `json:"scope"`
}
// Implements error.
func (err *Xoauth2Error) Error() string {
return fmt.Sprintf("XOAUTH2 authentication error (%v)", err.Status)
}
type xoauth2Client struct {
Username string
Token string
}
func (a *xoauth2Client) Start() (mech string, ir []byte, err error) {
mech = "XOAUTH2"
ir = []byte("user=" + a.Username + "\x01auth=Bearer " + a.Token + "\x01\x01")
return
}
func (a *xoauth2Client) Next(challenge []byte) ([]byte, error) {
// Server sent an error response
xoauth2Err := &Xoauth2Error{}
if err := json.Unmarshal(challenge, xoauth2Err); err != nil {
return nil, err
} else {
return nil, xoauth2Err
}
}
// An implementation of the XOAUTH2 authentication mechanism, as
// described in https://developers.google.com/gmail/xoauth2_protocol.
func NewXoauth2Client(username, token string) sasl.Client {
return &xoauth2Client{username, token}
}
type Xoauth2 struct {
OAuth2 *oauth2.Config
Enabled bool
}
func (c *Xoauth2) ExchangeRefreshToken(refreshToken string) (*oauth2.Token, error) {
token := new(oauth2.Token)
token.RefreshToken = refreshToken
token.TokenType = "Bearer"
return c.OAuth2.TokenSource(context.TODO(), token).Token()
}
func SaveRefreshToken(refreshToken string, acct string) error {
p := xdg.CachePath("aerc", acct+"-xoauth2.token")
_ = os.MkdirAll(xdg.CachePath("aerc"), 0o700)
return os.WriteFile(
p,
[]byte(refreshToken),
0o600,
)
}
func GetRefreshToken(acct string) ([]byte, error) {
p := xdg.CachePath("aerc", acct+"-xoauth2.token")
return os.ReadFile(p)
}
func (c *Xoauth2) Authenticate(
username string,
password string,
account string,
client *client.Client,
) error {
if ok, err := client.SupportAuth("XOAUTH2"); err != nil || !ok {
return fmt.Errorf("Xoauth2 not supported %w", err)
}
if c.OAuth2.Endpoint.TokenURL != "" {
usedCache := false
if r, err := GetRefreshToken(account); err == nil && len(r) > 0 {
password = string(r)
usedCache = true
}
token, err := c.ExchangeRefreshToken(password)
if err != nil {
if usedCache {
return fmt.Errorf("try removing cached refresh token. %w", err)
}
return err
}
password = token.AccessToken
if err := SaveRefreshToken(token.RefreshToken, account); err != nil {
return err
}
}
saslClient := NewXoauth2Client(username, password)
return client.Authenticate(saslClient)
}
+2 -44
View File
@@ -13,7 +13,6 @@ import (
"git.sr.ht/~rjarry/aerc/worker/lib"
"git.sr.ht/~rjarry/aerc/worker/middleware"
"git.sr.ht/~rjarry/aerc/worker/types"
"golang.org/x/oauth2"
)
func (w *IMAPWorker) handleConfigure(msg *types.Configure) error {
@@ -23,50 +22,9 @@ func (w *IMAPWorker) handleConfigure(msg *types.Configure) error {
return err
}
w.config.scheme = u.Scheme
if before, ok := strings.CutSuffix(w.config.scheme, "+insecure"); ok {
w.config.scheme = before
w.config.insecure = true
}
w.config.provider = w.providerFromURL(u.Host)
w.config.url = u
if before, ok := strings.CutSuffix(w.config.scheme, "+oauthbearer"); ok {
w.config.scheme = before
w.config.oauthBearer.Enabled = true
q := u.Query()
oauth2 := &oauth2.Config{}
if q.Get("token_endpoint") != "" {
oauth2.ClientID = q.Get("client_id")
oauth2.ClientSecret = q.Get("client_secret")
oauth2.Scopes = []string{q.Get("scope")}
oauth2.Endpoint.TokenURL = q.Get("token_endpoint")
}
w.config.oauthBearer.OAuth2 = oauth2
}
if before, ok := strings.CutSuffix(w.config.scheme, "+xoauth2"); ok {
w.config.scheme = before
w.config.xoauth2.Enabled = true
q := u.Query()
oauth2 := &oauth2.Config{}
if q.Get("token_endpoint") != "" {
oauth2.ClientID = q.Get("client_id")
oauth2.ClientSecret = q.Get("client_secret")
oauth2.Scopes = []string{q.Get("scope")}
oauth2.Endpoint.TokenURL = q.Get("token_endpoint")
}
w.config.xoauth2.OAuth2 = oauth2
}
w.config.addr = u.Host
if !strings.ContainsRune(w.config.addr, ':') {
w.config.addr += ":" + w.config.scheme
}
w.config.provider = w.providerFromURL(w.config.addr)
w.config.user = u.User
w.config.folders = msg.Config.Folders
w.config.headers = msg.Config.Headers
w.config.headersExclude = msg.Config.HeadersExclude
+40 -30
View File
@@ -5,13 +5,14 @@ import (
"fmt"
"net"
"os"
"strings"
"time"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/auth"
"git.sr.ht/~rjarry/aerc/lib/log"
"github.com/emersion/go-imap"
"github.com/emersion/go-imap/client"
"github.com/emersion/go-sasl"
)
// connect establishes a new tcp connection to the imap server, logs in and
@@ -24,7 +25,17 @@ func (w *IMAPWorker) connect() (*client.Client, error) {
c *client.Client
)
conn, err = newTCPConn(w.config.addr, w.config.connection_timeout)
protocol, mech, err := auth.ParseScheme(w.config.url)
if err != nil {
return nil, err
}
addr := w.config.url.Host
if !strings.Contains(addr, ":") {
addr += ":" + protocol
}
conn, err = newTCPConn(addr, w.config.connection_timeout)
if conn == nil || err != nil {
return nil, err
}
@@ -44,22 +55,24 @@ func (w *IMAPWorker) connect() (*client.Client, error) {
}
}
serverName, _, _ := net.SplitHostPort(w.config.addr)
serverName, _, _ := net.SplitHostPort(addr)
tlsConfig := &tls.Config{ServerName: serverName}
switch w.config.scheme {
protocol, insecure := strings.CutSuffix(protocol, "+insecure")
switch protocol {
case "imap":
c, err = client.New(conn)
if err != nil {
return nil, err
}
if !w.config.insecure {
if !insecure {
if err = c.StartTLS(tlsConfig); err != nil {
return nil, err
}
}
case "imaps":
if w.config.insecure {
if insecure {
tlsConfig.InsecureSkipVerify = true
}
tlsConn := tls.Client(conn, tlsConfig)
@@ -68,36 +81,33 @@ func (w *IMAPWorker) connect() (*client.Client, error) {
return nil, err
}
default:
return nil, fmt.Errorf("Unknown IMAP scheme %s", w.config.scheme)
return nil, fmt.Errorf("Unknown IMAP scheme %s", protocol)
}
c.ErrorLog = log.ErrorLogger()
if w.config.user != nil {
username := w.config.user.Username()
// TODO: 2nd parameter false if no password is set. ask for it
// if unset.
password, _ := w.config.user.Password()
if w.config.oauthBearer.Enabled {
if err := w.config.oauthBearer.Authenticate(
username, password, c); err != nil {
return nil, err
}
} else if w.config.xoauth2.Enabled {
if err := w.config.xoauth2.Authenticate(
username, password, w.config.name, c); err != nil {
return nil, err
}
} else if plain, err := c.SupportAuth("PLAIN"); err != nil {
if w.config.url.User != nil && mech == "" {
if plain, err := c.SupportAuth("PLAIN"); err != nil {
return nil, err
} else if plain {
auth := sasl.NewPlainClient("", username, password)
if err := c.Authenticate(auth); err != nil {
return nil, err
}
} else if err := c.Login(username, password); err != nil {
mech = "plain"
} else {
mech = "login"
}
}
saslClient, err := auth.NewSaslClient(mech, w.config.url, w.config.name)
if err != nil {
return nil, err
}
if saslClient != nil {
if ok, err := c.SupportAuth(strings.ToUpper(mech)); err != nil {
return nil, err
} else if !ok {
return nil, fmt.Errorf("%s auth not supported", mech)
}
err = c.Authenticate(saslClient)
if err != nil {
return nil, err
}
}
+1 -7
View File
@@ -11,7 +11,6 @@ import (
"github.com/pkg/errors"
"github.com/syndtr/goleveldb/leveldb"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/handlers"
"git.sr.ht/~rjarry/aerc/worker/imap/extensions"
@@ -52,16 +51,11 @@ type imapClient struct {
type imapConfig struct {
name string
scheme string
insecure bool
addr string
url *url.URL
provider imapProvider
user *url.Userinfo
headers []string
headersExclude []string
folders []string
oauthBearer lib.OAuthBearer
xoauth2 lib.Xoauth2
idle_timeout time.Duration
idle_debounce time.Duration
reconnect_maxwait time.Duration