Project

verikloak

0.0
No release in over 3 years
Verikloak is a lightweight Ruby gem that provides JWT access token verification middleware for Rack-based applications, including Rails API mode. It uses OpenID Connect discovery and JWKs to securely validate tokens issued by Keycloak.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
 Dependencies

Runtime

>= 2.0, < 3.0
>= 2.0, < 3.0
~> 2.6
>= 2.7, < 4.0
 Project Readme

verikloak

CI Gem Version Ruby Version Downloads

A lightweight Rack middleware for verifying Keycloak JWT access tokens via OpenID Connect.

Verikloak is a plug-and-play solution for Ruby (especially Rails API) apps that need to validate incoming Bearer tokens issued by Keycloak. It uses OpenID Connect Discovery and JWKs to fetch the public keys and verify JWT signatures securely.

Features

  • OpenID Connect Discovery (.well-known/openid-configuration)
  • JWKs auto-fetching with in-memory caching and ETag support
  • RS256 JWT verification using kid
  • aud, iss, exp, nbf claim validation
  • Rails/Rack middleware support
  • Faraday-based customizable HTTP layer

Installation

Add this line to your application's Gemfile:

gem "verikloak"

Then install:

bundle install

Usage

Rails (API mode)

Add the middleware in config/application.rb:

config.middleware.use Verikloak::Middleware,
  discovery_url: "https://keycloak.example.com/realms/myrealm/.well-known/openid-configuration",
  audience: "your-client-id",
  skip_paths: ['/skip_path']

Handling Authentication Failures

When you use the Rack middleware, authentication failures are automatically converted into JSON error responses (for example, 401 for token issues, 503 for JWKs/discovery errors). In most cases you do not need to add custom rescue_from handlers in Rails controllers.

If you use Verikloak components directly (bypassing the Rack middleware) or prefer centralized error handling, rescue from the base class Verikloak::Error. You can also match subclasses such as Verikloak::TokenDecoderError, Verikloak::DiscoveryError, or Verikloak::JwksCacheError depending on your needs:

class ApplicationController < ActionController::API
  rescue_from Verikloak::Error do |e|
    status =
      case e
      when Verikloak::TokenDecoderError
        :unauthorized
      when Verikloak::DiscoveryError, Verikloak::JwksCacheError
        :service_unavailable
      else
        :unauthorized
      end

    render json: { error: e.class.name, message: e.message }, status: status
  end
end

This ensures that even if you bypass the middleware, clients still receive structured error responses.

Note: When the Rack middleware is enabled, it already renders JSON error responses. The rescue_from example above is only necessary if you bypass the middleware or want custom behavior.

Error Hierarchy

All Verikloak errors inherit from Verikloak::Error:

  • Verikloak::TokenDecoderError – token parsing/verification (401 Unauthorized)
  • Verikloak::DiscoveryError – OIDC discovery fetch/parse (503 Service Unavailable)
  • Verikloak::JwksCacheError – JWKs fetch/parse/cache (503 Service Unavailable)
  • Verikloak::MiddlewareError – header/infra issues surfaced by the middleware (usually 401, sometimes 503)

Recommended: use environment variables in production

config.middleware.use Verikloak::Middleware,
  discovery_url: ENV.fetch("DISCOVERY_URL"),
  audience: ENV.fetch("CLIENT_ID"),
  skip_paths: ['/', '/health', '/public/*', '/rails/*']

This makes the configuration secure and flexible across environments.

Advanced middleware options

Verikloak::Middleware exposes a few optional knobs that help integrate with different Rack stacks:

  • token_env_key (default: "verikloak.token") — where the raw JWT is stored in the Rack env
  • user_env_key (default: "verikloak.user") — where decoded claims are stored
  • realm (default: "verikloak") — value used in the WWW-Authenticate header for 401 responses
  • logger — an object responding to error (and optionally debug) that receives unexpected 500-level failures
