keycloak-admin 2.0.0 → 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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -0
  3. data/README.md +54 -2
  4. data/keycloak-admin.gemspec +14 -5
  5. data/lib/keycloak-admin/client/client.rb +30 -11
  6. data/lib/keycloak-admin/client/client_authz_permission_client.rb +1 -1
  7. data/lib/keycloak-admin/client/client_authz_policy_client.rb +1 -1
  8. data/lib/keycloak-admin/client/client_authz_resource_client.rb +1 -1
  9. data/lib/keycloak-admin/client/client_authz_scope_client.rb +1 -1
  10. data/lib/keycloak-admin/client/configurable_token_client.rb +2 -1
  11. data/lib/keycloak-admin/client/group_client.rb +4 -6
  12. data/lib/keycloak-admin/client/organization_client.rb +7 -7
  13. data/lib/keycloak-admin/client/resource.rb +98 -0
  14. data/lib/keycloak-admin/client/response.rb +36 -0
  15. data/lib/keycloak-admin/client/role_mapper_client.rb +2 -1
  16. data/lib/keycloak-admin/client/user_client.rb +22 -11
  17. data/lib/keycloak-admin/configuration.rb +22 -0
  18. data/lib/keycloak-admin/error.rb +62 -0
  19. data/lib/keycloak-admin/version.rb +1 -1
  20. data/lib/keycloak-admin.rb +1 -0
  21. metadata +38 -53
  22. data/.github/workflows/Dockerfile +0 -24
  23. data/.github/workflows/ci.yml +0 -94
  24. data/.gitignore +0 -10
  25. data/.rspec +0 -2
  26. data/Dockerfile +0 -13
  27. data/Gemfile +0 -3
  28. data/Gemfile.lock +0 -52
  29. data/bin/console +0 -9
  30. data/spec/client/attack_detection_client_spec.rb +0 -102
  31. data/spec/client/client_authz_permission_client_spec.rb +0 -170
  32. data/spec/client/client_authz_policy_client_spec.rb +0 -170
  33. data/spec/client/client_authz_resource_client_spec.rb +0 -150
  34. data/spec/client/client_authz_scope_client_spec.rb +0 -134
  35. data/spec/client/client_client_spec.rb +0 -133
  36. data/spec/client/client_role_mappings_client_spec.rb +0 -82
  37. data/spec/client/client_scope_client_spec.rb +0 -220
  38. data/spec/client/client_scope_protocol_mapper_client_spec.rb +0 -230
  39. data/spec/client/client_spec.rb +0 -48
  40. data/spec/client/configurable_token_client_spec.rb +0 -34
  41. data/spec/client/group_client_spec.rb +0 -328
  42. data/spec/client/identity_provider_client_spec.rb +0 -92
  43. data/spec/client/organization_client_spec.rb +0 -595
  44. data/spec/client/realm_client_spec.rb +0 -155
  45. data/spec/client/role_client_spec.rb +0 -79
  46. data/spec/client/role_mapper_client_spec.rb +0 -113
  47. data/spec/client/token_client_spec.rb +0 -68
  48. data/spec/client/user_client_spec.rb +0 -418
  49. data/spec/configuration_spec.rb +0 -113
  50. data/spec/gemspec_spec.rb +0 -25
  51. data/spec/integration/client_authorization_spec.rb +0 -93
  52. data/spec/representation/attack_detection_representation_spec.rb +0 -16
  53. data/spec/representation/client_authz_permission_representation_spec.rb +0 -52
  54. data/spec/representation/client_authz_policy_representation_spec.rb +0 -47
  55. data/spec/representation/client_authz_resource_representation_spec.rb +0 -33
  56. data/spec/representation/client_authz_scope_representation_spec.rb +0 -19
  57. data/spec/representation/client_representation_spec.rb +0 -119
  58. data/spec/representation/client_scope_representation_spec.rb +0 -125
  59. data/spec/representation/credential_representation_spec.rb +0 -68
  60. data/spec/representation/group_representation_spec.rb +0 -22
  61. data/spec/representation/identity_provider_mapper_representation_spec.rb +0 -24
  62. data/spec/representation/identity_provider_representation_spec.rb +0 -113
  63. data/spec/representation/impersonation_representation_spec.rb +0 -163
  64. data/spec/representation/organization_representation_spec.rb +0 -64
  65. data/spec/representation/protocol_mapper_representation_spec.rb +0 -57
  66. data/spec/representation/role_representation_spec.rb +0 -37
  67. data/spec/representation/session_representation_spec.rb +0 -16
  68. data/spec/representation/user_representation_spec.rb +0 -15
  69. data/spec/resource/group_resource_spec.rb +0 -14
  70. data/spec/resource/user_resource_spec.rb +0 -14
  71. data/spec/spec_helper.rb +0 -29
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0c198fd53c7034b75c805c4479b73eec2dec2159da57e6cebd8056742ad40cd6
4
- data.tar.gz: f2532b1773d60f633644bb5dd9c0c361933037db228ec871ac92aea73f4baf0b
3
+ metadata.gz: 9bc43409191a911c85de270b70021cbe48d0372844c5a2a4d87409f250d6b646
4
+ data.tar.gz: eee1d04d6026a04da64769c02cbe49ec9f84d186a1fafefc45476925979333c6
5
5
  SHA512:
6
- metadata.gz: 553e781914529764e787e455918d878e7ce49902e4f75b679f66a6559d59a70684058f655e81f7de23bd3f83e9f884a26c3bad3a9d2c962600de66249176409a
7
- data.tar.gz: 626a03f798614205ffe9218b76f31d8fd625810980c33f49d12ce322e79e5e47977104873863ea36b955b79efb68aa995af3f63efa4aad2beef77b38dba85527
6
+ metadata.gz: 96fccdf9c111acf6cfc67d6d05f7fd3b7e87140292d63df5dd9fdfebb228c30595416ae23ee8e465e06be1b54e3be857d8a64f599a447d642082086054805eb4
7
+ data.tar.gz: c49b8b8e7b2a13ae05eb69c47c3a0ee4c935a4e8a689fbf8863d01f843f95ee2506460e2ccf852223586266ae3591ee9cefff83819d087410b2fe53ccfadf0c1
data/CHANGELOG.md CHANGED
@@ -5,6 +5,25 @@ 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.2] - 2026-08-01
9
+
10
+ * [Breaking] HTTP failures now raise a `KeycloakAdmin::ApiError` subclass instead of a bare `RuntimeError`.
11
+ * Messages are unchanged, so code matching on the text keeps working.
12
+ * Code rescuing `RuntimeError` explicitly must be updated.
13
+ * A create call answering something other than `201 Created` now raises `KeycloakAdmin::UnexpectedResponseError`.
14
+ * [Feature] A `401` answer now drops the cached access token and replays the request once with a freshly fetched one, so a token revoked before its advertised expiry no longer fails every call until it lapses.
15
+ * [Fix] `GroupClient#members` called ActiveSupport's `Object#try` and raised `NoMethodError` outside a Rails application.
16
+ * [Fix] `UserClient#execute_actions_email` (and `#forgot_password`) called ActiveSupport's `Numeric#seconds` and raised `NoMethodError` outside a Rails application whenever a `lifespan` was passed.
17
+ * [Fix] `ClientAuthzPolicyClient#find_by` opened a second `?` in its URL, which folded `name`, `type`, `first` and `max` into the value of `permission`; Keycloak silently returned an unfiltered list.
18
+ * [Fix] Query parameters are now percent-encoded everywhere they are built. Search terms, and the `redirect_uri` of `UserClient#execute_actions_email`, containing a space, `&` or `=` used to break the URL or forge extra parameters.
19
+ * [Fix] `OrganizationClient#build` raised `NameError` instead of the intended `ArgumentError` when `domains` was not an `Array`.
20
+
21
+ ## [2.0.1] - 2026-07-31
22
+
23
+ * [Feature] Requests are now logged through `config.logger` (method, URL, and response status) via Faraday's `:logger` middleware.
24
+ * [Feature] The access token is now cached and reused (tracking `expires_in`) instead of being fetched before nearly every call. Previously, caching lived on the `Client` instance, but a fresh `Client` subclass is created for almost every call (e.g. `KeycloakAdmin.realm(x).users` builds a new `UserClient`), so the cache was rarely hit in practice; it now lives on the shared `Configuration`.
25
+ * Publish the gem to RubyGems from Github Actions when a `v*` tag is pushed, using RubyGems' Trusted Publishing
26
+
8
27
  ## [2.0.0] - 2026-07-31
9
28
 
10
29
  * [Breaking] Replaced `rest-client` with `Faraday` as the underlying HTTP library. This should be transparent for callers of this gem's own API, but `config.rest_client_options` is renamed to `config.faraday_options` and its shape changes from rest-client's flat hash to Faraday's connection options (e.g. `{ timeout: 5 }` becomes `{ request: { timeout: 5 } }`, `{ verify_ssl: false }` becomes `{ ssl: { verify: false } }`). See the `Configuration` section of the README.
