keycloak-api-rails 2.0.0 → 2.0.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b23b08b9947c44a5c24411bf88b478a7042b574f9da4c18dcbc033d2b12c213f
4
- data.tar.gz: c7f2b04da31060ff404dec1469d54df5e20cd8dbc91fc602047250039985d010
3
+ metadata.gz: 1e80ef54a212cf523766b3d1f0a4d836dd4f32c59322eb9b90a13e0e530bff08
4
+ data.tar.gz: c6757d2a355db048389b3c697557daea96e912eea4d2fbeea413b78bd888ce03
5
5
  SHA512:
6
- metadata.gz: 8b7a27d90979f5a048f844a82def6876d42b734a6eb6af1a0ec5a99900f38c3b16009aea80c470068eb8d84fa4fd0ce671f3cddb53f853db8165f84f10808a12
7
- data.tar.gz: 58408083f436a01e19fb3ba1b2efb5cbf0a8b2a1cfb793c439b33339150c39cebb5f6b499f5c83955b6d57657c7c94a9ffd932b390ee4ae830472e3d260fe26a
6
+ metadata.gz: 2e46089640ad0a1d9f14e08e383a5f651431f533d88e9444c809d8d4b55edc91460e668b1ff21e23e863583b4fd956056fb82b0ea9dc5e16ea34cccb268ad2cf
7
+ data.tar.gz: e4bfba00be03edd43b60394146bdad990b14eeb731d7e878bdf8944c00e92986278b4c11ca721fa99cd309c54289ee21e0b5199c4f357b4e614691e9d1b6b3b6
data/CHANGELOG.md CHANGED
@@ -5,6 +5,38 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ### Performance
11
+
12
+ * Added `# frozen_string_literal: true` to all ruby files and optimized Hot Path methods in `Service` and `Helper` (e.g., using `Regexp#match?`, avoiding redundant String-to-Symbol conversions) to significantly reduce CPU usage and memory allocations per request.
13
+
14
+ ### Added
15
+
16
+ * Multi-tenancy support: `realm_id` can now be a String, an Array of Strings, or a Proc (e.g., `->(env) { ... }`) that evaluates to a String or Array of Strings, allowing dynamic realm resolution per request.
17
+ * The middleware now validates the token against the allowed realms. A token carrying an `iss` claim that does not match one of the expected realms will be rejected. New `KeycloakApiRails::TokenError` reason: `:invalid_realm`.
18
+ * `PublicKeyCachedResolver` caches public keys per-realm, ensuring safe operation in a multi-tenant environment.
19
+
20
+ ## [2.0.1] - 2026-08-02
21
+
22
+ ### Security
23
+
24
+ * `skip_paths` declaring its paths as Strings, e.g. `{ get: ["/health/db"] }`, opened routes that had to be authenticated.
25
+ * A path that is not a `Regexp` is discarded by the middleware rather than matched, and a warning is logged naming it.
26
+ * With `verify_not_before` enabled, an `nbf` claim carried as null was read as an absent one, skipping the very check the option asks for. It is now rejected.
27
+ * A token whose `exp` or `nbf` claim is not a number of seconds is answered a `401`, where `Time.at` used to raise a `TypeError` and answer a `500`. The 2.0.0 guard only checked that `exp` was present, not that it held a NumericDate. New `KeycloakApiRails::TokenError` reason: `:invalid_claim`.
28
+
29
+ ### Fixed
30
+
31
+ * `custom_attributes` declared as Symbols, e.g. `[:tenant_id]`, matched no claim at all: those of a decoded token are keyed by String. Both forms are honoured.
32
+ * `custom_attributes` holding something else than a claim name is reported by `Configuration#validate!`, instead of being silently read from no token.
33
+ * `KeycloakApiRails.configure` discards the service, the public key resolver and the HTTP client it had memoized
34
+
35
+ ### Availability
36
+
37
+ * A request whose token cannot be verified at all, Keycloak being unreachable and no public key having ever been retrieved, is answered a `503` carrying a `Retry-After` header. `KeycloakApiRails::HTTPError` and `KeycloakApiRails::MissingPublicKeysError` used to escape the middleware, and `keycloak_authenticate`, as a `500`: an outage of Keycloak was reported as a bug of the application. A request carrying no token at all is still answered a `401`, and the paths of `skip_paths` are still served.
38
+ * With nothing cached, a Keycloak that is down was called again by every single request, one at a time behind the mutex of the resolver, each waiting for `http_open_timeout` and `http_read_timeout` to elapse: ten concurrent requests held ten threads for a hundred seconds. It is now called once per `FAILED_REFRESH_RETRY_DELAY_IN_SECONDS`, and the error of the last attempt is raised straight away in between.
39
+
8
40
  ## [2.0.0] - 2026-08-01
