Sunday, January 4, 2009

Using Google Federated Login in your Rails Application

I'm in the progress of building an Android application with a Ruby on Rails backend. On Android, one of the first things you need to do is to tie the phone to a Google account. So to make it easier for the end users, I thought that I maybe could skip my own user account management and instead piggy back on the Google accounts.

After some initial research I found that Google recently released a single sign-on using OpenID. There are some sites that use the Google Federated Login, e.g. www.zoho.com, www.buxfer.com and http://www.plaxo.com/openid (look for the Google login button). However, it turned out that it wasn't really OpenID, it was something that resembles of OpenID. Even thought it isn't pure OpenID, it does everything I want anyway, so I started to code the login using the "official" OpenID Authentication plugin.

From Google Federated Login you can currently only get the email of a user, but it was impossible to get the email using the OpenID Authentication plugin. After some detective work it was clear that Google Federated Login uses AX attributes, not the SReg attributes that is used by default in the plugin. So my solution is to patch the plugin with the code below. You can add the code to your session controller and it will work with the Google OpenID URI.

  #
  # If we want to get the GMail address for a user using Google Federated Login,
  # we need to work with AX attributes, not SReg attributes which is used by
  # default.
  #
  # To solve this Ax/SReg attribute problem we patch the OpenIdAuthentication
  # module to use AX attributes when talking to the Google OpenID server
  #
  # This patch is based on the source from github[1], January 4, 2009
  #
  # 1. http://github.com/rails/open_id_authentication/commits/master
  #
  module ::OpenIdAuthentication
    require 'openid/extensions/ax'

    private
    def add_simple_registration_fields(open_id_request, fields)
      if is_google_federated_login?(open_id_request)
        ax_request = OpenID::AX::FetchRequest.new
        # Only the email attribute is currently supported by google federated login
        email_attr = OpenID::AX::AttrInfo.new('http://schema.openid.net/contact/email', 'email', true)
        ax_request.add(email_attr)
        open_id_request.add_extension(ax_request)
      else
        sreg_request = OpenID::SReg::Request.new
        sreg_request.request_fields(Array(fields[:required]).map(&:to_s), true) if fields[:required]
        sreg_request.request_fields(Array(fields[:optional]).map(&:to_s), false) if fields[:optional]
        sreg_request.policy_url = fields[:policy_url] if fields[:policy_url]
        open_id_request.add_extension(sreg_request)
      end
    end

def complete_open_id_authentication
      params_with_path = params.reject { |key, value| request.path_parameters[key] }
      params_with_path.delete(:format)
      open_id_response = timeout_protection_from_identity_server { open_id_consumer.complete(params_with_path, requested_url) }
      identity_url     = normalize_identifier(open_id_response.display_identifier) if open_id_response.display_identifier

case open_id_response.status
      when OpenID::Consumer::SUCCESS
        if is_google_federated_login?(open_id_response)
          yield Result[:successful], params['openid.identity'], OpenID::AX::FetchResponse.from_success_response(open_id_response)
        else
          yield Result[:successful], identity_url, OpenID::SReg::Response.from_success_response(open_id_response)
        end
      when OpenID::Consumer::CANCEL
        yield Result[:canceled], identity_url, nil
      when OpenID::Consumer::FAILURE
        yield Result[:failed], identity_url, nil
      when OpenID::Consumer::SETUP_NEEDED
        yield Result[:setup_needed], open_id_response.setup_url, nil
      end
    end

def is_google_federated_login?(request_response)
      return request_response.endpoint.server_url == "https://www.google.com/accounts/o8/ud"
    end
  end

And in the create method (following the example given in the README for the plugin), I have currently hard-coded the OpenID URI to 'https://www.google.com/accounts/o8/id', but you could get it from a form as well. Note that we have two cases to get the email depending if a Google OpenID URI or a regular OpenID URI was used.

  def create
    openid_url = 'https://www.google.com/accounts/o8/id'
    authenticate_with_open_id(openid_url, {:required => [ 'email' ] }) do |result, identity_url, registration|
      case result.status
      when :missing
        failed_login "Sorry, the OpenID server couldn't be found"
      when :invalid
        failed_login "Sorry, but this does not appear to be a valid OpenID"
      when :canceled
        failed_login "OpenID verification was canceled"
      when :failed
        failed_login "Sorry, the OpenID verification failed"
      when :successful
        if registration.class.to_s == "OpenID::AX::FetchResponse"
          email = registration['http://schema.openid.net/contact/email']
        else
          email = registration['email']
        end
        # Find (or create user) based on identity_url
# Note that email is not set when the user has selected 'always remember' in the Google login page for subsequent logins
      end
    end
  end

Update January 9: The code was updated to solve the Google 'always remember' problem

Update January 15: The most important technical issue in using the Google Federated Login API

10 comments:

  1. It seems that if you say to google "always remember", it will no longer pass along the email in the registration. Have you also noticed this do you have a fix?

    ReplyDelete
  2. Yes, I have it fixed, I will update the blog post in during the weekend if I have time. In short you can't look at the email, instead you should create/lookup the member based on params['openid.identity'] for the google case

    ReplyDelete
  3. The code is now updated to handle 'always remember' functionality in the Google login web page

    ReplyDelete
  4. Great info Nils! Even though zoho.com isn't doing true OpenID I think it's the best so far in just offering a shortcut. No reason to confuse average users who haven't heard of OpenID.

    Are there any other examples that come to mind of sites who are doing this really well?

    I'm anxious to see Google user experience testing integrated into a Rails plugin somehow. Thanks!

    ReplyDelete
  5. hey nils, Nice bit of code. Did exactly what I was looking for. I just have one question. How do I get it to work with yahoo's login system. Mainly you have the if google def is_google_federated_login? I'm really just wondering if you know what the yahoo endpoint url is. I can't seem to find it on developer network. Thanks in advance for any clues.

    ReplyDelete
  6. @Jeff: I haven't tried Yahoo OpenID at all, but at a first glance Yahoo seems to support OpenID out of the box, see http://openid.yahoo.com/

    If Yahoo have a real OpenID implementation, you don't need to do anything special to make it work.

    ReplyDelete
  7. [...] Using Google Federated Login in your Rails Application [...]

    ReplyDelete
  8. Thanks. This works great in my fork of Enki!

    ReplyDelete
  9. Just a note, a year later: I've been wrestling with exact problem for hours, and finally found this clear-as-day explanation: http://stackoverflow.com/questions/2492043/ruby-open-id-authentication-with-google-openid

    In short, authenticate_with_open_id returns the Sreg object, not the AX response, so a bit of code to retrieve the AX response object does the trick without needing to modify the plugin.

    ReplyDelete
  10. Excellent, thanks for posting this! Thank you.

    ReplyDelete