keycloak-api-rails 2.0.2 → 2.0.4

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: 1e80ef54a212cf523766b3d1f0a4d836dd4f32c59322eb9b90a13e0e530bff08
4
- data.tar.gz: c6757d2a355db048389b3c697557daea96e912eea4d2fbeea413b78bd888ce03
3
+ metadata.gz: e074e3c78c789139c8eeaf6a127dceaf0c7ff7235c1134f3fa0ea3c4149b277a
4
+ data.tar.gz: 6b4a5b9faaa16ab883a25c03e5eb31a0c8740f80b784812d6f47eb7aae117eb3
5
5
  SHA512:
6
- metadata.gz: 2e46089640ad0a1d9f14e08e383a5f651431f533d88e9444c809d8d4b55edc91460e668b1ff21e23e863583b4fd956056fb82b0ea9dc5e16ea34cccb268ad2cf
7
- data.tar.gz: e4bfba00be03edd43b60394146bdad990b14eeb731d7e878bdf8944c00e92986278b4c11ca721fa99cd309c54289ee21e0b5199c4f357b4e614691e9d1b6b3b6
6
+ metadata.gz: 9472f08064d6a3e3ba64b781f089d2a42861b26323f9eba3a752ec63d41e54f32288b82ed0fa1b12e46418e9c1771351c7be8676476303162e88a28d676655bd
7
+ data.tar.gz: 0f106f193ceaa023611b9128910b5f944ebfe213c7b7fc1237f7a2e49c55c9b76564ac84ef080e3a4c36f48bcb463da434756d00af5f370cc07a5e8842c5c0f6
data/CHANGELOG.md CHANGED
@@ -5,15 +5,41 @@ 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]
8
+ ## [2.0.4] - 2026-08-03
9
+
10
+ ### Added
11
+
12
+ * New `issuer_url` option: the `iss` claim is checked against it instead of `server_url` when set, for setups where the API reaches Keycloak through a different address than the one Keycloak advertises to its clients (e.g. an internal Docker hostname vs. a public one).
13
+
14
+ ## [2.0.3] - 2026-08-03
15
+
16
+ ### Security
17
+
18
+ * The realm named by the `iss` claim is read before the signature of the token is verified, and is interpolated in the URL its public keys are downloaded from. It is now refused unless it is a plain name: the unreserved characters of RFC 3986, at most 128 of them, and not a relative path segment. `https://keycloak/realms/master?` and `https://keycloak/realms/..` both are the last segment of a well-formed `iss`, and both used to build another URL than the one of the realm they name. Such a token is answered a `401`, reason `:invalid_realm`, and no request is sent to Keycloak.
19
+ * A token whose payload is not a JSON object, or whose `iss` claim is not a String, is answered a `401`. Reading such a payload used to raise a `NoMethodError` or a `TypeError` out of the middleware, and to be answered a `500`.
20
+ * The number of realms whose public keys are cached is now bounded.
21
+ * A token was verified with whichever algorithm its `alg` header named. New `allowed_algorithms` option, defaulting to the asymmetric algorithms Keycloak signs with, and narrowable to the one of the realm. Symmetric algorithms are refused.
22
+
23
+ * A `skip_paths` regexp anchored with `^` or `$` is warned about: in Ruby they match the beginning and the end of a line, and `PATH_INFO` is URL-decoded, so `"/private\n/health"` matches `/^\/health/` and skips authentication. Anchor with `\A` and `\z`.
24
+
25
+ ### Fixed
26
+
27
+ * `base64` and `logger` are declared as runtime dependencies.
28
+ * A `401` carries the `WWW-Authenticate: Bearer` header RFC 6750 requires of a resource protected by a Bearer token. A request carrying no token at all gets the bare challenge; any other gets `error="invalid_token"` and the reason it was refused.
9
29
 
10
30
  ### Performance
11
31
 
32
+ * An authenticated request took four locks to read two memoized objects: `KeycloakApiRails.service`, resolved three times per request, and the per-realm key cache. They are read without locking once memoized, and the middleware resolves the service once. Worth 0.3 µs per request, against the 50 µs of the RSA verification below.
33
+ * Every authenticated request verified the signature of its token twice: `JSON::JWT.decode` already verifies the one it is given a key for. One RSA verification per request instead of two.
12
34
  * 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
