No release in over 3 years
Custom OmniAuth strategy for OpenID Federation providers using openid_connect gem, supporting signed request objects (RFC 9101), ID token encryption/decryption, client assertion (private_key_jwt), and OpenID Federation entity statements. Framework-agnostic and works with Rails, Sinatra, Rack, and other Rack-compatible frameworks.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies
 Project Readme

omniauth_openid_federation

Gem Version Test Status codecov Quality Gate Status

OmniAuth strategy for OpenID Federation providers with comprehensive security features, supporting signed request objects, ID token encryption, and full OpenID Federation 1.0 compliance.

Sponsored by Kisko Labs.

Sponsored by Kisko Labs

Installation

# Gemfile
gem "omniauth_openid_federation"
bundle install

Features

  • Signed Request Objects (RFC 9101) - RS256 signing of authorization requests
  • Optional Request Object Encryption - RSA-OAEP encryption when provider requires it
  • ID Token Encryption/Decryption - RSA-OAEP encryption and A128CBC-HS256 decryption
  • OpenID Federation 1.0 - Full entity statement support and federation metadata
  • Federation Endpoint - Publish entity statements at /.well-known/openid-federation
  • Automatic Key Provisioning - Automatic extraction/generation of signing and encryption keys
  • Separate Key Support - Production-ready support for separate signing and encryption keys
  • Client Assertion (private_key_jwt) - Secure client authentication
  • Security Hardened - OWASP compliant, input validation, rate limiting

Quick Start

Step 1: Get Provider Information

Your provider will provide:

  • Entity statement URL: https://provider.example.com/.well-known/openid-federation
  • Expected fingerprint hash: For verification

Fetch and cache the entity statement:

rake openid_federation:fetch_entity_statement[
  "https://provider.example.com/.well-known/openid-federation",
  "expected-fingerprint-hash",
  "config/provider-entity-statement.jwt"
]

Step 2: Generate Client Keys

rake openid_federation:prepare_client_keys

This generates:

  • Private key: config/client-private-key.pem (keep secure, never commit)
  • Public JWKS: config/client-jwks.json (send to provider for explicit registration)

Security Warning:

  • NEVER commit production private keys to your repository
  • For production: Use environment variables (OPENID_CLIENT_PRIVATE_KEY_BASE64) or secure key management systems
  • For development: Add private key files to .gitignore:
.federation*
*.pem

Step 3: Register Client

Explicit Registration (default):

  1. Send config/client-jwks.json to your provider
  2. Receive Client ID from provider

Automatic Registration (if provider supports it):

  • No pre-registration needed
  • Set client_entity_statement_url to https://your-app.com/.well-known/openid-federation

Step 4: Configure OmniAuth Strategy

# config/initializers/devise.rb
require "omniauth_openid_federation"

# Global settings (optional)
OmniauthOpenidFederation.configure do |config|
  config.cache_ttl = 24 * 60 * 60
  config.rotate_on_errors = true
  config.http_timeout = 10
  config.max_retries = 3
end

