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)