omniauth_openid_federation 1.0.0

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 (67) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +16 -0
  3. data/LICENSE.md +22 -0
  4. data/README.md +822 -0
  5. data/SECURITY.md +129 -0
  6. data/examples/README_INTEGRATION_TESTING.md +399 -0
  7. data/examples/README_MOCK_OP.md +243 -0
  8. data/examples/app/controllers/users/omniauth_callbacks_controller.rb.example +33 -0
  9. data/examples/app/jobs/jwks_rotation_job.rb.example +60 -0
  10. data/examples/app/models/user.rb.example +39 -0
  11. data/examples/config/initializers/devise.rb.example +97 -0
  12. data/examples/config/initializers/federation_endpoint.rb.example +206 -0
  13. data/examples/config/mock_op.yml.example +83 -0
  14. data/examples/config/open_id_connect_config.rb.example +210 -0
  15. data/examples/config/routes.rb.example +12 -0
  16. data/examples/db/migrate/add_omniauth_to_users.rb.example +16 -0
  17. data/examples/integration_test_flow.rb +1334 -0
  18. data/examples/jobs/README.md +194 -0
  19. data/examples/jobs/federation_cache_refresh_job.rb.example +78 -0
  20. data/examples/jobs/federation_files_generation_job.rb.example +87 -0
  21. data/examples/mock_op_server.rb +775 -0
  22. data/examples/mock_rp_server.rb +435 -0
  23. data/lib/omniauth_openid_federation/access_token.rb +504 -0
  24. data/lib/omniauth_openid_federation/cache.rb +39 -0
  25. data/lib/omniauth_openid_federation/cache_adapter.rb +173 -0
  26. data/lib/omniauth_openid_federation/configuration.rb +135 -0
  27. data/lib/omniauth_openid_federation/constants.rb +13 -0
  28. data/lib/omniauth_openid_federation/endpoint_resolver.rb +168 -0
  29. data/lib/omniauth_openid_federation/entity_statement_reader.rb +122 -0
  30. data/lib/omniauth_openid_federation/errors.rb +52 -0
  31. data/lib/omniauth_openid_federation/federation/entity_statement.rb +331 -0
  32. data/lib/omniauth_openid_federation/federation/entity_statement_builder.rb +188 -0
  33. data/lib/omniauth_openid_federation/federation/entity_statement_fetcher.rb +142 -0
  34. data/lib/omniauth_openid_federation/federation/entity_statement_helper.rb +87 -0
  35. data/lib/omniauth_openid_federation/federation/entity_statement_parser.rb +198 -0
  36. data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +502 -0
  37. data/lib/omniauth_openid_federation/federation/metadata_policy_merger.rb +276 -0
  38. data/lib/omniauth_openid_federation/federation/signed_jwks.rb +210 -0
  39. data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +225 -0
  40. data/lib/omniauth_openid_federation/federation_endpoint.rb +949 -0
  41. data/lib/omniauth_openid_federation/http_client.rb +70 -0
  42. data/lib/omniauth_openid_federation/instrumentation.rb +383 -0
  43. data/lib/omniauth_openid_federation/jwks/cache.rb +76 -0
  44. data/lib/omniauth_openid_federation/jwks/decode.rb +174 -0
  45. data/lib/omniauth_openid_federation/jwks/fetch.rb +153 -0
  46. data/lib/omniauth_openid_federation/jwks/normalizer.rb +49 -0
  47. data/lib/omniauth_openid_federation/jwks/rotate.rb +97 -0
  48. data/lib/omniauth_openid_federation/jwks/selector.rb +101 -0
  49. data/lib/omniauth_openid_federation/jws.rb +416 -0
  50. data/lib/omniauth_openid_federation/key_extractor.rb +173 -0
  51. data/lib/omniauth_openid_federation/logger.rb +99 -0
  52. data/lib/omniauth_openid_federation/rack_endpoint.rb +187 -0
  53. data/lib/omniauth_openid_federation/railtie.rb +29 -0
  54. data/lib/omniauth_openid_federation/rate_limiter.rb +55 -0
  55. data/lib/omniauth_openid_federation/strategy.rb +2029 -0
  56. data/lib/omniauth_openid_federation/string_helpers.rb +30 -0
  57. data/lib/omniauth_openid_federation/tasks_helper.rb +428 -0
  58. data/lib/omniauth_openid_federation/utils.rb +166 -0
  59. data/lib/omniauth_openid_federation/validators.rb +126 -0
  60. data/lib/omniauth_openid_federation/version.rb +3 -0
  61. data/lib/omniauth_openid_federation.rb +98 -0
  62. data/lib/tasks/omniauth_openid_federation.rake +376 -0
  63. data/sig/federation.rbs +218 -0
  64. data/sig/jwks.rbs +63 -0
  65. data/sig/omniauth_openid_federation.rbs +254 -0
  66. data/sig/strategy.rbs +60 -0
  67. metadata +352 -0
