aboutsummaryrefslogtreecommitdiffstats
path: root/cloud_mdir_sync
diff options
context:
space:
mode:
authorJason Gunthorpe <jgg@mellanox.com>2020-05-28 10:13:37 -0300
committerJason Gunthorpe <jgg@mellanox.com>2020-05-28 11:41:47 -0300
commit192d633a13adf2d552f4257f4975b066204b9da9 (patch)
tree544fd2e7ec777e2bbf123369fd3de064958425c6 /cloud_mdir_sync
parent72b3e5ff5d68d9f70257f9556068cc1e5de23e1c (diff)
downloadcloud_mdir_sync-192d633a13adf2d552f4257f4975b066204b9da9.tar.gz
cloud_mdir_sync-192d633a13adf2d552f4257f4975b066204b9da9.tar.bz2
cloud_mdir_sync-192d633a13adf2d552f4257f4975b066204b9da9.zip
Add OAUTH Credential server
The OAUTH credential server allows CMS to ack as an OAUTH broker and supply bearer tokens to other applications in the system. Currently this only support SMTP tokens for outbound mail delivery. A UNIX domain socket is used to communicate between the SMTP agent and CMS. A simple one line protocol is used to specify the account requested and CMS returns the plain XAOUTH2 response string. The agent is responsible to base64 encode it. This works for GMail and O365 mailboxes. Signed-off-by: Jason Gunthorpe <jgg@mellanox.com>
Diffstat (limited to 'cloud_mdir_sync')
-rw-r--r--cloud_mdir_sync/config.py5
-rw-r--r--cloud_mdir_sync/credsrv.py65
-rw-r--r--cloud_mdir_sync/gmail.py34
-rw-r--r--cloud_mdir_sync/oauth.py25
-rw-r--r--cloud_mdir_sync/office365.py29
5 files changed, 143 insertions, 15 deletions
diff --git a/cloud_mdir_sync/config.py b/cloud_mdir_sync/config.py
index 88a16b6..79ab4a9 100644
--- a/cloud_mdir_sync/config.py
+++ b/cloud_mdir_sync/config.py
@@ -116,5 +116,10 @@ class Config(object):
self.local_mboxes.append(MailDirMailbox(self, directory))
return self.local_mboxes[-1]
+ def CredentialServer(self, path: str, accounts: List, umask=0o600):
+ from .credsrv import CredentialServer
+ self.async_tasks.append(CredentialServer(self, path, accounts, umask))
+ return self.async_tasks[-1]
+
def _direct_message(self, msg):
return self.local_mboxes[0]
diff --git a/cloud_mdir_sync/credsrv.py b/cloud_mdir_sync/credsrv.py
new file mode 100644
index 0000000..97357bc
--- /dev/null
+++ b/cloud_mdir_sync/credsrv.py
@@ -0,0 +1,65 @@
+# SPDX-License-Identifier: GPL-2.0+
+import asyncio
+import contextlib
+import os
+import re
+from typing import List
+
+from . import config, oauth
+
+
+class CredentialServer(object):
+ """Serve XOAUTH2 bearer tokens over a unix domain socket. The client
+ writes the user to obtain a token for and the server responds with the
+ token"""
+ def __init__(self,
+ cfg: config.Config,
+ path: str,
+ accounts: List[oauth.Account],
+ umask=0o600):
+ self.cfg = cfg
+ self.path = os.path.abspath(os.path.expanduser(path))
+ self.umask = umask
+ self.accounts = {}
+ for I in accounts:
+ I.oauth_smtp = True
+ self.accounts[I.user] = I
+
+ async def go(self):
+ old_umask = os.umask(self.umask)
+ try:
+ self.server = await asyncio.start_unix_server(
+ self.handle_client, self.path)
+ finally:
+ os.umask(old_umask)
+ os.chmod(self.path, self.umask)
+
+ async def close(self):
+ pass
+
+ async def handle_client(self, reader: asyncio.StreamReader,
+ writer: asyncio.StreamWriter) -> None:
+ with contextlib.closing(writer):
+ req = await reader.read()
+ g = re.match(r"([^ ,]+)(?:,(\S+))? (\S+@\S+)", req.decode())
+ if g is None:
+ self.cfg.logger.error(f"Invalid credential request {req!r}")
+ return
+ proto, opts_str, user = g.groups()
+ if opts_str:
+ opts = opts_str.split(',')
+ else:
+ opts = []
+
+ self.cfg.logger.debug(
+ f"Credential request {proto!r} {opts} {user!r}")
+
+ account = self.accounts.get(user)
+ if account is None:
+ return
+
+ xoauth2 = await account.get_xoauth2_bytes("SMTP")
+ if xoauth2 is None:
+ return
+ writer.write(xoauth2)
+ await writer.drain()
diff --git a/cloud_mdir_sync/gmail.py b/cloud_mdir_sync/gmail.py
index b22291f..b6d1490 100644
--- a/cloud_mdir_sync/gmail.py
+++ b/cloud_mdir_sync/gmail.py
@@ -14,7 +14,7 @@ import aiohttp
import oauthlib
import requests_oauthlib
-from . import config, mailbox, messages, util
+from . import config, mailbox, messages, oauth, util
from .util import asyncio_complete
@@ -108,7 +108,7 @@ def _retry_protect(func):
return async_wrapper
-class GmailAPI(object):
+class GmailAPI(oauth.Account):
"""An OAUTH2 authenticated session to the Google gmail API"""
# From ziepe.ca
client_id = "14979213351-bik90v3b8b9f22160ura3oah71u3l113.apps.googleusercontent.com"
@@ -119,9 +119,8 @@ class GmailAPI(object):
headers: Optional[Dict[str, str]] = None
def __init__(self, cfg: config.Config, user: str):
+ super().__init__(cfg, user)
self.domain_id = f"gmail-{user}"
- self.cfg = cfg
- self.user = user
async def go(self):
cfg = self.cfg
@@ -130,18 +129,22 @@ class GmailAPI(object):
self.session = aiohttp.ClientSession(connector=connector,
raise_for_status=False)
+ scopes = [
+ "https://www.googleapis.com/auth/gmail.modify",
+ ]
+ if self.oauth_smtp:
+ scopes.append("https://mail.google.com/")
+
self.redirect_url = cfg.web_app.url + "oauth2/gmail"
self.api_token = cfg.msgdb.get_authenticator(self.domain_id)
+ if not oauth.check_scopes(self.api_token, scopes):
+ self.api_token = None
self.oauth = requests_oauthlib.OAuth2Session(
client_id=self.client_id,
client=NativePublicApplicationClient(self.client_id),
redirect_uri=self.redirect_url,
token=self.api_token,
- scope=[
- "https://www.googleapis.com/auth/gmail.modify",
- # This one is needed for SMTP ?
- #"https://mail.google.com/",
- ])
+ scope=scopes)
if self.api_token:
self._set_token()
@@ -296,6 +299,19 @@ class GmailAPI(object):
async def close(self):
await self.session.close()
+ async def get_xoauth2_bytes(self, proto: str) -> bytes:
+ """Return the xoauth2 byte string for the given protocol to login to
+ this account."""
+ while self.api_token is None:
+ await self.authenticate()
+
+ if proto == "SMTP":
+ res = 'user=%s\1auth=%s %s\1\1' % (self.user,
+ self.api_token["token_type"],
+ self.api_token["access_token"])
+ return res.encode()
+ return None
+
class GMailMessage(messages.Message):
gmail_labels: Optional[Set[str]] = None
diff --git a/cloud_mdir_sync/oauth.py b/cloud_mdir_sync/oauth.py
index 163dcba..55f3f31 100644
--- a/cloud_mdir_sync/oauth.py
+++ b/cloud_mdir_sync/oauth.py
@@ -1,10 +1,35 @@
# SPDX-License-Identifier: GPL-2.0+
import asyncio
import os
+from abc import abstractmethod
+from typing import TYPE_CHECKING, List
import aiohttp
import aiohttp.web
+if TYPE_CHECKING:
+ from . import config
+
+
+def check_scopes(token, required_scopes: List[str]) -> bool:
+ if token is None:
+ return False
+ tscopes = set(token.get("scope", []))
+ return set(required_scopes).issubset(tscopes)
+
+
+class Account(object):
+ """An OAUTH2 account"""
+ oauth_smtp = False
+
+ def __init__(self, cfg: "config.Config", user: str):
+ self.cfg = cfg
+ self.user = user
+
+ @abstractmethod
+ async def get_xoauth2_bytes(self, proto: str) -> bytes:
+ pass
+
class WebServer(object):
"""A small web server is used to manage oauth requests. The user should point a browser
diff --git a/cloud_mdir_sync/office365.py b/cloud_mdir_sync/office365.py
index 09d8492..4945724 100644
--- a/cloud_mdir_sync/office365.py
+++ b/cloud_mdir_sync/office365.py
@@ -12,7 +12,7 @@ from typing import Any, Dict, Optional, Union
import aiohttp
import requests
-from . import config, mailbox, messages, util
+from . import config, mailbox, messages, oauth, util
from .util import asyncio_complete
@@ -67,21 +67,20 @@ def _retry_protect(func):
return async_wrapper
-class GraphAPI(object):
+class GraphAPI(oauth.Account):
"""An OAUTH2 authenticated session to the Microsoft Graph API"""
graph_scopes = [
"https://graph.microsoft.com/User.Read",
"https://graph.microsoft.com/Mail.ReadWrite"
]
- graph_token = None
+ graph_token: Optional[Dict[str,str]] = None
owa_scopes = ["https://outlook.office.com/mail.read"]
owa_token = None
authenticator = None
def __init__(self, cfg: config.Config, user: str, tenant: str):
+ super().__init__(cfg, user)
self.domain_id = f"o365-{user}-{tenant}"
- self.cfg = cfg
- self.user = user
self.tenant = tenant
if self.user is not None:
@@ -93,7 +92,7 @@ class GraphAPI(object):
# with our caching scheme. See
# https://docs.microsoft.com/en-us/graph/outlook-immutable-id
self.headers= {"Prefer": 'IdType="ImmutableId"'}
- self.owa_headers = {}
+ self.owa_headers: Dict[str, str] = {}
async def go(self):
import msal
@@ -111,6 +110,11 @@ class GraphAPI(object):
authority=f"https://login.microsoftonline.com/{self.tenant}",
token_cache=self.msl_cache)
+ if self.oauth_smtp:
+ self.owa_scopes = self.owa_scopes + [
+ "https://outlook.office.com/SMTP.Send"
+ ]
+
def _cached_authenticate(self):
accounts = self.msal.get_accounts(self.user)
if len(accounts) != 1:
@@ -352,6 +356,19 @@ class GraphAPI(object):
async def close(self):
await self.session.close()
+ async def get_xoauth2_bytes(self, proto: str) -> bytes:
+ """Return the xoauth2 byte string for the given protocol to login to
+ this account."""
+ while self.owa_token is None:
+ await self.authenticate()
+
+ if proto == "SMTP":
+ res = 'user=%s\1auth=%s %s\1\1' % (self.user,
+ self.owa_token["token_type"],
+ self.owa_token["access_token"])
+ return res.encode()
+ return None
+
class O365Mailbox(mailbox.Mailbox):
"""Cloud Office365 mailbox using the Microsoft Graph RESET API for data access"""