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)