35
 
14
36
  ### Added
15
37
 
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.
38
+ * CI checks the dependencies against the Ruby advisory database on every push, with `bundler-audit`, and Dependabot keeps the actions of the workflows up to date.
39
+ * Integration tests ensuring E2E compatibility with a real Keycloak server using Docker.
40
+ * Automated tests in CI against multiple Keycloak versions (19.0.3, 22.0.5, 25.0.0, 26.7.0).
41
+ * Enforced that the Rake `release` task cannot be executed locally without passing the test suite first.
42
+ * Multi-tenancy support: `realm_id` can now be a String, an Array of Strings, or a Proc (e.g. `->(realm) { Tenant.exists?(name: realm) }`) receiving the realm named by the token and answering whether it is allowed.
17
43
  * 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
44
  * `PublicKeyCachedResolver` caches public keys per-realm, ensuring safe operation in a multi-tenant environment.
19
45
 
data/README.md CHANGED
@@ -18,8 +18,7 @@ the recommended path.
18
18
  ## Install
19
19
 
20
20
  ```ruby
21
- gem "keycloak-api-rails", "2.0.2"
22
- ```
21
+ gem "keycloak-api-rails", "2.0.4"
23
22
 
24
23
  ## Token validation
25
24
 
@@ -36,7 +35,7 @@ _If both methods are used at the same time, the `Authorization` header takes pre
36
35
 
37
36
  By default, Keycloak-api-rails installs as a Rack Middleware. It processes all requests before any application logic. URIs/Paths can be excluded (opted-out) from this validation using the 'skip_paths' config option
38
37
 
39
- Alternatively, it can be configured to `opt-in` to validation. In this case, no Rack middleware is used, and controllers can request (opt-in) by including the module `KeycloakApiRails::Authentication` and calling `keycloak_authenticate`, for example in a `before_action`, like so:
38
+ Alternatively, it can be configured to `opt-in` to validation. The middleware is still installed and controllers request (opt-in) by including the module `KeycloakApiRails::Authentication` and calling `keycloak_authenticate`, for example in a `before_action`, like so:
40
39
 
