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) })) }