Class: Verikloak::JwksCache

Inherits:
Object
  • Object
show all
Defined in:
lib/verikloak/jwks_cache.rb

Overview

Caches and revalidates JSON Web Key Sets (JWKs) fetched from a remote endpoint.

This cache supports two HTTP cache mechanisms:

  • **ETag revalidation** via ‘If-None-Match` → returns `304 Not Modified` when unchanged.

  • **TTL freshness** via ‘Cache-Control: max-age` → avoids HTTP requests while fresh.

On a successful ‘200 OK`, the cache:

  • Parses the JWKs JSON (‘href="...">keys”:`) and validates each JWK has `kid`, `kty`, `n`, `e`.

  • Stores the keys in-memory, records ‘ETag`, and computes freshness from `Cache-Control`.

On a ‘304 Not Modified`, the cache:

  • Keeps existing keys and ETag, optionally updates TTL from new ‘Cache-Control`, and refreshes `fetched_at`.

Errors are raised as JwksCacheError with structured ‘code` values:

  • ‘jwks_fetch_failed` (network/HTTP errors)

  • ‘jwks_parse_failed` (invalid JSON / structure)

  • ‘jwks_cache_miss` (304 received but nothing cached)

## Dependency Injection Pass a preconfigured ‘Faraday::Connection` via `connection:` to control timeouts, adapters, and shared headers (kept consistent with Discovery).

`JwksCache.new(jwks_uri: "...", connection: Faraday.new { |f| f.request :retry })`

Examples:

Basic usage

cache = Verikloak::JwksCache.new(jwks_uri: "https://issuer.example.com/protocol/openid-connect/certs")
keys  = cache.fetch! # → Array<Hash> of JWKs

See Also:

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(jwks_uri:, connection: nil, allow_http: false) ⇒ JwksCache

Returns a new instance of JwksCache.

Parameters:

  • jwks_uri (String)

    HTTPS URL of the JWKs endpoint

  • connection (Faraday::Connection, nil) (defaults to: nil)

    Optional Faraday connection for HTTP requests

  • allow_http (Boolean) (defaults to: false)

    When false (default), raises on plain HTTP URIs. Set true for local development only.

Raises:

  • (JwksCacheError)

    if the URI is not an HTTP(S) URL or resolves to a private/internal address



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# File 'lib/verikloak/jwks_cache.rb', line 46

def initialize(jwks_uri:, connection: nil, allow_http: false)
  unless jwks_uri.is_a?(String)
    raise JwksCacheError.new('Invalid JWKs URI: must be a non-empty HTTP(S) URL', code: 'jwks_fetch_failed')
  end

  clean_jwks_uri = jwks_uri.strip

  unless clean_jwks_uri.match?(%r{^https?://})
    raise JwksCacheError.new('Invalid JWKs URI: must be a non-empty HTTP(S) URL', code: 'jwks_fetch_failed')
  end

  unless allow_http || clean_jwks_uri.start_with?('https://')
    raise JwksCacheError.new(
      'JWKs URI must use HTTPS. Set allow_http: true to permit plain HTTP (development only).',
      code: 'insecure_jwks_uri'
    )
  end

  validate_not_private!(clean_jwks_uri) unless allow_http

  @jwks_uri    = clean_jwks_uri
  @connection  = connection || Verikloak::HTTP.default_connection
  @cached_keys = nil
  @etag        = nil
  @fetched_at  = nil
  @max_age     = nil
  @mutex       = Mutex.new
end

Instance Attribute Details

#connectionFaraday::Connection (readonly)

Injected Faraday connection (for testing and shared config across the gem)

Returns:

  • (Faraday::Connection)


112
113
114
# File 'lib/verikloak/jwks_cache.rb', line 112

def connection
  @connection
end

Instance Method Details

#build_conditional_headersHash

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Builds conditional headers for revalidation.

Returns:

  • (Hash)

    ‘{ ’If-None-Match’ => etag }‘ when present, otherwise `{}`.



144
145
146
# File 'lib/verikloak/jwks_cache.rb', line 144

def build_conditional_headers
  @etag ? { 'If-None-Match' => @etag } : {}
end

#cachedArray<Hash>?

Returns the last cached JWKs without performing a network request.

Returns:

  • (Array<Hash>, nil)

    cached keys, or nil if never fetched



100
101
102
# File 'lib/verikloak/jwks_cache.rb', line 100

def cached
  @mutex.synchronize { @cached_keys }
end

#fetch!Array<Hash>

Fetches the JWKs and updates the in-memory cache.

Performs an HTTP GET with ‘If-None-Match` when an ETag is present and handles:

  • 200: parses/validates body, updates keys, ETag, TTL and ‘fetched_at`.

  • 304: keeps cached keys, updates TTL from headers (if present), refreshes ‘fetched_at`.

Returns:

  • (Array<Hash>)

    the cached JWKs after fetch/revalidation

Raises:

  • (JwksCacheError)

    on HTTP failures, invalid JSON, invalid structure, or cache miss on 304



83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/verikloak/jwks_cache.rb', line 83

def fetch!
  @mutex.synchronize do
    return @cached_keys if fresh_by_ttl_locked?

    with_error_handling do
      # Build conditional request headers (ETag-based)
      headers  = build_conditional_headers
      # Perform HTTP GET request
      response = @connection.get(@jwks_uri, nil, headers)
      # Handle HTTP response according to status code
      handle_response(response)
    end
  end
end

#fetched_atTime?

Timestamp of the last successful fetch or revalidation.

Returns:

  • (Time, nil)


106
107
108
# File 'lib/verikloak/jwks_cache.rb', line 106

def fetched_at
  @mutex.synchronize { @fetched_at }
end

#fresh_by_ttl?Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

True when cached keys are still fresh per ‘Cache-Control: max-age`.

Returns:

  • (Boolean)


151
152
153
# File 'lib/verikloak/jwks_cache.rb', line 151

def fresh_by_ttl?
  @mutex.synchronize { fresh_by_ttl_locked? }
end

#stale?Boolean

Whether the cache is considered stale.

Uses ‘Cache-Control: max-age` semantics when available: returns `true` if `max-age` has elapsed or nothing is cached.

Returns:

  • (Boolean)


120
121
122
# File 'lib/verikloak/jwks_cache.rb', line 120

def stale?
  @mutex.synchronize { !fresh_by_ttl_locked? }
end

#with_error_handlingObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Wraps network/parse errors into Verikloak::JwksCacheError with structured codes.

Raises:



127
128
129
130
131
132
133
134
135
136
137
138
139
# File 'lib/verikloak/jwks_cache.rb', line 127

def with_error_handling
  yield
rescue JwksCacheError
  raise
rescue Faraday::ConnectionFailed, Faraday::TimeoutError
  raise JwksCacheError.new('Connection failed', code: 'jwks_fetch_failed')
rescue Faraday::Error => e
  raise JwksCacheError.new("JWKs fetch failed: #{e.message}", code: 'jwks_fetch_failed')
rescue JSON::ParserError
  raise JwksCacheError.new('Response is not valid JSON', code: 'jwks_parse_failed')
rescue StandardError => e
  raise JwksCacheError.new("Unexpected JWKs fetch error: #{e.message}", code: 'jwks_fetch_failed')
end