keycloak-api-rails 2.0.0 → 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: b23b08b9947c44a5c24411bf88b478a7042b574f9da4c18dcbc033d2b12c213f
4
- data.tar.gz: c7f2b04da31060ff404dec1469d54df5e20cd8dbc91fc602047250039985d010
3
+ metadata.gz: 75cb985d142693a50337445bdde2608ee636c3ce77327e577a88f1d3ad6f12d7
4
+ data.tar.gz: a9220359016e6c96f594d2e43485bb2e5c2821da9d2055c6c622eef26fcdf33f
5
5
  SHA512:
6
- metadata.gz: 8b7a27d90979f5a048f844a82def6876d42b734a6eb6af1a0ec5a99900f38c3b16009aea80c470068eb8d84fa4fd0ce671f3cddb53f853db8165f84f10808a12
7
- data.tar.gz: 58408083f436a01e19fb3ba1b2efb5cbf0a8b2a1cfb793c439b33339150c39cebb5f6b499f5c83955b6d57657c7c94a9ffd932b390ee4ae830472e3d260fe26a
6
+ metadata.gz: fab532886c13b4e2b950ff31b591d9670b0d8a343e835afa8922b35cd742142f63a6943685332d9ad5baf1146344dd6ff452209793f8dc7864c5a3322a520fad
7
+ data.tar.gz: 0c8110fc856d3e74e49fb675749fcdac113808d140a15c29c01838abcd760ef10ff78527aec128e5c7eaa2c8d7d879ef1ebe111972fd1d0e8671ad188c2f638c
data/CHANGELOG.md CHANGED
@@ -5,6 +5,58 @@ 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
+ ### 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
+
24
+ ### Performance
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.
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.
29
+
30
+ ### Added
31
+
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.
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`.
38
+ * `PublicKeyCachedResolver` caches public keys per-realm, ensuring safe operation in a multi-tenant environment.
39
+
40
+ ## [2.0.1] - 2026-08-02
41
+
42
+ ### Security
43
+
44
+ * `skip_paths` declaring its paths as Strings, e.g. `{ get: ["/health/db"] }`, opened routes that had to be authenticated.
45
+ * A path that is not a `Regexp` is discarded by the middleware rather than matched, and a warning is logged naming it.
46
+ * 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.
47
+ * 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`.
48
+
49
+ ### Fixed
50
+
51
+ * `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.
52
+ * `custom_attributes` holding something else than a claim name is reported by `Configuration#validate!`, instead of being silently read from no token.
53
+ * `KeycloakApiRails.configure` discards the service, the public key resolver and the HTTP client it had memoized
54
+
55
+ ### Availability
56
+
57
+ * 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.
58
+ * 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.
59
+
8
60
  ## [2.0.0] - 2026-08-01
9
61
 
10
62
  ### Breaking changes
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.0"
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,17 +59,18 @@ 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 | Required | Realm's name (not id, actually) | `master` |
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 | `{ 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
- | `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"]` |
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` |
69
+ | `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
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
- | `expected_token_type` | `nil`| String | Optional | When set, a token is rejected unless its `typ` claim matches. Keycloak types its access tokens `Bearer` | `"Bearer"` |
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,6 +136,32 @@ 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
+
141
+ ## Multi-tenancy (Multiple Realms)
142
+
143
+ The library natively supports multi-tenancy by validating tokens issued by multiple Keycloak realms.
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.
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
+
149
+ Using an Array of allowed realms:
150
+ ```ruby
151
+ KeycloakApiRails.configure do |config|
152
+ config.server_url = ENV["KEYCLOAK_SERVER_URL"]
153
+ config.realm_id = ["master", "tenant-1", "tenant-2"]
154
+ end
155
+ ```
156
+
157
+ Using a Proc for dynamic validation (e.g., querying the database):
158
+ ```ruby
159
+ KeycloakApiRails.configure do |config|
160
+ config.server_url = ENV["KEYCLOAK_SERVER_URL"]
161
+ config.realm_id = ->(realm) { Tenant.exists?(name: realm) }
162
+ end
163
+ ```
164
+
132
165
  ## Use cases
