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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +54 -2
- data/keycloak-admin.gemspec +14 -5
- data/lib/keycloak-admin/client/client.rb +30 -11
- data/lib/keycloak-admin/client/client_authz_permission_client.rb +1 -1
- data/lib/keycloak-admin/client/client_authz_policy_client.rb +1 -1
- data/lib/keycloak-admin/client/client_authz_resource_client.rb +1 -1
- data/lib/keycloak-admin/client/client_authz_scope_client.rb +1 -1
- data/lib/keycloak-admin/client/configurable_token_client.rb +2 -1
- data/lib/keycloak-admin/client/group_client.rb +4 -6
- data/lib/keycloak-admin/client/organization_client.rb +7 -7
- data/lib/keycloak-admin/client/resource.rb +98 -0
- data/lib/keycloak-admin/client/response.rb +36 -0
- data/lib/keycloak-admin/client/role_mapper_client.rb +2 -1
- data/lib/keycloak-admin/client/user_client.rb +22 -11
- data/lib/keycloak-admin/configuration.rb +22 -0
- data/lib/keycloak-admin/error.rb +62 -0
- data/lib/keycloak-admin/version.rb +1 -1
- data/lib/keycloak-admin.rb +1 -0
- metadata +38 -53
- data/.github/workflows/Dockerfile +0 -24
- data/.github/workflows/ci.yml +0 -94
- data/.gitignore +0 -10
- data/.rspec +0 -2
- data/Dockerfile +0 -13
- data/Gemfile +0 -3
- data/Gemfile.lock +0 -52
- data/bin/console +0 -9
- data/spec/client/attack_detection_client_spec.rb +0 -102
- data/spec/client/client_authz_permission_client_spec.rb +0 -170
- data/spec/client/client_authz_policy_client_spec.rb +0 -170
- data/spec/client/client_authz_resource_client_spec.rb +0 -150
- data/spec/client/client_authz_scope_client_spec.rb +0 -134
- data/spec/client/client_client_spec.rb +0 -133
- data/spec/client/client_role_mappings_client_spec.rb +0 -82
- data/spec/client/client_scope_client_spec.rb +0 -220
- data/spec/client/client_scope_protocol_mapper_client_spec.rb +0 -230
- data/spec/client/client_spec.rb +0 -48
- data/spec/client/configurable_token_client_spec.rb +0 -34
- data/spec/client/group_client_spec.rb +0 -328
- data/spec/client/identity_provider_client_spec.rb +0 -92
- data/spec/client/organization_client_spec.rb +0 -595
- data/spec/client/realm_client_spec.rb +0 -155
- data/spec/client/role_client_spec.rb +0 -79
- data/spec/client/role_mapper_client_spec.rb +0 -113
- data/spec/client/token_client_spec.rb +0 -68
- data/spec/client/user_client_spec.rb +0 -418
- data/spec/configuration_spec.rb +0 -113
- data/spec/gemspec_spec.rb +0 -25
- data/spec/integration/client_authorization_spec.rb +0 -93
- data/spec/representation/attack_detection_representation_spec.rb +0 -16
- data/spec/representation/client_authz_permission_representation_spec.rb +0 -52
- data/spec/representation/client_authz_policy_representation_spec.rb +0 -47
- data/spec/representation/client_authz_resource_representation_spec.rb +0 -33
- data/spec/representation/client_authz_scope_representation_spec.rb +0 -19
- data/spec/representation/client_representation_spec.rb +0 -119
- data/spec/representation/client_scope_representation_spec.rb +0 -125
- data/spec/representation/credential_representation_spec.rb +0 -68
- data/spec/representation/group_representation_spec.rb +0 -22
- data/spec/representation/identity_provider_mapper_representation_spec.rb +0 -24
- data/spec/representation/identity_provider_representation_spec.rb +0 -113
- data/spec/representation/impersonation_representation_spec.rb +0 -163
- data/spec/representation/organization_representation_spec.rb +0 -64
- data/spec/representation/protocol_mapper_representation_spec.rb +0 -57
- data/spec/representation/role_representation_spec.rb +0 -37
- data/spec/representation/session_representation_spec.rb +0 -16
- data/spec/representation/user_representation_spec.rb +0 -15
- data/spec/resource/group_resource_spec.rb +0 -14
- data/spec/resource/user_resource_spec.rb +0 -14
- data/spec/spec_helper.rb +0 -29
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9bc43409191a911c85de270b70021cbe48d0372844c5a2a4d87409f250d6b646
|
|
4
|
+
data.tar.gz: eee1d04d6026a04da64769c02cbe49ec9f84d186a1fafefc45476925979333c6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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
|
+
```
|
data/keycloak-admin.gemspec
CHANGED
|
@@ -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
|
|
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 =
|
|
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",
|
|
24
|
-
spec.add_development_dependency "byebug",
|
|
25
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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
|
|
53
|
-
|
|
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
|
|
57
|
-
|
|
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)}
|
|
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)}
|
|
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)}
|
|
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)}
|
|
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
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 #{
|
|
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
|
|
@@ -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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|