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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 74dec51fa027308c2cf5b57ca67b7316b21677bf1592022f76988f232bbde911
4
- data.tar.gz: 4836a7951bbf1559ff5eec0dfeb004ff8bcd2955cee7e06ee9e8a553743ad99e
3
+ metadata.gz: 1e80ef54a212cf523766b3d1f0a4d836dd4f32c59322eb9b90a13e0e530bff08
4
+ data.tar.gz: c6757d2a355db048389b3c697557daea96e912eea4d2fbeea413b78bd888ce03
5
5
  SHA512:
6
- metadata.gz: 48b3307f4dff316dfa063deddf0d106bbcc409856c20f0af5c441bdc3b1163e9cf93f0c3f7e53fd1a90fcb7946744c2b1b8863fd5a61a0c4012fe3d7df6b5aee
7
- data.tar.gz: 81aca7224bcae644dd35f6fb715bdb447b0da549bc3da0bdd311a6bea06adfba468936aabcdaeb305a44839c6770a46644abfdf81f2f828246414880e9ab52dd
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
- * Rails `>= 4.2`
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", "1.1.2"
21
+ gem "keycloak-api-rails", "2.0.2"
22
22
  ```
23
23
 
24
24
  ## Token validation
25
25
 
26
- Tokens sent (through query strings or Authorization headers) 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`).
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
- _e.g_ using curl: `curl -H "Authorization: Bearer <your-token>" https://api.pouet.io/api/more-pouets`
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 method are used at the same time, The query string as a higher priority when reading given tokens._
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::authentication` and calling `keycloak_authenticate`, for example in a `before_action`, like so:
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. | `auth:8080` |
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 the token must not be validatefd | `{ get: [/^\/health\/.+/] }`| 
69
- | `opt_in` | `false` | Boolean | Optional | When true, All requests will be validated (excluding requests matching `skip_paths`). When false, validation must be explicitly requested | `true`
70
- | `token_expiration_tolerance_in_seconds` | `10`| Logger | Optional | Number of seconds a token can expire before being rejected by the API. | `15` | 
71
- | `public_key_cache_ttl` | `86400`| Integer | Optional | Amount of time, in seconds, specifying maximum interval between two requests to {project_name} 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. | `Rails.logger` | 
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` | 
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["REQUEST_URI"]
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 v1.2.0 -m "Version 1.2.0"
270
- $ git push origin v1.2.0
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`
@@ -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 "rails", ">= 4.2"
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
- # Required by 'rake release', which builds and publishes the gem from the CI.
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 = request.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(uri, env)
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.assign_current_user_id(env, decoded_token)
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", {}).inject({}) do |resource_roles, (name, resource_attributes)|
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
- env[CURRENT_USER_ATTRIBUTES] = token.select { |key, value| attribute_names.include?(key) }
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.current_user_roles(env)
82
- env[ROLES_KEY]
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"]&.gsub(/^Bearer /, "") || ""
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
- @server_url = configuration.server_url
5
- @ca_certificate_file = configuration.ca_certificate_file
6
- @logger = logger
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(@ca_certificate_file) if @ca_certificate_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
- uri = build_uri(realm_id, path)
14
- use_ssl = uri.scheme == "http" ? false : true
15
- Net::HTTP.start(uri.host, uri.port, :use_ssl => use_ssl, :cert_store => @x509_store) do |http|
16
- request = Net::HTTP::Get.new(uri)
17
- response = http.request(request)
18
-
19
- begin
20
- response.value
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
- string_uri = File.join(@server_url, "realms", realm_id, path)
32
- URI(string_uri)
69
+ URI(File.join(@configuration.server_url, "realms", realm_id, path))
33
70
  end
34
71
  end
35
72
  end