41
40
  ```ruby
42
41
  class MyApiController < ActionController::Base
@@ -60,10 +59,11 @@ All options have a default value. However, all of them can be changed in your in
60
59
 
61
60
  | Option | Default Value | Type | Required? | Description | Example |
62
61
  | ---- | ----- | ------ | ----- | ------ | ----- |
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, 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"]` |
62
+ | `server_url` | `nil`| String | Required | The base url where your Keycloak server is located, scheme included. Without one, the url cannot be parsed and no public key is ever downloaded | `https://keycloak.example.org` or `http://auth:8080` |
63
+ | `issuer_url` | `server_url`| String | Optional | The base url expected in a token's `iss` claim, when it differs from `server_url`. Needed when the API reaches Keycloak through a different address than the one Keycloak advertises to its clients, e.g. an internal Docker hostname vs. a public one | `https://keycloak.example.org` |
64
+ | `realm_id` | `nil`| String, Array or Proc | Required | Realm's name(s) (not id, actually). A single String, an Array of Strings for multiple tenants, or a Proc receiving the realm named by the token and answering whether it is allowed | `"master"`, `["tenant1", "tenant2"]` or `->(realm) { Tenant.exists?(name: realm) }` |
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. 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\/.+/] }`|
66
+ | `skip_paths` | `{}`| Hash of methods and paths regexp | Optional | Paths whose token must not be validated, matched against the URL-decoded path of the request. Each path must be a `Regexp`, anchored with `\A` and `\z`: a String is refused when the application boots, and `^` and `$` are warned about, for the reasons given below | `{ get: [/\A\/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
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` |
@@ -71,6 +71,7 @@ All options have a default value. However, all of them can be changed in your in
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
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
+ | `allowed_algorithms` | every asymmetric algorithm Keycloak signs with: `[:RS256, :RS384, :RS512, :PS256, :PS384, :PS512, :ES256, :ES384, :ES512]` | Array of String or Symbol | Optional | The algorithms a token may be signed with. Narrow it down to the one of the realm, so that the `alg` header of a token cannot pick how its own signature is verified. Symmetric algorithms are refused: a token checked against a JWKS is checked against a public key, and a key that verifies is a key that signs | `[:RS256]` |
74
75
  | `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
76
  | `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
77
  | `http_open_timeout` | `5`| Integer | Optional | Seconds to wait for the connection to Keycloak to open, when downloading the public keys | `2` |
@@ -90,12 +91,19 @@ KeycloakApiRails.configure do |config|
90
91
  config.realm_id = ENV["KEYCLOAK_REALM_ID"]
91
92
  config.logger = Rails.logger
92
93
  config.skip_paths = {
93
- post: [/^\/message/],
94
- get: [/^\/locales/, /^\/health\/.+/]
94
+ post: [/\A\/message/],
95
+ get: [/\A\/locales/, /\A\/health\/.+/]
95
96
  }
96
97
  end
97
98
  ```
98
99
 
100
+ Anchor those regexps with `\A` and `\z`, never with `^` and `$`. In Ruby, `^` and `$` match the
101
+ beginning and the end of a *line*, not of the string. The path they are matched against is
102
+ `PATH_INFO`, which the server has already URL-decoded, so a `%0a` in the request reaches them as a
103
+ real newline: `"/private\n/health"` matches `/^\/health/` and skips authentication on a path that
104
+ was never meant to be open. A regexp anchored with `^` or `$` is warned about when the first request
105
+ is served.
106
+
99
107
  Or using opt-in configuration:
100
108
 
101
109
  ```ruby
@@ -129,11 +137,28 @@ Keycloak only adds an API to the `aud` claim of a token once that API is declare
129
137
  `config.verify_not_before = true` additionally rejects a token whose `nbf` claim is in the future.
130
138
  It is disabled by default because a clock skew between Keycloak and the API rejects valid tokens.
131
139
 
140
+ The `iss` claim is checked against `issuer_url`, or `server_url` when `issuer_url` is not configured, only when one of the two is set. A test environment replacing the public key resolver, as `keycloak-api-rails/testing` does, accepts any issuer.
141
+
142
+ ## When Keycloak is reached through a different address than its own
143
+
144
+ `server_url` is used both to download public keys and, absent `issuer_url`, to check the `iss` claim. Those are two different needs: the former must be an address the API can reach, the latter must match what Keycloak put in the token, which is whatever address its own clients used. Behind a reverse proxy, or from inside Docker, these are often not the same:
145
+
146
+ ```ruby
147
+ KeycloakApiRails.configure do |config|
148
+ config.server_url = "http://keycloak:8080" # reachable from the API container
149
+ config.issuer_url = "https://keycloak.example.org" # what Keycloak's own hostname config puts in `iss`
150
+ config.realm_id = ENV["KEYCLOAK_REALM_ID"]
151
+ end
152
+ ```
153
+
132
154
  ## Multi-tenancy (Multiple Realms)
133
155
 
134
156
  The library natively supports multi-tenancy by validating tokens issued by multiple Keycloak realms.
135
157
  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
158
 
159
+ Those realms all live under the same `server_url` (or `issuer_url`, when configured): a token whose `iss` names another server is rejected, however allowed the realm it names.
160
+ Declaring `expected_audience` matters all the more here — without it, a token issued for any client of any allowed realm opens the API.
161
+
137
162
  Using an Array of allowed realms:
138
163
  ```ruby
139
164
  KeycloakApiRails.configure do |config|
@@ -301,9 +326,23 @@ Assigning `KeycloakApiRails.public_key_resolver = nil` restores the regular reso
301
326
 
302
327
  From the `keycloak-rails-api` directory:
303
328
 
329
+ ```
330
+ $ bundle exec rspec
331
+ ```
332
+
333
+ The examples of `spec/integration` are excluded: they start a Keycloak container, and without Docker
334
+ they wait for a server that never answers. Run them, against the Keycloak version of your choice,
335
+ with:
336
+
337
+ ```
338
+ $ KEYCLOAK_INTEGRATION=1 KEYCLOAK_VERSION=26.7.0 bundle exec rspec spec/integration
339
+ ```
340
+
341
+ Or run everything in a container, which needs a Docker socket of its own to reach Keycloak:
342
+
304
343
  ```
305
344
  $ docker build . -t keycloak-rails-api:test
306
- $ docker run -v `pwd`:/usr/src/app/ keycloak-rails-api:test bundle exec rspec spec
345
+ $ docker run -v `pwd`:/usr/src/app/ keycloak-rails-api:test bundle exec rspec
307
346
  ```
308
347
 
309
348
  ## How to release a new version
@@ -318,8 +357,8 @@ scoped RubyGems credential.
318
357
  3. Tag the commit and push the tag:
319
358
 
320
359
  ```
321
- $ git tag -a v2.0.2 -m "Version 2.0.2"
322
- $ git push origin v2.0.2
360
+ $ git tag -a v2.0.4 -m "Version 2.0.4"
361
+ $ git push origin v2.0.4
323
362
  ```
324
363
 
325
364
  The workflow then checks that the tag matches `KeycloakApiRails::VERSION`, runs the tests, builds the gem
@@ -31,10 +31,14 @@ Gem::Specification.new do |spec|
31
31
  spec.add_dependency "railties", ">= 4.2"
32
32
  spec.add_dependency "json-jwt", ">= 1.11.0"
33
33
 
34
+ spec.add_dependency "base64"
35
+ spec.add_dependency "logger"
36
+
34
37
  spec.add_development_dependency "rspec", "3.13.2"
35
38
  spec.add_development_dependency "timecop", "0.9.11"
36
39
  spec.add_development_dependency "rails", ">= 4.2"
37
40
  spec.add_development_dependency "rack"
38
41
  spec.add_development_dependency "rake", ">= 13.0"
39
42
  spec.add_development_dependency "byebug", ">= 11.1.3"
43
+ spec.add_development_dependency "bundler-audit", ">= 0.9"
40
44
  end
@@ -16,10 +16,12 @@ module KeycloakApiRails
16
16
  path = env["PATH_INFO"]
17
17
 
18
18
  KeycloakApiRails.logger.debug("Start authentication for #{method} : #{path}")
19
- token = KeycloakApiRails.service.read_token(Helper.request_uri(env), env)
20
- decoded_token = KeycloakApiRails.service.decode_and_verify(token)
19
+ service = KeycloakApiRails.service
20
+ token = service.read_token(Helper.request_uri(env), env)
21
+ decoded_token = service.decode_and_verify(token)
21
22
  authentication_succeeded(env, decoded_token)
22
23
  rescue TokenError => e
24
+ response.headers["WWW-Authenticate"] = e.challenge
23
25
  authentication_failed(e.message)
24
26
  rescue KeycloakApiRails::HTTPError, KeycloakApiRails::MissingPublicKeysError => e
25
27
  authentication_unavailable(e)
@@ -7,6 +7,7 @@ module KeycloakApiRails
7
7
  LOGGER_METHODS = [:debug, :info, :warn, :error].freeze
8
8
 
9
9
  attr_accessor :server_url
10
+ attr_accessor :issuer_url
10
11
  attr_accessor :realm_id
11
12
  attr_accessor :skip_paths
12
13
  attr_accessor :opt_in
@@ -21,11 +22,13 @@ module KeycloakApiRails
21
22
  attr_accessor :allow_token_in_query_string
22
23
  attr_accessor :http_open_timeout
23
24
  attr_accessor :http_read_timeout
25
+ attr_accessor :allowed_algorithms
24
26
 
25
27
  def validate!
26
28
  errors = []
27
29
 
28
30
  errors.push("'server_url' must be a String or nil, got #{server_url.inspect}") unless server_url.nil? || server_url.is_a?(String)
31
+ errors.push("'issuer_url' must be a String or nil, got #{issuer_url.inspect}") unless issuer_url.nil? || issuer_url.is_a?(String)
29
32
  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)
30
33
  errors.push("'logger' must respond to #{LOGGER_METHODS.join(', ')}") unless LOGGER_METHODS.all? { |method| logger.respond_to?(method) }
31
34
  errors.push("'opt_in' must be true or false, got #{opt_in.inspect}") unless boolean?(opt_in)
@@ -41,6 +44,7 @@ module KeycloakApiRails
41
44
  errors.concat(custom_attributes_errors)
42
45
  errors.concat(skip_paths_errors)
43
46
  errors.concat(expected_audience_errors)
47
+ errors.concat(allowed_algorithms_errors)
44
48
 
45
49
  raise InvalidConfigurationError, "Invalid Keycloak configuration: #{errors.join('; ')}" unless errors.empty?
46
50
 
@@ -97,6 +101,19 @@ module KeycloakApiRails
97
101
  end
98
102
  end
99
103
 
104
+ def allowed_algorithms_errors
105
+ unless allowed_algorithms.is_a?(Array) && !allowed_algorithms.empty?
106
+ return ["'allowed_algorithms' must be a non-empty Array of algorithm names, got #{allowed_algorithms.inspect}"]
107
+ end
108
+
109
+ unsupported = allowed_algorithms.reject { |algorithm| Service::SUPPORTED_ALGORITHMS.include?(algorithm.to_s.to_sym) }
110
+ if unsupported.empty?
111
+ []
112
+ else
113
+ ["'allowed_algorithms' declares #{unsupported.inspect}, which this library does not verify. It accepts #{Service::SUPPORTED_ALGORITHMS.inspect}"]
114
+ end
115
+ end
116
+
100
117
  def boolean?(value)
101
118
  value == true || value == false
102
119
  end
@@ -8,16 +8,17 @@ module KeycloakApiRails
8
8
  end
9
9
 
10
10
  def call(env)
11
- method = env["REQUEST_METHOD"]
12
- path = env["PATH_INFO"]
11
+ method = env["REQUEST_METHOD"]
12
+ path = env["PATH_INFO"]
13
+ service = KeycloakApiRails.service
13
14
 
14
15
  if service.need_middleware_authentication?(method, path, env)
15
16
  logger.debug("Start authentication for #{method} : #{path}")
16
17
  begin
17
- authenticate(env)
18
+ authenticate(service, env)
18
19
  rescue TokenError => e
19
20
  logger.debug("The error causing the Token to fail: #{e.original_error&.message || e.message}")
20
- return authentication_failed(e.message)
21
+ return authentication_failed(e)
21
22
  rescue HTTPError, MissingPublicKeysError => e
22
23
  logger.error("KeycloakApiRails: no token can be verified for #{method} : #{path}. #{e.class}: #{e.message}")
23
24
  return authentication_unavailable
@@ -31,15 +32,18 @@ module KeycloakApiRails
31
32
 
32
33
  private
33
34
 
34
- def authenticate(env)
35
+ def authenticate(service, env)
35
36
  token = service.read_token(Helper.request_uri(env), env)
36
37
  decoded_token = service.decode_and_verify(token)
37
38
  Helper.assign_token(env, decoded_token, config.custom_attributes)
38
39
  end
39
40
 
40
- def authentication_failed(message)
41
+ def authentication_failed(error)
41
42
  # Rack 3 requires header names to be lowercase.
42
- [401, { "content-type" => "application/json" }, [{ error: message }.to_json]]
43
+ [401,
44
+ { "content-type" => "application/json",
45
+ "www-authenticate" => error.challenge },
46
+ [{ error: error.message }.to_json]]
43
47
  end
44
48
 
45
49
  def authentication_unavailable
@@ -49,10 +53,6 @@ module KeycloakApiRails
49
53
  [{ error: "Authentication is temporarily unavailable" }.to_json]]