config.middleware.use Verikloak::Middleware,
  discovery_url: ENV.fetch("DISCOVERY_URL"),
  audience: ENV.fetch("CLIENT_ID"),
  token_env_key: "rack.session.token",
  user_env_key: "rack.session.claims",
  realm: "my-api",
  logger: Rails.logger

Accessing claims in controllers

Once the middleware is enabled, Verikloak adds the decoded token and raw JWT to the Rack environment.
You can access them in any Rails controller:

class Api::V1::NotesController < ApplicationController
  def index
    user_claims = request.env["verikloak.user"]    # Hash of decoded Keycloak JWT claims
    token       = request.env["verikloak.token"]   # Raw JWT token string
    
    # Example: use claims for authorization or logging
    render json: { sub: user_claims["sub"], email: user_claims["email"] }
  end
end

Standalone Rack app

# config.ru example for a standalone Rack app
require "verikloak"

use Verikloak::Middleware,
  discovery_url: "https://keycloak.example.com/realms/myrealm/.well-known/openid-configuration",
  audience: "my-client-id"

run ->(env) {
  user = env["verikloak.user"] # Decoded JWT claims hash (if token is valid)
  [200, { "Content-Type" => "application/json" }, [user.to_json]]
}

How It Works

  1. Extracts the Authorization: Bearer <token> header
  2. Fetches the OIDC discovery document (only once or when expired)
  3. Downloads JWKs public keys from the provided jwks_uri
  4. Matches the kid from JWT header to select the right JWK
  5. Decodes and verifies the JWT using RS256
  6. Validates the following claims:
    • aud (audience)
    • iss (issuer)
    • exp (expiration)
    • nbf (not before)
  7. Makes the decoded payload available in env["verikloak.user"]

Error Responses

Verikloak returns JSON error responses in a consistent format with structured error codes. The HTTP status code reflects the nature of the error: 401 for client-side authentication issues, 503 for server-side discovery/JWKs errors, and 500 for unexpected internal errors.

Common HTTP Responses

  • 401 Unauthorized: The access token is missing, invalid, expired, or otherwise not valid.
  • 503 Service Unavailable: Discovery or JWKs fetch/parsing failed (server-side issue).
  • 500 Internal Server Error: An unexpected error occurred.

Representative Examples

{
  "error": "invalid_token",
  "message": "The access token is missing or invalid"
}
{
  "error": "expired_token",
  "message": "The access token has expired"
}
{
  "error": "jwks_fetch_failed",
  "message": "Failed to fetch JWKs"
}
{
  "error": "jwks_parse_failed",
  "message": "Failed to parse JWKs"
}
{
  "error": "discovery_metadata_fetch_failed",
  "message": "Failed to fetch OIDC discovery document"
}
{
  "error": "discovery_metadata_invalid",
  "message": "Failed to parse OIDC discovery document"
}

Error Types

Error Code HTTP Status Description
invalid_token 401 Unauthorized The token is missing, malformed, or invalid
expired_token 401 Unauthorized The token has expired
missing_authorization_header 401 Unauthorized The Authorization header is missing
invalid_authorization_header 401 Unauthorized The Authorization header format is invalid
unsupported_algorithm 401 Unauthorized The token’s signing algorithm is not supported
invalid_signature 401 Unauthorized The token signature could not be verified
invalid_issuer 401 Unauthorized Invalid iss claim
invalid_audience 401 Unauthorized Invalid aud claim
not_yet_valid 401 Unauthorized The token is not yet valid (nbf in the future)
jwks_fetch_failed 503 Service Unavailable Failed to fetch JWKs
jwks_parse_failed 503 Service Unavailable Failed to parse JWKs
jwks_cache_miss 503 Service Unavailable JWKs cache is empty (e.g., 304 Not Modified without prior cache)
discovery_metadata_fetch_failed 503 Service Unavailable Failed to fetch OIDC discovery document
discovery_metadata_invalid 503 Service Unavailable Failed to parse OIDC discovery document
discovery_redirect_error 503 Service Unavailable Discovery response was a redirect without a valid Location header
internal_server_error 500 Internal Server Error Unexpected internal error (catch-all)

