omniauth_openid_federation 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +44 -0
  3. data/LICENSE.md +22 -0
  4. data/README.md +922 -0
  5. data/SECURITY.md +28 -0
  6. data/app/controllers/omniauth_openid_federation/federation_controller.rb +160 -0
  7. data/config/routes.rb +17 -0
  8. data/examples/README_INTEGRATION_TESTING.md +399 -0
  9. data/examples/README_MOCK_OP.md +243 -0
  10. data/examples/app/controllers/users/omniauth_callbacks_controller.rb.example +37 -0
  11. data/examples/app/jobs/jwks_rotation_job.rb.example +60 -0
  12. data/examples/app/models/user.rb.example +39 -0
  13. data/examples/config/initializers/devise.rb.example +131 -0
  14. data/examples/config/initializers/federation_endpoint.rb.example +206 -0
  15. data/examples/config/mock_op.yml.example +83 -0
  16. data/examples/config/open_id_connect_config.rb.example +210 -0
  17. data/examples/config/routes.rb.example +12 -0
  18. data/examples/db/migrate/add_omniauth_to_users.rb.example +16 -0
  19. data/examples/integration_test_flow.rb +1334 -0
  20. data/examples/jobs/README.md +194 -0
  21. data/examples/jobs/federation_cache_refresh_job.rb.example +78 -0
  22. data/examples/jobs/federation_files_generation_job.rb.example +87 -0
  23. data/examples/mock_op_server.rb +775 -0
  24. data/examples/mock_rp_server.rb +435 -0
  25. data/lib/omniauth_openid_federation/access_token.rb +504 -0
  26. data/lib/omniauth_openid_federation/cache.rb +39 -0
  27. data/lib/omniauth_openid_federation/cache_adapter.rb +173 -0
  28. data/lib/omniauth_openid_federation/configuration.rb +135 -0
  29. data/lib/omniauth_openid_federation/constants.rb +13 -0
  30. data/lib/omniauth_openid_federation/endpoint_resolver.rb +168 -0
  31. data/lib/omniauth_openid_federation/engine.rb +17 -0
  32. data/lib/omniauth_openid_federation/entity_statement_reader.rb +129 -0
  33. data/lib/omniauth_openid_federation/errors.rb +52 -0
  34. data/lib/omniauth_openid_federation/federation/entity_statement.rb +331 -0
  35. data/lib/omniauth_openid_federation/federation/entity_statement_builder.rb +188 -0
  36. data/lib/omniauth_openid_federation/federation/entity_statement_fetcher.rb +142 -0
  37. data/lib/omniauth_openid_federation/federation/entity_statement_helper.rb +87 -0
  38. data/lib/omniauth_openid_federation/federation/entity_statement_parser.rb +198 -0
  39. data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +502 -0
  40. data/lib/omniauth_openid_federation/federation/metadata_policy_merger.rb +276 -0
  41. data/lib/omniauth_openid_federation/federation/signed_jwks.rb +210 -0
  42. data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +225 -0
  43. data/lib/omniauth_openid_federation/federation_endpoint.rb +949 -0
  44. data/lib/omniauth_openid_federation/http_client.rb +70 -0
  45. data/lib/omniauth_openid_federation/instrumentation.rb +399 -0
  46. data/lib/omniauth_openid_federation/jwks/cache.rb +76 -0
  47. data/lib/omniauth_openid_federation/jwks/decode.rb +175 -0
  48. data/lib/omniauth_openid_federation/jwks/fetch.rb +153 -0
  49. data/lib/omniauth_openid_federation/jwks/normalizer.rb +49 -0
  50. data/lib/omniauth_openid_federation/jwks/rotate.rb +97 -0
  51. data/lib/omniauth_openid_federation/jwks/selector.rb +101 -0
  52. data/lib/omniauth_openid_federation/jws.rb +410 -0
  53. data/lib/omniauth_openid_federation/key_extractor.rb +173 -0
  54. data/lib/omniauth_openid_federation/logger.rb +99 -0
  55. data/lib/omniauth_openid_federation/rack_endpoint.rb +187 -0
  56. data/lib/omniauth_openid_federation/railtie.rb +15 -0
  57. data/lib/omniauth_openid_federation/rate_limiter.rb +55 -0
  58. data/lib/omniauth_openid_federation/strategy.rb +2114 -0
  59. data/lib/omniauth_openid_federation/string_helpers.rb +30 -0
  60. data/lib/omniauth_openid_federation/tasks_helper.rb +428 -0
  61. data/lib/omniauth_openid_federation/utils.rb +168 -0
  62. data/lib/omniauth_openid_federation/validators.rb +126 -0
  63. data/lib/omniauth_openid_federation/version.rb +3 -0
  64. data/lib/omniauth_openid_federation.rb +99 -0
  65. data/lib/tasks/omniauth_openid_federation.rake +376 -0
  66. data/sig/federation.rbs +218 -0
  67. data/sig/jwks.rbs +63 -0
  68. data/sig/omniauth_openid_federation.rbs +254 -0
  69. data/sig/strategy.rbs +60 -0
  70. metadata +361 -0
