Skip to content

Commit cf4f1bf

Browse files
committed
[1.1.5] Added the ability to list credentials for a given user
1 parent f2c4cb2 commit cf4f1bf

File tree

8 files changed

+179
-7
lines changed

8 files changed

+179
-7
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.1.5] - 2026-01-05
9+
10+
* [Feature] Added the ability to list credentials for a given user.
11+
* [Fix] Implemented safe parsing for nested JSON elements within `CredentialRepresentation` (handling both `credentialData` and `secretData` fields). Please refer to [the official documentation](https://www.keycloak.org/docs-api/latest/rest-api/index.html#CredentialRepresentation).
12+
* [Breaking] Renamed `CredentialRepresentation` attribute `created_date` $\rightarrow$ `createdDate` to align with the Keycloak Admin API.
13+
814
## [1.1.4] - 2025-11-08
915

1016
* Add remove_realm_level_role_name! action on a GroupClient (thanks to @mkrawc)

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
keycloak-admin (1.1.4)
4+
keycloak-admin (1.1.5)
55
http-cookie (~> 1.0, >= 1.0.3)
66
rest-client (~> 2.0)
77

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ This gem *does not* require Rails.
1212
For example, using `bundle`, add this line to your Gemfile.
1313

1414
```ruby
15-
gem "keycloak-admin", "1.1.4"
15+
gem "keycloak-admin", "1.1.5"
1616
```
1717

1818
## Login
@@ -112,6 +112,7 @@ All options have a default value. However, all of them can be changed in your in
112112
* Get an access token
113113
* Create/update/get/delete a user
114114
* Get list of users, search for user(s)
115+
* List credentials of a user
115116
* Reset credentials
116117
* Impersonate a user
117118
* Exchange a configurable token
@@ -226,6 +227,13 @@ new_password = "coco"
226227
KeycloakAdmin.realm("a_realm").users.update_password(user_id, new_password)
227228
```
228229

230+
### List credentials
231+
232+
```ruby
233+
user_id = "95985b21-d884-4bbd-b852-cb8cd365afc2"
234+
KeycloakAdmin.realm("a_realm").users.credentials(user_id)
235+
```
236+
229237
### Impersonate a password directly
230238

231239
Returns an instance of `KeycloakAdmin::ImpersonationRepresentation`.

lib/keycloak-admin/client/user_client.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,13 @@ def update_password(user_id, new_password)
123123
user_id
124124
end
125125

126+
def credentials(user_id)
127+
response = execute_http do
128+
RestClient::Resource.new(credentials_url(user_id), @configuration.rest_client_options).get(headers)
129+
end
130+
JSON.parse(response).map { |group_as_hash| CredentialRepresentation.from_hash(group_as_hash) }
131+
end
132+
126133
def forgot_password(user_id, lifespan=nil)
127134
execute_actions_email(user_id, ["UPDATE_PASSWORD"], lifespan)
128135
end
@@ -232,6 +239,11 @@ def groups_url(user_id)
232239
"#{users_url(user_id)}/groups"
233240
end
234241

242+
def credentials_url(user_id)
243+
raise ArgumentError.new("user_id must be defined") if user_id.nil?
244+
"#{users_url(user_id)}/credentials"
245+
end
246+
235247
def impersonation_url(user_id)
236248
raise ArgumentError.new("user_id must be defined") if user_id.nil?
237249
"#{users_url(user_id)}/impersonation"

lib/keycloak-admin/representation/credential_representation.rb

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
module KeycloakAdmin
22
class CredentialRepresentation < Representation
3-
attr_accessor :type,
3+
attr_accessor :id,
4+
:type,
5+
:userLabel,
46
:device,
57
:value,
68
:hashedSaltedValue,
@@ -10,7 +12,9 @@ class CredentialRepresentation < Representation
1012
:algorithm,
1113
:digits,
1214
:period,
13-
:created_date,
15+
:createdDate,
16+
:credentialData,
17+
:secretData,
1418
:config,
1519
:temporary
1620

@@ -30,10 +34,39 @@ def self.from_json(json)
3034
def self.from_hash(hash)
3135
credential = new
3236
hash.each do |key, value|
33-
property = "@#{key}".to_sym
34-
credential.instance_variable_set(property, value)
37+
if credential.respond_to?("#{key}=")
38+
credential.public_send("#{key}=", value)
39+
end
3540
end
41+
42+
nested_attributes = safely_parse_nested_json(hash["credentialData"]).merge(safely_parse_nested_json(hash["secretData"]))
43+
44+
nested_attributes.each do |key, value|
45+
if credential.respond_to?("#{key}=")
46+
current_value = credential.public_send(key)
47+
if current_value.nil?
48+
credential.public_send("#{key}=", value)
49+
end
50+
end
51+
end
52+
3653
credential
3754
end
55+
56+
class << self
57+
private
58+
59+
def safely_parse_nested_json(json_string)
60+
if json_string.nil? || json_string.strip.empty?
61+
{}
62+
else
63+
begin
64+
JSON.parse(json_string)
65+
rescue JSON::ParserError
66+
{}
67+
end
68+
end
69+
end
70+
end
3871
end
3972
end

lib/keycloak-admin/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
module KeycloakAdmin
2-
VERSION = "1.1.4"
2+
VERSION = "1.1.5"
33
end

spec/client/user_client_spec.rb

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,4 +370,49 @@
370370
end
371371
end
372372
end
373+
374+
describe '#credentials' do
375+
let(:realm_name) { "valid-realm" }
376+
377+
before(:each) do
378+
@user_client = KeycloakAdmin.realm(realm_name).users
379+
stub_token_client
380+
json_payload = <<-'payload'
381+
[
382+
{
383+
"id": "2ff4b4d0-fd72-4c6e-9684-02ab337687c2",
384+
"type": "password",
385+
"userLabel": "My password",
386+
"createdDate": 1767604673211,
387+
"credentialData": "{\"hashIterations\":5,\"algorithm\":\"argon2\",\"additionalParameters\":{\"hashLength\":[\"32\"],\"memory\":[\"7168\"],\"type\":[\"id\"],\"version\":[\"1.3\"],\"parallelism\":[\"1\"]}}"
388+
},
389+
{
390+
"id": "34389672-9356-4154-9ed6-6c212b869010",
391+
"type": "otp",
392+
"userLabel": "Smartphone",
393+
"createdDate": 1767605202060,
394+
"credentialData": "{\"subType\":\"totp\",\"digits\":6,\"counter\":0,\"period\":30,\"algorithm\":\"HmacSHA1\"}"
395+
}
396+
]
397+
payload
398+
allow_any_instance_of(RestClient::Resource).to receive(:get).and_return json_payload
399+
end
400+
401+
context 'when user_id is defined' do
402+
let(:user_id) { '95985b21-d884-4bbd-b852-cb8cd365afc2' }
403+
it 'returns list of credentials' do
404+
response = @user_client.credentials(user_id)
405+
expect(response.size).to eq 2
406+
expect(response[0].id).to eq "2ff4b4d0-fd72-4c6e-9684-02ab337687c2"
407+
expect(response[1].id).to eq "34389672-9356-4154-9ed6-6c212b869010"
408+
end
409+
end
410+
411+
context 'when user_id is not defined' do
412+
let(:user_id) { nil }
413+
it 'raise argument error' do
414+
expect { @user_client.credentials(user_id) }.to raise_error(ArgumentError)
415+
end
416+
end
417+
end
373418
end
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
2+
RSpec.describe KeycloakAdmin::CredentialRepresentation do
3+
describe ".from_json" do
4+
it "parses a password" do
5+
json_payload = <<-'payload'
6+
{
7+
"id": "2ff4b4d0-fd72-4c6e-9684-02ab337687c2",
8+
"type": "password",
9+
"userLabel": "My password",
10+
"createdDate": 1767604673211,
11+
"credentialData": "{\"hashIterations\":5,\"algorithm\":\"argon2\",\"additionalParameters\":{\"hashLength\":[\"32\"],\"memory\":[\"7168\"],\"type\":[\"id\"],\"version\":[\"1.3\"],\"parallelism\":[\"1\"]}}"
12+
}
13+
payload
14+
15+
credential = described_class.from_json(json_payload)
16+
expect(credential).to be
17+
expect(credential).to be_a described_class
18+
expect(credential.id).to eq "2ff4b4d0-fd72-4c6e-9684-02ab337687c2"
19+
expect(credential.type).to eq "password"
20+
expect(credential.createdDate).to eq 1767604673211
21+
expect(credential.credentialData).to eq "{\"hashIterations\":5,\"algorithm\":\"argon2\",\"additionalParameters\":{\"hashLength\":[\"32\"],\"memory\":[\"7168\"],\"type\":[\"id\"],\"version\":[\"1.3\"],\"parallelism\":[\"1\"]}}"
22+
expect(credential.userLabel).to eq "My password"
23+
expect(credential.device).to be_nil
24+
expect(credential.value).to be_nil
25+
expect(credential.hashedSaltedValue).to be_nil
26+
expect(credential.salt).to be_nil
27+
expect(credential.hashIterations).to eq 5
28+
expect(credential.counter).to be_nil
29+
expect(credential.algorithm).to eq "argon2"
30+
expect(credential.digits).to be_nil
31+
expect(credential.period).to be_nil
32+
expect(credential.config).to be_nil
33+
expect(credential.temporary).to be_nil
34+
end
35+
36+
it "parses an otp" do
37+
json_payload = <<-'payload'
38+
{
39+
"id": "34389672-9356-4154-9ed6-6c212b869010",
40+
"type": "otp",
41+
"userLabel": "Smartphone",
42+
"createdDate": 1767605202060,
43+
"credentialData": "{\"subType\":\"totp\",\"digits\":6,\"counter\":0,\"period\":30,\"algorithm\":\"HmacSHA1\"}"
44+
}
45+
payload
46+
47+
credential = described_class.from_json(json_payload)
48+
expect(credential).to be
49+
expect(credential).to be_a described_class
50+
expect(credential.id).to eq "34389672-9356-4154-9ed6-6c212b869010"
51+
expect(credential.type).to eq "otp"
52+
expect(credential.createdDate).to eq 1767605202060
53+
expect(credential.credentialData).to eq "{\"subType\":\"totp\",\"digits\":6,\"counter\":0,\"period\":30,\"algorithm\":\"HmacSHA1\"}"
54+
expect(credential.userLabel).to eq "Smartphone"
55+
expect(credential.device).to be_nil
56+
expect(credential.value).to be_nil
57+
expect(credential.hashedSaltedValue).to be_nil
58+
expect(credential.salt).to be_nil
59+
expect(credential.hashIterations).to be_nil
60+
expect(credential.counter).to eq 0
61+
expect(credential.algorithm).to eq "HmacSHA1"
62+
expect(credential.digits).to eq 6
63+
expect(credential.period).to eq 30
64+
expect(credential.config).to be_nil
65+
expect(credential.temporary).to be_nil
66+
end
67+
end
68+
end

0 commit comments

Comments
 (0)