50
54
  end
51
55
 
52
- def service
53
- KeycloakApiRails.service
54
- end
55
-
56
56
  def logger
57
57
  KeycloakApiRails.logger
58
58
  end
@@ -3,6 +3,7 @@
3
3
  module KeycloakApiRails
4
4
  class PublicKeyCachedResolver
5
5
  FAILED_REFRESH_RETRY_DELAY_IN_SECONDS = 10
6
+ MAX_CACHED_REALMS = 64
6
7
 
7
8
  class RealmCache
8
9
  attr_reader :cached_public_key_retrieved_at
@@ -58,10 +59,12 @@ module KeycloakApiRails
58
59
  def initialize(http_client, realm_id, public_key_cache_ttl, logger = nil)
59
60
  @http_client = http_client
60
61
  @realm_id = realm_id
62
+ @configured_realms = configured_realms(realm_id)
61
63
  @public_key_cache_ttl = public_key_cache_ttl
62
64
  @logger = logger
63
65
  @caches = {}
64
66
  @caches_mutex = Mutex.new
67
+ @refusal_logged = false
65
68
  end
66
69
 
67
70
  def self.from_configuration(http_client, configuration)
@@ -69,22 +72,49 @@ module KeycloakApiRails
69
72
  end
70
73
 
71
74
  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)