if ENV["OPENID_ENABLED"] == "true"
  # Load private key from environment variable (recommended for production)
  private_key = if ENV["OPENID_CLIENT_PRIVATE_KEY_BASE64"]
    OpenSSL::PKey::RSA.new(Base64.decode64(ENV["OPENID_CLIENT_PRIVATE_KEY_BASE64"]))
  elsif ENV["OPENID_CLIENT_PRIVATE_KEY_PATH"]
    OpenSSL::PKey::RSA.new(File.read(Rails.root.join(ENV["OPENID_CLIENT_PRIVATE_KEY_PATH"])))
  else
    OpenSSL::PKey::RSA.new(File.read(Rails.root.join("config", "client-private-key.pem")))
  end

  entity_statement_path = ENV["OPENID_ENTITY_STATEMENT_PATH"] || 
    Rails.root.join("config", ".federation-entity-statement.jwt").to_s

  # Configure CSRF protection
  if defined?(OmniAuth)
    OmniAuth.config.allowed_request_methods = [:post]
    OmniAuth.config.request_validation_phase = lambda do |env|
      request = Rack::Request.new(env)
      return true if request.path.end_with?("/callback")
      
      session = env["rack.session"] || {}
      token = request.params["authenticity_token"] || request.get_header("X-CSRF-Token")
      expected_token = session[:_csrf_token] || session["_csrf_token"]
      
      if token.present? && expected_token.present?
        ActiveSupport::SecurityUtils.secure_compare(token.to_s, expected_token.to_s)
      else
        false
      end
    end
  end

  Devise.setup do |config|
    config.omniauth :openid_federation,
      strategy_class: OmniAuth::Strategies::OpenIDFederation,
      name: :openid_federation,
      scope: [:openid],
      response_type: "code",
      discovery: true,
      client_auth_method: :jwt_bearer,
      client_signing_alg: :RS256,
      entity_statement_path: entity_statement_path,
      always_encrypt_request_object: true,
      client_options: {
        identifier: ENV["OPENID_CLIENT_ID"],
        redirect_uri: ENV["OPENID_REDIRECT_URI"] || "#{ENV["APP_URL"]}/users/auth/openid_federation/callback",
        private_key: private_key
      }
  end
end

Step 5: Configure Federation Endpoint (For Automatic Registration)

# config/initializers/omniauth_openid_federation.rb
if ENV["OPENID_ENABLED"] == "true"
  app_url = ENV["APP_URL"] || "https://your-app.example.com"
  
  private_key = if ENV["OPENID_CLIENT_PRIVATE_KEY_BASE64"]
    OpenSSL::PKey::RSA.new(Base64.decode64(ENV["OPENID_CLIENT_PRIVATE_KEY_BASE64"]))
  elsif ENV["OPENID_CLIENT_PRIVATE_KEY_PATH"]
    OpenSSL::PKey::RSA.new(File.read(Rails.root.join(ENV["OPENID_CLIENT_PRIVATE_KEY_PATH"])))
  else
    OpenSSL::PKey::RSA.new(File.read(Rails.root.join("config", "client-private-key.pem")))
  end

  client_entity_statement_path = ENV["OPENID_CLIENT_ENTITY_STATEMENT_PATH"] || 
    Rails.root.join("config", "client-entity-statement.jwt").to_s

  OmniauthOpenidFederation::FederationEndpoint.auto_configure(
    issuer: app_url,
    private_key: private_key,
    entity_statement_path: client_entity_statement_path,
    metadata: {
      openid_relying_party: {
        redirect_uris: [
          ENV["OPENID_REDIRECT_URI"] || "#{app_url}/users/auth/openid_federation/callback"
        ],
        client_registration_types: ["automatic"],
        application_type: "web",
        grant_types: ["authorization_code"],
        response_types: ["code"],
        token_endpoint_auth_method: "private_key_jwt",
        token_endpoint_auth_signing_alg: "RS256",
        request_object_signing_alg: "RS256",
        id_token_encrypted_response_alg: "RSA-OAEP",
        id_token_encrypted_response_enc: "A128CBC-HS256",
        organization_name: ENV["OPENID_ORGANIZATION_NAME"]
      }
    }
  )
end

Step 6: Add Routes

# config/routes.rb
if ENV["OPENID_ENABLED"] == "true"
  mount OmniauthOpenidFederation::Engine => "/"
end

Rails.application.routes.draw do
  devise_for :users, controllers: {
    omniauth_callbacks: "users/omniauth_callbacks"
  }
end

Step 7: Create Callback Controller

# app/controllers/users/omniauth_callbacks_controller.rb
class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
  skip_before_action :verify_authenticity_token, only: [:openid_federation, :failure]
  skip_before_action :authenticate_user!, only: [:openid_federation, :failure]

  def openid_federation
    auth = request.env["omniauth.auth"]
    user = User.find_or_create_from_omniauth(auth)
    
    if user&.persisted?
      sign_in_and_redirect user, event: :authentication
    else
      redirect_to root_path, alert: "Authentication failed"
    end
  end

  def failure
    redirect_to root_path, alert: "Authentication failed"
  end
