keycloak-api-rails 1.1.2 → 2.0.0

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: 74dec51fa027308c2cf5b57ca67b7316b21677bf1592022f76988f232bbde911
4
- data.tar.gz: 4836a7951bbf1559ff5eec0dfeb004ff8bcd2955cee7e06ee9e8a553743ad99e
3
+ metadata.gz: b23b08b9947c44a5c24411bf88b478a7042b574f9da4c18dcbc033d2b12c213f
4
+ data.tar.gz: c7f2b04da31060ff404dec1469d54df5e20cd8dbc91fc602047250039985d010
5
5
  SHA512:
6
- metadata.gz: 48b3307f4dff316dfa063deddf0d106bbcc409856c20f0af5c441bdc3b1163e9cf93f0c3f7e53fd1a90fcb7946744c2b1b8863fd5a61a0c4012fe3d7df6b5aee
7
- data.tar.gz: 81aca7224bcae644dd35f6fb715bdb447b0da549bc3da0bdd311a6bea06adfba468936aabcdaeb305a44839c6770a46644abfdf81f2f828246414880e9ab52dd
6
+ metadata.gz: 8b7a27d90979f5a048f844a82def6876d42b734a6eb6af1a0ec5a99900f38c3b16009aea80c470068eb8d84fa4fd0ce671f3cddb53f853db8165f84f10808a12
7
+ data.tar.gz: 58408083f436a01e19fb3ba1b2efb5cbf0a8b2a1cfb793c439b33339150c39cebb5f6b499f5c83955b6d57657c7c94a9ffd932b390ee4ae830472e3d260fe26a
data/CHANGELOG.md CHANGED
@@ -5,6 +5,61 @@ 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
+ ## [2.0.0] - 2026-08-01
9
+
10
+ ### Breaking changes
11
+
12
+ * `TokenError` is now namespaced: `KeycloakApiRails::TokenError`.
13
+ * The gem depends on `railties` rather than on the whole `rails` meta gem
14
+ * An invalid configuration raises `KeycloakApiRails::InvalidConfigurationError` when the application boots
15
+ * A token this library cannot read at all is answered a `401`, where an unexpected error used to escape the middleware as a `500`
16
+ * A token carried by the `authorizationToken` query string parameter is ignored unless the new `allow_token_in_query_string` option is enabled
17
+ * `KeycloakApiRails::HTTPClient` raises `KeycloakApiRails::HTTPError` when Keycloak answers an error, a malformed payload, or cannot be reached.
18
+
19
+ ### Security
20
+
21
+ * New `expected_audience` option: when set, a token whose `aud` claim does not carry one of the expected audiences is rejected. Without it, every token signed by the realm is accepted (including its ID tokens) which Keycloak signs with the very same key, and the access tokens issued for its other clients
22
+ * New `expected_token_type` option: when set, a token whose `typ` claim does not match is rejected. Keycloak types its access tokens `Bearer`
23
+ * New `verify_not_before` option: when enabled, a token whose `nbf` claim is in the future is rejected. Disabled by default, since a clock skew between Keycloak and the API would reject valid tokens
24
+ * A token carrying no `exp` claim is rejected, where `Time.at(nil)` used to raise a `TypeError` and answer a `500`
25
+ * The resolver never answers without a public key: decoding a token without one would skip the signature verification altogether
26
+
27
+ ### Fixed
28
+
29
+ * The `401` answered by the middleware carries a lowercase `content-type` header, as the Rack 3 SPEC requires.
30
+ * The `Authorization` header is read again when the Rack environment carries no `REQUEST_URI`.
31
+ * A `TokenError` raised further down the stack is no longer swallowed by the middleware and turned into a `401`
32
+ * `skip_paths` declared with String or upcased HTTP methods, e.g. `{ "GET" => [...] }`, are honoured instead of silently never matching
33
+ * `TokenError.unknown` raised an `ArgumentError`, being called without any argument
34
+ * `TokenError.invalid_format` no longer reports a `nil` cause: the rescue clause read an `e` that was never bound
35
+ * `Helper.current_user_roles` was declared twice
36
+ * The `Authorization` scheme is read case-insensitively, as RFC 7235 requires, and any amount of whitespace may separate it from the token. `gsub(/^Bearer /, "")` also stripped the scheme from every line that followed the first one, `^` matching the beginning of any line in Ruby
37
+
38
+ ### Thread safety
39
+
40
+ * The memoized service, public key resolver and HTTP client are built once per process, whichever thread reaches them first. Every thread of a threaded server used to build its own on the first request
41
+ * The public keys are downloaded once when several threads find the cache expired at the same time, rather than once per thread
42
+ * `KeycloakApiRails::Testing` generates a single key pair under a parallelized test suite. Two threads generating one each would leave the signing key and the published public key out of sync, failing the verification of every forged token
43
+
44
+ ### Availability
45
+
46
+ * The requests downloading the public keys apply the new `http_open_timeout` and `http_read_timeout` options, 5 seconds each. Without them, `Net::HTTP` waits 60 seconds to open the connection and 60 more to read the answer: a Keycloak that hangs held every request thread of the API
47
+ * An unreachable Keycloak no longer takes the API down: the public keys retrieved last keep being used past their TTL, and a failed refresh is not attempted again for 10 seconds
48
+
49
+ ### Added
50
+
51
+ * The configuration is validated at boot, and `Configuration#validate!` reports every problem at once
52
+ * A warning is logged at boot when `server_url` and `realm_id` are not both configured
53
+
54
+ ### Upgrading from 1.x to 2.0
55
+
56
+ * `TokenError` has moved into the module of the library, and is now `KeycloakApiRails::TokenError`. An application rescuing it (typically around `keycloak_authenticate`) has to be updated. Its `reason` can now also be `:not_yet_valid`, `:invalid_audience`, `:invalid_token_type`, `:missing_claim` and `:unknown`
57
+ * A token carried by the `authorizationToken` query string parameter is ignored unless `config.allow_token_in_query_string = true` is set, and the `Authorization` header now takes precedence over it. An API whose clients pass their token through the URL (a browser following a link, a `<video>` tag) has to enable the option explicitly.
58
+ * The gem depends on `railties` instead of `rails`. An application that relied on this gem to pull Rails in has to declare `rails` itself
59
+ * A configuration mistake now raises a `KeycloakApiRails::InvalidConfigurationError` when the application boots, instead of failing on the first request
60
+ * A request carrying a token that this library cannot read at all is answered a `401` instead of raising, which used to result in a `500`
61
+ * Failing to download the public keys raises a `KeycloakApiRails::HTTPError`. It used to log the error and let the resolver fail later, with an unrelated `NoMethodError`
62
+
8
63
  ## [1.1.2] - 2026-08-01
