Rails/Devise authenticating using AWS/Cognito Identity

Background

For a while now, I’m developing a sort of IoT controller with Rails 4. Until now, Devise was used to authenticate users locally using the Devise’s provided :database_authenticable module.

Things changed recently, and I had to move some features of this IoT controller toward AWS. Authentication against AWS/Cognito Identity is one part of the project.
But Why?” you might wonder. Because we need to extend authentication to other products, using a common user database. Cognito fits the description and helps boost the development on the AWS ecosystem. In other words, we want to be able to use our IoT controller alongside with other new products without depending exclusively on the authentication part of the IoT controller.
This means that local user database will still be used for the application-related data like permissions, session, extended profile attributes or database relationship, but pure authentication happen exclusively on Cognito. I moved to AWS just the first A of AAA. This is a disputable choice, let’s call it team work.

So, the purpose of this post is to synthesize the procedure required in order to:

  • authenticate against Cognito
  • handle passwords manipulation and accounts validation
  • migrate user data with a Lambda triggered by Cognito
  • create new local users if they are not already existing

The following example should work well with recent Rails versions.

Dependencies

Obviously Rails and Devise are required, in addition to the following gem:

gem 'aws-sdk', '~> 3'

Rails 4/Devise adaptation

Environment variables will carry all the AWS specific access information to Rails. Depending on the setup,  /.env file probably already exists in which we can add the two following Cognito Pool info (TLDR; Create a new App client without App client secret) :

AWS_COGNITO_USER_POOL_ID=eu-west-1_234567
AWS_COGNITO_CLIENT_ID=01234567890asdfghjkllqwertzuiop1234567890

where the variables values matches the AWS setup.

We’ll need a new a new Devise strategy, a safe location for this file is in /config/initializers/cognito_authenticable.rb :

require 'aws-sdk'
require 'devise/strategies/authenticatable'

module Devise
  module Strategies
    class CognitoAuthenticatable < Authenticatable
      def authenticate!
        if params[:user]

          client = Aws::CognitoIdentityProvider::Client.new

          begin

            resp = client.initiate_auth({
              client_id: ENV["AWS_COGNITO_CLIENT_ID"],
              auth_flow: "USER_PASSWORD_AUTH",
              auth_parameters: {
                "USERNAME" => email,
                "PASSWORD" => password
              }
            })

            if resp
              user = User.where(email: email).try(:first)
              if user
                success!(user)
              else
                user = User.create(email: email, password: password, password_confirmation: password)
                if user.valid?
                  success!(user)
                else
                  return fail(:failed_to_create_user)
                end
              end
            else
              return fail(:unknow_cognito_response)
            end

          rescue Aws::CognitoIdentityProvider::Errors::NotAuthorizedException => e

            return fail(:invalid_login)

          rescue

            return fail(:unknow_cognito_error)

          end

        end
      end

      def email
        params[:user][:email]
      end

      def password
        params[:user][:password]
      end

    end
  end
end

This strategy will create a new local user if Cognito authenticated it, but the user doesn’t exist locally.

The we can configure Devise to use the strategy by giving it the class name in the file /config/initializers/devise.rb

Devise.setup do |config|

  # (...)

  config.warden do |manager|
    manager.strategies.add(:cognito, Devise::Strategies::CognitoAuthenticatable)
    manager.default_strategies(:scope => :user).unshift :cognito
  end

  # (...)

end

Now Devise should authenticate against the Cognito user database.

The next step would be to allow password reset using the password recovery system of AWS Cognito.

class Users::PasswordsController < Devise::PasswordsController

  skip_before_action :assert_reset_token_passed

  def create

    raise ArgumentError, "Unexpected block given for requested action: #{params.inspect}" if block_given?

    begin

      client = Aws::CognitoIdentityProvider::Client.new
      resp = client.forgot_password({
        client_id: ENV["AWS_COGNITO_CLIENT_ID"],
        username: params[:user][:email]
      })

      session[:reset_password_email] = params[:user][:email]

      redirect_to edit_user_password_path

    rescue

      flash[:alert] = I18n.t("devise.errors.unknown_error")
      redirect_to new_user_password_path

    end

  end

  def edit

    gon.flash_notice = I18n.t("devise.notices.change_password_email")
    super

  end

  def update

    if params[:user][:password].blank?

      flash[:alert] = I18n.t("activerecord.errors.models.user.attributes.password.blank")
      redirect_to edit_user_password_path(reset_password_token: params[:user][:reset_password_token])

    elsif params[:user][:password] != params[:user][:password_confirmation]

      flash[:alert] = I18n.t("activerecord.errors.models.user.attributes.password.mismatch")
      redirect_to edit_user_password_path(reset_password_token: params[:user][:reset_password_token])

    elsif params[:user][:reset_password_token].blank?

      flash[:alert] = I18n.t("devise.errors.verification_code_missing")
      redirect_to edit_user_password_path(reset_password_token: params[:user][:reset_password_token])

    elsif session[:reset_password_email].nil?

      flash[:alert] = I18n.t("devise.errors.verification_code_expired")
      redirect_to new_user_password_path

    else

      begin

        client = Aws::CognitoIdentityProvider::Client.new
        resp = client.confirm_forgot_password({
          client_id: ENV["AWS_COGNITO_CLIENT_ID"],
          confirmation_code: params[:user][:reset_password_token],
          username: session[:reset_password_email],
          password: params[:user][:password]
        })

        session.delete :reset_password_email

        redirect_to unauthenticated_root_path, notice: I18n.t("devise.notices.password_changed")

      rescue Aws::CognitoIdentityProvider::Errors::InvalidPasswordException => e

        flash[:alert] = e.to_s
        redirect_to edit_user_password_path(reset_password_token: params[:user][:reset_password_token])

      rescue

        flash[:alert] = I18n.t("devise.errors.unknown_error")
        redirect_to edit_user_password_path(reset_password_token: params[:user][:reset_password_token])

      end

    end

  end

