Skip to content

Commit ac488f3

Browse files
committed
fix: notifications - cache token to avoid auth failure on repeated calls
Previously we were retrieving the acess token from Keycloak for each request to the notification service. This is not efficient and furthermore keycloak deactivates the user temporarily when it detects an excessing amount of logins. Moved the authentication to a separate client as it can be useful on its own in other scenarios. This is a breaking change in the notification client initialization.
1 parent ddfc347 commit ac488f3

6 files changed

Lines changed: 324 additions & 89 deletions

File tree

pkg/auth/README.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<!-- gomarkdoc:embed:start -->
2+
3+
<!-- Code generated by gomarkdoc. DO NOT EDIT -->
4+
5+
# auth
6+
7+
```go
8+
import "github.com/greenbone/opensight-golang-libraries/pkg/auth"
9+
```
10+
11+
Package auth provides a client to authenticate against a Keycloak server.
12+
13+
## Index
14+
15+
- [type KeycloakClient](<#KeycloakClient>)
16+
- [func NewKeycloakClient\(httpClient \*http.Client, cfg KeycloakConfig\) \*KeycloakClient](<#NewKeycloakClient>)
17+
- [func \(c \*KeycloakClient\) GetToken\(ctx context.Context\) \(string, error\)](<#KeycloakClient.GetToken>)
18+
- [type KeycloakConfig](<#KeycloakConfig>)
19+
20+
21+
<a name="KeycloakClient"></a>
22+
## type [KeycloakClient](<https://github.com/greenbone/opensight-golang-libraries/blob/main/pkg/auth/auth_client.go#L42-L47>)
23+
24+
KeycloakClient can be used to authenticate against a Keycloak server.
25+
26+
```go
27+
type KeycloakClient struct {
28+
// contains filtered or unexported fields
29+
}
30+
```
31+
32+
<a name="NewKeycloakClient"></a>
33+
### func [NewKeycloakClient](<https://github.com/greenbone/opensight-golang-libraries/blob/main/pkg/auth/auth_client.go#L50>)
34+
35+
```go
36+
func NewKeycloakClient(httpClient *http.Client, cfg KeycloakConfig) *KeycloakClient
37+
```
38+
39+
NewKeycloakClient creates a new KeycloakClient.
40+
41+
<a name="KeycloakClient.GetToken"></a>
42+
### func \(\*KeycloakClient\) [GetToken](<https://github.com/greenbone/opensight-golang-libraries/blob/main/pkg/auth/auth_client.go#L60>)
43+
44+
```go
45+
func (c *KeycloakClient) GetToken(ctx context.Context) (string, error)
46+
```
47+
48+
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
49+
50+
<a name="KeycloakConfig"></a>
51+
## type [KeycloakConfig](<https://github.com/greenbone/opensight-golang-libraries/blob/main/pkg/auth/auth_client.go#L23-L29>)
52+
53+
KeycloakConfig holds the credentials and configuration details
54+
55+
```go
56+
type KeycloakConfig struct {
57+
ClientID string
58+
Username string
59+
Password string
60+
AuthURL string
61+
KeycloakRealm string
62+
}
63+
```
64+
65+
Generated by [gomarkdoc](<https://github.com/princjef/gomarkdoc>)
66+
67+
68+
<!-- gomarkdoc:embed:end -->

pkg/auth/auth_client.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// SPDX-FileCopyrightText: 2025 Greenbone AG <https://greenbone.net>
2+
//
3+
// SPDX-License-Identifier: AGPL-3.0-or-later
4+
5+
// Package auth provides a client to authenticate against a Keycloak server.
6+
package auth
7+
8+
import (
9+
"context"
10+
"encoding/json"
11+
"fmt"
12+
"io"
13+
"net/http"
14+
"net/url"
15+
"strings"
16+
"sync"
17+
"time"
18+
)
19+
20+
const tokenRefreshMargin = 10 * time.Second
21+
22+
// KeycloakConfig holds the credentials and configuration details
23+
type KeycloakConfig struct {
24+
ClientID string
25+
Username string
26+
Password string
27+
AuthURL string
28+
KeycloakRealm string
29+
}
30+
31+
type tokenInfo struct {
32+
AccessToken string
33+
ExpiresAt time.Time
34+
}
35+
36+
type authResponse struct {
37+
AccessToken string `json:"access_token"`
38+
ExpiresIn int `json:"expires_in"` // in seconds
39+
}
40+
41+
// KeycloakClient can be used to authenticate against a Keycloak server.
42+
type KeycloakClient struct {
43+
httpClient *http.Client
44+
cfg KeycloakConfig
45+
tokenInfo tokenInfo
46+
tokenMutex sync.RWMutex
47+
}
48+
49+
// NewKeycloakClient creates a new KeycloakClient.
50+
func NewKeycloakClient(httpClient *http.Client, cfg KeycloakConfig) *KeycloakClient {
51+
return &KeycloakClient{
52+
httpClient: httpClient,
53+
cfg: cfg,
54+
}
55+
}
56+
57+
// GetToken retrieves a valid access token. The token is cached and refreshed before expiry.
58+
// The token is obtained by `Resource owner password credentials grant` flow.
59+
// Ref: https://www.keycloak.org/docs/latest/server_admin/index.html#_oidc-auth-flows-direct
60+
func (c *KeycloakClient) GetToken(ctx context.Context) (string, error) {
61+
getCachedToken := func() (token string, ok bool) {
62+
c.tokenMutex.RLock()
63+
defer c.tokenMutex.RUnlock()
64+
if time.Now().Before(c.tokenInfo.ExpiresAt.Add(-tokenRefreshMargin)) {
65+
return c.tokenInfo.AccessToken, true
66+
} else {
67+
return "", false
68+
}
69+
}
70+
71+
token, ok := getCachedToken()
72+
if ok {
73+
return token, nil
74+
}
75+
76+
// need to retrieve new token
77+
c.tokenMutex.Lock()
78+
defer c.tokenMutex.Unlock()
79+
80+
// check again in case another goroutine already refreshed the token
81+
if time.Now().Before(c.tokenInfo.ExpiresAt.Add(-tokenRefreshMargin)) {
82+
return c.tokenInfo.AccessToken, nil
83+
}
84+
85+
data := url.Values{}
86+
data.Set("client_id", c.cfg.ClientID)
87+
data.Set("password", c.cfg.Password)
88+
data.Set("username", c.cfg.Username)
89+
data.Set("grant_type", "password")
90+
91+
authenticationURL := fmt.Sprintf("%s/realms/%s/protocol/openid-connect/token",
92+
c.cfg.AuthURL, c.cfg.KeycloakRealm)
93+
94+
req, err := http.NewRequest(http.MethodPost, authenticationURL, strings.NewReader(data.Encode()))
95+
if err != nil {
96+
return "", fmt.Errorf("failed to create authentication request: %w", err)
97+
}
98+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
99+
100+
resp, err := c.httpClient.Do(req)
101+
if err != nil {
102+
return "", fmt.Errorf("failed to execute authentication request with retry: %w", err)
103+
}
104+
defer resp.Body.Close()
105+
106+
if resp.StatusCode != http.StatusOK {
107+
respBody, err := io.ReadAll(resp.Body)
108+
if err != nil {
109+
respBody = []byte("failed to read response body: " + err.Error())
110+
}
111+
return "", fmt.Errorf("authentication request failed with status: %s: %s", resp.Status, string(respBody))
112+
}
113+
114+
var authResponse authResponse
115+
if err := json.NewDecoder(resp.Body).Decode(&authResponse); err != nil {
116+
return "", fmt.Errorf("failed to parse authentication response: %w", err)
117+
}
118+
119+
c.tokenInfo = tokenInfo{
120+
AccessToken: authResponse.AccessToken,
121+
ExpiresAt: time.Now().Add(time.Duration(authResponse.ExpiresIn) * time.Second),
122+
}
123+
124+
return authResponse.AccessToken, nil
125+
}

pkg/auth/auth_client_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// SPDX-FileCopyrightText: 2025 Greenbone AG <https://greenbone.net>
2+
//
3+
// SPDX-License-Identifier: AGPL-3.0-or-later
4+
5+
package auth
6+
7+
import (
8+
"context"
9+
"net/http"
10+
"net/http/httptest"
11+
"sync/atomic"
12+
"testing"
13+
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
func TestKeycloakClient_GetToken(t *testing.T) {
19+
tests := map[string]struct {
20+
responseBody string
21+
responseCode int
22+
wantErr bool
23+
wantToken string
24+
}{
25+
"successful token retrieval": {
26+
responseBody: `{"access_token": "test-token", "expires_in": 3600}`,
27+
responseCode: http.StatusOK,
28+
wantErr: false,
29+
wantToken: "test-token",
30+
},
31+
"failed authentication": {
32+
responseBody: `{}`,
33+
responseCode: http.StatusUnauthorized,
34+
wantErr: true,
35+
},
36+
}
37+
for name, tt := range tests {
38+
t.Run(name, func(t *testing.T) {
39+
var serverCallCount atomic.Int32
40+
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
41+
serverCallCount.Add(1)
42+
w.WriteHeader(tt.responseCode)
43+
_, err := w.Write([]byte(tt.responseBody))
44+
require.NoError(t, err)
45+
}))
46+
defer mockServer.Close()
47+
48+
client := NewKeycloakClient(http.DefaultClient, KeycloakConfig{
49+
AuthURL: mockServer.URL,
50+
// the other fields are also required in real scenario, but omit here for brevity
51+
})
52+
gotToken, err := client.GetToken(context.Background())
53+
assert.True(t, serverCallCount.Load() > 0, "server was not called")
54+
55+
if tt.wantErr {
56+
require.Error(t, err)
57+
} else {
58+
require.NoError(t, err)
59+
assert.Equal(t, tt.wantToken, gotToken)
60+
}
61+
})
62+
}
63+
}
64+
65+
func TestKeycloakClient_GetToken_Refresh(t *testing.T) {
66+
tests := map[string]struct {
67+
responseBody string
68+
responseCode int
69+
wantServerCalled int
70+
wantToken string
71+
}{
72+
"token is cached": {
73+
responseBody: `{"access_token": "test-token", "expires_in": 3600}`,
74+
responseCode: http.StatusOK,
75+
wantServerCalled: 1, // should be called only once due to caching
76+
wantToken: "test-token",
77+
},
78+
"token expiry handling": {
79+
responseBody: `{"access_token": "test-token", "expires_in": 0}`, // expires immediately
80+
responseCode: http.StatusOK,
81+
wantServerCalled: 2, // should be called twice due to expiry
82+
wantToken: "test-token",
83+
},
84+
}
85+
86+
for name, tc := range tests {
87+
t.Run(name, func(t *testing.T) {
88+
var serverCallCount atomic.Int32
89+
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
90+
serverCallCount.Add(1)
91+
w.WriteHeader(tc.responseCode)
92+
_, err := w.Write([]byte(tc.responseBody))
93+
require.NoError(t, err)
94+
}))
95+
defer mockServer.Close()
96+
97+
client := NewKeycloakClient(http.DefaultClient, KeycloakConfig{
98+
AuthURL: mockServer.URL,
99+
// the other fields are also required in real scenario, but omit here for brevity
100+
})
101+
_, err := client.GetToken(context.Background())
102+
require.NoError(t, err)
103+
104+
gotToken, err := client.GetToken(context.Background()) // second call to test caching/refresh
105+
require.NoError(t, err)
106+
107+
assert.Equal(t, tc.wantServerCalled, int(serverCallCount.Load()), "unexpected number of server calls")
108+
assert.Equal(t, tc.wantToken, gotToken)
109+
})
110+
}
111+
}

0 commit comments

Comments
 (0)