Class: OmniauthOpenidFederation::Federation::SignedJWKS

Inherits:
Object
  • Object
show all
Defined in:
lib/omniauth_openid_federation/federation/signed_jwks.rb,
sig/federation.rbs

Overview

Signed JWKS implementation for OpenID Federation 1.0

Examples:

Fetch and validate signed JWKS

signed_jwks = SignedJWKS.fetch!(
  "https://provider.example.com/.well-known/signed-jwks",
  entity_jwks
)

Constant Summary collapse

FetchError =

Compatibility aliases for backward compatibility

OmniauthOpenidFederation::FetchError
ValidationError =
OmniauthOpenidFederation::ValidationError

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(signed_jwks_uri, entity_jwks) ⇒ SignedJWKS

Initialize signed JWKS fetcher

Parameters:

  • signed_jwks_uri (String)

    The URI to fetch signed JWKS from

  • entity_jwks (Hash, Array)

    Entity statement JWKS for validation



118
119
120
121
# File 'lib/omniauth_openid_federation/federation/signed_jwks.rb', line 118

def initialize(signed_jwks_uri, entity_jwks)
  @signed_jwks_uri = signed_jwks_uri
  @entity_jwks = entity_jwks
end

Class Method Details

.fetch!(signed_jwks_uri, entity_jwks, cache_key: nil, cache_ttl: nil, force_refresh: false) ⇒ Hash

Fetch and validate signed JWKS

Parameters:

  • signed_jwks_uri (String)

    The URI to fetch signed JWKS from

  • entity_jwks (Hash, Array)

    Entity statement JWKS for validation

  • cache_key (String, nil) (defaults to: nil)

    Custom cache key (default: auto-generated)

  • cache_ttl (Integer, nil) (defaults to: nil)

    Cache TTL in seconds (default: from configuration)

    • nil: Use configuration default (manual rotation if not set, or configured TTL)
    • positive integer: Cache expires after this many seconds
  • force_refresh (Boolean) (defaults to: false)

    Force refresh even if cached (default: false)

Returns:

  • (Hash)

    The validated JWKS hash

Raises:



62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'lib/omniauth_openid_federation/federation/signed_jwks.rb', line 62

def self.fetch!(signed_jwks_uri, entity_jwks, cache_key: nil, cache_ttl: nil, force_refresh: false)
  cache_key ||= OmniauthOpenidFederation::Cache.key_for_signed_jwks(signed_jwks_uri)
  config = OmniauthOpenidFederation::Configuration.config
  cache_ttl ||= config.cache_ttl
  rotate_on_errors = config.rotate_on_errors

  # Use cache adapter if available, otherwise fetch directly
  if CacheAdapter.available?
    if force_refresh
      # Force refresh: clear cache and fetch fresh
      CacheAdapter.delete(cache_key)
    end

    if cache_ttl.nil?
      # Manual rotation: cache forever, only rotate on errors if rotate_on_errors is enabled
      begin
        CacheAdapter.fetch(cache_key, expires_in: nil) do
          new(signed_jwks_uri, entity_jwks).fetch_and_validate
        end
      rescue KeyRelatedError, KeyRelatedValidationError => e
        # Rotate on key-related errors if configured
        if rotate_on_errors
          OmniauthOpenidFederation::Logger.warn("[SignedJWKS] Key-related error detected, rotating cache: #{e.message}")
          CacheAdapter.delete(cache_key)
          new(signed_jwks_uri, entity_jwks).fetch_and_validate
        else
          raise
        end
      end
    else
      # TTL-based cache: expires after cache_ttl seconds
      # Rotate on errors if configured
      begin
        CacheAdapter.fetch(cache_key, expires_in: cache_ttl) do
          new(signed_jwks_uri, entity_jwks).fetch_and_validate
        end
      rescue KeyRelatedError, KeyRelatedValidationError => e
        # Rotate on key-related errors if configured
        if rotate_on_errors
          OmniauthOpenidFederation::Logger.warn("[SignedJWKS] Key-related error detected, rotating cache: #{e.message}")
          CacheAdapter.delete(cache_key)
          new(signed_jwks_uri, entity_jwks).fetch_and_validate
        else
          raise
        end
      end
    end
  else
    new(signed_jwks_uri, entity_jwks).fetch_and_validate
  end