9
64
 
10
65
  * Gem metadata
data/README.md CHANGED
@@ -5,7 +5,7 @@ This gem validates Keycloak JWT token for Ruby On Rails APIs.
5
5
  ## Requirements
6
6
 
7
7
  * Ruby `>= 2.7`
8
- * Rails `>= 4.2`
8
+ * Railties `>= 4.2` — only `Rails::Railtie` is used
9
9
 
10
10
  Every push is tested against Ruby 2.7, 3.0, 3.1, 3.2, 3.3, 3.4 and 4.0. Each of them installs the
11
11
  most recent dependencies it supports, so the test suite runs against Rails 7.1 (Ruby 2.7 and 3.0),
@@ -18,27 +18,25 @@ the recommended path.
18
18
  ## Install
19
19
 
20
20
  ```ruby
21
- gem "keycloak-api-rails", "1.1.2"
21
+ gem "keycloak-api-rails", "2.0.0"
22
22
  ```
23
23
 
24
24
  ## Token validation
25
25
 
26
- Tokens sent (through query strings or Authorization headers) are validated against a Keycloak public key. This public key is downloaded every day by default (this interval can be changed through `public_key_cache_ttl`).
26
+ Tokens are validated against a Keycloak public key. This public key is downloaded every day by default (this interval can be changed through `public_key_cache_ttl`).
27
27
 
28
28
  ## Pass token to the API
29
29
 
30
- * Method 1: By adding an `Authorization` HTTP Header with its value set to `Bearer <your token>`.
31
- _e.g_ using curl: `curl -H "Authorization: Bearer <your-token>" https://api.pouet.io/api/more-pouets`
32
- * Method 2: By providing the token via query string, especially via the parameter named `authorizationToken`. Keep in mind that this method is less secure (url are kept intact in your browser history, and so on...)
33
- _e.g._ using curl: `curl https://api.pouet.io/api/more-pouets?authorizationToken<your-token>`
30
+ * Method 1: By adding an `Authorization` HTTP Header with its value set to `Bearer <your token>`. The scheme is case-insensitive. _e.g_ using curl: `curl -H "Authorization: Bearer <your-token>" https://api.pouet.io/api/more-pouets`
31
+ * Method 2: By providing the token via query string, in the parameter named `authorizationToken`. This method has to be enabled explicitly, through `config.allow_token_in_query_string = true`: a token carried by a URL is kept in the browser history, sent along in the `Referer` header, and written to the access logs of every proxy on the way. _e.g._ using curl: `curl https://api.pouet.io/api/more-pouets?authorizationToken=<your-token>`
34
32
 
35
- _If both method are used at the same time, The query string as a higher priority when reading given tokens._
33
+ _If both methods are used at the same time, the `Authorization` header takes precedence: it is the one that does not leak._
36
34
 
37
35
  ## Opt-in vs. Opt-out validation
38
36
 
39
37
  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
40
38
 
41
- 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:
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:
42
40
 
43
41
  ```ruby
44
42
  class MyApiController < ActionController::Base
@@ -62,15 +60,26 @@ All options have a default value. However, all of them can be changed in your in
62
60
 
63
61
  | Option | Default Value | Type | Required? | Description | Example |
64
62
  | ---- | ----- | ------ | ----- | ------ | ----- |
65
- | `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` |
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` |
66
64
  | `realm_id` | `nil`| String | Required | Realm's name (not id, actually) | `master` |
67
- | `logger` | `Logger.new(STDOUT)`| Logger | Optional | The logger used by `keycloak-api-rails` | `Rails.logger` | 
68
- | `skip_paths` | `{}`| Hash of methods and paths regexp | Optional | Paths whose the token must not be validatefd | `{ get: [/^\/health\/.+/] }`| 
69
- | `opt_in` | `false` | Boolean | Optional | When true, All requests will be validated (excluding requests matching `skip_paths`). When false, validation must be explicitly requested | `true`
70
- | `token_expiration_tolerance_in_seconds` | `10`| Logger | Optional | Number of seconds a token can expire before being rejected by the API. | `15` | 
71
- | `public_key_cache_ttl` | `86400`| Integer | Optional | Amount of time, in seconds, specifying maximum interval between two requests to {project_name} 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. | `Rails.logger` | 
72
- | `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"]` | 
73
- | `ca_certificate_file` | `nil`| String | Optional | Path to the certificate authority used to validate the Keycloak server certificate | `/credentials/production_root_ca_cert.pem` | 
65
+ | `logger` | `Logger.new(STDOUT)`| Logger | Optional | The logger used by `keycloak-api-rails` | `Rails.logger` |
66
+ | `skip_paths` | `{}`| Hash of methods and paths regexp | Optional | Paths whose token must not be validated | `{ get: [/^\/health\/.+/] }`|
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
+ | `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"]` |
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
+ | `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"` |
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
+ | `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
+ | `http_open_timeout` | `5`| Integer | Optional | Seconds to wait for the connection to Keycloak to open, when downloading the public keys | `2` |
77
+ | `http_read_timeout` | `5`| Integer | Optional | Seconds to wait for the answer of Keycloak, when downloading the public keys | `2` |
78
+
79
+ The configuration is validated when the application boots: a mistake in the initializer raises a
80
+ `KeycloakApiRails::InvalidConfigurationError` naming the offending option, rather than failing on
81
+ the first request that reaches the middleware.
82
+
74
83
  ## Configure it
75
84
 
76
85
  Create a `keycloak.rb` file in your Rails `config/initializers` folder. For instance:
@@ -100,6 +109,26 @@ end
100
109
 
101
110
  When using `opt-in` is true, `skip_paths` is not used.
102
111
 
112
+ ## Restricting which tokens are accepted
113
+
114
+ A token is always checked against the public keys of the realm, and against its expiration date.
115
+ That alone accepts *every* token the realm signed: the ID token of the same user, and the access tokens issued for the other clients of that realm. Declaring the audience the API expects.
116
+ The `aud` claim, and the type of token it accepts, the `typ` claim, which Keycloak sets to `Bearer` on its access tokens, narrows that down:
117
+
118
+ ```ruby
119
+ KeycloakApiRails.configure do |config|
120
+ config.server_url = ENV["KEYCLOAK_SERVER_URL"]
121
+ config.realm_id = ENV["KEYCLOAK_REALM_ID"]
122
+ config.expected_audience = "my-api"
123
+ config.expected_token_type = "Bearer"
124
+ end
125
+ ```
126
+
127
+ Keycloak only adds an API to the `aud` claim of a token once that API is declared as an audience of the client requesting it, through an *audience mapper* on the client scope. Check what your realm actually issues before enabling `expected_audience`, or every request will be answered a `401`.
128
+
129
+ `config.verify_not_before = true` additionally rejects a token whose `nbf` claim is in the future.
130
+ It is disabled by default because a clock skew between Keycloak and the API rejects valid tokens.
131
+
103
132
  ## Use cases
104
133
 
105
134
  Once this gem is configured in your Rails project, you can read, validate and use tokens in your controllers.
@@ -173,6 +202,8 @@ end
173
202
 
174
203
  This should output `https://api.pouet.io/api/more-pouets?authorizationToken=myToken`.
