keycloak-api-rails 2.0.2 → 2.0.3

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: 75cb985d142693a50337445bdde2608ee636c3ce77327e577a88f1d3ad6f12d7
4
+ data.tar.gz: a9220359016e6c96f594d2e43485bb2e5c2821da9d2055c6c622eef26fcdf33f
5
5
  SHA512:
6
- metadata.gz: 2e46089640ad0a1d9f14e08e383a5f651431f533d88e9444c809d8d4b55edc91460e668b1ff21e23e863583b4fd956056fb82b0ea9dc5e16ea34cccb268ad2cf
7
- data.tar.gz: e4bfba00be03edd43b60394146bdad990b14eeb731d7e878bdf8944c00e92986278b4c11ca721fa99cd309c54289ee21e0b5199c4f357b4e614691e9d1b6b3b6
6
+ metadata.gz: fab532886c13b4e2b950ff31b591d9670b0d8a343e835afa8922b35cd742142f63a6943685332d9ad5baf1146344dd6ff452209793f8dc7864c5a3322a520fad
7
+ data.tar.gz: 0c8110fc856d3e74e49fb675749fcdac113808d140a15c29c01838abcd760ef10ff78527aec128e5c7eaa2c8d7d879ef1ebe111972fd1d0e8671ad188c2f638c
data/CHANGELOG.md CHANGED
@@ -7,13 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Security
11
+
12
+ * 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.
13
+ * 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`.
14
+ * The number of realms whose public keys are cached is now bounded.
15
+ * 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.
16
+
17
+ * 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`.
18
+
19
+ ### Fixed
20
+
21
+ * `base64` and `logger` are declared as runtime dependencies.
22
+ * 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.
23
+
10
24
  ### Performance
11
25
 
26
+ * 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.
27
+ * 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
28
  * 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
29
 
14
30
  ### Added
15
31
 
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.
32
+ * 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.
33
+ * Integration tests ensuring E2E compatibility with a real Keycloak server using Docker.
34
+ * Automated tests in CI against multiple Keycloak versions (19.0.3, 22.0.5, 25.0.0, 26.7.0).
35
+ * Enforced that the Rake `release` task cannot be executed locally without passing the test suite first.
36
+ * 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
37
  * 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
38
  * `PublicKeyCachedResolver` caches public keys per-realm, ensuring safe operation in a multi-tenant environment.
19
39
 
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.3"
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,10 @@ 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
+ | `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
64
  | `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\/.+/] }`|
65
+ | `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
66
  | `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
67
  | `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
68
  | `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 +70,7 @@ All options have a default value. However, all of them can be changed in your in
71
70
  | `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
71
  | `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
72
  | `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"` |
73
+ | `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
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` |
@@ -90,12 +90,19 @@ KeycloakApiRails.configure do |config|
90
90
  config.realm_id = ENV["KEYCLOAK_REALM_ID"]
91
91
  config.logger = Rails.logger
92
92
  config.skip_paths = {
93
- post: [/^\/message/],
94
- get: [/^\/locales/, /^\/health\/.+/]
93
+ post: [/\A\/message/],
94
+ get: [/\A\/locales/, /\A\/health\/.+/]
95
95
  }
96
96
  end
97
97
  ```
98
98
 
99
+ Anchor those regexps with `\A` and `\z`, never with `^` and `$`. In Ruby, `^` and `$` match the
100
+ beginning and the end of a *line*, not of the string. The path they are matched against is
101
+ `PATH_INFO`, which the server has already URL-decoded, so a `%0a` in the request reaches them as a
102
+ real newline: `"/private\n/health"` matches `/^\/health/` and skips authentication on a path that
103
+ was never meant to be open. A regexp anchored with `^` or `$` is warned about when the first request
104
+ is served.
105
+
99
106
  Or using opt-in configuration:
100
107
 
101
108
  ```ruby
@@ -129,11 +136,16 @@ Keycloak only adds an API to the `aud` claim of a token once that API is declare
129
136
  `config.verify_not_before = true` additionally rejects a token whose `nbf` claim is in the future.
130
137
  It is disabled by default because a clock skew between Keycloak and the API rejects valid tokens.
131
138
 
139
+ The `iss` claim is checked against `server_url` only when one is configured. A test environment replacing the public key resolver, as `keycloak-api-rails/testing` does, accepts any issuer.
140
+
132
141
  ## Multi-tenancy (Multiple Realms)
133
142
 
134
143
  The library natively supports multi-tenancy by validating tokens issued by multiple Keycloak realms.
135
144
  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
145
 
146
+ Those realms all live under the same `server_url`: a token whose `iss` names another server is rejected, however allowed the realm it names.
147
+ Declaring `expected_audience` matters all the more here — without it, a token issued for any client of any allowed realm opens the API.
148
+
137
149
  Using an Array of allowed realms:
138
150
  ```ruby
139
151
  KeycloakApiRails.configure do |config|
@@ -301,9 +313,23 @@ Assigning `KeycloakApiRails.public_key_resolver = nil` restores the regular reso
301
313
 
302
314
  From the `keycloak-rails-api` directory:
303
315
 
316
+ ```
317
+ $ bundle exec rspec
318
+ ```
319
+
320
+ The examples of `spec/integration` are excluded: they start a Keycloak container, and without Docker
321
+ they wait for a server that never answers. Run them, against the Keycloak version of your choice,
322
+ with:
323
+
324
+ ```
325
+ $ KEYCLOAK_INTEGRATION=1 KEYCLOAK_VERSION=26.7.0 bundle exec rspec spec/integration
326
+ ```
327
+
328
+ Or run everything in a container, which needs a Docker socket of its own to reach Keycloak:
329
+
304
330
  ```
305
331
  $ docker build . -t keycloak-rails-api:test
306
- $ docker run -v `pwd`:/usr/src/app/ keycloak-rails-api:test bundle exec rspec spec
332
+ $ docker run -v `pwd`:/usr/src/app/ keycloak-rails-api:test bundle exec rspec
307
333
  ```
308
334
 
309
335
  ## How to release a new version
@@ -318,8 +344,8 @@ scoped RubyGems credential.
318
344
  3. Tag the commit and push the tag:
319
345
 
320
346
  ```
321
- $ git tag -a v2.0.2 -m "Version 2.0.2"
322
- $ git push origin v2.0.2
347
+ $ git tag -a v2.0.3 -m "Version 2.0.3"
348
+ $ git push origin v2.0.3
323
349
  ```
324
350
 
325
351
  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)
@@ -21,6 +21,7 @@ module KeycloakApiRails
21
21
  attr_accessor :allow_token_in_query_string
22
22
  attr_accessor :http_open_timeout
23
23
  attr_accessor :http_read_timeout
24
+ attr_accessor :allowed_algorithms
24
25
 
25
26
  def validate!
26
27
  errors = []
@@ -41,6 +42,7 @@ module KeycloakApiRails
41
42
  errors.concat(custom_attributes_errors)
42
43
  errors.concat(skip_paths_errors)
43
44
  errors.concat(expected_audience_errors)
45
+ errors.concat(allowed_algorithms_errors)
44
46
 
45
47
  raise InvalidConfigurationError, "Invalid Keycloak configuration: #{errors.join('; ')}" unless errors.empty?
46
48
 
@@ -97,6 +99,19 @@ module KeycloakApiRails
97
99
  end
98
100
  end
99
101
 
102
+ def allowed_algorithms_errors
103
+ unless allowed_algorithms.is_a?(Array) && !allowed_algorithms.empty?
104
+ return ["'allowed_algorithms' must be a non-empty Array of algorithm names, got #{allowed_algorithms.inspect}"]
105
+ end
106
+
107
+ unsupported = allowed_algorithms.reject { |algorithm| Service::SUPPORTED_ALGORITHMS.include?(algorithm.to_s.to_sym) }
108
+ if unsupported.empty?
109
+ []
110
+ else
111
+ ["'allowed_algorithms' declares #{unsupported.inspect}, which this library does not verify. It accepts #{Service::SUPPORTED_ALGORITHMS.inspect}"]
112
+ end
113
+ end
114
+
100
115
  def boolean?(value)
101
116
  value == true || value == false
102
117
  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
@@ -128,10 +139,20 @@ module KeycloakApiRails
128
139
  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
140
  end
130
141
 
142
+ line_anchored = regexps.select { |regexp| line_anchored?(regexp) }
143
+ unless line_anchored.empty?
144
+ 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'.")
145
+ end
146
+
131
147
  normalized[method.to_s.upcase] = regexps
132
148
  end
133
149
  end
134
150
 
151
+ # Escaped pairs are dropped first, so that '\^' does not count and '[^/]' is read as a class.
152
+ def line_anchored?(regexp)
153
+ regexp.source.gsub(/\\./, "").gsub(/\[[^\]]*\]/, "").match?(/[\^$]/)
154
+ end
155
+
135
156
  def should_skip?(method, path)
136
157
  skip_paths = @skip_paths[method]
137
158
  !skip_paths.nil? && skip_paths.any? { |skip_path| skip_path.match?(path) }
@@ -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.3"
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
@@ -87,6 +92,7 @@ module KeycloakApiRails
87
92
  config.allow_token_in_query_string = false
88
93
  config.http_open_timeout = 5
89
94
  config.http_read_timeout = 5
95
+ config.allowed_algorithms = KeycloakApiRails::Service::SUPPORTED_ALGORITHMS
90
96
  end
91
97
  end
92
98
 
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.3
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