keycloak-admin 2.0.1 → 2.0.3

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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/MIT-LICENSE +18 -17
  4. data/README.md +102 -5
  5. data/keycloak-admin.gemspec +15 -6
  6. data/lib/keycloak-admin/client/attack_detection_client.rb +2 -1
  7. data/lib/keycloak-admin/client/client.rb +36 -11
  8. data/lib/keycloak-admin/client/client_authz_permission_client.rb +5 -4
  9. data/lib/keycloak-admin/client/client_authz_policy_client.rb +4 -3
  10. data/lib/keycloak-admin/client/client_authz_resource_client.rb +15 -11
  11. data/lib/keycloak-admin/client/client_authz_scope_client.rb +5 -4
  12. data/lib/keycloak-admin/client/client_client.rb +6 -2
  13. data/lib/keycloak-admin/client/client_role_client.rb +2 -1
  14. data/lib/keycloak-admin/client/client_role_mappings_client.rb +3 -2
  15. data/lib/keycloak-admin/client/client_scope_client.rb +2 -1
  16. data/lib/keycloak-admin/client/client_scope_protocol_mapper_client.rb +3 -2
  17. data/lib/keycloak-admin/client/configurable_token_client.rb +2 -1
  18. data/lib/keycloak-admin/client/group_client.rb +15 -9
  19. data/lib/keycloak-admin/client/identity_provider_client.rb +2 -1
  20. data/lib/keycloak-admin/client/organization_client.rb +12 -11
  21. data/lib/keycloak-admin/client/realm_client.rb +3 -2
  22. data/lib/keycloak-admin/client/resource.rb +8 -6
  23. data/lib/keycloak-admin/client/response.rb +1 -0
  24. data/lib/keycloak-admin/client/role_client.rb +3 -6
  25. data/lib/keycloak-admin/client/role_mapper_client.rb +2 -1
  26. data/lib/keycloak-admin/client/token_client.rb +1 -0
  27. data/lib/keycloak-admin/client/user_client.rb +70 -47
  28. data/lib/keycloak-admin/configuration.rb +37 -2
  29. data/lib/keycloak-admin/error.rb +63 -0
  30. data/lib/keycloak-admin/path_encoding.rb +24 -0
  31. data/lib/keycloak-admin/representation/attack_detection_representation.rb +1 -0
  32. data/lib/keycloak-admin/representation/camel_json.rb +7 -3
  33. data/lib/keycloak-admin/representation/client_authz_policy_config_representation.rb +1 -0
  34. data/lib/keycloak-admin/representation/client_authz_policy_representation.rb +1 -0
  35. data/lib/keycloak-admin/representation/client_authz_resource_representation.rb +1 -0
  36. data/lib/keycloak-admin/representation/client_authz_scope_representation.rb +1 -0
  37. data/lib/keycloak-admin/representation/client_representation.rb +4 -5
  38. data/lib/keycloak-admin/representation/client_scope_representation.rb +1 -0
  39. data/lib/keycloak-admin/representation/credential_representation.rb +1 -0
  40. data/lib/keycloak-admin/representation/federated_identity_representation.rb +1 -0
  41. data/lib/keycloak-admin/representation/group_representation.rb +1 -0
  42. data/lib/keycloak-admin/representation/identity_provider_mapper_representation.rb +1 -0
  43. data/lib/keycloak-admin/representation/identity_provider_representation.rb +1 -0
  44. data/lib/keycloak-admin/representation/impersonation_redirection_representation.rb +1 -0
  45. data/lib/keycloak-admin/representation/impersonation_representation.rb +1 -0
  46. data/lib/keycloak-admin/representation/member_representation.rb +1 -0
  47. data/lib/keycloak-admin/representation/organization_domain_representation.rb +1 -0
  48. data/lib/keycloak-admin/representation/organization_representation.rb +1 -1
  49. data/lib/keycloak-admin/representation/protocol_mapper_representation.rb +1 -0
  50. data/lib/keycloak-admin/representation/realm_representation.rb +1 -0
  51. data/lib/keycloak-admin/representation/representation.rb +7 -5
  52. data/lib/keycloak-admin/representation/role_representation.rb +2 -1
  53. data/lib/keycloak-admin/representation/session_representation.rb +2 -1
  54. data/lib/keycloak-admin/representation/token_representation.rb +1 -0
  55. data/lib/keycloak-admin/representation/user_representation.rb +1 -0
  56. data/lib/keycloak-admin/resource/base_role_containing_resource.rb +4 -1
  57. data/lib/keycloak-admin/resource/group_resource.rb +1 -0
  58. data/lib/keycloak-admin/resource/user_resource.rb +1 -0
  59. data/lib/keycloak-admin/version.rb +2 -1
  60. data/lib/keycloak-admin.rb +4 -0
  61. metadata +36 -56
  62. data/.github/workflows/Dockerfile +0 -24
  63. data/.github/workflows/ci.yml +0 -94
  64. data/.github/workflows/release.yml +0 -112
  65. data/.gitignore +0 -10
  66. data/.rspec +0 -2
  67. data/Dockerfile +0 -13
  68. data/Gemfile +0 -3
  69. data/Gemfile.lock +0 -54
  70. data/Rakefile +0 -6
  71. data/bin/console +0 -9
  72. data/spec/client/attack_detection_client_spec.rb +0 -102
  73. data/spec/client/client_authz_permission_client_spec.rb +0 -170
  74. data/spec/client/client_authz_policy_client_spec.rb +0 -170
  75. data/spec/client/client_authz_resource_client_spec.rb +0 -150
  76. data/spec/client/client_authz_scope_client_spec.rb +0 -134
  77. data/spec/client/client_client_spec.rb +0 -133
  78. data/spec/client/client_role_mappings_client_spec.rb +0 -83
  79. data/spec/client/client_scope_client_spec.rb +0 -220
  80. data/spec/client/client_scope_protocol_mapper_client_spec.rb +0 -230
  81. data/spec/client/client_spec.rb +0 -101
  82. data/spec/client/configurable_token_client_spec.rb +0 -34
  83. data/spec/client/group_client_spec.rb +0 -328
  84. data/spec/client/identity_provider_client_spec.rb +0 -92
  85. data/spec/client/organization_client_spec.rb +0 -595
  86. data/spec/client/realm_client_spec.rb +0 -155
  87. data/spec/client/resource_spec.rb +0 -223
  88. data/spec/client/response_spec.rb +0 -68
  89. data/spec/client/role_client_spec.rb +0 -79
  90. data/spec/client/role_mapper_client_spec.rb +0 -113
  91. data/spec/client/token_client_spec.rb +0 -68
  92. data/spec/client/user_client_spec.rb +0 -418
  93. data/spec/configuration_spec.rb +0 -150
  94. data/spec/gemspec_spec.rb +0 -25
  95. data/spec/integration/client_authorization_spec.rb +0 -93
  96. data/spec/representation/attack_detection_representation_spec.rb +0 -16
  97. data/spec/representation/client_authz_permission_representation_spec.rb +0 -52
  98. data/spec/representation/client_authz_policy_representation_spec.rb +0 -47
  99. data/spec/representation/client_authz_resource_representation_spec.rb +0 -33
  100. data/spec/representation/client_authz_scope_representation_spec.rb +0 -19
  101. data/spec/representation/client_representation_spec.rb +0 -119
  102. data/spec/representation/client_scope_representation_spec.rb +0 -125
  103. data/spec/representation/credential_representation_spec.rb +0 -68
  104. data/spec/representation/group_representation_spec.rb +0 -22
  105. data/spec/representation/identity_provider_mapper_representation_spec.rb +0 -24
  106. data/spec/representation/identity_provider_representation_spec.rb +0 -113
  107. data/spec/representation/impersonation_representation_spec.rb +0 -163
  108. data/spec/representation/organization_representation_spec.rb +0 -64
  109. data/spec/representation/protocol_mapper_representation_spec.rb +0 -57
  110. data/spec/representation/role_representation_spec.rb +0 -37
  111. data/spec/representation/session_representation_spec.rb +0 -16
  112. data/spec/representation/user_representation_spec.rb +0 -15
  113. data/spec/resource/group_resource_spec.rb +0 -14
  114. data/spec/resource/user_resource_spec.rb +0 -14
  115. data/spec/spec_helper.rb +0 -36
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module KeycloakAdmin
2
3
  class GroupClient < Client