175
204
 
205
+ Such an URL is only accepted by an API that set `config.allow_token_in_query_string = true`.
206
+
176
207
 
177
208
  ### Accessing Keycloak Service
178
209
 
@@ -266,8 +297,8 @@ scoped RubyGems credential.
266
297
  3. Tag the commit and push the tag:
267
298
 
268
299
  ```
269
- $ git tag -a v1.2.0 -m "Version 1.2.0"
270
- $ git push origin v1.2.0
300
+ $ git tag -a v2.0.0 -m "Version 2.0.0"
301
+ $ git push origin v2.0.0
271
302
  ```
272
303
 
273
304
  The workflow then checks that the tag matches `KeycloakApiRails::VERSION`, runs the tests, builds the gem
@@ -275,5 +306,4 @@ and pushes it. It only publishes tags starting with `v`.
275
306
 
276
307
  ## Next developments
277
308
 
278
- * Manage multiple realms
279
- * Avoid duplicate code in KeycloakApiRails::Middleware and `KeycloakApiRails::Authentication`
309
+ * Manage multiple realms
@@ -28,14 +28,13 @@ Gem::Specification.new do |spec|
28
28
 
29
29
  spec.required_ruby_version = ">= 2.7"
30
30
 
31
- spec.add_dependency "rails", ">= 4.2"
31
+ spec.add_dependency "railties", ">= 4.2"
32
32
  spec.add_dependency "json-jwt", ">= 1.11.0"
33
33
 
34
34
  spec.add_development_dependency "rspec", "3.13.2"
35
35
  spec.add_development_dependency "timecop", "0.9.11"
36
- # Required by 'rake release', which builds and publishes the gem from the CI.
36
+ spec.add_development_dependency "rails", ">= 4.2"
37
+ spec.add_development_dependency "rack"
37
38
  spec.add_development_dependency "rake", ">= 13.0"
38
- # Not pinned to an exact version: byebug 12 requires Ruby >= 3.1, byebug 13 requires Ruby >= 3.2.
39
- # Older Rubies resolve to byebug 11.
40
39
  spec.add_development_dependency "byebug", ">= 11.1.3"
41
40
  end
@@ -9,13 +9,12 @@ module KeycloakApiRails
9
9
  protected
10
10
 
11
11
  def keycloak_authenticate
12
- env = request.env
12
+ env = request.env
13
13
  method = env["REQUEST_METHOD"]
14
14
  path = env["PATH_INFO"]
15
- uri = env["REQUEST_URI"]
16
15
 
17
16
  KeycloakApiRails.logger.debug("Start authentication for #{method} : #{path}")
18
- token = KeycloakApiRails.service.read_token(uri, env)
17
+ token = KeycloakApiRails.service.read_token(Helper.request_uri(env), env)
19
18
  decoded_token = KeycloakApiRails.service.decode_and_verify(token)
20
19
  authentication_succeeded(env, decoded_token)
21
20
  rescue TokenError => e
@@ -28,14 +27,7 @@ module KeycloakApiRails
28
27
  end
29
28
 
30
29
  def authentication_succeeded(env, decoded_token)
31
- Helper.assign_current_user_id(env, decoded_token)
32
- Helper.assign_current_authorized_party(env, decoded_token)
33
- Helper.assign_current_user_email(env, decoded_token)
34
- Helper.assign_current_user_locale(env, decoded_token)
35
- Helper.assign_current_user_custom_attributes(env, decoded_token, KeycloakApiRails.config.custom_attributes)
36
- Helper.assign_realm_roles(env, decoded_token)
37
- Helper.assign_resource_roles(env, decoded_token)
38
- Helper.assign_keycloak_token(env, decoded_token)
30
+ Helper.assign_token(env, decoded_token, KeycloakApiRails.config.custom_attributes)
39
31
  end
40
32
  end
41
33
  end
@@ -1,5 +1,9 @@
1
1
  module KeycloakApiRails
2
+ class InvalidConfigurationError < StandardError; end
3
+
2
4
  class Configuration
5
+ LOGGER_METHODS = [:debug, :info, :warn, :error].freeze
6
+
3
7
  attr_accessor :server_url
4
8
  attr_accessor :realm_id
5
9
  attr_accessor :skip_paths
@@ -9,5 +13,85 @@ module KeycloakApiRails
9
13
  attr_accessor :custom_attributes
