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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE.md +22 -0
- data/README.md +822 -0
- data/SECURITY.md +129 -0
- data/examples/README_INTEGRATION_TESTING.md +399 -0
- data/examples/README_MOCK_OP.md +243 -0
- data/examples/app/controllers/users/omniauth_callbacks_controller.rb.example +33 -0
- data/examples/app/jobs/jwks_rotation_job.rb.example +60 -0
- data/examples/app/models/user.rb.example +39 -0
- data/examples/config/initializers/devise.rb.example +97 -0
- data/examples/config/initializers/federation_endpoint.rb.example +206 -0
- data/examples/config/mock_op.yml.example +83 -0
- data/examples/config/open_id_connect_config.rb.example +210 -0
- data/examples/config/routes.rb.example +12 -0
- data/examples/db/migrate/add_omniauth_to_users.rb.example +16 -0
- data/examples/integration_test_flow.rb +1334 -0
- data/examples/jobs/README.md +194 -0
- data/examples/jobs/federation_cache_refresh_job.rb.example +78 -0
- data/examples/jobs/federation_files_generation_job.rb.example +87 -0
- data/examples/mock_op_server.rb +775 -0
- data/examples/mock_rp_server.rb +435 -0
- data/lib/omniauth_openid_federation/access_token.rb +504 -0
- data/lib/omniauth_openid_federation/cache.rb +39 -0
- data/lib/omniauth_openid_federation/cache_adapter.rb +173 -0
- data/lib/omniauth_openid_federation/configuration.rb +135 -0
- data/lib/omniauth_openid_federation/constants.rb +13 -0
- data/lib/omniauth_openid_federation/endpoint_resolver.rb +168 -0
- data/lib/omniauth_openid_federation/entity_statement_reader.rb +122 -0
- data/lib/omniauth_openid_federation/errors.rb +52 -0
- data/lib/omniauth_openid_federation/federation/entity_statement.rb +331 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_builder.rb +188 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_fetcher.rb +142 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_helper.rb +87 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_parser.rb +198 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +502 -0
- data/lib/omniauth_openid_federation/federation/metadata_policy_merger.rb +276 -0
- data/lib/omniauth_openid_federation/federation/signed_jwks.rb +210 -0
- data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +225 -0
- data/lib/omniauth_openid_federation/federation_endpoint.rb +949 -0
- data/lib/omniauth_openid_federation/http_client.rb +70 -0
- data/lib/omniauth_openid_federation/instrumentation.rb +383 -0
- data/lib/omniauth_openid_federation/jwks/cache.rb +76 -0
- data/lib/omniauth_openid_federation/jwks/decode.rb +174 -0
- data/lib/omniauth_openid_federation/jwks/fetch.rb +153 -0
- data/lib/omniauth_openid_federation/jwks/normalizer.rb +49 -0
- data/lib/omniauth_openid_federation/jwks/rotate.rb +97 -0
- data/lib/omniauth_openid_federation/jwks/selector.rb +101 -0
- data/lib/omniauth_openid_federation/jws.rb +416 -0
- data/lib/omniauth_openid_federation/key_extractor.rb +173 -0
- data/lib/omniauth_openid_federation/logger.rb +99 -0
- data/lib/omniauth_openid_federation/rack_endpoint.rb +187 -0
- data/lib/omniauth_openid_federation/railtie.rb +29 -0
- data/lib/omniauth_openid_federation/rate_limiter.rb +55 -0
- data/lib/omniauth_openid_federation/strategy.rb +2029 -0
- data/lib/omniauth_openid_federation/string_helpers.rb +30 -0
- data/lib/omniauth_openid_federation/tasks_helper.rb +428 -0
- data/lib/omniauth_openid_federation/utils.rb +166 -0
- data/lib/omniauth_openid_federation/validators.rb +126 -0
- data/lib/omniauth_openid_federation/version.rb +3 -0
- data/lib/omniauth_openid_federation.rb +98 -0
- data/lib/tasks/omniauth_openid_federation.rake +376 -0
- data/sig/federation.rbs +218 -0
- data/sig/jwks.rbs +63 -0
- data/sig/omniauth_openid_federation.rbs +254 -0
- data/sig/strategy.rbs +60 -0
- 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
|