From fb634a6b8f70c50da05d026fc94da68553c398e7 Mon Sep 17 00:00:00 2001 From: chellaVignesh Date: Wed, 29 Apr 2026 17:53:21 +0200 Subject: [PATCH 1/4] Keycloak api client to get list of Users and Groups --- pkg/auth/auth_client.go | 10 ++ pkg/keycloakApi/keycloak_api_client.go | 107 ++++++++++++++++++++ pkg/keycloakApi/keycloak_api_client_test.go | 97 ++++++++++++++++++ 3 files changed, 214 insertions(+) create mode 100644 pkg/keycloakApi/keycloak_api_client.go create mode 100644 pkg/keycloakApi/keycloak_api_client_test.go diff --git a/pkg/auth/auth_client.go b/pkg/auth/auth_client.go index addefc8..ec71381 100644 --- a/pkg/auth/auth_client.go +++ b/pkg/auth/auth_client.go @@ -174,3 +174,13 @@ func (c *KeycloakClient) requestToken(ctx context.Context) (*authResponse, error return &authResp, nil } + +// HTTPClient returns the underlying http.Client used by the KeycloakClient. +func (c *KeycloakClient) HTTPClient() *http.Client { + return c.httpClient +} + +// Config returns the KeycloakConfig used by the KeycloakClient. +func (c *KeycloakClient) Config() KeycloakConfig { + return c.cfg +} diff --git a/pkg/keycloakApi/keycloak_api_client.go b/pkg/keycloakApi/keycloak_api_client.go new file mode 100644 index 0000000..9d745f0 --- /dev/null +++ b/pkg/keycloakApi/keycloak_api_client.go @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: 2025 Greenbone AG +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package keycloakApi + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/greenbone/opensight-golang-libraries/pkg/auth" +) + +// Group represents a Keycloak group. +type Group struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// User represents a Keycloak user. +type User struct { + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` +} + +// KeycloakAPIClient provides methods to interact with Keycloak's REST API. +type KeycloakAPIClient struct { + AuthClient *auth.KeycloakClient +} + +// NewKeycloakAPIClient creates a new KeycloakAPIClient. +func NewKeycloakAPIClient(authClient *auth.KeycloakClient) *KeycloakAPIClient { + return &KeycloakAPIClient{AuthClient: authClient} +} + +// ListGroups retrieves all groups from Keycloak. +func (kc *KeycloakAPIClient) ListGroups(ctx context.Context) ([]Group, error) { + token, err := kc.AuthClient.GetToken(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get token: %w", err) + } + + config := kc.AuthClient.Config() + url := fmt.Sprintf("%s/admin/realms/%s/groups", config.AuthURL, config.Realm) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + resp, err := kc.AuthClient.HTTPClient().Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("failed to list groups: %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + + var groups []Group + if err := json.NewDecoder(resp.Body).Decode(&groups); err != nil { + return nil, fmt.Errorf("failed to decode groups response: %w", err) + } + return groups, nil +} + +// ListUsers retrieves all users from Keycloak. +func (kc *KeycloakAPIClient) ListUsers(ctx context.Context) ([]User, error) { + token, err := kc.AuthClient.GetToken(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get token: %w", err) + } + + config := kc.AuthClient.Config() + url := fmt.Sprintf("%s/admin/realms/%s/users", config.AuthURL, config.Realm) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + resp, err := kc.AuthClient.HTTPClient().Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("failed to list users: %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + + var users []User + if err := json.NewDecoder(resp.Body).Decode(&users); err != nil { + return nil, fmt.Errorf("failed to decode users response: %w", err) + } + return users, nil +} diff --git a/pkg/keycloakApi/keycloak_api_client_test.go b/pkg/keycloakApi/keycloak_api_client_test.go new file mode 100644 index 0000000..d073e4a --- /dev/null +++ b/pkg/keycloakApi/keycloak_api_client_test.go @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: 2025 Greenbone AG +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package keycloakApi + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/greenbone/opensight-golang-libraries/pkg/auth" + "github.com/stretchr/testify/require" +) + +func newTestKeycloakClient(serverURL string) *auth.KeycloakClient { + return auth.NewKeycloakClient( + &http.Client{}, + auth.KeycloakConfig{ + AuthURL: serverURL, + Realm: "test-realm", + }, + auth.ClientCredentials{ + ClientID: "test-client", + ClientSecret: "test-secret", + }, + ) +} + +func TestListGroups(t *testing.T) { + groupsJSON := `[{"id":"1","name":"group1"},{"id":"2","name":"group2"}]` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/realms/test-realm/protocol/openid-connect/token" { + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"access_token":"test-token","expires_in":3600}`)) + require.NoError(t, err) + return + } + if r.URL.Path == "/admin/realms/test-realm/groups" { + if r.Header.Get("Authorization") != "Bearer test-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(groupsJSON)) + require.NoError(t, err) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + authClient := newTestKeycloakClient(server.URL) + apiClient := NewKeycloakAPIClient(authClient) + groups, err := apiClient.ListGroups(context.Background()) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(groups) != 2 || groups[0].Name != "group1" || groups[1].Name != "group2" { + t.Fatalf("unexpected groups: %+v", groups) + } +} + +func TestListUsers(t *testing.T) { + usersJSON := `[{"id":"1","username":"user1","email":"user1@example.com"},{"id":"2","username":"user2","email":"user2@example.com"}]` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/realms/test-realm/protocol/openid-connect/token" { + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"access_token":"test-token","expires_in":3600}`)) + require.NoError(t, err) + return + } + if r.URL.Path == "/admin/realms/test-realm/users" { + if r.Header.Get("Authorization") != "Bearer test-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(usersJSON)) + require.NoError(t, err) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + authClient := newTestKeycloakClient(server.URL) + apiClient := NewKeycloakAPIClient(authClient) + users, err := apiClient.ListUsers(context.Background()) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(users) != 2 || users[0].Username != "user1" || users[1].Email != "user2@example.com" { + t.Fatalf("unexpected users: %+v", users) + } +} From c60185da2ee02eb2476a7eda446d9b9d9197510b Mon Sep 17 00:00:00 2001 From: chellaVignesh Date: Thu, 30 Apr 2026 10:45:53 +0200 Subject: [PATCH 2/4] Using testify --- pkg/keycloakApi/keycloak_api_client_test.go | 22 ++++++++++----------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/pkg/keycloakApi/keycloak_api_client_test.go b/pkg/keycloakApi/keycloak_api_client_test.go index d073e4a..8c01d63 100644 --- a/pkg/keycloakApi/keycloak_api_client_test.go +++ b/pkg/keycloakApi/keycloak_api_client_test.go @@ -54,12 +54,11 @@ func TestListGroups(t *testing.T) { authClient := newTestKeycloakClient(server.URL) apiClient := NewKeycloakAPIClient(authClient) groups, err := apiClient.ListGroups(context.Background()) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if len(groups) != 2 || groups[0].Name != "group1" || groups[1].Name != "group2" { - t.Fatalf("unexpected groups: %+v", groups) - } + require.NoError(t, err, "expected no error") + + require.Equal(t, 2, len(groups), "expected 2 groups") + require.Equal(t, "group1", groups[0].Name, "first group name mismatch") + require.Equal(t, "group2", groups[1].Name, "second group name mismatch") } func TestListUsers(t *testing.T) { @@ -88,10 +87,9 @@ func TestListUsers(t *testing.T) { authClient := newTestKeycloakClient(server.URL) apiClient := NewKeycloakAPIClient(authClient) users, err := apiClient.ListUsers(context.Background()) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if len(users) != 2 || users[0].Username != "user1" || users[1].Email != "user2@example.com" { - t.Fatalf("unexpected users: %+v", users) - } + require.NoError(t, err, "expected no error") + + require.Equal(t, 2, len(users), "expected 2 users") + require.Equal(t, "user1", users[0].Username, "first user username mismatch") + require.Equal(t, "user2@example.com", users[1].Email, "second user email mismatch") } From bf562531a3c424ddd1e882302bcbb349d128c7b4 Mon Sep 17 00:00:00 2001 From: chellaVignesh Date: Thu, 30 Apr 2026 13:56:18 +0200 Subject: [PATCH 3/4] Addressing review comments --- pkg/keycloakapi/README.md | 89 +++++++++++++++++++ .../keycloak_api_client.go | 32 ++++--- .../keycloak_api_client_test.go | 19 ++-- 3 files changed, 121 insertions(+), 19 deletions(-) create mode 100644 pkg/keycloakapi/README.md rename pkg/{keycloakApi => keycloakapi}/keycloak_api_client.go (77%) rename pkg/{keycloakApi => keycloakapi}/keycloak_api_client_test.go (86%) diff --git a/pkg/keycloakapi/README.md b/pkg/keycloakapi/README.md new file mode 100644 index 0000000..d3ef7be --- /dev/null +++ b/pkg/keycloakapi/README.md @@ -0,0 +1,89 @@ + + + + +# keycloakapi + +```go +import "github.com/greenbone/opensight-golang-libraries/pkg/keycloakapi" +``` + +## Index + +- [type Group](<#Group>) +- [type KeycloakAPIClient](<#KeycloakAPIClient>) + - [func NewKeycloakAPIClient\(authClient \*auth.KeycloakClient\) \*KeycloakAPIClient](<#NewKeycloakAPIClient>) + - [func \(kc \*KeycloakAPIClient\) ListGroups\(ctx context.Context\) \(\[\]Group, error\)](<#KeycloakAPIClient.ListGroups>) + - [func \(kc \*KeycloakAPIClient\) ListUsers\(ctx context.Context\) \(\[\]User, error\)](<#KeycloakAPIClient.ListUsers>) +- [type User](<#User>) + + + +## type [Group]() + +Group represents a Keycloak group. + +```go +type Group struct { + ID string `json:"id"` + Name string `json:"name"` +} +``` + + +## type [KeycloakAPIClient]() + +KeycloakAPIClient provides methods to interact with Keycloak's REST API. + +```go +type KeycloakAPIClient struct { + // contains filtered or unexported fields +} +``` + + +### func [NewKeycloakAPIClient]() + +```go +func NewKeycloakAPIClient(authClient *auth.KeycloakClient) *KeycloakAPIClient +``` + +NewKeycloakAPIClient creates a new KeycloakAPIClient. + + +### func \(\*KeycloakAPIClient\) [ListGroups]() + +```go +func (kc *KeycloakAPIClient) ListGroups(ctx context.Context) ([]Group, error) +``` + +ListGroups retrieves all groups from Keycloak. + + +### func \(\*KeycloakAPIClient\) [ListUsers]() + +```go +func (kc *KeycloakAPIClient) ListUsers(ctx context.Context) ([]User, error) +``` + +ListUsers retrieves all users from Keycloak. + + +## type [User]() + +User represents a Keycloak user. + +```go +type User struct { + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` +} +``` + +Generated by [gomarkdoc]() + + + \ No newline at end of file diff --git a/pkg/keycloakApi/keycloak_api_client.go b/pkg/keycloakapi/keycloak_api_client.go similarity index 77% rename from pkg/keycloakApi/keycloak_api_client.go rename to pkg/keycloakapi/keycloak_api_client.go index 9d745f0..ac2c13b 100644 --- a/pkg/keycloakApi/keycloak_api_client.go +++ b/pkg/keycloakapi/keycloak_api_client.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -package keycloakApi +package keycloakapi import ( "context" @@ -32,22 +32,22 @@ type User struct { // KeycloakAPIClient provides methods to interact with Keycloak's REST API. type KeycloakAPIClient struct { - AuthClient *auth.KeycloakClient + authClient *auth.KeycloakClient } // NewKeycloakAPIClient creates a new KeycloakAPIClient. func NewKeycloakAPIClient(authClient *auth.KeycloakClient) *KeycloakAPIClient { - return &KeycloakAPIClient{AuthClient: authClient} + return &KeycloakAPIClient{authClient: authClient} } // ListGroups retrieves all groups from Keycloak. func (kc *KeycloakAPIClient) ListGroups(ctx context.Context) ([]Group, error) { - token, err := kc.AuthClient.GetToken(ctx) + token, err := kc.authClient.GetToken(ctx) if err != nil { return nil, fmt.Errorf("failed to get token: %w", err) } - config := kc.AuthClient.Config() + config := kc.authClient.Config() url := fmt.Sprintf("%s/admin/realms/%s/groups", config.AuthURL, config.Realm) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { @@ -55,15 +55,19 @@ func (kc *KeycloakAPIClient) ListGroups(ctx context.Context) ([]Group, error) { } req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - resp, err := kc.AuthClient.HTTPClient().Do(req) + resp, err := kc.authClient.HTTPClient().Do(req) if err != nil { return nil, fmt.Errorf("failed to execute request: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("failed to list groups: %s: %s", resp.Status, strings.TrimSpace(string(body))) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to list users: %s: could not read response body: %w", resp.Status, err) + } + + return nil, fmt.Errorf("failed to list users: %s: %s", resp.Status, strings.TrimSpace(string(body))) } var groups []Group @@ -75,12 +79,12 @@ func (kc *KeycloakAPIClient) ListGroups(ctx context.Context) ([]Group, error) { // ListUsers retrieves all users from Keycloak. func (kc *KeycloakAPIClient) ListUsers(ctx context.Context) ([]User, error) { - token, err := kc.AuthClient.GetToken(ctx) + token, err := kc.authClient.GetToken(ctx) if err != nil { return nil, fmt.Errorf("failed to get token: %w", err) } - config := kc.AuthClient.Config() + config := kc.authClient.Config() url := fmt.Sprintf("%s/admin/realms/%s/users", config.AuthURL, config.Realm) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { @@ -88,14 +92,18 @@ func (kc *KeycloakAPIClient) ListUsers(ctx context.Context) ([]User, error) { } req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - resp, err := kc.AuthClient.HTTPClient().Do(req) + resp, err := kc.authClient.HTTPClient().Do(req) if err != nil { return nil, fmt.Errorf("failed to execute request: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to list users: %s: could not read response body: %w", resp.Status, err) + } + return nil, fmt.Errorf("failed to list users: %s: %s", resp.Status, strings.TrimSpace(string(body))) } diff --git a/pkg/keycloakApi/keycloak_api_client_test.go b/pkg/keycloakapi/keycloak_api_client_test.go similarity index 86% rename from pkg/keycloakApi/keycloak_api_client_test.go rename to pkg/keycloakapi/keycloak_api_client_test.go index 8c01d63..605d7fd 100644 --- a/pkg/keycloakApi/keycloak_api_client_test.go +++ b/pkg/keycloakapi/keycloak_api_client_test.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -package keycloakApi +package keycloakapi import ( "context" @@ -11,6 +11,7 @@ import ( "testing" "github.com/greenbone/opensight-golang-libraries/pkg/auth" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -56,9 +57,11 @@ func TestListGroups(t *testing.T) { groups, err := apiClient.ListGroups(context.Background()) require.NoError(t, err, "expected no error") - require.Equal(t, 2, len(groups), "expected 2 groups") - require.Equal(t, "group1", groups[0].Name, "first group name mismatch") - require.Equal(t, "group2", groups[1].Name, "second group name mismatch") + wantGroups := []Group{ + {ID: "1", Name: "group1"}, + {ID: "2", Name: "group2"}, + } + assert.ElementsMatch(t, wantGroups, groups, "groups mismatch") } func TestListUsers(t *testing.T) { @@ -89,7 +92,9 @@ func TestListUsers(t *testing.T) { users, err := apiClient.ListUsers(context.Background()) require.NoError(t, err, "expected no error") - require.Equal(t, 2, len(users), "expected 2 users") - require.Equal(t, "user1", users[0].Username, "first user username mismatch") - require.Equal(t, "user2@example.com", users[1].Email, "second user email mismatch") + wantUsers := []User{ + {ID: "1", Username: "user1", Email: "user1@example.com"}, + {ID: "2", Username: "user2", Email: "user2@example.com"}, + } + assert.ElementsMatch(t, wantUsers, users, "users mismatch") } From ca8850e8c327112ab367671fb3049ef158643abd Mon Sep 17 00:00:00 2001 From: chellaVignesh Date: Thu, 30 Apr 2026 13:57:25 +0200 Subject: [PATCH 4/4] Generating docs --- pkg/auth/README.md | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/pkg/auth/README.md b/pkg/auth/README.md index 531c3c5..71d4789 100644 --- a/pkg/auth/README.md +++ b/pkg/auth/README.md @@ -17,7 +17,9 @@ Package auth provides a client to authenticate against a Keycloak server. - [type Credentials](<#Credentials>) - [type KeycloakClient](<#KeycloakClient>) - [func NewKeycloakClient\(httpClient \*http.Client, cfg KeycloakConfig, credentials Credentials\) \*KeycloakClient](<#NewKeycloakClient>) + - [func \(c \*KeycloakClient\) Config\(\) KeycloakConfig](<#KeycloakClient.Config>) - [func \(c \*KeycloakClient\) GetToken\(ctx context.Context\) \(string, error\)](<#KeycloakClient.GetToken>) + - [func \(c \*KeycloakClient\) HTTPClient\(\) \*http.Client](<#KeycloakClient.HTTPClient>) - [type KeycloakConfig](<#KeycloakConfig>) - [type ResourceOwnerCredentials](<#ResourceOwnerCredentials>) @@ -76,6 +78,15 @@ func NewKeycloakClient(httpClient *http.Client, cfg KeycloakConfig, credentials NewKeycloakClient creates a new KeycloakClient. Passed [Credentials](<#Credentials>) determines the used auth type. + +### func \(\*KeycloakClient\) [Config]() + +```go +func (c *KeycloakClient) Config() KeycloakConfig +``` + +Config returns the KeycloakConfig used by the KeycloakClient. + ### func \(\*KeycloakClient\) [GetToken]() @@ -85,6 +96,15 @@ func (c *KeycloakClient) GetToken(ctx context.Context) (string, error) GetToken retrieves a valid access token. The token is cached and refreshed before expiry. + +### func \(\*KeycloakClient\) [HTTPClient]() + +```go +func (c *KeycloakClient) HTTPClient() *http.Client +``` + +HTTPClient returns the underlying http.Client used by the KeycloakClient. + ## type [KeycloakConfig]() @@ -92,13 +112,13 @@ KeycloakConfig holds the credentials and configuration details ```go type KeycloakConfig struct { - AuthURL string - KeycloakRealm string + AuthURL string + Realm string } ``` -## type [ResourceOwnerCredentials]() +## type [ResourceOwnerCredentials]() ResourceOwnerCredentials to authenticate via \`Resource owner password credentials grant\` flow. Ref: https://www.keycloak.org/docs/latest/server_admin/index.html#_oidc-auth-flows-direct