imap,smtp: add XOAUTH2 support

Add XOAUTH2 authentication support for IMAP and SMTP. Although XOAUTH2
is now deprecated in favor of OAuthBearer, it is the only way to connect
to Office365 since Basic Auth is now completely removed.

Since XOAUTH2 is very similar to OAuthBearer and uses the same
configuration parameters, this is basically a copy-paste of the existing
OAuthBearer code.

However, XOAUTH2 support was removed from go-sasl library, so this
change reimports the code that was removed from go-sasl and offers it
a new home in lib/xoauth2.go. Hopefully it shouldn't be too hard to
maintain, being less than 50 SLOC.

Link: https://github.com/emersion/go-sasl/commit/7bfe0ed36a21
Implements: https://todo.sr.ht/~rjarry/aerc/78
Signed-off-by: Julian Pidancet <julian.pidancet@oracle.com>
Tested-by: Inwit <inwit@sindominio.net>
Acked-by: Tim Culverhouse <tim@timculverhouse.com>
This commit is contained in:
Julian Pidancet 2022-09-28 19:49:11 +02:00 committed by Robin Jarry
parent 45bff88515
commit 9217dbeea4
8 changed files with 142 additions and 2 deletions

View file

@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Override `:open` handler on a per-MIME-type basis in `aerc.conf`.
- Specify opener as the first `:open` param instead of always using default
handler (i.e. `:open gimp` to open attachment in GIMP).
- Restored XOAUTH2 support for IMAP and SMTP.
### Changed

View file

@ -302,6 +302,28 @@ func newSaslClient(auth string, uri *url.URL) (sasl.Client, error) {
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)
}

View file

@ -19,7 +19,7 @@ In accounts.conf (see *aerc-config*(5)), the following IMAP-specific options are
available:
*source*
imap[s][+insecure|+oauthbearer]://username[:password]@hostname[:port]?[:oauth2_params]
imap[s][+insecure|+oauthbearer|+xoauth2]://username[:password]@hostname[:port]?[:oauth2_params]
Remember that all fields must be URL encoded. The "@" symbol, when URL
encoded, is *%40*.
@ -53,6 +53,10 @@ available:
Example:
imaps+oauthbearer://...?token_endpoint=https://...&client_id=
*imaps+xoauth2://*
IMAP with TLS/SSL using XOAUTH2 Authentication. Parameters are
the same as OAUTHBEARER.
*source-cred-cmd*
Specifies the command to run to get the password for the IMAP
account. This command will be run using `sh -c [command]`. If a

View file

@ -16,7 +16,7 @@ In accounts.conf (see *aerc-config*(5)), the following SMTP-specific options are
available:
*outgoing*
smtp[s][+plain|+login|+none|+oauthbearer]://username[:password]@hostname[:port]?[:oauth2_params]
smtp[s][+plain|+login|+none|+oauthbearer|+xoauth2]://username[:password]@hostname[:port]?[:oauth2_params]
Remember that all fields must be URL encoded. The "@" symbol, when URL
encoded, is *%40*.
@ -47,6 +47,10 @@ available:
SMTP with TLS/SSL using OAUTHBEARER Authentication. See documentation in
*aerc-imap*(5) for usage.
*+xoauth2*:
SMTP with TLS/SSL using XOAUTH2 Authentication. See documentation in
*aerc-imap*(5) for usage.
*outgoing-cred-cmd*
Specifies the command to run to get the password for the SMTP
account. This command will be run using `sh -c [command]`. If a

88
lib/xoauth2.go Normal file
View file

@ -0,0 +1,88 @@
//
// 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"
"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 (c *Xoauth2) Authenticate(username string, password 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 != "" {
token, err := c.ExchangeRefreshToken(password)
if err != nil {
return err
}
password = token.AccessToken
}
saslClient := NewXoauth2Client(username, password)
return client.Authenticate(saslClient)
}

View file

@ -38,6 +38,21 @@ func (w *IMAPWorker) handleConfigure(msg *types.Configure) error {
w.config.oauthBearer.OAuth2 = oauth2
}
if strings.HasSuffix(w.config.scheme, "+xoauth2") {
w.config.scheme = strings.TrimSuffix(w.config.scheme, "+xoauth2")
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

View file

@ -80,6 +80,11 @@ func (w *IMAPWorker) connect() (*client.Client, error) {
username, password, c); err != nil {
return nil, err
}
} else if w.config.xoauth2.Enabled {
if err := w.config.xoauth2.Authenticate(
username, password, c); err != nil {
return nil, err
}
} else if err := c.Login(username, password); err != nil {
return nil, err
}

View file

@ -43,6 +43,7 @@ type imapConfig struct {
user *url.Userinfo
folders []string
oauthBearer lib.OAuthBearer
xoauth2 lib.Xoauth2
idle_timeout time.Duration
idle_debounce time.Duration
reconnect_maxwait time.Duration