data/README.md ADDED
@@ -0,0 +1,822 @@
1
+ # omniauth_openid_federation
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/omniauth_openid_federation.svg)](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)
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
+ #### For OmniAuth (non-Rails)
132
+
133
+ ```ruby
134
+ # config/initializers/omniauth.rb
135
+ require "omniauth_openid_federation"
136
+
137
+ entity_statement_url = "https://provider.example.com/.well-known/openid-federation"
138
+ entity_statement_fingerprint = "expected-fingerprint-hash"
139
+
140
+ Rails.application.config.middleware.use OmniAuth::Builder do
141
+ provider :openid_federation,
142
+ discovery: true,
143
+ entity_statement_path: "config/provider-entity-statement.jwt", # Cached copy from URL
144
+ entity_statement_url: entity_statement_url, # Always provide URL as source of truth
145
+ entity_statement_fingerprint: entity_statement_fingerprint, # Fingerprint for verification
146
+ client_options: {
147
+ identifier: ENV["OPENID_CLIENT_ID"],
148
+ redirect_uri: "https://your-app.com/auth/openid_federation/callback",
149
+ private_key: OpenSSL::PKey::RSA.new(File.read("config/client-private-key.pem"))
150
+ }
151
+ end
152
+ ```
153
+
154
+ ### Step 5: Configure Federation Endpoint (For Automatic Registration)
155
+
156
+ If using automatic registration, publish your client entity statement:
157
+
158
+ ```ruby
159
+ # config/initializers/omniauth_openid_federation.rb
160
+ OmniauthOpenidFederation::FederationEndpoint.auto_configure(
161
+ issuer: ENV["APP_URL"],
162
+ private_key: private_key,
163
+ entity_statement_path: "config/client-entity-statement.jwt", # Optional: cached copy for offline dev
164
+ metadata: {
165
+ openid_provider: {
166
+ issuer: ENV["APP_URL"],
167
+ authorization_endpoint: "#{ENV["APP_URL"]}/users/auth/openid_federation",
168
+ token_endpoint: "#{ENV["APP_URL"]}/users/auth/openid_federation",
169
+ userinfo_endpoint: "#{ENV["APP_URL"]}/users/auth/openid_federation",
170
+ jwks_uri: "#{ENV["APP_URL"]}/.well-known/jwks.json",
171
+ signed_jwks_uri: "#{ENV["APP_URL"]}/.well-known/signed-jwks.json"
172
+ }
173
+ }
174
+ )
175
+ ```
176
+
177
+ ```ruby
178
+ # config/routes.rb
179
+ OmniauthOpenidFederation::FederationEndpoint.mount_routes(self)
180
+ ```
181
+
182
+ **Key Points**:
183
+ - `auto_configure` automatically extracts/generates JWKS from keys
184
+ - Only application-specific endpoints need to be provided in metadata
185
+ - Well-known endpoints are auto-generated
186
+
187
+ ### Step 6: Add Routes
188
+
189
+ #### For Devise
190
+
191
+ ```ruby
192
+ # config/routes.rb
193
+ Rails.application.routes.draw do
194
+ devise_for :users, controllers: {
195
+ omniauth_callbacks: "users/omniauth_callbacks"
196
+ }
197
+ end
198
+ ```
199
+
200
+ #### For OmniAuth
201
+
202
+ ```ruby
203
+ # config/routes.rb
204
+ Rails.application.routes.draw do
205
+ get "/auth/:provider/callback", to: "sessions#create"
206
+ get "/auth/failure", to: "sessions#failure"
207
+ end
208
+ ```
209
+
210
+ ### Step 7: Create Callback Controller
211
+
212
+ #### For Devise
213
+
214
+ ```ruby
215
+ # app/controllers/users/omniauth_callbacks_controller.rb
216
+ class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
217
+ skip_before_action :authenticate_user!, only: [:openid_federation, :failure]
218
+
219
+ def openid_federation
220
+ auth = request.env["omniauth.auth"]
221
+ user = User.find_or_create_from_omniauth(auth)
222
+
223
+ if user&.persisted?
224
+ sign_in_and_redirect user, event: :authentication
225
+ else
226
+ redirect_to root_path, alert: "Authentication failed"
227
+ end
228
+ end
229
+
230
+ def failure
231
+ redirect_to root_path, alert: "Authentication failed"
232
+ end
233
+ end
234
+ ```
235
+
236
+ ### Step 8: Create User Model Method
237
+
238
+ ```ruby
239
+ # app/models/user.rb
240
+ class User < ApplicationRecord
241
+ def self.find_or_create_from_omniauth(auth)
242
+ user = find_by(provider: auth.provider, uid: auth.uid)
243
+
244
+ if user
245
+ user.update(
246
+ email: auth.info.email,
247
+ name: auth.info.name,
248
+ first_name: auth.info.first_name,
249
+ last_name: auth.info.last_name
250
+ )
251
+ else
252
+ user = create(
253
+ provider: auth.provider,
254
+ uid: auth.uid,
255
+ email: auth.info.email,
256
+ name: auth.info.name,
257
+ first_name: auth.info.first_name,
258
+ last_name: auth.info.last_name
259
+ )
260
+ end
261
+
262
+ user
263
+ end
264
+ end
265
+ ```
266
+
267
+ ## Rake Tasks
268
+
269
+ ### Prepare Client Keys
270
+
271
+ ```bash
272
+ rake openid_federation:prepare_client_keys
273
+ rake openid_federation:prepare_client_keys[separate,config] # Separate signing/encryption keys
274
+ ```
275
+
276
+ ### Fetch Entity Statement
277
+
278
+ Fetches entity statement from provider URL, verifies fingerprint, and caches locally:
279
+
280
+ ```bash
281
+ rake openid_federation:fetch_entity_statement[
282
+ "https://provider.example.com/.well-known/openid-federation",
283
+ "expected-fingerprint-hash",
284
+ "config/provider-entity-statement.jwt"
285
+ ]
286
+ ```
287
+
288
+ **Note**: Always use the URL as the source of truth - the local file is just a cached copy.
289
+
290
+ ### Parse Entity Statement
291
+
292
+ ```bash
293
+ rake openid_federation:parse_entity_statement["config/provider-entity-statement.jwt"]
294
+ ```
295
+
296
+ ### Test Local Entity Statement Endpoint
297
+
298
+ Validates your local entity statement endpoint and tests all linked endpoints. Useful for verifying your federation endpoint implementation:
299
+
300
+ ```bash
301
+ # Default (localhost:3000)
302
+ rake openid_federation:test_local_endpoint
303
+
304
+ # Custom base URL
305
+ rake openid_federation:test_local_endpoint[http://localhost:3000]
306
+
307
+ # Via environment variable
308
+ BASE_URL=http://localhost:3000 rake openid_federation:test_local_endpoint
309
+ ```
310
+
311
+ This task:
312
+ - Fetches and validates the entity statement from `/.well-known/openid-federation`
313
+ - Shows key configuration status (single vs separate keys) with recommendations
314
+ - Tests all endpoints mentioned in the entity statement
315
+ - Displays validation warnings without blocking execution
316
+
317
+ See all tasks: `rake -T openid_federation`
318
+
319
+ ### Cache Configuration and Key Rotation
320
+
321
+ Configure automatic key rotation:
322
+
323
+ ```ruby
324
+ OmniauthOpenidFederation.configure do |config|
325
+ config.cache_ttl = 3600 # Refresh provider keys every hour
326
+ config.rotate_on_errors = true # Auto-handle provider key rotation
327
+ end
328
+ ```
329
+
330
+ ### Security Instrumentation
331
+
332
+ Configure custom instrumentation for security events, MITM attack detection, and authentication mismatches:
333
+
334
+ ```ruby
335
+ OmniauthOpenidFederation.configure do |config|
336
+ # Configure with Sentry
337
+ config.instrumentation = ->(event, data) do
338
+ Sentry.capture_message(
339
+ "OpenID Federation: #{event}",
340
+ level: data[:severity] == :error ? :error : :warning,
341
+ extra: data
342
+ )
343
+ end
344
+ end
345
+ ```
346
+
347
+ **With Honeybadger**:
348
+ ```ruby
349
+ OmniauthOpenidFederation.configure do |config|
350
+ config.instrumentation = ->(event, data) do
351
+ Honeybadger.notify("OpenID Federation: #{event}", context: data)
352
+ end
353
+ end
354
+ ```
355
+
356
+ **With custom logger**:
357
+ ```ruby
358
+ OmniauthOpenidFederation.configure do |config|
359
+ config.instrumentation = ->(event, data) do
360
+ Rails.logger.warn("[Security] #{event}: #{data.inspect}")
361
+ end
362
+ end
363
+ ```
364
+
365
+ **Instrumented Events**:
366
+ - `csrf_detected` - CSRF attack detected (state mismatch)
367
+ - `signature_verification_failed` - JWT signature verification failed (possible MITM)
368
+ - `decryption_failed` - Token decryption failed (possible MITM or key mismatch)
369
+ - `token_validation_failed` - Token validation failed (possible tampering)
370
+ - `key_rotation_detected` - Key rotation detected (normal operation)
371
+ - `kid_not_found` - Key ID not found in JWKS (possible key rotation or MITM)
372
+ - `entity_statement_validation_failed` - Entity statement validation failed (possible MITM)
373
+ - `fingerprint_mismatch` - Entity statement fingerprint mismatch (possible MITM)
374
+ - `trust_chain_validation_failed` - Trust chain validation failed
375
+ - `unexpected_authentication_break` - Unexpected authentication failure
376
+ - `missing_required_claims` - Token missing required claims
377
+
378
+ **Security Note**: All sensitive data (tokens, keys, fingerprints) is automatically sanitized before being sent to your instrumentation callback.
379
+
380
+ **Key Rotation Types**:
381
+ - **Provider Keys** (from external providers): ✅ Automatic via Signed JWKS - library automatically detects and uses new provider keys
382
+ - **Client Keys** (your own keys): ⚠️ **Manual rotation required** - you must generate new RSA keys and update entity statement
383
+
384
+ **Client Key Rotation Process** (Manual Steps Required):
385
+ 1. **Generate new RSA keys** (manual):
386
+ ```bash
387
+ bundle exec rake omniauth_openid_federation:prepare_client_keys[key_type=separate]
388
+ ```
389
+ 2. **Update entity statement file** (manual): Update `entity_statement_path` with new keys, or let the library regenerate it
390
+ 3. **Library automatically uses new keys** (automatic): Library extracts JWKS from updated entity statement file on next cache refresh
391
+
392
+ **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.
393
+
394
+ ### Publishing Federation Endpoint
395
+
396
+ Publish your entity statement at `/.well-known/openid-federation` using `auto_configure`.
397
+
398
+ The library supports two entity types:
399
+ - **openid_relying_party (RP)**: For clients/relying parties (PRIMARY USE CASE)
400
+ - **openid_provider (OP)**: For providers/servers (secondary use case)
401
+
402
+ #### Relying Party (RP) Configuration (Primary Use Case)
403
+
404
+ **First, generate your RSA keys** (if not already generated):
405
+
406
+ ```bash
407
+ # Generate separate signing and encryption keys (RECOMMENDED for production)
408
+ bundle exec rake omniauth_openid_federation:prepare_client_keys[key_type=separate]
409
+
410
+ # Or generate single key for dev/testing (NOT RECOMMENDED for production)
411
+ bundle exec rake omniauth_openid_federation:prepare_client_keys[key_type=single]
412
+ ```
413
+
414
+ This creates:
415
+ - `config/client-signing-private-key.pem` and `config/client-encryption-private-key.pem` (separate keys)
416
+ - OR `config/client-private-key.pem` (single key for dev/testing)
417
+
418
+ **Then configure the federation endpoint** - the library automatically generates JWKS from your keys:
419
+
420
+ ```ruby
421
+ # config/initializers/omniauth_openid_federation.rb
422
+ # Production Setup (RECOMMENDED): Separate signing and encryption keys
423
+ # The library automatically generates JWKS from these keys
424
+ OmniauthOpenidFederation::FederationEndpoint.auto_configure(
425
+ issuer: "https://your-app.com",
426
+ signing_key: OpenSSL::PKey::RSA.new(File.read("config/client-signing-private-key.pem")),
427
+ encryption_key: OpenSSL::PKey::RSA.new(File.read("config/client-encryption-private-key.pem")),
428
+ entity_statement_path: "config/client-entity-statement.jwt", # Cache for automatic key rotation
429
+ metadata: {
430
+ openid_relying_party: {
431
+ redirect_uris: ["https://your-app.com/users/auth/openid_federation/callback"],
432
+ client_registration_types: ["automatic"],
433
+ application_type: "web",
434
+ grant_types: ["authorization_code"],
435
+ response_types: ["code"],
436
+ token_endpoint_auth_method: "private_key_jwt",
437
+ token_endpoint_auth_signing_alg: "RS256",
438
+ request_object_signing_alg: "RS256",
439
+ id_token_encrypted_response_alg: "RSA-OAEP",
440
+ id_token_encrypted_response_enc: "A128CBC-HS256"
441
+ }
442
+ },
443
+ auto_provision_keys: true # Library automatically generates JWKS from provided keys
444
+ )
445
+ ```
446
+
447
+ **Development/Testing** (NOT RECOMMENDED FOR PRODUCTION):
448
+ ```ruby
449
+ OmniauthOpenidFederation::FederationEndpoint.auto_configure(
450
+ issuer: "https://your-app.com",
451
+ private_key: private_key, # DEV/TESTING ONLY - single key for both signing and encryption
452
+ entity_statement_path: "config/client-entity-statement.jwt",
453
+ metadata: {
454
+ openid_relying_party: { ... }
455
+ },
456
+ auto_provision_keys: true
457
+ )
458
+ ```
459
+
460
+ #### OpenID Provider (OP) Configuration (Secondary Use Case)
461
+
462
+ **First, generate your RSA keys** (if not already generated):
463
+
464
+ ```bash
465
+ # Generate separate signing and encryption keys (RECOMMENDED for production)
466
+ bundle exec rake omniauth_openid_federation:prepare_client_keys[key_type=separate,output_dir=config]
467
+
468
+ # Or generate single key for dev/testing (NOT RECOMMENDED for production)
469
+ bundle exec rake omniauth_openid_federation:prepare_client_keys[key_type=single,output_dir=config]
470
+ ```
471
+
472
+ **Then configure the federation endpoint** - the library automatically generates JWKS from your keys:
473
+
474
+ ```ruby
475
+ # For provider/server applications
476
+ # Production Setup (RECOMMENDED): Separate signing and encryption keys
477
+ # The library automatically generates JWKS from these keys
478
+ signing_key = OpenSSL::PKey::RSA.new(File.read("config/client-signing-private-key.pem"))
479
+ encryption_key = OpenSSL::PKey::RSA.new(File.read("config/client-encryption-private-key.pem"))
480
+
481
+ OmniauthOpenidFederation::FederationEndpoint.auto_configure(
482
+ issuer: "https://provider.example.com",
483
+ signing_key: signing_key,
484
+ encryption_key: encryption_key,
485
+ entity_statement_path: "config/provider-entity-statement.jwt",
486
+ metadata: {
487
+ openid_provider: {
488
+ issuer: "https://provider.example.com",
489
+ authorization_endpoint: "https://provider.example.com/oauth2/authorize",
490
+ token_endpoint: "https://provider.example.com/oauth2/token",
491
+ userinfo_endpoint: "https://provider.example.com/oauth2/userinfo",
492
+ jwks_uri: "https://provider.example.com/.well-known/jwks.json",
493
+ signed_jwks_uri: "https://provider.example.com/.well-known/signed-jwks.json"
494
+ # federation_fetch_endpoint is automatically added for OPs
495
+ }
496
+ },
497
+ auto_provision_keys: true # Library automatically generates JWKS from provided keys
498
+ )
499
+ ```
500
+
501
+ **Development/Testing** (NOT RECOMMENDED FOR PRODUCTION):
502
+ ```ruby
503
+ # Single private key for both signing and encryption (DEV/TESTING ONLY)
504
+ OmniauthOpenidFederation::FederationEndpoint.auto_configure(
505
+ issuer: "https://provider.example.com",
506
+ private_key: private_key, # DEV/TESTING ONLY - not recommended for production
507
+ entity_statement_path: "config/provider-entity-statement.jwt",
508
+ metadata: {
509
+ openid_provider: {
510
+ issuer: "https://provider.example.com",
511
+ authorization_endpoint: "https://provider.example.com/oauth2/authorize",
512
+ token_endpoint: "https://provider.example.com/oauth2/token",
513
+ userinfo_endpoint: "https://provider.example.com/oauth2/userinfo",
514
+ jwks_uri: "https://provider.example.com/.well-known/jwks.json",
515
+ signed_jwks_uri: "https://provider.example.com/.well-known/signed-jwks.json"
516
+ }
517
+ },
518
+ auto_provision_keys: true
519
+ )
520
+ ```
521
+
522
+ ```ruby
523
+ # config/routes.rb
524
+ OmniauthOpenidFederation::FederationEndpoint.mount_routes(self)
525
+ ```
526
+
527
+ **What `auto_configure` does automatically**:
528
+ - Extracts JWKS from entity statement file or generates from provided keys
529
+ - Supports separate signing/encryption keys (RECOMMENDED) or single key (dev/testing)
530
+ - Auto-detects entity type and generates well-known endpoints
531
+ - Uses `entity_statement_path` as cache for key rotation
532
+
533
+ **Manual Configuration** (advanced, not recommended):
534
+
535
+ If you need manual control, use `configure` instead of `auto_configure`:
536
+
537
+ ```ruby
538
+ OmniauthOpenidFederation::FederationEndpoint.configure do |config|
539
+ config.issuer = "https://your-app.com"
540
+ config.subject = "https://your-app.com"
541
+ config.signing_key = signing_key # RECOMMENDED: Separate signing key
542
+ config.encryption_key = encryption_key # RECOMMENDED: Separate encryption key
543
+ config.jwks = jwks # Must provide manually
544
+ config.metadata = { ... }
545
+ end
546
+ ```
547
+
548
+ ### Automatic Key Provisioning
549
+
550
+ The `auto_configure` method automatically generates JWKS from your RSA keys (generate keys first using the rake task).
551
+
552
+ **Priority Order**:
553
+ 1. Extracts JWKS from `entity_statement_path` if file exists (supports key rotation)
554
+ 2. Generates JWKS from separate `signing_key` and `encryption_key` (RECOMMENDED)
555
+ 3. Generates JWKS from single `private_key` (dev/testing only)
556
+
557
+ **Key Rotation** (Semi-Automatic):
558
+ 1. **Manual**: Generate new RSA keys using `rake omniauth_openid_federation:prepare_client_keys`
559
+ 2. **Manual**: Update entity statement file at `entity_statement_path` with new keys
560
+ 3. **Automatic**: Library extracts and uses new keys from updated file on next cache refresh
561
+
562
+ ## Configuration Options
563
+
564
+ ### Required
565
+
566
+ - `client_options[:identifier]` - Client ID from provider
567
+ - `client_options[:redirect_uri]` - Callback URL
568
+ - `client_options[:private_key]` - RSA private key for signing
569
+ - **One of the following** (for provider entity statement):
570
+ - `entity_statement_url` - Provider entity statement URL (recommended - library fetches and caches automatically)
571
+ - `issuer` - Provider issuer URI (library builds entity statement URL from issuer + `/.well-known/openid-federation`)
572
+ - `entity_statement_path` - Provider entity statement path (optional - for offline development)
573
+
574
+ ### Optional
575
+
576
+ - `discovery` - Enable automatic endpoint discovery (default: `true`)
577
+ - `entity_statement_fingerprint` - Expected SHA-256 fingerprint for verification (recommended when using `entity_statement_url` or `issuer`)
578
+ - `entity_statement_path` - Path to provider entity statement (optional - for offline development, cached copy)
579
+ - `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)
580
+ - `client_entity_statement_url` - URL to client entity statement (for automatic registration)
581
+ - `client_entity_statement_path` - Path to client entity statement (fallback if URL not available)
582
+ - `client_registration_type` - `:explicit` (default) or `:automatic` (auto-detected if client_entity_statement_url/path provided)
583
+ - `client_entity_identifier` - Entity identifier for automatic registration
584
+ - `scope` - OAuth scopes (default: `[:openid]`)
585
+ - `response_type` - Response type (default: `"code"`)
586
+ - `client_auth_method` - Client authentication (default: `:jwt_bearer`)
587
+ - `client_signing_alg` - Signing algorithm (default: `:RS256`)
588
+ - `fetch_userinfo` - Whether to fetch userinfo endpoint (default: `true`)
589
+ - `acr_values` - Authentication Context Class Reference values (provider-specific)
590
+ - `key_source` - `:local` (default) or `:federation` (advanced)
591
+
592
+ ### Global Configuration
593
+
594
+ Configure global settings via `OmniauthOpenidFederation.configure`:
595
+
596
+ ```ruby
597
+ OmniauthOpenidFederation.configure do |config|
598
+ # Cache configuration
599
+ config.cache_ttl = 3600 # JWKS cache TTL in seconds
600
+ config.rotate_on_errors = true # Auto-rotate on key-related errors
601
+
602
+ # Security instrumentation (Sentry, Honeybadger, etc.)
603
+ config.instrumentation = ->(event, data) do
604
+ Sentry.capture_message("OpenID Federation: #{event}", level: :warning, extra: data)
605
+ end
606
+
607
+ # HTTP configuration
608
+ config.http_timeout = 10
609
+ config.max_retries = 3
610
+ config.verify_ssl = true
611
+ end
612
+ ```
613
+
614
+ ### Request Object Security (Signing vs Encryption)
615
+
616
+ **Per OpenID Federation 1.0 and RFC 9101:**
617
+ - **Signing (MANDATORY)**: Request objects **MUST be signed** using RS256 (always enforced, cannot be disabled)
618
+ - **Encryption (OPTIONAL)**: Request objects **MAY be encrypted** when provider requires it or when `always_encrypt_request_object: true`
619
+
620
+ **Encryption Behavior:**
621
+ - **Default** (`always_encrypt_request_object: false`): Only encrypts if provider metadata specifies `request_object_encryption_alg`
622
+ - **When `true`**: Encrypts even if provider doesn't require it (if encryption keys available)
623
+ - **Use case**: High-security deployments requiring defense-in-depth beyond minimum spec
624
+
625
+ **Note**: Signing provides authentication and integrity. Encryption adds confidentiality but is optional and adds overhead.
626
+
627
+ ### Detailed Configuration Examples
628
+
629
+ #### Devise with Environment Variables (Recommended)
630
+
631
+ ```ruby
632
+ # config/initializers/devise.rb
633
+ require "omniauth_openid_federation"
634
+
635
+ private_key = if ENV["OPENID_CLIENT_PRIVATE_KEY"]
636
+ OpenSSL::PKey::RSA.new(Base64.decode64(ENV["OPENID_CLIENT_PRIVATE_KEY"]))
637
+ else
638
+ OpenSSL::PKey::RSA.new(File.read("config/client-private-key.pem"))
639
+ end
640
+
641
+ config.omniauth :openid_federation,
642
+ discovery: true, # Auto-discovers endpoints from entity statement
643
+ entity_statement_url: ENV["OPENID_ENTITY_STATEMENT_URL"], # Always provide URL
644
+ entity_statement_fingerprint: ENV["OPENID_ENTITY_STATEMENT_FINGERPRINT"], # Fingerprint for verification
645
+ entity_statement_path: "config/provider-entity-statement.jwt", # Cached copy from URL (fetch via rake task)
646
+ client_entity_statement_url: "#{ENV["APP_URL"]}/.well-known/openid-federation", # For automatic registration
647
+ client_options: {
648
+ identifier: ENV["OPENID_CLIENT_ID"],
649
+ redirect_uri: "#{ENV["APP_URL"]}/users/auth/openid_federation/callback",
650
+ private_key: private_key
651
+ }
652
+ # All endpoints are auto-discovered - no manual configuration needed
653
+ ```
654
+
655
+ #### OmniAuth with URL-based Entity Statement (Production)
656
+
657
+ ```ruby
658
+ # config/initializers/omniauth.rb
659
+ require "omniauth_openid_federation"
660
+
661
+ entity_statement_url = "https://provider.example.com/.well-known/openid-federation"
662
+ entity_statement_fingerprint = "expected-fingerprint-hash"
663
+
664
+ Rails.application.config.middleware.use OmniAuth::Builder do
665
+ provider :openid_federation,
666
+ discovery: true,
667
+ entity_statement_url: entity_statement_url, # Always provide URL
668
+ entity_statement_fingerprint: entity_statement_fingerprint, # Fingerprint for verification
669
+ entity_statement_path: "config/provider-entity-statement.jwt", # Cached copy from URL
670
+ client_options: {
671
+ identifier: ENV["OPENID_CLIENT_ID"],
672
+ redirect_uri: "https://your-app.com/auth/openid_federation/callback",
673
+ private_key: OpenSSL::PKey::RSA.new(File.read("config/client-private-key.pem"))
674
+ }
675
+ end
676
+ ```
677
+
678
+ **Key Points**:
679
+ - **Always provide `entity_statement_url`** - this is the source of truth
680
+ - `entity_statement_fingerprint` is used for verification when fetching
681
+ - `entity_statement_path` points to the cached copy fetched from the URL
682
+ - All endpoints are automatically discovered - no manual endpoint configuration
683
+
684
+ ## API Reference
685
+
686
+ ### `OmniauthOpenidFederation::Jws`
687
+
688
+ Builds and signs JWT request objects:
689
+
690
+ ```ruby
691
+ jws = OmniauthOpenidFederation::Jws.new(
692
+ client_id: "client-id",
693
+ redirect_uri: "https://example.com/callback",
694
+ scope: "openid",
695
+ issuer: "https://provider.example.com",
696
+ audience: "https://provider.example.com",
697
+ private_key: private_key
698
+ )
699
+ signed_jwt = jws.sign
700
+ ```
701
+
702
+ ### `OmniauthOpenidFederation::Federation::EntityStatement`
703
+
704
+ Fetches and validates entity statements:
705
+
706
+ ```ruby
707
+ statement = OmniauthOpenidFederation::Federation::EntityStatement.fetch!(
708
+ "https://provider.example.com/.well-known/openid-federation",
709
+ fingerprint: "expected-fingerprint"
710
+ )
711
+ metadata = statement.parse
712
+ ```
713
+
714
+ ### `OmniauthOpenidFederation::Federation::SignedJWKS`
715
+
716
+ Fetches and validates signed JWKS:
717
+
718
+ ```ruby
719
+ signed_jwks = OmniauthOpenidFederation::Federation::SignedJWKS.fetch!(
720
+ signed_jwks_uri,
721
+ entity_jwks
722
+ )
723
+ ```
724
+
725
+ See inline code documentation for complete API reference.
726
+
727
+ ## Troubleshooting
728
+
729
+ **"Private key is required"**
730
+ - Generate keys: `rake openid_federation:prepare_client_keys`
731
+ - Verify key path and format (PEM)
732
+
733
+ **"Audience is required"**
734
+ - Provide `entity_statement_url` and `entity_statement_path` (auto-resolves audience from entity statement)
735
+
736
+ **"Entity statement fingerprint mismatch"**
737
+ - Verify `entity_statement_fingerprint` with provider
738
+ - Fetch fresh entity statement from URL: `rake openid_federation:fetch_entity_statement[entity_statement_url, entity_statement_fingerprint, entity_statement_path]`
739
+ - Always use the provider URL as the source of truth
740
+
741
+ **"JWT signature verification failed"**
742
+ - Provider may have rotated keys (auto-handled with `rotate_on_errors: true`)
743
+ - Clear cache: `Rails.cache.delete_matched("openid_federation_jwks_*")`
744
+
745
+ ## Security
746
+
747
+ See [SECURITY.md](SECURITY.md) for detailed security features, protections, and vulnerability reporting.
748
+
749
+ ## Requirements
750
+
751
+ - Ruby >= 3.0
752
+ - Rails >= 6.0 (optional)
753
+ - `omniauth-oauth2` ~> 1.8
754
+ - `openid_connect` ~> 2.3
755
+ - `jwe` ~> 1.1
756
+ - `jwt` ~> 3.1
757
+ - `http` ~> 5.3
758
+
759
+ ## Example Files
760
+
761
+ See `examples/` directory for complete configuration examples:
762
+ - `examples/config/initializers/devise.rb.example`
763
+ - `examples/app/controllers/users/omniauth_callbacks_controller.rb.example`
764
+ - `examples/app/models/user.rb.example`
765
+
766
+ ## Development
767
+
768
+ Run release.rb script to prepare code for publishing, it has all the required checks and tests.
769
+
770
+ ```bash
771
+ usr/bin/release.rb
772
+ ```
773
+
774
+ ### Development: Using from Local Repository
775
+
776
+ When developing the gem or testing changes in your application, you can point your Gemfile to a local path:
777
+
778
+ ```ruby
779
+ # In your application's Gemfile
780
+ gem "omniauth_openid_federation", path: "../omniauth_openid_federation.rb"
781
+ ```
782
+
783
+ Then run:
784
+
785
+ ```bash
786
+ bundle install
787
+ ```
788
+
789
+ **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.
790
+
791
+ ## Contributing
792
+
793
+ Bug reports and pull requests are welcome on GitHub at https://github.com/amkisko/omniauth_openid_federation.rb
794
+
795
+ Contribution policy:
796
+ - New features are not necessarily added to the gem
797
+ - Pull request should have test coverage for affected parts
798
+ - Pull request should have changelog entry
799
+
800
+ Review policy:
801
+ - It might take up to 2 calendar weeks to review and merge critical fixes
802
+ - It might take up to 6 calendar months to review and merge pull request
803
+ - It might take up to 1 calendar year to review an issue
804
+
805
+
806
+ ## Publishing
807
+
808
+ ```sh
809
+ rm omniauth_openid_federation-*.gem
810
+ gem build omniauth_openid_federation.gemspec
811
+ gem push omniauth_openid_federation-*.gem
812
+ ```
813
+
814
+ ## References
815
+
816
+ - [OpenID Federation 1.0 Specification](https://openid.net/specs/openid-federation-1_0.html)
817
+ - [RFC 9101 - OAuth 2.0 Authorization Request](https://datatracker.ietf.org/doc/html/rfc9101)
818
+ - [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
819
+
820
+ ## License
821
+
822
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).