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