omniauth_openid_federation
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.
Installation
# Gemfile
gem "omniauth_openid_federation"bundle installFeatures
- ✅ 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_keysThis 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):
- Send
config/client-jwks.jsonto your provider - Receive Client ID from provider
Automatic Registration (if provider supports it):
- No pre-registration needed
- Set
client_entity_statement_urltohttps://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
endStep 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"]
}
}
)
endStep 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"
}
endStep 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
endStep 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
endPassing 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 optionsParameters 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_spnamefrom 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 optionsForm 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
endWith this configuration, the Rodauth :omniauth feature:
- uses
OmniAuth::Strategies::OpenIDFederationas the strategy implementation, - calls the strategy's request and callback phases under
/auth/openid_federation, - persists external identities into the
account_identitiestable following therodauth-omniauthschema, - and exposes helper methods like
rodauth.omniauth_auth,rodauth.omniauth_email, androdauth.omniauth_nameas documented in the Rodauth documentation androdauth-omniauthREADME.
Rake Tasks
Prepare Client Keys
rake openid_federation:prepare_client_keysFetch 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/rspecContributing
Contributions welcome! Please read CONTRIBUTING.md for guidelines.
References
Specifications
- OpenID Federation 1.0
- RFC 9101: OAuth 2.0 Authorization Server Issuer Identification
- OpenID Connect Core 1.0
Related Gems
- omniauth - Authentication framework
- devise - Rails authentication solution
- jwt - JSON Web Token implementation
- jwe - JSON Web Encryption
- openid_connect - OpenID Connect client
- http - HTTP client
- anyway_config - Configuration management
- action_reporter - Error reporting
License
MIT License. See LICENSE.md for details.