Eh, this site could be worse.

Using Portier with Python's asyncio

23 Jan 2017

Updates

The asyncio-portier library

15 Feb 2017

I’ve published the asyncio-portier Python library. Read the announcement post here.

Original Post

Mozilla Persona was an authentication solution that sought to preserve user privacy while making life easy for developers (as explained here). You may have noticed the past tense there — Persona has gone away. A new project, Portier, picks up where Persona left off, and I’m eager to see it succeed. I’ll do anything to avoid storing password hashes or dealing with OAuth. With that in mind, here is my small contribution: a guide to using Portier with Python’s asyncio.

The Big Idea

So far, the official guide to using Portier is… short. It tells you to inspect and reuse code from the demo implementation. So that’s what we’ll do! But first, let’s take a look at how this is supposed to work.

The guide says

The Portier Broker’s API is identical to OpenID Connect’s “Implicit Flow.”

From the developer’s perspective, that means

  1. A user submits their e-mail address via a POST to an endpoint on your server.
  2. Your server
    1. Generates and stores a nonce for this request.
    2. Replies with a redirect to an authorization endpoint (as of today, that means hardcoding Portier’s Broker URL: https://broker.portier.io).
  3. Your server will eventually receive a POST to one of its endpoints (/verify in the demo implementation). At this point
    1. If the data supplied are all valid (including checking against the nonce created in step 2.1.), then the user has been authenticated and your server can set a session cookie.
    2. If something is invalid, show the user an error.

This is all well and good… provided that you’re using Python and your server code is synchronous. I generally use Tornado with asyncio for my Python web framework needs, so some tweaks need to be made to get everything working together nicely.

If you want to use something other than Python, I can’t really help you. I did say Portier is new, didn’t I?

Enter asyncio

For some background, Python 3.4 added a way to write non-blocking single-threaded code, and Python 3.5 added some language keywords to make this feature easier to use. For the sake of brevity I’ll include code that works in Python 3.5 or later. Here is a blog post describing the changes in case you need to use an earlier version of Python.

For those of you using the same setup that I do (Tornado and asyncio), refer to this page for getting things up and running.

The login endpoint

This code does not need to be modified to work with asyncio. I’ll include what it should look like when using Tornado, though. Assuming that

from datetime import timedelta
from urllib.parse import urlencode
from uuid import uuid4
import tornado.web

class LoginHandler(tornado.web.RequestHandler):
    def post(self):
        nonce = uuid4().hex
        REDIS.setex(nonce, timedelta(minutes=15), '')
        query_args = urlencode({
            'login_hint': self.get_argument('email'),
            'scope': 'openid email',
            'nonce': nonce,
            'response_type': 'id_token',
            'response_mode': 'form_post',
            'client_id': SETTINGS['WebsiteURL'],
            'redirect_uri': SETTINGS['WebsiteURL'] + '/verify',
        })
        self.redirect(SETTINGS['BrokerURL'] + '/auth?' + query_args)

The verify endpoint

This does need some modification to work. Assuming that you have defined an exception class for your application called ApplicationError

import tornado.web

class VerifyHandler(tornado.web.RequestHandler):
    def check_xsrf_cookie(self):
        """Disable XSRF check.
        OIDC Implicit Flow doesn't reply with _xsrf header.
        https://github.com/portier/demo-rp/issues/10
        """
        pass

    async def post(self):  # Make this method a coroutine with async def
        if 'error' in self.request.arguments:
            error = self.get_argument('error')
            description = self.get_argument('error_description')
            raise ApplicationError('Broker Error: {}: {}'.format(error, description))
        token = self.get_argument('id_token')
        email = await get_verified_email(token)  # Use await to make this asynchronous
        # The demo implementation handles RuntimeError here but you may want to
        # deal with errors in your own application-specific way

        # At this point, the user has authenticated, so set the user cookie in
        # whatever way makes sense for your application.
        self.set_secure_cookie(...)
        self.redirect(self.get_argument('next', '/'))

get_verified_email

This function only needs two straightforward changes from the demo implementation:

async def get_verified_email(token):

and

keys = await discover_keys(SETTINGS['BrokerURL'])

discover_keys

This function needs three changes from the demo implementation. The first is simple again:

async def discover_keys(broker):

The second change is in the line with res = urlopen(''.join((broker, '/.well-known/openid-configuration'))). The problem is that urlopen is blocking, so you can’t just await it. If you’re not using Tornado, I recommend using the aiohttp library (refer to the client example). If you are using Tornado, you can use the AsyncHTTPClient class.

http_client = tornado.httpclient.AsyncHTTPClient()
url = broker + '/.well-known/openid-configuration'
res = await http_client.fetch(url)

The third change is similar to the second: raw_jwks = urlopen(discovery['jwks_uri']).read() uses urlopen again. Solve it the same way:

raw_jwks = (await http_client.fetch(discovery['jwks_uri'])).body

Wrapping up

Down the line, there will be client-side Portier libraries (or, at least, other demo implementations) for various languages. Until then, you’ll need to do some of the heavy lifting yourself. I think it’s worth it, and I hope you will, too.

« Blog

Comments: View this post's comment section here.