10
14
  attr_accessor :logger
11
15
  attr_accessor :ca_certificate_file
16
+ attr_accessor :expected_audience
17
+ attr_accessor :expected_token_type
18
+ attr_accessor :verify_not_before
19
+ attr_accessor :allow_token_in_query_string
20
+ attr_accessor :http_open_timeout
21
+ attr_accessor :http_read_timeout
22
+
23
+ def validate!
24
+ errors = []
25
+
26
+ 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)
28
+ errors.push("'logger' must respond to #{LOGGER_METHODS.join(', ')}") unless LOGGER_METHODS.all? { |method| logger.respond_to?(method) }
29
+ errors.push("'opt_in' must be true or false, got #{opt_in.inspect}") unless boolean?(opt_in)
30
+ errors.push("'verify_not_before' must be true or false, got #{verify_not_before.inspect}") unless boolean?(verify_not_before)
31
+ 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
+ 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
+ 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
+ errors.push("'http_open_timeout' must be a positive number of seconds, got #{http_open_timeout.inspect}") unless number?(http_open_timeout)
36
+ errors.push("'http_read_timeout' must be a positive number of seconds, got #{http_read_timeout.inspect}") unless number?(http_read_timeout)
37
+ 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
+ errors.push("'ca_certificate_file' must be the path of a readable file, got #{ca_certificate_file.inspect}") unless ca_certificate_file.nil? || File.readable?(ca_certificate_file.to_s)
39
+
40
+ errors.concat(skip_paths_errors)
41
+ errors.concat(expected_audience_errors)
42
+
43
+ raise InvalidConfigurationError, "Invalid Keycloak configuration: #{errors.join('; ')}" unless errors.empty?
44
+
45
+ true
46
+ end
47
+
48
+ def validate_server!
49
+ errors = []
50
+ errors.push("'server_url' must be configured, e.g. 'https://keycloak.example.org'") if missing?(server_url)
51
+ errors.push("'realm_id' must be configured, e.g. 'master'") if missing?(realm_id)
52
+
53
+ raise InvalidConfigurationError, "Invalid Keycloak configuration: #{errors.join('; ')}" unless errors.empty?
54
+
55
+ true
56
+ end
57
+
58
+ def server_configured?
59
+ !missing?(server_url) && !missing?(realm_id)
60
+ end
61
+
62
+ private
63
+
64
+ def skip_paths_errors
65
+ return ["'skip_paths' must be a Hash of HTTP methods and path regexps, got #{skip_paths.inspect}"] unless skip_paths.is_a?(Hash)
66
+
67
+ skip_paths.filter_map do |method, paths|
68
+ next if paths.is_a?(Array) && paths.all? { |path| path.respond_to?(:match) }
69
+
70
+ "'skip_paths[#{method.inspect}]' must be an Array of regexps, got #{paths.inspect}"
71
+ end
72
+ end
73
+
74
+ def expected_audience_errors
75
+ case expected_audience
76
+ when nil, String
77
+ []
78
+ when Array
79
+ expected_audience.all? { |audience| audience.is_a?(String) } ? [] : ["'expected_audience' must only contain Strings, got #{expected_audience.inspect}"]
80
+ else
81
+ ["'expected_audience' must be a String, an Array of Strings, or nil, got #{expected_audience.inspect}"]
82
+ end
83
+ end
84
+
85
+ def boolean?(value)
86
+ value == true || value == false
87
+ end
88
+
89
+ def number?(value, allow_zero: false)
90
+ value.is_a?(Numeric) && (allow_zero ? value >= 0 : value > 0)
91
+ end
92
+
93
+ def missing?(value)
94
+ value.nil? || value.to_s.strip.empty?
95
+ end
12
96
  end
13
97
  end
@@ -10,6 +10,18 @@ module KeycloakApiRails
10
10
  RESOURCE_ROLES_KEY = "keycloak:resource_roles"
11
11
  TOKEN_KEY = "keycloak:token"
12
12
  QUERY_STRING_TOKEN_KEY = "authorizationToken"
13
+ BEARER_PREFIX = /\ABearer[[:space:]]+/i.freeze # RFC 7235 makes the authentication scheme case-insensitive.
14
+
15
+ def self.assign_token(env, token, custom_attribute_names)
16
+ assign_current_user_id(env, token)
17
+ assign_current_authorized_party(env, token)
18
+ assign_current_user_email(env, token)
19
+ assign_current_user_locale(env, token)
20
+ assign_current_user_custom_attributes(env, token, custom_attribute_names)
21
+ assign_realm_roles(env, token)
22
+ assign_resource_roles(env, token)
23
+ assign_keycloak_token(env, token)
24
+ end
13
25
 
14
26
  def self.current_user_id(env)
15
27
  env[CURRENT_USER_ID_KEY]
@@ -71,15 +83,23 @@ module KeycloakApiRails
71
83
  end
72
84
 
73
85
  def self.assign_current_user_custom_attributes(env, token, attribute_names)
74
- env[CURRENT_USER_ATTRIBUTES] = token.select { |key, value| attribute_names.include?(key) }
86
+ names = Array(attribute_names)
87
+ env[CURRENT_USER_ATTRIBUTES] = token.select { |key, _value| names.include?(key) }
75
88
  end
76
89
 
77
90
  def self.current_user_custom_attributes(env)
78
91
  env[CURRENT_USER_ATTRIBUTES]
79
92
  end
80
93
 
81
- def self.current_user_roles(env)
82
- env[ROLES_KEY]
94
+ def self.request_uri(env)
95
+ # 'REQUEST_URI' is not part of the Rack spec: Puma and Unicorn do set it
96
+ if env["REQUEST_URI"].nil?
97
+ query_string = env["QUERY_STRING"]
98
+ path = env["PATH_INFO"].to_s
99
+ query_string.nil? || query_string.empty? ? path : "#{path}?#{query_string}"
100
+ else
101
+ env["REQUEST_URI"]
102
+ end
83
103
  end
84
104
 
85
105
  def self.read_token_from_query_string(uri)
@@ -91,6 +111,8 @@ module KeycloakApiRails
91
111
  else
