Skip to content

Rails middleware that validates Authorization token emitted by Keycloak

License

Notifications You must be signed in to change notification settings

looorent/keycloak-api-rails

Repository files navigation

Keycloak-Rails-Api

This gem validates Keycloak JWT token for Ruby On Rails APIs.

Install

gem "keycloak-api-rails", "0.12.4"

Token validation

Tokens sent (through query strings or Authorization headers) are validated against a Keycloak public key. This public key is downloaded every day by default (this interval can be changed through public_key_cache_ttl).

Pass token to the API

  • Method 1: By adding an Authorization HTTP Header with its value set to Bearer <your token>. e.g using curl: curl -H "Authorization: Bearer <your-token>" https://api.pouet.io/api/more-pouets
  • Method 2: By providing the token via query string, especially via the parameter named authorizationToken. Keep in mind that this method is less secure (url are kept intact in your browser history, and so on...) e.g. using curl: curl https://api.pouet.io/api/more-pouets?authorizationToken<your-token>

If both method are used at the same time, The query string as a higher priority when reading given tokens.

Opt-in vs. Opt-out validation

By default, Keycloak-api-rails installs as a Rack Middleware. It processes all requests before any application logic. URIs/Paths can be excluded (opted-out) from this validation using the 'skip_paths' config option

Alternatively, it can be configured to opt-in to validation. In this case, no Rack middleware is used, and controllers can request (opt-in) by including the module Keycloak::authentication and calling keycloak_authenticate, for example in a before_action, like so:

class MyApiController < ActionController::Base
  include Keycloak::Authentication

  before_action :keycloak_authenticate
end

When a token is validated

In Rails controller, the request env variables has two more properties:

  • keycloak:keycloak_id
  • keycloak:roles

They can be accessed using Keycloak::Helper methods.

Overall configuration options

All options have a default value. However, all of them can be changed in your initializer file.

Option Default Value Type Required? Description Example
server_url nil String Required The base url where your Keycloak server is located. This value can be retrieved in your Keycloak client configuration.  auth:8080
realm_id nil String Required Realm's name (not id, actually) master
logger Logger.new(STDOUT) Logger Optional The logger used by keycloak-api-rails Rails.logger 
skip_paths {} Hash of methods and paths regexp Optional Paths whose the token must not be validatefd { get: [/^\/health\/.+/] }
opt_in false Boolean Optional When true, All requests will be validated (excluding requests matching skip_paths). When false, validation must be explicitly requested true
token_expiration_tolerance_in_seconds 10 Logger Optional Number of seconds a token can expire before being rejected by the API. 15 
public_key_cache_ttl 86400 Integer Optional Amount of time, in seconds, specifying maximum interval between two requests to {project_name} to retrieve new public keys. It is 86400 seconds (1 day) by default. At least once per this configured interval (1 day by default) will be new public key always downloaded. Rails.logger 
custom_attributes [] Array Of String Optional List of token attributes to read from each token and to add to their http request env ["originalFirstName", "originalLastName"] 
ca_certificate_file nil String Optional Path to the certificate authority used to validate the Keycloak server certificate /credentials/production_root_ca_cert.pem 

Configure it

Create a keycloak.rb file in your Rails config/initializers folder. For instance:

Keycloak.configure do |config|
  config.server_url = ENV["KEYCLOAK_SERVER_URL"]
  config.realm_id   = ENV["KEYCLOAK_REALM_ID"]
  config.logger     = Rails.logger
  config.skip_paths = {
    post:   [/^\/message/],
    get:    [/^\/locales/, /^\/health\/.+/]
  }
end

Or using opt-in configuration:

Keycloak.configure do |config|
  config.server_url = ENV["KEYCLOAK_SERVER_URL"]
  config.realm_id   = ENV["KEYCLOAK_REALM_ID"]
  config.logger     = Rails.logger
  config.opt_in     = true
end

When using opt-in is true, skip_paths is not used.

Use cases

Once this gem is configured in your Rails project, you can read, validate and use tokens in your controllers.

Keycloak Id

If you identify users using their Keycloak Id, this value can be read from your controllers using Keycloak::Helper.current_user_id(request.env).

class AuthenticatedController < ApplicationController

  def user
    keycloak_id = Keycloak::Helper.current_user_id(request.env)
    User.active.find_by(keycloak_id: keycloak_id)
  end
end

Or if using opt-in mode, the controller can request validation conditionally:

class MostlyAuthenticatedController < ApplicationController
  include Keycloak::Authentication

  before_action :keycloak_authenticate, only: show

  def show
    keycloak_id = Keycloak::Helper.current_user_id(request.env)
    User.active.find_by(keycloak_id: keycloak_id)
  end

  def index 
    # unauthenticated
  end
end

Roles

Keycloak::Helper.current_user_roles can be use against a Rails request to read user's roles.

For example, a controller can require users to be administrator (considering you defined an application-admin role):

class AdminController < ApplicationController

  before_action :require_to_be_admin!

  def require_to_be_admin!
    if !current_user_roles.include?("application-admin")
      render(json: { reason: "admin", message: "You have to be an administrator to access that endpoint." }, status: :forbidden)
    end
  end

  private

  def current_user_roles
    Keycloak::Helper.current_user_roles(request.env)
  end
end

Create an URL where the token must be passed via query string

Keycloak::Helper.create_url_with_token method can be used to build an url from another, by adding a token as query string.

def example
  Keycloak::Helper.create_url_with_token("https://api.pouet.io/api/more-pouets", "myToken")
end

This should output https://api.pouet.io/api/more-pouets?authorizationToken=myToken.

Accessing Keycloak Service

A lazy-loaded service Keycloak::Service can be accessed using Keycloak.service. For instance, to read a provided token:

class RenderTokenController < ApplicationController
  def show
    uri     = request.env["REQUEST_URI"]
    headers = request.env
    token   = Keycloak.service.read_token(uri, headers)
    render json: { token: token }, status: :ok
  end
end

Writing integration tests

If you want to write controller tests in your codebase and that Keycloak is configured for these controllers, here is how to mock it. These lines are based on tests written using rspec.

  • First, create a private key. This key should be created once per test suite for performance matters.
config.before(:suite) do
  $private_key = OpenSSL::PKey::RSA.generate(1024)
end
  • Then, in a shared_context, configure a lazy token based on your main user. (here, we assume you have a user variable with a keycloak_id property)
let(:jwt) do
  claims = {
    iat: Time.zone.now.to_i,
    exp: (Time.zone.now + 1.day).to_i,
    sub: user.keycloak_id,
  }
  token = JSON::JWT.new(claims)
  token.kid = "default"
  token.sign($private_key, :RS256).to_s
end
  • Finally, in the same shared_context, stub Keycloak.public_key_resolver to use a valid public key that is able to validate jwt:
before(:each) do
  public_key_resolver = Keycloak.public_key_resolver
  allow(public_key_resolver).to receive(:find_public_keys) { JSON::JWK::Set.new(JSON::JWK.new($private_key, kid: "default")) }
end

How to execute library tests

From the keycloak-rails-api directory:

  $ docker build . -t keycloak-rails-api:test
  $ docker run -v `pwd`:/usr/src/app/ keycloak-rails-api:test bundle exec rspec spec

Next developments

  • Manage multiple realms
  • Avoid duplicate code in Keycloak::Middleware and Keycloak::Authentication

About

Rails middleware that validates Authorization token emitted by Keycloak

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published