9
41
 
10
42
  ### Breaking changes
data/README.md CHANGED
@@ -18,7 +18,7 @@ the recommended path.
18
18
  ## Install
19
19
 
20
20
  ```ruby
21
- gem "keycloak-api-rails", "2.0.0"
21
+ gem "keycloak-api-rails", "2.0.2"
22
22
  ```
23
23
 
24
24
  ## Token validation
@@ -61,16 +61,16 @@ All options have a default value. However, all of them can be changed in your in
61
61
  | Option | Default Value | Type | Required? | Description | Example |
62
62
  | ---- | ----- | ------ | ----- | ------ | ----- |
63
63
  | `server_url` | `nil`| String | Required | The base url where your Keycloak server is located. This value can be retrieved in your Keycloak client configuration. | `auth:8080` |
64
- | `realm_id` | `nil`| String | Required | Realm's name (not id, actually) | `master` |
64
+ | `realm_id` | `nil`| String, Array or Proc | Required | Realm's name(s) (not id, actually). Can be a single String, an Array of Strings for multiple tenants, or a Proc for dynamic validation. | `"master"` or `["tenant1", "tenant2"]` |
65
65
  | `logger` | `Logger.new(STDOUT)`| Logger | Optional | The logger used by `keycloak-api-rails` | `Rails.logger` |
66
- | `skip_paths` | `{}`| Hash of methods and paths regexp | Optional | Paths whose token must not be validated | `{ get: [/^\/health\/.+/] }`|
66
+ | `skip_paths` | `{}`| Hash of methods and paths regexp | Optional | Paths whose token must not be validated. Each path must be a `Regexp`. A String is refused when the application boots: `String#match` compiles its argument into a regexp, so the path of the request would become the pattern and other routes would skip authentication | `{ get: [/^\/health\/.+/] }`|
67
67
  | `opt_in` | `false` | Boolean | Optional | When false, every request is validated by the middleware, except the ones matching `skip_paths`. When true, no middleware validates anything and authentication must be requested explicitly, by calling `keycloak_authenticate` from a controller | `true`
68
68
  | `token_expiration_tolerance_in_seconds` | `10`| Integer | Optional | Safety margin: a token is rejected this number of seconds *before* the date of its `exp` claim, so that it cannot expire in the middle of a request | `15` |
69
- | `public_key_cache_ttl` | `86400`| Integer | Optional | Amount of time, in seconds, specifying maximum interval between two requests to Keycloak to retrieve new public keys. It is 86400 seconds (1 day) by default. At least once per this configured interval (1 day by default) will be new public key always downloaded. | `3600` |
70
- | `custom_attributes` | `[]`| Array Of String | Optional | List of token attributes to read from each token and to add to their http request env | `["originalFirstName", "originalLastName"]` |
69
+ | `public_key_cache_ttl` | `86400`| Integer | Optional | Amount of time, in seconds, specifying maximum interval between two requests to Keycloak to retrieve new public keys. It is 86400 seconds (1 day) by default. At least once per this configured interval (1 day by default) will be new public key always downloaded. The refresh happens under a lock and blocks request threads if Keycloak hangs; reducing this TTL increases the frequency of this risk. | `3600` |
70
+ | `custom_attributes` | `[]`| Array of String or Symbol | Optional | List of token attributes to read from each token and to add to their http request env | `["originalFirstName", "originalLastName"]` |
71
71
  | `ca_certificate_file` | `nil`| String | Optional | Path to the certificate authority used to validate the Keycloak server certificate | `/credentials/production_root_ca_cert.pem` |
72
72
  | `expected_audience` | `nil`| String or Array of String | Optional | When set, a token is rejected unless its `aud` claim carries one of these audiences. Left unset, every token signed by the realm is accepted, including its ID tokens and the tokens issued for its other clients | `"my-api"` |
73
- | `expected_token_type` | `nil`| String | Optional | When set, a token is rejected unless its `typ` claim matches. Keycloak types its access tokens `Bearer` | `"Bearer"` |
73
+ | `expected_token_type` | `nil`| String | Optional | When set, a token is rejected unless its `typ` claim matches (case-insensitive). Keycloak types its access tokens `Bearer` | `"Bearer"` |
74
74
  | `verify_not_before` | `false`| Boolean | Optional | When true, a token whose `nbf` claim is in the future is rejected. Disabled by default: a clock skew between Keycloak and the API would reject valid tokens | `true` |
75
75
  | `allow_token_in_query_string` | `false`| Boolean | Optional | When true, a request carrying no `Authorization` header may be authenticated by the `authorizationToken` parameter of its query string | `true` |