92
112
  ""
93
113
  end
114
+ rescue URI::InvalidURIError, ArgumentError
115
+ nil
94
116
  end
95
117
 
96
118
  def self.create_url_with_token(uri, token)
@@ -102,7 +124,12 @@ module KeycloakApiRails
102
124
  end
103
125
 
104
126
  def self.read_token_from_headers(headers)
105
- headers["HTTP_AUTHORIZATION"]&.gsub(/^Bearer /, "") || ""
127
+ authorization = headers["HTTP_AUTHORIZATION"]
128
+ if authorization.nil?
129
+ ""
130
+ else
131
+ authorization.sub(BEARER_PREFIX, "")
132
+ end
106
133
  end
107
134
  end
108
135
  end
@@ -1,35 +1,70 @@
1
1
  module KeycloakApiRails
2
+ class HTTPError < StandardError
3
+ attr_reader :status
4
+
5
+ def initialize(message, status = nil)
6
+ super(message)
7
+ @status = status
8
+ end
9
+ end
10
+
2
11
  class HTTPClient
12
+ UNREACHABLE_ERRORS = [
13
+ Timeout::Error,
14
+ SocketError,
15
+ SystemCallError,
16
+ IOError,
17
+ OpenSSL::SSL::SSLError,
18
+ Net::ProtocolError
19
+ ].freeze
20
+
3
21
  def initialize(configuration, logger)
4
- @server_url = configuration.server_url
5
- @ca_certificate_file = configuration.ca_certificate_file
6
- @logger = logger
7
- @x509_store = OpenSSL::X509::Store.new
22
+ @configuration = configuration
23
+ @logger = logger
24
+ @x509_store = OpenSSL::X509::Store.new
8
25
  @x509_store.set_default_paths
9
- @x509_store.add_file(@ca_certificate_file) if @ca_certificate_file
26
+ @x509_store.add_file(configuration.ca_certificate_file) if configuration.ca_certificate_file
10
27
  end
11
28
 
12
29
  def get(realm_id, path)
13
- uri = build_uri(realm_id, path)
14
- use_ssl = uri.scheme == "http" ? false : true
15
- Net::HTTP.start(uri.host, uri.port, :use_ssl => use_ssl, :cert_store => @x509_store) do |http|
16
- request = Net::HTTP::Get.new(uri)
17
- response = http.request(request)
18
-
19
- begin
20
- response.value
21
- JSON.parse(response.body)
22
- rescue
23
- @logger.error("KeycloakApiRails responded with an error when calling '#{path}'. Status #{response.code}. Payload: #{response.body}")
24
- end
30
+ @configuration.validate_server!
31
+
32
+ uri = build_uri(realm_id, path)
33
+ response = request(uri)
34
+
35
+ unless response.is_a?(Net::HTTPSuccess)
36
+ @logger.error("KeycloakApiRails: Keycloak responded with an error when calling '#{path}'. Status #{response.code}. Payload: #{response.body}")
37
+ raise HTTPError.new("Keycloak responded with a #{response.code} status when calling '#{path}'", response.code)
25
38
  end
39
+
40
+ parse(response, path)
26
41
  end
27
42
 
28
43
  private
29
44
 
45
+ def request(uri)
46
+ Net::HTTP.start(uri.host,
47
+ uri.port,
48
+ use_ssl: uri.scheme != "http",
49
+ cert_store: @x509_store,
50
+ open_timeout: @configuration.http_open_timeout,
51
+ read_timeout: @configuration.http_read_timeout) do |http|
52
+ http.request(Net::HTTP::Get.new(uri))
53
+ end
54
+ rescue *UNREACHABLE_ERRORS => e
55
+ @logger.error("KeycloakApiRails: could not reach Keycloak at '#{uri}'. #{e.class}: #{e.message}")
56
+ raise HTTPError, "Could not reach Keycloak at '#{uri}': #{e.message}"
57
+ end
58
+
59
+ def parse(response, path)
60
+ JSON.parse(response.body)
61
+ rescue JSON::ParserError => e
62
+ @logger.error("KeycloakApiRails: could not parse the response of '#{path}'. #{e.message}")
63
+ raise HTTPError, "Keycloak returned a malformed JSON payload when calling '#{path}'"
64
+ end
65
+
30
66
  def build_uri(realm_id, path)
31
- string_uri = File.join(@server_url, "realms", realm_id, path)
32
- URI(string_uri)
67
+ URI(File.join(@configuration.server_url, "realms", realm_id, path))
33
68
  end
34
69
  end
35
70
  end
@@ -8,36 +8,33 @@ module KeycloakApiRails
8
8
  def call(env)
9
9
  method = env["REQUEST_METHOD"]
10
10
  path = env["PATH_INFO"]
11
- uri = env["REQUEST_URI"]
12
11
 
13
12
  if service.need_middleware_authentication?(method, path, env)
14
13
  logger.debug("Start authentication for #{method} : #{path}")
15
- token = service.read_token(uri, env)
16
- decoded_token = service.decode_and_verify(token)
17
- authentication_succeeded(env, decoded_token)
14
+ begin
15
+ authenticate(env)
16
+ rescue TokenError => e
17
+ logger.debug("The error causing the Token to fail: #{e.original_error&.message || e.message}")
18
+ return authentication_failed(e.message)
19
+ end
18
20
  else
19
21
  logger.debug("Skip authentication for #{method} : #{path}")
20
- @app.call(env)
21
22
  end
22
- rescue TokenError => e
23
- logger.debug("The error causing the Token to fail: #{e.original_error&.message || e.message}")
24
- authentication_failed(e.message)
23
+
24
+ @app.call(env)
25
25
  end
26
26
 
27
- def authentication_failed(message)
28
- [401, {"Content-Type" => "application/json"}, [ { error: message }.to_json]]
27
+ private
28
+
29
+ def authenticate(env)
30
+ token = service.read_token(Helper.request_uri(env), env)
31
+ decoded_token = service.decode_and_verify(token)
32
+ Helper.assign_token(env, decoded_token, config.custom_attributes)
29
33
  end