data/README.md ADDED
@@ -0,0 +1,922 @@
1
+ # omniauth_openid_federation
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/omniauth_openid_federation.svg?v=1.1.0)](https://badge.fury.io/rb/omniauth_openid_federation) [![Test Status](https://github.com/amkisko/omniauth_openid_federation.rb/actions/workflows/test.yml/badge.svg)](https://github.com/amkisko/omniauth_openid_federation.rb/actions/workflows/test.yml) [![codecov](https://codecov.io/gh/amkisko/omniauth_openid_federation.rb/graph/badge.svg?token=CX3O9M1GIT)](https://codecov.io/gh/amkisko/omniauth_openid_federation.rb)
4
+
5
+ OmniAuth strategy for OpenID Federation providers with comprehensive security features, supporting signed request objects, ID token encryption, and full OpenID Federation 1.0 compliance.
6
+
7
+ Sponsored by [Kisko Labs](https://www.kiskolabs.com).
8
+
9
+ <a href="https://www.kiskolabs.com">
10
+ <img src="kisko.svg" width="200" alt="Sponsored by Kisko Labs" />
11
+ </a>
12
+
13
+
14
+ ## Installation
15
+
16
+ ```ruby
17
+ # Gemfile
18
+ gem "omniauth_openid_federation"
19
+ ```
20
+
21
+ ```bash
22
+ bundle install
23
+ ```
24
+
25
+ ## Features
26
+
27
+ - ✅ **Signed Request Objects (RFC 9101)** - RS256 signing of authorization requests (per OpenID Federation spec: "MUST be signed")
28
+ - ✅ **Optional Request Object Encryption** - Optional RSA-OAEP encryption when provider requires it (per spec: "MAY be encrypted")
29
+ - ✅ **ID Token Encryption/Decryption** - RSA-OAEP encryption and A128CBC-HS256 decryption
30
+ - ✅ **OpenID Federation 1.0** - Full entity statement support and federation metadata
31
+ - ✅ **Federation Endpoint** - Publish entity statements at `/.well-known/openid-federation`
32
+ - ✅ **Automatic Key Provisioning** - Automatic extraction/generation of signing and encryption keys with caching support
33
+ - ✅ **Separate Key Support** - Production-ready support for separate signing and encryption keys
34
+ - ✅ **Entity Type Support** - Full support for both `openid_relying_party` (RP) and `openid_provider` (OP) entity types
35
+ - ✅ **Signed JWKS Support** - Automatic validation for key rotation compliance
36
+ - ✅ **Automatic Provider Key Rotation** - Handles external provider key rotation automatically via Signed JWKS (client key rotation is manual)
37
+ - ✅ **Client Assertion (private_key_jwt)** - Secure client authentication
38
+ - ✅ **Security Hardened** - OWASP compliant, rate limiting, path traversal protection
39
+ - ✅ **Production Ready** - Thread-safe, comprehensive error handling
40
+
41
+ ## Quick Start
42
+
43
+ The library relies on **URLs and fingerprint verification** for security. Always fetch entity statements from provider URLs - local files are cached copies for configuration use. Everything is automated via discovery.
44
+
45
+ ### Step 1: Get Provider Information
46
+
47
+ Your provider will provide:
48
+ - **Entity statement URL**: `https://provider.example.com/.well-known/openid-federation`
49
+ - **Expected fingerprint hash**: For verification (security guard)
50
+
51
+ **Always use URLs**: Fetch and cache the entity statement locally using the URL and fingerprint:
52
+
53
+ ```bash
54
+ rake openid_federation:fetch_entity_statement[
55
+ "https://provider.example.com/.well-known/openid-federation",
56
+ "expected-fingerprint-hash",
57
+ "config/provider-entity-statement.jwt"
58
+ ]
59
+ ```
60
+
61
+ This fetches from the URL, verifies the fingerprint, and stores locally. The local file is a cached copy of the URL - always use the URL as the source of truth.
62
+
63
+ ### Step 2: Generate Client Keys
64
+
65
+ Generate RSA key pair for client authentication:
66
+
67
+ ```bash
68
+ rake openid_federation:prepare_client_keys
69
+ ```
70
+
71
+ This generates:
72
+ - Private key: `config/client-private-key.pem` (keep secure, never commit)
73
+ - Public JWKS: `config/client-jwks.json` (send to provider for explicit registration)
74
+
75
+ **Security**: Never commit private keys. Add to `.gitignore`:
76
+ ```
77
+ config/*-private-key.pem
78
+ ```
79
+
80
+ ### Step 3: Register Client
81
+
82
+ **Explicit Registration** (default):
83
+ 1. Send `config/client-jwks.json` to your provider
84
+ 2. Receive Client ID from provider
85
+
86
+ **Automatic Registration** (if provider supports it):
87
+ - No pre-registration needed
88
+ - Client entity statement is auto-generated via `FederationEndpoint` (see Step 5)
89
+ - Set `client_entity_statement_url` to `https://your-app.com/.well-known/openid-federation`
90
+
91
+ ### Step 4: Configure OmniAuth Strategy
92
+
93
+ #### For Devise (Rails)
94
+
95
+ ```ruby
96
+ # config/initializers/devise.rb
97
+ require "omniauth_openid_federation"
98
+
99
+ private_key = OpenSSL::PKey::RSA.new(File.read("config/client-private-key.pem"))
100
+
101
+ # Always provide the entity statement URL
102
+ entity_statement_url = "https://provider.example.com/.well-known/openid-federation"
103
+ entity_statement_fingerprint = "expected-fingerprint-hash"
104
+
105
+ # Fetch and cache entity statement from URL (run this via rake task or in initializer)
106
+ # rake openid_federation:fetch_entity_statement[entity_statement_url, entity_statement_fingerprint, "config/provider-entity-statement.jwt"]
107
+
108
+ config.omniauth :openid_federation,
109
+ discovery: true, # Enables automatic endpoint discovery
110
+ # Option 1: Provide URL (recommended - library fetches and caches automatically)
111
+ entity_statement_url: entity_statement_url, # Always provide URL as source of truth
112
+ entity_statement_fingerprint: entity_statement_fingerprint, # Fingerprint for verification
113
+ # Option 2: Provide issuer (library builds URL from issuer + /.well-known/openid-federation)
114
+ # issuer: "https://provider.example.com",
115
+ # Option 3: Provide cached path (optional - for offline development)
116
+ # entity_statement_path: "config/provider-entity-statement.jwt", # Cached copy from URL
117
+ client_options: {
118
+ identifier: ENV["OPENID_CLIENT_ID"],
119
+ redirect_uri: "#{ENV["APP_URL"]}/users/auth/openid_federation/callback",
120
+ private_key: private_key
121
+ }
122
+ ```
123
+
124
+ **Key Points**:
125
+ - `entity_statement_url` is recommended - library automatically fetches and caches
126
+ - `entity_statement_fingerprint` is used for verification when fetching from URL
127
+ - `issuer` can be used instead - library builds URL from issuer + `/.well-known/openid-federation`
128
+ - `entity_statement_path` is optional - only for offline development (cached copy)
129
+ - `discovery: true` automatically discovers all endpoints from entity statement
130
+
131
+ **Important**: Don't forget to configure CSRF protection (see [Step 7: Configure CSRF Protection](#step-7-configure-csrf-protection)) to ensure proper security for both request and callback phases.
132
+
133
+ #### For OmniAuth (non-Rails)
134
+
135
+ ```ruby
136
+ # config/initializers/omniauth.rb
137
+ require "omniauth_openid_federation"
138
+
139
+ entity_statement_url = "https://provider.example.com/.well-known/openid-federation"
140
+ entity_statement_fingerprint = "expected-fingerprint-hash"
141
+
142
+ Rails.application.config.middleware.use OmniAuth::Builder do
143
+ provider :openid_federation,
144
+ discovery: true,
145
+ entity_statement_path: "config/provider-entity-statement.jwt", # Cached copy from URL
146
+ entity_statement_url: entity_statement_url, # Always provide URL as source of truth
147
+ entity_statement_fingerprint: entity_statement_fingerprint, # Fingerprint for verification
148
+ client_options: {
149
+ identifier: ENV["OPENID_CLIENT_ID"],
150
+ redirect_uri: "https://your-app.com/auth/openid_federation/callback",
151
+ private_key: OpenSSL::PKey::RSA.new(File.read("config/client-private-key.pem"))
152
+ }
153
+ end
154
+ ```
155
+
156
+ ### Step 5: Configure Federation Endpoint (For Automatic Registration)
157
+
158
+ If using automatic registration, publish your client entity statement:
159
+
160
+ ```ruby
161
+ # config/initializers/omniauth_openid_federation.rb
162
+ OmniauthOpenidFederation::FederationEndpoint.auto_configure(
163
+ issuer: ENV["APP_URL"],
164
+ private_key: private_key,
165
+ entity_statement_path: "config/client-entity-statement.jwt", # Optional: cached copy for offline dev
166
+ metadata: {
167
+ openid_provider: {
168
+ issuer: ENV["APP_URL"],
169
+ authorization_endpoint: "#{ENV["APP_URL"]}/users/auth/openid_federation",
170
+ token_endpoint: "#{ENV["APP_URL"]}/users/auth/openid_federation",
171
+ userinfo_endpoint: "#{ENV["APP_URL"]}/users/auth/openid_federation",
172
+ jwks_uri: "#{ENV["APP_URL"]}/.well-known/jwks.json",
173
+ signed_jwks_uri: "#{ENV["APP_URL"]}/.well-known/signed-jwks.json"
174
+ }
175
+ }
176
+ )
177
+ ```
178
+
179
+ ```ruby
180
+ # config/routes.rb
181
+ # RECOMMENDED: Mount the Engine (Rails-idiomatic way)
182
+ mount OmniauthOpenidFederation::Engine => "/"
183
+
184
+ # ALTERNATIVE: Use mount_routes helper (for backward compatibility)
185
+ # OmniauthOpenidFederation::FederationEndpoint.mount_routes(self)
186
+ ```
187
+
188
+ **Key Points**:
189
+ - `auto_configure` automatically extracts/generates JWKS from keys
190
+ - Only application-specific endpoints need to be provided in metadata
191
+ - Well-known endpoints are auto-generated
192
+
193
+ ### Step 6: Add Routes
194
+
195
+ #### Mount the Engine (Required for Federation Endpoints)
196
+
197
+ The gem provides a Rails Engine that serves the well-known OpenID Federation endpoints. Mount it in your routes:
198
+
199
+ ```ruby
200
+ # config/routes.rb
201
+ Rails.application.routes.draw do
202
+ # Mount the Engine to enable /.well-known/openid-federation endpoint
203
+ mount OmniauthOpenidFederation::Engine => "/"
204
+
205
+ # Your other routes...
206
+ devise_for :users, controllers: {
207
+ omniauth_callbacks: "users/omniauth_callbacks"
208
+ }
209
+ end
210
+ ```
211
+
212
+ **Note**: The Engine is mounted at root (`"/"`) because OpenID Federation requires endpoints at specific well-known paths (e.g., `/.well-known/openid-federation`). The Engine's routes are defined in the gem and automatically available when mounted.
213
+
214
+ #### For OmniAuth (Non-Devise)
215
+
216
+ ```ruby
217
+ # config/routes.rb
218
+ Rails.application.routes.draw do
219
+ mount OmniauthOpenidFederation::Engine => "/"
220
+
221
+ get "/auth/:provider/callback", to: "sessions#create"
222
+ get "/auth/failure", to: "sessions#failure"
223
+ end
224
+ ```
225
+
226
+ #### Alternative: Manual Route Mounting (Backward Compatibility)
227
+
228
+ If you need custom paths or prefer manual route definition, you can use the `mount_routes` helper (deprecated):
229
+
230
+ ```ruby
231
+ # config/routes.rb
232
+ Rails.application.routes.draw do
233
+ # Use mount_routes helper for custom paths (deprecated - prefer Engine mounting)
234
+ OmniauthOpenidFederation::FederationEndpoint.mount_routes(self)
235
+ # ... your other routes
236
+ end
237
+ ```
238
+
239
+ ### Step 7: Configure CSRF Protection
240
+
241
+ OmniAuth requires CSRF protection configuration to handle both the request phase (initiating OAuth) and callback phase (external provider redirect).
242
+
243
+ **Important**: The request phase uses Rails CSRF tokens (forms must include them), while the callback phase uses OAuth state parameter for CSRF protection (external providers cannot include Rails CSRF tokens).
244
+
245
+ #### For Devise (Rails)
246
+
247
+ ```ruby
248
+ # config/initializers/devise.rb
249
+ if defined?(OmniAuth)
250
+ OmniAuth.config.allowed_request_methods = [:post]
251
+ OmniAuth.config.silence_get_warning = false
252
+
253
+ # Configure CSRF validation to check tokens only for request phase (initiating OAuth)
254
+ # Callback phase uses OAuth state parameter for CSRF protection (validated in strategy)
255
+ # This ensures:
256
+ # - Request phase: Forms must include Rails CSRF tokens (standard Rails protection)
257
+ # - Callback phase: OAuth state parameter provides CSRF protection (external providers can't include Rails tokens)
258
+ OmniAuth.config.request_validation_phase = lambda do |env|
259
+ request = Rack::Request.new(env)
260
+ path = request.path
261
+
262
+ # Skip CSRF validation for callback paths (external providers can't include Rails CSRF tokens)
263
+ # OAuth state parameter provides CSRF protection for callbacks (validated in OpenIDFederation strategy)
264
+ return true if path.end_with?("/callback")
265
+
266
+ # For request phase, use Rails' standard CSRF token validation
267
+ # This ensures forms must include valid CSRF tokens when initiating OAuth
268
+ session = env["rack.session"] || {}
269
+ token = request.params["authenticity_token"] || request.get_header("X-CSRF-Token")
270
+ expected_token = session[:_csrf_token] || session["_csrf_token"]
271
+
272
+ # Validate CSRF token using constant-time comparison
273
+ if token.present? && expected_token.present?
274
+ ActiveSupport::SecurityUtils.secure_compare(token.to_s, expected_token.to_s)
275
+ else
276
+ false
277
+ end
278
+ end
279
+ end
280
+ ```
281
+
282
+ **Security Notes**:
283
+ - **Request phase** (initiating OAuth): Forms must include Rails CSRF tokens via `button_to` or `form_with` helpers
284
+ - **Callback phase** (external provider redirect): OAuth `state` parameter provides CSRF protection (automatically validated in `OpenIDFederation` strategy using constant-time comparison)
285
+ - Both layers provide equivalent security - Rails CSRF tokens for request phase, OAuth state parameter for callbacks
286
+
287
+ ### Step 8: Create Callback Controller
288
+
289
+ #### For Devise
290
+
291
+ ```ruby
292
+ # app/controllers/users/omniauth_callbacks_controller.rb
293
+ class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
294
+ # Skip Rails CSRF protection for OAuth callbacks
295
+ # OAuth callbacks from external providers cannot include Rails CSRF tokens
296
+ # CSRF protection is handled by OAuth state parameter validation in the strategy
297
+ skip_before_action :verify_authenticity_token, only: [:openid_federation, :failure]
298
+ skip_before_action :authenticate_user!, only: [:openid_federation, :failure]
299
+
300
+ def openid_federation
301
+ auth = request.env["omniauth.auth"]
302
+ user = User.find_or_create_from_omniauth(auth)
303
+
304
+ if user&.persisted?
305
+ sign_in_and_redirect user, event: :authentication
306
+ else
307
+ redirect_to root_path, alert: "Authentication failed"
308
+ end
309
+ end
310
+
311
+ def failure
312
+ redirect_to root_path, alert: "Authentication failed"
313
+ end
314
+ end
315
+ ```
316
+
317
+ **Note**: The `skip_before_action :verify_authenticity_token` is required because Rails' `protect_from_forgery` in `ApplicationController` checks CSRF tokens for all POST requests. External providers cannot include Rails CSRF tokens in callbacks, so we skip Rails' check while relying on OAuth state parameter validation (handled by the strategy).
318
+
319
+ ### Step 9: Create User Model Method
320
+
321
+ ```ruby
322
+ # app/models/user.rb
323
+ class User < ApplicationRecord
324
+ def self.find_or_create_from_omniauth(auth)
325
+ user = find_by(provider: auth.provider, uid: auth.uid)
326
+
327
+ if user
328
+ user.update(
329
+ email: auth.info.email,
330
+ name: auth.info.name,
331
+ first_name: auth.info.first_name,
332
+ last_name: auth.info.last_name
333
+ )
334
+ else
335
+ user = create(
336
+ provider: auth.provider,
337
+ uid: auth.uid,
338
+ email: auth.info.email,
339
+ name: auth.info.name,
340
+ first_name: auth.info.first_name,
341
+ last_name: auth.info.last_name
342
+ )
343
+ end
344
+
345
+ user
346
+ end
347
+ end
348
+ ```
349
+
350
+ ## Rake Tasks
351
+
352
+ ### Prepare Client Keys
353
+
354
+ ```bash
355
+ rake openid_federation:prepare_client_keys
356
+ rake openid_federation:prepare_client_keys[separate,config] # Separate signing/encryption keys
357
+ ```
358
+
359
+ ### Fetch Entity Statement
360
+
361
+ Fetches entity statement from provider URL, verifies fingerprint, and caches locally:
362
+
363
+ ```bash
364
+ rake openid_federation:fetch_entity_statement[
365
+ "https://provider.example.com/.well-known/openid-federation",
366
+ "expected-fingerprint-hash",
367
+ "config/provider-entity-statement.jwt"
368
+ ]
369
+ ```
370
+
371
+ **Note**: Always use the URL as the source of truth - the local file is just a cached copy.
372
+
373
+ ### Parse Entity Statement
374
+
375
+ ```bash
376
+ rake openid_federation:parse_entity_statement["config/provider-entity-statement.jwt"]
377
+ ```
378
+
379
+ ### Test Local Entity Statement Endpoint
380
+
381
+ Validates your local entity statement endpoint and tests all linked endpoints. Useful for verifying your federation endpoint implementation:
382
+
383
+ ```bash
384
+ # Default (localhost:3000)
385
+ rake openid_federation:test_local_endpoint
386
+
387
+ # Custom base URL
388
+ rake openid_federation:test_local_endpoint[http://localhost:3000]
389
+
390
+ # Via environment variable
391
+ BASE_URL=http://localhost:3000 rake openid_federation:test_local_endpoint
392
+ ```
393
+
394
+ This task:
395
+ - Fetches and validates the entity statement from `/.well-known/openid-federation`
396
+ - Shows key configuration status (single vs separate keys) with recommendations
397
+ - Tests all endpoints mentioned in the entity statement
398
+ - Displays validation warnings without blocking execution
399
+
400
+ See all tasks: `rake -T openid_federation`
401
+
402
+ ### Cache Configuration and Key Rotation
403
+
404
+ Configure automatic key rotation:
405
+
406
+ ```ruby
407
+ OmniauthOpenidFederation.configure do |config|
408
+ config.cache_ttl = 3600 # Refresh provider keys every hour
409
+ config.rotate_on_errors = true # Auto-handle provider key rotation
410
+ end
411
+ ```
412
+
413
+ ### Security Instrumentation
414
+
415
+ Configure custom instrumentation for security events, MITM attack detection, and authentication mismatches:
416
+
417
+ ```ruby
418
+ OmniauthOpenidFederation.configure do |config|
419
+ # Configure with Sentry
420
+ config.instrumentation = ->(event, data) do
421
+ Sentry.capture_message(
422
+ "OpenID Federation: #{event}",
423
+ level: data[:severity] == :error ? :error : :warning,
424
+ extra: data
425
+ )
426
+ end
427
+ end
428
+ ```
429
+
430
+ **With Honeybadger**:
431
+ ```ruby
432
+ OmniauthOpenidFederation.configure do |config|
433
+ config.instrumentation = ->(event, data) do
434
+ Honeybadger.notify("OpenID Federation: #{event}", context: data)
435
+ end
436
+ end
437
+ ```
438
+
439
+ **With custom logger**:
440
+ ```ruby
441
+ OmniauthOpenidFederation.configure do |config|
442
+ config.instrumentation = ->(event, data) do
443
+ Rails.logger.warn("[Security] #{event}: #{data.inspect}")
444
+ end
445
+ end
446
+ ```
447
+
448
+ **Instrumented Events**:
449
+ - `csrf_detected` - CSRF attack detected (state mismatch in callback phase)
450
+ - `authenticity_error` - OmniAuth CSRF protection blocked request (Rails CSRF token validation failed in request phase)
451
+ - `signature_verification_failed` - JWT signature verification failed (possible MITM)
452
+ - `decryption_failed` - Token decryption failed (possible MITM or key mismatch)
453
+ - `token_validation_failed` - Token validation failed (possible tampering)
454
+ - `key_rotation_detected` - Key rotation detected (normal operation)
455
+ - `kid_not_found` - Key ID not found in JWKS (possible key rotation or MITM)
456
+ - `entity_statement_validation_failed` - Entity statement validation failed (possible MITM)
457
+ - `fingerprint_mismatch` - Entity statement fingerprint mismatch (possible MITM)
458
+ - `trust_chain_validation_failed` - Trust chain validation failed
459
+ - `unexpected_authentication_break` - Unexpected authentication failure (missing code, token exchange errors, unknown errors)
460
+ - `missing_required_claims` - Token missing required claims
461
+
462
+ **Note**: All blocking exceptions are automatically reported through instrumentation, including:
463
+ - OmniAuth middleware errors (like `AuthenticityTokenProtection` blocking requests)
464
+ - Strategy-level errors (CSRF detected, missing code, token exchange failures)
465
+ - Unknown error types (reported as `unexpected_authentication_break`)
466
+
467
+ **Security Note**: All sensitive data (tokens, keys, fingerprints) is automatically sanitized before being sent to your instrumentation callback.
468
+
469
+ **Key Rotation Types**:
470
+ - **Provider Keys** (from external providers): ✅ Automatic via Signed JWKS - library automatically detects and uses new provider keys
471
+ - **Client Keys** (your own keys): ⚠️ **Manual rotation required** - you must generate new RSA keys and update entity statement
472
+
473
+ **Client Key Rotation Process** (Manual Steps Required):
474
+ 1. **Generate new RSA keys** (manual):
475
+ ```bash
476
+ bundle exec rake omniauth_openid_federation:prepare_client_keys[key_type=separate]
477
+ ```
478
+ 2. **Update entity statement file** (manual): Update `entity_statement_path` with new keys, or let the library regenerate it
479
+ 3. **Library automatically uses new keys** (automatic): Library extracts JWKS from updated entity statement file on next cache refresh
480
+
481
+ **Note**: The library automatically generates JWKS from your RSA keys, but you must manually generate new RSA keys when rotating. The library then automatically uses the new keys from the updated entity statement file. See [Automatic Key Provisioning](#automatic-key-provisioning) for details.
482
+
483
+ ### Publishing Federation Endpoint
484
+
485
+ Publish your entity statement at `/.well-known/openid-federation` using `auto_configure`.
486
+
487
+ The library supports two entity types:
488
+ - **openid_relying_party (RP)**: For clients/relying parties (PRIMARY USE CASE)
489
+ - **openid_provider (OP)**: For providers/servers (secondary use case)
490
+
491
+ #### Relying Party (RP) Configuration (Primary Use Case)
492
+
493
+ **First, generate your RSA keys** (if not already generated):
494
+
495
+ ```bash
496
+ # Generate separate signing and encryption keys (RECOMMENDED for production)
497
+ bundle exec rake omniauth_openid_federation:prepare_client_keys[key_type=separate]
498
+
499
+ # Or generate single key for dev/testing (NOT RECOMMENDED for production)
500
+ bundle exec rake omniauth_openid_federation:prepare_client_keys[key_type=single]
501
+ ```
502
+
503
+ This creates:
504
+ - `config/client-signing-private-key.pem` and `config/client-encryption-private-key.pem` (separate keys)
505
+ - OR `config/client-private-key.pem` (single key for dev/testing)
506
+
507
+ **Then configure the federation endpoint** - the library automatically generates JWKS from your keys:
508
+
509
+ ```ruby
510
+ # config/initializers/omniauth_openid_federation.rb
511
+ # Production Setup (RECOMMENDED): Separate signing and encryption keys
512
+ # The library automatically generates JWKS from these keys
513
+ OmniauthOpenidFederation::FederationEndpoint.auto_configure(
514
+ issuer: "https://your-app.com",
515
+ signing_key: OpenSSL::PKey::RSA.new(File.read("config/client-signing-private-key.pem")),
516
+ encryption_key: OpenSSL::PKey::RSA.new(File.read("config/client-encryption-private-key.pem")),
517
+ entity_statement_path: "config/client-entity-statement.jwt", # Cache for automatic key rotation
518
+ metadata: {
519
+ openid_relying_party: {
520
+ redirect_uris: ["https://your-app.com/users/auth/openid_federation/callback"],
521
+ client_registration_types: ["automatic"],
522
+ application_type: "web",
523
+ grant_types: ["authorization_code"],
524
+ response_types: ["code"],
525
+ token_endpoint_auth_method: "private_key_jwt",
526
+ token_endpoint_auth_signing_alg: "RS256",
527
+ request_object_signing_alg: "RS256",
528
+ id_token_encrypted_response_alg: "RSA-OAEP",
529
+ id_token_encrypted_response_enc: "A128CBC-HS256"
530
+ }
531
+ },
532
+ auto_provision_keys: true # Library automatically generates JWKS from provided keys
533
+ )
534
+ ```
535
+
536
+ **Development/Testing** (NOT RECOMMENDED FOR PRODUCTION):
537
+ ```ruby
538
+ OmniauthOpenidFederation::FederationEndpoint.auto_configure(
539
+ issuer: "https://your-app.com",
540
+ private_key: private_key, # DEV/TESTING ONLY - single key for both signing and encryption
541
+ entity_statement_path: "config/client-entity-statement.jwt",
542
+ metadata: {
543
+ openid_relying_party: { ... }
544
+ },
545
+ auto_provision_keys: true
546
+ )
547
+ ```
548
+
549
+ #### OpenID Provider (OP) Configuration (Secondary Use Case)
550
+
551
+ **First, generate your RSA keys** (if not already generated):
552
+
553
+ ```bash
554
+ # Generate separate signing and encryption keys (RECOMMENDED for production)
555
+ bundle exec rake omniauth_openid_federation:prepare_client_keys[key_type=separate,output_dir=config]
556
+
557
+ # Or generate single key for dev/testing (NOT RECOMMENDED for production)
558
+ bundle exec rake omniauth_openid_federation:prepare_client_keys[key_type=single,output_dir=config]
559
+ ```
560
+
561
+ **Then configure the federation endpoint** - the library automatically generates JWKS from your keys:
562
+
563
+ ```ruby
564
+ # For provider/server applications
565
+ # Production Setup (RECOMMENDED): Separate signing and encryption keys
566
+ # The library automatically generates JWKS from these keys
567
+ signing_key = OpenSSL::PKey::RSA.new(File.read("config/client-signing-private-key.pem"))
568
+ encryption_key = OpenSSL::PKey::RSA.new(File.read("config/client-encryption-private-key.pem"))
569
+
570
+ OmniauthOpenidFederation::FederationEndpoint.auto_configure(
571
+ issuer: "https://provider.example.com",
572
+ signing_key: signing_key,
573
+ encryption_key: encryption_key,
574
+ entity_statement_path: "config/provider-entity-statement.jwt",
575
+ metadata: {
576
+ openid_provider: {
577
+ issuer: "https://provider.example.com",
578
+ authorization_endpoint: "https://provider.example.com/oauth2/authorize",
579
+ token_endpoint: "https://provider.example.com/oauth2/token",
580
+ userinfo_endpoint: "https://provider.example.com/oauth2/userinfo",
581
+ jwks_uri: "https://provider.example.com/.well-known/jwks.json",
582
+ signed_jwks_uri: "https://provider.example.com/.well-known/signed-jwks.json"
583
+ # federation_fetch_endpoint is automatically added for OPs
584
+ }
585
+ },
586
+ auto_provision_keys: true # Library automatically generates JWKS from provided keys
587
+ )
588
+ ```
589
+
590
+ **Development/Testing** (NOT RECOMMENDED FOR PRODUCTION):
591
+ ```ruby
592
+ # Single private key for both signing and encryption (DEV/TESTING ONLY)
593
+ OmniauthOpenidFederation::FederationEndpoint.auto_configure(
594
+ issuer: "https://provider.example.com",
595
+ private_key: private_key, # DEV/TESTING ONLY - not recommended for production
596
+ entity_statement_path: "config/provider-entity-statement.jwt",
597
+ metadata: {
598
+ openid_provider: {
599
+ issuer: "https://provider.example.com",
600
+ authorization_endpoint: "https://provider.example.com/oauth2/authorize",
601
+ token_endpoint: "https://provider.example.com/oauth2/token",
602
+ userinfo_endpoint: "https://provider.example.com/oauth2/userinfo",
603
+ jwks_uri: "https://provider.example.com/.well-known/jwks.json",
604
+ signed_jwks_uri: "https://provider.example.com/.well-known/signed-jwks.json"
605
+ }
606
+ },
607
+ auto_provision_keys: true
608
+ )
609
+ ```
610
+
611
+ ```ruby
612
+ # config/routes.rb
613
+ # RECOMMENDED: Mount the Engine (Rails-idiomatic way)
614
+ mount OmniauthOpenidFederation::Engine => "/"
615
+
616
+ # ALTERNATIVE: Use mount_routes helper (for backward compatibility)
617
+ # OmniauthOpenidFederation::FederationEndpoint.mount_routes(self)
618
+ ```
619
+
620
+ **What `auto_configure` does automatically**:
621
+ - Extracts JWKS from entity statement file or generates from provided keys
622
+ - Supports separate signing/encryption keys (RECOMMENDED) or single key (dev/testing)
623
+ - Auto-detects entity type and generates well-known endpoints
624
+ - Uses `entity_statement_path` as cache for key rotation
625
+
626
+ **Manual Configuration** (advanced, not recommended):
627
+
628
+ If you need manual control, use `configure` instead of `auto_configure`:
629
+
630
+ ```ruby
631
+ OmniauthOpenidFederation::FederationEndpoint.configure do |config|
632
+ config.issuer = "https://your-app.com"
633
+ config.subject = "https://your-app.com"
634
+ config.signing_key = signing_key # RECOMMENDED: Separate signing key
635
+ config.encryption_key = encryption_key # RECOMMENDED: Separate encryption key
636
+ config.jwks = jwks # Must provide manually
637
+ config.metadata = { ... }
638
+ end
639
+ ```
640
+
641
+ ### Automatic Key Provisioning
642
+
643
+ The `auto_configure` method automatically generates JWKS from your RSA keys (generate keys first using the rake task).
644
+
645
+ **Priority Order**:
646
+ 1. Extracts JWKS from `entity_statement_path` if file exists (supports key rotation)
647
+ 2. Generates JWKS from separate `signing_key` and `encryption_key` (RECOMMENDED)
648
+ 3. Generates JWKS from single `private_key` (dev/testing only)
649
+
650
+ **Key Rotation** (Semi-Automatic):
651
+ 1. **Manual**: Generate new RSA keys using `rake omniauth_openid_federation:prepare_client_keys`
652
+ 2. **Manual**: Update entity statement file at `entity_statement_path` with new keys
653
+ 3. **Automatic**: Library extracts and uses new keys from updated file on next cache refresh
654
+
655
+ ## Configuration Options
656
+
657
+ ### Required
658
+
659
+ - `client_options[:identifier]` - Client ID from provider
660
+ - `client_options[:redirect_uri]` - Callback URL
661
+ - `client_options[:private_key]` - RSA private key for signing
662
+ - **One of the following** (for provider entity statement):
663
+ - `entity_statement_url` - Provider entity statement URL (recommended - library fetches and caches automatically)
664
+ - `issuer` - Provider issuer URI (library builds entity statement URL from issuer + `/.well-known/openid-federation`)
665
+ - `entity_statement_path` - Provider entity statement path (optional - for offline development)
666
+
667
+ ### Optional
668
+
669
+ - `discovery` - Enable automatic endpoint discovery (default: `true`)
670
+ - `entity_statement_fingerprint` - Expected SHA-256 fingerprint for verification (recommended when using `entity_statement_url` or `issuer`)
671
+ - `entity_statement_path` - Path to provider entity statement (optional - for offline development, cached copy)
672
+ - `always_encrypt_request_object` - Always encrypt request objects if encryption keys are available (default: `false`, see [Request Object Security](#request-object-security-signing-vs-encryption) below)
673
+ - `client_entity_statement_url` - URL to client entity statement (for automatic registration)
674
+ - `client_entity_statement_path` - Path to client entity statement (fallback if URL not available)
675
+ - `client_registration_type` - `:explicit` (default) or `:automatic` (auto-detected if client_entity_statement_url/path provided)
676
+ - `client_entity_identifier` - Entity identifier for automatic registration
677
+ - `scope` - OAuth scopes (default: `[:openid]`)
678
+ - `response_type` - Response type (default: `"code"`)
679
+ - `client_auth_method` - Client authentication (default: `:jwt_bearer`)
680
+ - `client_signing_alg` - Signing algorithm (default: `:RS256`)
681
+ - `fetch_userinfo` - Whether to fetch userinfo endpoint (default: `true`)
682
+ - `acr_values` - Authentication Context Class Reference values (provider-specific)
683
+ - `key_source` - `:local` (default) or `:federation` (advanced)
684
+
685
+ ### Global Configuration
686
+
687
+ Configure global settings via `OmniauthOpenidFederation.configure`:
688
+
689
+ ```ruby
690
+ OmniauthOpenidFederation.configure do |config|
691
+ # Cache configuration
692
+ config.cache_ttl = 3600 # JWKS cache TTL in seconds
693
+ config.rotate_on_errors = true # Auto-rotate on key-related errors
694
+
695
+ # Security instrumentation (Sentry, Honeybadger, etc.)
696
+ config.instrumentation = ->(event, data) do
697
+ Sentry.capture_message("OpenID Federation: #{event}", level: :warning, extra: data)
698
+ end
699
+
700
+ # HTTP configuration
701
+ config.http_timeout = 10
702
+ config.max_retries = 3
703
+ config.verify_ssl = true
704
+ end
705
+ ```
706
+
707
+ ### Request Object Security (Signing vs Encryption)
708
+
709
+ **Per OpenID Federation 1.0 and RFC 9101:**
710
+ - **Signing (MANDATORY)**: Request objects **MUST be signed** using RS256 (always enforced, cannot be disabled)
711
+ - **Encryption (OPTIONAL)**: Request objects **MAY be encrypted** when provider requires it or when `always_encrypt_request_object: true`
712
+
713
+ **Encryption Behavior:**
714
+ - **Default** (`always_encrypt_request_object: false`): Only encrypts if provider metadata specifies `request_object_encryption_alg`
715
+ - **When `true`**: Encrypts even if provider doesn't require it (if encryption keys available)
716
+ - **Use case**: High-security deployments requiring defense-in-depth beyond minimum spec
717
+
718
+ **Note**: Signing provides authentication and integrity. Encryption adds confidentiality but is optional and adds overhead.
719
+
720
+ ### Detailed Configuration Examples
721
+
722
+ #### Devise with Environment Variables (Recommended)
723
+
724
+ ```ruby
725
+ # config/initializers/devise.rb
726
+ require "omniauth_openid_federation"
727
+
728
+ private_key = if ENV["OPENID_CLIENT_PRIVATE_KEY"]
729
+ OpenSSL::PKey::RSA.new(Base64.decode64(ENV["OPENID_CLIENT_PRIVATE_KEY"]))
730
+ else
731
+ OpenSSL::PKey::RSA.new(File.read("config/client-private-key.pem"))
732
+ end
733
+
734
+ config.omniauth :openid_federation,
735
+ discovery: true, # Auto-discovers endpoints from entity statement
736
+ entity_statement_url: ENV["OPENID_ENTITY_STATEMENT_URL"], # Always provide URL
737
+ entity_statement_fingerprint: ENV["OPENID_ENTITY_STATEMENT_FINGERPRINT"], # Fingerprint for verification
738
+ entity_statement_path: "config/provider-entity-statement.jwt", # Cached copy from URL (fetch via rake task)
739
+ client_entity_statement_url: "#{ENV["APP_URL"]}/.well-known/openid-federation", # For automatic registration
740
+ client_options: {
741
+ identifier: ENV["OPENID_CLIENT_ID"],
742
+ redirect_uri: "#{ENV["APP_URL"]}/users/auth/openid_federation/callback",
743
+ private_key: private_key
744
+ }
745
+ # All endpoints are auto-discovered - no manual configuration needed
746
+ ```
747
+
748
+ #### OmniAuth with URL-based Entity Statement (Production)
749
+
750
+ ```ruby
751
+ # config/initializers/omniauth.rb
752
+ require "omniauth_openid_federation"
753
+
754
+ entity_statement_url = "https://provider.example.com/.well-known/openid-federation"
755
+ entity_statement_fingerprint = "expected-fingerprint-hash"
756
+
757
+ Rails.application.config.middleware.use OmniAuth::Builder do
758
+ provider :openid_federation,
759
+ discovery: true,
760
+ entity_statement_url: entity_statement_url, # Always provide URL
761
+ entity_statement_fingerprint: entity_statement_fingerprint, # Fingerprint for verification
762
+ entity_statement_path: "config/provider-entity-statement.jwt", # Cached copy from URL
763
+ client_options: {
764
+ identifier: ENV["OPENID_CLIENT_ID"],
765
+ redirect_uri: "https://your-app.com/auth/openid_federation/callback",
766
+ private_key: OpenSSL::PKey::RSA.new(File.read("config/client-private-key.pem"))
767
+ }
768
+ end
769
+ ```
770
+
771
+ **Key Points**:
772
+ - **Always provide `entity_statement_url`** - this is the source of truth
773
+ - `entity_statement_fingerprint` is used for verification when fetching
774
+ - `entity_statement_path` points to the cached copy fetched from the URL
775
+ - All endpoints are automatically discovered - no manual endpoint configuration
776
+
777
+ ## API Reference
778
+
779
+ ### `OmniauthOpenidFederation::Jws`
780
+
781
+ Builds and signs JWT request objects:
782
+
783
+ ```ruby
784
+ jws = OmniauthOpenidFederation::Jws.new(
785
+ client_id: "client-id",
786
+ redirect_uri: "https://example.com/callback",
787
+ scope: "openid",
788
+ issuer: "https://provider.example.com",
789
+ audience: "https://provider.example.com",
790
+ private_key: private_key
791
+ )
792
+ signed_jwt = jws.sign
793
+ ```
794
+
795
+ ### `OmniauthOpenidFederation::Federation::EntityStatement`
796
+
797
+ Fetches and validates entity statements:
798
+
799
+ ```ruby
800
+ statement = OmniauthOpenidFederation::Federation::EntityStatement.fetch!(
801
+ "https://provider.example.com/.well-known/openid-federation",
802
+ fingerprint: "expected-fingerprint"
803
+ )
804
+ metadata = statement.parse
805
+ ```
806
+
807
+ ### `OmniauthOpenidFederation::Federation::SignedJWKS`
808
+
809
+ Fetches and validates signed JWKS:
810
+
811
+ ```ruby
812
+ signed_jwks = OmniauthOpenidFederation::Federation::SignedJWKS.fetch!(
813
+ signed_jwks_uri,
814
+ entity_jwks
815
+ )
816
+ ```
817
+
818
+ See inline code documentation for complete API reference.
819
+
820
+ ## Troubleshooting
821
+
822
+ **"Private key is required"**
823
+ - Generate keys: `rake openid_federation:prepare_client_keys`
824
+ - Verify key path and format (PEM)
825
+
826
+ **"Audience is required"**
827
+ - Provide `entity_statement_url` and `entity_statement_path` (auto-resolves audience from entity statement)
828
+
829
+ **"Entity statement fingerprint mismatch"**
830
+ - Verify `entity_statement_fingerprint` with provider
831
+ - Fetch fresh entity statement from URL: `rake openid_federation:fetch_entity_statement[entity_statement_url, entity_statement_fingerprint, entity_statement_path]`
832
+ - Always use the provider URL as the source of truth
833
+
834
+ **"JWT signature verification failed"**
835
+ - Provider may have rotated keys (auto-handled with `rotate_on_errors: true`)
836
+ - Clear cache: `Rails.cache.delete_matched("openid_federation_jwks_*")`
837
+
838
+ **"Attack prevented by OmniAuth::AuthenticityTokenProtection" or "OmniAuth::AuthenticityError"**
839
+ - **Request phase (initiating OAuth)**: Ensure forms include Rails CSRF tokens using `button_to` or `form_with` helpers
840
+ - **Callback phase (external provider redirect)**: Ensure CSRF protection is configured correctly (see [Step 7: Configure CSRF Protection](#step-7-configure-csrf-protection))
841
+ - Verify `OmniAuth.config.request_validation_phase` is configured to skip CSRF validation for callback paths
842
+ - Ensure `skip_before_action :verify_authenticity_token` is present in the callback controller for callback actions
843
+ - Check that OAuth state parameter validation is working (handled automatically by the strategy)
844
+
845
+ ## Security
846
+
847
+ See [SECURITY.md](SECURITY.md) for detailed security features, protections, and vulnerability reporting.
848
+
849
+ ## Requirements
850
+
851
+ - Ruby >= 3.0
852
+ - Rails >= 6.0 (optional)
853
+ - `omniauth-oauth2` ~> 1.8
854
+ - `openid_connect` ~> 2.3
855
+ - `jwe` ~> 1.1
856
+ - `jwt` ~> 3.1
857
+ - `http` ~> 5.3
858
+
859
+ ## Example Files
860
+
861
+ See `examples/` directory for complete configuration examples:
862
+ - `examples/config/initializers/devise.rb.example`
863
+ - `examples/app/controllers/users/omniauth_callbacks_controller.rb.example`
864
+ - `examples/app/models/user.rb.example`
865
+
866
+ ## Development
867
+
868
+ Run release.rb script to prepare code for publishing, it has all the required checks and tests.
869
+
870
+ ```bash
871
+ usr/bin/release.rb
872
+ ```
873
+
874
+ ### Development: Using from Local Repository
875
+
876
+ When developing the gem or testing changes in your application, you can point your Gemfile to a local path:
877
+
878
+ ```ruby
879
+ # In your application's Gemfile
880
+ gem "omniauth_openid_federation", path: "../omniauth_openid_federation.rb"
881
+ ```
882
+
883
+ Then run:
884
+
885
+ ```bash
886
+ bundle install
887
+ ```
888
+
889
+ **Note:** When using `path:` in your Gemfile, Bundler will use the local gem directly. Changes you make to the gem code will be immediately available in your application without needing to rebuild or reinstall the gem. This is ideal for development and testing.
890
+
891
+ ## Contributing
892
+
893
+ Bug reports and pull requests are welcome on GitHub at https://github.com/amkisko/omniauth_openid_federation.rb
894
+
895
+ Contribution policy:
896
+ - New features are not necessarily added to the gem
897
+ - Pull request should have test coverage for affected parts
898
+ - Pull request should have changelog entry
899
+
900
+ Review policy:
901
+ - It might take up to 2 calendar weeks to review and merge critical fixes
902
+ - It might take up to 6 calendar months to review and merge pull request
903
+ - It might take up to 1 calendar year to review an issue
904
+
905
+
906
+ ## Publishing
907
+
908
+ ```sh
909
+ rm omniauth_openid_federation-*.gem
910
+ gem build omniauth_openid_federation.gemspec
911
+ gem push omniauth_openid_federation-*.gem
912
+ ```
913
+
914
+ ## References
915
+
916
+ - [OpenID Federation 1.0 Specification](https://openid.net/specs/openid-federation-1_0.html)
917
+ - [RFC 9101 - OAuth 2.0 Authorization Request](https://datatracker.ietf.org/doc/html/rfc9101)
918
+ - [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
919
+
920
+ ## License
921
+
922
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).