diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fb0cb1..4602f94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/commands/compose/send.go b/commands/compose/send.go index ec9e06b..3786721 100644 --- a/commands/compose/send.go +++ b/commands/compose/send.go @@ -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) } diff --git a/doc/aerc-imap.5.scd b/doc/aerc-imap.5.scd index 39ac337..2a5f6c8 100644 --- a/doc/aerc-imap.5.scd +++ b/doc/aerc-imap.5.scd @@ -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 diff --git a/doc/aerc-smtp.5.scd b/doc/aerc-smtp.5.scd index 16ff605..ee2aa17 100644 --- a/doc/aerc-smtp.5.scd +++ b/doc/aerc-smtp.5.scd @@ -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 diff --git a/lib/xoauth2.go b/lib/xoauth2.go new file mode 100644 index 0000000..83f0630 --- /dev/null +++ b/lib/xoauth2.go @@ -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) +} diff --git a/worker/imap/configure.go b/worker/imap/configure.go index 691e0d7..a9689f6 100644 --- a/worker/imap/configure.go +++ b/worker/imap/configure.go @@ -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 diff --git a/worker/imap/connect.go b/worker/imap/connect.go index 7c43b56..035feab 100644 --- a/worker/imap/connect.go +++ b/worker/imap/connect.go @@ -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 } diff --git a/worker/imap/worker.go b/worker/imap/worker.go index 66e4cdf..c5032a0 100644 --- a/worker/imap/worker.go +++ b/worker/imap/worker.go @@ -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