data/README.md CHANGED
@@ -16,7 +16,7 @@ This gem *does not* require Rails.
16
16
  For example, using `bundle`, add this line to your Gemfile.
17
17
 
18
18
  ```ruby
19
- gem "keycloak-admin", "2.0.0"
19
+ gem "keycloak-admin", "2.0.2"
20
20
  ```
21
21
 
22
22
  ## Login
@@ -109,6 +109,42 @@ All options have a default value. However, all of them can be changed in your in
109
109
  | `faraday_options` | `{}`| Hash | Optional | Options to pass to `Faraday.new` (e.g. `request:`, `ssl:`, `proxy:`) | `{ request: { timeout: 5 } }` | 
110
110
 
111
111
 
112
+ ## Error handling
113
+
114
+ Every HTTP failure raises a `KeycloakAdmin::ApiError` carrying the `status`, `body` and `headers`
115
+ of the response, so you never have to parse the message. Rescue as narrowly or as broadly as you need:
116
+
117
+ ```ruby
118
+ begin
119
+ KeycloakAdmin.realm("a-realm").users.get(user_id)
120
+ rescue KeycloakAdmin::NotFoundError => e # exactly 404
121
+ nil
122
+ rescue KeycloakAdmin::ClientError => e # any 4xx
123
+ logger.warn("Rejected with #{e.status}: #{e.body}")
124
+ raise
125
+ rescue KeycloakAdmin::ServerError => e # any 5xx
126
+ raise
127
+ end
128
+ ```
129
+
130
+ | Class | Raised on |
131
+ | ---- | ---- |
132
+ | `KeycloakAdmin::Error` | Base class of every error this gem raises |
133
+ | `KeycloakAdmin::ApiError` | Any non-2xx answer; parent of the two families below |
134
+ | `KeycloakAdmin::ClientError` | Any 4xx |
135
+ | `KeycloakAdmin::BadRequestError` | 400 |
136
+ | `KeycloakAdmin::UnauthorizedError` | 401 |
137
+ | `KeycloakAdmin::ForbiddenError` | 403 |
138
+ | `KeycloakAdmin::NotFoundError` | 404 |
139
+ | `KeycloakAdmin::ConflictError` | 409 |
140
+ | `KeycloakAdmin::ServerError` | Any 5xx |
141
+ | `KeycloakAdmin::UnexpectedResponseError` | A create call answered something other than `201 Created` |
142
+
143
+ `Faraday::TimeoutError` and the other connection-level Faraday errors are left untouched and propagate as-is, since no response ever came back to describe.
144
+
145
+ A `401` is handled before it reaches you: the cached access token is dropped and the request is replayed once with a freshly fetched one, so a token revoked before its advertised expiry does not fail every call until it lapses `KeycloakAdmin::UnauthorizedError` is raised only if the replay is
146
+ refused too.
147
+
112
148
  ## Use Cases
113
149
 
114
150
  ### Supported features
@@ -348,7 +384,7 @@ KeycloakAdmin.realm("a_realm").groups.list
348
384
 
349
385
  Returns an array of `KeycloakAdmin::GroupRepresentation`.
350
386
 