3
4
  def initialize(configuration, realm_client)
@@ -21,8 +22,16 @@ module KeycloakAdmin
21
22
  JSON.parse(response).map { |group_as_hash| GroupRepresentation.from_hash(group_as_hash) }
22
23
  end
23
24
 
24
- def list
25
- search(nil)
25
+ # Unlike the users endpoint, this one applies no default cap: a bare list already returns
26
+ # every group. Passing neither bound sends no query parameter at all, exactly as before.
27
+ #
28
+ # Keycloak 19 only paginates here when both bounds are present - a lone `max` is ignored and
29
+ # the whole list comes back. Sending `first=0` alongside it makes the call behave the same on
30
+ # every supported version, and is a no-op from Keycloak 23 on.
31
+ def list(first: nil, max: nil)
32
+ first = 0 if first.nil? && !max.nil?
33
+ pagination = {first: first, max: max}.compact
34
+ search(pagination.empty? ? nil : pagination)
26
35
  end
27
36
 
28
37
  def search(query)
@@ -75,11 +84,8 @@ module KeycloakAdmin
75
84
 
76
85
  def members(group_id, first=0, max=100)
77
86
  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
87
+ query = {first: first&.to_i, max: max&.to_i}.compact
88
+ url = "#{url}?#{build_query(query)}" unless query.empty?
83
89
  response = execute_http do
