Twisted mail vs XOAUTH2 (google)
I am working on a email thing using the excellent asynchronous framework Twisted (like Node.js but been around much longer and is more “mature”).
Anyway, Twisted has great asynchronous imap support through the twisted.mail.imap4 module. There is to my knowledge no support for OAUTH in any form in this module. And since the service I am developing focuses on Gmail, oauth seems like a good idea even though Gmail supports standard imap login. Nobody would want to give out their gmail login credentials on a public web site.
So to be a bit more specific, the preferred auth method for gmail is XOAUTH2.
So the question is, how do we add support for the XOAUTH2 authentication method? imap4.IMAP4Client already contains a system for adding authentication providers (e.g. imap4.PLAINAuthenticator and a few others through the registerAuthenticator method). The problem is that these methods are build based on the fact that the authentication / login sequence is performed in several steps. Typically you start by initiating the authentication by sending an AUTHENTICATE command with the preferred method. Then you get a server response and you continue the sequence, e.g:
To login using XOAUTH2, you just add the access token along with the XOAUTH on the AUTHENTICATE command:
AUTHENTICATE XOAUTH2 token
Okay, so far so good. But how is this thing added to the twisted imap4 mail module?
Actually I have not find a clean way yet. What I needed to do was to override several methods in my imap4.IMAP4Client subclass. Unfortunately the main fix is within a private method so there is more overriding going on than actually would be necessary (we need to replace all private methods that exists in the entire callback chain of the autenticate method).
First off I wrote an XOATH2 authenticator:
class XOAUTH2Authenticator: implements(imap4.IClientAuthentication) def __init__(self, username, token, base64Encode = False): authString = 'user=%s\1auth=Bearer %s\1\1' % (username, token) if base64Encode: authString = base64.b64encode(authString) self.args = authString def getName(self): return "XOAUTH2"
And then I implemented (overridden) the following methods in my subclass. Note that the actual addition is the following section:
authenticator = self.authenticators[scheme] if (hasattr(authenticator, 'args')): cmd = imap4.Command('AUTHENTICATE', scheme + " " + authenticator.args) else: cmd = Command('AUTHENTICATE', scheme, (), self.__cbContinueAuth, scheme, secret)
This section aborts further authenticate interaction by not supplying the __cbContinueAuth callback. Instead it sends the token through the authenticator.args property together with the authentication scheme (XOAUTH2).
And here are all the methods that I needed to be override. Note that I have not tested this extensively yet. And obviously it will not be safe for future changes in the imap4 module.
def authenticate(self, secret): if self._capCache is None: d = self.getCapabilities() else: d = defer.succeed(self._capCache) d.addCallback(self.__cbAuthenticate, secret) return d def __cbAuthenticate(self, caps, secret): auths = caps.get('AUTH', ()) for scheme in auths: if scheme.upper() in self.authenticators: authenticator = self.authenticators[scheme] if (hasattr(authenticator, 'args')): cmd = imap4.Command('AUTHENTICATE', scheme + " " + authenticator.args) else: cmd = Command('AUTHENTICATE', scheme, (), self.__cbContinueAuth, scheme, secret) return self.sendCommand(cmd) if self.startedTLS: return defer.fail(NoSupportedAuthentication( auths, self.authenticators.keys())) else: def ebStartTLS(err): err.trap(IMAP4Exception) # We couldn't negotiate TLS for some reason return defer.fail(NoSupportedAuthentication( auths, self.authenticators.keys())) d = self.startTLS() d.addErrback(ebStartTLS) d.addCallback(lambda _: self.getCapabilities()) d.addCallback(self.__cbAuthTLS, secret) return d def __cbContinueAuth(self, rest, scheme, secret): try: chal = base64.decodestring(rest + '\n') except binascii.Error: self.sendLine('*') raise IllegalServerResponse(rest) self.transport.loseConnection() else: auth = self.authenticators[scheme] chal = auth.challengeResponse(secret, chal) self.sendLine(base64.encodestring(chal).strip()) def __cbAuthTLS(self, caps, secret): auths = caps.get('AUTH', ()) for scheme in auths: if scheme.upper() in self.authenticators: cmd = Command('AUTHENTICATE', scheme, (), self.__cbContinueAuth, scheme, secret) return self.sendCommand(cmd) raise NoSupportedAuthentication(auths, self.authenticators.keys())
NOTE: Currently the solution assumes that each authentication provider that supplies an extra argument does not require any additional interaction (like the login flow for example). Perhaps a mechanism for handling additional interaction should be added (or at least raise an error if the provider would need it and it is not implement it).