75
+ cache_for(realm_id || default_realm).find_public_keys(@public_key_cache_ttl, @logger)
74
76
  end
75
77
 
76
78
  # Keep this method backward-compatible for testing
77
79
  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
80
+ cache_for(realm_id || default_realm).cached_public_key_retrieved_at
80
81
  end
81
82
 
82
83
  private
83
84
 
85
+ def default_realm
86
+ @realm_id if @realm_id.is_a?(String)
87
+ end
88
+
89
+ def configured_realms(realm_id)
90
+ case realm_id
91
+ when String then [realm_id].freeze
92
+ when Array then realm_id.dup.freeze
93
+ end
94
+ end
95
+
84
96
  def cache_for(realm_id)
85
- @caches_mutex.synchronize do
86
- @caches[realm_id] ||= RealmCache.new(@http_client, realm_id)
97
+ @caches[realm_id] || @caches_mutex.synchronize { @caches[realm_id] || create_cache(realm_id) }
98
+ end
99
+
100
+ def create_cache(realm_id)
101
+ refuse(realm_id, "it is not one of the configured realms") unless cacheable?(realm_id)
102
+ refuse(realm_id, "#{MAX_CACHED_REALMS} realms are already cached") if @caches.size >= MAX_CACHED_REALMS
103
+
104
+ @caches[realm_id] = RealmCache.new(@http_client, realm_id)
105
+ end
106
+
107
+ def cacheable?(realm_id)
108
+ !realm_id.nil? && (@configured_realms.nil? || @configured_realms.include?(realm_id))
109
+ end
110
+
111
+ def refuse(realm_id, reason)
112
+ unless @refusal_logged
113
+ @refusal_logged = true
114
+ @logger&.warn("KeycloakApiRails: no public key is downloaded for the realm #{realm_id.inspect}, #{reason}. The requests naming it are answered a 503. Further refusals are not logged.")
87
115
  end