end

Step 8: Create User Model Method

# app/models/user.rb
class User < ApplicationRecord
  def self.find_or_create_from_omniauth(auth)
    user = find_by(provider: auth.provider, uid: auth.uid)
    
    if user
      user.update(
        email: auth.info.email,
        name: auth.info.name
      )
    else
      user = create(
        provider: auth.provider,
        uid: auth.uid,
        email: auth.info.email,
        name: auth.info.name
      )
    end
    
    user
  end
end

Passing Custom Parameters

Using request_object_params (Allow-List)

Pass custom parameters via request_object_params allow-list:

config.omniauth :openid_federation,
  request_object_params: ["custom_param", "another_param"],
  # ... other options

Parameters in the allow-list are automatically included in the JWT request object if present in the HTTP request.

Using prepare_request_object_params (Proc)

Use prepare_request_object_params proc to modify parameters before they're added to the signed request object. This is useful for:

  • Combining config values with form values (e.g., base acr_values + provider-specific)
  • Adding config-based parameters (e.g., ftn_spname from config)
  • Transforming or validating parameters
config.omniauth :openid_federation,
  request_object_params: [:ftn_spname], # Allow-list for custom params
  prepare_request_object_params: proc do |params|
    # Combine config acr_values with form acr_values
    form_acr_values = params["acr_values"]&.to_s&.strip
    config_acr_values = ENV["OPENID_ACR_VALUES"].to_s.strip
    
    if config_acr_values.present? && form_acr_values.present?
      params["acr_values"] = "#{config_acr_values} #{form_acr_values}".strip
    elsif config_acr_values.present?
      params["acr_values"] = config_acr_values
    end
    
    # Add custom parameter from config
    params["ftn_spname"] = ENV["OPENID_FTN_SPNAME"] if ENV["OPENID_FTN_SPNAME"].present?
    
    params
  end,
  # ... other options

Form Example (pass clean values, proc handles combining):

# In your form - pass only provider-specific value
<%= button_to "Login", user_openid_federation_omniauth_authorize_path, 
    method: :post,
    params: { acr_values: "provider_specific_level" } %>

The proc will combine this with config values before adding to the signed JWT.

Rodauth Integration

This strategy can be used with the Rodauth authentication framework via the rodauth-omniauth feature.

Minimal Roda/Rodauth setup

require "roda"
require "rodauth"
require "rodauth/omniauth"
require "omniauth_openid_federation"

DB = Sequel.sqlite # or your production database

DB.create_table? :accounts do
  primary_key :id
  String :email, null: false
end

DB.create_table? :account_identities do
  primary_key :id
  foreign_key :account_id, :accounts, null: false
  String :provider, null: false
  String :uid, null: false
  index [:provider, :uid], unique: true
end

class App < Roda
  plugin :sessions, secret: ENV.fetch("SESSION_SECRET") { SecureRandom.hex(32) }
  plugin :json

  plugin :rodauth, json: true do
    db DB
    enable :omniauth

    # Mount OmniAuth under /auth, as recommended by rodauth-omniauth
    omniauth_prefix "/auth"

    omniauth_provider :openid_federation,
      nil,
      nil,
      strategy_class: OmniAuth::Strategies::OpenIDFederation,
      name: :openid_federation,
      issuer: ENV.fetch("OPENID_ISSUER", "https://provider.example.com"),
      audience: ENV.fetch("OPENID_AUDIENCE", "https://provider.example.com"),
      client_options: {
        identifier: ENV["OPENID_CLIENT_ID"],
        redirect_uri: ENV["OPENID_REDIRECT_URI"] || "https://your-app.example.com/auth/openid_federation/callback",
        host: URI.parse(ENV.fetch("OPENID_ISSUER", "https://provider.example.com")).host,
        scheme: URI.parse(ENV.fetch("OPENID_ISSUER", "https://provider.example.com")).scheme,
        authorization_endpoint: "/oauth2/authorize",
        token_endpoint: "/oauth2/token",
        userinfo_endpoint: "/oauth2/userinfo",
        jwks_uri: "/.well-known/jwks.json",
        private_key: OpenSSL::PKey::RSA.new(
          Base64.decode64(ENV.fetch("OPENID_CLIENT_PRIVATE_KEY_BASE64"))
        )
      },
      entity_statement_url: ENV["OPENID_ENTITY_STATEMENT_URL"],
      entity_statement_fingerprint: ENV["OPENID_ENTITY_STATEMENT_FINGERPRINT"]
  end

  route do |r|
    # Rodauth authentication + OmniAuth endpoints
    # r.rodauth automatically handles omniauth routes based on omniauth_prefix setting
    r.rodauth

    r.root do
      if rodauth.logged_in?
        {logged_in: true, account_id: rodauth.session_value}
      else
        {logged_in: false}
      end
    end
  end
