Skip to content

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

License

Notifications You must be signed in to change notification settings

amkisko/omniauth_openid_federation.rb

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

54 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

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.

About

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

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Sponsor this project

Languages