116
+
117
+ raise MissingPublicKeysError, "No Keycloak public key can be downloaded for the realm #{realm_id.inspect}"
88
118
  end
89
119
  end
90
120
  end
@@ -5,6 +5,8 @@ module KeycloakApiRails
5
5
  class MissingPublicKeysError < StandardError; end
6
6
 
7
7
  class Service
8
+ REALM_NAME = /\A(?!\.+\z)[A-Za-z0-9._~-]{1,128}\z/.freeze
9
+ SUPPORTED_ALGORITHMS = %i[RS256 RS384 RS512 PS256 PS384 PS512 ES256 ES384 ES512].freeze
8
10
 
9
11
  def initialize(key_resolver)
10
12
  configuration = KeycloakApiRails.config
@@ -16,6 +18,7 @@ module KeycloakApiRails
16
18
  @expected_token_type = configuration.expected_token_type
17
19
  @verify_not_before = configuration.verify_not_before
18
20
  @allow_token_in_query_string = configuration.allow_token_in_query_string
21
+ @allowed_algorithms = Array(configuration.allowed_algorithms).map(&:to_sym)
19
22
  end
20
23
 
21
24
  def decode_and_verify(token)
@@ -43,11 +46,21 @@ module KeycloakApiRails
43
46
  return nil unless payload_segment
44
47
 
45
48
  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