133
166
 
134
167
  Once this gem is configured in your Rails project, you can read, validate and use tokens in your controllers.
@@ -212,7 +245,7 @@ For instance, to read a provided token:
212
245
  ```ruby
213
246
  class RenderTokenController < ApplicationController
214
247
  def show
215
- uri = request.env["REQUEST_URI"]
248
+ uri = KeycloakApiRails::Helper.request_uri(request.env)
216
249
  headers = request.env
217
250
  token = KeycloakApiRails.service.read_token(uri, headers)
218
251
  render json: { token: token }, status: :ok
@@ -280,9 +313,23 @@ Assigning `KeycloakApiRails.public_key_resolver = nil` restores the regular reso
280
313
 
281
314
  From the `keycloak-rails-api` directory:
282
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
+
283
330
  ```
284
331
  $ docker build . -t keycloak-rails-api:test
285
- $ 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
286
333
  ```
287
334
 
288
335
  ## How to release a new version
@@ -297,13 +344,9 @@ scoped RubyGems credential.
297
344
  3. Tag the commit and push the tag:
298
345
 
299
346
  ```
300
- $ git tag -a v2.0.0 -m "Version 2.0.0"
301
- $ git push origin v2.0.0
347
+ $ git tag -a v2.0.3 -m "Version 2.0.3"
348
+ $ git push origin v2.0.3
302
349
  ```
303
350
 
304
351
  The workflow then checks that the tag matches `KeycloakApiRails::VERSION`, runs the tests, builds the gem
305
352
  and pushes it. It only publishes tags starting with `v`.
306
-
307
- ## Next developments
308
-
309
- * Manage multiple realms
@@ -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
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module KeycloakApiRails
2
4
  module Authentication
3
5
  def self.included(base)
@@ -14,11 +16,15 @@ module KeycloakApiRails
14
16
  path = env["PATH_INFO"]
15
17
 
16
18
  KeycloakApiRails.logger.debug("Start authentication for #{method} : #{path}")
17
- token = KeycloakApiRails.service.read_token(Helper.request_uri(env), env)
18
- 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)
19
22
  authentication_succeeded(env, decoded_token)
20
23
  rescue TokenError => e
24
+ response.headers["WWW-Authenticate"] = e.challenge
21
25
  authentication_failed(e.message)
26
+ rescue KeycloakApiRails::HTTPError, KeycloakApiRails::MissingPublicKeysError => e
27
+ authentication_unavailable(e)
22
28
  end
23
29
 
24
30
  def authentication_failed(message)
@@ -26,6 +32,12 @@ module KeycloakApiRails
26
32
  render status: :unauthorized, json: { error: message }
27
33
  end
28
34
 
35
+ def authentication_unavailable(error)
36
+ KeycloakApiRails.logger.error("KeycloakApiRails: no token can be verified. #{error.class}: #{error.message}")
37
+ response.headers["Retry-After"] = KeycloakApiRails::PublicKeyCachedResolver::FAILED_REFRESH_RETRY_DELAY_IN_SECONDS.to_s
38
+ render status: :service_unavailable, json: { error: "Authentication is temporarily unavailable" }
39
+ end
40
+
29
41
  def authentication_succeeded(env, decoded_token)
30
42
  Helper.assign_token(env, decoded_token, KeycloakApiRails.config.custom_attributes)
31
43
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module KeycloakApiRails
2
4
  class InvalidConfigurationError < StandardError; end
3
5
 
@@ -19,17 +21,17 @@ module KeycloakApiRails
19
21
  attr_accessor :allow_token_in_query_string
20
22
  attr_accessor :http_open_timeout
21
23
  attr_accessor :http_read_timeout
24
+ attr_accessor :allowed_algorithms
22
25
 
23
26
  def validate!
24
27
  errors = []
25
28
 
26
29
  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)
30
+ 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
31
  errors.push("'logger' must respond to #{LOGGER_METHODS.join(', ')}") unless LOGGER_METHODS.all? { |method| logger.respond_to?(method) }
29
32
  errors.push("'opt_in' must be true or false, got #{opt_in.inspect}") unless boolean?(opt_in)
30
33
  errors.push("'verify_not_before' must be true or false, got #{verify_not_before.inspect}") unless boolean?(verify_not_before)
31
34
  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
35
  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
36
  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
37
  errors.push("'http_open_timeout' must be a positive number of seconds, got #{http_open_timeout.inspect}") unless number?(http_open_timeout)
@@ -37,8 +39,10 @@ module KeycloakApiRails
37
39
  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
40
  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
41
 
42
+ errors.concat(custom_attributes_errors)
40
43
  errors.concat(skip_paths_errors)
41
44
  errors.concat(expected_audience_errors)
45
+ errors.concat(allowed_algorithms_errors)
42
46
 
43
47
  raise InvalidConfigurationError, "Invalid Keycloak configuration: #{errors.join('; ')}" unless errors.empty?
44
48
 
@@ -61,13 +65,26 @@ module KeycloakApiRails
61
65
 
62
66
  private
63
67
 
68
+ def custom_attributes_errors
69
+ if custom_attributes.is_a?(Array)
70
+ invalid_names = custom_attributes.reject { |name| name.is_a?(String) || name.is_a?(Symbol) }
71
+ if invalid_names.empty?
72
+ []
73
+ else
74
+ ["'custom_attributes' must only contain claim names, as Strings or Symbols, got #{invalid_names.inspect}"]
75
+ end
76
+ else
77
+ ["'custom_attributes' must be an Array of claim names, got #{custom_attributes.inspect}"]
78
+ end
79
+ end
80
+
64
81
  def skip_paths_errors
65
82
  return ["'skip_paths' must be a Hash of HTTP methods and path regexps, got #{skip_paths.inspect}"] unless skip_paths.is_a?(Hash)
66
83
 
67
84
  skip_paths.filter_map do |method, paths|
68
- next if paths.is_a?(Array) && paths.all? { |path| path.respond_to?(:match) }
85
+ next if paths.is_a?(Array) && paths.all? { |path| path.is_a?(Regexp) }
69
86
 
70
- "'skip_paths[#{method.inspect}]' must be an Array of regexps, got #{paths.inspect}"
87
+ "'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
88
  end
72
89
  end
73
90
 
@@ -82,6 +99,19 @@ module KeycloakApiRails
82
99
  end
83
100
  end
84
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
+
85
115
  def boolean?(value)
86
116
  value == true || value == false
87
117
  end
@@ -91,7 +121,20 @@ module KeycloakApiRails
91
121
  end
92
122
 
93
123
  def missing?(value)
94
- value.nil? || value.to_s.strip.empty?
124
+ if value.is_a?(Array)
125
+ value.empty?
126
+ elsif value.is_a?(String)
127
+ value.strip.empty?
128
+ else
129
+ value.nil?
130
+ end
131
+ end
132
+
133
+ def valid_realm_id?(realm_id)
134
+ realm_id.nil? ||
135
+ realm_id.is_a?(String) ||
136
+ (realm_id.is_a?(Array) && realm_id.all? { |r| r.is_a?(String) }) ||
137
+ realm_id.respond_to?(:call)
95
138
  end
96
139
  end
97
140
  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
@@ -6,16 +8,20 @@ module KeycloakApiRails
6
8
  end
7
9
 
8
10
  def call(env)
9
- method = env["REQUEST_METHOD"]
10
- path = env["PATH_INFO"]
11
+ method = env["REQUEST_METHOD"]
12
+ path = env["PATH_INFO"]
13
+ service = KeycloakApiRails.service
11
14
 
12
15
  if service.need_middleware_authentication?(method, path, env)
13
16
  logger.debug("Start authentication for #{method} : #{path}")
14
17
  begin
15
- authenticate(env)
18
+ authenticate(service, env)
16
19
  rescue TokenError => e
17
20
  logger.debug("The error causing the Token to fail: #{e.original_error&.message || e.message}")
18
- return authentication_failed(e.message)
21
+ return authentication_failed(e)
22
+ rescue HTTPError, MissingPublicKeysError => e
23
+ logger.error("KeycloakApiRails: no token can be verified for #{method} : #{path}. #{e.class}: #{e.message}")
24
+ return authentication_unavailable
19
25
  end
20
26
  else
21
27
  logger.debug("Skip authentication for #{method} : #{path}")
@@ -26,19 +32,25 @@ module KeycloakApiRails
26
32
 
27
33
  private
28
34
 
29
- def authenticate(env)
35
+ def authenticate(service, env)
30
36
  token = service.read_token(Helper.request_uri(env), env)
31
37
  decoded_token = service.decode_and_verify(token)
32
38
  Helper.assign_token(env, decoded_token, config.custom_attributes)
33
39
  end
34
40
 
35
- def authentication_failed(message)
41
+ def authentication_failed(error)
36
42
  # Rack 3 requires header names to be lowercase.
37
- [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]]
38
47
  end
39
48
 
40
- def service
41
- KeycloakApiRails.service
49
+ def authentication_unavailable
50
+ [503,
51
+ { "content-type" => "application/json",
52
+ "retry-after" => PublicKeyCachedResolver::FAILED_REFRESH_RETRY_DELAY_IN_SECONDS.to_s },
53
+ [{ error: "Authentication is temporarily unavailable" }.to_json]]
42
54
  end
43
55
 
44
56
  def logger
@@ -1,49 +1,120 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module KeycloakApiRails
2
4
  class PublicKeyCachedResolver
3
5
  FAILED_REFRESH_RETRY_DELAY_IN_SECONDS = 10
6
+ MAX_CACHED_REALMS = 64
7
+
8
+ class RealmCache
9
+ attr_reader :cached_public_key_retrieved_at
10
+
11
+ def initialize(http_client, realm_id)
12
+ @resolver = PublicKeyResolver.new(http_client, realm_id)
13
+ @cached_public_keys = nil
14
+ @cached_public_key_retrieved_at = nil
15
+ @last_refresh_failure_at = nil
16
+ @last_refresh_error = nil
17
+ @mutex = Mutex.new
18
+ end
19
+
20
+ def find_public_keys(public_key_cache_ttl, logger)
21
+ @mutex.synchronize do
22
+ raise @last_refresh_error if refresh_recently_failed_without_any_cache?
23
+
24
+ refresh_public_keys(logger) if public_keys_are_outdated?(public_key_cache_ttl)
25
+ @cached_public_keys
26
+ end
27
+ end
28
+
29
+ private
4
30
 
5
- attr_reader :cached_public_key_retrieved_at
31
+ def refresh_public_keys(logger)
32
+ @cached_public_keys = @resolver.find_public_keys
33
+ @cached_public_key_retrieved_at = Time.now
34
+ @last_refresh_failure_at = nil
35
+ @last_refresh_error = nil
36
+ rescue StandardError => e
37
+ @last_refresh_failure_at = Time.now
38
+ @last_refresh_error = e
39
+ raise if @cached_public_keys.nil?
40
+
41
+ logger&.warn("KeycloakApiRails: could not refresh the public keys (#{e.class}: #{e.message}). Keeping the ones retrieved at #{@cached_public_key_retrieved_at}.")
42
+ end
43
+
44
+ def refresh_recently_failed_without_any_cache?
45
+ @cached_public_keys.nil? &&
46
+ !@last_refresh_failure_at.nil? &&
47
+ Time.now <= @last_refresh_failure_at + FAILED_REFRESH_RETRY_DELAY_IN_SECONDS
48
+ end
49
+
50
+ def public_keys_are_outdated?(public_key_cache_ttl)
51
+ @cached_public_keys.nil? ||
52
+ @cached_public_key_retrieved_at.nil? ||
53
+ (Time.now > @cached_public_key_retrieved_at + public_key_cache_ttl &&
54
+ (@last_refresh_failure_at.nil? ||
55
+ Time.now > @last_refresh_failure_at + FAILED_REFRESH_RETRY_DELAY_IN_SECONDS))
56
+ end
57
+ end
6
58
 
7
59
  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
60
+ @http_client = http_client
61
+ @realm_id = realm_id
62
+ @configured_realms = configured_realms(realm_id)
63
+ @public_key_cache_ttl = public_key_cache_ttl
64
+ @logger = logger
65
+ @caches = {}
66
+ @caches_mutex = Mutex.new
67
+ @refusal_logged = false
15
68
  end
16
69
 
17
70
  def self.from_configuration(http_client, configuration)
18
71
  new(http_client, configuration.realm_id, configuration.public_key_cache_ttl, configuration.logger)
19
72
  end
20
73
 
21
- def find_public_keys
22
- @mutex.synchronize do
23
- refresh_public_keys if public_keys_are_outdated?
24
- @cached_public_keys
25
- end
74
+ def find_public_keys(realm_id = nil)
75
+ cache_for(realm_id || default_realm).find_public_keys(@public_key_cache_ttl, @logger)
76
+ end
77
+
78
+ # Keep this method backward-compatible for testing
79
+ def cached_public_key_retrieved_at(realm_id = nil)
80
+ cache_for(realm_id || default_realm).cached_public_key_retrieved_at
26
81
  end
27
82
 
28
83
  private
29
84
 
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?
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
+
96
+ def cache_for(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
36
103
 
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}.")
104
+ @caches[realm_id] = RealmCache.new(@http_client, realm_id)
39
105
  end
40
106
 
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))
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.")
115
+ end
116
+
117
+ raise MissingPublicKeysError, "No Keycloak public key can be downloaded for the realm #{realm_id.inspect}"
47
118
  end
48
119
  end
49
120
  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,34 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
1
4
  module KeycloakApiRails
2
5
  class MissingPublicKeysError < StandardError; end
3
6
 
4
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
5
10
 
6
11
  def initialize(key_resolver)
7
12
  configuration = KeycloakApiRails.config
8
13
  @key_resolver = key_resolver
9
- @skip_paths = normalize_skip_paths(configuration.skip_paths)
14
+ @skip_paths = normalize_skip_paths(configuration.skip_paths, configuration.logger)
10
15
  @opt_in = configuration.opt_in
11
16
  @token_expiration_tolerance_in_seconds = configuration.token_expiration_tolerance_in_seconds
12
17
  @expected_audiences = Array(configuration.expected_audience).map(&:to_s)
13
18
  @expected_token_type = configuration.expected_token_type
14
19
  @verify_not_before = configuration.verify_not_before
15
20
  @allow_token_in_query_string = configuration.allow_token_in_query_string
21
+ @allowed_algorithms = Array(configuration.allowed_algorithms).map(&:to_sym)
16
22
  end
17
23
 
18
24
  def decode_and_verify(token)
19
25
  raise TokenError.no_token(token) if token.nil? || token.empty?
26
+
27
+ parts = token.to_s.split('.')
28
+ raise TokenError.invalid_format(token) if parts.length < 3
20
29
 
21
- public_keys = @key_resolver.find_public_keys
30
+ realm_id = extract_realm_from_token(token)
31
+ raise TokenError.invalid_realm(token) unless realm_allowed?(realm_id)
32
+
33
+ public_keys = @key_resolver.find_public_keys(realm_id)
22
34
 
23
35
  if public_keys.nil?
24
36
  raise MissingPublicKeysError, "No Keycloak public key is available to verify the token"
25
37
  end
26
38
 
27
39
  decoded_token = decode(token, public_keys)
28
- verify_claims!(token, decoded_token)
40
+ verify_claims!(token, decoded_token, realm_id)
29
41
  decoded_token
30
42
  end
31
43
 
44
+ def extract_realm_from_token(token)
45
+ payload_segment = token.split('.', 3)[1]
46
+ return nil unless payload_segment
47
+
48
+ decoded_payload = Base64.urlsafe_decode64(payload_segment)
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
64
+ rescue JSON::ParserError, ArgumentError
65
+ nil
66
+ end
67
+
68
+ def realm_allowed?(realm_id)
69
+ config_realm_id = KeycloakApiRails.config.realm_id
70
+ return true if config_realm_id.nil?
71
+ return false if realm_id.nil?
72
+
73
+ if config_realm_id.respond_to?(:call)
74
+ config_realm_id.call(realm_id)
75
+ elsif config_realm_id.is_a?(Array)
76
+ config_realm_id.include?(realm_id)
77
+ else
78
+ config_realm_id == realm_id
79
+ end
80
+ end
81
+
32
82
  def read_token(uri, headers)
33
83
  header_token = Helper.read_token_from_headers(headers)
34
84
  if !header_token.empty?
@@ -47,9 +97,7 @@ module KeycloakApiRails
47
97
  private
48
98
 
49
99
  def decode(token, public_keys)
50
- decoded_token = JSON::JWT.decode(token, public_keys)
51
- decoded_token.verify!(public_keys)
52
- decoded_token
100
+ JSON::JWT.decode(token, public_keys, @allowed_algorithms)
53
101
  rescue JSON::JWT::VerificationFailed, JSON::JWK::Set::KidNotFound => e
54
102
  raise TokenError.verification_failed(token, e)
55
103
  rescue JSON::JWT::InvalidFormat => e
@@ -58,28 +106,60 @@ module KeycloakApiRails
58
106
  raise TokenError.unknown(token, e)
59
107
  end
60
108
 
61
- def verify_claims!(token, decoded_token)
109
+ # RFC 7519 requires 'exp' and 'nbf' to be NumericDates.
110
+ def verify_claims!(token, decoded_token, realm_id)
62
111
  raise TokenError.missing_claim(token, "exp") unless decoded_token.key?("exp")
112
+ raise TokenError.invalid_claim(token, "exp") unless decoded_token["exp"].is_a?(Numeric)
113
+ raise TokenError.invalid_claim(token, "nbf") if not_before_is_invalid?(decoded_token)
63
114
  raise TokenError.expired(token) if expired?(decoded_token)
64
115
  raise TokenError.not_yet_valid(token) if not_yet_valid?(decoded_token)
65
116
  raise TokenError.invalid_audience(token) unless audience_valid?(decoded_token)
66
117
  raise TokenError.invalid_token_type(token) unless token_type_valid?(decoded_token)
118
+
119
+ if KeycloakApiRails.config.server_url
120
+ expected_iss = File.join(KeycloakApiRails.config.server_url.to_s, "realms", realm_id.to_s)
121
+ raise TokenError.invalid_realm(token) unless decoded_token["iss"] == expected_iss
122
+ end
123
+ end
124
+
125
+ def not_before_is_invalid?(token)
126
+ @verify_not_before && token.key?("nbf") && !token["nbf"].is_a?(Numeric)
67
127
  end
68
128
 
69
- def normalize_skip_paths(skip_paths)
129
+ # Anything that is not a regexp is discarded rather than matched: 'String#match' compiles its
130
+ # argument into a regexp, so a String would be matched against the path of the request instead of
131
+ # the other way around, and would open every path that is a sub-pattern of it. The railtie rejects
132
+ # such a configuration when the application boots; a Rack application running without Rails never
133
+ # calls 'validate!', so the paths keep being authenticated here.
134
+ def normalize_skip_paths(skip_paths, logger)
70
135
  (skip_paths || {}).each_with_object({}) do |(method, paths), normalized|
71
- normalized[method.to_s.downcase.to_sym] = Array(paths)
136
+ regexps, discarded = Array(paths).partition { |path| path.is_a?(Regexp) }
137
+
138
+ unless discarded.empty?
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.")
140
+ end
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
+
147
+ normalized[method.to_s.upcase] = regexps
72
148
  end
73
149
  end
74
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
+
75
156
  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) }
157
+ skip_paths = @skip_paths[method]
158
+ !skip_paths.nil? && skip_paths.any? { |skip_path| skip_path.match?(path) }
78
159
  end
79
160
 
80
161
  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?
162
+ method == "OPTIONS" && !headers["HTTP_ACCESS_CONTROL_REQUEST_METHOD"].nil?
83
163
  end
84
164
 
85
165
  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
@@ -9,14 +11,27 @@ module KeycloakApiRails
9
11
  @original_error = original_error
10
12
  end
11
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
+
12
23
  def self.verification_failed(token, original_error)
13
24
  new(token, :verification_failed, "Failed to verify JWT token", original_error)
14
25
  end
15
26
 
16
- def self.invalid_format(token, original_error)
27
+ def self.invalid_format(token, original_error = nil)
17
28
  new(token, :invalid_format, "Wrong JWT Format", original_error)
18
29
  end
19
30
 
31
+ def self.invalid_realm(token)
32
+ new(token, :invalid_realm, "JWT token does not have a valid realm")
33
+ end
34
+
20
35
  def self.no_token(token)
21
36
  new(token, :no_token, "No JWT token provided")
22
37
  end
@@ -41,6 +56,10 @@ module KeycloakApiRails
41
56
  new(token, :missing_claim, "JWT token does not carry the mandatory claim '#{claim}'")
42
57
  end
43
58
 
59
+ def self.invalid_claim(token, claim)
60
+ new(token, :invalid_claim, "JWT token carries an invalid '#{claim}' claim: it must be a number of seconds since the Epoch")
61
+ end
62
+
44
63
  def self.unknown(token, original_error)
45
64
  new(token, :unknown, "Failed to read JWT token", original_error)
46
65
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module KeycloakApiRails
2
- VERSION = "2.0.0"
4
+ VERSION = "2.0.3"
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"
@@ -21,22 +23,37 @@ module KeycloakApiRails
21
23
  # These objects are memoized lazily, on the first request each process serves -- which several
22
24
  # threads of a threaded server reach at the same time. A Monitor rather than a Mutex: the
23
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.
24
31
  MONITOR = Monitor.new
25
32
 
26
33
  def self.configure
27
- MONITOR.synchronize { yield @configuration ||= KeycloakApiRails::Configuration.new }
34
+ MONITOR.synchronize do
35
+ yield @configuration ||= KeycloakApiRails::Configuration.new
36
+ discard_configured_objects
37
+ end
38
+ end
39
+
40
+ def self.discard_configured_objects
41
+ @http_client = nil
42
+ @public_key_resolver = nil unless @public_key_resolver_assigned
43
+ @service = nil
28
44
  end
45
+ private_class_method :discard_configured_objects
29
46
 
30
47
  def self.config
31
48
  @configuration
32
49
  end
33
50
 
34
51
  def self.http_client
35
- MONITOR.synchronize { @http_client ||= KeycloakApiRails::HTTPClient.new(config, logger) }
52
+ @http_client || MONITOR.synchronize { @http_client ||= KeycloakApiRails::HTTPClient.new(config, logger) }
36
53
  end
37
54
 
38
55
  def self.public_key_resolver
39
- 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) }
40
57
  end
41
58
 
42
59
  # Mainly used by "keycloak-api-rails/testing" to validate tokens without a Keycloak server.
@@ -44,13 +61,14 @@ module KeycloakApiRails
44
61
  # a reference to the resolver that is being replaced.
45
62
  def self.public_key_resolver=(resolver)
46
63
  MONITOR.synchronize do
47
- @public_key_resolver = resolver
48
- @service = nil
64
+ @public_key_resolver = resolver
65
+ @public_key_resolver_assigned = !resolver.nil?
66
+ @service = nil
49
67
  end
50
68
  end
51
69
 
52
70
  def self.service
53
- MONITOR.synchronize { @service ||= KeycloakApiRails::Service.new(public_key_resolver) }
71
+ @service || MONITOR.synchronize { @service ||= KeycloakApiRails::Service.new(public_key_resolver) }
54
72
  end
55
73
 
56
74
  def self.logger
@@ -74,6 +92,7 @@ module KeycloakApiRails
74
92
  config.allow_token_in_query_string = false
75
93
  config.http_open_timeout = 5
76
94
  config.http_read_timeout = 5
95
+ config.allowed_algorithms = KeycloakApiRails::Service::SUPPORTED_ALGORITHMS
77
96
  end
78
97
  end
79
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.0
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