end

With this configuration, the Rodauth :omniauth feature:

  • uses OmniAuth::Strategies::OpenIDFederation as the strategy implementation,
  • calls the strategy's request and callback phases under /auth/openid_federation,
  • persists external identities into the account_identities table following the rodauth-omniauth schema,
  • and exposes helper methods like rodauth.omniauth_auth, rodauth.omniauth_email, and rodauth.omniauth_name as documented in the Rodauth documentation and rodauth-omniauth README.

Rake Tasks

Prepare Client Keys

rake openid_federation:prepare_client_keys

Fetch Entity Statement

rake openid_federation:fetch_entity_statement[
  "https://provider.example.com/.well-known/openid-federation",
  "expected-fingerprint-hash",
  "config/provider-entity-statement.jwt"
]

Test Authentication Flow

rake openid_federation:test_authentication_flow[
  "https://provider.example.com/login",
  "https://your-app.com",
  "urn:mace:incommon:iap:silver"
]

Configuration Options

Required

  • client_options.identifier - Client ID from provider
  • client_options.redirect_uri - Callback URL
  • client_options.private_key - RSA private key for signing
  • entity_statement_path - Path to cached entity statement file

Optional

  • entity_statement_url - URL to fetch entity statement (auto-fetches if provided)
  • entity_statement_fingerprint - Fingerprint for verification
  • client_entity_statement_url - Client entity statement URL (for automatic registration)
  • client_entity_statement_path - Client entity statement path (cached copy)
  • always_encrypt_request_object - Force encryption of request objects (default: false)
  • request_object_params - Array of parameter names to include in request object (allow-list)
  • prepare_request_object_params - Proc to modify params before adding to signed request object: proc { |params| modified_params }
  • discovery - Enable automatic endpoint discovery (default: true)

Security

  • All user input is validated and sanitized
  • Configuration values are trusted (not validated)
  • Signed request objects are required (RFC 9101)
  • CSRF protection via Rails tokens (request phase) and OAuth state (callback phase)
  • Private keys should never be committed to version control

Troubleshooting

"Missing authorization code": Check that redirect_uri matches provider configuration exactly.

"Failed to exchange authorization code": Verify private key is correct and client_id matches provider.

"Entity statement not found": Ensure entity statement is fetched and cached locally, or provide entity_statement_url.

Requirements

  • Ruby >= 3.0
  • Rails >= 6.1 (or compatible Rack application)
  • OpenSSL (for RSA key operations)

Example Files

See examples/ directory for complete configuration examples:

  • examples/standalone_multiple_endpoints_example.rb - Standalone Sinatra app with multiple auth endpoints and entrance point-based redirects
  • examples/config/initializers/devise.rb.example - Devise integration example (Rails)
  • examples/config/initializers/omniauth_openid_federation.rb.example - Federation endpoint configuration
  • examples/config/open_id_connect_config.rb.example - Configuration class example

Development

git clone https://github.com/amkisko/omniauth_openid_federation.rb.git
cd omniauth_openid_federation.rb
bundle install
bin/rspec

Contributing

Contributions welcome! Please read CONTRIBUTING.md for guidelines.

References

Specifications

Related Gems

License

MIT License. See LICENSE.md for details.