keycloak-api-rails 2.0.0 → 2.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +52 -0
- data/README.md +62 -19
- data/keycloak-api-rails.gemspec +4 -0
- data/lib/keycloak-api-rails/authentication.rb +14 -2
- data/lib/keycloak-api-rails/configuration.rb +48 -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 +21 -9
- data/lib/keycloak-api-rails/public_key_cached_resolver.rb +98 -27
- 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 +93 -13
- data/lib/keycloak-api-rails/testing.rb +9 -1
- data/lib/keycloak-api-rails/token_error.rb +20 -1
- data/lib/keycloak-api-rails/version.rb +3 -1
- data/lib/keycloak-api-rails.rb +25 -6
- 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
|
@@ -5,6 +5,58 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
### Security
|
|
11
|
+
|
|
12
|
+
* The realm named by the `iss` claim is read before the signature of the token is verified, and is interpolated in the URL its public keys are downloaded from. It is now refused unless it is a plain name: the unreserved characters of RFC 3986, at most 128 of them, and not a relative path segment. `https://keycloak/realms/master?` and `https://keycloak/realms/..` both are the last segment of a well-formed `iss`, and both used to build another URL than the one of the realm they name. Such a token is answered a `401`, reason `:invalid_realm`, and no request is sent to Keycloak.
|
|
13
|
+
* A token whose payload is not a JSON object, or whose `iss` claim is not a String, is answered a `401`. Reading such a payload used to raise a `NoMethodError` or a `TypeError` out of the middleware, and to be answered a `500`.
|
|
14
|
+
* The number of realms whose public keys are cached is now bounded.
|
|
15
|
+
* A token was verified with whichever algorithm its `alg` header named. New `allowed_algorithms` option, defaulting to the asymmetric algorithms Keycloak signs with, and narrowable to the one of the realm. Symmetric algorithms are refused.
|
|
16
|
+
|
|
17
|
+
* A `skip_paths` regexp anchored with `^` or `$` is warned about: in Ruby they match the beginning and the end of a line, and `PATH_INFO` is URL-decoded, so `"/private\n/health"` matches `/^\/health/` and skips authentication. Anchor with `\A` and `\z`.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
* `base64` and `logger` are declared as runtime dependencies.
|
|
22
|
+
* A `401` carries the `WWW-Authenticate: Bearer` header RFC 6750 requires of a resource protected by a Bearer token. A request carrying no token at all gets the bare challenge; any other gets `error="invalid_token"` and the reason it was refused.
|
|
23
|
+
|
|
24
|
+
### Performance
|
|
25
|
+
|
|
26
|
+
* An authenticated request took four locks to read two memoized objects: `KeycloakApiRails.service`, resolved three times per request, and the per-realm key cache. They are read without locking once memoized, and the middleware resolves the service once. Worth 0.3 µs per request, against the 50 µs of the RSA verification below.
|
|
27
|
+
* Every authenticated request verified the signature of its token twice: `JSON::JWT.decode` already verifies the one it is given a key for. One RSA verification per request instead of two.
|
|
28
|
+
* Added `# frozen_string_literal: true` to all ruby files and optimized Hot Path methods in `Service` and `Helper` (e.g., using `Regexp#match?`, avoiding redundant String-to-Symbol conversions) to significantly reduce CPU usage and memory allocations per request.
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
* CI checks the dependencies against the Ruby advisory database on every push, with `bundler-audit`, and Dependabot keeps the actions of the workflows up to date.
|
|
33
|
+
* Integration tests ensuring E2E compatibility with a real Keycloak server using Docker.
|
|
34
|
+
* Automated tests in CI against multiple Keycloak versions (19.0.3, 22.0.5, 25.0.0, 26.7.0).
|
|
35
|
+
* Enforced that the Rake `release` task cannot be executed locally without passing the test suite first.
|
|
36
|
+
* Multi-tenancy support: `realm_id` can now be a String, an Array of Strings, or a Proc (e.g. `->(realm) { Tenant.exists?(name: realm) }`) receiving the realm named by the token and answering whether it is allowed.
|
|
37
|
+
* The middleware now validates the token against the allowed realms. A token carrying an `iss` claim that does not match one of the expected realms will be rejected. New `KeycloakApiRails::TokenError` reason: `:invalid_realm`.
|
|
38
|
+
* `PublicKeyCachedResolver` caches public keys per-realm, ensuring safe operation in a multi-tenant environment.
|
|
39
|
+
|
|
40
|
+
## [2.0.1] - 2026-08-02
|
|
41
|
+
|
|
42
|
+
### Security
|
|
43
|
+
|
|
44
|
+
* `skip_paths` declaring its paths as Strings, e.g. `{ get: ["/health/db"] }`, opened routes that had to be authenticated.
|
|
45
|
+
* A path that is not a `Regexp` is discarded by the middleware rather than matched, and a warning is logged naming it.
|
|
46
|
+
* With `verify_not_before` enabled, an `nbf` claim carried as null was read as an absent one, skipping the very check the option asks for. It is now rejected.
|
|
47
|
+
* A token whose `exp` or `nbf` claim is not a number of seconds is answered a `401`, where `Time.at` used to raise a `TypeError` and answer a `500`. The 2.0.0 guard only checked that `exp` was present, not that it held a NumericDate. New `KeycloakApiRails::TokenError` reason: `:invalid_claim`.
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
* `custom_attributes` declared as Symbols, e.g. `[:tenant_id]`, matched no claim at all: those of a decoded token are keyed by String. Both forms are honoured.
|
|
52
|
+
* `custom_attributes` holding something else than a claim name is reported by `Configuration#validate!`, instead of being silently read from no token.
|
|
53
|
+
* `KeycloakApiRails.configure` discards the service, the public key resolver and the HTTP client it had memoized
|
|
54
|
+
|
|
55
|
+
### Availability
|
|
56
|
+
|
|
57
|
+
* A request whose token cannot be verified at all, Keycloak being unreachable and no public key having ever been retrieved, is answered a `503` carrying a `Retry-After` header. `KeycloakApiRails::HTTPError` and `KeycloakApiRails::MissingPublicKeysError` used to escape the middleware, and `keycloak_authenticate`, as a `500`: an outage of Keycloak was reported as a bug of the application. A request carrying no token at all is still answered a `401`, and the paths of `skip_paths` are still served.
|
|
58
|
+
* With nothing cached, a Keycloak that is down was called again by every single request, one at a time behind the mutex of the resolver, each waiting for `http_open_timeout` and `http_read_timeout` to elapse: ten concurrent requests held ten threads for a hundred seconds. It is now called once per `FAILED_REFRESH_RETRY_DELAY_IN_SECONDS`, and the error of the last attempt is raised straight away in between.
|
|
59
|
+
|
|
8
60
|
## [2.0.0] - 2026-08-01
|
|
9
61
|
|
|
10
62
|
### Breaking changes
|
data/README.md
CHANGED
|
@@ -18,8 +18,7 @@ the recommended path.
|
|
|
18
18
|
## Install
|
|
19
19
|
|
|
20
20
|
```ruby
|
|
21
|
-
gem "keycloak-api-rails", "2.0.
|
|
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,17 +59,18 @@ All options have a default value. However, all of them can be changed in your in
|
|
|
60
59
|
|
|
61
60
|
| Option | Default Value | Type | Required? | Description | Example |
|
|
62
61
|
| ---- | ----- | ------ | ----- | ------ | ----- |
|
|
63
|
-
| `server_url` | `nil`| String | Required | The base url where your Keycloak server is located.
|
|
64
|
-
| `realm_id` | `nil`| String | Required | Realm's name (not id, actually) | `master` |
|
|
62
|
+
| `server_url` | `nil`| String | Required | The base url where your Keycloak server is located, scheme included. Without one, the url cannot be parsed and no public key is ever downloaded | `https://keycloak.example.org` or `http://auth:8080` |
|
|
63
|
+
| `realm_id` | `nil`| String, Array or Proc | Required | Realm's name(s) (not id, actually). A single String, an Array of Strings for multiple tenants, or a Proc receiving the realm named by the token and answering whether it is allowed | `"master"`, `["tenant1", "tenant2"]` or `->(realm) { Tenant.exists?(name: realm) }` |
|
|
65
64
|
| `logger` | `Logger.new(STDOUT)`| Logger | Optional | The logger used by `keycloak-api-rails` | `Rails.logger` |
|
|
66
|
-
| `skip_paths` | `{}`| Hash of methods and paths regexp | Optional | Paths whose token must not be validated | `{ get: [
|
|
65
|
+
| `skip_paths` | `{}`| Hash of methods and paths regexp | Optional | Paths whose token must not be validated, matched against the URL-decoded path of the request. Each path must be a `Regexp`, anchored with `\A` and `\z`: a String is refused when the application boots, and `^` and `$` are warned about, for the reasons given below | `{ get: [/\A\/health\/.+/] }`|
|
|
67
66
|
| `opt_in` | `false` | Boolean | Optional | When false, every request is validated by the middleware, except the ones matching `skip_paths`. When true, no middleware validates anything and authentication must be requested explicitly, by calling `keycloak_authenticate` from a controller | `true`
|
|
68
67
|
| `token_expiration_tolerance_in_seconds` | `10`| Integer | Optional | Safety margin: a token is rejected this number of seconds *before* the date of its `exp` claim, so that it cannot expire in the middle of a request | `15` |
|
|
69
|
-
| `public_key_cache_ttl` | `86400`| Integer | Optional | Amount of time, in seconds, specifying maximum interval between two requests to Keycloak to retrieve new public keys. It is 86400 seconds (1 day) by default. At least once per this configured interval (1 day by default) will be new public key always downloaded. | `3600` |
|
|
70
|
-
| `custom_attributes` | `[]`| Array
|
|
68
|
+
| `public_key_cache_ttl` | `86400`| Integer | Optional | Amount of time, in seconds, specifying maximum interval between two requests to Keycloak to retrieve new public keys. It is 86400 seconds (1 day) by default. At least once per this configured interval (1 day by default) will be new public key always downloaded. The refresh happens under a lock and blocks request threads if Keycloak hangs; reducing this TTL increases the frequency of this risk. | `3600` |
|
|
69
|
+
| `custom_attributes` | `[]`| Array of String or Symbol | Optional | List of token attributes to read from each token and to add to their http request env | `["originalFirstName", "originalLastName"]` |
|
|
71
70
|
| `ca_certificate_file` | `nil`| String | Optional | Path to the certificate authority used to validate the Keycloak server certificate | `/credentials/production_root_ca_cert.pem` |
|
|
72
71
|
| `expected_audience` | `nil`| String or Array of String | Optional | When set, a token is rejected unless its `aud` claim carries one of these audiences. Left unset, every token signed by the realm is accepted, including its ID tokens and the tokens issued for its other clients | `"my-api"` |
|
|
73
|
-
| `expected_token_type` | `nil`| String | Optional | When set, a token is rejected unless its `typ` claim matches. Keycloak types its access tokens `Bearer` | `"Bearer"` |
|
|
72
|
+
| `expected_token_type` | `nil`| String | Optional | When set, a token is rejected unless its `typ` claim matches (case-insensitive). Keycloak types its access tokens `Bearer` | `"Bearer"` |
|
|
73
|
+
| `allowed_algorithms` | every asymmetric algorithm Keycloak signs with: `[:RS256, :RS384, :RS512, :PS256, :PS384, :PS512, :ES256, :ES384, :ES512]` | Array of String or Symbol | Optional | The algorithms a token may be signed with. Narrow it down to the one of the realm, so that the `alg` header of a token cannot pick how its own signature is verified. Symmetric algorithms are refused: a token checked against a JWKS is checked against a public key, and a key that verifies is a key that signs | `[:RS256]` |
|
|
74
74
|
| `verify_not_before` | `false`| Boolean | Optional | When true, a token whose `nbf` claim is in the future is rejected. Disabled by default: a clock skew between Keycloak and the API would reject valid tokens | `true` |
|
|
75
75
|
| `allow_token_in_query_string` | `false`| Boolean | Optional | When true, a request carrying no `Authorization` header may be authenticated by the `authorizationToken` parameter of its query string | `true` |
|
|
76
76
|
| `http_open_timeout` | `5`| Integer | Optional | Seconds to wait for the connection to Keycloak to open, when downloading the public keys | `2` |
|
|
@@ -90,12 +90,19 @@ KeycloakApiRails.configure do |config|
|
|
|
90
90
|
config.realm_id = ENV["KEYCLOAK_REALM_ID"]
|
|
91
91
|
config.logger = Rails.logger
|
|
92
92
|
config.skip_paths = {
|
|
93
|
-
post: [
|
|
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,6 +136,32 @@ Keycloak only adds an API to the `aud` claim of a token once that API is declare
|
|
|
129
136
|
`config.verify_not_before = true` additionally rejects a token whose `nbf` claim is in the future.
|
|
130
137
|
It is disabled by default because a clock skew between Keycloak and the API rejects valid tokens.
|
|
131
138
|
|
|
139
|
+
The `iss` claim is checked against `server_url` only when one is configured. A test environment replacing the public key resolver, as `keycloak-api-rails/testing` does, accepts any issuer.
|
|
140
|
+
|
|
141
|
+
## Multi-tenancy (Multiple Realms)
|
|
142
|
+
|
|
143
|
+
The library natively supports multi-tenancy by validating tokens issued by multiple Keycloak realms.
|
|
144
|
+
Instead of a static string, you can configure `realm_id` with an Array or a Proc. The library will dynamically extract the realm from the token's `iss` claim, check if it is permitted, and fetch the appropriate public keys for that specific realm.
|
|
145
|
+
|
|
146
|
+
Those realms all live under the same `server_url`: a token whose `iss` names another server is rejected, however allowed the realm it names.
|
|
147
|
+
Declaring `expected_audience` matters all the more here — without it, a token issued for any client of any allowed realm opens the API.
|
|
148
|
+
|
|
149
|
+
Using an Array of allowed realms:
|
|
150
|
+
```ruby
|
|
151
|
+
KeycloakApiRails.configure do |config|
|
|
152
|
+
config.server_url = ENV["KEYCLOAK_SERVER_URL"]
|
|
153
|
+
config.realm_id = ["master", "tenant-1", "tenant-2"]
|
|
154
|
+
end
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Using a Proc for dynamic validation (e.g., querying the database):
|
|
158
|
+
```ruby
|
|
159
|
+
KeycloakApiRails.configure do |config|
|
|
160
|
+
config.server_url = ENV["KEYCLOAK_SERVER_URL"]
|
|
161
|
+
config.realm_id = ->(realm) { Tenant.exists?(name: realm) }
|
|
162
|
+
end
|
|
163
|
+
```
|
|
164
|
+
|
|
132
165
|
## Use cases
|
|
133
166
|
|
|
134
167
|
Once this gem is configured in your Rails project, you can read, validate and use tokens in your controllers.
|
|
@@ -212,7 +245,7 @@ For instance, to read a provided token:
|
|
|
212
245
|
```ruby
|
|
213
246
|
class RenderTokenController < ApplicationController
|
|
214
247
|
def show
|
|
215
|
-
uri = request.env
|
|
248
|
+
uri = KeycloakApiRails::Helper.request_uri(request.env)
|
|
216
249
|
headers = request.env
|
|
217
250
|
token = KeycloakApiRails.service.read_token(uri, headers)
|
|
218
251
|
render json: { token: token }, status: :ok
|
|
@@ -280,9 +313,23 @@ Assigning `KeycloakApiRails.public_key_resolver = nil` restores the regular reso
|
|
|
280
313
|
|
|
281
314
|
From the `keycloak-rails-api` directory:
|
|
282
315
|
|
|
316
|
+
```
|
|
317
|
+
$ bundle exec rspec
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
The examples of `spec/integration` are excluded: they start a Keycloak container, and without Docker
|
|
321
|
+
they wait for a server that never answers. Run them, against the Keycloak version of your choice,
|
|
322
|
+
with:
|
|
323
|
+
|
|
324
|
+
```
|
|
325
|
+
$ KEYCLOAK_INTEGRATION=1 KEYCLOAK_VERSION=26.7.0 bundle exec rspec spec/integration
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Or run everything in a container, which needs a Docker socket of its own to reach Keycloak:
|
|
329
|
+
|
|
283
330
|
```
|
|
284
331
|
$ docker build . -t keycloak-rails-api:test
|
|
285
|
-
$ docker run -v `pwd`:/usr/src/app/ keycloak-rails-api:test bundle exec rspec
|
|
332
|
+
$ docker run -v `pwd`:/usr/src/app/ keycloak-rails-api:test bundle exec rspec
|
|
286
333
|
```
|
|
287
334
|
|
|
288
335
|
## How to release a new version
|
|
@@ -297,13 +344,9 @@ scoped RubyGems credential.
|
|
|
297
344
|
3. Tag the commit and push the tag:
|
|
298
345
|
|
|
299
346
|
```
|
|
300
|
-
$ git tag -a v2.0.
|
|
301
|
-
$ git push origin v2.0.
|
|
347
|
+
$ git tag -a v2.0.3 -m "Version 2.0.3"
|
|
348
|
+
$ git push origin v2.0.3
|
|
302
349
|
```
|
|
303
350
|
|
|
304
351
|
The workflow then checks that the tag matches `KeycloakApiRails::VERSION`, runs the tests, builds the gem
|
|
305
352
|
and pushes it. It only publishes tags starting with `v`.
|
|
306
|
-
|
|
307
|
-
## Next developments
|
|
308
|
-
|
|
309
|
-
* Manage multiple realms
|
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
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module KeycloakApiRails
|
|
2
4
|
module Authentication
|
|
3
5
|
def self.included(base)
|
|
@@ -14,11 +16,15 @@ module KeycloakApiRails
|
|
|
14
16
|
path = env["PATH_INFO"]
|
|
15
17
|
|
|
16
18
|
KeycloakApiRails.logger.debug("Start authentication for #{method} : #{path}")
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
service = KeycloakApiRails.service
|
|
20
|
+
token = service.read_token(Helper.request_uri(env), env)
|
|
21
|
+
decoded_token = service.decode_and_verify(token)
|
|
19
22
|
authentication_succeeded(env, decoded_token)
|
|
20
23
|
rescue TokenError => e
|
|
24
|
+
response.headers["WWW-Authenticate"] = e.challenge
|
|
21
25
|
authentication_failed(e.message)
|
|
26
|
+
rescue KeycloakApiRails::HTTPError, KeycloakApiRails::MissingPublicKeysError => e
|
|
27
|
+
authentication_unavailable(e)
|
|
22
28
|
end
|
|
23
29
|
|
|
24
30
|
def authentication_failed(message)
|
|
@@ -26,6 +32,12 @@ module KeycloakApiRails
|
|
|
26
32
|
render status: :unauthorized, json: { error: message }
|
|
27
33
|
end
|
|
28
34
|
|
|
35
|
+
def authentication_unavailable(error)
|
|
36
|
+
KeycloakApiRails.logger.error("KeycloakApiRails: no token can be verified. #{error.class}: #{error.message}")
|
|
37
|
+
response.headers["Retry-After"] = KeycloakApiRails::PublicKeyCachedResolver::FAILED_REFRESH_RETRY_DELAY_IN_SECONDS.to_s
|
|
38
|
+
render status: :service_unavailable, json: { error: "Authentication is temporarily unavailable" }
|
|
39
|
+
end
|
|
40
|
+
|
|
29
41
|
def authentication_succeeded(env, decoded_token)
|
|
30
42
|
Helper.assign_token(env, decoded_token, KeycloakApiRails.config.custom_attributes)
|
|
31
43
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module KeycloakApiRails
|
|
2
4
|
class InvalidConfigurationError < StandardError; end
|
|
3
5
|
|
|
@@ -19,17 +21,17 @@ module KeycloakApiRails
|
|
|
19
21
|
attr_accessor :allow_token_in_query_string
|
|
20
22
|
attr_accessor :http_open_timeout
|
|
21
23
|
attr_accessor :http_read_timeout
|
|
24
|
+
attr_accessor :allowed_algorithms
|
|
22
25
|
|
|
23
26
|
def validate!
|
|
24
27
|
errors = []
|
|
25
28
|
|
|
26
29
|
errors.push("'server_url' must be a String or nil, got #{server_url.inspect}") unless server_url.nil? || server_url.is_a?(String)
|
|
27
|
-
errors.push("'realm_id' must be a String or nil, got #{realm_id.inspect}") unless
|
|
30
|
+
errors.push("'realm_id' must be a String, an Array of Strings, a Proc, or nil, got #{realm_id.inspect}") unless valid_realm_id?(realm_id)
|
|
28
31
|
errors.push("'logger' must respond to #{LOGGER_METHODS.join(', ')}") unless LOGGER_METHODS.all? { |method| logger.respond_to?(method) }
|
|
29
32
|
errors.push("'opt_in' must be true or false, got #{opt_in.inspect}") unless boolean?(opt_in)
|
|
30
33
|
errors.push("'verify_not_before' must be true or false, got #{verify_not_before.inspect}") unless boolean?(verify_not_before)
|
|
31
34
|
errors.push("'allow_token_in_query_string' must be true or false, got #{allow_token_in_query_string.inspect}") unless boolean?(allow_token_in_query_string)
|
|
32
|
-
errors.push("'custom_attributes' must be an Array of claim names, got #{custom_attributes.inspect}") unless custom_attributes.is_a?(Array)
|
|
33
35
|
errors.push("'token_expiration_tolerance_in_seconds' must be a number of seconds, got #{token_expiration_tolerance_in_seconds.inspect}") unless number?(token_expiration_tolerance_in_seconds, allow_zero: true)
|
|
34
36
|
errors.push("'public_key_cache_ttl' must be a positive number of seconds, got #{public_key_cache_ttl.inspect}") unless number?(public_key_cache_ttl)
|
|
35
37
|
errors.push("'http_open_timeout' must be a positive number of seconds, got #{http_open_timeout.inspect}") unless number?(http_open_timeout)
|
|
@@ -37,8 +39,10 @@ module KeycloakApiRails
|
|
|
37
39
|
errors.push("'expected_token_type' must be a String or nil, got #{expected_token_type.inspect}") unless expected_token_type.nil? || expected_token_type.is_a?(String)
|
|
38
40
|
errors.push("'ca_certificate_file' must be the path of a readable file, got #{ca_certificate_file.inspect}") unless ca_certificate_file.nil? || File.readable?(ca_certificate_file.to_s)
|
|
39
41
|
|
|
42
|
+
errors.concat(custom_attributes_errors)
|
|
40
43
|
errors.concat(skip_paths_errors)
|
|
41
44
|
errors.concat(expected_audience_errors)
|
|
45
|
+
errors.concat(allowed_algorithms_errors)
|
|
42
46
|
|
|
43
47
|
raise InvalidConfigurationError, "Invalid Keycloak configuration: #{errors.join('; ')}" unless errors.empty?
|
|
44
48
|
|
|
@@ -61,13 +65,26 @@ module KeycloakApiRails
|
|
|
61
65
|
|
|
62
66
|
private
|
|
63
67
|
|
|
68
|
+
def custom_attributes_errors
|
|
69
|
+
if custom_attributes.is_a?(Array)
|
|
70
|
+
invalid_names = custom_attributes.reject { |name| name.is_a?(String) || name.is_a?(Symbol) }
|
|
71
|
+
if invalid_names.empty?
|
|
72
|
+
[]
|
|
73
|
+
else
|
|
74
|
+
["'custom_attributes' must only contain claim names, as Strings or Symbols, got #{invalid_names.inspect}"]
|
|
75
|
+
end
|
|
76
|
+
else
|
|
77
|
+
["'custom_attributes' must be an Array of claim names, got #{custom_attributes.inspect}"]
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
64
81
|
def skip_paths_errors
|
|
65
82
|
return ["'skip_paths' must be a Hash of HTTP methods and path regexps, got #{skip_paths.inspect}"] unless skip_paths.is_a?(Hash)
|
|
66
83
|
|
|
67
84
|
skip_paths.filter_map do |method, paths|
|
|
68
|
-
next if paths.is_a?(Array) && paths.all? { |path| path.
|
|
85
|
+
next if paths.is_a?(Array) && paths.all? { |path| path.is_a?(Regexp) }
|
|
69
86
|
|
|
70
|
-
"'skip_paths[#{method.inspect}]' must be an Array of regexps, got #{paths.inspect}"
|
|
87
|
+
"'skip_paths[#{method.inspect}]' must be an Array of regexps, got #{paths.inspect}. A String is refused because 'String#match' compiles its argument into a regexp: the path of the request would become the pattern, and routes that must be authenticated would be skipped"
|
|
71
88
|
end
|
|
72
89
|
end
|
|
73
90
|
|
|
@@ -82,6 +99,19 @@ module KeycloakApiRails
|
|
|
82
99
|
end
|
|
83
100
|
end
|
|
84
101
|
|
|
102
|
+
def allowed_algorithms_errors
|
|
103
|
+
unless allowed_algorithms.is_a?(Array) && !allowed_algorithms.empty?
|
|
104
|
+
return ["'allowed_algorithms' must be a non-empty Array of algorithm names, got #{allowed_algorithms.inspect}"]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
unsupported = allowed_algorithms.reject { |algorithm| Service::SUPPORTED_ALGORITHMS.include?(algorithm.to_s.to_sym) }
|
|
108
|
+
if unsupported.empty?
|
|
109
|
+
[]
|
|
110
|
+
else
|
|
111
|
+
["'allowed_algorithms' declares #{unsupported.inspect}, which this library does not verify. It accepts #{Service::SUPPORTED_ALGORITHMS.inspect}"]
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
85
115
|
def boolean?(value)
|
|
86
116
|
value == true || value == false
|
|
87
117
|
end
|
|
@@ -91,7 +121,20 @@ module KeycloakApiRails
|
|
|
91
121
|
end
|
|
92
122
|
|
|
93
123
|
def missing?(value)
|
|
94
|
-
|
|
124
|
+
if value.is_a?(Array)
|
|
125
|
+
value.empty?
|
|
126
|
+
elsif value.is_a?(String)
|
|
127
|
+
value.strip.empty?
|
|
128
|
+
else
|
|
129
|
+
value.nil?
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def valid_realm_id?(realm_id)
|
|
134
|
+
realm_id.nil? ||
|
|
135
|
+
realm_id.is_a?(String) ||
|
|
136
|
+
(realm_id.is_a?(Array) && realm_id.all? { |r| r.is_a?(String) }) ||
|
|
137
|
+
realm_id.respond_to?(:call)
|
|
95
138
|
end
|
|
96
139
|
end
|
|
97
140
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module KeycloakApiRails
|
|
2
4
|
class Helper
|
|
3
5
|
|
|
@@ -76,15 +78,18 @@ module KeycloakApiRails
|
|
|
76
78
|
end
|
|
77
79
|
|
|
78
80
|
def self.assign_resource_roles(env, token)
|
|
79
|
-
env[RESOURCE_ROLES_KEY] = token.fetch("resource_access", {}).
|
|
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
|
|
@@ -6,16 +8,20 @@ module KeycloakApiRails
|
|
|
6
8
|
end
|
|
7
9
|
|
|
8
10
|
def call(env)
|
|
9
|
-
method
|
|
10
|
-
path
|
|
11
|
+
method = env["REQUEST_METHOD"]
|
|
12
|
+
path = env["PATH_INFO"]
|
|
13
|
+
service = KeycloakApiRails.service
|
|
11
14
|
|
|
12
15
|
if service.need_middleware_authentication?(method, path, env)
|
|
13
16
|
logger.debug("Start authentication for #{method} : #{path}")
|
|
14
17
|
begin
|
|
15
|
-
authenticate(env)
|
|
18
|
+
authenticate(service, env)
|
|
16
19
|
rescue TokenError => e
|
|
17
20
|
logger.debug("The error causing the Token to fail: #{e.original_error&.message || e.message}")
|
|
18
|
-
return authentication_failed(e
|
|
21
|
+
return authentication_failed(e)
|
|
22
|
+
rescue HTTPError, MissingPublicKeysError => e
|
|
23
|
+
logger.error("KeycloakApiRails: no token can be verified for #{method} : #{path}. #{e.class}: #{e.message}")
|
|
24
|
+
return authentication_unavailable
|
|
19
25
|
end
|
|
20
26
|
else
|
|
21
27
|
logger.debug("Skip authentication for #{method} : #{path}")
|
|
@@ -26,19 +32,25 @@ module KeycloakApiRails
|
|
|
26
32
|
|
|
27
33
|
private
|
|
28
34
|
|
|
29
|
-
def authenticate(env)
|
|
35
|
+
def authenticate(service, env)
|
|
30
36
|
token = service.read_token(Helper.request_uri(env), env)
|
|
31
37
|
decoded_token = service.decode_and_verify(token)
|
|
32
38
|
Helper.assign_token(env, decoded_token, config.custom_attributes)
|
|
33
39
|
end
|
|
34
40
|
|
|
35
|
-
def authentication_failed(
|
|
41
|
+
def authentication_failed(error)
|
|
36
42
|
# Rack 3 requires header names to be lowercase.
|
|
37
|
-
[401,
|
|
43
|
+
[401,
|
|
44
|
+
{ "content-type" => "application/json",
|
|
45
|
+
"www-authenticate" => error.challenge },
|
|
46
|
+
[{ error: error.message }.to_json]]
|
|
38
47
|
end
|
|
39
48
|
|
|
40
|
-
def
|
|
41
|
-
|
|
49
|
+
def authentication_unavailable
|
|
50
|
+
[503,
|
|
51
|
+
{ "content-type" => "application/json",
|
|
52
|
+
"retry-after" => PublicKeyCachedResolver::FAILED_REFRESH_RETRY_DELAY_IN_SECONDS.to_s },
|
|
53
|
+
[{ error: "Authentication is temporarily unavailable" }.to_json]]
|
|
42
54
|
end
|
|
43
55
|
|
|
44
56
|
def logger
|
|
@@ -1,49 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module KeycloakApiRails
|
|
2
4
|
class PublicKeyCachedResolver
|
|
3
5
|
FAILED_REFRESH_RETRY_DELAY_IN_SECONDS = 10
|
|
6
|
+
MAX_CACHED_REALMS = 64
|
|
7
|
+
|
|
8
|
+
class RealmCache
|
|
9
|
+
attr_reader :cached_public_key_retrieved_at
|
|
10
|
+
|
|
11
|
+
def initialize(http_client, realm_id)
|
|
12
|
+
@resolver = PublicKeyResolver.new(http_client, realm_id)
|
|
13
|
+
@cached_public_keys = nil
|
|
14
|
+
@cached_public_key_retrieved_at = nil
|
|
15
|
+
@last_refresh_failure_at = nil
|
|
16
|
+
@last_refresh_error = nil
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def find_public_keys(public_key_cache_ttl, logger)
|
|
21
|
+
@mutex.synchronize do
|
|
22
|
+
raise @last_refresh_error if refresh_recently_failed_without_any_cache?
|
|
23
|
+
|
|
24
|
+
refresh_public_keys(logger) if public_keys_are_outdated?(public_key_cache_ttl)
|
|
25
|
+
@cached_public_keys
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
4
30
|
|
|
5
|
-
|
|
31
|
+
def refresh_public_keys(logger)
|
|
32
|
+
@cached_public_keys = @resolver.find_public_keys
|
|
33
|
+
@cached_public_key_retrieved_at = Time.now
|
|
34
|
+
@last_refresh_failure_at = nil
|
|
35
|
+
@last_refresh_error = nil
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
@last_refresh_failure_at = Time.now
|
|
38
|
+
@last_refresh_error = e
|
|
39
|
+
raise if @cached_public_keys.nil?
|
|
40
|
+
|
|
41
|
+
logger&.warn("KeycloakApiRails: could not refresh the public keys (#{e.class}: #{e.message}). Keeping the ones retrieved at #{@cached_public_key_retrieved_at}.")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def refresh_recently_failed_without_any_cache?
|
|
45
|
+
@cached_public_keys.nil? &&
|
|
46
|
+
!@last_refresh_failure_at.nil? &&
|
|
47
|
+
Time.now <= @last_refresh_failure_at + FAILED_REFRESH_RETRY_DELAY_IN_SECONDS
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def public_keys_are_outdated?(public_key_cache_ttl)
|
|
51
|
+
@cached_public_keys.nil? ||
|
|
52
|
+
@cached_public_key_retrieved_at.nil? ||
|
|
53
|
+
(Time.now > @cached_public_key_retrieved_at + public_key_cache_ttl &&
|
|
54
|
+
(@last_refresh_failure_at.nil? ||
|
|
55
|
+
Time.now > @last_refresh_failure_at + FAILED_REFRESH_RETRY_DELAY_IN_SECONDS))
|
|
56
|
+
end
|
|
57
|
+
end
|
|
6
58
|
|
|
7
59
|
def initialize(http_client, realm_id, public_key_cache_ttl, logger = nil)
|
|
8
|
-
@
|
|
9
|
-
@
|
|
10
|
-
@
|
|
11
|
-
@
|
|
12
|
-
@
|
|
13
|
-
@
|
|
14
|
-
@
|
|
60
|
+
@http_client = http_client
|
|
61
|
+
@realm_id = realm_id
|
|
62
|
+
@configured_realms = configured_realms(realm_id)
|
|
63
|
+
@public_key_cache_ttl = public_key_cache_ttl
|
|
64
|
+
@logger = logger
|
|
65
|
+
@caches = {}
|
|
66
|
+
@caches_mutex = Mutex.new
|
|
67
|
+
@refusal_logged = false
|
|
15
68
|
end
|
|
16
69
|
|
|
17
70
|
def self.from_configuration(http_client, configuration)
|
|
18
71
|
new(http_client, configuration.realm_id, configuration.public_key_cache_ttl, configuration.logger)
|
|
19
72
|
end
|
|
20
73
|
|
|
21
|
-
def find_public_keys
|
|
22
|
-
@
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
74
|
+
def find_public_keys(realm_id = nil)
|
|
75
|
+
cache_for(realm_id || default_realm).find_public_keys(@public_key_cache_ttl, @logger)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Keep this method backward-compatible for testing
|
|
79
|
+
def cached_public_key_retrieved_at(realm_id = nil)
|
|
80
|
+
cache_for(realm_id || default_realm).cached_public_key_retrieved_at
|
|
26
81
|
end
|
|
27
82
|
|
|
28
83
|
private
|
|
29
84
|
|
|
30
|
-
def
|
|
31
|
-
@
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
85
|
+
def default_realm
|
|
86
|
+
@realm_id if @realm_id.is_a?(String)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def configured_realms(realm_id)
|
|
90
|
+
case realm_id
|
|
91
|
+
when String then [realm_id].freeze
|
|
92
|
+
when Array then realm_id.dup.freeze
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def cache_for(realm_id)
|
|
97
|
+
@caches[realm_id] || @caches_mutex.synchronize { @caches[realm_id] || create_cache(realm_id) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def create_cache(realm_id)
|
|
101
|
+
refuse(realm_id, "it is not one of the configured realms") unless cacheable?(realm_id)
|
|
102
|
+
refuse(realm_id, "#{MAX_CACHED_REALMS} realms are already cached") if @caches.size >= MAX_CACHED_REALMS
|
|
36
103
|
|
|
37
|
-
@
|
|
38
|
-
@logger&.warn("KeycloakApiRails: could not refresh the public keys (#{e.class}: #{e.message}). Keeping the ones retrieved at #{@cached_public_key_retrieved_at}.")
|
|
104
|
+
@caches[realm_id] = RealmCache.new(@http_client, realm_id)
|
|
39
105
|
end
|
|
40
106
|
|
|
41
|
-
def
|
|
42
|
-
@
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
107
|
+
def cacheable?(realm_id)
|
|
108
|
+
!realm_id.nil? && (@configured_realms.nil? || @configured_realms.include?(realm_id))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def refuse(realm_id, reason)
|
|
112
|
+
unless @refusal_logged
|
|
113
|
+
@refusal_logged = true
|
|
114
|
+
@logger&.warn("KeycloakApiRails: no public key is downloaded for the realm #{realm_id.inspect}, #{reason}. The requests naming it are answered a 503. Further refusals are not logged.")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
raise MissingPublicKeysError, "No Keycloak public key can be downloaded for the realm #{realm_id.inspect}"
|
|
47
118
|
end
|
|
48
119
|
end
|
|
49
120
|
end
|
|
@@ -1,34 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
1
4
|
module KeycloakApiRails
|
|
2
5
|
class MissingPublicKeysError < StandardError; end
|
|
3
6
|
|
|
4
7
|
class Service
|
|
8
|
+
REALM_NAME = /\A(?!\.+\z)[A-Za-z0-9._~-]{1,128}\z/.freeze
|
|
9
|
+
SUPPORTED_ALGORITHMS = %i[RS256 RS384 RS512 PS256 PS384 PS512 ES256 ES384 ES512].freeze
|
|
5
10
|
|
|
6
11
|
def initialize(key_resolver)
|
|
7
12
|
configuration = KeycloakApiRails.config
|
|
8
13
|
@key_resolver = key_resolver
|
|
9
|
-
@skip_paths = normalize_skip_paths(configuration.skip_paths)
|
|
14
|
+
@skip_paths = normalize_skip_paths(configuration.skip_paths, configuration.logger)
|
|
10
15
|
@opt_in = configuration.opt_in
|
|
11
16
|
@token_expiration_tolerance_in_seconds = configuration.token_expiration_tolerance_in_seconds
|
|
12
17
|
@expected_audiences = Array(configuration.expected_audience).map(&:to_s)
|
|
13
18
|
@expected_token_type = configuration.expected_token_type
|
|
14
19
|
@verify_not_before = configuration.verify_not_before
|
|
15
20
|
@allow_token_in_query_string = configuration.allow_token_in_query_string
|
|
21
|
+
@allowed_algorithms = Array(configuration.allowed_algorithms).map(&:to_sym)
|
|
16
22
|
end
|
|
17
23
|
|
|
18
24
|
def decode_and_verify(token)
|
|
19
25
|
raise TokenError.no_token(token) if token.nil? || token.empty?
|
|
26
|
+
|
|
27
|
+
parts = token.to_s.split('.')
|
|
28
|
+
raise TokenError.invalid_format(token) if parts.length < 3
|
|
20
29
|
|
|
21
|
-
|
|
30
|
+
realm_id = extract_realm_from_token(token)
|
|
31
|
+
raise TokenError.invalid_realm(token) unless realm_allowed?(realm_id)
|
|
32
|
+
|
|
33
|
+
public_keys = @key_resolver.find_public_keys(realm_id)
|
|
22
34
|
|
|
23
35
|
if public_keys.nil?
|
|
24
36
|
raise MissingPublicKeysError, "No Keycloak public key is available to verify the token"
|
|
25
37
|
end
|
|
26
38
|
|
|
27
39
|
decoded_token = decode(token, public_keys)
|
|
28
|
-
verify_claims!(token, decoded_token)
|
|
40
|
+
verify_claims!(token, decoded_token, realm_id)
|
|
29
41
|
decoded_token
|
|
30
42
|
end
|
|
31
43
|
|
|
44
|
+
def extract_realm_from_token(token)
|
|
45
|
+
payload_segment = token.split('.', 3)[1]
|
|
46
|
+
return nil unless payload_segment
|
|
47
|
+
|
|
48
|
+
decoded_payload = Base64.urlsafe_decode64(payload_segment)
|
|
49
|
+
parsed_payload = JSON.parse(decoded_payload)
|
|
50
|
+
|
|
51
|
+
if parsed_payload.is_a?(Hash)
|
|
52
|
+
iss = parsed_payload['iss']
|
|
53
|
+
return nil unless iss.is_a?(String)
|
|
54
|
+
|
|
55
|
+
realm_id = iss.split('/').last
|
|
56
|
+
if realm_id&.match?(REALM_NAME)
|
|
57
|
+
realm_id
|
|
58
|
+
else
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
else
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
rescue JSON::ParserError, ArgumentError
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def realm_allowed?(realm_id)
|
|
69
|
+
config_realm_id = KeycloakApiRails.config.realm_id
|
|
70
|
+
return true if config_realm_id.nil?
|
|
71
|
+
return false if realm_id.nil?
|
|
72
|
+
|
|
73
|
+
if config_realm_id.respond_to?(:call)
|
|
74
|
+
config_realm_id.call(realm_id)
|
|
75
|
+
elsif config_realm_id.is_a?(Array)
|
|
76
|
+
config_realm_id.include?(realm_id)
|
|
77
|
+
else
|
|
78
|
+
config_realm_id == realm_id
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
32
82
|
def read_token(uri, headers)
|
|
33
83
|
header_token = Helper.read_token_from_headers(headers)
|
|
34
84
|
if !header_token.empty?
|
|
@@ -47,9 +97,7 @@ module KeycloakApiRails
|
|
|
47
97
|
private
|
|
48
98
|
|
|
49
99
|
def decode(token, public_keys)
|
|
50
|
-
|
|
51
|
-
decoded_token.verify!(public_keys)
|
|
52
|
-
decoded_token
|
|
100
|
+
JSON::JWT.decode(token, public_keys, @allowed_algorithms)
|
|
53
101
|
rescue JSON::JWT::VerificationFailed, JSON::JWK::Set::KidNotFound => e
|
|
54
102
|
raise TokenError.verification_failed(token, e)
|
|
55
103
|
rescue JSON::JWT::InvalidFormat => e
|
|
@@ -58,28 +106,60 @@ module KeycloakApiRails
|
|
|
58
106
|
raise TokenError.unknown(token, e)
|
|
59
107
|
end
|
|
60
108
|
|
|
61
|
-
|
|
109
|
+
# RFC 7519 requires 'exp' and 'nbf' to be NumericDates.
|
|
110
|
+
def verify_claims!(token, decoded_token, realm_id)
|
|
62
111
|
raise TokenError.missing_claim(token, "exp") unless decoded_token.key?("exp")
|
|
112
|
+
raise TokenError.invalid_claim(token, "exp") unless decoded_token["exp"].is_a?(Numeric)
|
|
113
|
+
raise TokenError.invalid_claim(token, "nbf") if not_before_is_invalid?(decoded_token)
|
|
63
114
|
raise TokenError.expired(token) if expired?(decoded_token)
|
|
64
115
|
raise TokenError.not_yet_valid(token) if not_yet_valid?(decoded_token)
|
|
65
116
|
raise TokenError.invalid_audience(token) unless audience_valid?(decoded_token)
|
|
66
117
|
raise TokenError.invalid_token_type(token) unless token_type_valid?(decoded_token)
|
|
118
|
+
|
|
119
|
+
if KeycloakApiRails.config.server_url
|
|
120
|
+
expected_iss = File.join(KeycloakApiRails.config.server_url.to_s, "realms", realm_id.to_s)
|
|
121
|
+
raise TokenError.invalid_realm(token) unless decoded_token["iss"] == expected_iss
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def not_before_is_invalid?(token)
|
|
126
|
+
@verify_not_before && token.key?("nbf") && !token["nbf"].is_a?(Numeric)
|
|
67
127
|
end
|
|
68
128
|
|
|
69
|
-
|
|
129
|
+
# Anything that is not a regexp is discarded rather than matched: 'String#match' compiles its
|
|
130
|
+
# argument into a regexp, so a String would be matched against the path of the request instead of
|
|
131
|
+
# the other way around, and would open every path that is a sub-pattern of it. The railtie rejects
|
|
132
|
+
# such a configuration when the application boots; a Rack application running without Rails never
|
|
133
|
+
# calls 'validate!', so the paths keep being authenticated here.
|
|
134
|
+
def normalize_skip_paths(skip_paths, logger)
|
|
70
135
|
(skip_paths || {}).each_with_object({}) do |(method, paths), normalized|
|
|
71
|
-
|
|
136
|
+
regexps, discarded = Array(paths).partition { |path| path.is_a?(Regexp) }
|
|
137
|
+
|
|
138
|
+
unless discarded.empty?
|
|
139
|
+
logger&.warn("KeycloakApiRails: 'skip_paths[#{method.inspect}]' declares #{discarded.map(&:inspect).join(', ')}, which are not regexps. They are ignored, and the paths they were meant to open keep being authenticated.")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
line_anchored = regexps.select { |regexp| line_anchored?(regexp) }
|
|
143
|
+
unless line_anchored.empty?
|
|
144
|
+
logger&.warn("KeycloakApiRails: 'skip_paths[#{method.inspect}]' declares #{line_anchored.map(&:inspect).join(', ')}, anchored with '^' or '$'. In Ruby those match the beginning and the end of a line, not of the path: \"/private\\n/health\" matches /^\\/health/ and skips authentication. Anchor with '\\A' and '\\z'.")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
normalized[method.to_s.upcase] = regexps
|
|
72
148
|
end
|
|
73
149
|
end
|
|
74
150
|
|
|
151
|
+
# Escaped pairs are dropped first, so that '\^' does not count and '[^/]' is read as a class.
|
|
152
|
+
def line_anchored?(regexp)
|
|
153
|
+
regexp.source.gsub(/\\./, "").gsub(/\[[^\]]*\]/, "").match?(/[\^$]/)
|
|
154
|
+
end
|
|
155
|
+
|
|
75
156
|
def should_skip?(method, path)
|
|
76
|
-
skip_paths = @skip_paths[method
|
|
77
|
-
!skip_paths.nil? && skip_paths.any? { |skip_path| skip_path.match(path) }
|
|
157
|
+
skip_paths = @skip_paths[method]
|
|
158
|
+
!skip_paths.nil? && skip_paths.any? { |skip_path| skip_path.match?(path) }
|
|
78
159
|
end
|
|
79
160
|
|
|
80
161
|
def is_preflight?(method, headers)
|
|
81
|
-
|
|
82
|
-
method_symbol == :options && !headers["HTTP_ACCESS_CONTROL_REQUEST_METHOD"].nil?
|
|
162
|
+
method == "OPTIONS" && !headers["HTTP_ACCESS_CONTROL_REQUEST_METHOD"].nil?
|
|
83
163
|
end
|
|
84
164
|
|
|
85
165
|
def expired?(token)
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
# Test helpers for the applications that authenticate their requests with this library.
|
|
2
4
|
#
|
|
3
5
|
# This file is *not* loaded by "keycloak-api-rails": it has to be required explicitly, so that
|
|
@@ -33,7 +35,7 @@ module KeycloakApiRails
|
|
|
33
35
|
@public_keys = public_keys
|
|
34
36
|
end
|
|
35
37
|
|
|
36
|
-
def find_public_keys
|
|
38
|
+
def find_public_keys(realm_id = nil)
|
|
37
39
|
@public_keys
|
|
38
40
|
end
|
|
39
41
|
end
|
|
@@ -85,6 +87,12 @@ module KeycloakApiRails
|
|
|
85
87
|
payload["realm_access"] = { "roles" => roles.map(&:to_s) } unless roles.nil? || roles.empty?
|
|
86
88
|
payload["resource_access"] = build_resource_access(resource_roles) unless resource_roles.nil? || resource_roles.empty?
|
|
87
89
|
|
|
90
|
+
unless claims.key?(:iss) || claims.key?("iss")
|
|
91
|
+
config_realm_id = KeycloakApiRails.config.realm_id
|
|
92
|
+
realm_id = config_realm_id.is_a?(String) ? config_realm_id : "master"
|
|
93
|
+
payload["iss"] = File.join(KeycloakApiRails.config.server_url.to_s, "realms", realm_id)
|
|
94
|
+
end
|
|
95
|
+
|
|
88
96
|
claims.each { |name, value| payload[name.to_s] = value }
|
|
89
97
|
|
|
90
98
|
JSON::JWT.new(payload).sign(signing_key, ALGORITHM).to_s
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module KeycloakApiRails
|
|
2
4
|
class TokenError < StandardError
|
|
3
5
|
attr_reader :token, :reason, :original_error
|
|
@@ -9,14 +11,27 @@ module KeycloakApiRails
|
|
|
9
11
|
@original_error = original_error
|
|
10
12
|
end
|
|
11
13
|
|
|
14
|
+
# RFC 6750: a request that carried no credentials at all gets the bare challenge, no error code.
|
|
15
|
+
def challenge
|
|
16
|
+
if reason == :no_token
|
|
17
|
+
"Bearer"
|
|
18
|
+
else
|
|
19
|
+
%(Bearer error="invalid_token", error_description="#{message.gsub(/["\\]/, '')}")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
12
23
|
def self.verification_failed(token, original_error)
|
|
13
24
|
new(token, :verification_failed, "Failed to verify JWT token", original_error)
|
|
14
25
|
end
|
|
15
26
|
|
|
16
|
-
def self.invalid_format(token, original_error)
|
|
27
|
+
def self.invalid_format(token, original_error = nil)
|
|
17
28
|
new(token, :invalid_format, "Wrong JWT Format", original_error)
|
|
18
29
|
end
|
|
19
30
|
|
|
31
|
+
def self.invalid_realm(token)
|
|
32
|
+
new(token, :invalid_realm, "JWT token does not have a valid realm")
|
|
33
|
+
end
|
|
34
|
+
|
|
20
35
|
def self.no_token(token)
|
|
21
36
|
new(token, :no_token, "No JWT token provided")
|
|
22
37
|
end
|
|
@@ -41,6 +56,10 @@ module KeycloakApiRails
|
|
|
41
56
|
new(token, :missing_claim, "JWT token does not carry the mandatory claim '#{claim}'")
|
|
42
57
|
end
|
|
43
58
|
|
|
59
|
+
def self.invalid_claim(token, claim)
|
|
60
|
+
new(token, :invalid_claim, "JWT token carries an invalid '#{claim}' claim: it must be a number of seconds since the Epoch")
|
|
61
|
+
end
|
|
62
|
+
|
|
44
63
|
def self.unknown(token, original_error)
|
|
45
64
|
new(token, :unknown, "Failed to read JWT token", original_error)
|
|
46
65
|
end
|
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"
|
|
@@ -21,22 +23,37 @@ module KeycloakApiRails
|
|
|
21
23
|
# These objects are memoized lazily, on the first request each process serves -- which several
|
|
22
24
|
# threads of a threaded server reach at the same time. A Monitor rather than a Mutex: the
|
|
23
25
|
# memoizations nest, 'service' needing 'public_key_resolver', which needs 'http_client'.
|
|
26
|
+
#
|
|
27
|
+
# Once memoized they are read without taking it: the assignment publishes an object that is fully
|
|
28
|
+
# built, and MRI reads an instance variable atomically. A 'configure' running while requests are
|
|
29
|
+
# already being served may then be picked up one request late, which is one more reason for it to
|
|
30
|
+
# belong in an initializer.
|
|
24
31
|
MONITOR = Monitor.new
|
|
25
32
|
|
|
26
33
|
def self.configure
|
|
27
|
-
MONITOR.synchronize
|
|
34
|
+
MONITOR.synchronize do
|
|
35
|
+
yield @configuration ||= KeycloakApiRails::Configuration.new
|
|
36
|
+
discard_configured_objects
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.discard_configured_objects
|
|
41
|
+
@http_client = nil
|
|
42
|
+
@public_key_resolver = nil unless @public_key_resolver_assigned
|
|
43
|
+
@service = nil
|
|
28
44
|
end
|
|
45
|
+
private_class_method :discard_configured_objects
|
|
29
46
|
|
|
30
47
|
def self.config
|
|
31
48
|
@configuration
|
|
32
49
|
end
|
|
33
50
|
|
|
34
51
|
def self.http_client
|
|
35
|
-
MONITOR.synchronize { @http_client ||= KeycloakApiRails::HTTPClient.new(config, logger) }
|
|
52
|
+
@http_client || MONITOR.synchronize { @http_client ||= KeycloakApiRails::HTTPClient.new(config, logger) }
|
|
36
53
|
end
|
|
37
54
|
|
|
38
55
|
def self.public_key_resolver
|
|
39
|
-
MONITOR.synchronize { @public_key_resolver ||= PublicKeyCachedResolver.from_configuration(http_client, config) }
|
|
56
|
+
@public_key_resolver || MONITOR.synchronize { @public_key_resolver ||= PublicKeyCachedResolver.from_configuration(http_client, config) }
|
|
40
57
|
end
|
|
41
58
|
|
|
42
59
|
# Mainly used by "keycloak-api-rails/testing" to validate tokens without a Keycloak server.
|
|
@@ -44,13 +61,14 @@ module KeycloakApiRails
|
|
|
44
61
|
# a reference to the resolver that is being replaced.
|
|
45
62
|
def self.public_key_resolver=(resolver)
|
|
46
63
|
MONITOR.synchronize do
|
|
47
|
-
@public_key_resolver
|
|
48
|
-
@
|
|
64
|
+
@public_key_resolver = resolver
|
|
65
|
+
@public_key_resolver_assigned = !resolver.nil?
|
|
66
|
+
@service = nil
|
|
49
67
|
end
|
|
50
68
|
end
|
|
51
69
|
|
|
52
70
|
def self.service
|
|
53
|
-
MONITOR.synchronize { @service ||= KeycloakApiRails::Service.new(public_key_resolver) }
|
|
71
|
+
@service || MONITOR.synchronize { @service ||= KeycloakApiRails::Service.new(public_key_resolver) }
|
|
54
72
|
end
|
|
55
73
|
|
|
56
74
|
def self.logger
|
|
@@ -74,6 +92,7 @@ module KeycloakApiRails
|
|
|
74
92
|
config.allow_token_in_query_string = false
|
|
75
93
|
config.http_open_timeout = 5
|
|
76
94
|
config.http_read_timeout = 5
|
|
95
|
+
config.allowed_algorithms = KeycloakApiRails::Service::SUPPORTED_ALGORITHMS
|
|
77
96
|
end
|
|
78
97
|
end
|
|
79
98
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: keycloak-api-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.0.
|
|
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
|