30
34
 
31
- def authentication_succeeded(env, decoded_token)
32
- Helper.assign_current_user_id(env, decoded_token)
33
- Helper.assign_current_authorized_party(env, decoded_token)
34
- Helper.assign_current_user_email(env, decoded_token)
35
- Helper.assign_current_user_locale(env, decoded_token)
36
- Helper.assign_current_user_custom_attributes(env, decoded_token, config.custom_attributes)
37
- Helper.assign_realm_roles(env, decoded_token)
38
- Helper.assign_resource_roles(env, decoded_token)
39
- Helper.assign_keycloak_token(env, decoded_token)
40
- @app.call(env)
35
+ def authentication_failed(message)
36
+ # Rack 3 requires header names to be lowercase.
37
+ [401, { "content-type" => "application/json" }, [{ error: message }.to_json]]
41
38
  end
42
39
 
43
40
  def service
@@ -1,30 +1,49 @@
1
1
  module KeycloakApiRails
2
2
  class PublicKeyCachedResolver
3
+ FAILED_REFRESH_RETRY_DELAY_IN_SECONDS = 10
4
+
3
5
  attr_reader :cached_public_key_retrieved_at
4
6
 
5
- def initialize(http_client, realm_id, public_key_cache_ttl)
7
+ def initialize(http_client, realm_id, public_key_cache_ttl, logger = nil)
6
8
  @resolver = PublicKeyResolver.new(http_client, realm_id)
7
9
  @public_key_cache_ttl = public_key_cache_ttl
10
+ @logger = logger
8
11
  @cached_public_keys = nil
9
12
  @cached_public_key_retrieved_at = nil
13
+ @last_refresh_failure_at = nil
14
+ @mutex = Mutex.new
10
15
  end
11
16
 
12
17
  def self.from_configuration(http_client, configuration)
13
- PublicKeyCachedResolver.new(http_client, configuration.realm_id, configuration.public_key_cache_ttl)
18
+ new(http_client, configuration.realm_id, configuration.public_key_cache_ttl, configuration.logger)
14
19
  end
15
20
 
16
21
  def find_public_keys
17
- if public_keys_are_outdated?
18
- @cached_public_keys = @resolver.find_public_keys
19
- @cached_public_key_retrieved_at = Time.now
22
+ @mutex.synchronize do
23
+ refresh_public_keys if public_keys_are_outdated?
24
+ @cached_public_keys
20
25
  end
21
- @cached_public_keys
22
26
  end
23
27
 
24
28
  private
25
29
 
30
+ def refresh_public_keys
31
+ @cached_public_keys = @resolver.find_public_keys
32
+ @cached_public_key_retrieved_at = Time.now
33
+ @last_refresh_failure_at = nil
34
+ rescue StandardError => e
35
+ raise if @cached_public_keys.nil?
36
+
37
+ @last_refresh_failure_at = Time.now
38
+ @logger&.warn("KeycloakApiRails: could not refresh the public keys (#{e.class}: #{e.message}). Keeping the ones retrieved at #{@cached_public_key_retrieved_at}.")
39
+ end
40
+
26
41
  def public_keys_are_outdated?
27
- @cached_public_keys.nil? || @cached_public_key_retrieved_at.nil? || Time.now > (@cached_public_key_retrieved_at + @public_key_cache_ttl)
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))
28
47
  end
29
48
  end
30
49
  end
@@ -5,5 +5,20 @@ module KeycloakApiRails
5
5
  initializer("keycloak.insert_middleware") do |app|
6
6
  app.config.middleware.use(KeycloakApiRails::Middleware)
7
7
  end
8
+
9
+ # Runs once every initializer has run, config/initializers/keycloak.rb included, so that a
10
+ # misconfiguration fails at boot instead of on the first request reaching the middleware.
11
+ config.after_initialize do
12
+ keycloak_configuration = KeycloakApiRails.config
13
+ keycloak_configuration.validate!
14
+
15
+ unless keycloak_configuration.server_configured?
16
+ keycloak_configuration.logger.warn(
17
+ "KeycloakApiRails: 'server_url' and 'realm_id' are not both configured. No token can be " \
18
+ "verified until they are, unless the public key resolver is replaced -- as " \
19
+ "\"keycloak-api-rails/testing\" does."
20
+ )
21
+ end
22
+ end
8
23
  end
9
- end
24
+ end
@@ -1,38 +1,43 @@
1
1
  module KeycloakApiRails
2
+ class MissingPublicKeysError < StandardError; end
3
+
2
4
  class Service
3
-
5
+
4
6
  def initialize(key_resolver)
7
+ configuration = KeycloakApiRails.config
5
8
  @key_resolver = key_resolver
6
- @skip_paths = KeycloakApiRails.config.skip_paths
7
- @opt_in = KeycloakApiRails.config.opt_in
8
- @logger = KeycloakApiRails.config.logger
9
- @token_expiration_tolerance_in_seconds = KeycloakApiRails.config.token_expiration_tolerance_in_seconds
9
+ @skip_paths = normalize_skip_paths(configuration.skip_paths)
10
+ @opt_in = configuration.opt_in
11
+ @token_expiration_tolerance_in_seconds = configuration.token_expiration_tolerance_in_seconds
12
+ @expected_audiences = Array(configuration.expected_audience).map(&:to_s)
13
+ @expected_token_type = configuration.expected_token_type
14
+ @verify_not_before = configuration.verify_not_before
15
+ @allow_token_in_query_string = configuration.allow_token_in_query_string
10
16
  end
11
17
 
12
18
  def decode_and_verify(token)
13
- unless token.nil? || token&.empty?
14
- public_key = @key_resolver.find_public_keys
15
- decoded_token = JSON::JWT.decode(token, public_key)
16
-
17
- unless expired?(decoded_token)
18
- decoded_token.verify!(public_key)
19
- decoded_token
20
- else
21
- raise TokenError.expired(token)
22
- end
23
- else
24
- raise TokenError.no_token(token)
19
+ raise TokenError.no_token(token) if token.nil? || token.empty?
20
+
21
+ public_keys = @key_resolver.find_public_keys
22
+
23
+ if public_keys.nil?
24
+ raise MissingPublicKeysError, "No Keycloak public key is available to verify the token"
25
25
  end
