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,174 @@
|
|
|
1
|
+
require "jwt"
|
|
2
|
+
require "digest"
|
|
3
|
+
require "base64"
|
|
4
|
+
require_relative "../logger"
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
require_relative "../cache"
|
|
7
|
+
require_relative "fetch"
|
|
8
|
+
|
|
9
|
+
# JWT decoding service with automatic key rotation handling
|
|
10
|
+
# @see https://openid.net/specs/openid-federation-1_0.html OpenID Federation 1.0 Specification
|
|
11
|
+
# @see https://openid.net/specs/openid-federation-1_0.html#section-11.1 Section 11.1: Protocol Key Rollover
|
|
12
|
+
#
|
|
13
|
+
# Decodes and validates JWTs using JWKS from the provider. Implements automatic
|
|
14
|
+
# key rotation handling by:
|
|
15
|
+
# - Detecting signature verification failures (possible key rotation)
|
|
16
|
+
# - Clearing JWKS cache and re-fetching fresh keys
|
|
17
|
+
# - Retrying JWT validation with updated keys
|
|
18
|
+
#
|
|
19
|
+
# This handles provider key rotation automatically without manual intervention.
|
|
20
|
+
# Providers will notify before key rotation, and this implementation handles it gracefully.
|
|
21
|
+
module OmniauthOpenidFederation
|
|
22
|
+
module Jwks
|
|
23
|
+
class Decode
|
|
24
|
+
# Decode JWT with automatic key rotation handling
|
|
25
|
+
#
|
|
26
|
+
# @param encoded_jwt [String] The JWT to decode
|
|
27
|
+
# @param jwks_uri [String] The JWKS URI for key lookup
|
|
28
|
+
# @param retried [Boolean] Internal flag for retry logic (default: false)
|
|
29
|
+
# @param entity_statement_keys [Hash, Array, nil] Entity statement keys for validation
|
|
30
|
+
# @yield [jwks] Optional block to process JWKS before decoding
|
|
31
|
+
# @yieldparam jwks [Hash] The JWKS hash
|
|
32
|
+
# @return [Object] Result from block or JWKS hash
|
|
33
|
+
# @raise [ValidationError] If JWT validation fails
|
|
34
|
+
# @raise [SignatureError] If signature verification fails after retry
|
|
35
|
+
def self.run(encoded_jwt, jwks_uri, retried: false, entity_statement_keys: nil, &block)
|
|
36
|
+
# Fetch JWKS
|
|
37
|
+
jwks = OmniauthOpenidFederation::Jwks::Fetch.run(jwks_uri, entity_statement_keys: entity_statement_keys)
|
|
38
|
+
|
|
39
|
+
begin
|
|
40
|
+
# Try to decode
|
|
41
|
+
if block_given?
|
|
42
|
+
yield(jwks)
|
|
43
|
+
else
|
|
44
|
+
jwks
|
|
45
|
+
end
|
|
46
|
+
rescue JWT::ExpiredSignature, JWT::InvalidJtiError, JWT::InvalidIatError, JWT::InvalidAudError, JWT::InvalidIssuerError => e
|
|
47
|
+
# These are JWT validation errors that shouldn't trigger retry
|
|
48
|
+
# They indicate the token itself is invalid (expired, wrong audience, etc.), not that the keys are wrong
|
|
49
|
+
OmniauthOpenidFederation::Logger.error("[Jwks::Decode] JWT validation failed: #{e.class} - #{e.message}")
|
|
50
|
+
raise ValidationError, e.message, e.backtrace
|
|
51
|
+
rescue JWT::DecodeError => e
|
|
52
|
+
# JWT::DecodeError can be either:
|
|
53
|
+
# - Format errors (invalid JWT structure) - shouldn't retry
|
|
54
|
+
# - Verification errors (signature mismatch) - should retry (might be key rotation)
|
|
55
|
+
# - Key not found errors (kid not found) - should raise ValidationError
|
|
56
|
+
if e.message.include?("Could not find public key for kid") || e.message.include?("Key with kid")
|
|
57
|
+
# Key not found error - might indicate key rotation, so clear cache and retry once
|
|
58
|
+
if retried
|
|
59
|
+
# Already retried, raise ValidationError
|
|
60
|
+
error_msg = "Key with kid not found in JWKS after cache refresh: #{e.message}"
|
|
61
|
+
OmniauthOpenidFederation::Logger.error("[Jwks::Decode] #{error_msg}")
|
|
62
|
+
raise ValidationError, error_msg, e.backtrace
|
|
63
|
+
else
|
|
64
|
+
# First attempt - clear cache and retry (might be key rotation)
|
|
65
|
+
OmniauthOpenidFederation::Logger.warn("[Jwks::Decode] Key with kid not found (clearing cache and retrying - possible key rotation): #{e.message}")
|
|
66
|
+
# Instrument key rotation detection
|
|
67
|
+
OmniauthOpenidFederation::Instrumentation.notify_key_rotation_detected(
|
|
68
|
+
jwks_uri: jwks_uri,
|
|
69
|
+
error_message: e.message,
|
|
70
|
+
error_class: e.class.name,
|
|
71
|
+
reason: "kid_not_found"
|
|
72
|
+
)
|
|
73
|
+
OmniauthOpenidFederation::Cache.delete_jwks(jwks_uri)
|
|
74
|
+
run(encoded_jwt, jwks_uri, retried: true, entity_statement_keys: entity_statement_keys, &block)
|
|
75
|
+
end
|
|
76
|
+
elsif e.is_a?(JWT::VerificationError) || e.message.include?("verification") || e.message.include?("signature")
|
|
77
|
+
# Verification error - might be key rotation, so retry
|
|
78
|
+
if retried
|
|
79
|
+
error_msg = "JWT signature verification failed after cache refresh (possible key rotation issue): #{e.class} - #{e.message}"
|
|
80
|
+
OmniauthOpenidFederation::Logger.error("[Jwks::Decode] #{error_msg}")
|
|
81
|
+
# Instrument signature verification failure after retry
|
|
82
|
+
OmniauthOpenidFederation::Instrumentation.notify_signature_verification_failed(
|
|
83
|
+
token_type: "jwt",
|
|
84
|
+
jwks_uri: jwks_uri,
|
|
85
|
+
error_message: e.message,
|
|
86
|
+
error_class: e.class.name,
|
|
87
|
+
retried: true
|
|
88
|
+
)
|
|
89
|
+
raise SignatureError, error_msg, e.backtrace
|
|
90
|
+
else
|
|
91
|
+
OmniauthOpenidFederation::Logger.warn("[Jwks::Decode] JWT signature verification failed (clearing cache and retrying - possible key rotation): #{e.class} - #{e.message}")
|
|
92
|
+
# Instrument key rotation detection
|
|
93
|
+
OmniauthOpenidFederation::Instrumentation.notify_key_rotation_detected(
|
|
94
|
+
jwks_uri: jwks_uri,
|
|
95
|
+
error_message: e.message,
|
|
96
|
+
error_class: e.class.name
|
|
97
|
+
)
|
|
98
|
+
OmniauthOpenidFederation::Cache.delete_jwks(jwks_uri)
|
|
99
|
+
run(encoded_jwt, jwks_uri, retried: true, entity_statement_keys: entity_statement_keys, &block)
|
|
100
|
+
end
|
|
101
|
+
else
|
|
102
|
+
# Format error - don't retry
|
|
103
|
+
error_msg = "JWT format error: #{e.class} - #{e.message}"
|
|
104
|
+
OmniauthOpenidFederation::Logger.error("[Jwks::Decode] #{error_msg}")
|
|
105
|
+
raise ValidationError, error_msg, e.backtrace
|
|
106
|
+
end
|
|
107
|
+
rescue ArgumentError => e
|
|
108
|
+
# Argument errors from invalid JWT format
|
|
109
|
+
if e.message.include?("Invalid")
|
|
110
|
+
error_msg = "JWT decode failed due to invalid format: #{e.class} - #{e.message}"
|
|
111
|
+
OmniauthOpenidFederation::Logger.error("[Jwks::Decode] #{error_msg}")
|
|
112
|
+
raise ValidationError, error_msg, e.backtrace
|
|
113
|
+
else
|
|
114
|
+
raise e
|
|
115
|
+
end
|
|
116
|
+
rescue => e
|
|
117
|
+
# Other errors might be due to key rotation
|
|
118
|
+
if retried
|
|
119
|
+
# If already re-tried to fetch, raise error
|
|
120
|
+
error_msg = "JWT decode failed after cache refresh: #{e.class} - #{e.message}"
|
|
121
|
+
OmniauthOpenidFederation::Logger.error("[Jwks::Decode] #{error_msg}")
|
|
122
|
+
raise ValidationError, error_msg, e.backtrace
|
|
123
|
+
else
|
|
124
|
+
OmniauthOpenidFederation::Logger.warn("[Jwks::Decode] JWT decode error (clearing cache and retrying - possible key rotation): #{e.class} - #{e.message}")
|
|
125
|
+
# Reset cache to force re-fetching of keys
|
|
126
|
+
OmniauthOpenidFederation::Cache.delete_jwks(jwks_uri)
|
|
127
|
+
run(encoded_jwt, jwks_uri, retried: true, entity_statement_keys: entity_statement_keys, &block)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Decode JWT using JWT gem
|
|
133
|
+
#
|
|
134
|
+
# @param encoded_jwt [String] The JWT to decode
|
|
135
|
+
# @param jwks_uri [String] The JWKS URI for key lookup
|
|
136
|
+
# @param retried [Boolean] Internal flag for retry logic (default: false)
|
|
137
|
+
# @param entity_statement_keys [Hash, Array, nil] Entity statement keys for validation
|
|
138
|
+
# @return [Array<Hash>] Array with [payload, header]
|
|
139
|
+
# @raise [ValidationError] If JWT validation fails
|
|
140
|
+
# @raise [SignatureError] If signature verification fails
|
|
141
|
+
def self.jwt(encoded_jwt, jwks_uri, retried: false, entity_statement_keys: nil)
|
|
142
|
+
run(encoded_jwt, jwks_uri, retried: retried, entity_statement_keys: entity_statement_keys) do |jwks|
|
|
143
|
+
# jwks should be a HashWithIndifferentAccess with "keys" array
|
|
144
|
+
jwks_for_decode = if jwks.is_a?(Hash) && jwks.key?("keys")
|
|
145
|
+
jwks
|
|
146
|
+
else
|
|
147
|
+
{keys: jwks}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
::JWT.decode(
|
|
151
|
+
encoded_jwt,
|
|
152
|
+
nil,
|
|
153
|
+
true,
|
|
154
|
+
{algorithms: ["RS256"], jwks: jwks_for_decode}
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Decode JWT using jwt gem (legacy method name kept for backward compatibility)
|
|
160
|
+
#
|
|
161
|
+
# @param encoded_jwt [String] The JWT to decode
|
|
162
|
+
# @param jwks_uri [String] The JWKS URI for key lookup
|
|
163
|
+
# @param retried [Boolean] Internal flag for retry logic (default: false)
|
|
164
|
+
# @param entity_statement_keys [Hash, Array, nil] Entity statement keys for validation
|
|
165
|
+
# @return [Array<Hash>] Array with [payload, header]
|
|
166
|
+
# @raise [ValidationError] If JWT validation fails
|
|
167
|
+
# @raise [SignatureError] If signature verification fails
|
|
168
|
+
# @deprecated Use jwt() method instead
|
|
169
|
+
def self.json_jwt(encoded_jwt, jwks_uri, retried: false, entity_statement_keys: nil)
|
|
170
|
+
jwt(encoded_jwt, jwks_uri, retried: retried, entity_statement_keys: entity_statement_keys)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
require "http"
|
|
2
|
+
require "jwt"
|
|
3
|
+
require "openssl"
|
|
4
|
+
require_relative "../logger"
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
require_relative "../http_client"
|
|
7
|
+
require_relative "../cache"
|
|
8
|
+
require_relative "../cache_adapter"
|
|
9
|
+
require_relative "../utils"
|
|
10
|
+
require_relative "../constants"
|
|
11
|
+
require_relative "../rate_limiter"
|
|
12
|
+
require_relative "normalizer"
|
|
13
|
+
|
|
14
|
+
# JWKS fetching service with support for both standard and signed JWKS
|
|
15
|
+
# @see https://openid.net/specs/openid-federation-1_0.html OpenID Federation 1.0 Specification
|
|
16
|
+
# @see https://openid.net/specs/openid-federation-1_0.html#section-5.2.1.1 Section 5.2.1.1: Usage of jwks, jwks_uri, and signed_jwks_uri
|
|
17
|
+
#
|
|
18
|
+
# Fetches JWKS from either:
|
|
19
|
+
# - Standard JWKS endpoint (jwks_uri) - Returns JSON directly
|
|
20
|
+
# - Signed JWKS endpoint (signed_jwks_uri) - Returns a JWT containing JWKS, validated using entity statement keys
|
|
21
|
+
#
|
|
22
|
+
# Supports caching for performance (24 hour TTL by default) and handles both federation and standard OIDC scenarios.
|
|
23
|
+
# According to RFC 7517 and OpenID Connect best practices, JWKS should be cached to reduce latency and avoid
|
|
24
|
+
# unnecessary network requests. The cache TTL balances performance with key rotation needs.
|
|
25
|
+
#
|
|
26
|
+
# Key rotation is handled automatically via retry logic in Jwks::Decode which clears the cache
|
|
27
|
+
# on signature verification failures, allowing fresh keys to be fetched when providers rotate keys.
|
|
28
|
+
module OmniauthOpenidFederation
|
|
29
|
+
module Jwks
|
|
30
|
+
class Fetch
|
|
31
|
+
# Fetch JWKS from provider with caching support
|
|
32
|
+
#
|
|
33
|
+
# @param jwks_uri [String] The JWKS URI to fetch from
|
|
34
|
+
# @param entity_statement_keys [Hash, Array, nil] Entity statement keys for validating signed JWKS
|
|
35
|
+
# @param cache_ttl [Integer, nil] Cache TTL in seconds (default: from configuration)
|
|
36
|
+
# - nil: Use configuration default (manual rotation if not set, or configured TTL)
|
|
37
|
+
# - positive integer: Cache expires after this many seconds
|
|
38
|
+
# @param force_refresh [Boolean] Force refresh even if cached (default: false)
|
|
39
|
+
# @return [Hash] JWKS hash with "keys" array
|
|
40
|
+
# @raise [FetchError] If fetching fails
|
|
41
|
+
def self.run(jwks_uri, entity_statement_keys: nil, cache_ttl: nil, force_refresh: false)
|
|
42
|
+
cache_key = OmniauthOpenidFederation::Cache.key_for_jwks(jwks_uri)
|
|
43
|
+
config = OmniauthOpenidFederation::Configuration.config
|
|
44
|
+
cache_ttl ||= config.cache_ttl
|
|
45
|
+
rotate_on_errors = config.rotate_on_errors
|
|
46
|
+
|
|
47
|
+
# Use cache adapter if available, otherwise fetch directly
|
|
48
|
+
if CacheAdapter.available?
|
|
49
|
+
if force_refresh
|
|
50
|
+
# Force refresh: clear cache and fetch fresh
|
|
51
|
+
CacheAdapter.delete(cache_key)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
if cache_ttl.nil?
|
|
55
|
+
# Manual rotation: cache forever, only rotate on errors if rotate_on_errors is enabled
|
|
56
|
+
begin
|
|
57
|
+
CacheAdapter.fetch(cache_key, expires_in: nil) do
|
|
58
|
+
fetch_jwks(jwks_uri, entity_statement_keys)
|
|
59
|
+
end
|
|
60
|
+
rescue KeyRelatedError => e
|
|
61
|
+
# Rotate on key-related errors if configured
|
|
62
|
+
if rotate_on_errors
|
|
63
|
+
OmniauthOpenidFederation::Logger.warn("[Jwks::Fetch] Key-related error detected, rotating cache: #{e.message}")
|
|
64
|
+
CacheAdapter.delete(cache_key)
|
|
65
|
+
fetch_jwks(jwks_uri, entity_statement_keys)
|
|
66
|
+
else
|
|
67
|
+
raise
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
else
|
|
71
|
+
# TTL-based cache: expires after cache_ttl seconds
|
|
72
|
+
# Rotate on errors if configured
|
|
73
|
+
begin
|
|
74
|
+
CacheAdapter.fetch(cache_key, expires_in: cache_ttl) do
|
|
75
|
+
fetch_jwks(jwks_uri, entity_statement_keys)
|
|
76
|
+
end
|
|
77
|
+
rescue KeyRelatedError => e
|
|
78
|
+
# Rotate on key-related errors if configured
|
|
79
|
+
if rotate_on_errors
|
|
80
|
+
OmniauthOpenidFederation::Logger.warn("[Jwks::Fetch] Key-related error detected, rotating cache: #{e.message}")
|
|
81
|
+
CacheAdapter.delete(cache_key)
|
|
82
|
+
fetch_jwks(jwks_uri, entity_statement_keys)
|
|
83
|
+
else
|
|
84
|
+
raise
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
else
|
|
89
|
+
fetch_jwks(jwks_uri, entity_statement_keys)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.fetch_jwks(jwks_uri, entity_statement_keys)
|
|
94
|
+
# Rate limiting to prevent DoS
|
|
95
|
+
unless RateLimiter.allow?(jwks_uri)
|
|
96
|
+
raise FetchError, "Rate limit exceeded for JWKS fetching"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Use HTTP client with retry logic and configurable SSL verification
|
|
100
|
+
begin
|
|
101
|
+
response = HttpClient.get(jwks_uri)
|
|
102
|
+
rescue OmniauthOpenidFederation::NetworkError => e
|
|
103
|
+
sanitized_uri = Utils.sanitize_uri(jwks_uri)
|
|
104
|
+
OmniauthOpenidFederation::Logger.error("[Jwks::Fetch] Failed to fetch JWKS from #{sanitized_uri}: #{e.message}")
|
|
105
|
+
raise FetchError, "Failed to fetch JWKS: #{e.message}", e.backtrace
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
unless response.status.success?
|
|
109
|
+
sanitized_uri = Utils.sanitize_uri(jwks_uri)
|
|
110
|
+
error_msg = "Failed to fetch JWKS: HTTP #{response.status}"
|
|
111
|
+
OmniauthOpenidFederation::Logger.error("[Jwks::Fetch] #{error_msg} from #{sanitized_uri}")
|
|
112
|
+
# If it's a key-related error (401, 403, 404), this might indicate key rotation
|
|
113
|
+
if Constants::KEY_ROTATION_HTTP_CODES.include?(response.status.code)
|
|
114
|
+
raise KeyRelatedError, error_msg
|
|
115
|
+
else
|
|
116
|
+
raise FetchError, error_msg
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
if entity_statement_keys
|
|
121
|
+
# Validate signed JWKS using entity statement keys
|
|
122
|
+
# If jwks_uri returns a JWT (signed JWKS), validate it
|
|
123
|
+
if Utils.valid_jwt_format?(response.body.to_s)
|
|
124
|
+
# It's a signed JWKS (JWT format)
|
|
125
|
+
jwks_jwt = response.body.to_s
|
|
126
|
+
|
|
127
|
+
# Convert entity statement keys to format expected by JWT gem
|
|
128
|
+
jwks_hash = Normalizer.to_jwks_hash(entity_statement_keys)
|
|
129
|
+
|
|
130
|
+
jwks_array = ::JWT.decode(
|
|
131
|
+
jwks_jwt,
|
|
132
|
+
nil,
|
|
133
|
+
true,
|
|
134
|
+
{algorithms: ["RS256"], jwks: jwks_hash}
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Extract JWKS from the decoded JWT payload
|
|
138
|
+
jwks_payload = jwks_array.first
|
|
139
|
+
Utils.to_indifferent_hash(jwks_payload)
|
|
140
|
+
else
|
|
141
|
+
# Standard JWKS JSON
|
|
142
|
+
json = response.parse(:json)
|
|
143
|
+
Utils.to_indifferent_hash(json)
|
|
144
|
+
end
|
|
145
|
+
else
|
|
146
|
+
# Standard JWKS without validation
|
|
147
|
+
json = response.parse(:json)
|
|
148
|
+
Utils.to_indifferent_hash(json)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "../validators"
|
|
3
|
+
|
|
4
|
+
# JWKS normalization utilities
|
|
5
|
+
# Converts various JWKS formats to the format expected by JWT gem
|
|
6
|
+
module OmniauthOpenidFederation
|
|
7
|
+
module Jwks
|
|
8
|
+
class Normalizer
|
|
9
|
+
# Convert JWKS to format expected by JWT gem
|
|
10
|
+
# Handles various input formats: Hash with "keys" or :keys, Array of keys, etc.
|
|
11
|
+
#
|
|
12
|
+
# @param jwks [Hash, Array, Object] JWKS in various formats
|
|
13
|
+
# @return [Hash] Normalized JWKS hash with "keys" array (string keys)
|
|
14
|
+
def self.to_jwks_hash(jwks)
|
|
15
|
+
if jwks.is_a?(Hash) && (jwks.key?("keys") || jwks.key?(:keys))
|
|
16
|
+
# Hash with keys array
|
|
17
|
+
keys = jwks["keys"] || jwks[:keys]
|
|
18
|
+
normalize_keys_array(keys)
|
|
19
|
+
elsif jwks.is_a?(Array)
|
|
20
|
+
# Array of keys
|
|
21
|
+
normalize_keys_array(jwks)
|
|
22
|
+
else
|
|
23
|
+
# Fallback: try to convert to hash
|
|
24
|
+
normalized = Validators.normalize_hash(jwks || {})
|
|
25
|
+
keys = normalized[:keys] || []
|
|
26
|
+
normalize_keys_array(keys)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Normalize an array of JWK objects to hash format with string keys
|
|
31
|
+
#
|
|
32
|
+
# @param keys [Array] Array of JWK objects (Hash, etc.)
|
|
33
|
+
# @return [Hash] Hash with "keys" array containing normalized JWKs
|
|
34
|
+
def self.normalize_keys_array(keys)
|
|
35
|
+
{
|
|
36
|
+
"keys" => Array(keys).map do |jwk|
|
|
37
|
+
if jwk.is_a?(Hash)
|
|
38
|
+
jwk.stringify_keys
|
|
39
|
+
else
|
|
40
|
+
JSON.parse(jwk.to_json)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private_class_method :normalize_keys_array
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
require_relative "../utils"
|
|
2
|
+
require_relative "../logger"
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "../configuration"
|
|
5
|
+
require_relative "../string_helpers"
|
|
6
|
+
require_relative "../entity_statement_reader"
|
|
7
|
+
require_relative "fetch"
|
|
8
|
+
require_relative "../federation/entity_statement_helper"
|
|
9
|
+
require_relative "../federation/signed_jwks"
|
|
10
|
+
|
|
11
|
+
# JWKS rotation service for OpenID Federation 1.0
|
|
12
|
+
# @see https://openid.net/specs/openid-federation-1_0.html OpenID Federation 1.0 Specification
|
|
13
|
+
#
|
|
14
|
+
# Provides functionality to proactively refresh JWKS cache for providers.
|
|
15
|
+
# This is useful for background jobs to refresh keys before they expire.
|
|
16
|
+
#
|
|
17
|
+
# Supports both standard JWKS and signed JWKS (OpenID Federation).
|
|
18
|
+
module OmniauthOpenidFederation
|
|
19
|
+
module Jwks
|
|
20
|
+
# JWKS rotation service
|
|
21
|
+
#
|
|
22
|
+
# @example Rotate JWKS for a provider
|
|
23
|
+
# OmniauthOpenidFederation::Jwks::Rotate.run(
|
|
24
|
+
# "https://provider.example.com/.well-known/jwks.json",
|
|
25
|
+
# entity_statement_path: "config/provider-entity-statement.jwt"
|
|
26
|
+
# )
|
|
27
|
+
class Rotate
|
|
28
|
+
# Rotate JWKS cache for a provider
|
|
29
|
+
# This is useful for background jobs to proactively refresh keys
|
|
30
|
+
#
|
|
31
|
+
# @param jwks_uri [String] The JWKS URI to refresh
|
|
32
|
+
# @param entity_statement_path [String, nil] Path to entity statement file (optional)
|
|
33
|
+
# @return [Hash] The refreshed JWKS hash
|
|
34
|
+
# @raise [FetchError] If fetching fails
|
|
35
|
+
# @raise [ValidationError] If validation fails
|
|
36
|
+
# @raise [SecurityError] If path validation fails
|
|
37
|
+
# @raise [ConfigurationError] If entity statement file not found
|
|
38
|
+
def self.run(jwks_uri, entity_statement_path: nil)
|
|
39
|
+
if entity_statement_path
|
|
40
|
+
# Validate file path to prevent path traversal
|
|
41
|
+
begin
|
|
42
|
+
# Determine allowed directories for file path validation
|
|
43
|
+
config = Configuration.config
|
|
44
|
+
allowed_dirs = if defined?(Rails) && Rails.root
|
|
45
|
+
[Rails.root.join("config").to_s]
|
|
46
|
+
elsif config.root_path
|
|
47
|
+
[File.join(config.root_path, "config")]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
validated_path = Utils.validate_file_path!(
|
|
51
|
+
entity_statement_path,
|
|
52
|
+
allowed_dirs: allowed_dirs
|
|
53
|
+
)
|
|
54
|
+
rescue SecurityError => e
|
|
55
|
+
Logger.error("[Jwks::Rotate] #{e.message}")
|
|
56
|
+
raise SecurityError, e.message
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
unless File.exist?(validated_path)
|
|
60
|
+
sanitized_path = Utils.sanitize_path(validated_path)
|
|
61
|
+
Logger.warn("[Jwks::Rotate] Entity statement file not found: #{sanitized_path}")
|
|
62
|
+
raise ConfigurationError, "Entity statement file not found: #{sanitized_path}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Try to use signed JWKS if entity statement is available
|
|
66
|
+
begin
|
|
67
|
+
parsed = Federation::EntityStatementHelper.parse_for_signed_jwks(validated_path)
|
|
68
|
+
if parsed && StringHelpers.present?(parsed[:signed_jwks_uri])
|
|
69
|
+
return Federation::SignedJWKS.fetch!(
|
|
70
|
+
parsed[:signed_jwks_uri],
|
|
71
|
+
parsed[:entity_jwks],
|
|
72
|
+
force_refresh: true
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
rescue SecurityError
|
|
76
|
+
raise
|
|
77
|
+
rescue
|
|
78
|
+
Logger.warn("[Jwks::Rotate] Failed to use signed JWKS, falling back to standard JWKS")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Fallback to standard JWKS with entity statement keys
|
|
82
|
+
entity_statement_keys = EntityStatementReader.fetch_keys(
|
|
83
|
+
entity_statement_path: validated_path
|
|
84
|
+
)
|
|
85
|
+
return Fetch.run(
|
|
86
|
+
jwks_uri,
|
|
87
|
+
entity_statement_keys: entity_statement_keys,
|
|
88
|
+
force_refresh: true
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Use standard JWKS
|
|
93
|
+
Fetch.run(jwks_uri, force_refresh: true)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
require_relative "../logger"
|
|
2
|
+
|
|
3
|
+
# JWKS Selector utilities for filtering keys
|
|
4
|
+
# @see https://openid.net/specs/openid-federation-1_0.html OpenID Federation 1.0 Specification
|
|
5
|
+
#
|
|
6
|
+
# Provides utilities for selecting specific keys from a JWKS set, such as:
|
|
7
|
+
# - Current keys (first signing and encryption keys)
|
|
8
|
+
# - All keys (including previous keys for rotation support)
|
|
9
|
+
# - Keys by use (signing vs encryption)
|
|
10
|
+
# - Keys by kid (key ID)
|
|
11
|
+
module OmniauthOpenidFederation
|
|
12
|
+
module Jwks
|
|
13
|
+
class Selector
|
|
14
|
+
# Get current keys from JWKS (first signing and encryption keys)
|
|
15
|
+
# This is useful for operations that only need the current keys, not all keys
|
|
16
|
+
# including previous keys from rotation.
|
|
17
|
+
#
|
|
18
|
+
# @param jwks [Hash, Array] JWKS hash with "keys" array or array of keys
|
|
19
|
+
# @return [Hash] JWKS hash with only current keys (one signing, one encryption)
|
|
20
|
+
def self.current_keys(jwks)
|
|
21
|
+
keys = extract_keys_array(jwks)
|
|
22
|
+
return {"keys" => []} if keys.empty?
|
|
23
|
+
|
|
24
|
+
taken_uses = []
|
|
25
|
+
current_keys = keys.select do |key|
|
|
26
|
+
use = key[:use] || key["use"]
|
|
27
|
+
if use == "sig" && !taken_uses.include?("sig")
|
|
28
|
+
taken_uses << "sig"
|
|
29
|
+
true
|
|
30
|
+
elsif use == "enc" && !taken_uses.include?("enc")
|
|
31
|
+
taken_uses << "enc"
|
|
32
|
+
true
|
|
33
|
+
else
|
|
34
|
+
false
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
{"keys" => current_keys}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Get all keys from JWKS (including previous keys from rotation)
|
|
42
|
+
#
|
|
43
|
+
# @param jwks [Hash, Array] JWKS hash with "keys" array or array of keys
|
|
44
|
+
# @return [Hash] JWKS hash with all keys
|
|
45
|
+
def self.all_keys(jwks)
|
|
46
|
+
keys = extract_keys_array(jwks)
|
|
47
|
+
{"keys" => keys}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Get signing keys only
|
|
51
|
+
#
|
|
52
|
+
# @param jwks [Hash, Array] JWKS hash with "keys" array or array of keys
|
|
53
|
+
# @return [Array<Hash>] Array of signing keys
|
|
54
|
+
def self.signing_keys(jwks)
|
|
55
|
+
keys = extract_keys_array(jwks)
|
|
56
|
+
keys.select { |key| (key[:use] || key["use"]) == "sig" }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Get encryption keys only
|
|
60
|
+
#
|
|
61
|
+
# @param jwks [Hash, Array] JWKS hash with "keys" array or array of keys
|
|
62
|
+
# @return [Array<Hash>] Array of encryption keys
|
|
63
|
+
def self.encryption_keys(jwks)
|
|
64
|
+
keys = extract_keys_array(jwks)
|
|
65
|
+
keys.select { |key| (key[:use] || key["use"]) == "enc" }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Get key by kid (key ID)
|
|
69
|
+
#
|
|
70
|
+
# @param jwks [Hash, Array] JWKS hash with "keys" array or array of keys
|
|
71
|
+
# @param kid [String] The key ID to find
|
|
72
|
+
# @return [Hash, nil] The key with matching kid, or nil if not found
|
|
73
|
+
def self.key_by_kid(jwks, kid)
|
|
74
|
+
keys = extract_keys_array(jwks)
|
|
75
|
+
keys.find { |key| (key[:kid] || key["kid"]) == kid }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Extract keys array from various JWKS formats
|
|
79
|
+
#
|
|
80
|
+
# @param jwks [Hash, Array] JWKS in various formats
|
|
81
|
+
# @return [Array<Hash>] Array of key hashes
|
|
82
|
+
def self.extract_keys_array(jwks)
|
|
83
|
+
if jwks.is_a?(Hash)
|
|
84
|
+
if jwks.key?("keys")
|
|
85
|
+
jwks["keys"] || []
|
|
86
|
+
elsif jwks.key?(:keys)
|
|
87
|
+
jwks[:keys] || []
|
|
88
|
+
else
|
|
89
|
+
[]
|
|
90
|
+
end
|
|
91
|
+
elsif jwks.is_a?(Array)
|
|
92
|
+
jwks
|
|
93
|
+
else
|
|
94
|
+
[]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private_class_method :extract_keys_array
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|