84
90
  resource(url).get(headers)
85
91
  end
@@ -114,7 +120,7 @@ module KeycloakAdmin
114
120
  url = "#{groups_url(group_id)}/role-mappings/realm"
115
121
  execute_http do
116
122
  Resource.execute(
117
- @configuration.faraday_options.merge(
123
+ connection_options.merge(
118
124
  url: url,
119
125
  method: :delete,
120
126
  payload: create_payload([role_representation]),
@@ -128,7 +134,7 @@ module KeycloakAdmin
128
134
 
129
135
  def groups_url(id=nil)
130
136
  if id
131
- "#{@realm_client.realm_admin_url}/groups/#{id}"
137
+ "#{@realm_client.realm_admin_url}/groups/#{encode_segment(id)}"
132
138
  else
133
139
  "#{@realm_client.realm_admin_url}/groups"
134
140
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module KeycloakAdmin
2
3
  class IdentityProviderClient < Client
3
4
  def initialize(configuration, realm_client)
@@ -38,7 +39,7 @@ module KeycloakAdmin
38
39
 
39
40
  def identity_providers_url(internal_id_or_alias=nil)
40
41
  if internal_id_or_alias
41
- "#{@realm_client.realm_admin_url}/identity-provider/instances/#{internal_id_or_alias}"
42
+ "#{@realm_client.realm_admin_url}/identity-provider/instances/#{encode_segment(internal_id_or_alias)}"
42
43
  else
43
44
  "#{@realm_client.realm_admin_url}/identity-provider/instances"
44
45
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module KeycloakAdmin
2
3
  class OrganizationClient < Client
3
4
  def initialize(configuration, realm_client)
@@ -69,7 +70,7 @@ module KeycloakAdmin
69
70
  def get_identity_provider(organization_id, identity_provider_alias)
70
71
  raise ArgumentError.new("identity_provider_alias must be defined") if identity_provider_alias.nil?
71
72
  response = execute_http do
72
- resource("#{identity_providers_url(organization_id)}/#{identity_provider_alias}").get(headers)
73
+ resource(identity_provider_url(organization_id, identity_provider_alias)).get(headers)
73
74
  end
74
75
  IdentityProviderRepresentation.from_hash(JSON.parse(response))
75
76
  end
@@ -157,7 +158,7 @@ module KeycloakAdmin
157
158
 
158
159
  def organization_url(organization_id)
159
160
  raise ArgumentError.new("organization_id must be defined") if organization_id.nil?
160
- "#{organizations_url}/#{organization_id}"
161
+ "#{organizations_url}/#{encode_segment(organization_id)}"
161
162
  end
162
163
 
163
164
  def identity_providers_url(organization_id)
@@ -166,28 +167,28 @@ module KeycloakAdmin
166
167
 
167
168
  def identity_provider_url(organization_id, identity_provider_alias)
168
169
  raise ArgumentError.new("identity_provider_alias must be defined") if identity_provider_alias.nil?
169
- "#{identity_providers_url(organization_id)}/#{identity_provider_alias}"
170
+ "#{identity_providers_url(organization_id)}/#{encode_segment(identity_provider_alias)}"
170
171
  end
171
172
 
172
173
  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("&")
174
+ query_parameters = build_query({exact: exact, q: query, search: search}.compact)
174
175
  "#{organizations_url}/count?#{query_parameters}"
175
176
  end
176
177
 
177
178
  def organizations_url_with_parameters(brief_representation, exact, first, max, query, search)
178
- query_parameters = {
179
+ query_parameters = build_query({
179
180
  briefRepresentation: brief_representation,
180
181
  exact: exact,
181
182
  first: first,
182
183
  max: max,
183
184
  q: query,
184
185
  search: search
185
- }.compact.to_a.map { |e| "#{e[0]}=#{e[1]}" }.join("&")
186
+ }.compact)
186
187
  "#{organizations_url}?#{query_parameters}"
187
188
  end
188
189
 
189
190
  def associated_with_member_url(member_id, brief_representation=true)
190
- "#{organizations_url}/members/#{member_id}/organizations?briefRepresentation=#{brief_representation}"
191
+ "#{organizations_url}/members/#{encode_segment(member_id)}/organizations?#{build_query(briefRepresentation: brief_representation)}"
191
192
  end
192
193
 
193
194
  def members_count_url(organization_id)
@@ -196,7 +197,7 @@ module KeycloakAdmin
196
197
 
197
198
  def member_url(organization_id, member_id)
198
199
  raise ArgumentError.new("member_id must be defined") if member_id.nil?
199
- "#{organization_url(organization_id)}/members/#{member_id}"
200
+ "#{organization_url(organization_id)}/members/#{encode_segment(member_id)}"
200
201
  end
201
202
 
202
203
  def invite_existing_user_url(organization_id)
@@ -212,19 +213,19 @@ module KeycloakAdmin
212
213
  end
213
214
 
214
215
  def members_url_with_query_parameters(organization_id, exact, first, max, membership_type, search)
215
- query_parameters = {
216
+ query_parameters = build_query({
216
217
  exact: exact,
217
218
  first: first,
218
219
  max: max,
219
220
  membershipType: membership_type,
220
221
  search: search
221
- }.compact.to_a.map { |e| "#{e[0]}=#{e[1]}" }.join("&")
222
+ }.compact)
222
223
  "#{organization_url(organization_id)}/members?#{query_parameters}"
223
224
  end
224
225
 
225
226
  def build(name, alias_name, enabled, description, redirect_url=nil, domains=[], attributes={})
226
227
  unless domains.is_a?(Array)
227
- raise ArgumentError.new("domains must be an Array, got #{new_domains.class}")
228
+ raise ArgumentError.new("domains must be an Array, got #{domains.class}")
228
229
  end
229
230
 
230
231
  unless domains.all? { |domain| domain.is_a?(KeycloakAdmin::OrganizationDomainRepresentation) }
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module KeycloakAdmin
2
3
  class RealmClient < Client
3
4
  def initialize(configuration, realm_name=nil)
@@ -37,7 +38,7 @@ module KeycloakAdmin
37
38
 
38
39
  def realm_url
39
40
  if @realm_name
40
- "#{server_url}/realms/#{@realm_name}"
41
+ "#{server_url}/realms/#{encode_segment(@realm_name)}"
41
42
  else
42
43
  "#{server_url}/realms"
43
44
  end
@@ -45,7 +46,7 @@ module KeycloakAdmin
45
46
 
46
47
  def realm_admin_url
47
48
  if @realm_name
48
- "#{server_url}/admin/realms/#{@realm_name}"
49
+ "#{server_url}/admin/realms/#{encode_segment(@realm_name)}"
49
50
  else
50
51
  "#{server_url}/admin/realms"
51
52
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "faraday"
2
3
  require "uri"
3
4
 
@@ -18,13 +19,11 @@ module KeycloakAdmin
18
19
  new(url, options, logger).send(:request, method, payload, headers)
19
20
  end
20
21
 
21
- def self.put(url, payload, headers = {}, logger = nil)
22
- new(url, {}, logger).put(payload, headers)
23
- end
24
-
25
22
  def initialize(url, options = {}, logger = nil)
23
+ options = (options || {}).dup
24
+ @adapter = options.delete(:adapter)
26
25
  @url = url
27
- @options = options || {}
26
+ @options = options
28
27
  @logger = logger
29
28
  end
30
29
 
@@ -75,7 +74,10 @@ module KeycloakAdmin
75
74
  # headers by default, which would print the bearer token on every call; headers: false
76
75
  # keeps this to method/url/status only. Bodies are already off by Faraday's own default.
77
76
  f.response :logger, @logger, headers: false if @logger
78
- f.adapter Faraday.default_adapter
77
+ # Faraday's default net_http adapter wraps every request in http.start, which reopens
78
+ # the connection each time; config.faraday_adapter lets a caller swap in a pooling one.
79
+ # Splatted so an adapter needing arguments can be given as [:name, options].
80
+ f.adapter(*Array(@adapter || Faraday.default_adapter))
79
81
  end
80
82
  end
81
83
 
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module KeycloakAdmin
2
3
  # Wraps a Faraday::Response the way RestClient::Response used to: as the body string
3
4
  # itself (RestClient::Response < String), so existing call sites that do JSON.parse(response)
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module KeycloakAdmin
2
3
  class RoleClient < Client
3
4
  def initialize(configuration, realm_client)
@@ -15,8 +16,6 @@ module KeycloakAdmin
15
16
 
16
17
  # Returns the role representation for the specified role name
17
18
  def get(name)
18
- # allows special characters in the name like space
19
- name = URI.encode_uri_component(name)
20
19
  response = execute_http do
21
20
  resource(role_name_url(name)).get(headers)
22
21
  end
@@ -25,8 +24,6 @@ module KeycloakAdmin
25
24
 
26
25
  # Lists all groups that have the specified role name assigned
27
26
  def list_groups(name)
28
- # allows special characters in the name like space
29
- name = URI.encode_uri_component(name)
30
27
  response = execute_http do
31
28
  resource("#{role_name_url(name)}/groups").get(headers)
32
29
  end
@@ -49,11 +46,11 @@ module KeycloakAdmin
49
46
  end
50
47
 
51
48
  def role_id_url(id)
52
- "#{@realm_client.realm_admin_url}/roles-by-id/#{id}"
49
+ "#{@realm_client.realm_admin_url}/roles-by-id/#{encode_segment(id)}"
53
50
  end
54
51
 
55
52
  def role_name_url(name)
56
- "#{@realm_client.realm_admin_url}/roles/#{name}"
53
+ "#{@realm_client.realm_admin_url}/roles/#{encode_segment(name)}"
57
54
  end
58
55
  end
59
56
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module KeycloakAdmin
2
3
  class RoleMapperClient < Client
3
4
  def initialize(configuration, user_resource)
@@ -23,7 +24,7 @@ module KeycloakAdmin
23
24
  def remove_realm_level(role_representation_list)
24
25
  execute_http do
25
26
  Resource.execute(
26
- @configuration.faraday_options.merge(
27
+ connection_options.merge(
27
28
  method: :delete,
28
29
  url: realm_level_url,
29
30
  payload: create_payload(role_representation_list),
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module KeycloakAdmin
2
3
  class TokenClient < Client
3
4
  def initialize(configuration, realm_client)
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module KeycloakAdmin
2
3
  class UserClient < Client
3
4
  def initialize(configuration, realm_client)
@@ -6,55 +7,61 @@ module KeycloakAdmin
6
7
  @realm_client = realm_client
7
8
  end
8
9
 
10
+ # Reads the id of the new user off the Location header rather than searching for it
11
+ # afterwards: Keycloak's `search` parameter matches a substring of the username, email,
12
+ # first name or last name, so an existing user merely containing this email was returned
13
+ # instead of the one just created.
9
14
  def create!(username, email, password, email_verified, locale, attributes={})
10
- user = save(build(username, email, password, email_verified, locale, attributes))
11
- search(user.email)&.first
15
+ response = post_user(build(username, email, password, email_verified, locale, attributes))
16
+ get(created_id(response))
12
17
  end
13
18
 
14
19
  def save(user_representation)
15
- execute_http do
16
- resource(users_url).post(
17
- create_payload(user_representation), headers
18
- )
19
- end
20
+ post_user(user_representation)
20
21
  user_representation
21
22
  end
22
23
 
23
24
  # pay attention that, since Keycloak 24.0.4, partial updates of attributes are not authorized anymore
24
25
  def update(user_id, user_representation_body)
25
26
  raise ArgumentError.new("user_id must be defined") if user_id.nil?
26
- Resource.execute(
27
- @configuration.faraday_options.merge(
28
- method: :put,
29
- url: users_url(user_id),
30
- payload: create_payload(user_representation_body),
31
- headers: headers,
32
- logger: @configuration.logger
27
+ execute_http do
28
+ Resource.execute(
29
+ connection_options.merge(
30
+ method: :put,
31
+ url: users_url(user_id),
32
+ payload: create_payload(user_representation_body),
33
+ headers: headers,
34
+ logger: @configuration.logger
35
+ )
33
36
  )
34
- )
37
+ end
35
38
  end
36
39
 
37
40
  def add_group(user_id, group_id)
38
- Resource.execute(
39
- @configuration.faraday_options.merge(
40
- method: :put,
41
- url: "#{users_url(user_id)}/groups/#{group_id}",
42
- payload: create_payload({}),
43
- headers: headers,
44
- logger: @configuration.logger
41
+ execute_http do
42
+ Resource.execute(
43
+ connection_options.merge(
44
+ method: :put,
45
+ url: "#{users_url(user_id)}/groups/#{encode_segment(group_id)}",
46
+ payload: create_payload({}),
47
+ headers: headers,
48
+ logger: @configuration.logger
49
+ )
45
50
  )
46
- )
51
+ end
47
52
  end
48
53
 
49
54
  def remove_group(user_id, group_id)
50
- Resource.execute(
51
- @configuration.faraday_options.merge(
52
- method: :delete,
53
- url: "#{users_url(user_id)}/groups/#{group_id}",
54
- headers: headers,
55
- logger: @configuration.logger
55
+ execute_http do
56
+ Resource.execute(
57
+ connection_options.merge(
58
+ method: :delete,
59
+ url: "#{users_url(user_id)}/groups/#{encode_segment(group_id)}",
60
+ headers: headers,
61
+ logger: @configuration.logger
62
+ )
56
63
  )
57
- )
64
+ end
58
65
  end
59
66
 
60
67
  def add_client_roles_on_user(user_id, client_id, role_representations)
@@ -94,8 +101,12 @@ module KeycloakAdmin
94
101
  JSON.parse(response).map { |user_as_hash| UserRepresentation.from_hash(user_as_hash) }
95
102
  end
96
103
 
97
- def list
98
- search(nil)
104
+ # Keycloak answers this endpoint with at most 100 users unless `max` says otherwise, so a
105
+ # bare `list` silently truncates a larger realm. Passing neither bound sends no query
106
+ # parameter at all, exactly as before.
107
+ def list(first: nil, max: nil)
108
+ pagination = {first: first, max: max}.compact
109
+ search(pagination.empty? ? nil : pagination)
99
110
  end
100
111
 
101
112
  def delete(user_id)
@@ -112,13 +123,13 @@ module KeycloakAdmin
112
123
  JSON.parse(response).map { |group_as_hash| GroupRepresentation.from_hash(group_as_hash) }
113
124
  end
114
125
 
115
- def update_password(user_id, new_password)
126
+ def update_password(user_id, new_password, temporary: false)
116
127
  execute_http do
117
128
  Resource.execute(
118
- @configuration.faraday_options.merge(
129
+ connection_options.merge(
119
130
  method: :put,
120
131
  url: reset_password_url(user_id),
121
- payload: { type: "password", value: new_password, temporary: false }.to_json,
132
+ payload: { type: "password", value: new_password, temporary: temporary }.to_json,
122
133
  headers: headers,
123
134
  logger: @configuration.logger
124
135
  )
@@ -141,19 +152,23 @@ module KeycloakAdmin
141
152
  def execute_actions_email(user_id, actions=[], lifespan=nil, redirect_uri=nil, client_id=nil)
142
153
  raise ArgumentError.new("client_id must be defined") if client_id.nil? && !redirect_uri.nil?
143
154
  execute_http do
144
- lifespan_param = lifespan.nil? ? "" : "&lifespan=#{lifespan.seconds}"
145
- redirect_uri_param = redirect_uri.nil? ? "" : "&redirect_uri=#{redirect_uri}"
146
- client_id_param = client_id.nil? ? "" : "client_id=#{client_id}"
147
- Resource.put("#{execute_actions_email_url(user_id)}?#{client_id_param}#{redirect_uri_param}#{lifespan_param}", create_payload(actions), headers, @configuration.logger)
155
+ query = {}
156
+ query[:client_id] = client_id unless client_id.nil?
157
+ query[:redirect_uri] = redirect_uri unless redirect_uri.nil?
158
+ query[:lifespan] = lifespan.to_i unless lifespan.nil?
159
+
160
+ url = execute_actions_email_url(user_id)
161
+ url = "#{url}?#{build_query(query)}" unless query.empty?
162
+ resource(url).put(create_payload(actions), headers)
148
163
  end
149
164
  user_id
150
165
  end
151
166
 
152
167
  def impersonate(user_id)
153
- impersonation = get_redirect_impersonation(user_id)
154
168
  response = execute_http do
169
+ impersonation = get_redirect_impersonation(user_id)
155
170
  Resource.execute(
156
- @configuration.faraday_options.merge(
171
+ connection_options.merge(
157
172
  method: :post,
158
173
  url: impersonation.impersonation_url,
159
174
  payload: impersonation.body.to_json,
@@ -179,7 +194,7 @@ module KeycloakAdmin
179
194
 
180
195
  execute_http do
181
196
  Resource.execute(
182
- @configuration.faraday_options.merge(
197
+ connection_options.merge(
183
198
  method: :post,
184
199
  url: logout_url(user_id),
185
200
  headers: headers,
@@ -202,7 +217,7 @@ module KeycloakAdmin
202
217
 
203
218
  execute_http do
204
219
  Resource.execute(
205
- @configuration.faraday_options.merge(
220
+ connection_options.merge(
206
221
  method: :post,
207
222
  url: federated_identity_url(user_id, idp_id),
208
223
  payload: fed_id_rep.to_json,
@@ -221,14 +236,14 @@ module KeycloakAdmin
221
236
 
222
237
  def users_url(id=nil)
223
238
  if id
224
- "#{@realm_client.realm_admin_url}/users/#{id}"
239
+ "#{@realm_client.realm_admin_url}/users/#{encode_segment(id)}"
225
240
  else
226
241
  "#{@realm_client.realm_admin_url}/users"
227
242
  end
228
243
  end
229
244
 
230
245
  def user_client_role_mappings_url(user_id, client_id)
231
- "#{users_url(user_id)}/role-mappings/clients/#{client_id}"
246
+ "#{users_url(user_id)}/role-mappings/clients/#{encode_segment(client_id)}"
232
247
  end
233
248
 
234
249
  def reset_password_url(user_id)
@@ -259,7 +274,7 @@ module KeycloakAdmin
259
274
  def federated_identity_url(user_id, identity_provider)
260
275
  raise ArgumentError.new("user_id must be defined") if user_id.nil?
261
276
  raise ArgumentError.new("identity_provider must be defined") if identity_provider.nil?
262
- "#{users_url(user_id)}/federated-identity/#{identity_provider}"
277
+ "#{users_url(user_id)}/federated-identity/#{encode_segment(identity_provider)}"
263
278
  end
264
279
 
265
280
  def logout_url(user_id)
@@ -270,13 +285,21 @@ module KeycloakAdmin
270
285
 
271
286
  private
272
287
 
288
+ def post_user(user_representation)
289
+ execute_http do
290
+ resource(users_url).post(
291
+ create_payload(user_representation), headers
292
+ )
293
+ end
294
+ end
295
+
273
296
  def build(username, email, password, email_verified, locale, attributes={})
274
297
  user = UserRepresentation.new
275
298
  user.email = email
276
299
  user.username = username
277
300
  user.email_verified = email_verified
278
301
  user.enabled = true
279
- user.attributes = attributes || {}
302
+ user.attributes = (attributes || {}).dup
280
303
  user.attributes[:locale] = locale if locale
281
304
  user.add_credential(CredentialRepresentation.from_password(password)) if !password.nil?
282
305
  user
@@ -1,13 +1,48 @@
1
+ # frozen_string_literal: true
1
2
  require "base64"
2
3
 
3
4
  module KeycloakAdmin
4
5
  class Configuration
5
- attr_accessor :server_url, :server_domain, :client_id, :client_secret, :client_realm_name, :use_service_account, :username, :password, :logger, :faraday_options
6
+ attr_accessor :server_url, :server_domain, :client_id, :client_secret, :client_realm_name, :use_service_account, :username, :password, :logger, :faraday_options, :faraday_adapter
6
7
 
7
8
  # Treat a cached token as expired this many seconds before its real expiry, so it
8
9
  # cannot go stale between the check below and the request that relies on it.
9
10
  TOKEN_EXPIRY_SAFETY_MARGIN_SECONDS = 10
10
11
 
12
+ FILTERED_ATTRIBUTES = %i[@client_secret @password @cached_token].freeze
13
+ FILTERED_PLACEHOLDER = "[FILTERED]".freeze
14
+
15
+ def initialize
16
+ @token_mutex = Mutex.new
17
+ end
18
+
19
+ def inspect
20
+ rendered = instance_variables.map do |name|
21
+ value = instance_variable_get(name)
22
+ shown = if FILTERED_ATTRIBUTES.include?(name) && !value.nil?
23
+ FILTERED_PLACEHOLDER
24
+ else
25
+ value.inspect
26
+ end
27
+ "#{name}=#{shown}"
28
+ end
29
+ "#<#{self.class.name} #{rendered.join(", ")}>"
30
+ end
31
+
32
+ # Returns the cached token, fetching one through the block if there is none.
33
+ # The block runs at most once across threads.
34
+ def fetch_token_once
35
+ token = cached_token
36
+ if token.nil?
37
+ @token_mutex.synchronize do
38
+ token = cached_token
39
+ token.nil? ? cache_token(yield) : token
40
+ end
41
+ else
42
+ token
43
+ end
44
+ end
45
+
11
46
  # Returns the cached TokenRepresentation, or nil if there is none or it is (about to be) expired.
12
47
  def cached_token
13
48
  return nil if @token_expires_at.nil? || Time.now >= @token_expires_at
@@ -22,8 +57,8 @@ module KeycloakAdmin
22
57
  end
23
58
 
24
59
  def clear_cached_token!
25
- @cached_token = nil
26
60
  @token_expires_at = nil
61
+ @cached_token = nil
27
62
  end
28
63
 
29
64
  def body_for_token_retrieval
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+ module KeycloakAdmin
3
+ class Error < StandardError; end
4
+
5
+ # Raised when Keycloak answers with a non-2xx status.
6
+ #
7
+ # Callers can rescue the precise failure (KeycloakAdmin::NotFoundError), a whole family
8
+ # (KeycloakAdmin::ClientError for any 4xx) or every HTTP failure (KeycloakAdmin::ApiError),
9
+ # and read #status/#body off the exception rather than parsing the message.
10
+ class ApiError < Error
11
+ attr_reader :status, :body, :headers
12
+
13
+ def initialize(status, body, headers = {})
14
+ @status = status
15
+ @body = body
16
+ @headers = headers || {}
17
+ super("Keycloak: The request failed with response code #{status} and message: #{body}")
18
+ end
19
+
20
+ def self.from_response(response)
21
+ response ||= {}
22
+ status = response[:status]
23
+ error_class_for(status).new(status, response[:body], response[:headers])
24
+ end
25
+
26
+ def self.error_class_for(status)
27
+ case status
28
+ when 400 then BadRequestError
29
+ when 401 then UnauthorizedError
30
+ when 403 then ForbiddenError
31
+ when 404 then NotFoundError
32
+ when 409 then ConflictError
33
+ when 400..499 then ClientError
34
+ when 500..599 then ServerError
35
+ else ApiError
36
+ end
37
+ end
38
+ end
39
+
40
+ # Any 4xx: the request itself was rejected.
41
+ class ClientError < ApiError; end
42
+
43
+ class BadRequestError < ClientError; end
44
+ class UnauthorizedError < ClientError; end
45
+ class ForbiddenError < ClientError; end
46
+ class NotFoundError < ClientError; end
47
+ class ConflictError < ClientError; end
48
+
49
+ # Any 5xx: Keycloak accepted the request but failed to serve it.
50
+ class ServerError < ApiError; end
51
+
52
+ # Raised when a create call succeeds at the HTTP level but answers something other than
53
+ # 201 Created, leaving no Location header to read the new resource's id from.
54
+ class UnexpectedResponseError < Error
55
+ attr_reader :status, :reason_phrase
56
+
57
+ def initialize(status, reason_phrase)
58
+ @status = status
59
+ @reason_phrase = reason_phrase
60
+ super("Create method returned status #{reason_phrase} (Code: #{status}); expected status: Created (201)")
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+ require "uri"
3
+
4
+ module KeycloakAdmin
5
+ # Percent-encoding for the identifiers interpolated into a URL path.
6
+ #
7
+ # Included by Client and BaseRoleContainingResource, which have no common ancestor but
8
+ # both build URLs out of caller-supplied values.
9
+ module PathEncoding
10
+ private
11
+
12
+ # Encodes '/' as %2F too, so an identifier can never open a path segment of its own.
13
+ # Values that are already URL-safe - the UUIDs Keycloak hands out - come out unchanged,
14
+ # so this is a no-op for the usual case.
15
+ #
16
+ # Without it, any identifier holding a character that is illegal in a URI (a space, most
17
+ # obviously) made Ruby's URI parser raise URI::InvalidURIError before the request was
18
+ # ever sent. That is reachable with values users choose themselves, identity provider
19
+ # aliases being the clearest case.
20
+ def encode_segment(value)
21
+ URI.encode_uri_component(value.to_s)
22
+ end
23
+ end
24
+ end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module KeycloakAdmin
2
3
  class AttackDetectionRepresentation < Representation
3
4
  attr_accessor :num_failures,