Note: The decode_with_public_key method ensures consistent error codes for all JWT verification failures.
It may raise invalid_signature, unsupported_algorithm, expired_token, invalid_issuer, invalid_audience, or not_yet_valid depending on the verification outcome.

For a full list of error cases and detailed explanations, please see the ERRORS.md file.

Configuration Options

Key Required Description
discovery_url Yes Full URL to your realm's OIDC discovery doc
audience Yes Your client ID (checked against aud). Accepts a String or callable returning a String/Array per request.
skip_paths No Array of paths or wildcards to skip authentication, e.g. ['/', '/health', '/public/*']. Note: Regex patterns are not supported.
discovery No Inject custom Discovery instance (advanced/testing)
jwks_cache No Inject custom JwksCache instance (advanced/testing)
leeway No Clock skew tolerance (seconds) applied during JWT verification. Defaults to TokenDecoder::DEFAULT_LEEWAY.
token_verify_options No Hash of advanced JWT verification options passed through to TokenDecoder. For example: { verify_iat: false, leeway: 10, algorithms: ["RS256"] }. If both leeway: and token_verify_options[:leeway] are set, the latter takes precedence.
decoder_cache_limit No Maximum number of TokenDecoder instances retained per middleware. Defaults to 128. Set to 0 to disable caching or nil for an unlimited cache.
connection No Inject a Faraday::Connection used for both Discovery and JWKs fetches. Defaults to a safe connection with timeouts and retries.

Option: skip_paths