351
- According to [the documentation](https://www.keycloak.org/docs-api/22.0.1/rest-api/index.html#_groups):
387
+ According to [the documentation](https://www.keycloak.org/docs-api/22.0.2/rest-api/index.html#_groups):
352
388
  * When providing a `String` parameter, this produces an arbitrary search string
353
389
  * When providing a `Hash`, you can specify other fields (_e.g_ q, max, first)
354
390
 
@@ -885,3 +921,19 @@ From the `keycloak-admin-api` directory:
885
921
  $ docker build . -t keycloak-admin:test
886
922
  $ docker run -v `pwd`:/usr/src/app/ keycloak-admin:test rspec spec
887
923
  ```
924
+
925
+ ## How to release a new version
926
+
927
+ Releases are published to [RubyGems](https://rubygems.org/gems/keycloak-admin) by GitHub Actions
928
+ (`.github/workflows/release.yml`), through [Trusted Publishing](https://guides.rubygems.org/trusted-publishing/):
929
+ no API key is stored in this repository, the workflow exchanges a short-lived GitHub OIDC token for a
930
+ scoped RubyGems credential.
931
+
932
+ 1. Update `KeycloakAdmin::VERSION` in `lib/keycloak-admin/version.rb` and the `CHANGELOG.md`
933
+ 2. Commit and push these changes to `main`
934
+ 3. Tag the commit and push the tag:
935
+
936
+ ```
937
+ $ git tag -a v2.0.2 -m "Version 2.0.2"
938
+ $ git push origin v2.0.2
939
+ ```
@@ -12,14 +12,23 @@ Gem::Specification.new do |spec|
12
12
  spec.description = "Keycloak Admin REST API client written in Ruby"
13
13
  spec.license = "MIT"
14
14
 
15
- spec.files = `git ls-files -z`.split("\x0")
15
+ spec.files = Dir.glob(["lib/**/*.rb", "CHANGELOG.md", "MIT-LICENSE", "README.md", "keycloak-admin.gemspec"]).sort
16
16
  spec.require_paths = ["lib"]
17
17
 
18
- spec.required_ruby_version = '>= 3.1'
18
+ spec.required_ruby_version = ">= 3.1"
19
+
20
+ spec.metadata = {
21
+ "source_code_uri" => spec.homepage,
22
+ "changelog_uri" => "#{spec.homepage}/blob/main/CHANGELOG.md",
23
+ "bug_tracker_uri" => "#{spec.homepage}/issues",
24
+ "rubygems_mfa_required" => "true"
25
+ }
19
26
 
20
27
  spec.add_dependency "http-cookie", "~> 1.0", ">= 1.0.3"
21
28
  spec.add_dependency "faraday", "~> 2.0"
22
29
  spec.add_dependency "base64"
23
- spec.add_development_dependency "rspec", "3.13.2"
24
- spec.add_development_dependency "byebug", "12.0.0"
25
- end
30
+ spec.add_development_dependency "rspec", "3.13.2"
31
+ spec.add_development_dependency "byebug", "12.0.0"
32
+ spec.add_development_dependency "rake", ">= 13.0"
33
+ spec.add_development_dependency "webmock", "~> 3.0"
34
+ end
@@ -1,3 +1,5 @@
1
+ require "uri"
2
+
1
3
  module KeycloakAdmin
2
4
  class Client
3
5
 
@@ -9,8 +11,11 @@ module KeycloakAdmin
9
11
  @configuration.server_url&.sub(/\/+\z/, "")
10
12
  end
11
13
 
14
+ # Cached on @configuration, not on this instance, since a new Client subclass is
15
+ # created for nearly every call (e.g. KeycloakAdmin.realm(x).users creates a fresh
16
+ # UserClient) - caching here alone would fetch a new token on almost every request.
12
17
  def current_token
13
- @current_token ||= KeycloakAdmin.create_client(@configuration, @configuration.client_realm_name).token.get
18
+ @configuration.cached_token || @configuration.cache_token(fetch_token)
14
19
  end
15
20
 
16
21
  def headers
@@ -22,16 +27,24 @@ module KeycloakAdmin
22
27
  end
23
28
 
24
29
  def execute_http
25
- yield
26
- rescue Faraday::TimeoutError
27
- raise
28
- rescue Faraday::ClientError, Faraday::ServerError => e
29
- http_error(e.response)
30
+ replayed = false
31
+ begin
32
+ yield
33
+ rescue Faraday::TimeoutError
34
+ raise
35
+ rescue Faraday::ClientError, Faraday::ServerError => e
36
+ if !replayed && e.response && e.response[:status] == 401 && !@configuration.cached_token.nil?
37
+ @configuration.clear_cached_token!
38
+ replayed = true
39
+ retry
40
+ end
41
+ raise ApiError.from_response(e.response)
42
+ end
30
43
  end
31
44
 
32
45
  def created_id(response)
33
46
  unless response.status == 201
34
- raise "Create method returned status #{response.reason_phrase} (Code: #{response.status}); expected status: Created (201)"
47
+ raise UnexpectedResponseError.new(response.status, response.reason_phrase)
35
48
  end
36
49
  (_head, _separator, id) = response.headers[:location].rpartition("/")
37
50
  id
@@ -49,12 +62,18 @@ module KeycloakAdmin
49
62
 
50
63
  private
51
64
 
52
- def resource(url)
53
- Resource.new(url, @configuration.faraday_options)
65
+ def build_query(parameters)
66
+ parameters.map do |name, value|
67
+ "#{URI.encode_uri_component(name.to_s)}=#{URI.encode_uri_component(value.to_s)}"
68
+ end.join("&")
54
69
  end
55
70
 
56
- def http_error(response)
57
- raise "Keycloak: The request failed with response code #{response[:status]} and message: #{response[:body]}"
71
+ def fetch_token
72
+ KeycloakAdmin.create_client(@configuration, @configuration.client_realm_name).token.get
73
+ end
74
+
75
+ def resource(url)
76
+ Resource.new(url, @configuration.faraday_options, @configuration.logger)
58
77
  end
59
78
  end
60
79
  end
@@ -20,7 +20,7 @@ module KeycloakAdmin
20
20
 
21
21
  def find_by(name, resource_param, scope = nil)
22
22
  response = execute_http do
23
- url = "#{authz_permission_url(@client_id)}?name=#{name}&resource=#{resource_param}&type=#{@type}&scope=#{scope}&deep=true&first=0&max=100"
23
+ url = "#{authz_permission_url(@client_id)}?#{build_query(name: name, resource: resource_param, type: @type, scope: scope, deep: true, first: 0, max: 100)}"
24
24
  resource(url).get(headers)
25
25
  end
26
26
  JSON.parse(response).map { |role_as_hash| ClientAuthzPermissionRepresentation.from_hash(role_as_hash) }
@@ -33,7 +33,7 @@ module KeycloakAdmin
33
33
 
34
34
  def find_by(name, type)
35
35
  response = execute_http do
36
- url = "#{authz_policy_url(@client_id, @type)}?permission=false&name=#{name}&type=#{type}&first=0&max=11"
36
+ url = "#{authz_policy_url(@client_id, @type)}&#{build_query(name: name, type: type, first: 0, max: 11)}"
37
37
  resource(url).get(headers)
38
38
  end
39
39
  JSON.parse(response).map { |role_as_hash| ClientAuthzPolicyRepresentation.from_hash(role_as_hash) }
@@ -47,7 +47,7 @@ module KeycloakAdmin
47
47
 
48
48
  def find_by(name, type, uris, owner, scope)
49
49
  response = execute_http do
50
- url = "#{authz_resources_url(@client_id)}?name=#{name}&type=#{type}&uris=#{uris}&owner=#{owner}&scope=#{scope}&deep=true&first=0&max=100"
50
+ url = "#{authz_resources_url(@client_id)}?#{build_query(name: name, type: type, uris: uris, owner: owner, scope: scope, deep: true, first: 0, max: 100)}"
51
51
  resource(url).get(headers)
52
52
  end
53
53
  JSON.parse(response).map { |role_as_hash| ClientAuthzResourceRepresentation.from_hash(role_as_hash) }
@@ -35,7 +35,7 @@ module KeycloakAdmin
35
35
  end
36
36
 
37
37
  def search(name)
38
- url = "#{authz_scopes_url(@client_id)}?first=0&max=11&deep=false&name=#{name}"
38
+ url = "#{authz_scopes_url(@client_id)}?#{build_query(first: 0, max: 11, deep: false, name: name)}"
39
39
  response = execute_http do
40
40
  resource(url).get(headers)
41
41
  end
@@ -25,7 +25,8 @@ module KeycloakAdmin
25
25
  Authorization: "Bearer #{user_access_token}",
26
26
  content_type: :json,
27
27
  accept: :json
28
- }
28
+ },
29
+ logger: @configuration.logger
29
30
  )
30
31
  )
31
32
  end
@@ -75,11 +75,8 @@ module KeycloakAdmin
75
75
 
76
76
  def members(group_id, first=0, max=100)
77
77
  url = "#{groups_url(group_id)}/members"
78
- query = {first: first.try(:to_i), max: max.try(:to_i)}.compact
79
- unless query.empty?
80
- query_string = query.to_a.map { |e| "#{e[0]}=#{e[1]}" }.join("&")
81
- url = "#{url}?#{query_string}"
82
- end
78
+ query = {first: first&.to_i, max: max&.to_i}.compact
79
+ url = "#{url}?#{build_query(query)}" unless query.empty?
83
80
  response = execute_http do
84
81
  resource(url).get(headers)
85
82
  end
@@ -118,7 +115,8 @@ module KeycloakAdmin
118
115
  url: url,
119
116
  method: :delete,
120
117
  payload: create_payload([role_representation]),
121
- headers: headers
118
+ headers: headers,
119
+ logger: @configuration.logger
122
120
  )
123
121
  )
124
122
  end
@@ -170,24 +170,24 @@ module KeycloakAdmin
170
170
  end
171
171
 
172
172
  def count_url(exact, query, search)
173
- query_parameters = {exact: exact, q: query, search: search}.compact.to_a.map { |e| "#{e[0]}=#{e[1]}" }.join("&")
173
+ query_parameters = build_query({exact: exact, q: query, search: search}.compact)
174
174
  "#{organizations_url}/count?#{query_parameters}"
175
175
  end
176
176
 
177
177
  def organizations_url_with_parameters(brief_representation, exact, first, max, query, search)
178
- query_parameters = {
178
+ query_parameters = build_query({
179
179
  briefRepresentation: brief_representation,
180
180
  exact: exact,
181
181
  first: first,
182
182
  max: max,
183
183
  q: query,
184
184
  search: search
185
- }.compact.to_a.map { |e| "#{e[0]}=#{e[1]}" }.join("&")
185
+ }.compact)
186
186
  "#{organizations_url}?#{query_parameters}"
187
187
  end
188
188
 
189
189
  def associated_with_member_url(member_id, brief_representation=true)
190
- "#{organizations_url}/members/#{member_id}/organizations?briefRepresentation=#{brief_representation}"
190
+ "#{organizations_url}/members/#{member_id}/organizations?#{build_query(briefRepresentation: brief_representation)}"
191
191
  end
192
192
 
193
193
  def members_count_url(organization_id)
@@ -212,19 +212,19 @@ module KeycloakAdmin
212
212
  end
213
213
 
214
214
  def members_url_with_query_parameters(organization_id, exact, first, max, membership_type, search)
215
- query_parameters = {
215
+ query_parameters = build_query({
216
216
  exact: exact,
217
217
  first: first,
218
218
  max: max,
219
219
  membershipType: membership_type,
220
220
  search: search
221
- }.compact.to_a.map { |e| "#{e[0]}=#{e[1]}" }.join("&")
221
+ }.compact)
222
222
  "#{organization_url(organization_id)}/members?#{query_parameters}"
223
223
  end
224
224
 
225
225
  def build(name, alias_name, enabled, description, redirect_url=nil, domains=[], attributes={})
226
226
  unless domains.is_a?(Array)
227
- raise ArgumentError.new("domains must be an Array, got #{new_domains.class}")
227
+ raise ArgumentError.new("domains must be an Array, got #{domains.class}")
228
228
  end
229
229
 
230
230
  unless domains.all? { |domain| domain.is_a?(KeycloakAdmin::OrganizationDomainRepresentation) }
@@ -0,0 +1,98 @@
1
+ require "faraday"
2
+ require "uri"
3
+
4
+ module KeycloakAdmin
5
+ # Thin facade over Faraday that mirrors RestClient::Resource/RestClient::Request's call
6
+ # shape, so every client class can build/send requests without depending on Faraday's API
7
+ # directly.
8
+ class Resource
9
+ MIME_TYPES = { json: "application/json" }.freeze
10
+
11
+ def self.execute(options)
12
+ options = options.dup
13
+ method = options.delete(:method)
14
+ url = options.delete(:url)
15
+ payload = options.delete(:payload)
16
+ headers = options.delete(:headers) || {}
17
+ logger = options.delete(:logger)
18
+ new(url, options, logger).send(:request, method, payload, headers)
19
+ end
20
+
21
+ def self.put(url, payload, headers = {}, logger = nil)
22
+ new(url, {}, logger).put(payload, headers)
23
+ end
24
+
25
+ def initialize(url, options = {}, logger = nil)
26
+ @url = url
27
+ @options = options || {}
28
+ @logger = logger
29
+ end
30
+
31
+ def get(headers = {})
32
+ request(:get, nil, headers)
33
+ end
34
+
35
+ def delete(headers = {})
36
+ request(:delete, nil, headers)
37
+ end
38
+
39
+ def post(payload, headers = {})
40
+ request(:post, payload, headers)
41
+ end
42
+
43
+ def put(payload, headers = {})
44
+ request(:put, payload, headers)
45
+ end
46
+
47
+ private
48
+
49
+ def request(method, payload, headers)
50
+ headers = headers || {}
51
+ query = headers[:params]
52
+ send_headers = build_headers(headers.reject { |key, _| key == :params })
53
+ body = encode_payload(payload)
54
+
55
+ # RestClient::Payload::UrlEncoded always stamped this Content-Type for Hash payloads,
56
+ # regardless of what the caller's headers said; token retrieval relies on it and never
57
+ # sets content_type itself.
58
+ if payload.is_a?(Hash) && !send_headers.key?("Content-Type")
59
+ send_headers["Content-Type"] = "application/x-www-form-urlencoded"
60
+ end
61
+
62
+ faraday_response = connection.public_send(method) do |req|
63
+ req.params.update(query) if query
64
+ send_headers.each { |name, value| req.headers[name] = value }
65
+ req.body = body unless body.nil?
66
+ end
67
+ Response.new(faraday_response)
68
+ end
69
+
70
+ def connection
71
+ Faraday.new(url: @url, **@options) do |f|
72
+ f.response :raise_error
73
+ # Added after :raise_error so it logs the raw response (status/duration) before
74
+ # :raise_error can raise, including on failures. Faraday's logger middleware logs
75
+ # headers by default, which would print the bearer token on every call; headers: false
76
+ # keeps this to method/url/status only. Bodies are already off by Faraday's own default.
77
+ f.response :logger, @logger, headers: false if @logger
78
+ f.adapter Faraday.default_adapter
79
+ end
80
+ end
81
+
82
+ def encode_payload(payload)
83
+ return nil if payload.nil?
84
+ payload.is_a?(Hash) ? URI.encode_www_form(payload) : payload.to_s
85
+ end
86
+
87
+ def build_headers(headers)
88
+ headers.each_with_object({}) do |(key, value), result|
89
+ name = key.is_a?(Symbol) ? humanize(key) : key.to_s
90
+ result[name] = value.is_a?(Symbol) ? MIME_TYPES.fetch(value, value.to_s) : value
91
+ end
92
+ end
93
+
94
+ def humanize(key)
95
+ key.to_s.split("_").map { |word| word.capitalize }.join("-")
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,36 @@
1
+ module KeycloakAdmin
2
+ # Wraps a Faraday::Response the way RestClient::Response used to: as the body string
3
+ # itself (RestClient::Response < String), so existing call sites that do JSON.parse(response)
4
+ # or response.to_i keep working, while .body/.headers/.status/.code/.reason_phrase stay
5
+ # available for call sites that need response metadata.
6
+ class Response < String
7
+ attr_reader :status, :reason_phrase, :headers
8
+
9
+ def initialize(faraday_response)
10
+ super(faraday_response.body.to_s)
11
+ @status = faraday_response.status
12
+ @reason_phrase = faraday_response.reason_phrase
13
+ @headers = symbolize_headers(faraday_response.headers)
14
+ end
15
+
16
+ alias_method :code, :status
17
+ alias_method :body, :to_s
18
+
19
+ private
20
+
21
+ def symbolize_headers(faraday_headers)
22
+ faraday_headers.each_with_object({}) do |(key, value), result|
23
+ symbol_key = key.downcase.tr("-", "_").to_sym
24
+ result[symbol_key] = symbol_key == :set_cookie ? split_set_cookie(value) : value
25
+ end
26
+ end
27
+
28
+ # Net::HTTP (used by Faraday's default adapter) joins repeated Set-Cookie header lines with
29
+ # ", " (see Net::HTTPHeader#each_header), losing the one-header-per-cookie structure RestClient
30
+ # exposed as an Array. Re-split on cookie boundaries rather than on every comma, since a comma
31
+ # can also appear inside a cookie's own "Expires=Wed, 09 Jun 2027 ..." attribute.
32
+ def split_set_cookie(value)
33
+ value.split(/,(?=\s*[^;\s]+=)/).map(&:strip)
34
+ end
35
+ end
36
+ end
@@ -27,7 +27,8 @@ module KeycloakAdmin
27
27
  method: :delete,
28
28
  url: realm_level_url,
29
29
  payload: create_payload(role_representation_list),
30
- headers: headers
30
+ headers: headers,
31
+ logger: @configuration.logger
31
32
  )
32
33
  )
33
34
  end
@@ -28,7 +28,8 @@ module KeycloakAdmin
28
28
  method: :put,
29
29
  url: users_url(user_id),
30
30
  payload: create_payload(user_representation_body),
31
- headers: headers
31
+ headers: headers,
32
+ logger: @configuration.logger
32
33
  )
33
34
  )
34
35
  end
@@ -39,7 +40,8 @@ module KeycloakAdmin
39
40
  method: :put,
40
41
  url: "#{users_url(user_id)}/groups/#{group_id}",
41
42
  payload: create_payload({}),
42
- headers: headers
43
+ headers: headers,
44
+ logger: @configuration.logger
43
45
  )
44
46
  )
45
47
  end
@@ -49,7 +51,8 @@ module KeycloakAdmin
49
51
  @configuration.faraday_options.merge(
50
52
  method: :delete,
51
53
  url: "#{users_url(user_id)}/groups/#{group_id}",
52
- headers: headers
54
+ headers: headers,
55
+ logger: @configuration.logger
53
56
  )
54
57
  )
55
58
  end
@@ -116,7 +119,8 @@ module KeycloakAdmin
116
119
  method: :put,
117
120
  url: reset_password_url(user_id),
118
121
  payload: { type: "password", value: new_password, temporary: false }.to_json,
119
- headers: headers
122
+ headers: headers,
123
+ logger: @configuration.logger
120
124
  )
121
125
  )
122
126
  end
@@ -137,10 +141,14 @@ module KeycloakAdmin
137
141
  def execute_actions_email(user_id, actions=[], lifespan=nil, redirect_uri=nil, client_id=nil)
138
142
  raise ArgumentError.new("client_id must be defined") if client_id.nil? && !redirect_uri.nil?
139
143
  execute_http do
140
- lifespan_param = lifespan.nil? ? "" : "&lifespan=#{lifespan.seconds}"
141
- redirect_uri_param = redirect_uri.nil? ? "" : "&redirect_uri=#{redirect_uri}"
142
- client_id_param = client_id.nil? ? "" : "client_id=#{client_id}"
143
- Resource.put("#{execute_actions_email_url(user_id)}?#{client_id_param}#{redirect_uri_param}#{lifespan_param}", create_payload(actions), headers)
144
+ query = {}
145
+ query[:client_id] = client_id unless client_id.nil?
146
+ query[:redirect_uri] = redirect_uri unless redirect_uri.nil?
147
+ query[:lifespan] = lifespan.to_i unless lifespan.nil?
148
+
149
+ url = execute_actions_email_url(user_id)
150
+ url = "#{url}?#{build_query(query)}" unless query.empty?
151
+ Resource.put(url, create_payload(actions), headers, @configuration.logger)
144
152
  end
145
153
  user_id
146
154
  end
@@ -153,7 +161,8 @@ module KeycloakAdmin
153
161
  method: :post,
154
162
  url: impersonation.impersonation_url,
155
163
  payload: impersonation.body.to_json,
156
- headers: impersonation.headers
164
+ headers: impersonation.headers,
165
+ logger: @configuration.logger
157
166
  )
158
167
  )
159
168
  end
@@ -177,7 +186,8 @@ module KeycloakAdmin
177
186
  @configuration.faraday_options.merge(
178
187
  method: :post,
179
188
  url: logout_url(user_id),
180
- headers: headers
189
+ headers: headers,
190
+ logger: @configuration.logger
181
191
  )
182
192
  )
183
193
  end
@@ -200,7 +210,8 @@ module KeycloakAdmin
200
210
  method: :post,
201
211
  url: federated_identity_url(user_id, idp_id),
202
212
  payload: fed_id_rep.to_json,
203
- headers: headers
213
+ headers: headers,
214
+ logger: @configuration.logger
204
215
  )
205
216
  )
206
217
  end
@@ -4,6 +4,28 @@ module KeycloakAdmin
4
4
  class Configuration
5
5
  attr_accessor :server_url, :server_domain, :client_id, :client_secret, :client_realm_name, :use_service_account, :username, :password, :logger, :faraday_options
6
6
 
7
+ # Treat a cached token as expired this many seconds before its real expiry, so it
8
+ # cannot go stale between the check below and the request that relies on it.
9
+ TOKEN_EXPIRY_SAFETY_MARGIN_SECONDS = 10
10
+
11
+ # Returns the cached TokenRepresentation, or nil if there is none or it is (about to be) expired.
12
+ def cached_token
13
+ return nil if @token_expires_at.nil? || Time.now >= @token_expires_at
14
+ @cached_token
15
+ end
16
+
17
+ def cache_token(token_representation)
18
+ return token_representation unless token_representation.expires_in.is_a?(Numeric)
19
+ @cached_token = token_representation
20
+ @token_expires_at = Time.now + token_representation.expires_in - TOKEN_EXPIRY_SAFETY_MARGIN_SECONDS
21
+ token_representation
22
+ end
23
+
24
+ def clear_cached_token!
25
+ @cached_token = nil
26
+ @token_expires_at = nil
27
+ end
28
+
7
29
  def body_for_token_retrieval
8
30
  if use_service_account
9
31
  body_for_service_account