Repository: robocorp/rpaframework

Issue:
`RPA.Email.Exchange` Implement Oauth2 flow as basic auth is being deprecated
Per the top of the [Exchange Web Services API Overview page](https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/get-started-with-ews-managed-api-client-applications), Microsoft is deprecating basic authentication by October 2022. We need to implement the Oauth2 flow in its place. The underlying `credentials.py` appears to already have a class for Oauth2.


Discussion:

Resolving patch:
diff --git a/packages/main/src/RPA/Email/Exchange.py b/packages/main/src/RPA/Email/Exchange.py
--- a/packages/main/src/RPA/Email/Exchange.py
+++ b/packages/main/src/RPA/Email/Exchange.py
@@ -1,15 +1,15 @@
 import datetime
+import email
 import logging
-from multiprocessing import AuthenticationError
 import os
-from pathlib import Path
 import re
 import time
-from typing import Any, List, Optional, Union
-import email
 from email import policy  # pylint: disable=no-name-in-module
-import pytz
+from multiprocessing import AuthenticationError
+from pathlib import Path
+from typing import Any, Callable, List, Optional, Union
 
+import pytz
 from exchangelib import (
     Account,
     Configuration,
@@ -19,11 +19,15 @@
     Folder,
     HTMLBody,
     IMPERSONATION,
+    Identity,
     ItemAttachment,
     Mailbox,
     Message,
+    OAUTH2,
+    OAuth2AuthorizationCodeCredentials,
     UTC,
 )
+from oauthlib.oauth2 import OAuth2Token
 
 
 def mailbox_to_email_address(mailbox):
@@ -55,6 +59,18 @@ class NoRecipientsError(ValueError):
     """Raised when email to be sent does not have any recipients, cc or bcc addresses."""  # noqa: E501
 
 