76
76
  | `http_open_timeout` | `5`| Integer | Optional | Seconds to wait for the connection to Keycloak to open, when downloading the public keys | `2` |
@@ -129,6 +129,27 @@ Keycloak only adds an API to the `aud` claim of a token once that API is declare
129
129
  `config.verify_not_before = true` additionally rejects a token whose `nbf` claim is in the future.
130
130
  It is disabled by default because a clock skew between Keycloak and the API rejects valid tokens.
131
131
 
132
+ ## Multi-tenancy (Multiple Realms)
133
+
134
+ The library natively supports multi-tenancy by validating tokens issued by multiple Keycloak realms.
135
+ Instead of a static string, you can configure `realm_id` with an Array or a Proc. The library will dynamically extract the realm from the token's `iss` claim, check if it is permitted, and fetch the appropriate public keys for that specific realm.
136
+
137
+ Using an Array of allowed realms:
138
+ ```ruby
139
+ KeycloakApiRails.configure do |config|
140
+ config.server_url = ENV["KEYCLOAK_SERVER_URL"]
141
+ config.realm_id = ["master", "tenant-1", "tenant-2"]
142
+ end
143
+ ```
144
+
145
+ Using a Proc for dynamic validation (e.g., querying the database):
146
+ ```ruby
147
+ KeycloakApiRails.configure do |config|
148
+ config.server_url = ENV["KEYCLOAK_SERVER_URL"]
149
+ config.realm_id = ->(realm) { Tenant.exists?(name: realm) }
150
+ end
151
+ ```
152
+
132
153
  ## Use cases
133
154
 
134
155
  Once this gem is configured in your Rails project, you can read, validate and use tokens in your controllers.
@@ -212,7 +233,7 @@ For instance, to read a provided token:
212
233
  ```ruby
213
234
  class RenderTokenController < ApplicationController
214
235
  def show
215
- uri = request.env["REQUEST_URI"]
236
+ uri = KeycloakApiRails::Helper.request_uri(request.env)
216
237
  headers = request.env
217
238
  token = KeycloakApiRails.service.read_token(uri, headers)
218
239
  render json: { token: token }, status: :ok
@@ -297,13 +318,9 @@ scoped RubyGems credential.
297
318
  3. Tag the commit and push the tag:
298
319
 
299
320
  ```
