diff --git a/pkg/auth/README.md b/pkg/auth/README.md
new file mode 100644
index 0000000..dd5b420
--- /dev/null
+++ b/pkg/auth/README.md
@@ -0,0 +1,68 @@
+
+
+
+
+# auth
+
+```go
+import "github.com/greenbone/opensight-golang-libraries/pkg/auth"
+```
+
+Package auth provides a client to authenticate against a Keycloak server.
+
+## Index
+
+- [type KeycloakClient](<#KeycloakClient>)
+ - [func NewKeycloakClient\(httpClient \*http.Client, cfg KeycloakConfig\) \*KeycloakClient](<#NewKeycloakClient>)
+ - [func \(c \*KeycloakClient\) GetToken\(ctx context.Context\) \(string, error\)](<#KeycloakClient.GetToken>)
+- [type KeycloakConfig](<#KeycloakConfig>)
+
+
+
+## type [KeycloakClient]()
+
+KeycloakClient can be used to authenticate against a Keycloak server.
+
+```go
+type KeycloakClient struct {
+ // contains filtered or unexported fields
+}
+```
+
+
+### func [NewKeycloakClient]()
+
+```go
+func NewKeycloakClient(httpClient *http.Client, cfg KeycloakConfig) *KeycloakClient
+```
+
+NewKeycloakClient creates a new KeycloakClient.
+
+
+### func \(\*KeycloakClient\) [GetToken]()
+
+```go
+func (c *KeycloakClient) GetToken(ctx context.Context) (string, error)
+```
+
+GetToken retrieves a valid access token. The token is cached and refreshed before expiry. The token is obtained by \`Resource owner password credentials grant\` flow. Ref: https://www.keycloak.org/docs/latest/server_admin/index.html#_oidc-auth-flows-direct
+
+
+## type [KeycloakConfig]()
+
+KeycloakConfig holds the credentials and configuration details
+
+```go
+type KeycloakConfig struct {
+ ClientID string
+ Username string
+ Password string
+ AuthURL string
+ KeycloakRealm string
+}
+```
+
+Generated by [gomarkdoc]()
+
+
+
\ No newline at end of file
diff --git a/pkg/auth/auth_client.go b/pkg/auth/auth_client.go
new file mode 100644
index 0000000..2e1e5a1
--- /dev/null
+++ b/pkg/auth/auth_client.go
@@ -0,0 +1,144 @@
+// SPDX-FileCopyrightText: 2025 Greenbone AG
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package auth provides a client to authenticate against a Keycloak server.
+package auth
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "sync"
+ "time"
+)
+
+type Clock interface {
+ Now() time.Time
+}
+
+type realClock struct{}
+
+func (realClock) Now() time.Time { return time.Now() }
+
+const tokenRefreshMargin = 10 * time.Second
+
+// KeycloakConfig holds the credentials and configuration details
+type KeycloakConfig struct {
+ ClientID string
+ Username string
+ Password string
+ AuthURL string
+ KeycloakRealm string
+}
+
+type tokenInfo struct {
+ AccessToken string
+ ExpiresAt time.Time
+}
+
+type authResponse struct {
+ AccessToken string `json:"access_token"`
+ ExpiresIn int `json:"expires_in"` // in seconds
+}
+
+// KeycloakClient can be used to authenticate against a Keycloak server.
+type KeycloakClient struct {
+ httpClient *http.Client
+ cfg KeycloakConfig
+ tokenInfo tokenInfo
+ tokenMutex sync.RWMutex
+
+ clock Clock // to mock time in tests
+}
+
+// NewKeycloakClient creates a new KeycloakClient.
+func NewKeycloakClient(httpClient *http.Client, cfg KeycloakConfig) *KeycloakClient {
+ return &KeycloakClient{
+ httpClient: httpClient,
+ cfg: cfg,
+ clock: realClock{},
+ }
+}
+
+// GetToken retrieves a valid access token. The token is cached and refreshed before expiry.
+// The token is obtained by `Resource owner password credentials grant` flow.
+// Ref: https://www.keycloak.org/docs/latest/server_admin/index.html#_oidc-auth-flows-direct
+func (c *KeycloakClient) GetToken(ctx context.Context) (string, error) {
+ token, ok := c.getCachedToken()
+ if ok {
+ return token, nil
+ }
+
+ // need to retrieve new token
+ c.tokenMutex.Lock()
+ defer c.tokenMutex.Unlock()
+
+ // check again in case another goroutine already refreshed the token
+ if c.clock.Now().Before(c.tokenInfo.ExpiresAt.Add(-tokenRefreshMargin)) {
+ return c.tokenInfo.AccessToken, nil
+ }
+
+ authResponse, err := c.requestToken(ctx)
+ if err != nil {
+ return "", fmt.Errorf("failed to request token: %w", err)
+ }
+
+ c.tokenInfo = tokenInfo{
+ AccessToken: authResponse.AccessToken,
+ ExpiresAt: c.clock.Now().UTC().Add(time.Duration(authResponse.ExpiresIn) * time.Second),
+ }
+
+ return authResponse.AccessToken, nil
+}
+
+func (c *KeycloakClient) getCachedToken() (token string, ok bool) {
+ c.tokenMutex.RLock()
+ defer c.tokenMutex.RUnlock()
+ if c.clock.Now().Before(c.tokenInfo.ExpiresAt.Add(-tokenRefreshMargin)) {
+ return c.tokenInfo.AccessToken, true
+ }
+ return "", false
+}
+
+func (c *KeycloakClient) requestToken(ctx context.Context) (*authResponse, error) {
+ data := url.Values{}
+ data.Set("client_id", c.cfg.ClientID)
+ data.Set("password", c.cfg.Password)
+ data.Set("username", c.cfg.Username)
+ data.Set("grant_type", "password")
+
+ authenticationURL := fmt.Sprintf("%s/realms/%s/protocol/openid-connect/token",
+ c.cfg.AuthURL, c.cfg.KeycloakRealm)
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, authenticationURL, strings.NewReader(data.Encode()))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create authentication request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to execute authentication request: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("authentication request failed with status: %s: %w body: %s", resp.Status, err, string(respBody))
+ }
+ return nil, fmt.Errorf("authentication request failed with status: %s: %s", resp.Status, string(respBody))
+ }
+
+ var authResp authResponse
+ if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil {
+ return nil, fmt.Errorf("failed to parse authentication response: %w", err)
+ }
+
+ return &authResp, nil
+}
diff --git a/pkg/auth/auth_client_test.go b/pkg/auth/auth_client_test.go
new file mode 100644
index 0000000..64ad24b
--- /dev/null
+++ b/pkg/auth/auth_client_test.go
@@ -0,0 +1,137 @@
+// SPDX-FileCopyrightText: 2025 Greenbone AG
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package auth
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestKeycloakClient_GetToken(t *testing.T) {
+ tests := map[string]struct {
+ responseBody string
+ responseCode int
+ wantErr bool
+ wantToken string
+ }{
+ "successful token retrieval": {
+ responseBody: `{"access_token": "test-token", "expires_in": 3600}`,
+ responseCode: http.StatusOK,
+ wantErr: false,
+ wantToken: "test-token",
+ },
+ "failed authentication": {
+ responseBody: `{}`,
+ responseCode: http.StatusUnauthorized,
+ wantErr: true,
+ },
+ }
+ for name, tt := range tests {
+ t.Run(name, func(t *testing.T) {
+ var serverCallCount atomic.Int32
+ mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ serverCallCount.Add(1)
+ w.WriteHeader(tt.responseCode)
+ _, err := w.Write([]byte(tt.responseBody))
+ require.NoError(t, err)
+ }))
+ defer mockServer.Close()
+
+ client := NewKeycloakClient(http.DefaultClient, KeycloakConfig{
+ AuthURL: mockServer.URL,
+ // the other fields are also required in real scenario, but omit here for brevity
+ })
+ gotToken, err := client.GetToken(context.Background())
+ assert.Greater(t, serverCallCount.Load(), int32(0), "server was not called")
+
+ if tt.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ assert.Equal(t, tt.wantToken, gotToken)
+ }
+ })
+ }
+}
+
+type fakeClock struct {
+ currentTime time.Time
+}
+
+func (fc *fakeClock) Now() time.Time {
+ return fc.currentTime
+}
+
+func (fc *fakeClock) Advance(d time.Duration) {
+ fc.currentTime = fc.currentTime.Add(d)
+}
+
+func NewFakeClock(startTime time.Time) *fakeClock {
+ return &fakeClock{currentTime: startTime}
+}
+
+func TestKeycloakClient_GetToken_Refresh(t *testing.T) {
+ tokenValidity := 60 * time.Second
+ kcMockResponse := []byte(fmt.Sprintf(`{"access_token": "test-token", "expires_in": %d}`, int(tokenValidity.Seconds())))
+
+ tests := map[string]struct {
+ responseBody string
+ responseCode int
+ requestAfter time.Duration
+ wantServerCalled int
+ wantToken string
+ }{
+ "token is cached": {
+ requestAfter: tokenValidity - tokenRefreshMargin - time.Nanosecond,
+ wantServerCalled: 1, // should be called only once due to caching
+ wantToken: "test-token",
+ },
+ "token expiry handling": {
+ requestAfter: tokenValidity - tokenRefreshMargin + time.Nanosecond,
+ wantServerCalled: 2, // should be called twice due to expiry
+ wantToken: "test-token",
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ fakeClock := NewFakeClock(time.Now())
+
+ var serverCallCount atomic.Int32
+ mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ serverCallCount.Add(1)
+ w.WriteHeader(200)
+ _, err := w.Write(kcMockResponse)
+ require.NoError(t, err)
+ }))
+ defer mockServer.Close()
+
+ client := NewKeycloakClient(http.DefaultClient, KeycloakConfig{
+ AuthURL: mockServer.URL,
+ // the other fields are also required in real scenario, but omit here for brevity
+ })
+ client.clock = fakeClock
+
+ _, err := client.GetToken(context.Background())
+ require.NoError(t, err)
+
+ fakeClock.Advance(tc.requestAfter)
+
+ gotToken, err := client.GetToken(context.Background()) // second call to test caching/refresh
+ require.NoError(t, err)
+
+ assert.Equal(t, tc.wantServerCalled, int(serverCallCount.Load()), "unexpected number of server calls")
+ assert.Equal(t, tc.wantToken, gotToken)
+ })
+ }
+}
diff --git a/pkg/notifications/README.md b/pkg/notifications/README.md
index d1fe7a1..6c2dd27 100644
--- a/pkg/notifications/README.md
+++ b/pkg/notifications/README.md
@@ -13,17 +13,15 @@ Package Notifications provides a client to communicate with the OpenSight Notifi
## Index
- [type Client](<#Client>)
- - [func NewClient\(httpClient \*http.Client, config Config, authentication KeycloakAuthentication\) \*Client](<#NewClient>)
+ - [func NewClient\(httpClient \*http.Client, config Config, authCfg auth.KeycloakConfig\) \*Client](<#NewClient>)
- [func \(c \*Client\) CreateNotification\(ctx context.Context, notification Notification\) error](<#Client.CreateNotification>)
- - [func \(c \*Client\) GetAuthenticationToken\(ctx context.Context\) \(string, error\)](<#Client.GetAuthenticationToken>)
- [type Config](<#Config>)
-- [type KeycloakAuthentication](<#KeycloakAuthentication>)
- [type Level](<#Level>)
- [type Notification](<#Notification>)
-## type Client
+## type [Client]()
Client can be used to send notifications
@@ -34,16 +32,16 @@ type Client struct {
```
-### func NewClient
+### func [NewClient]()
```go
-func NewClient(httpClient *http.Client, config Config, authentication KeycloakAuthentication) *Client
+func NewClient(httpClient *http.Client, config Config, authCfg auth.KeycloakConfig) *Client
```
NewClient returns a new [Client](<#Client>) with the notification service address \(host:port\) set. As httpClient you can use e.g. \[http.DefaultClient\].
-### func \(\*Client\) CreateNotification
+### func \(\*Client\) [CreateNotification]()
```go
func (c *Client) CreateNotification(ctx context.Context, notification Notification) error
@@ -51,17 +49,8 @@ func (c *Client) CreateNotification(ctx context.Context, notification Notificati
CreateNotification sends a notification to the notification service. It is retried up to the configured number of retries with an exponential backoff, So it can take some time until the functions returns.
-
-### func \(\*Client\) GetAuthenticationToken
-
-```go
-func (c *Client) GetAuthenticationToken(ctx context.Context) (string, error)
-```
-
-GetAuthenticationToken retrieves an authentication token using client credentials. It constructs a form\-encoded request, sends it with retry logic, and parses the response.
-
-## type Config
+## type [Config]()
Config configures the notification service client
@@ -74,23 +63,8 @@ type Config struct {
}
```
-
-## type KeycloakAuthentication
-
-KeycloakAuthentication holds the credentials and configuration details required for Keycloak authentication in the notification service.
-
-```go
-type KeycloakAuthentication struct {
- ClientID string
- Username string
- Password string
- AuthURL string
- KeycloakRealm string
-}
-```
-
-## type Level
+## type [Level]()
Level describes the severity of the notification
@@ -109,7 +83,7 @@ const (
```
-## type Notification
+## type [Notification]()
diff --git a/pkg/notifications/notification.go b/pkg/notifications/notification.go
index db8a1d3..992382e 100644
--- a/pkg/notifications/notification.go
+++ b/pkg/notifications/notification.go
@@ -12,9 +12,9 @@ import (
"fmt"
"net/http"
"net/url"
- "strings"
"time"
+ "github.com/greenbone/opensight-golang-libraries/pkg/auth"
"github.com/greenbone/opensight-golang-libraries/pkg/retryableRequest"
)
@@ -26,11 +26,11 @@ const (
// Client can be used to send notifications
type Client struct {
httpClient *http.Client
+ authClient *auth.KeycloakClient
notificationServiceAddress string
maxRetries int
retryWaitMin time.Duration
retryWaitMax time.Duration
- authentication KeycloakAuthentication
}
// Config configures the notification service client
@@ -41,26 +41,18 @@ type Config struct {
RetryWaitMax time.Duration
}
-// KeycloakAuthentication holds the credentials and configuration details
-// required for Keycloak authentication in the notification service.
-type KeycloakAuthentication struct {
- ClientID string
- Username string
- Password string
- AuthURL string
- KeycloakRealm string
-}
-
// NewClient returns a new [Client] with the notification service address (host:port) set.
// As httpClient you can use e.g. [http.DefaultClient].
-func NewClient(httpClient *http.Client, config Config, authentication KeycloakAuthentication) *Client {
+func NewClient(httpClient *http.Client, config Config, authCfg auth.KeycloakConfig) *Client {
+ authClient := auth.NewKeycloakClient(httpClient, authCfg)
+
return &Client{
httpClient: httpClient,
+ authClient: authClient,
notificationServiceAddress: config.Address,
maxRetries: config.MaxRetries,
retryWaitMin: config.RetryWaitMin,
retryWaitMax: config.RetryWaitMax,
- authentication: authentication,
}
}
@@ -68,7 +60,7 @@ func NewClient(httpClient *http.Client, config Config, authentication KeycloakAu
// It is retried up to the configured number of retries with an exponential backoff,
// So it can take some time until the functions returns.
func (c *Client) CreateNotification(ctx context.Context, notification Notification) error {
- token, err := c.GetAuthenticationToken(ctx)
+ token, err := c.authClient.GetToken(ctx)
if err != nil {
return fmt.Errorf("failed to get authentication token: %w", err)
}
@@ -97,45 +89,8 @@ func (c *Client) CreateNotification(ctx context.Context, notification Notificati
c.maxRetries, c.retryWaitMin, c.retryWaitMax)
if err == nil {
// note: the successful response returns the notification object, but we don't care about its values and omit parsing the body here
- response.Body.Close()
+ _ = response.Body.Close()
}
return err
}
-
-// GetAuthenticationToken retrieves an authentication token using client credentials.
-// It constructs a form-encoded request, sends it with retry logic, and parses the response.
-func (c *Client) GetAuthenticationToken(ctx context.Context) (string, error) {
- data := url.Values{}
- data.Set("client_id", c.authentication.ClientID)
- data.Set("password", c.authentication.Password)
- data.Set("username", c.authentication.Username)
- data.Set("grant_type", "password")
-
- authenticationURL := fmt.Sprintf("%s/realms/%s/protocol/openid-connect/token",
- c.authentication.AuthURL, c.authentication.KeycloakRealm)
-
- req, err := http.NewRequest(http.MethodPost, authenticationURL, strings.NewReader(data.Encode()))
- if err != nil {
- return "", fmt.Errorf("failed to create authentication request: %w", err)
- }
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
-
- resp, err := retryableRequest.ExecuteRequestWithRetry(ctx, c.httpClient, req,
- c.maxRetries, c.retryWaitMin, c.retryWaitMax)
- if err != nil {
- return "", fmt.Errorf("failed to execute authentication request with retry: %w", err)
- }
- defer resp.Body.Close()
-
- // parse JSON response to extract the access token
- // only access token is needed from the response
- var authResponse struct {
- AccessToken string `json:"access_token"`
- }
- if err := json.NewDecoder(resp.Body).Decode(&authResponse); err != nil {
- return "", fmt.Errorf("failed to parse authentication response: %w", err)
- }
-
- return authResponse.AccessToken, nil
-}
diff --git a/pkg/notifications/notification_test.go b/pkg/notifications/notification_test.go
index 0adb0e5..6b7caea 100644
--- a/pkg/notifications/notification_test.go
+++ b/pkg/notifications/notification_test.go
@@ -14,6 +14,7 @@ import (
"testing"
"time"
+ "github.com/greenbone/opensight-golang-libraries/pkg/auth"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -118,7 +119,7 @@ func TestClient_CreateNotification(t *testing.T) {
RetryWaitMax: time.Second,
}
- authentication := KeycloakAuthentication{
+ authentication := auth.KeycloakConfig{
ClientID: "client_id",
Username: "username",
Password: "password",
@@ -129,7 +130,7 @@ func TestClient_CreateNotification(t *testing.T) {
err := client.CreateNotification(context.Background(), tt.notification)
if !tt.serverErrors.authenticationFail {
- require.True(t, serverCallCount.Load() > 0, "server was not called")
+ assert.Greater(t, serverCallCount.Load(), int32(0), "notification server was not called")
}
if tt.wantErr {
@@ -147,7 +148,8 @@ func setupMockAuthServer(t *testing.T, failAuth bool) *httptest.Server {
w.WriteHeader(http.StatusUnauthorized)
return
}
- err := json.NewEncoder(w).Encode(map[string]string{"access_token": "mock-token"})
+ w.Write([]byte(`{"access_token": "mock-token", "expires_in": 3600}`))
+ err := json.NewEncoder(w).Encode(map[string]any{})
require.NoError(t, err)
}))
}