end

In this case, the sign up procedure is still handled by Rails on the local database (see below), therefore account confirmation (with Devise’s :confirmable  module) is handled by Devise without any changes.

Migrating users from Rails to Cognito

We wanted to migrate a user from the Rails database to Cognito if the user isn’t already existing in the Cognito database. For that at least a new endpoint in config/routes.rb  is required:

​post '/aws/auth',
 to: 'users#aws_auth',
 defaults: {format: 'json'},
 as: 'aws_auth'

For this route it is supposed that controllers/users_controller.rb  have a aws_auth  method:

class UsersController < ApplicationController

  # skip_filter other access restrictions...
  before_filter :restrict_access, only: [:aws_auth]

  # (...)

  def aws_auth

    defaults = {
      id: nil,
      first_name: nil,
      last_name: nil,
      email: nil,
      authentication_hash: nil
    }
    user = User.where(email: aws_auth_params[:email]).first

    if user
      answer = user.as_json(only: defaults.keys)
      answer[:user_exists] = true
      answer[:success] = user.valid_password?(aws_auth_params[:password])
    else
      answer = defaults
      answer[:success] = false
      answer[:user_exists] = false
    end

    respond_to do |format|
      format.json { render json: answer }
    end

  end

  # (...)

  private

    def restrict_access
      head :unauthorized unless params[:access_token] == TOKEN_AUTH_OF_YOUR_CHOICE
    end

end

That’s all for the Rails side, now in Cognito Pool of the AWS console there is a Trigger, in which lambdas can be attached. The User Migration trigger can be set to the following JavaScript lambda:

'use strict';

console.log('Loading function');

const https = require('https');

const attributes = (response) => {
  return {
    "email": response.email,
    "email_verified": "true",
    "name": response.first_name + " " + response.last_name,
    "custom:rails_app_id": response.id
  };
};

const checkUser = (server, data, callback) => {
  let postData = JSON.stringify( data );

  let options = {
    hostname: server,
    port: 443,
    path: "/aws/auth",
    method: 'POST',
    headers: {
         'Content-Type': 'application/json',
         'Content-Length': postData.length
       }
  };

  let req = https.request(options, (res) => {

    let data = "";
    res.on('data', (chunk) => {
      data += chunk;
    });
    res.on('end', () => {
      if ( data ){
        let response = JSON.parse( data );
        console.log( 'response:', JSON.stringify(response, null, 2) );
        callback( null, response);
      } else {
        callback( "Authentication error");
      }
    });
  });

  req.on('error', (e) => {
    callback( e );
  });

  req.write( postData );
  req.end();
}
exports.handler = (event, context, callback) => {

  console.log('Migrating user:', event.userName);

  let rails_server_url = process.env.rails_server_url || "rails.app.your_company.com";

  checkUser( rails_server_url, {
    email: event.userName,
    password: event.request && event.request.password,
    access_token: process.env.rails_server_access_token
  }, (err, response ) => {
    if ( err ){
      return context.fail("Connection error");
    }
    if ( event.triggerSource == "UserMigration_Authentication" ) {
      // authenticate the user with your existing user directory service
      if ( response.success ) {
          event.response.userAttributes = attributes( response ) ;
          event.response.finalUserStatus = "CONFIRMED";
          event.response.messageAction = "SUPPRESS";
          console.log('Migrating user:', event.userName);
          context.succeed(event);
      } else if ( response.user_exists ) {
          context.fail("Bad password");
      } else {
        context.fail("Bad user");
      }
    } else if ( event.triggerSource == "UserMigration_ForgotPassword" ) {
      if ( response.user_exists ) {
        event.response.userAttributes = attributes( response ) ;
        event.response.messageAction = "SUPPRESS";
        console.log('Migrating user with password reset:', event.userName);
        context.succeed(event);
      } else {
        context.fail("Bad user");
      }
    } else {
      context.fail("Bad triggerSource " + event.triggerSource);
    }
  });
};

This scripts use the two following environment variables, which should be set with the proper information:

rails_server_url=URL
rails_server_access_token=TOKEN_AUTH_OF_YOUR_CHOICE

Conclusion

It’s possible.