DEV Community

Mohammed O. Tillawy
Mohammed O. Tillawy

Posted on

Rails and Keycloak, Authentication, Authorization, part two

In the second part, let us setup Keycloak authentication with Rails.

Run Keycloak

First let us run Keycloak with some boilerplate setup.

Please run Keycloak on docker, please use the docker-compose.yml from the following repository to save time.

# checkout this repo
git clone https://github.com/tillawy/rails_keycloak_authorization.git
cd rails_keycloak_authorization/docker
docker compose up
Enter fullscreen mode Exit fullscreen mode

The previous step should run Keycloak on port 8080.
Credentials: Username=admin, password=admin

Let us use opentofu to setup keycloak.

# if you don't have opentofu
brew install opentofu
rails_keycloak_authorization/tofu
tofu apply -var-file=./secrets.tfvars -auto-approve
Enter fullscreen mode Exit fullscreen mode

The previous steps should create:

  • Keycloak Realm, called (Dummy)
  • Keycloak admin client with a secret with necessary roles and access
  • Keycloak client with a secret for our application
  • couple of users, groups and roles to do our tests

Our Rails project

Let us create our project

rails new ./rails_keycloak_authorization_demo --database=sqlite3
cd rails_keycloak_authorization_demo
Enter fullscreen mode Exit fullscreen mode

Let us create our User model:

bin/rails generate model User
Enter fullscreen mode Exit fullscreen mode

Let us change the migration to use UUID instead of int for id:

vim ./db/migrate/*_create_users.rb
Enter fullscreen mode Exit fullscreen mode
# ./db/migrate/*_create_users.rb

class CreateUsers < ActiveRecord::Migration[7.2]
  def change
    create_table :users, id: false do |t|
      t.primary_key :id, :string, default: -> { "lower(hex(randomblob(16)))" }
      t.string :email, null: false, index: { unique: true }
      t.string :first_name
      t.string :last_name

      t.timestamps
    end
  end
end

Enter fullscreen mode Exit fullscreen mode

Now let us scaffold couple of models:

bin/rails generate scaffold Project name:string
bin/rails generate scaffold Secret name:string
Enter fullscreen mode Exit fullscreen mode

Now migrate changes to database

bin/rails db:migrate 
Enter fullscreen mode Exit fullscreen mode

Let us run the server:

bin/rails s
Enter fullscreen mode Exit fullscreen mode

Let us check that our server is up & running using the following links: secrets, projects

Keycloak & Rails & Omniauth setup

Let us add the gems

# Gemfile
gem "omniauth"
gem "omniauth-keycloak"
Enter fullscreen mode Exit fullscreen mode

Let us create an initialize config/initializers/omniauth.rb

# config/initializers/omniauth.rb
Rails.application.config.middleware.use OmniAuth::Builder do
  provider :keycloak_openid, ENV.fetch("KEYCLOAK_AUTH_CLIENT_ID", "dummy-client"),
  ENV.fetch("KEYCLOAK_AUTH_CLIENT_SECRET", "dummy-client-super-secret-xxx"),
  client_options: {
    site: ENV.fetch("KEYCLOAK_SERVER_URL", "http://localhost:8080"),
    realm: ENV.fetch("KEYCLOAK_AUTH_CLIENT_REALM_NAME", "dummy"),
    raise_on_failure: true,
    base_url: ""
  },
  name: "keycloak",
  provider_ignores_state: true
end

OmniAuth.config.logger = Rails.logger

OmniAuth.config.path_prefix = ENV.fetch("KEYCLOAK_AUTH_SERVER_PATH_PREFIX", "/oauth")

OpenSSL::SSL::VERIFY_PEER = OpenSSL::SSL::VERIFY_NONE if Rails.env.development?
Enter fullscreen mode Exit fullscreen mode

Make sure your restart your Rails server after creating this initializer.

./bin/rails s
Enter fullscreen mode Exit fullscreen mode

let us add omniauth-keycloak routes:

# config/routes.rb
# assume any user visiting root / needs to authenticate
get "/", to: "oauth#new"
# callbacks from keycloak handling 
get "/oauth/:provider/callback", to: "oauth#create"
Enter fullscreen mode Exit fullscreen mode

Let us create the controller to handle our OAuth requests:

class OauthController < ApplicationController

  # to initiate the login process,
  # We will redirect the user to Keycloak with the parameter: redirect_uri
  # the user will be redirected to keycloak, and upon success redirected back to the application

  def new
    port_str = [80, 443].include?(request.port.to_i) ? "" : ":" + request.port.to_s
    redirect_uri = "#{request.scheme}://#{request.host}#{port_str}/oauth/keycloak/callback"
    redirect_uri_escaped = CGI.escape(redirect_uri)
    client_id =  ENV.fetch("KEYCLOAK_CLIENT_ID", "dummy-client")
    realm = ENV.fetch("KEYCLOAK_REALM", "dummy" )
    auth_server_url = ENV.fetch("KEYCLOAK_AUTH_SERVER_URL", "http://localhost:8080" )
    to = "#{auth_server_url}/realms/#{realm}/protocol/openid-connect/auth?response_type=code&client_id=#{client_id}&redirect_uri=#{redirect_uri_escaped}&login=true&scope=openid"
    redirect_to to, allow_other_host: true
  end

  # final callback from keycloak
  # the user is redirected back from keycloak with the user object in request.env

  def create
    current_user = User.find_or_create_by(id: auth_hash.extra.raw_info.sub, email: auth_hash.info.email, first_name: auth_hash.info.first_name, last_name: auth_hash.info.last_name)

    session[:current_user_id] = current_user.id
    redirect_to projects_path
  end

  protected

  def auth_hash
    auth = request.env["omniauth.auth"]
    raise 'NotAuthenticatedError' unless auth

    auth
  end
end
Enter fullscreen mode Exit fullscreen mode

We need a Rails concern to authenticate users on controllers
Please create the concern file app/controllers/concerns/with_current_user.rb

# app/controllers/concerns/with_current_user.rb

module WithCurrentUser
  extend ActiveSupport::Concern
  included do
    before_action :authenticate_user!

    def authenticate_user!
      raise "NotAuthenticatedError" unless current_user
    end

    def current_user
      current_jwt_user(nil_on_failure: true) || (session[:current_user_id] && User.find(session[:current_user_id]))
    end

    def user_with(id:, email:, first_name:, last_name:)
      upsert = User.upsert({ id: id, email: email, first_name: first_name, last_name: last_name }, unique_by: :id)
      User.find(upsert.first["id"])
    end

    def jwk_user_from(jwt:)
      jwk_loader = ->(options) do
        @cached_keys = nil if options[:invalidate] # need to reload the keys
        return @cached_keys if @cached_keys

        keycloak = ENV.fetch("KEYCLOAK_AUTH_SERVER_URL", "http://localhost:8080")
        realm = ENV.fetch("KEYCLOAK_REALM", "dummy")
        uri = URI("#{keycloak}/realms/#{realm}/protocol/openid-connect/certs")
        req = Net::HTTP::Get.new uri
        res = Rails.cache.fetch("jwk_loader-certs") do
          Net::HTTP.start(uri.host, uri.port, open_time: 1, read_timeout: 1, write_timeout: 1) { |http| http.request(req) }
        end
        unless res.is_a?(Net::HTTPSuccess)
          logger.warn res.body
          raise "JWKS #{uri} FAILED"
        end
        @cached_keys ||= JSON.parse res.body
      end

      decoded = JWT.decode(jwt, nil, !Rails.env.test?, { algorithms: [ "RS256" ], jwks: jwk_loader })

      email = decoded[0]["email"] || decoded[0]["preferred_username"]
      id = decoded[0]["sub"]
      logger.debug("found (email:#{email}, id: #{id})")
      { email: email, id: id, first_name: decoded[0]["given_name"], last_name: decoded[0]["family_name"] }
    end

    def extract_token_from(headers:)
      header = headers["Authorization"]
      header&.split(" ")&.last
    end

    def current_jwt_user(nil_on_failure: false)
      return nil unless request.authorization&.downcase&.start_with?("bearer ")

      token = extract_token_from(headers: request.headers)
      begin
        user = jwk_user_from(jwt: token)
        user_with(email: user[:email], id: user[:id], first_name: user[:first_name], last_name: user[:last_name])
      rescue ActiveRecord::RecordNotFound => e
        logger.error("User NOT found in DB, make sure to run Kafka consumer")
        return nil if nil_on_failure
        raise e
      rescue JWT::JWKError => e
        logger.info "ApplicationController current_jwt_user JWT::JWKError " + e.message
        return nil if nil_on_failure
        raise e
      rescue JWT::DecodeError => e
        logger.info "ApplicationController current_jwt_user JWT::DecodeError " + e.message
        return nil if nil_on_failure
        raise e
      end
    end
  end
end

Enter fullscreen mode Exit fullscreen mode

We will enforce authentication on the controllers level, we will use the concern in the controllers include WithCurrentUser:

file: app/controllers/projects_controller.rb

class ProjectsController < ApplicationController
  before_action :set_project, only: %i[ show edit update destroy ]
  include WithCurrentUser   # <!--- Add this line

Enter fullscreen mode Exit fullscreen mode

file: app/controllers/secrets_controller.rb

class SecretsController < ApplicationController
  before_action :set_secret, only: %i[ show edit update destroy ]
  include WithCurrentUser   # <!--- Add this line

Enter fullscreen mode Exit fullscreen mode

Let us test our setup:

Please open link, you should be redirected to Keyloak,
Authenticate using username: test@test.com, password: test.
You should be redirect to back to the http://localhost:3000/projects.
You should see: Welcome tester

Let us test our project using curl / JWT

#!/bin/bash

readonly username="employee@test.com";
readonly password="secret";

function get_access_token {
curl --silent \
    -d 'client_id=dummy-client' \
    -d 'client_secret=dummy-client-super-secret-xxx' \
    -d "username=${username}" \
    -d "password=${password}" \
    -d 'grant_type=password' \
    -d 'response_type=code' \
    -d 'scope=openid' \
    'http://localhost:8080/realms/dummy/protocol/openid-connect/token' | jq -r '.access_token'
}

access_token=$(get_access_token);

readonly url1="http://localhost:3000/projects.json"

echo requesting ${url1};

curl -H "Authorization: bearer ${access_token}" ${url1};
Enter fullscreen mode Exit fullscreen mode

You should see:

[]%
Enter fullscreen mode Exit fullscreen mode

Congratulation!
You have setup Keycloak with Rails.

In the third part of this series, we will setup Authorization for Rails using keycloak.

You can find the source of this project in the repo:

Top comments (0)