Moving LibraryHippo to Python 2.7 - OpenID edition

Now that Google has announced that Python 2.7 is fully supported on Google App Engine, I figured I should get my act in gear and make convert LibraryHippo over. I'd had a few aborted attempts earlier, but this time things are going much better.

How We Got Here - Cloning LibraryHippo

One of the requirements for moving to Python 2.7 is that the app must use the High Replication Datastore, and LibraryHippo did not. Moreover, the only way to convert to the HRD is to copy your data to a whole new application. So I bit the bullet, and made a new application from the LibraryHippo source.

When you set up a new application, you have the option of allowing federated authentication via OpenID. I'd wanted to do this for some time, so I thought, "While I'm changing the datastore, template engine, and version of Python under the hood, why not add a little complexity?", and I picked it.

The Simplest Thing That Should Work - Google as Provider

In theory, LibraryHippo should be able to support any OpenID provider, but I wanted to start with Google as provider for a few reasons:

  • concentrating on one provider would get the site running quickly and I could add additional providers over time
  • I need to support existing users - they've already registered with App Engine using Google, and I want things to keep working for them, and
  • I wanted to minimize my headaches - I figure, if an organization supports both an OpenID client feature and an OpenID provider, they must work together as well as any other combination.

Even though there's been official guidance around using OpenID in App Engine since mid-2010, I started with Nick Johnson's article for an overview - he's never steered me wrong before. And I'm glad I did. While the official guide is very informative, Nick broke things down really well. To quote him,

Once you've enabled OpenID authentication for your app, a few things change:
  • URLs generated by create_login_url without a federated_identity parameter specified will redirect to the OpenID login page for Google Accounts.
  • URLs that are protected by "login: required" in app.yaml or web.xml will result in a redirect to the path "/_ah/login_required", with a "continue" parameter of the page originally fetched. This allows you to provide your own openid login page.
  • URLs generated by create_login_url with a federated_identity provider will redirect to the specified provider.

That sounded pretty good - the existing application didn't use login: required anywhere, just create_login_url (without a federated_identity, of course). So, LibraryHippo should be good to go - every time create_login_url is used to generate a URL, it'll send users to Google Accounts. I tried it out.

It just worked, almost. When a not-logged-in user tried to access a page that required a login, she was directed to the Google Accounts page. There were cosmetic differences, but I don't think they're worth worrying about:

standard Google login page

standard Google login page

federated Google login page

federated Google login page

Approve access to e-mail address

After providing her credentials, the user was redirected to a page that asked her if it was okay for LibraryHippo to know her e-mail address. After that approval was granted, it was back to the LibaryHippo site and everything operated as usual.

However, login: admin is still a problem. I really shouldn't have been surprised by this, but login: admin seems to do the same thing that login: required does - redirect to /_ah/login_required, which is not found.

Login Required Not Found

This isn't a huge problem - it only affects administrators (me), and I could workaround by visiting a page that required any kind of login first, but it still stuck in my craw. Fortunately, the fix is very easy - just handle /_ah/login_required. I ripped off Nick's OpenIdLoginHandler, only instead of offering a choice of providers using users.create_login_url, this one always redirects to Google's OpenId provider page. With this fix, admins are able to go directly from a not-logged-in state to any admin required page.

class OpenIdLoginHandler(webapp2.RequestHandler):
    def get(self):
        continue_url = self.request.GET.get('continue')
        login_url = users.create_login_url(dest_url=continue_url)

        self.redirect(login_url)        

...

handlers = [ ...
    ('/_ah/login_required$', OpenIdLoginHandler),
    ... ]

Using Other Providers

With the above solution, LibraryHippo's authentication system has the same functionality as before - users can login with a Google account. It's time to add support for other OpenID providers.

username.myopenid.com

I added a custom provider picker page as Nick suggested, and tried to login with my myOpenID account, with my vanity URL as provider - blair.conrad.myopenid.com. The redirect to MyOpenID worked just as it should, and once I was authenticated, I landed back at LibraryHippo, at the "family creation" page, since LibraryHippo recognized me as a newly-authenticated user, with no history.

myopenid.com

Buoyed by my success, I tried again, this time using the "direct provider federated identity" MyOpenID url - myopenid.com. It was a complete disaster.

Error: Server Error  The server encountered an error and could not complete your request. If the problem persists, please report your problem and mention this error message and the query that caused it.

Once MyOpenID had confirmed my identity, and I was redirected back to the LibraryHippo application, App Engine threw a 500 Server Error. There's nothing in the logs - just the horrible error on the screen. In desperation, I stripped down my login handler to the bare minimum, using the example at Using Federated Authentication via OpenID in Google App Engine as my guide. I ended up with this class that reproduces the problem:

class TryLogin(webapp2.RequestHandler):
    def get(self):
        providers = {
            'Google'   : 'www.google.com/accounts/o8/id',
            'MyOpenID' : 'myopenid.com',
            'Blair Conrad\'s MyOpenID' : 'blair.conrad.myopenid.com',
            'Blair Conrad\'s Wordpress' : 'blairconrad.wordpress.com',
            'Yahoo' : 'yahoo.com',
            'StackExchange': 'openid.stackexchange.com',
            }

        user = users.get_current_user()
        if user:  # signed in already
            self.response.out.write('Hello %s! [sign out]' % (
                user.nickname(), users.create_logout_url(self.request.uri)))
        else:     # let user choose authenticator
            self.response.out.write('Hello world! Sign in at: ')
            for name, uri in providers.items():
                self.response.out.write('[%s]' % (
                    users.create_login_url(dest_url= '/trylogin', federated_identity=uri), name))

...

handlers = [
    ('/trylogin$', TryLogin),
    ('/_ah/login_required$', OpenIdLoginHandler),
    ...
]

Interestingly, both Yahoo! and WordPress work, but StackExchange does not. If it weren't for Yahoo!, I'd guess that it's the direct provider federated identities that give App Engine problems (yes, Google is a direct provider, but I consider it to be an exception in any case).

Next steps

For now, I'm going to use the simple "just Google as federated ID provider" solution that I described above. It seems to work, and I'd rather see if I can find out why these providers fail before implementing an OpenID selector that excludes a few providers. Also, implementing the simple solution will allow me to experiment with federated IDs on the side, since I don't know how e-mail will work with federated IDs, or how best to add federated users as families' responsible parties. But that's a story for another day.