end

Instance Method Details

#fetch_and_validateHash

Fetch and validate signed JWKS

Returns:

  • (Hash)

    The validated JWKS hash

Raises:



128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
# File 'lib/omniauth_openid_federation/federation/signed_jwks.rb', line 128

def fetch_and_validate
  # Rate limiting to prevent DoS
  unless RateLimiter.allow?(@signed_jwks_uri)
    raise FetchError, "Rate limit exceeded for signed JWKS fetching"
  end

  # Fetch signed JWKS using HttpClient with retry logic
  begin
    response = HttpClient.get(@signed_jwks_uri)
  rescue OmniauthOpenidFederation::NetworkError => e
    sanitized_uri = Utils.sanitize_uri(@signed_jwks_uri)
    OmniauthOpenidFederation::Logger.error("[SignedJWKS] Failed to fetch signed JWKS from #{sanitized_uri}")
    raise FetchError, "Failed to fetch signed JWKS: #{e.message}", e.backtrace
  end

  unless response.status.success?
    error_msg = "Failed to fetch signed JWKS: HTTP #{response.status}"
    OmniauthOpenidFederation::Logger.error("[SignedJWKS] #{error_msg}")
    # If it's a key-related error (401, 403, 404), this might indicate key rotation
    if Constants::KEY_ROTATION_HTTP_CODES.include?(response.status.code)
      raise KeyRelatedError, error_msg
    else
      raise FetchError, error_msg
    end
  end

  signed_jwks_jwt = response.body.to_s

  # Validate it's a JWT (must have exactly 3 parts: header.payload.signature)
  unless Utils.valid_jwt_format?(signed_jwks_jwt)
    raise ValidationError, "Signed JWKS is not in JWT format"
  end

  # Convert entity JWKS to format expected by JWT gem
  jwks_hash = Jwks::Normalizer.to_jwks_hash(@entity_jwks)

  # Decode and validate signed JWKS
  begin
    OmniauthOpenidFederation::Logger.debug("[SignedJWKS] Validating signed JWKS signature")
    decoded = ::JWT.decode(
      signed_jwks_jwt,
      nil,
      true,
      {algorithms: ["RS256"], jwks: jwks_hash}
    )

    # Extract JWKS from decoded JWT payload
    # The JWT payload can be in two formats:
    # 1. OpenID Federation format: { iss, sub, iat, exp, jwks: { keys: [...] } }
    # 2. Legacy format: { keys: [...] } (direct JWKS)
    full_payload = decoded.first

    # Check if payload has 'jwks' field (OpenID Federation format)
    if full_payload.key?("jwks") || full_payload.key?(:jwks)
      jwks_payload = full_payload["jwks"] || full_payload[:jwks]
    elsif full_payload.key?("keys") || full_payload.key?(:keys)
      # Legacy format: payload is the JWKS directly
      jwks_payload = full_payload
    else
      error_msg = "Signed JWKS payload does not contain 'jwks' or 'keys' field"
      OmniauthOpenidFederation::Logger.error("[SignedJWKS] #{error_msg}")
      raise ValidationError, error_msg
    end

    OmniauthOpenidFederation::Logger.debug("[SignedJWKS] Successfully validated signed JWKS")

    # Ensure it's a HashWithIndifferentAccess if available
    Utils.to_indifferent_hash(jwks_payload)
  rescue JWT::VerificationError => e
    # More specific exception must be rescued first
    error_msg = "Signed JWKS signature validation failed: #{e.class} - #{e.message}"
    OmniauthOpenidFederation::Logger.error("[SignedJWKS] #{error_msg}")
    raise KeyRelatedValidationError, error_msg
  rescue JWT::DecodeError => e
    error_msg = "Failed to decode signed JWKS: #{e.class} - #{e.message}"
    OmniauthOpenidFederation::Logger.error("[SignedJWKS] #{error_msg}")
    raise KeyRelatedValidationError, error_msg
  end
end