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.