49
+ parsed_payload = JSON.parse(decoded_payload)
50
+
51
+ if parsed_payload.is_a?(Hash)
52
+ iss = parsed_payload['iss']
53
+ return nil unless iss.is_a?(String)
54
+
55
+ realm_id = iss.split('/').last
56
+ if realm_id&.match?(REALM_NAME)
57
+ realm_id
58
+ else
59
+ nil
60
+ end
61
+ else
62
+ nil
63
+ end
51
64
  rescue JSON::ParserError, ArgumentError
52
65
  nil
53
66
  end
@@ -84,9 +97,7 @@ module KeycloakApiRails
84
97
  private
85
98
 
86
99
  def decode(token, public_keys)
87
- decoded_token = JSON::JWT.decode(token, public_keys)
88
- decoded_token.verify!(public_keys)
89
- decoded_token
100
+ JSON::JWT.decode(token, public_keys, @allowed_algorithms)
90
101
  rescue JSON::JWT::VerificationFailed, JSON::JWK::Set::KidNotFound => e
91
102
  raise TokenError.verification_failed(token, e)
92
103
  rescue JSON::JWT::InvalidFormat => e
@@ -105,8 +116,9 @@ module KeycloakApiRails
105
116
  raise TokenError.invalid_audience(token) unless audience_valid?(decoded_token)
106
117
  raise TokenError.invalid_token_type(token) unless token_type_valid?(decoded_token)
107
118
 
108
- if KeycloakApiRails.config.server_url
109
- expected_iss = File.join(KeycloakApiRails.config.server_url.to_s, "realms", realm_id.to_s)
119
+ issuer_url = KeycloakApiRails.config.issuer_url || KeycloakApiRails.config.server_url
120
+ if issuer_url
121
+ expected_iss = File.join(issuer_url.to_s, "realms", realm_id.to_s)
110
122
  raise TokenError.invalid_realm(token) unless decoded_token["iss"] == expected_iss
111
123
  end
112
124
  end
@@ -128,10 +140,20 @@ module KeycloakApiRails
128
140
  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
141
  end
130
142
 
143
+ line_anchored = regexps.select { |regexp| line_anchored?(regexp) }
144
+ unless line_anchored.empty?
145
+ logger&.warn("KeycloakApiRails: 'skip_paths[#{method.inspect}]' declares #{line_anchored.map(&:inspect).join(', ')}, anchored with '^' or '$'. In Ruby those match the beginning and the end of a line, not of the path: \"/private\\n/health\" matches /^\\/health/ and skips authentication. Anchor with '\\A' and '\\z'.")
146
+ end
147
+
131
148
  normalized[method.to_s.upcase] = regexps
132
149
  end
133
150
  end
134
151
 
152
+ # Escaped pairs are dropped first, so that '\^' does not count and '[^/]' is read as a class.
153
+ def line_anchored?(regexp)
154
+ regexp.source.gsub(/\\./, "").gsub(/\[[^\]]*\]/, "").match?(/[\^$]/)
155
+ end
156
+
135
157
  def should_skip?(method, path)
136
158
  skip_paths = @skip_paths[method]
137
159
  !skip_paths.nil? && skip_paths.any? { |skip_path| skip_path.match?(path) }
@@ -90,7 +90,8 @@ module KeycloakApiRails
90
90
  unless claims.key?(:iss) || claims.key?("iss")
91
91
  config_realm_id = KeycloakApiRails.config.realm_id
92
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)
93
+ issuer_url = KeycloakApiRails.config.issuer_url || KeycloakApiRails.config.server_url
94
+ payload["iss"] = File.join(issuer_url.to_s, "realms", realm_id)
94
95
  end
95
96
 
96
97
  claims.each { |name, value| payload[name.to_s] = value }
@@ -11,6 +11,15 @@ module KeycloakApiRails
11
11
  @original_error = original_error
12
12
  end
13
13
 
14
+ # RFC 6750: a request that carried no credentials at all gets the bare challenge, no error code.
15
+ def challenge
16
+ if reason == :no_token
17
+ "Bearer"
18
+ else
19
+ %(Bearer error="invalid_token", error_description="#{message.gsub(/["\\]/, '')}")
20
+ end
21
+ end
22
+
14
23
  def self.verification_failed(token, original_error)
