keycloak-api-rails 1.1.2 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +87 -0
- data/README.md +73 -26
- data/keycloak-api-rails.gemspec +3 -4
- data/lib/keycloak-api-rails/authentication.rb +13 -11
- data/lib/keycloak-api-rails/configuration.rb +112 -0
- data/lib/keycloak-api-rails/helper.rb +38 -6
- data/lib/keycloak-api-rails/http_client.rb +56 -19
- data/lib/keycloak-api-rails/middleware.rb +30 -21
- data/lib/keycloak-api-rails/public_key_cached_resolver.rb +75 -15
- data/lib/keycloak-api-rails/public_key_resolver.rb +2 -0
- data/lib/keycloak-api-rails/railtie.rb +18 -1
- data/lib/keycloak-api-rails/service.rb +136 -28
- data/lib/keycloak-api-rails/testing.rb +14 -4
- data/lib/keycloak-api-rails/token_error.rb +51 -23
- data/lib/keycloak-api-rails/version.rb +3 -1
- data/lib/keycloak-api-rails.rb +34 -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: 1e80ef54a212cf523766b3d1f0a4d836dd4f32c59322eb9b90a13e0e530bff08
|
|
4
|
+
data.tar.gz: c6757d2a355db048389b3c697557daea96e912eea4d2fbeea413b78bd888ce03
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2e46089640ad0a1d9f14e08e383a5f651431f533d88e9444c809d8d4b55edc91460e668b1ff21e23e863583b4fd956056fb82b0ea9dc5e16ea34cccb268ad2cf
|
|
7
|
+
data.tar.gz: e4bfba00be03edd43b60394146bdad990b14eeb731d7e878bdf8944c00e92986278b4c11ca721fa99cd309c54289ee21e0b5199c4f357b4e614691e9d1b6b3b6
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,93 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
### Performance
|
|
11
|
+
|
|
12
|
+
* Added `# frozen_string_literal: true` to all ruby files and optimized Hot Path methods in `Service` and `Helper` (e.g., using `Regexp#match?`, avoiding redundant String-to-Symbol conversions) to significantly reduce CPU usage and memory allocations per request.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
* Multi-tenancy support: `realm_id` can now be a String, an Array of Strings, or a Proc (e.g., `->(env) { ... }`) that evaluates to a String or Array of Strings, allowing dynamic realm resolution per request.
|
|
17
|
+
* The middleware now validates the token against the allowed realms. A token carrying an `iss` claim that does not match one of the expected realms will be rejected. New `KeycloakApiRails::TokenError` reason: `:invalid_realm`.
|
|
18
|
+
* `PublicKeyCachedResolver` caches public keys per-realm, ensuring safe operation in a multi-tenant environment.
|
|
19
|
+
|
|
20
|
+
## [2.0.1] - 2026-08-02
|
|
21
|
+
|
|
22
|
+
### Security
|
|
23
|
+
|
|
24
|
+
* `skip_paths` declaring its paths as Strings, e.g. `{ get: ["/health/db"] }`, opened routes that had to be authenticated.
|
|
25
|
+
* A path that is not a `Regexp` is discarded by the middleware rather than matched, and a warning is logged naming it.
|
|
26
|
+
* With `verify_not_before` enabled, an `nbf` claim carried as null was read as an absent one, skipping the very check the option asks for. It is now rejected.
|
|
27
|
+
* A token whose `exp` or `nbf` claim is not a number of seconds is answered a `401`, where `Time.at` used to raise a `TypeError` and answer a `500`. The 2.0.0 guard only checked that `exp` was present, not that it held a NumericDate. New `KeycloakApiRails::TokenError` reason: `:invalid_claim`.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
* `custom_attributes` declared as Symbols, e.g. `[:tenant_id]`, matched no claim at all: those of a decoded token are keyed by String. Both forms are honoured.
|
|
32
|
+
* `custom_attributes` holding something else than a claim name is reported by `Configuration#validate!`, instead of being silently read from no token.
|
|
33
|
+
* `KeycloakApiRails.configure` discards the service, the public key resolver and the HTTP client it had memoized
|
|
34
|
+
|
|
35
|
+
### Availability
|
|
36
|
+
|
|
37
|
+
* A request whose token cannot be verified at all, Keycloak being unreachable and no public key having ever been retrieved, is answered a `503` carrying a `Retry-After` header. `KeycloakApiRails::HTTPError` and `KeycloakApiRails::MissingPublicKeysError` used to escape the middleware, and `keycloak_authenticate`, as a `500`: an outage of Keycloak was reported as a bug of the application. A request carrying no token at all is still answered a `401`, and the paths of `skip_paths` are still served.
|
|
38
|
+
* With nothing cached, a Keycloak that is down was called again by every single request, one at a time behind the mutex of the resolver, each waiting for `http_open_timeout` and `http_read_timeout` to elapse: ten concurrent requests held ten threads for a hundred seconds. It is now called once per `FAILED_REFRESH_RETRY_DELAY_IN_SECONDS`, and the error of the last attempt is raised straight away in between.
|
|
39
|
+
|
|
40
|
+
## [2.0.0] - 2026-08-01
|
|
41
|
+
|
|
42
|
+
### Breaking changes
|
|
43
|
+
|
|
44
|
+
* `TokenError` is now namespaced: `KeycloakApiRails::TokenError`.
|
|
45
|
+
* The gem depends on `railties` rather than on the whole `rails` meta gem
|
|
46
|
+
* An invalid configuration raises `KeycloakApiRails::InvalidConfigurationError` when the application boots
|
|
47
|
+
* A token this library cannot read at all is answered a `401`, where an unexpected error used to escape the middleware as a `500`
|
|
48
|
+
* A token carried by the `authorizationToken` query string parameter is ignored unless the new `allow_token_in_query_string` option is enabled
|
|
49
|
+
* `KeycloakApiRails::HTTPClient` raises `KeycloakApiRails::HTTPError` when Keycloak answers an error, a malformed payload, or cannot be reached.
|
|
50
|
+
|
|
51
|
+
### Security
|
|
52
|
+
|
|
53
|
+
* 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
|
|
54
|
+
* New `expected_token_type` option: when set, a token whose `typ` claim does not match is rejected. Keycloak types its access tokens `Bearer`
|
|
55
|
+
* 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
|
|
56
|
+
* A token carrying no `exp` claim is rejected, where `Time.at(nil)` used to raise a `TypeError` and answer a `500`
|
|
57
|
+
* The resolver never answers without a public key: decoding a token without one would skip the signature verification altogether
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
|
|
61
|
+
* The `401` answered by the middleware carries a lowercase `content-type` header, as the Rack 3 SPEC requires.
|
|
62
|
+
* The `Authorization` header is read again when the Rack environment carries no `REQUEST_URI`.
|
|
63
|
+
* A `TokenError` raised further down the stack is no longer swallowed by the middleware and turned into a `401`
|
|
64
|
+
* `skip_paths` declared with String or upcased HTTP methods, e.g. `{ "GET" => [...] }`, are honoured instead of silently never matching
|
|
65
|
+
* `TokenError.unknown` raised an `ArgumentError`, being called without any argument
|
|
66
|
+
* `TokenError.invalid_format` no longer reports a `nil` cause: the rescue clause read an `e` that was never bound
|
|
67
|
+
* `Helper.current_user_roles` was declared twice
|
|
68
|
+
* 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
|
|
69
|
+
|
|
70
|
+
### Thread safety
|
|
71
|
+
|
|
72
|
+
* 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
|
|
73
|
+
* The public keys are downloaded once when several threads find the cache expired at the same time, rather than once per thread
|
|
74
|
+
* `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
|
|
75
|
+
|
|
76
|
+
### Availability
|
|
77
|
+
|
|
78
|
+
* 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
|
|
79
|
+
* 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
|
|
80
|
+
|
|
81
|
+
### Added
|
|
82
|
+
|
|
83
|
+
* The configuration is validated at boot, and `Configuration#validate!` reports every problem at once
|
|
84
|
+
* A warning is logged at boot when `server_url` and `realm_id` are not both configured
|
|
85
|
+
|
|
86
|
+
### Upgrading from 1.x to 2.0
|
|
87
|
+
|
|
88
|
+
* `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`
|
|
89
|
+
* 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.
|
|
90
|
+
* The gem depends on `railties` instead of `rails`. An application that relied on this gem to pull Rails in has to declare `rails` itself
|
|
91
|
+
* A configuration mistake now raises a `KeycloakApiRails::InvalidConfigurationError` when the application boots, instead of failing on the first request
|
|
92
|
+
* 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`
|
|
93
|
+
* 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`
|
|
94
|
+
|
|
8
95
|
## [1.1.2] - 2026-08-01
|
|
9
96
|
|
|
10
97
|
* 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.2"
|
|
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. |
|
|
66
|
-
| `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
|
|
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`
|
|
63
|
+
| `server_url` | `nil`| String | Required | The base url where your Keycloak server is located. This value can be retrieved in your Keycloak client configuration. | `auth:8080` |
|
|
64
|
+
| `realm_id` | `nil`| String, Array or Proc | Required | Realm's name(s) (not id, actually). Can be a single String, an Array of Strings for multiple tenants, or a Proc for dynamic validation. | `"master"` or `["tenant1", "tenant2"]` |
|
|
65
|
+
| `logger` | `Logger.new(STDOUT)`| Logger | Optional | The logger used by `keycloak-api-rails` | `Rails.logger` |
|
|
66
|
+
| `skip_paths` | `{}`| Hash of methods and paths regexp | Optional | Paths whose token must not be validated. Each path must be a `Regexp`. A String is refused when the application boots: `String#match` compiles its argument into a regexp, so the path of the request would become the pattern and other routes would skip authentication | `{ get: [/^\/health\/.+/] }`|
|
|
67
|
+
| `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. The refresh happens under a lock and blocks request threads if Keycloak hangs; reducing this TTL increases the frequency of this risk. | `3600` |
|
|
70
|
+
| `custom_attributes` | `[]`| Array of String or Symbol | Optional | List of token attributes to read from each token and to add to their http request env | `["originalFirstName", "originalLastName"]` |
|
|
71
|
+
| `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 (case-insensitive). 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,47 @@ 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
|
+
|
|
132
|
+
## Multi-tenancy (Multiple Realms)
|
|
133
|
+
|
|
134
|
+
The library natively supports multi-tenancy by validating tokens issued by multiple Keycloak realms.
|
|
135
|
+
Instead of a static string, you can configure `realm_id` with an Array or a Proc. The library will dynamically extract the realm from the token's `iss` claim, check if it is permitted, and fetch the appropriate public keys for that specific realm.
|
|
136
|
+
|
|
137
|
+
Using an Array of allowed realms:
|
|
138
|
+
```ruby
|
|
139
|
+
KeycloakApiRails.configure do |config|
|
|
140
|
+
config.server_url = ENV["KEYCLOAK_SERVER_URL"]
|
|
141
|
+
config.realm_id = ["master", "tenant-1", "tenant-2"]
|
|
142
|
+
end
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Using a Proc for dynamic validation (e.g., querying the database):
|
|
146
|
+
```ruby
|
|
147
|
+
KeycloakApiRails.configure do |config|
|
|
148
|
+
config.server_url = ENV["KEYCLOAK_SERVER_URL"]
|
|
149
|
+
config.realm_id = ->(realm) { Tenant.exists?(name: realm) }
|
|
150
|
+
end
|
|
151
|
+
```
|
|
152
|
+
|
|
103
153
|
## Use cases
|
|
104
154
|
|
|
105
155
|
Once this gem is configured in your Rails project, you can read, validate and use tokens in your controllers.
|
|
@@ -173,6 +223,8 @@ end
|
|
|
173
223
|
|
|
174
224
|
This should output `https://api.pouet.io/api/more-pouets?authorizationToken=myToken`.
|
|
175
225
|
|
|
226
|
+
Such an URL is only accepted by an API that set `config.allow_token_in_query_string = true`.
|
|
227
|
+
|
|
176
228
|
|
|
177
229
|
### Accessing Keycloak Service
|
|
178
230
|
|
|
@@ -181,7 +233,7 @@ For instance, to read a provided token:
|
|
|
181
233
|
```ruby
|
|
182
234
|
class RenderTokenController < ApplicationController
|
|
183
235
|
def show
|
|
184
|
-
uri = request.env
|
|
236
|
+
uri = KeycloakApiRails::Helper.request_uri(request.env)
|
|
185
237
|
headers = request.env
|
|
186
238
|
token = KeycloakApiRails.service.read_token(uri, headers)
|
|
187
239
|
render json: { token: token }, status: :ok
|
|
@@ -266,14 +318,9 @@ scoped RubyGems credential.
|
|
|
266
318
|
3. Tag the commit and push the tag:
|
|
267
319
|
|
|
268
320
|
```
|
|
269
|
-
$ git tag -a
|
|
270
|
-
$ git push origin
|
|
321
|
+
$ git tag -a v2.0.2 -m "Version 2.0.2"
|
|
322
|
+
$ git push origin v2.0.2
|
|
271
323
|
```
|
|
272
324
|
|
|
273
325
|
The workflow then checks that the tag matches `KeycloakApiRails::VERSION`, runs the tests, builds the gem
|
|
274
326
|
and pushes it. It only publishes tags starting with `v`.
|
|
275
|
-
|
|
276
|
-
## Next developments
|
|
277
|
-
|
|
278
|
-
* Manage multiple realms
|
|
279
|
-
* Avoid duplicate code in KeycloakApiRails::Middleware and `KeycloakApiRails::Authentication`
|
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
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module KeycloakApiRails
|
|
2
4
|
module Authentication
|
|
3
5
|
def self.included(base)
|
|
@@ -9,17 +11,18 @@ module KeycloakApiRails
|
|
|
9
11
|
protected
|
|
10
12
|
|
|
11
13
|
def keycloak_authenticate
|
|
12
|
-
env
|
|
14
|
+
env = request.env
|
|
13
15
|
method = env["REQUEST_METHOD"]
|
|
14
16
|
path = env["PATH_INFO"]
|
|
15
|
-
uri = env["REQUEST_URI"]
|
|
16
17
|
|
|
17
18
|
KeycloakApiRails.logger.debug("Start authentication for #{method} : #{path}")
|
|
18
|
-
token = KeycloakApiRails.service.read_token(
|
|
19
|
+
token = KeycloakApiRails.service.read_token(Helper.request_uri(env), env)
|
|
19
20
|
decoded_token = KeycloakApiRails.service.decode_and_verify(token)
|
|
20
21
|
authentication_succeeded(env, decoded_token)
|
|
21
22
|
rescue TokenError => e
|
|
22
23
|
authentication_failed(e.message)
|
|
24
|
+
rescue KeycloakApiRails::HTTPError, KeycloakApiRails::MissingPublicKeysError => e
|
|
25
|
+
authentication_unavailable(e)
|
|
23
26
|
end
|
|
24
27
|
|
|
25
28
|
def authentication_failed(message)
|
|
@@ -27,15 +30,14 @@ module KeycloakApiRails
|
|
|
27
30
|
render status: :unauthorized, json: { error: message }
|
|
28
31
|
end
|
|
29
32
|
|
|
33
|
+
def authentication_unavailable(error)
|
|
34
|
+
KeycloakApiRails.logger.error("KeycloakApiRails: no token can be verified. #{error.class}: #{error.message}")
|
|
35
|
+
response.headers["Retry-After"] = KeycloakApiRails::PublicKeyCachedResolver::FAILED_REFRESH_RETRY_DELAY_IN_SECONDS.to_s
|
|
36
|
+
render status: :service_unavailable, json: { error: "Authentication is temporarily unavailable" }
|
|
37
|
+
end
|
|
38
|
+
|
|
30
39
|
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)
|
|
40
|
+
Helper.assign_token(env, decoded_token, KeycloakApiRails.config.custom_attributes)
|
|
39
41
|
end
|
|
40
42
|
end
|
|
41
43
|
end
|
|
@@ -1,5 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module KeycloakApiRails
|
|
4
|
+
class InvalidConfigurationError < StandardError; end
|
|
5
|
+
|
|
2
6
|
class Configuration
|
|
7
|
+
LOGGER_METHODS = [:debug, :info, :warn, :error].freeze
|
|
8
|
+
|
|
3
9
|
attr_accessor :server_url
|
|
4
10
|
attr_accessor :realm_id
|
|
5
11
|
attr_accessor :skip_paths
|
|
@@ -9,5 +15,111 @@ module KeycloakApiRails
|
|
|
9
15
|
attr_accessor :custom_attributes
|
|
10
16
|
attr_accessor :logger
|
|
11
17
|
attr_accessor :ca_certificate_file
|
|
18
|
+
attr_accessor :expected_audience
|
|
19
|
+
attr_accessor :expected_token_type
|
|
20
|
+
attr_accessor :verify_not_before
|
|
21
|
+
attr_accessor :allow_token_in_query_string
|
|
22
|
+
attr_accessor :http_open_timeout
|
|
23
|
+
attr_accessor :http_read_timeout
|
|
24
|
+
|
|
25
|
+
def validate!
|
|
26
|
+
errors = []
|
|
27
|
+
|
|
28
|
+
errors.push("'server_url' must be a String or nil, got #{server_url.inspect}") unless server_url.nil? || server_url.is_a?(String)
|
|
29
|
+
errors.push("'realm_id' must be a String, an Array of Strings, a Proc, or nil, got #{realm_id.inspect}") unless valid_realm_id?(realm_id)
|
|
30
|
+
errors.push("'logger' must respond to #{LOGGER_METHODS.join(', ')}") unless LOGGER_METHODS.all? { |method| logger.respond_to?(method) }
|
|
31
|
+
errors.push("'opt_in' must be true or false, got #{opt_in.inspect}") unless boolean?(opt_in)
|
|
32
|
+
errors.push("'verify_not_before' must be true or false, got #{verify_not_before.inspect}") unless boolean?(verify_not_before)
|
|
33
|
+
errors.push("'allow_token_in_query_string' must be true or false, got #{allow_token_in_query_string.inspect}") unless boolean?(allow_token_in_query_string)
|
|
34
|
+
errors.push("'token_expiration_tolerance_in_seconds' must be a number of seconds, got #{token_expiration_tolerance_in_seconds.inspect}") unless number?(token_expiration_tolerance_in_seconds, allow_zero: true)
|
|
35
|
+
errors.push("'public_key_cache_ttl' must be a positive number of seconds, got #{public_key_cache_ttl.inspect}") unless number?(public_key_cache_ttl)
|
|
36
|
+
errors.push("'http_open_timeout' must be a positive number of seconds, got #{http_open_timeout.inspect}") unless number?(http_open_timeout)
|
|
37
|
+
errors.push("'http_read_timeout' must be a positive number of seconds, got #{http_read_timeout.inspect}") unless number?(http_read_timeout)
|
|
38
|
+
errors.push("'expected_token_type' must be a String or nil, got #{expected_token_type.inspect}") unless expected_token_type.nil? || expected_token_type.is_a?(String)
|
|
39
|
+
errors.push("'ca_certificate_file' must be the path of a readable file, got #{ca_certificate_file.inspect}") unless ca_certificate_file.nil? || File.readable?(ca_certificate_file.to_s)
|
|
40
|
+
|
|
41
|
+
errors.concat(custom_attributes_errors)
|
|
42
|
+
errors.concat(skip_paths_errors)
|
|
43
|
+
errors.concat(expected_audience_errors)
|
|
44
|
+
|
|
45
|
+
raise InvalidConfigurationError, "Invalid Keycloak configuration: #{errors.join('; ')}" unless errors.empty?
|
|
46
|
+
|
|
47
|
+
true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def validate_server!
|
|
51
|
+
errors = []
|
|
52
|
+
errors.push("'server_url' must be configured, e.g. 'https://keycloak.example.org'") if missing?(server_url)
|
|
53
|
+
errors.push("'realm_id' must be configured, e.g. 'master'") if missing?(realm_id)
|
|
54
|
+
|
|
55
|
+
raise InvalidConfigurationError, "Invalid Keycloak configuration: #{errors.join('; ')}" unless errors.empty?
|
|
56
|
+
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def server_configured?
|
|
61
|
+
!missing?(server_url) && !missing?(realm_id)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def custom_attributes_errors
|
|
67
|
+
if custom_attributes.is_a?(Array)
|
|
68
|
+
invalid_names = custom_attributes.reject { |name| name.is_a?(String) || name.is_a?(Symbol) }
|
|
69
|
+
if invalid_names.empty?
|
|
70
|
+
[]
|
|
71
|
+
else
|
|
72
|
+
["'custom_attributes' must only contain claim names, as Strings or Symbols, got #{invalid_names.inspect}"]
|
|
73
|
+
end
|
|
74
|
+
else
|
|
75
|
+
["'custom_attributes' must be an Array of claim names, got #{custom_attributes.inspect}"]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def skip_paths_errors
|
|
80
|
+
return ["'skip_paths' must be a Hash of HTTP methods and path regexps, got #{skip_paths.inspect}"] unless skip_paths.is_a?(Hash)
|
|
81
|
+
|
|
82
|
+
skip_paths.filter_map do |method, paths|
|
|
83
|
+
next if paths.is_a?(Array) && paths.all? { |path| path.is_a?(Regexp) }
|
|
84
|
+
|
|
85
|
+
"'skip_paths[#{method.inspect}]' must be an Array of regexps, got #{paths.inspect}. A String is refused because 'String#match' compiles its argument into a regexp: the path of the request would become the pattern, and routes that must be authenticated would be skipped"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def expected_audience_errors
|
|
90
|
+
case expected_audience
|
|
91
|
+
when nil, String
|
|
92
|
+
[]
|
|
93
|
+
when Array
|
|
94
|
+
expected_audience.all? { |audience| audience.is_a?(String) } ? [] : ["'expected_audience' must only contain Strings, got #{expected_audience.inspect}"]
|
|
95
|
+
else
|
|
96
|
+
["'expected_audience' must be a String, an Array of Strings, or nil, got #{expected_audience.inspect}"]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def boolean?(value)
|
|
101
|
+
value == true || value == false
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def number?(value, allow_zero: false)
|
|
105
|
+
value.is_a?(Numeric) && (allow_zero ? value >= 0 : value > 0)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def missing?(value)
|
|
109
|
+
if value.is_a?(Array)
|
|
110
|
+
value.empty?
|
|
111
|
+
elsif value.is_a?(String)
|
|
112
|
+
value.strip.empty?
|
|
113
|
+
else
|
|
114
|
+
value.nil?
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def valid_realm_id?(realm_id)
|
|
119
|
+
realm_id.nil? ||
|
|
120
|
+
realm_id.is_a?(String) ||
|
|
121
|
+
(realm_id.is_a?(Array) && realm_id.all? { |r| r.is_a?(String) }) ||
|
|
122
|
+
realm_id.respond_to?(:call)
|
|
123
|
+
end
|
|
12
124
|
end
|
|
13
125
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module KeycloakApiRails
|
|
2
4
|
class Helper
|
|
3
5
|
|
|
@@ -10,6 +12,18 @@ module KeycloakApiRails
|
|
|
10
12
|
RESOURCE_ROLES_KEY = "keycloak:resource_roles"
|
|
11
13
|
TOKEN_KEY = "keycloak:token"
|
|
12
14
|
QUERY_STRING_TOKEN_KEY = "authorizationToken"
|
|
15
|
+
BEARER_PREFIX = /\ABearer[[:space:]]+/i.freeze # RFC 7235 makes the authentication scheme case-insensitive.
|
|
16
|
+
|
|
17
|
+
def self.assign_token(env, token, custom_attribute_names)
|
|
18
|
+
assign_current_user_id(env, token)
|
|
19
|
+
assign_current_authorized_party(env, token)
|
|
20
|
+
assign_current_user_email(env, token)
|
|
21
|
+
assign_current_user_locale(env, token)
|
|
22
|
+
assign_current_user_custom_attributes(env, token, custom_attribute_names)
|
|
23
|
+
assign_realm_roles(env, token)
|
|
24
|
+
assign_resource_roles(env, token)
|
|
25
|
+
assign_keycloak_token(env, token)
|
|
26
|
+
end
|
|
13
27
|
|
|
14
28
|
def self.current_user_id(env)
|
|
15
29
|
env[CURRENT_USER_ID_KEY]
|
|
@@ -64,22 +78,33 @@ module KeycloakApiRails
|
|
|
64
78
|
end
|
|
65
79
|
|
|
66
80
|
def self.assign_resource_roles(env, token)
|
|
67
|
-
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|
|
|
68
82
|
resource_roles[name] = resource_attributes.fetch("roles", [])
|
|
69
|
-
resource_roles
|
|
70
83
|
end
|
|
71
84
|
end
|
|
72
85
|
|
|
73
86
|
def self.assign_current_user_custom_attributes(env, token, attribute_names)
|
|
74
|
-
|
|
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
|
|
75
93
|
end
|
|
76
94
|
|
|
77
95
|
def self.current_user_custom_attributes(env)
|
|
78
96
|
env[CURRENT_USER_ATTRIBUTES]
|
|
79
97
|
end
|
|
80
98
|
|
|
81
|
-
def self.
|
|
82
|
-
|
|
99
|
+
def self.request_uri(env)
|
|
100
|
+
# 'REQUEST_URI' is not part of the Rack spec: Puma and Unicorn do set it
|
|
101
|
+
if env["REQUEST_URI"].nil?
|
|
102
|
+
query_string = env["QUERY_STRING"]
|
|
103
|
+
path = env["PATH_INFO"].to_s
|
|
104
|
+
query_string.nil? || query_string.empty? ? path : "#{path}?#{query_string}"
|
|
105
|
+
else
|
|
106
|
+
env["REQUEST_URI"]
|
|
107
|
+
end
|
|
83
108
|
end
|
|
84
109
|
|
|
85
110
|
def self.read_token_from_query_string(uri)
|
|
@@ -91,6 +116,8 @@ module KeycloakApiRails
|
|
|
91
116
|
else
|
|
92
117
|
""
|
|
93
118
|
end
|
|
119
|
+
rescue URI::InvalidURIError, ArgumentError
|
|
120
|
+
nil
|
|
94
121
|
end
|
|
95
122
|
|
|
96
123
|
def self.create_url_with_token(uri, token)
|
|
@@ -102,7 +129,12 @@ module KeycloakApiRails
|
|
|
102
129
|
end
|
|
103
130
|
|
|
104
131
|
def self.read_token_from_headers(headers)
|
|
105
|
-
headers["HTTP_AUTHORIZATION"]
|
|
132
|
+
authorization = headers["HTTP_AUTHORIZATION"]
|
|
133
|
+
if authorization.nil?
|
|
134
|
+
""
|
|
135
|
+
else
|
|
136
|
+
authorization.sub(BEARER_PREFIX, "")
|
|
137
|
+
end
|
|
106
138
|
end
|
|
107
139
|
end
|
|
108
140
|
end
|
|
@@ -1,35 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module KeycloakApiRails
|
|
4
|
+
class HTTPError < StandardError
|
|
5
|
+
attr_reader :status
|
|
6
|
+
|
|
7
|
+
def initialize(message, status = nil)
|
|
8
|
+
super(message)
|
|
9
|
+
@status = status
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
2
13
|
class HTTPClient
|
|
14
|
+
UNREACHABLE_ERRORS = [
|
|
15
|
+
Timeout::Error,
|
|
16
|
+
SocketError,
|
|
17
|
+
SystemCallError,
|
|
18
|
+
IOError,
|
|
19
|
+
OpenSSL::SSL::SSLError,
|
|
20
|
+
Net::ProtocolError
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
3
23
|
def initialize(configuration, logger)
|
|
4
|
-
@
|
|
5
|
-
@
|
|
6
|
-
@
|
|
7
|
-
@x509_store = OpenSSL::X509::Store.new
|
|
24
|
+
@configuration = configuration
|
|
25
|
+
@logger = logger
|
|
26
|
+
@x509_store = OpenSSL::X509::Store.new
|
|
8
27
|
@x509_store.set_default_paths
|
|
9
|
-
@x509_store.add_file(
|
|
28
|
+
@x509_store.add_file(configuration.ca_certificate_file) if configuration.ca_certificate_file
|
|
10
29
|
end
|
|
11
30
|
|
|
12
31
|
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
|
|
32
|
+
@configuration.validate_server!
|
|
33
|
+
|
|
34
|
+
uri = build_uri(realm_id, path)
|
|
35
|
+
response = request(uri)
|
|
36
|
+
|
|
37
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
38
|
+
@logger.error("KeycloakApiRails: Keycloak responded with an error when calling '#{path}'. Status #{response.code}. Payload: #{response.body}")
|
|
39
|
+
raise HTTPError.new("Keycloak responded with a #{response.code} status when calling '#{path}'", response.code)
|
|
25
40
|
end
|
|
41
|
+
|
|
42
|
+
parse(response, path)
|
|
26
43
|
end
|
|
27
44
|
|
|
28
45
|
private
|
|
29
46
|
|
|
47
|
+
def request(uri)
|
|
48
|
+
Net::HTTP.start(uri.host,
|
|
49
|
+
uri.port,
|
|
50
|
+
use_ssl: uri.scheme != "http",
|
|
51
|
+
cert_store: @x509_store,
|
|
52
|
+
open_timeout: @configuration.http_open_timeout,
|
|
53
|
+
read_timeout: @configuration.http_read_timeout) do |http|
|
|
54
|
+
http.request(Net::HTTP::Get.new(uri))
|
|
55
|
+
end
|
|
56
|
+
rescue *UNREACHABLE_ERRORS => e
|
|
57
|
+
@logger.error("KeycloakApiRails: could not reach Keycloak at '#{uri}'. #{e.class}: #{e.message}")
|
|
58
|
+
raise HTTPError, "Could not reach Keycloak at '#{uri}': #{e.message}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def parse(response, path)
|
|
62
|
+
JSON.parse(response.body)
|
|
63
|
+
rescue JSON::ParserError => e
|
|
64
|
+
@logger.error("KeycloakApiRails: could not parse the response of '#{path}'. #{e.message}")
|
|
65
|
+
raise HTTPError, "Keycloak returned a malformed JSON payload when calling '#{path}'"
|
|
66
|
+
end
|
|
67
|
+
|
|
30
68
|
def build_uri(realm_id, path)
|
|
31
|
-
|
|
32
|
-
URI(string_uri)
|
|
69
|
+
URI(File.join(@configuration.server_url, "realms", realm_id, path))
|
|
33
70
|
end
|
|
34
71
|
end
|
|
35
72
|
end
|