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
@@ -0,0 +1,416 @@
1
+ require "jwt"
2
+ require "jwe"
3
+ require "securerandom"
4
+ require "base64"
5
+ require_relative "string_helpers"
6
+ require_relative "logger"
7
+ require_relative "errors"
8
+ require_relative "validators"
9
+ require_relative "key_extractor"
10
+
11
+ # JWT Request Object builder for signed authorization requests
12
+ # @see https://datatracker.ietf.org/doc/html/rfc9101 RFC 9101 - OAuth 2.0 Authorization Request
13
+ # @see https://openid.net/specs/openid-federation-1_0.html#section-12.1.1.1.1 Section 12.1.1.1.1: Authorization Request with a Trust Chain
14
+ #
15
+ # Implements signed request objects as required by RFC 9101 for secure authorization requests.
16
+ # All authorization parameters are included in a JWT signed with RS256 using the client's signing key.
17
+ #
18
+ # Required claims per RFC 9101:
19
+ # - iss: Client identifier
20
+ # - aud: Provider issuer or configured audience (for OpenID Federation, typically provider issuer)
21
+ # - client_id: Client identifier
22
+ # - redirect_uri: Callback URI
23
+ # - response_type: Authorization response type (typically "code")
24
+ # - scope: Requested scopes (typically "openid")
25
+ # - state: CSRF protection token
26
+ # - nonce: Replay protection token
27
+ # - exp: Expiration time (10 minutes)
28
+ # - jti: JWT ID for replay prevention
29
+ module OmniauthOpenidFederation
30
+ # JWT Request Object builder for signed authorization requests
31
+ #
32
+ # @example Create and sign a request object with local private key
33
+ # jws = Jws.new(
34
+ # client_id: "client-id",
35
+ # redirect_uri: "https://example.com/callback",
36
+ # scope: "openid",
37
+ # issuer: "https://provider.example.com",
38
+ # audience: "https://provider.example.com",
39
+ # private_key: private_key,
40
+ # key_source: :local
41
+ # )
42
+ # signed_jwt = jws.sign
43
+ #
44
+ # @example Create and sign a request object with federation/JWKS
45
+ # jws = Jws.new(
46
+ # client_id: "client-id",
47
+ # redirect_uri: "https://example.com/callback",
48
+ # scope: "openid",
49
+ # issuer: "https://provider.example.com",
50
+ # audience: "https://provider.example.com",
51
+ # private_key: private_key, # Fallback if JWKS not available
52
+ # jwks: jwks_hash,
53
+ # entity_statement_path: "config/provider-entity-statement.jwt",
54
+ # key_source: :federation
55
+ # )
56
+ # signed_jwt = jws.sign
57
+ class Jws
58
+ # Request object expiration constants
59
+ REQUEST_OBJECT_EXPIRATION_SECONDS = 600 # 10 minutes in seconds
60
+ REQUEST_OBJECT_EXPIRATION_MINUTES = 10
61
+
62
+ # State generation constants
63
+ STATE_BYTES = 16 # Number of hex bytes for state parameter
64
+
65
+ attr_accessor :private_key, :state, :nonce
66
+ # Provider-specific extension parameters (outside JWT)
67
+ # Some providers may require additional parameters that are not part of the JWT
68
+ # @deprecated Use provider_extension_params hash instead
69
+ attr_accessor :ftn_spname
70
+
71
+ # Initialize JWT request object builder
72
+ #
73
+ # @param client_id [String] OAuth client identifier
74
+ # @param redirect_uri [String] OAuth redirect URI
75
+ # @param scope [String] OAuth scopes (default: "openid")
76
+ # @param issuer [String, nil] Provider issuer URI
77
+ # @param audience [String, nil] JWT audience (typically provider issuer)
78
+ # @param state [String, nil] CSRF protection state (auto-generated if nil)
79
+ # @param nonce [String, nil] Replay protection nonce
80
+ # @param response_type [String] OAuth response type (default: "code")
81
+ # @param response_mode [String, nil] OAuth response mode
82
+ # @param login_hint [String, nil] Login hint for provider
83
+ # @param ui_locales [String, nil] UI locale preferences
84
+ # @param claims_locales [String, nil] Claims locale preferences
85
+ # @param prompt [String, nil] OAuth prompt parameter
86
+ # @param hd [String, nil] Hosted domain parameter
87
+ # @param acr_values [String, nil] Authentication context class reference values
88
+ # @param extra_params [Hash] Additional claims to include in JWT
89
+ # @param private_key [OpenSSL::PKey::RSA, String, nil] Private key for signing (fallback if JWKS not provided)
90
+ # @param jwks [Hash, Array, nil] JWKS hash or array for extracting signing key
91
+ # @param entity_statement_path [String, nil] Path to entity statement file for key extraction (replaces metadata_path)
92
+ # @param key_source [Symbol] Key source: :local (use local static private_key) or :federation (use federation/JWKS)
93
+ # @param client_entity_statement [String, nil] Client's entity statement JWT string (for automatic registration)
94
+ def initialize(
95
+ client_id:,
96
+ redirect_uri:,
97
+ scope: "openid",
98
+ issuer: nil,
99
+ audience: nil,
100
+ state: nil,
101
+ nonce: nil,
102
+ response_type: "code",
103
+ response_mode: nil,
104
+ login_hint: nil,
105
+ ui_locales: nil,
106
+ claims_locales: nil,
107
+ prompt: nil,
108
+ hd: nil,
109
+ acr_values: nil,
110
+ extra_params: {},
111
+ private_key: nil,
112
+ jwks: nil,
113
+ entity_statement_path: nil,
114
+ key_source: :local,
115
+ client_entity_statement: nil
116
+ )
117
+ @client_id = client_id
118
+ @redirect_uri = redirect_uri
119
+ @scope = scope
120
+ @issuer = issuer
121
+ @audience = audience
122
+ @state = state || SecureRandom.hex(STATE_BYTES)
123
+ @nonce = nonce
124
+ @response_type = response_type
125
+ @response_mode = response_mode
126
+ @login_hint = login_hint
127
+ @ui_locales = ui_locales
128
+ @claims_locales = claims_locales
129
+ @prompt = prompt
130
+ @hd = hd
131
+ @acr_values = acr_values
132
+ @extra_params = extra_params
133
+ @jwks = jwks
134
+ @entity_statement_path = entity_statement_path
135
+ @key_source = key_source
136
+ @client_entity_statement = client_entity_statement
137
+
138
+ # Extract signing key based on key_source configuration
139
+ # :local - Use local static private_key directly (for current setup)
140
+ # :federation - Use federation/JWKS from entity statement first, fallback to private_key
141
+ # According to OpenID Federation spec: supports separate signing/encryption keys
142
+ if @key_source == :federation
143
+ # Try federation/JWKS from entity statement first, then fallback to local private_key
144
+ metadata = load_metadata_from_entity_statement if @entity_statement_path
145
+ @private_key = KeyExtractor.extract_signing_key(
146
+ jwks: @jwks,
147
+ metadata: metadata,
148
+ private_key: private_key
149
+ ) || private_key
150
+ else
151
+ # :local - Use local private_key directly, ignore JWKS/metadata
152
+ @private_key = private_key
153
+ end
154
+ end
155
+
156
+ # Add a custom claim to the JWT
157
+ #
158
+ # @param key [Symbol, String] Claim key
159
+ # @param value [Object] Claim value
160
+ def add_claim(key, value)
161
+ @extra_params[key] = value
162
+ end
163
+
164
+ # Sign the request object JWT
165
+ #
166
+ # Required for secure authorization requests per RFC 9101.
167
+ # All authentication requests MUST use signed request objects.
168
+ # This method enforces this requirement - unsigned requests are NOT allowed.
169
+ #
170
+ # According to OpenID Connect Core and RFC 9101, request objects can be:
171
+ # - Signed only (default)
172
+ # - Signed and encrypted (if provider metadata specifies encryption)
173
+ #
174
+ # @param provider_metadata [Hash, nil] Provider metadata from entity statement (optional)
175
+ # @return [String] The signed (and optionally encrypted) JWT request object
176
+ # @raise [SecurityError] If private key is missing or signing fails
177
+ def sign(provider_metadata: nil, always_encrypt: false)
178
+ # ENFORCE: Private key is MANDATORY - no bypass possible
179
+ Validators.validate_private_key!(@private_key)
180
+
181
+ begin
182
+ signed_jwt = build_jwt
183
+ unless OmniauthOpenidFederation::StringHelpers.present?(signed_jwt)
184
+ error_msg = "Failed to sign JWT request object - signed request objects are MANDATORY"
185
+ OmniauthOpenidFederation::Logger.error("[Jws] #{error_msg}")
186
+ raise SecurityError, error_msg
187
+ end
188
+
189
+ # Extract kid from header for logging
190
+ header_part = signed_jwt.split(".").first
191
+ header = JSON.parse(Base64.urlsafe_decode64(header_part))
192
+ kid = header["kid"]
193
+ OmniauthOpenidFederation::Logger.debug("[Jws] Successfully signed request object with kid: #{kid}")
194
+
195
+ # Encrypt if required (provider metadata specifies encryption OR always_encrypt option is true)
196
+ # According to RFC 9101 and OpenID Connect Core, if provider specifies
197
+ # request_object_encryption_alg, the client SHOULD encrypt request objects
198
+ if should_encrypt_request_object?(provider_metadata, always_encrypt: always_encrypt)
199
+ encrypted_jwt = encrypt_request_object(signed_jwt, provider_metadata)
200
+ OmniauthOpenidFederation::Logger.debug("[Jws] Successfully encrypted request object")
201
+ encrypted_jwt
202
+ else
203
+ signed_jwt
204
+ end
205
+ rescue => e
206
+ error_msg = "Failed to sign JWT request object (required for secure authorization): #{e.class} - #{e.message}"
207
+ OmniauthOpenidFederation::Logger.error("[Jws] #{error_msg}")
208
+ raise SignatureError, error_msg, e.backtrace
209
+ end
210
+ end
211
+
212
+ private
213
+
214
+ def build_jwt
215
+ claim = {
216
+ iss: @client_id,
217
+ aud: client_audience || @issuer,
218
+ client_id: @client_id,
219
+ redirect_uri: @redirect_uri,
220
+ response_type: @response_type,
221
+ scope: @scope,
222
+ state: state,
223
+ exp: (Time.now + (defined?(ActiveSupport) ? REQUEST_OBJECT_EXPIRATION_MINUTES.minutes : REQUEST_OBJECT_EXPIRATION_SECONDS)).to_i,
224
+ jti: SecureRandom.uuid # JWT ID to prevent replay
225
+ }
226
+
227
+ # Add optional claims
228
+ claim[:nonce] = nonce if OmniauthOpenidFederation::StringHelpers.present?(nonce)
229
+ claim[:response_mode] = @response_mode if OmniauthOpenidFederation::StringHelpers.present?(@response_mode)
230
+ claim[:login_hint] = @login_hint if OmniauthOpenidFederation::StringHelpers.present?(@login_hint)
231
+ claim[:ui_locales] = @ui_locales if OmniauthOpenidFederation::StringHelpers.present?(@ui_locales)
232
+ claim[:claims_locales] = @claims_locales if OmniauthOpenidFederation::StringHelpers.present?(@claims_locales)
233
+ claim[:prompt] = @prompt if OmniauthOpenidFederation::StringHelpers.present?(@prompt)
234
+ claim[:hd] = @hd if OmniauthOpenidFederation::StringHelpers.present?(@hd)
235
+ claim[:acr_values] = @acr_values if OmniauthOpenidFederation::StringHelpers.present?(@acr_values)
236
+
237
+ # Add extra parameters
238
+ claim.merge!(@extra_params)
239
+
240
+ # Include client entity statement for automatic registration (OpenID Federation Section 12.1)
241
+ # When using automatic registration, the entity statement is included in the request object
242
+ if OmniauthOpenidFederation::StringHelpers.present?(@client_entity_statement)
243
+ claim[:trust_chain] = [@client_entity_statement]
244
+ OmniauthOpenidFederation::Logger.debug("[Jws] Including client entity statement in request object for automatic registration")
245
+ end
246
+
247
+ # Build JWT header
248
+ header = {
249
+ alg: "RS256",
250
+ typ: "JWT"
251
+ }
252
+ kid = signing_key_kid
253
+ header[:kid] = kid if OmniauthOpenidFederation::StringHelpers.present?(kid)
254
+
255
+ # Encode JWT using jwt gem
256
+ JWT.encode(claim, @private_key, "RS256", header)
257
+ end
258
+
259
+ def load_signing_key
260
+ # Deprecated: Use KeyExtractor.extract_signing_key instead
261
+ # This method is kept for backward compatibility but should not be used
262
+ nil
263
+ end
264
+
265
+ def signing_key_kid
266
+ metadata = load_metadata_from_entity_statement
267
+ return nil unless metadata
268
+
269
+ jwks = metadata[:jwks] || metadata["jwks"] || {}
270
+ keys = jwks[:keys] || jwks["keys"] || []
271
+ signing_key = keys.find { |key| (key[:use] || key["use"]) == "sig" }
272
+ return nil unless signing_key
273
+
274
+ # Try to get kid from signing key (handle both symbol and string keys)
275
+ signing_key[:kid] || signing_key["kid"]
276
+ end
277
+
278
+ def client_audience
279
+ # Use configured audience if provided
280
+ return @audience if OmniauthOpenidFederation::StringHelpers.present?(@audience)
281
+
282
+ # If no audience configured, return nil - it should be provided via options
283
+ # Audience is typically the token_endpoint URL
284
+ nil
285
+ end
286
+
287
+ # Load metadata from entity statement (replaces static metadata file)
288
+ # Extracts metadata and JWKS from entity statement for key extraction
289
+ #
290
+ # @return [Hash, nil] Metadata hash with JWKS or nil if not available
291
+ def load_metadata_from_entity_statement
292
+ return nil unless @entity_statement_path
293
+ return nil unless File.exist?(@entity_statement_path)
294
+
295
+ begin
296
+ # Parse entity statement to extract metadata and JWKS
297
+ parsed = OmniauthOpenidFederation::Federation::EntityStatementHelper.parse_for_signed_jwks(
298
+ @entity_statement_path
299
+ )
300
+ return nil unless parsed && parsed[:metadata]
301
+
302
+ # Return metadata in format expected by KeyExtractor
303
+ metadata = parsed[:metadata]
304
+ entity_jwks = parsed[:entity_jwks] || metadata[:jwks] || {}
305
+
306
+ # Return metadata with JWKS included
307
+ metadata.merge(jwks: entity_jwks)
308
+ rescue => e
309
+ OmniauthOpenidFederation::Logger.warn("[Jws] Failed to load metadata from entity statement: #{e.message}")
310
+ nil
311
+ end
312
+ end
313
+
314
+ # Check if request object encryption is required
315
+ # Priority:
316
+ # 1. always_encrypt_request_object option (if set to true, always encrypt if keys available)
317
+ # 2. Provider metadata request_object_encryption_alg (if provider requires encryption)
318
+ #
319
+ # According to OpenID Connect Core spec, if provider metadata specifies
320
+ # request_object_encryption_alg, the client SHOULD encrypt request objects
321
+ #
322
+ # @param provider_metadata [Hash, nil] Provider metadata from entity statement
323
+ # @param always_encrypt [Boolean, nil] Force encryption if encryption keys are available
324
+ # @return [Boolean] true if encryption is required, false otherwise
325
+ def should_encrypt_request_object?(provider_metadata, always_encrypt: false)
326
+ # If always_encrypt is true, check if encryption keys are available
327
+ if always_encrypt
328
+ return has_encryption_keys?(provider_metadata)
329
+ end
330
+
331
+ # Otherwise, check provider metadata for encryption requirements
332
+ return false unless provider_metadata
333
+
334
+ encryption_alg = provider_metadata["request_object_encryption_alg"] ||
335
+ provider_metadata[:request_object_encryption_alg]
336
+
337
+ OmniauthOpenidFederation::StringHelpers.present?(encryption_alg) && encryption_alg == "RSA-OAEP"
338
+ end
339
+
340
+ # Check if encryption keys are available in provider metadata
341
+ #
342
+ # @param provider_metadata [Hash, nil] Provider metadata from entity statement
343
+ # @return [Boolean] true if encryption keys are available, false otherwise
344
+ def has_encryption_keys?(provider_metadata)
345
+ return false unless provider_metadata
346
+
347
+ provider_jwks = provider_metadata["jwks"] || provider_metadata[:jwks]
348
+ return false unless provider_jwks
349
+
350
+ keys = provider_jwks["keys"] || provider_jwks[:keys] || []
351
+ keys.any? { |key| (key["use"] == "enc" || key[:use] == "enc") || (!key["use"] && !key[:use]) }
352
+ end
353
+
354
+ # Encrypt the signed request object using provider's public key
355
+ # According to RFC 9101 and OpenID Connect Core, encryption uses:
356
+ # - Key encryption: RSA-OAEP (from request_object_encryption_alg)
357
+ # - Content encryption: A128CBC-HS256 or A128GCM (from request_object_encryption_enc)
358
+ #
359
+ # @param signed_jwt [String] The signed JWT request object
360
+ # @param provider_metadata [Hash] Provider metadata containing encryption parameters
361
+ # @return [String] The encrypted JWT (JWE format)
362
+ # @raise [EncryptionError] If encryption fails
363
+ def encrypt_request_object(signed_jwt, provider_metadata)
364
+ encryption_alg = provider_metadata["request_object_encryption_alg"] ||
365
+ provider_metadata[:request_object_encryption_alg]
366
+ encryption_enc = provider_metadata["request_object_encryption_enc"] ||
367
+ provider_metadata[:request_object_encryption_enc]
368
+
369
+ unless encryption_alg == "RSA-OAEP"
370
+ error_msg = "Unsupported request object encryption algorithm: #{encryption_alg}"
371
+ OmniauthOpenidFederation::Logger.error("[Jws] #{error_msg}")
372
+ raise EncryptionError, error_msg
373
+ end
374
+
375
+ # Get provider's public key from JWKS
376
+ # Note: This requires provider JWKS to be available
377
+ # In practice, provider JWKS should be fetched from entity statement or jwks_uri
378
+ provider_jwks = provider_metadata["jwks"] || provider_metadata[:jwks]
379
+ unless provider_jwks
380
+ error_msg = "Provider JWKS not available for request object encryption"
381
+ OmniauthOpenidFederation::Logger.error("[Jws] #{error_msg}")
382
+ raise EncryptionError, error_msg
383
+ end
384
+
385
+ # Find encryption key (use: "enc" or first key if no use specified)
386
+ keys = provider_jwks["keys"] || provider_jwks[:keys] || []
387
+ encryption_key_data = keys.find { |key| key["use"] == "enc" || key[:use] == "enc" } || keys.first
388
+
389
+ unless encryption_key_data
390
+ error_msg = "No encryption key found in provider JWKS"
391
+ OmniauthOpenidFederation::Logger.error("[Jws] #{error_msg}")
392
+ raise EncryptionError, error_msg
393
+ end
394
+
395
+ begin
396
+ # Convert JWK to OpenSSL public key
397
+ public_key = KeyExtractor.jwk_to_openssl_key(encryption_key_data)
398
+
399
+ # Encrypt the signed JWT using JWE gem
400
+ # For JWE, we encrypt the signed JWT string as plaintext
401
+ # The pattern is: sign first, then encrypt (nested JWT)
402
+ # JWE.encrypt(plaintext, key, alg: "RSA-OAEP", enc: "A128CBC-HS256")
403
+ JWE.encrypt(
404
+ signed_jwt,
405
+ public_key,
406
+ alg: encryption_alg,
407
+ enc: encryption_enc
408
+ )
409
+ rescue => e
410
+ error_msg = "Failed to encrypt request object: #{e.class} - #{e.message}"
411
+ OmniauthOpenidFederation::Logger.error("[Jws] #{error_msg}")
412
+ raise EncryptionError, error_msg, e.backtrace
413
+ end
414
+ end
415
+ end
416
+ end
@@ -0,0 +1,173 @@
1
+ require "jwt"
2
+ require "openssl"
3
+ require "base64"
4
+ require_relative "validators"
5
+ require_relative "jwks/normalizer"
6
+
7
+ # Key extractor for OpenID Federation
8
+ # Extracts signing and encryption keys from JWKS according to OpenID Federation spec
9
+ # Supports both separate keys (use: "sig" and use: "enc") and single key (backward compatibility)
10
+ #
11
+ # According to OpenID Federation spec:
12
+ # - "When both signing and encryption keys are present" - separate keys are supported
13
+ # - Separate keys are not mandatory
14
+ # - Using the same key for both is allowed
15
+ module OmniauthOpenidFederation
16
+ class KeyExtractor
17
+ # Extract signing key from JWKS or metadata
18
+ #
19
+ # @param jwks [Hash, Array, nil] JWKS hash or array of keys
20
+ # @param metadata [Hash, nil] Metadata hash containing JWKS
21
+ # @param private_key [OpenSSL::PKey::RSA, String, nil] Fallback private key if JWKS not available
22
+ # @return [OpenSSL::PKey::RSA, nil] Signing key or nil if not found
23
+ def self.extract_signing_key(jwks: nil, metadata: nil, private_key: nil)
24
+ # Try to extract from JWKS first
25
+ if jwks || metadata
26
+ keys = extract_keys_from_jwks(jwks: jwks, metadata: metadata)
27
+ signing_key_data = find_key_by_use(keys, "sig")
28
+
29
+ if signing_key_data
30
+ return jwk_to_openssl_key(signing_key_data)
31
+ end
32
+
33
+ # If no signing key found but keys exist, try first key without use field (backward compatibility)
34
+ if keys.any?
35
+ first_key = keys.first
36
+ unless first_key["use"] # Only use if no use field specified
37
+ return jwk_to_openssl_key(first_key)
38
+ end
39
+ end
40
+ end
41
+
42
+ # Fallback to provided private_key (backward compatibility)
43
+ if private_key
44
+ return normalize_private_key(private_key)
45
+ end
46
+
47
+ nil
48
+ end
49
+
50
+ # Extract encryption key from JWKS or metadata
51
+ #
52
+ # @param jwks [Hash, Array, nil] JWKS hash or array of keys
53
+ # @param metadata [Hash, nil] Metadata hash containing JWKS
54
+ # @param private_key [OpenSSL::PKey::RSA, String, nil] Fallback private key if JWKS not available
55
+ # @return [OpenSSL::PKey::RSA, nil] Encryption key or nil if not found
56
+ def self.extract_encryption_key(jwks: nil, metadata: nil, private_key: nil)
57
+ # Try to extract from JWKS first
58
+ if jwks || metadata
59
+ keys = extract_keys_from_jwks(jwks: jwks, metadata: metadata)
60
+ encryption_key_data = find_key_by_use(keys, "enc")
61
+
62
+ if encryption_key_data
63
+ return jwk_to_openssl_key(encryption_key_data)
64
+ end
65
+
66
+ # If no encryption key found but keys exist, try first key without use field (backward compatibility)
67
+ if keys.any?
68
+ first_key = keys.first
69
+ unless first_key["use"] # Only use if no use field specified
70
+ return jwk_to_openssl_key(first_key)
71
+ end
72
+ end
73
+ end
74
+
75
+ # Fallback to provided private_key (backward compatibility)
76
+ if private_key
77
+ return normalize_private_key(private_key)
78
+ end
79
+
80
+ nil
81
+ end
82
+
83
+ # Extract key by use value or fallback to single key
84
+ #
85
+ # @param jwks [Hash, Array, nil] JWKS hash or array of keys
86
+ # @param metadata [Hash, nil] Metadata hash containing JWKS
87
+ # @param use [String, nil] Use value ("sig" or "enc")
88
+ # @param private_key [OpenSSL::PKey::RSA, String, nil] Fallback private key
89
+ # @return [OpenSSL::PKey::RSA, nil] Key or nil if not found
90
+ def self.extract_key(jwks: nil, metadata: nil, use: nil, private_key: nil)
91
+ if use == "sig"
92
+ extract_signing_key(jwks: jwks, metadata: metadata, private_key: private_key)
93
+ elsif use == "enc"
94
+ extract_encryption_key(jwks: jwks, metadata: metadata, private_key: private_key)
95
+ else
96
+ # No use specified, try signing first, then encryption, then fallback
97
+ extract_signing_key(jwks: jwks, metadata: metadata, private_key: private_key) ||
98
+ extract_encryption_key(jwks: jwks, metadata: metadata, private_key: private_key)
99
+ end
100
+ end
101
+
102
+ # Extract keys array from JWKS or metadata
103
+ #
104
+ # @param jwks [Hash, Array, nil] JWKS hash or array
105
+ # @param metadata [Hash, nil] Metadata hash
106
+ # @return [Array<Hash>] Array of key hashes
107
+ def self.extract_keys_from_jwks(jwks: nil, metadata: nil)
108
+ if jwks
109
+ normalized = Jwks::Normalizer.to_jwks_hash(jwks)
110
+ return normalized["keys"] || []
111
+ end
112
+
113
+ if metadata
114
+ jwks_data = metadata["jwks"] || metadata[:jwks]
115
+ if jwks_data
116
+ normalized = Jwks::Normalizer.to_jwks_hash(jwks_data)
117
+ return normalized["keys"] || []
118
+ end
119
+ end
120
+
121
+ []
122
+ end
123
+
124
+ # Find key by use value
125
+ #
126
+ # @param keys [Array<Hash>] Array of key hashes
127
+ # @param use [String] Use value ("sig" or "enc")
128
+ # @return [Hash, nil] Key hash or nil
129
+ def self.find_key_by_use(keys, use)
130
+ keys.find { |key| key["use"] == use || key[:use] == use }
131
+ end
132
+
133
+ # Normalize private key to OpenSSL::PKey::RSA
134
+ #
135
+ # @param private_key [OpenSSL::PKey::RSA, String] Private key
136
+ # @return [OpenSSL::PKey::RSA] Normalized private key
137
+ def self.normalize_private_key(private_key)
138
+ if private_key.is_a?(String)
139
+ OpenSSL::PKey::RSA.new(private_key)
140
+ elsif private_key.is_a?(OpenSSL::PKey::RSA)
141
+ private_key
142
+ else
143
+ raise ArgumentError, "Invalid private key type: #{private_key.class}"
144
+ end
145
+ end
146
+
147
+ # Convert JWK hash to OpenSSL key (private or public)
148
+ #
149
+ # @param jwk_data [Hash] JWK hash
150
+ # @return [OpenSSL::PKey::RSA] OpenSSL key
151
+ def self.jwk_to_openssl_key(jwk_data)
152
+ # Use JWT::JWK if available (jwt gem 2.7+)
153
+ # JWT::JWK.import handles both public and private keys and is OpenSSL 3.0 compatible
154
+ if defined?(JWT::JWK)
155
+ jwk = JWT::JWK.import(jwk_data)
156
+ # JWT::JWK::RSA has keypair method for private keys, public_key for public keys
157
+ if jwk_data[:d] || jwk_data["d"]
158
+ # Private key - use keypair method
159
+ jwk.keypair
160
+ else
161
+ # Public key
162
+ jwk.public_key
163
+ end
164
+ else
165
+ # Fallback: Manual conversion (OpenSSL 2.x compatible only)
166
+ # For OpenSSL 3.0, JWT::JWK is required
167
+ raise ArgumentError, "JWT::JWK is required for OpenSSL 3.0 compatibility. Please ensure jwt gem >= 2.7 is installed."
168
+ end
169
+ end
170
+
171
+ private_class_method :extract_keys_from_jwks, :find_key_by_use, :normalize_private_key
172
+ end
173
+ end
@@ -0,0 +1,99 @@
1
+ # Logger abstraction for omniauth_openid_federation
2
+ # Provides a configurable logging interface that works with or without Rails
3
+ #
4
+ # Logger Priority (automatic detection):
5
+ # 1. OmniAuth.config.logger (if configured)
6
+ # 2. Rails.logger (if Rails is available)
7
+ # 3. Standard Logger (if available)
8
+ # 4. NullLogger (silent fallback)
9
+ #
10
+ # Developers can configure logging once via OmniAuth.config.logger and this library
11
+ # will automatically use it, eliminating the need for separate configuration.
12
+ #
13
+ # Logging Level Guidelines:
14
+ # - debug: Detailed flow information, verbose debugging (development only)
15
+ # - info: Important state changes, successful operations, key rotations
16
+ # - warn: Recoverable errors, fallbacks, deprecation warnings, rate limiting
17
+ # - error: Unrecoverable errors, security issues, validation failures
18
+ module OmniauthOpenidFederation
19
+ class Logger
20
+ class << self
21
+ attr_writer :logger
22
+
23
+ # Get the configured logger instance
24
+ #
25
+ # @return [Logger, #debug, #info, #warn, #error] The logger instance
26
+ def logger
27
+ @logger ||= default_logger
28
+ end
29
+
30
+ # Log a debug message
31
+ # Use for: Detailed flow information, verbose debugging (development only)
32
+ #
33
+ # @param message [String] The message to log
34
+ def debug(message)
35
+ logger.debug("[OpenIDFederation] #{message}")
36
+ end
37
+
38
+ # Log an info message
39
+ # Use for: Important state changes, successful operations, key rotations
40
+ #
41
+ # @param message [String] The message to log
42
+ def info(message)
43
+ logger.info("[OpenIDFederation] #{message}")
44
+ end
45
+
46
+ # Log a warning message
47
+ # Use for: Recoverable errors, fallbacks, deprecation warnings, rate limiting
48
+ #
49
+ # @param message [String] The message to log
50
+ def warn(message)
51
+ logger.warn("[OpenIDFederation] #{message}")
52
+ end
53
+
54
+ # Log an error message
55
+ # Use for: Unrecoverable errors, security issues, validation failures
56
+ #
57
+ # @param message [String] The message to log
58
+ def error(message)
59
+ logger.error("[OpenIDFederation] #{message}")
60
+ end
61
+
62
+ private
63
+
64
+ # Get the default logger based on available libraries
65
+ # Priority: OmniAuth logger > Rails logger > standard Logger > NullLogger
66
+ #
67
+ # @return [Logger, NullLogger] The default logger instance
68
+ def default_logger
69
+ # Respect OmniAuth's configured logger if available
70
+ # This allows developers to configure logging once via OmniAuth.config.logger
71
+ if defined?(OmniAuth) && OmniAuth.config.respond_to?(:logger) && OmniAuth.config.logger
72
+ OmniAuth.config.logger
73
+ elsif defined?(Rails) && Rails.logger
74
+ Rails.logger
75
+ elsif defined?(::Logger)
76
+ ::Logger.new($stdout)
77
+ else
78
+ NullLogger.new
79
+ end
80
+ end
81
+ end
82
+
83
+ # Null logger that discards all log messages
84
+ # Used when no logger is available
85
+ class NullLogger
86
+ def debug(*)
87
+ end
88
+
89
+ def info(*)
90
+ end
91
+
92
+ def warn(*)
93
+ end
94
+
95
+ def error(*)
96
+ end
97
+ end
98
+ end
99
+ end