26
- rescue JSON::JWT::VerificationFailed => e
27
- raise TokenError.verification_failed(token, e)
28
- rescue JSON::JWK::Set::KidNotFound => e
29
- raise TokenError.verification_failed(token, e)
30
- rescue JSON::JWT::InvalidFormat
31
- raise TokenError.invalid_format(token, e)
26
+
27
+ decoded_token = decode(token, public_keys)
28
+ verify_claims!(token, decoded_token)
29
+ decoded_token
32
30
  end
33
31
 
34
32
  def read_token(uri, headers)
35
- Helper.read_token_from_query_string(uri) || Helper.read_token_from_headers(headers)
33
+ header_token = Helper.read_token_from_headers(headers)
34
+ if !header_token.empty?
35
+ header_token
36
+ elsif @allow_token_in_query_string
37
+ Helper.read_token_from_query_string(uri).to_s
38
+ else
39
+ ""
40
+ end
36
41
  end
37
42
 
38
43
  def need_middleware_authentication?(method, path, headers)
@@ -41,14 +46,39 @@ module KeycloakApiRails
41
46
 
42
47
  private
43
48
 
49
+ def decode(token, public_keys)
50
+ decoded_token = JSON::JWT.decode(token, public_keys)
51
+ decoded_token.verify!(public_keys)
52
+ decoded_token
53
+ rescue JSON::JWT::VerificationFailed, JSON::JWK::Set::KidNotFound => e
54
+ raise TokenError.verification_failed(token, e)
55
+ rescue JSON::JWT::InvalidFormat => e
56
+ raise TokenError.invalid_format(token, e)
57
+ rescue StandardError => e
58
+ raise TokenError.unknown(token, e)
59
+ end
60
+
61
+ def verify_claims!(token, decoded_token)
62
+ raise TokenError.missing_claim(token, "exp") unless decoded_token.key?("exp")
63
+ raise TokenError.expired(token) if expired?(decoded_token)
64
+ raise TokenError.not_yet_valid(token) if not_yet_valid?(decoded_token)
65
+ raise TokenError.invalid_audience(token) unless audience_valid?(decoded_token)
66
+ raise TokenError.invalid_token_type(token) unless token_type_valid?(decoded_token)
67
+ end
68
+
69
+ def normalize_skip_paths(skip_paths)
70
+ (skip_paths || {}).each_with_object({}) do |(method, paths), normalized|
71
+ normalized[method.to_s.downcase.to_sym] = Array(paths)
72
+ end
73
+ end
74
+
44
75
  def should_skip?(method, path)
45
- method_symbol = method&.downcase&.to_sym
46
- skip_paths = @skip_paths[method_symbol]
47
- !skip_paths.nil? && !skip_paths.empty? && !skip_paths.find_index { |skip_path| skip_path.match(path) }.nil?
76
+ skip_paths = @skip_paths[method&.to_s&.downcase&.to_sym]
77
+ !skip_paths.nil? && skip_paths.any? { |skip_path| skip_path.match(path) }
48
78
  end
49
79
 
50
80
  def is_preflight?(method, headers)
51
- method_symbol = method&.downcase&.to_sym
81
+ method_symbol = method&.to_s&.downcase&.to_sym
52
82
  method_symbol == :options && !headers["HTTP_ACCESS_CONTROL_REQUEST_METHOD"].nil?
53
83
  end
54
84
 
@@ -56,5 +86,24 @@ module KeycloakApiRails
56
86
  token_expiration = Time.at(token["exp"])
57
87
  token_expiration < Time.now + @token_expiration_tolerance_in_seconds
58
88
  end
89
+
90
+ def not_yet_valid?(token)
91
+ return false unless @verify_not_before
92
+
93
+ not_before = token["nbf"]
94
+ !not_before.nil? && Time.at(not_before) > Time.now
95
+ end
96
+
97
+ def audience_valid?(token)
98
+ return true if @expected_audiences.empty?
99
+
100
+ Array(token["aud"]).any? { |audience| @expected_audiences.include?(audience.to_s) }
101
+ end
102
+
103
+ def token_type_valid?(token)
104
+ return true if @expected_token_type.nil?
105
+
106
+ token["typ"].to_s.casecmp?(@expected_token_type)
107
+ end
59
108
  end
60
109
  end
@@ -38,19 +38,21 @@ module KeycloakApiRails
38
38
  end
39
39
  end
40
40
 
41
+ MONITOR = Monitor.new
42
+
41
43
  class << self
42
44
  # The RSA key pair used to sign the tokens forged by this module. It is generated once per
43
45
  # process: generating a key is by far the slowest operation of a test suite that uses tokens.
44
46
  def private_key
45
- @private_key ||= OpenSSL::PKey::RSA.generate(KEY_SIZE)
47
+ MONITOR.synchronize { @private_key ||= OpenSSL::PKey::RSA.generate(KEY_SIZE) }
46
48
  end
47
49
 
48
50
  def signing_key
49
- @signing_key ||= JSON::JWK.new(private_key, kid: KEY_ID)
51
+ MONITOR.synchronize { @signing_key ||= JSON::JWK.new(private_key, kid: KEY_ID) }
50
52
  end
51
53
 
52
54
  def public_keys
53
- @public_keys ||= JSON::JWK::Set.new(JSON::JWK.new(private_key.public_key, kid: KEY_ID))
55
+ MONITOR.synchronize { @public_keys ||= JSON::JWK::Set.new(JSON::JWK.new(private_key.public_key, kid: KEY_ID)) }
54
56
  end
55
57
 
56
58
  # Makes the library validate the tokens forged by this module. Assigning
@@ -1,30 +1,48 @@
1
- class TokenError < StandardError
2
- attr_reader :token, :reason, :original_error
3
-
4
- def initialize(token, reason, message, original_error)
5
- super(message)
6
- @token = token
7
- @reason = reason
8
- @original_error = original_error
9
- end
1
+ module KeycloakApiRails
2
+ class TokenError < StandardError
3
+ attr_reader :token, :reason, :original_error
10
4
 
