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 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 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 Let us create our User model:
bin/rails generate model User Let us change the migration to use UUID instead of int for id:
vim ./db/migrate/*_create_users.rb # ./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 Now let us scaffold couple of models:
bin/rails generate scaffold Project name:string bin/rails generate scaffold Secret name:string Now migrate changes to database
bin/rails db:migrate Let us run the server:
bin/rails s 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" 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? Make sure your restart your Rails server after creating this initializer.
./bin/rails s 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" 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 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 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 file: app/controllers/secrets_controller.rb
class SecretsController < ApplicationController before_action :set_secret, only: %i[ show edit update destroy ] include WithCurrentUser # <!--- Add this line 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}; You should see:
[]% 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)