keycloak-api-rails 2.0.2 → 2.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -1
- data/README.md +37 -11
- data/keycloak-api-rails.gemspec +4 -0
- data/lib/keycloak-api-rails/authentication.rb +4 -2
- data/lib/keycloak-api-rails/configuration.rb +15 -0
- data/lib/keycloak-api-rails/middleware.rb +11 -11
- data/lib/keycloak-api-rails/public_key_cached_resolver.rb +36 -6
- data/lib/keycloak-api-rails/service.rb +29 -8
- data/lib/keycloak-api-rails/token_error.rb +9 -0
- data/lib/keycloak-api-rails/version.rb +1 -1
- data/lib/keycloak-api-rails.rb +9 -3
- metadata +43 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 75cb985d142693a50337445bdde2608ee636c3ce77327e577a88f1d3ad6f12d7
|
|
4
|
+
data.tar.gz: a9220359016e6c96f594d2e43485bb2e5c2821da9d2055c6c622eef26fcdf33f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fab532886c13b4e2b950ff31b591d9670b0d8a343e835afa8922b35cd742142f63a6943685332d9ad5baf1146344dd6ff452209793f8dc7864c5a3322a520fad
|
|
7
|
+
data.tar.gz: 0c8110fc856d3e74e49fb675749fcdac113808d140a15c29c01838abcd760ef10ff78527aec128e5c7eaa2c8d7d879ef1ebe111972fd1d0e8671ad188c2f638c
|
data/CHANGELOG.md
CHANGED
|
@@ -7,13 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
### Security
|
|
11
|
+
|
|
12
|
+
* The realm named by the `iss` claim is read before the signature of the token is verified, and is interpolated in the URL its public keys are downloaded from. It is now refused unless it is a plain name: the unreserved characters of RFC 3986, at most 128 of them, and not a relative path segment. `https://keycloak/realms/master?` and `https://keycloak/realms/..` both are the last segment of a well-formed `iss`, and both used to build another URL than the one of the realm they name. Such a token is answered a `401`, reason `:invalid_realm`, and no request is sent to Keycloak.
|
|
13
|
+
* A token whose payload is not a JSON object, or whose `iss` claim is not a String, is answered a `401`. Reading such a payload used to raise a `NoMethodError` or a `TypeError` out of the middleware, and to be answered a `500`.
|
|
14
|
+
* The number of realms whose public keys are cached is now bounded.
|
|
15
|
+
* A token was verified with whichever algorithm its `alg` header named. New `allowed_algorithms` option, defaulting to the asymmetric algorithms Keycloak signs with, and narrowable to the one of the realm. Symmetric algorithms are refused.
|
|
16
|
+
|
|
17
|
+
* A `skip_paths` regexp anchored with `^` or `$` is warned about: in Ruby they match the beginning and the end of a line, and `PATH_INFO` is URL-decoded, so `"/private\n/health"` matches `/^\/health/` and skips authentication. Anchor with `\A` and `\z`.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
* `base64` and `logger` are declared as runtime dependencies.
|
|
22
|
+
* A `401` carries the `WWW-Authenticate: Bearer` header RFC 6750 requires of a resource protected by a Bearer token. A request carrying no token at all gets the bare challenge; any other gets `error="invalid_token"` and the reason it was refused.
|
|
23
|
+
|
|
10
24
|
### Performance
|
|
11
25
|
|
|
26
|
+
* An authenticated request took four locks to read two memoized objects: `KeycloakApiRails.service`, resolved three times per request, and the per-realm key cache. They are read without locking once memoized, and the middleware resolves the service once. Worth 0.3 µs per request, against the 50 µs of the RSA verification below.
|
|
27
|
+
* Every authenticated request verified the signature of its token twice: `JSON::JWT.decode` already verifies the one it is given a key for. One RSA verification per request instead of two.
|
|
12
28
|
* Added `# frozen_string_literal: true` to all ruby files and optimized Hot Path methods in `Service` and `Helper` (e.g., using `Regexp#match?`, avoiding redundant String-to-Symbol conversions) to significantly reduce CPU usage and memory allocations per request.
|
|
13
29
|
|
|
14
30
|
### Added
|
|
15
31
|
|
|
16
|
-
*
|
|
32
|
+
* CI checks the dependencies against the Ruby advisory database on every push, with `bundler-audit`, and Dependabot keeps the actions of the workflows up to date.
|
|
33
|
+
* Integration tests ensuring E2E compatibility with a real Keycloak server using Docker.
|
|
34
|
+
* Automated tests in CI against multiple Keycloak versions (19.0.3, 22.0.5, 25.0.0, 26.7.0).
|
|
35
|
+
* Enforced that the Rake `release` task cannot be executed locally without passing the test suite first.
|
|
36
|
+
* Multi-tenancy support: `realm_id` can now be a String, an Array of Strings, or a Proc (e.g. `->(realm) { Tenant.exists?(name: realm) }`) receiving the realm named by the token and answering whether it is allowed.
|
|
17
37
|
* The middleware now validates the token against the allowed realms. A token carrying an `iss` claim that does not match one of the expected realms will be rejected. New `KeycloakApiRails::TokenError` reason: `:invalid_realm`.
|
|
18
38
|
* `PublicKeyCachedResolver` caches public keys per-realm, ensuring safe operation in a multi-tenant environment.
|
|
19
39
|
|
data/README.md
CHANGED
|
@@ -18,8 +18,7 @@ the recommended path.
|
|
|
18
18
|
## Install
|
|
19
19
|
|
|
20
20
|
```ruby
|
|
21
|
-
gem "keycloak-api-rails", "2.0.
|
|
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.
|
|
38
|
+
Alternatively, it can be configured to `opt-in` to validation. The middleware is still installed and controllers request (opt-in) by including the module `KeycloakApiRails::Authentication` and calling `keycloak_authenticate`, for example in a `before_action`, like so:
|
|
40
39
|
|
|
41
40
|
```ruby
|
|
42
41
|
class MyApiController < ActionController::Base
|
|
@@ -60,10 +59,10 @@ All options have a default value. However, all of them can be changed in your in
|
|
|
60
59
|
|
|
61
60
|
| Option | Default Value | Type | Required? | Description | Example |
|
|
62
61
|
| ---- | ----- | ------ | ----- | ------ | ----- |
|
|
63
|
-
| `server_url` | `nil`| String | Required | The base url where your Keycloak server is located.
|
|
64
|
-
| `realm_id` | `nil`| String, Array or Proc | Required | Realm's name(s) (not id, actually).
|
|
62
|
+
| `server_url` | `nil`| String | Required | The base url where your Keycloak server is located, scheme included. Without one, the url cannot be parsed and no public key is ever downloaded | `https://keycloak.example.org` or `http://auth:8080` |
|
|
63
|
+
| `realm_id` | `nil`| String, Array or Proc | Required | Realm's name(s) (not id, actually). A single String, an Array of Strings for multiple tenants, or a Proc receiving the realm named by the token and answering whether it is allowed | `"master"`, `["tenant1", "tenant2"]` or `->(realm) { Tenant.exists?(name: realm) }` |
|
|
65
64
|
| `logger` | `Logger.new(STDOUT)`| Logger | Optional | The logger used by `keycloak-api-rails` | `Rails.logger` |
|
|
66
|
-
| `skip_paths` | `{}`| Hash of methods and paths regexp | Optional | Paths whose token must not be validated. Each path must be a `Regexp
|
|
65
|
+
| `skip_paths` | `{}`| Hash of methods and paths regexp | Optional | Paths whose token must not be validated, matched against the URL-decoded path of the request. Each path must be a `Regexp`, anchored with `\A` and `\z`: a String is refused when the application boots, and `^` and `$` are warned about, for the reasons given below | `{ get: [/\A\/health\/.+/] }`|
|
|
67
66
|
| `opt_in` | `false` | Boolean | Optional | When false, every request is validated by the middleware, except the ones matching `skip_paths`. When true, no middleware validates anything and authentication must be requested explicitly, by calling `keycloak_authenticate` from a controller | `true`
|
|
68
67
|
| `token_expiration_tolerance_in_seconds` | `10`| Integer | Optional | Safety margin: a token is rejected this number of seconds *before* the date of its `exp` claim, so that it cannot expire in the middle of a request | `15` |
|
|
69
68
|
| `public_key_cache_ttl` | `86400`| Integer | Optional | Amount of time, in seconds, specifying maximum interval between two requests to Keycloak to retrieve new public keys. It is 86400 seconds (1 day) by default. At least once per this configured interval (1 day by default) will be new public key always downloaded. The refresh happens under a lock and blocks request threads if Keycloak hangs; reducing this TTL increases the frequency of this risk. | `3600` |
|
|
@@ -71,6 +70,7 @@ All options have a default value. However, all of them can be changed in your in
|
|
|
71
70
|
| `ca_certificate_file` | `nil`| String | Optional | Path to the certificate authority used to validate the Keycloak server certificate | `/credentials/production_root_ca_cert.pem` |
|
|
72
71
|
| `expected_audience` | `nil`| String or Array of String | Optional | When set, a token is rejected unless its `aud` claim carries one of these audiences. Left unset, every token signed by the realm is accepted, including its ID tokens and the tokens issued for its other clients | `"my-api"` |
|
|
73
72
|
| `expected_token_type` | `nil`| String | Optional | When set, a token is rejected unless its `typ` claim matches (case-insensitive). Keycloak types its access tokens `Bearer` | `"Bearer"` |
|
|
73
|
+
| `allowed_algorithms` | every asymmetric algorithm Keycloak signs with: `[:RS256, :RS384, :RS512, :PS256, :PS384, :PS512, :ES256, :ES384, :ES512]` | Array of String or Symbol | Optional | The algorithms a token may be signed with. Narrow it down to the one of the realm, so that the `alg` header of a token cannot pick how its own signature is verified. Symmetric algorithms are refused: a token checked against a JWKS is checked against a public key, and a key that verifies is a key that signs | `[:RS256]` |
|
|
74
74
|
| `verify_not_before` | `false`| Boolean | Optional | When true, a token whose `nbf` claim is in the future is rejected. Disabled by default: a clock skew between Keycloak and the API would reject valid tokens | `true` |
|
|
75
75
|
| `allow_token_in_query_string` | `false`| Boolean | Optional | When true, a request carrying no `Authorization` header may be authenticated by the `authorizationToken` parameter of its query string | `true` |
|
|
76
76
|
| `http_open_timeout` | `5`| Integer | Optional | Seconds to wait for the connection to Keycloak to open, when downloading the public keys | `2` |
|
|
@@ -90,12 +90,19 @@ KeycloakApiRails.configure do |config|
|
|
|
90
90
|
config.realm_id = ENV["KEYCLOAK_REALM_ID"]
|
|
91
91
|
config.logger = Rails.logger
|
|
92
92
|
config.skip_paths = {
|
|
93
|
-
post: [
|
|
94
|
-
get: [
|
|
93
|
+
post: [/\A\/message/],
|
|
94
|
+
get: [/\A\/locales/, /\A\/health\/.+/]
|
|
95
95
|
}
|
|
96
96
|
end
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
+
Anchor those regexps with `\A` and `\z`, never with `^` and `$`. In Ruby, `^` and `$` match the
|
|
100
|
+
beginning and the end of a *line*, not of the string. The path they are matched against is
|
|
101
|
+
`PATH_INFO`, which the server has already URL-decoded, so a `%0a` in the request reaches them as a
|
|
102
|
+
real newline: `"/private\n/health"` matches `/^\/health/` and skips authentication on a path that
|
|
103
|
+
was never meant to be open. A regexp anchored with `^` or `$` is warned about when the first request
|
|
104
|
+
is served.
|
|
105
|
+
|
|
99
106
|
Or using opt-in configuration:
|
|
100
107
|
|
|
101
108
|
```ruby
|
|
@@ -129,11 +136,16 @@ Keycloak only adds an API to the `aud` claim of a token once that API is declare
|
|
|
129
136
|
`config.verify_not_before = true` additionally rejects a token whose `nbf` claim is in the future.
|
|
130
137
|
It is disabled by default because a clock skew between Keycloak and the API rejects valid tokens.
|
|
131
138
|
|
|
139
|
+
The `iss` claim is checked against `server_url` only when one is configured. A test environment replacing the public key resolver, as `keycloak-api-rails/testing` does, accepts any issuer.
|
|
140
|
+
|
|
132
141
|
## Multi-tenancy (Multiple Realms)
|
|
133
142
|
|
|
134
143
|
The library natively supports multi-tenancy by validating tokens issued by multiple Keycloak realms.
|
|
135
144
|
Instead of a static string, you can configure `realm_id` with an Array or a Proc. The library will dynamically extract the realm from the token's `iss` claim, check if it is permitted, and fetch the appropriate public keys for that specific realm.
|
|
136
145
|
|
|
146
|
+
Those realms all live under the same `server_url`: a token whose `iss` names another server is rejected, however allowed the realm it names.
|
|
147
|
+
Declaring `expected_audience` matters all the more here — without it, a token issued for any client of any allowed realm opens the API.
|
|
148
|
+
|
|
137
149
|
Using an Array of allowed realms:
|
|
138
150
|
```ruby
|
|
139
151
|
KeycloakApiRails.configure do |config|
|
|
@@ -301,9 +313,23 @@ Assigning `KeycloakApiRails.public_key_resolver = nil` restores the regular reso
|
|
|
301
313
|
|
|
302
314
|
From the `keycloak-rails-api` directory:
|
|
303
315
|
|
|
316
|
+
```
|
|
317
|
+
$ bundle exec rspec
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
The examples of `spec/integration` are excluded: they start a Keycloak container, and without Docker
|
|
321
|
+
they wait for a server that never answers. Run them, against the Keycloak version of your choice,
|
|
322
|
+
with:
|
|
323
|
+
|
|
324
|
+
```
|
|
325
|
+
$ KEYCLOAK_INTEGRATION=1 KEYCLOAK_VERSION=26.7.0 bundle exec rspec spec/integration
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Or run everything in a container, which needs a Docker socket of its own to reach Keycloak:
|
|
329
|
+
|
|
304
330
|
```
|
|
305
331
|
$ docker build . -t keycloak-rails-api:test
|
|
306
|
-
$ docker run -v `pwd`:/usr/src/app/ keycloak-rails-api:test bundle exec rspec
|
|
332
|
+
$ docker run -v `pwd`:/usr/src/app/ keycloak-rails-api:test bundle exec rspec
|
|
307
333
|
```
|
|
308
334
|
|
|
309
335
|
## How to release a new version
|
|
@@ -318,8 +344,8 @@ scoped RubyGems credential.
|
|
|
318
344
|
3. Tag the commit and push the tag:
|
|
319
345
|
|
|
320
346
|
```
|
|
321
|
-
$ git tag -a v2.0.
|
|
322
|
-
$ git push origin v2.0.
|
|
347
|
+
$ git tag -a v2.0.3 -m "Version 2.0.3"
|
|
348
|
+
$ git push origin v2.0.3
|
|
323
349
|
```
|
|
324
350
|
|
|
325
351
|
The workflow then checks that the tag matches `KeycloakApiRails::VERSION`, runs the tests, builds the gem
|
data/keycloak-api-rails.gemspec
CHANGED
|
@@ -31,10 +31,14 @@ Gem::Specification.new do |spec|
|
|
|
31
31
|
spec.add_dependency "railties", ">= 4.2"
|
|
32
32
|
spec.add_dependency "json-jwt", ">= 1.11.0"
|
|
33
33
|
|
|
34
|
+
spec.add_dependency "base64"
|
|
35
|
+
spec.add_dependency "logger"
|
|
36
|
+
|
|
34
37
|
spec.add_development_dependency "rspec", "3.13.2"
|
|
35
38
|
spec.add_development_dependency "timecop", "0.9.11"
|
|
36
39
|
spec.add_development_dependency "rails", ">= 4.2"
|
|
37
40
|
spec.add_development_dependency "rack"
|
|
38
41
|
spec.add_development_dependency "rake", ">= 13.0"
|
|
39
42
|
spec.add_development_dependency "byebug", ">= 11.1.3"
|
|
43
|
+
spec.add_development_dependency "bundler-audit", ">= 0.9"
|
|
40
44
|
end
|
|
@@ -16,10 +16,12 @@ module KeycloakApiRails
|
|
|
16
16
|
path = env["PATH_INFO"]
|
|
17
17
|
|
|
18
18
|
KeycloakApiRails.logger.debug("Start authentication for #{method} : #{path}")
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
service = KeycloakApiRails.service
|
|
20
|
+
token = service.read_token(Helper.request_uri(env), env)
|
|
21
|
+
decoded_token = service.decode_and_verify(token)
|
|
21
22
|
authentication_succeeded(env, decoded_token)
|
|
22
23
|
rescue TokenError => e
|
|
24
|
+
response.headers["WWW-Authenticate"] = e.challenge
|
|
23
25
|
authentication_failed(e.message)
|
|
24
26
|
rescue KeycloakApiRails::HTTPError, KeycloakApiRails::MissingPublicKeysError => e
|
|
25
27
|
authentication_unavailable(e)
|
|
@@ -21,6 +21,7 @@ module KeycloakApiRails
|
|
|
21
21
|
attr_accessor :allow_token_in_query_string
|
|
22
22
|
attr_accessor :http_open_timeout
|
|
23
23
|
attr_accessor :http_read_timeout
|
|
24
|
+
attr_accessor :allowed_algorithms
|
|
24
25
|
|
|
25
26
|
def validate!
|
|
26
27
|
errors = []
|
|
@@ -41,6 +42,7 @@ module KeycloakApiRails
|
|
|
41
42
|
errors.concat(custom_attributes_errors)
|
|
42
43
|
errors.concat(skip_paths_errors)
|
|
43
44
|
errors.concat(expected_audience_errors)
|
|
45
|
+
errors.concat(allowed_algorithms_errors)
|
|
44
46
|
|
|
45
47
|
raise InvalidConfigurationError, "Invalid Keycloak configuration: #{errors.join('; ')}" unless errors.empty?
|
|
46
48
|
|
|
@@ -97,6 +99,19 @@ module KeycloakApiRails
|
|
|
97
99
|
end
|
|
98
100
|
end
|
|
99
101
|
|
|
102
|
+
def allowed_algorithms_errors
|
|
103
|
+
unless allowed_algorithms.is_a?(Array) && !allowed_algorithms.empty?
|
|
104
|
+
return ["'allowed_algorithms' must be a non-empty Array of algorithm names, got #{allowed_algorithms.inspect}"]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
unsupported = allowed_algorithms.reject { |algorithm| Service::SUPPORTED_ALGORITHMS.include?(algorithm.to_s.to_sym) }
|
|
108
|
+
if unsupported.empty?
|
|
109
|
+
[]
|
|
110
|
+
else
|
|
111
|
+
["'allowed_algorithms' declares #{unsupported.inspect}, which this library does not verify. It accepts #{Service::SUPPORTED_ALGORITHMS.inspect}"]
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
100
115
|
def boolean?(value)
|
|
101
116
|
value == true || value == false
|
|
102
117
|
end
|
|
@@ -8,16 +8,17 @@ module KeycloakApiRails
|
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
def call(env)
|
|
11
|
-
method
|
|
12
|
-
path
|
|
11
|
+
method = env["REQUEST_METHOD"]
|
|
12
|
+
path = env["PATH_INFO"]
|
|
13
|
+
service = KeycloakApiRails.service
|
|
13
14
|
|
|
14
15
|
if service.need_middleware_authentication?(method, path, env)
|
|
15
16
|
logger.debug("Start authentication for #{method} : #{path}")
|
|
16
17
|
begin
|
|
17
|
-
authenticate(env)
|
|
18
|
+
authenticate(service, env)
|
|
18
19
|
rescue TokenError => e
|
|
19
20
|
logger.debug("The error causing the Token to fail: #{e.original_error&.message || e.message}")
|
|
20
|
-
return authentication_failed(e
|
|
21
|
+
return authentication_failed(e)
|
|
21
22
|
rescue HTTPError, MissingPublicKeysError => e
|
|
22
23
|
logger.error("KeycloakApiRails: no token can be verified for #{method} : #{path}. #{e.class}: #{e.message}")
|
|
23
24
|
return authentication_unavailable
|
|
@@ -31,15 +32,18 @@ module KeycloakApiRails
|
|
|
31
32
|
|
|
32
33
|
private
|
|
33
34
|
|
|
34
|
-
def authenticate(env)
|
|
35
|
+
def authenticate(service, env)
|
|
35
36
|
token = service.read_token(Helper.request_uri(env), env)
|
|
36
37
|
decoded_token = service.decode_and_verify(token)
|
|
37
38
|
Helper.assign_token(env, decoded_token, config.custom_attributes)
|
|
38
39
|
end
|
|
39
40
|
|
|
40
|
-
def authentication_failed(
|
|
41
|
+
def authentication_failed(error)
|
|
41
42
|
# Rack 3 requires header names to be lowercase.
|
|
42
|
-
[401,
|
|
43
|
+
[401,
|
|
44
|
+
{ "content-type" => "application/json",
|
|
45
|
+
"www-authenticate" => error.challenge },
|
|
46
|
+
[{ error: error.message }.to_json]]
|
|
43
47
|
end
|
|
44
48
|
|
|
45
49
|
def authentication_unavailable
|
|
@@ -49,10 +53,6 @@ module KeycloakApiRails
|
|
|
49
53
|
[{ error: "Authentication is temporarily unavailable" }.to_json]]
|
|
50
54
|
end
|
|
51
55
|
|
|
52
|
-
def service
|
|
53
|
-
KeycloakApiRails.service
|
|
54
|
-
end
|
|
55
|
-
|
|
56
56
|
def logger
|
|
57
57
|
KeycloakApiRails.logger
|
|
58
58
|
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
module KeycloakApiRails
|
|
4
4
|
class PublicKeyCachedResolver
|
|
5
5
|
FAILED_REFRESH_RETRY_DELAY_IN_SECONDS = 10
|
|
6
|
+
MAX_CACHED_REALMS = 64
|
|
6
7
|
|
|
7
8
|
class RealmCache
|
|
8
9
|
attr_reader :cached_public_key_retrieved_at
|
|
@@ -58,10 +59,12 @@ module KeycloakApiRails
|
|
|
58
59
|
def initialize(http_client, realm_id, public_key_cache_ttl, logger = nil)
|
|
59
60
|
@http_client = http_client
|
|
60
61
|
@realm_id = realm_id
|
|
62
|
+
@configured_realms = configured_realms(realm_id)
|
|
61
63
|
@public_key_cache_ttl = public_key_cache_ttl
|
|
62
64
|
@logger = logger
|
|
63
65
|
@caches = {}
|
|
64
66
|
@caches_mutex = Mutex.new
|
|
67
|
+
@refusal_logged = false
|
|
65
68
|
end
|
|
66
69
|
|
|
67
70
|
def self.from_configuration(http_client, configuration)
|
|
@@ -69,22 +72,49 @@ module KeycloakApiRails
|
|
|
69
72
|
end
|
|
70
73
|
|
|
71
74
|
def find_public_keys(realm_id = nil)
|
|
72
|
-
|
|
73
|
-
cache_for(target_realm).find_public_keys(@public_key_cache_ttl, @logger)
|
|
75
|
+
cache_for(realm_id || default_realm).find_public_keys(@public_key_cache_ttl, @logger)
|
|
74
76
|
end
|
|
75
77
|
|
|
76
78
|
# Keep this method backward-compatible for testing
|
|
77
79
|
def cached_public_key_retrieved_at(realm_id = nil)
|
|
78
|
-
|
|
79
|
-
cache_for(target_realm).cached_public_key_retrieved_at
|
|
80
|
+
cache_for(realm_id || default_realm).cached_public_key_retrieved_at
|
|
80
81
|
end
|
|
81
82
|
|
|
82
83
|
private
|
|
83
84
|
|
|
85
|
+
def default_realm
|
|
86
|
+
@realm_id if @realm_id.is_a?(String)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def configured_realms(realm_id)
|
|
90
|
+
case realm_id
|
|
91
|
+
when String then [realm_id].freeze
|
|
92
|
+
when Array then realm_id.dup.freeze
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
84
96
|
def cache_for(realm_id)
|
|
85
|
-
@caches_mutex.synchronize
|
|
86
|
-
|
|
97
|
+
@caches[realm_id] || @caches_mutex.synchronize { @caches[realm_id] || create_cache(realm_id) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def create_cache(realm_id)
|
|
101
|
+
refuse(realm_id, "it is not one of the configured realms") unless cacheable?(realm_id)
|
|
102
|
+
refuse(realm_id, "#{MAX_CACHED_REALMS} realms are already cached") if @caches.size >= MAX_CACHED_REALMS
|
|
103
|
+
|
|
104
|
+
@caches[realm_id] = RealmCache.new(@http_client, realm_id)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def cacheable?(realm_id)
|
|
108
|
+
!realm_id.nil? && (@configured_realms.nil? || @configured_realms.include?(realm_id))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def refuse(realm_id, reason)
|
|
112
|
+
unless @refusal_logged
|
|
113
|
+
@refusal_logged = true
|
|
114
|
+
@logger&.warn("KeycloakApiRails: no public key is downloaded for the realm #{realm_id.inspect}, #{reason}. The requests naming it are answered a 503. Further refusals are not logged.")
|
|
87
115
|
end
|
|
116
|
+
|
|
117
|
+
raise MissingPublicKeysError, "No Keycloak public key can be downloaded for the realm #{realm_id.inspect}"
|
|
88
118
|
end
|
|
89
119
|
end
|
|
90
120
|
end
|
|
@@ -5,6 +5,8 @@ module KeycloakApiRails
|
|
|
5
5
|
class MissingPublicKeysError < StandardError; end
|
|
6
6
|
|
|
7
7
|
class Service
|
|
8
|
+
REALM_NAME = /\A(?!\.+\z)[A-Za-z0-9._~-]{1,128}\z/.freeze
|
|
9
|
+
SUPPORTED_ALGORITHMS = %i[RS256 RS384 RS512 PS256 PS384 PS512 ES256 ES384 ES512].freeze
|
|
8
10
|
|
|
9
11
|
def initialize(key_resolver)
|
|
10
12
|
configuration = KeycloakApiRails.config
|
|
@@ -16,6 +18,7 @@ module KeycloakApiRails
|
|
|
16
18
|
@expected_token_type = configuration.expected_token_type
|
|
17
19
|
@verify_not_before = configuration.verify_not_before
|
|
18
20
|
@allow_token_in_query_string = configuration.allow_token_in_query_string
|
|
21
|
+
@allowed_algorithms = Array(configuration.allowed_algorithms).map(&:to_sym)
|
|
19
22
|
end
|
|
20
23
|
|
|
21
24
|
def decode_and_verify(token)
|
|
@@ -43,11 +46,21 @@ module KeycloakApiRails
|
|
|
43
46
|
return nil unless payload_segment
|
|
44
47
|
|
|
45
48
|
decoded_payload = Base64.urlsafe_decode64(payload_segment)
|
|
46
|
-
parsed_payload
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
parsed_payload = JSON.parse(decoded_payload)
|
|
50
|
+
|
|
51
|
+
if parsed_payload.is_a?(Hash)
|
|
52
|
+
iss = parsed_payload['iss']
|
|
53
|
+
return nil unless iss.is_a?(String)
|
|
54
|
+
|
|
55
|
+
realm_id = iss.split('/').last
|
|
56
|
+
if realm_id&.match?(REALM_NAME)
|
|
57
|
+
realm_id
|
|
58
|
+
else
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
else
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
51
64
|
rescue JSON::ParserError, ArgumentError
|
|
52
65
|
nil
|
|
53
66
|
end
|
|
@@ -84,9 +97,7 @@ module KeycloakApiRails
|
|
|
84
97
|
private
|
|
85
98
|
|
|
86
99
|
def decode(token, public_keys)
|
|
87
|
-
|
|
88
|
-
decoded_token.verify!(public_keys)
|
|
89
|
-
decoded_token
|
|
100
|
+
JSON::JWT.decode(token, public_keys, @allowed_algorithms)
|
|
90
101
|
rescue JSON::JWT::VerificationFailed, JSON::JWK::Set::KidNotFound => e
|
|
91
102
|
raise TokenError.verification_failed(token, e)
|
|
92
103
|
rescue JSON::JWT::InvalidFormat => e
|
|
@@ -128,10 +139,20 @@ module KeycloakApiRails
|
|
|
128
139
|
logger&.warn("KeycloakApiRails: 'skip_paths[#{method.inspect}]' declares #{discarded.map(&:inspect).join(', ')}, which are not regexps. They are ignored, and the paths they were meant to open keep being authenticated.")
|
|
129
140
|
end
|
|
130
141
|
|
|
142
|
+
line_anchored = regexps.select { |regexp| line_anchored?(regexp) }
|
|
143
|
+
unless line_anchored.empty?
|
|
144
|
+
logger&.warn("KeycloakApiRails: 'skip_paths[#{method.inspect}]' declares #{line_anchored.map(&:inspect).join(', ')}, anchored with '^' or '$'. In Ruby those match the beginning and the end of a line, not of the path: \"/private\\n/health\" matches /^\\/health/ and skips authentication. Anchor with '\\A' and '\\z'.")
|
|
145
|
+
end
|
|
146
|
+
|
|
131
147
|
normalized[method.to_s.upcase] = regexps
|
|
132
148
|
end
|
|
133
149
|
end
|
|
134
150
|
|
|
151
|
+
# Escaped pairs are dropped first, so that '\^' does not count and '[^/]' is read as a class.
|
|
152
|
+
def line_anchored?(regexp)
|
|
153
|
+
regexp.source.gsub(/\\./, "").gsub(/\[[^\]]*\]/, "").match?(/[\^$]/)
|
|
154
|
+
end
|
|
155
|
+
|
|
135
156
|
def should_skip?(method, path)
|
|
136
157
|
skip_paths = @skip_paths[method]
|
|
137
158
|
!skip_paths.nil? && skip_paths.any? { |skip_path| skip_path.match?(path) }
|
|
@@ -11,6 +11,15 @@ module KeycloakApiRails
|
|
|
11
11
|
@original_error = original_error
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
+
# RFC 6750: a request that carried no credentials at all gets the bare challenge, no error code.
|
|
15
|
+
def challenge
|
|
16
|
+
if reason == :no_token
|
|
17
|
+
"Bearer"
|
|
18
|
+
else
|
|
19
|
+
%(Bearer error="invalid_token", error_description="#{message.gsub(/["\\]/, '')}")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
14
23
|
def self.verification_failed(token, original_error)
|
|
15
24
|
new(token, :verification_failed, "Failed to verify JWT token", original_error)
|
|
16
25
|
end
|
data/lib/keycloak-api-rails.rb
CHANGED
|
@@ -23,6 +23,11 @@ module KeycloakApiRails
|
|
|
23
23
|
# These objects are memoized lazily, on the first request each process serves -- which several
|
|
24
24
|
# threads of a threaded server reach at the same time. A Monitor rather than a Mutex: the
|
|
25
25
|
# memoizations nest, 'service' needing 'public_key_resolver', which needs 'http_client'.
|
|
26
|
+
#
|
|
27
|
+
# Once memoized they are read without taking it: the assignment publishes an object that is fully
|
|
28
|
+
# built, and MRI reads an instance variable atomically. A 'configure' running while requests are
|
|
29
|
+
# already being served may then be picked up one request late, which is one more reason for it to
|
|
30
|
+
# belong in an initializer.
|
|
26
31
|
MONITOR = Monitor.new
|
|
27
32
|
|
|
28
33
|
def self.configure
|
|
@@ -44,11 +49,11 @@ module KeycloakApiRails
|
|
|
44
49
|
end
|
|
45
50
|
|
|
46
51
|
def self.http_client
|
|
47
|
-
MONITOR.synchronize { @http_client ||= KeycloakApiRails::HTTPClient.new(config, logger) }
|
|
52
|
+
@http_client || MONITOR.synchronize { @http_client ||= KeycloakApiRails::HTTPClient.new(config, logger) }
|
|
48
53
|
end
|
|
49
54
|
|
|
50
55
|
def self.public_key_resolver
|
|
51
|
-
MONITOR.synchronize { @public_key_resolver ||= PublicKeyCachedResolver.from_configuration(http_client, config) }
|
|
56
|
+
@public_key_resolver || MONITOR.synchronize { @public_key_resolver ||= PublicKeyCachedResolver.from_configuration(http_client, config) }
|
|
52
57
|
end
|
|
53
58
|
|
|
54
59
|
# Mainly used by "keycloak-api-rails/testing" to validate tokens without a Keycloak server.
|
|
@@ -63,7 +68,7 @@ module KeycloakApiRails
|
|
|
63
68
|
end
|
|
64
69
|
|
|
65
70
|
def self.service
|
|
66
|
-
MONITOR.synchronize { @service ||= KeycloakApiRails::Service.new(public_key_resolver) }
|
|
71
|
+
@service || MONITOR.synchronize { @service ||= KeycloakApiRails::Service.new(public_key_resolver) }
|
|
67
72
|
end
|
|
68
73
|
|
|
69
74
|
def self.logger
|
|
@@ -87,6 +92,7 @@ module KeycloakApiRails
|
|
|
87
92
|
config.allow_token_in_query_string = false
|
|
88
93
|
config.http_open_timeout = 5
|
|
89
94
|
config.http_read_timeout = 5
|
|
95
|
+
config.allowed_algorithms = KeycloakApiRails::Service::SUPPORTED_ALGORITHMS
|
|
90
96
|
end
|
|
91
97
|
end
|
|
92
98
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: keycloak-api-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.0.
|
|
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
|