300
- $ git tag -a v2.0.0 -m "Version 2.0.0"
301
- $ git push origin v2.0.0
321
+ $ git tag -a v2.0.2 -m "Version 2.0.2"
322
+ $ git push origin v2.0.2
302
323
  ```
303
324
 
304
325
  The workflow then checks that the tag matches `KeycloakApiRails::VERSION`, runs the tests, builds the gem
305
326
  and pushes it. It only publishes tags starting with `v`.
306
-
307
- ## Next developments
308
-
309
- * Manage multiple realms
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module KeycloakApiRails
2
4
  module Authentication
3
5
  def self.included(base)
@@ -19,6 +21,8 @@ module KeycloakApiRails
19
21
  authentication_succeeded(env, decoded_token)
20
22
  rescue TokenError => e
21
23
  authentication_failed(e.message)
24
+ rescue KeycloakApiRails::HTTPError, KeycloakApiRails::MissingPublicKeysError => e
25
+ authentication_unavailable(e)
22
26
  end
23
27
 
24
28
  def authentication_failed(message)
@@ -26,6 +30,12 @@ module KeycloakApiRails
26
30
  render status: :unauthorized, json: { error: message }
27
31
  end
28
32
 
33
+ def authentication_unavailable(error)
34
+ KeycloakApiRails.logger.error("KeycloakApiRails: no token can be verified. #{error.class}: #{error.message}")
35
+ response.headers["Retry-After"] = KeycloakApiRails::PublicKeyCachedResolver::FAILED_REFRESH_RETRY_DELAY_IN_SECONDS.to_s
36
+ render status: :service_unavailable, json: { error: "Authentication is temporarily unavailable" }
37
+ end
38
+
29
39
  def authentication_succeeded(env, decoded_token)
30
40
  Helper.assign_token(env, decoded_token, KeycloakApiRails.config.custom_attributes)
31
41
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module KeycloakApiRails
2
4
  class InvalidConfigurationError < StandardError; end
3
5
 
@@ -24,12 +26,11 @@ module KeycloakApiRails
24
26
  errors = []
25
27
 
26
28
  errors.push("'server_url' must be a String or nil, got #{server_url.inspect}") unless server_url.nil? || server_url.is_a?(String)
27
- errors.push("'realm_id' must be a String or nil, got #{realm_id.inspect}") unless realm_id.nil? || realm_id.is_a?(String)
29
+ errors.push("'realm_id' must be a String, an Array of Strings, a Proc, or nil, got #{realm_id.inspect}") unless valid_realm_id?(realm_id)
28
30
  errors.push("'logger' must respond to #{LOGGER_METHODS.join(', ')}") unless LOGGER_METHODS.all? { |method| logger.respond_to?(method) }
29
31
  errors.push("'opt_in' must be true or false, got #{opt_in.inspect}") unless boolean?(opt_in)
30
32
  errors.push("'verify_not_before' must be true or false, got #{verify_not_before.inspect}") unless boolean?(verify_not_before)
31
33
  errors.push("'allow_token_in_query_string' must be true or false, got #{allow_token_in_query_string.inspect}") unless boolean?(allow_token_in_query_string)
32
- errors.push("'custom_attributes' must be an Array of claim names, got #{custom_attributes.inspect}") unless custom_attributes.is_a?(Array)
33
34
  errors.push("'token_expiration_tolerance_in_seconds' must be a number of seconds, got #{token_expiration_tolerance_in_seconds.inspect}") unless number?(token_expiration_tolerance_in_seconds, allow_zero: true)
34
35
  errors.push("'public_key_cache_ttl' must be a positive number of seconds, got #{public_key_cache_ttl.inspect}") unless number?(public_key_cache_ttl)
35
36
  errors.push("'http_open_timeout' must be a positive number of seconds, got #{http_open_timeout.inspect}") unless number?(http_open_timeout)
@@ -37,6 +38,7 @@ module KeycloakApiRails
37
38
  errors.push("'expected_token_type' must be a String or nil, got #{expected_token_type.inspect}") unless expected_token_type.nil? || expected_token_type.is_a?(String)
38
39
  errors.push("'ca_certificate_file' must be the path of a readable file, got #{ca_certificate_file.inspect}") unless ca_certificate_file.nil? || File.readable?(ca_certificate_file.to_s)
39
40
 
41
+ errors.concat(custom_attributes_errors)
40
42
  errors.concat(skip_paths_errors)
41
43
  errors.concat(expected_audience_errors)
42
44
 
@@ -61,13 +63,26 @@ module KeycloakApiRails
61
63
 
62
64
  private
63
65
 
66
+ def custom_attributes_errors
67
+ if custom_attributes.is_a?(Array)
68
+ invalid_names = custom_attributes.reject { |name| name.is_a?(String) || name.is_a?(Symbol) }
69
+ if invalid_names.empty?
70
+ []
71
+ else
72
+ ["'custom_attributes' must only contain claim names, as Strings or Symbols, got #{invalid_names.inspect}"]
73
+ end
74
+ else
75
+ ["'custom_attributes' must be an Array of claim names, got #{custom_attributes.inspect}"]
76
+ end
77
+ end
78
+
64
79
  def skip_paths_errors
65
80
  return ["'skip_paths' must be a Hash of HTTP methods and path regexps, got #{skip_paths.inspect}"] unless skip_paths.is_a?(Hash)
66
81
 
67
82
  skip_paths.filter_map do |method, paths|
68
- next if paths.is_a?(Array) && paths.all? { |path| path.respond_to?(:match) }
83
+ next if paths.is_a?(Array) && paths.all? { |path| path.is_a?(Regexp) }
69
84
 
70
- "'skip_paths[#{method.inspect}]' must be an Array of regexps, got #{paths.inspect}"
85
+ "'skip_paths[#{method.inspect}]' must be an Array of regexps, got #{paths.inspect}. A String is refused because 'String#match' compiles its argument into a regexp: the path of the request would become the pattern, and routes that must be authenticated would be skipped"
71
86
  end
72
87
  end
73
88
 
@@ -91,7 +106,20 @@ module KeycloakApiRails
91
106
  end
92
107
 
93
108
  def missing?(value)
94
- value.nil? || value.to_s.strip.empty?
109
+ if value.is_a?(Array)
110
+ value.empty?
111
+ elsif value.is_a?(String)
112
+ value.strip.empty?
113
+ else
114
+ value.nil?
115
+ end
116
+ end
117
+
118
+ def valid_realm_id?(realm_id)
119
+ realm_id.nil? ||
120
+ realm_id.is_a?(String) ||
121
+ (realm_id.is_a?(Array) && realm_id.all? { |r| r.is_a?(String) }) ||
122
+ realm_id.respond_to?(:call)
95
123
  end
96
124
  end
97
125
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module KeycloakApiRails
2
4
  class Helper
3
5
 
@@ -76,15 +78,18 @@ module KeycloakApiRails
76
78
  end
77
79
 
78
80
  def self.assign_resource_roles(env, token)
79
- env[RESOURCE_ROLES_KEY] = token.fetch("resource_access", {}).inject({}) do |resource_roles, (name, resource_attributes)|
81
+ env[RESOURCE_ROLES_KEY] = token.fetch("resource_access", {}).each_with_object({}) do |(name, resource_attributes), resource_roles|
80
82
  resource_roles[name] = resource_attributes.fetch("roles", [])
81
- resource_roles
82
83
  end
83
84
  end
84
85
 
85
86
  def self.assign_current_user_custom_attributes(env, token, attribute_names)
86
- names = Array(attribute_names)
87
- env[CURRENT_USER_ATTRIBUTES] = token.select { |key, _value| names.include?(key) }
87
+ attributes = {}
88
+ Array(attribute_names).each do |name|
89
+ name_str = name.to_s
90
+ attributes[name_str] = token[name_str] if token.key?(name_str)
91
+ end
92
+ env[CURRENT_USER_ATTRIBUTES] = attributes
88
93
  end
89
94
 
90
95
  def self.current_user_custom_attributes(env)
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module KeycloakApiRails
2
4
  class HTTPError < StandardError
3
5
  attr_reader :status
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module KeycloakApiRails
2
4
 
3
5
  class Middleware
@@ -16,6 +18,9 @@ module KeycloakApiRails
16
18
  rescue TokenError => e
17
19
  logger.debug("The error causing the Token to fail: #{e.original_error&.message || e.message}")
18
20
  return authentication_failed(e.message)
21
+ rescue HTTPError, MissingPublicKeysError => e
22
+ logger.error("KeycloakApiRails: no token can be verified for #{method} : #{path}. #{e.class}: #{e.message}")
23
+ return authentication_unavailable
19
24
  end
20
25
  else
21
26
  logger.debug("Skip authentication for #{method} : #{path}")
@@ -37,6 +42,13 @@ module KeycloakApiRails
37
42
  [401, { "content-type" => "application/json" }, [{ error: message }.to_json]]
38
43
  end
39
44
 
45
+ def authentication_unavailable
46
+ [503,
47
+ { "content-type" => "application/json",
48
+ "retry-after" => PublicKeyCachedResolver::FAILED_REFRESH_RETRY_DELAY_IN_SECONDS.to_s },
49
+ [{ error: "Authentication is temporarily unavailable" }.to_json]]
50
+ end
51
+
40
52
  def service
41
53
  KeycloakApiRails.service
42
54
  end
@@ -1,49 +1,90 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module KeycloakApiRails
2
4
  class PublicKeyCachedResolver
3
5
  FAILED_REFRESH_RETRY_DELAY_IN_SECONDS = 10
4
6
 
5
- attr_reader :cached_public_key_retrieved_at
7
+ class RealmCache
8
+ attr_reader :cached_public_key_retrieved_at
9
+
10
+ def initialize(http_client, realm_id)
11
+ @resolver = PublicKeyResolver.new(http_client, realm_id)
12
+ @cached_public_keys = nil
13
+ @cached_public_key_retrieved_at = nil
14
+ @last_refresh_failure_at = nil
15
+ @last_refresh_error = nil
16
+ @mutex = Mutex.new
17
+ end
18
+
19
+ def find_public_keys(public_key_cache_ttl, logger)
20
+ @mutex.synchronize do
21
+ raise @last_refresh_error if refresh_recently_failed_without_any_cache?
22
+
23
+ refresh_public_keys(logger) if public_keys_are_outdated?(public_key_cache_ttl)
24
+ @cached_public_keys
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def refresh_public_keys(logger)
31
+ @cached_public_keys = @resolver.find_public_keys
32
+ @cached_public_key_retrieved_at = Time.now
33
+ @last_refresh_failure_at = nil
34
+ @last_refresh_error = nil
35
+ rescue StandardError => e
36
+ @last_refresh_failure_at = Time.now
37
+ @last_refresh_error = e
38
+ raise if @cached_public_keys.nil?
39
+
40
+ logger&.warn("KeycloakApiRails: could not refresh the public keys (#{e.class}: #{e.message}). Keeping the ones retrieved at #{@cached_public_key_retrieved_at}.")
41
+ end
42
+
43
+ def refresh_recently_failed_without_any_cache?
44
+ @cached_public_keys.nil? &&
45
+ !@last_refresh_failure_at.nil? &&
46
+ Time.now <= @last_refresh_failure_at + FAILED_REFRESH_RETRY_DELAY_IN_SECONDS
47
+ end
48
+
49
+ def public_keys_are_outdated?(public_key_cache_ttl)
50
+ @cached_public_keys.nil? ||
51
+ @cached_public_key_retrieved_at.nil? ||
52
+ (Time.now > @cached_public_key_retrieved_at + public_key_cache_ttl &&
53
+ (@last_refresh_failure_at.nil? ||
54
+ Time.now > @last_refresh_failure_at + FAILED_REFRESH_RETRY_DELAY_IN_SECONDS))
55
+ end
56
+ end
6
57
 
7
58
  def initialize(http_client, realm_id, public_key_cache_ttl, logger = nil)
8
- @resolver = PublicKeyResolver.new(http_client, realm_id)
9
- @public_key_cache_ttl = public_key_cache_ttl
10
- @logger = logger
11
- @cached_public_keys = nil
12
- @cached_public_key_retrieved_at = nil
13
- @last_refresh_failure_at = nil
14
- @mutex = Mutex.new
59
+ @http_client = http_client
60
+ @realm_id = realm_id
61
+ @public_key_cache_ttl = public_key_cache_ttl
62
+ @logger = logger
63
+ @caches = {}
64
+ @caches_mutex = Mutex.new
15
65
  end
16
66
 
17
67
  def self.from_configuration(http_client, configuration)
18
68
  new(http_client, configuration.realm_id, configuration.public_key_cache_ttl, configuration.logger)
19
69
  end
20
70
 
21
- def find_public_keys
22
- @mutex.synchronize do
23
- refresh_public_keys if public_keys_are_outdated?
24
- @cached_public_keys
25
- end
71
+ def find_public_keys(realm_id = nil)
72
+ target_realm = realm_id || @realm_id
73
+ cache_for(target_realm).find_public_keys(@public_key_cache_ttl, @logger)
26
74
  end
27
75
 
28
- private
29
-
30
- def refresh_public_keys
31
- @cached_public_keys = @resolver.find_public_keys
32
- @cached_public_key_retrieved_at = Time.now
33
- @last_refresh_failure_at = nil
34
- rescue StandardError => e
35
- raise if @cached_public_keys.nil?
36
-
37
- @last_refresh_failure_at = Time.now
38
- @logger&.warn("KeycloakApiRails: could not refresh the public keys (#{e.class}: #{e.message}). Keeping the ones retrieved at #{@cached_public_key_retrieved_at}.")
76
+ # Keep this method backward-compatible for testing
77
+ def cached_public_key_retrieved_at(realm_id = nil)
78
+ target_realm = realm_id || @realm_id
79
+ cache_for(target_realm).cached_public_key_retrieved_at
39
80
  end
40
81
 
41
- def public_keys_are_outdated?
42
- @cached_public_keys.nil? ||
43
- @cached_public_key_retrieved_at.nil? ||
44
- (Time.now > @cached_public_key_retrieved_at + @public_key_cache_ttl &&
45
- (@last_refresh_failure_at.nil? ||
46
- Time.now > @last_refresh_failure_at + FAILED_REFRESH_RETRY_DELAY_IN_SECONDS))
82
+ private
83
+
84
+ def cache_for(realm_id)
85
+ @caches_mutex.synchronize do
86
+ @caches[realm_id] ||= RealmCache.new(@http_client, realm_id)
87
+ end
47
88
  end
48
89
  end
49
90
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module KeycloakApiRails
2
4
  class PublicKeyResolver
3
5
  def initialize(http_client, realm_id)
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module KeycloakApiRails
2
4
  class Railtie < Rails::Railtie
3
5
  railtie_name :keycloak_api_rails
@@ -1,3 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
1
4
  module KeycloakApiRails
2
5
  class MissingPublicKeysError < StandardError; end
3
6
 
@@ -6,7 +9,7 @@ module KeycloakApiRails
6
9
  def initialize(key_resolver)
7
10
  configuration = KeycloakApiRails.config
8
11
  @key_resolver = key_resolver
9
- @skip_paths = normalize_skip_paths(configuration.skip_paths)
12
+ @skip_paths = normalize_skip_paths(configuration.skip_paths, configuration.logger)
10
13
  @opt_in = configuration.opt_in
11
14
  @token_expiration_tolerance_in_seconds = configuration.token_expiration_tolerance_in_seconds
12
15
  @expected_audiences = Array(configuration.expected_audience).map(&:to_s)
@@ -17,18 +20,52 @@ module KeycloakApiRails
17
20
 
18
21
  def decode_and_verify(token)
19
22
  raise TokenError.no_token(token) if token.nil? || token.empty?
23
+
24
+ parts = token.to_s.split('.')
25
+ raise TokenError.invalid_format(token) if parts.length < 3
20
26
 
21
- public_keys = @key_resolver.find_public_keys
27
+ realm_id = extract_realm_from_token(token)
28
+ raise TokenError.invalid_realm(token) unless realm_allowed?(realm_id)
29
+
30
+ public_keys = @key_resolver.find_public_keys(realm_id)
22
31
 
23
32
  if public_keys.nil?
24
33
  raise MissingPublicKeysError, "No Keycloak public key is available to verify the token"
25
34
  end
26
35
 
27
36
  decoded_token = decode(token, public_keys)
28
- verify_claims!(token, decoded_token)
37
+ verify_claims!(token, decoded_token, realm_id)
29
38
  decoded_token
30
39
  end
31
40
 
41
+ def extract_realm_from_token(token)
42
+ payload_segment = token.split('.', 3)[1]
43
+ return nil unless payload_segment
44
+
45
+ decoded_payload = Base64.urlsafe_decode64(payload_segment)
46
+ parsed_payload = JSON.parse(decoded_payload)
47
+ iss = parsed_payload['iss']
48
+ return nil unless iss
49
+
50
+ iss.split('/').last
51
+ rescue JSON::ParserError, ArgumentError
52
+ nil
53
+ end
54
+
55
+ def realm_allowed?(realm_id)
56
+ config_realm_id = KeycloakApiRails.config.realm_id
57
+ return true if config_realm_id.nil?
58
+ return false if realm_id.nil?
59
+
60
+ if config_realm_id.respond_to?(:call)
61
+ config_realm_id.call(realm_id)
62
+ elsif config_realm_id.is_a?(Array)
63
+ config_realm_id.include?(realm_id)
64
+ else
65
+ config_realm_id == realm_id
66
+ end
67
+ end
68
+
32
69
  def read_token(uri, headers)
33
70
  header_token = Helper.read_token_from_headers(headers)
34
71
  if !header_token.empty?
@@ -58,28 +95,50 @@ module KeycloakApiRails
58
95
  raise TokenError.unknown(token, e)
59
96
  end
60
97
 
61
- def verify_claims!(token, decoded_token)
98
+ # RFC 7519 requires 'exp' and 'nbf' to be NumericDates.
99
+ def verify_claims!(token, decoded_token, realm_id)
62
100
  raise TokenError.missing_claim(token, "exp") unless decoded_token.key?("exp")
101
+ raise TokenError.invalid_claim(token, "exp") unless decoded_token["exp"].is_a?(Numeric)
102
+ raise TokenError.invalid_claim(token, "nbf") if not_before_is_invalid?(decoded_token)
63
103
  raise TokenError.expired(token) if expired?(decoded_token)
64
104
  raise TokenError.not_yet_valid(token) if not_yet_valid?(decoded_token)
65
105
  raise TokenError.invalid_audience(token) unless audience_valid?(decoded_token)
66
106
  raise TokenError.invalid_token_type(token) unless token_type_valid?(decoded_token)
107
+
108
+ if KeycloakApiRails.config.server_url
109
+ expected_iss = File.join(KeycloakApiRails.config.server_url.to_s, "realms", realm_id.to_s)
110
+ raise TokenError.invalid_realm(token) unless decoded_token["iss"] == expected_iss
111
+ end
67
112
  end
68
113
 
69
- def normalize_skip_paths(skip_paths)
114
+ def not_before_is_invalid?(token)
115
+ @verify_not_before && token.key?("nbf") && !token["nbf"].is_a?(Numeric)
116
+ end
117
+
118
+ # Anything that is not a regexp is discarded rather than matched: 'String#match' compiles its
119
+ # argument into a regexp, so a String would be matched against the path of the request instead of
120
+ # the other way around, and would open every path that is a sub-pattern of it. The railtie rejects
121
+ # such a configuration when the application boots; a Rack application running without Rails never
122
+ # calls 'validate!', so the paths keep being authenticated here.
123
+ def normalize_skip_paths(skip_paths, logger)
70
124
  (skip_paths || {}).each_with_object({}) do |(method, paths), normalized|
71
- normalized[method.to_s.downcase.to_sym] = Array(paths)
125
+ regexps, discarded = Array(paths).partition { |path| path.is_a?(Regexp) }
126
+
127
+ unless discarded.empty?
128
+ logger&.warn("KeycloakApiRails: 'skip_paths[#{method.inspect}]' declares #{discarded.map(&:inspect).join(', ')}, which are not regexps. They are ignored, and the paths they were meant to open keep being authenticated.")
129
+ end
130
+
131
+ normalized[method.to_s.upcase] = regexps
72
132
  end
73
133
  end
74
134
 
75
135
  def should_skip?(method, path)
76
- skip_paths = @skip_paths[method&.to_s&.downcase&.to_sym]
77
- !skip_paths.nil? && skip_paths.any? { |skip_path| skip_path.match(path) }
136
+ skip_paths = @skip_paths[method]
137
+ !skip_paths.nil? && skip_paths.any? { |skip_path| skip_path.match?(path) }
78
138
  end
79
139
 
80
140
  def is_preflight?(method, headers)
81
- method_symbol = method&.to_s&.downcase&.to_sym
82
- method_symbol == :options && !headers["HTTP_ACCESS_CONTROL_REQUEST_METHOD"].nil?
141
+ method == "OPTIONS" && !headers["HTTP_ACCESS_CONTROL_REQUEST_METHOD"].nil?
83
142
  end
84
143
 
85
144
  def expired?(token)
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # Test helpers for the applications that authenticate their requests with this library.
2
4
  #
3
5
  # This file is *not* loaded by "keycloak-api-rails": it has to be required explicitly, so that
@@ -33,7 +35,7 @@ module KeycloakApiRails
33
35
  @public_keys = public_keys
34
36
  end
35
37
 
36
- def find_public_keys
38
+ def find_public_keys(realm_id = nil)
37
39
  @public_keys
38
40
  end
39
41
  end
@@ -85,6 +87,12 @@ module KeycloakApiRails
85
87
  payload["realm_access"] = { "roles" => roles.map(&:to_s) } unless roles.nil? || roles.empty?
86
88
  payload["resource_access"] = build_resource_access(resource_roles) unless resource_roles.nil? || resource_roles.empty?
87
89
 
90
+ unless claims.key?(:iss) || claims.key?("iss")
91
+ config_realm_id = KeycloakApiRails.config.realm_id
92
+ realm_id = config_realm_id.is_a?(String) ? config_realm_id : "master"
93
+ payload["iss"] = File.join(KeycloakApiRails.config.server_url.to_s, "realms", realm_id)
94
+ end
95
+
88
96
  claims.each { |name, value| payload[name.to_s] = value }
89
97
 
90
98
  JSON::JWT.new(payload).sign(signing_key, ALGORITHM).to_s
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module KeycloakApiRails
2
4
  class TokenError < StandardError
3
5
  attr_reader :token, :reason, :original_error
@@ -13,10 +15,14 @@ module KeycloakApiRails
13
15
  new(token, :verification_failed, "Failed to verify JWT token", original_error)
14
16
  end
15
17
 
16
- def self.invalid_format(token, original_error)
18
+ def self.invalid_format(token, original_error = nil)
17
19
  new(token, :invalid_format, "Wrong JWT Format", original_error)
18
20
  end
19
21
 
22
+ def self.invalid_realm(token)
23
+ new(token, :invalid_realm, "JWT token does not have a valid realm")
24
+ end
25
+
20
26
  def self.no_token(token)
21
27
  new(token, :no_token, "No JWT token provided")
22
28
  end
@@ -41,6 +47,10 @@ module KeycloakApiRails
41
47
  new(token, :missing_claim, "JWT token does not carry the mandatory claim '#{claim}'")
42
48
  end
43
49
 
50
+ def self.invalid_claim(token, claim)
51
+ new(token, :invalid_claim, "JWT token carries an invalid '#{claim}' claim: it must be a number of seconds since the Epoch")
52
+ end
53
+
44
54
  def self.unknown(token, original_error)
45
55
  new(token, :unknown, "Failed to read JWT token", original_error)
46
56
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module KeycloakApiRails
2
- VERSION = "2.0.0"
4
+ VERSION = "2.0.2"
3
5
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "logger"
2
4
  require "json/jwt"
3
5
  require "uri"
@@ -24,8 +26,18 @@ module KeycloakApiRails
24
26
  MONITOR = Monitor.new
25
27
 
26
28
  def self.configure
27
- MONITOR.synchronize { yield @configuration ||= KeycloakApiRails::Configuration.new }
29
+ MONITOR.synchronize do
30
+ yield @configuration ||= KeycloakApiRails::Configuration.new
31
+ discard_configured_objects
32
+ end
33
+ end
34
+
35
+ def self.discard_configured_objects
36
+ @http_client = nil
37
+ @public_key_resolver = nil unless @public_key_resolver_assigned
38
+ @service = nil
28
39
  end
40
+ private_class_method :discard_configured_objects
29
41
 
30
42
  def self.config
31
43
  @configuration
@@ -44,8 +56,9 @@ module KeycloakApiRails
44
56
  # a reference to the resolver that is being replaced.
45
57
  def self.public_key_resolver=(resolver)
46
58
  MONITOR.synchronize do
47
- @public_key_resolver = resolver
48
- @service = nil
59
+ @public_key_resolver = resolver
60
+ @public_key_resolver_assigned = !resolver.nil?
61
+ @service = nil
49
62
  end
50
63
  end
51
64
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: keycloak-api-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lorent Lempereur