11
- def self.verification_failed(token, original_error)
12
- TokenError.new(token, :verification_failed, "Failed to verify JWT token", original_error)
13
- end
5
+ def initialize(token, reason, message, original_error = nil)
6
+ super(message)
7
+ @token = token
8
+ @reason = reason
9
+ @original_error = original_error
10
+ end
14
11
 
15
- def self.invalid_format(token, original_error)
16
- TokenError.new(token, :invalid_format, "Wrong JWT Format", original_error)
17
- end
12
+ def self.verification_failed(token, original_error)
13
+ new(token, :verification_failed, "Failed to verify JWT token", original_error)
14
+ end
18
15
 
19
- def self.no_token(token)
20
- TokenError.new(token, :no_token, "No JWT token provided", nil)
21
- end
16
+ def self.invalid_format(token, original_error)
17
+ new(token, :invalid_format, "Wrong JWT Format", original_error)
18
+ end
22
19
 
23
- def self.expired(token)
24
- TokenError.new(token, :expired, "JWT token is expired", nil)
25
- end
20
+ def self.no_token(token)
21
+ new(token, :no_token, "No JWT token provided")
22
+ end
23
+
24
+ def self.expired(token)
25
+ new(token, :expired, "JWT token is expired")
26
+ end
27
+
28
+ def self.not_yet_valid(token)
29
+ new(token, :not_yet_valid, "JWT token is not valid yet")
30
+ end
31
+
32
+ def self.invalid_audience(token)
33
+ new(token, :invalid_audience, "JWT token has been issued for another audience")
34
+ end
35
+
36
+ def self.invalid_token_type(token)
37
+ new(token, :invalid_token_type, "JWT token is not of the expected type")
38
+ end
39
+
40
+ def self.missing_claim(token, claim)
41
+ new(token, :missing_claim, "JWT token does not carry the mandatory claim '#{claim}'")
42
+ end
26
43
 
27
- def self.unknown(token)
28
- TokenError.new
44
+ def self.unknown(token, original_error)
45
+ new(token, :unknown, "Failed to read JWT token", original_error)
46
+ end
29
47
  end
30
48
  end
@@ -1,3 +1,3 @@
1
1
  module KeycloakApiRails
2
- VERSION = "1.1.2"
2
+ VERSION = "2.0.0"
3
3
  end
@@ -2,6 +2,7 @@ require "logger"
2
2
  require "json/jwt"
3
3
  require "uri"
4
4
  require "date"
5
+ require "monitor"
5
6
  require "net/http"
6
7
 
7
8
  require_relative "keycloak-api-rails/authentication"
@@ -17,8 +18,13 @@ require_relative "keycloak-api-rails/railtie" if defined?(Rails)
17
18
 
18
19
  module KeycloakApiRails
19
20
 
21
+ # These objects are memoized lazily, on the first request each process serves -- which several
22
+ # threads of a threaded server reach at the same time. A Monitor rather than a Mutex: the
23
+ # memoizations nest, 'service' needing 'public_key_resolver', which needs 'http_client'.
24
+ MONITOR = Monitor.new
25
+
20
26
  def self.configure
21
- yield @configuration ||= KeycloakApiRails::Configuration.new
27
+ MONITOR.synchronize { yield @configuration ||= KeycloakApiRails::Configuration.new }
22
28
  end
23
29
 
24
30
  def self.config
@@ -26,23 +32,25 @@ module KeycloakApiRails
26
32
  end
27
33
 
28
34
  def self.http_client
29
- @http_client ||= KeycloakApiRails::HTTPClient.new(config, logger)
35
+ MONITOR.synchronize { @http_client ||= KeycloakApiRails::HTTPClient.new(config, logger) }
30
36
  end
31
37
 
32
38
  def self.public_key_resolver
33
- @public_key_resolver ||= PublicKeyCachedResolver.from_configuration(http_client, config)
39
+ MONITOR.synchronize { @public_key_resolver ||= PublicKeyCachedResolver.from_configuration(http_client, config) }
34
40
  end
35
41
 
36
42
  # Mainly used by "keycloak-api-rails/testing" to validate tokens without a Keycloak server.
37
43
  # Assigning nil restores the regular resolver. The memoized service is discarded, since it holds
38
44
  # a reference to the resolver that is being replaced.
39
45
  def self.public_key_resolver=(resolver)
40
- @public_key_resolver = resolver
41
- @service = nil
46
+ MONITOR.synchronize do
47
+ @public_key_resolver = resolver
48
+ @service = nil
49
+ end
42
50
  end
43
51
 
44
52
  def self.service
45
- @service ||= KeycloakApiRails::Service.new(public_key_resolver)
53
+ MONITOR.synchronize { @service ||= KeycloakApiRails::Service.new(public_key_resolver) }
46
54
  end
47
55
 
48
56
  def self.logger
@@ -59,6 +67,13 @@ module KeycloakApiRails
59
67
  config.token_expiration_tolerance_in_seconds = 10
60
68
  config.public_key_cache_ttl = 86400
61
69
  config.custom_attributes = []
70
+ config.ca_certificate_file = nil
71
+ config.expected_audience = nil
72
+ config.expected_token_type = nil
73
+ config.verify_not_before = false
74
+ config.allow_token_in_query_string = false
75
+ config.http_open_timeout = 5
76
+ config.http_read_timeout = 5
62
77
  end
63
78
  end
64
79
 
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: 1.1.2
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lorent Lempereur
@@ -10,7 +10,7 @@ cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
- name: rails
13
+ name: railties
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
16
  - - ">="
@@ -65,6 +65,34 @@ dependencies:
65
65
  - - '='
66
66
  - !ruby/object:Gem::Version
67
67
  version: 0.9.11
68
+ - !ruby/object:Gem::Dependency
69
+ name: rails
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '4.2'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '4.2'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rack
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
68
96
  - !ruby/object:Gem::Dependency
69
97
  name: rake
70
98
  requirement: !ruby/object:Gem::Requirement