+class OAuth2Creds(OAuth2AuthorizationCodeCredentials):
+
+    """OAuth2 auth code flow credentials wrapper supporting token state on refresh."""
+
+    def __init__(self, *args, on_token_refresh: Callable, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._on_token_refresh = on_token_refresh
+
+    def on_token_auto_refreshed(self, access_token: OAuth2Token):
+        self._on_token_refresh(access_token)
+
+
 class Exchange:
     """`Exchange` is a library for sending, reading, and deleting emails.
     `Exchange` is interfacing with Exchange Web Services (EWS).
@@ -196,24 +212,40 @@ def __init__(self) -> None:
         self.account = None
         self._saved_attachments = []
 
+    def on_token_refresh(self, token: OAuth2Token):
+        """Callable you can override in order to save the newly obtained token in a
+        safe place.
+        """
+        self.logger.info(
+            "OAuth2 token was refreshed. (new expiry: %d)", token["expires_at"]
+        )
+
     def authorize(
         self,
         username: str,
-        password: str,
+        password: Optional[str] = None,
         autodiscover: Optional[bool] = True,
         access_type: Optional[str] = "DELEGATE",
         server: Optional[str] = None,
         primary_smtp_address: Optional[str] = None,
+        is_oauth: bool = False,
+        client_id: Optional[str] = None,
+        client_secret: Optional[str] = None,
+        token: Optional[dict] = None,
     ) -> None:
         """Connect to Exchange account
 
         :param username: account username
-        :param password: account password
-        :param autodiscover: use autodiscover or set it off
-        :param accesstype: default "DELEGATE", other option "IMPERSONATION"
-        :param server: required for configuration options
+        :param password: account password (can be skipped with OAuth2)
+        :param autodiscover: use autodiscover or set it off (on by default)
+        :param access_type: default "DELEGATE", other option "IMPERSONATION"
+        :param server: required for configuration setting (with `autodiscover` off)
         :param primary_smtp_address: by default set to username, but can be
-            set to be different than username
+            set to be different from username
+        :param is_oauth: use the OAuth2 authorization code flow (instead of basic auth)
+        :param client_id: registered application ID
+        :param client_secret: registered application secret (password)
+        :param token: contains access and refresh tokens, type, scope, expiry etc.
         """
         kwargs = {}
         kwargs["autodiscover"] = autodiscover
@@ -223,13 +255,37 @@ def authorize(
         kwargs["primary_smtp_address"] = (
             primary_smtp_address if primary_smtp_address else username
         )
-        self.credentials = Credentials(username, password)
+        if is_oauth:
+            self.credentials = OAuth2Creds(
+                on_token_refresh=self.on_token_refresh,
+                identity=Identity(upn=username),
+                client_id=client_id,
+                client_secret=client_secret,
+                # Contains at least a non-expired access token or non-revoked refresh
+                #  one. (otherwise an authorization code should be present inside)
+                access_token=OAuth2Token(params=token) if token else None,
+            )
+        else:
+            if password is None:
+                raise ValueError("A password should be provided with basic auth")
+            self.credentials = Credentials(username, password)
         if server:
-            self.config = Configuration(server=server, credentials=self.credentials)
+            self.config = Configuration(
+                server=server,
+                credentials=self.credentials,
+                # Automatically detects authentication type based on the provided
+                #  `credentials` object. (when not using OAuth2)
+                auth_type=OAUTH2 if is_oauth else None,
+            )
             kwargs["config"] = self.config
         else:
             kwargs["credentials"] = self.credentials
 
+        if self.config and autodiscover:
+            self.logger.warning(
+                "Autodiscovery is left ON while using custom configuration, you may "
+                "need to turn it OFF in order to use the custom setting."
+            )
         self.account = Account(**kwargs)
 
     def list_messages(


Test patch:
diff --git a/packages/main/tests/python/test_exchange.py b/packages/main/tests/python/test_exchange.py
--- a/packages/main/tests/python/test_exchange.py
+++ b/packages/main/tests/python/test_exchange.py
@@ -4,7 +4,6 @@
 from RPA.Email.Exchange import Exchange, UTC
 from RPA.Robocorp.Vault import Vault
 from . import RESOURCES_DIR
-from exchangelib.account import Account
 
 SENDMAIL_MOCK = "RPA.Email.Exchange.Message.send"
 recipient = "person1@domain.com"
@@ -16,7 +15,6 @@
 @pytest.fixture
 def library():
     lib = Exchange()
-    lib.account = Account(primary_smtp_address="user@domain.com")
     return lib
 
 
@@ -202,17 +200,43 @@ def test_get_filter_by_key_value_multiple_conditions(library):
         assert result == expected
 
 
-@pytest.mark.skip(reason="requires vault and valid email account")
-def test_send_message_with_only_bcc_addresses(library):
+@pytest.mark.skip(reason="requires Vault and valid email account")
+@pytest.mark.parametrize(
+    "is_oauth, to_addr",
+    [
+        (True, "cosmin-robocorp@ztzvn.onmicrosoft.com"),
+        (False, "cosmin-robocorp@ztzvn.onmicrosoft.com"),
+        (False, None),
+    ],
+)
+def test_send_message_with_out_bcc_addresses(library, is_oauth, to_addr):
     secrets = Vault().get_secret("Exchange")
+    if is_oauth:
+        params = {
+            "access_type": "IMPERSONATE",
+            "client_id": secrets["client_id"],
+            "client_secret": secrets["client_secret"],
+            "token": secrets["token"],
+        }
+    else:
+        params = {
+            "password": secrets["password"],
+        }
     library.authorize(
         username=secrets["account"],
-        password=secrets["password"],
         autodiscover=False,
         server="outlook.office365.com",
+        is_oauth=is_oauth,
+        **params,
     )
-    bcc_list = ["robocorp.tester@gmail.com", "robocorp.tester.2@gmail.com"]
+    if to_addr:
+        recipient = to_addr
+        bcc_list = []
+    else:
+        recipient = None
+        bcc_list = ["robocorp.tester@gmail.com", "robocorp.tester.2@gmail.com"]
     library.send_message(
+        recipients=recipient,
         bcc=bcc_list,
         subject="test_send_message_with_only_bcc_addresses",
         body="test_send_message_with_only_bcc_addresses",