15
24
  new(token, :verification_failed, "Failed to verify JWT token", original_error)
16
25
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module KeycloakApiRails
4
- VERSION = "2.0.2"
4
+ VERSION = "2.0.4"
5
5
  end
@@ -23,6 +23,11 @@ module KeycloakApiRails
23
23
  # These objects are memoized lazily, on the first request each process serves -- which several
24
24
  # threads of a threaded server reach at the same time. A Monitor rather than a Mutex: the
25
25
  # memoizations nest, 'service' needing 'public_key_resolver', which needs 'http_client'.
26
+ #
27
+ # Once memoized they are read without taking it: the assignment publishes an object that is fully
28
+ # built, and MRI reads an instance variable atomically. A 'configure' running while requests are
29
+ # already being served may then be picked up one request late, which is one more reason for it to
30
+ # belong in an initializer.
26
31
  MONITOR = Monitor.new
27
32
 
28
33
  def self.configure
@@ -44,11 +49,11 @@ module KeycloakApiRails
44
49
  end
45
50
 
46
51
  def self.http_client
47
- MONITOR.synchronize { @http_client ||= KeycloakApiRails::HTTPClient.new(config, logger) }
52
+ @http_client || MONITOR.synchronize { @http_client ||= KeycloakApiRails::HTTPClient.new(config, logger) }
48
53
  end
49
54
 
50
55
  def self.public_key_resolver
51
- MONITOR.synchronize { @public_key_resolver ||= PublicKeyCachedResolver.from_configuration(http_client, config) }
56
+ @public_key_resolver || MONITOR.synchronize { @public_key_resolver ||= PublicKeyCachedResolver.from_configuration(http_client, config) }
52
57
  end
53
58
 
54
59
  # Mainly used by "keycloak-api-rails/testing" to validate tokens without a Keycloak server.
@@ -63,7 +68,7 @@ module KeycloakApiRails
63
68
  end
64
69
 
65
70
  def self.service
66
- MONITOR.synchronize { @service ||= KeycloakApiRails::Service.new(public_key_resolver) }
71
+ @service || MONITOR.synchronize { @service ||= KeycloakApiRails::Service.new(public_key_resolver) }
67
72
  end
68
73
 
69
74
  def self.logger
@@ -73,6 +78,7 @@ module KeycloakApiRails
73
78
  def self.load_configuration
74
79
  configure do |config|
75
80
  config.server_url = nil
81
+ config.issuer_url = nil
76
82
  config.realm_id = nil
77
83
  config.logger = ::Logger.new(STDOUT)
78
84
  config.skip_paths = {}
@@ -87,6 +93,7 @@ module KeycloakApiRails
87
93
  config.allow_token_in_query_string = false
88
94
  config.http_open_timeout = 5
89
95
  config.http_read_timeout = 5
96
+ config.allowed_algorithms = KeycloakApiRails::Service::SUPPORTED_ALGORITHMS
90
97
  end
91
98
  end
92
99
 
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.2
4
+ version: 2.0.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lorent Lempereur
@@ -37,6 +37,34 @@ dependencies:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
39
  version: 1.11.0
40
+ - !ruby/object:Gem::Dependency
41
+ name: base64
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: logger
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
40
68
  - !ruby/object:Gem::Dependency
41
69
  name: rspec
42
70
  requirement: !ruby/object:Gem::Requirement
@@ -121,6 +149,20 @@ dependencies:
121
149
  - - ">="
122
150
  - !ruby/object:Gem::Version
123
151
  version: 11.1.3
152
+ - !ruby/object:Gem::Dependency
153
+ name: bundler-audit
154
+ requirement: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - ">="
157
+ - !ruby/object:Gem::Version
158
+ version: '0.9'
159
+ type: :development
160
+ prerelease: false
161
+ version_requirements: !ruby/object:Gem::Requirement
162
+ requirements:
163
+ - - ">="
164
+ - !ruby/object:Gem::Version
165
+ version: '0.9'
124
166
  description: Rack middleware that validates the Keycloak JWT access token carried
125
167
  by every request of a Ruby on Rails API. It verifies the token signature against
126
168
  the realm public keys, exposes the authenticated user, its roles and the custom