Plain paths are exact-match only, while /* at the end enables prefix matching.

skip_paths lets you specify paths (or wildcard patterns) where authentication should be skipped.
For example:

skip_paths: ['/', '/health', '/rails/*', '/public/src']
  • '/' matches only the root path.
  • '/health' matches only /health (for subpaths, use '/health/*').
  • '/rails/*' matches /rails itself as well as /rails/foo, /rails/foo/bar, etc.
  • '/public/src' matches /public/src, but does not match /public, any subpath like /public/src/html or other siblings like /public/css.

Paths not matched by any skip_paths entry will require a valid JWT.

Note: Regex patterns are not supported. Only literal paths and * wildcards are allowed.
Internally, * expands to match nested paths, so patterns like /rails/* are valid. This differs from regex — for example, '/rails' alone matches only /rails, while '/rails/*' covers both /rails and deeper subpaths.

Option: audience

The audience option may be either a static String or any callable object (Proc, lambda, object responding to #call). When a callable is provided it receives the Rack env (either as a positional argument or keyword env:) and can return a different audience for each request. This is useful when a single gateway serves multiple downstream clients:

Verikloak::Middleware.new(app,
  discovery_url: ENV['DISCOVERY_URL'],
  audience: ->(env) {
    env['PATH_INFO'].start_with?('/admin') ? 'admin-client-id' : 'public-client-id'
  }
)

The callable may also return an Array of audiences when a route is valid for multiple clients.

Customizing Faraday for Discovery and JWKs

Both Discovery and JwksCache accept a Faraday::Connection. Verikloak ships with a helper you can re-use anywhere:

connection = Verikloak::HTTP.default_connection

The default connection enables retries (via faraday-retry) for idempotent GET requests and applies conservative timeouts (5s request / 2s open). If you need to add extra middleware, adapters, or instrumentation, you can build on top of the defaults:

connection = Faraday.new(request: { timeout: 10 }) do |f|
  f.request :retry, Verikloak::HTTP::RETRY_OPTIONS
  f.response :logger
  f.adapter Faraday.default_adapter
end

config.middleware.use Verikloak::Middleware,
  discovery_url: ENV["DISCOVERY_URL"],
  audience: ENV["CLIENT_ID"],
  connection: connection

# Or pass the connection through to a shared JwksCache instance
jwks_cache = Verikloak::JwksCache.new(
  jwks_uri: "https://example.com/realms/myrealm/protocol/openid-connect/certs",
  connection: connection
)

This makes it easy to keep HTTP settings consistent across discovery, JWK refreshes, and any other Verikloak components you wire together.

Customizing token verification (leeway and options)

You can fine-tune how JWTs are verified by setting leeway or providing advanced options via token_verify_options. For example:

config.middleware.use Verikloak::Middleware,
  discovery_url: ENV["DISCOVERY_URL"],
  audience: ENV["CLIENT_ID"],
  leeway: 30, # allow 30s clock skew
  token_verify_options: {
    verify_iat: true,
    verify_expiration: true,
    verify_not_before: true,
    # algorithms: ["RS256"] # override algorithms if needed
    # leeway: 10            # this overrides the top-level leeway
  }
  • leeway: sets the default skew tolerance in seconds.
  • token_verify_options: is passed directly to TokenDecoder (and ultimately to JWT.decode).
  • If both are set, token_verify_options[:leeway] takes precedence.

Decoder cache & performance

Internally, Verikloak caches TokenDecoder instances per JWKs fetch to avoid reinitializing them on every request. The cache behaves like an LRU with a configurable size (decoder_cache_limit) so long-running processes do not accumulate decoders for one-off audiences. When the underlying JWK set rotates, the middleware now clears the cache to drop decoders that point at stale keys.

Set decoder_cache_limit to 0 if you prefer to construct a fresh decoder every time, or nil when you want the cache to grow without bounds (e.g., in short-lived jobs).

Sharing caches across verikloak gems

When combining verikloak with companion gems (such as verikloak-rails, verikloak-bff, etc.), reusing infrastructure objects avoids redundant HTTP calls:

connection = Verikloak::HTTP.default_connection
jwks_cache = Verikloak::JwksCache.new(jwks_uri: ENV['JWKS_URI'], connection: connection)

use Verikloak::Middleware,
    discovery_url: ENV['DISCOVERY_URL'],
    audience: ->(env) { env['verikloak.audience'] },
    connection: connection,
    jwks_cache: jwks_cache

# Rails initializer or service object can now reuse `connection` and `jwks_cache`
# when configuring other verikloak-* gems.

Sharing the Faraday connection keeps retry/time-out policies consistent, while a single JwksCache ensures all middleware layers refresh keys in unison.

Architecture

Verikloak consists of modular components, each with a focused responsibility:

Component Responsibility Layer
Middleware Rack-compatible entry point for token validation Rack layer
Discovery Fetches OIDC discovery metadata (.well-known) Network layer
HTTP Provides shared Faraday connection with retries/timeouts Network layer
JwksCache Fetches & caches JWKs public keys (with ETag) Cache layer
TokenDecoder Decodes and verifies JWTs (signature, exp, nbf, iss, aud) Crypto layer
Errors Centralized error hierarchy Core layer

This separation enables better testing, modular reuse, and flexibility.

Development (for contributors)

Clone and install dependencies:

git clone https://github.com/taiyaky/verikloak.git
cd verikloak
bundle install

See Testing below to run specs and RuboCop. For releasing, see Publishing.

Testing

All pull requests and pushes are automatically tested with RSpec and RuboCop via GitHub Actions. See the CI badge at the top for current build status.

To run the test suite locally:

docker compose run --rm dev rspec
docker compose run --rm dev rubocop

Contributing

Bug reports and pull requests are welcome! Please see CONTRIBUTING.md for details.

Security

If you find a security vulnerability, please follow the instructions in SECURITY.md.

License

This project is licensed under the MIT License.

Publishing (for maintainers)

Gem release instructions are documented separately in MAINTAINERS.md.

Changelog

See CHANGELOG.md for release history.

References