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