Skip to content

Commit 1c5385d

Browse files
authored
fix: notifications - cache token to avoid auth failure on repeated calls (#273)
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 236ac1c commit 1c5385d

File tree

6 files changed

+370
-90
lines changed

6 files changed

+370
-90
lines changed

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: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
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+
type Clock interface {
21+
Now() time.Time
22+
}
23+
24+
type realClock struct{}
25+
26+
func (realClock) Now() time.Time { return time.Now() }
27+
28+
const tokenRefreshMargin = 10 * time.Second
29+
30+
// KeycloakConfig holds the credentials and configuration details
31+
type KeycloakConfig struct {
32+
ClientID string
33+
Username string
34+
Password string
35+
AuthURL string
36+
KeycloakRealm string
37+
}
38+
39+
type tokenInfo struct {
40+
AccessToken string
41+
ExpiresAt time.Time
42+
}
43+
44+
type authResponse struct {
45+
AccessToken string `json:"access_token"`
46+
ExpiresIn int `json:"expires_in"` // in seconds
47+
}
48+
49+
// KeycloakClient can be used to authenticate against a Keycloak server.
50+
type KeycloakClient struct {
51+
httpClient *http.Client
52+
cfg KeycloakConfig
53+
tokenInfo tokenInfo
54+
tokenMutex sync.RWMutex
55+
56+
clock Clock // to mock time in tests
57+
}
58+
59+
// NewKeycloakClient creates a new KeycloakClient.
60+
func NewKeycloakClient(httpClient *http.Client, cfg KeycloakConfig) *KeycloakClient {
61+
return &KeycloakClient{
62+
httpClient: httpClient,
63+
cfg: cfg,
64+
clock: realClock{},
65+
}
66+
}
67+
68+
// GetToken retrieves a valid access token. The token is cached and refreshed before expiry.
69+
// The token is obtained by `Resource owner password credentials grant` flow.
70+
// Ref: https://www.keycloak.org/docs/latest/server_admin/index.html#_oidc-auth-flows-direct
71+
func (c *KeycloakClient) GetToken(ctx context.Context) (string, error) {
72+
token, ok := c.getCachedToken()
73+
if ok {
74+
return token, nil
75+
}
76+
77+
// need to retrieve new token
78+
c.tokenMutex.Lock()
79+
defer c.tokenMutex.Unlock()
80+
81+
// check again in case another goroutine already refreshed the token
82+
if c.clock.Now().Before(c.tokenInfo.ExpiresAt.Add(-tokenRefreshMargin)) {
83+
return c.tokenInfo.AccessToken, nil
84+
}
85+
86+
authResponse, err := c.requestToken(ctx)
87+
if err != nil {
88+
return "", fmt.Errorf("failed to request token: %w", err)
89+
}
90+
91+
c.tokenInfo = tokenInfo{
92+
AccessToken: authResponse.AccessToken,
93+
ExpiresAt: c.clock.Now().UTC().Add(time.Duration(authResponse.ExpiresIn) * time.Second),
94+
}
95+
96+
return authResponse.AccessToken, nil
97+
}
98+
99+
func (c *KeycloakClient) getCachedToken() (token string, ok bool) {
100+
c.tokenMutex.RLock()
101+
defer c.tokenMutex.RUnlock()
102+
if c.clock.Now().Before(c.tokenInfo.ExpiresAt.Add(-tokenRefreshMargin)) {
103+
return c.tokenInfo.AccessToken, true
104+
}
105+
return "", false
106+
}
107+
108+
func (c *KeycloakClient) requestToken(ctx context.Context) (*authResponse, error) {
109+
data := url.Values{}
110+
data.Set("client_id", c.cfg.ClientID)
111+
data.Set("password", c.cfg.Password)
112+
data.Set("username", c.cfg.Username)
113+
data.Set("grant_type", "password")
114+
115+
authenticationURL := fmt.Sprintf("%s/realms/%s/protocol/openid-connect/token",
116+
c.cfg.AuthURL, c.cfg.KeycloakRealm)
117+
118+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, authenticationURL, strings.NewReader(data.Encode()))
119+
if err != nil {
120+
return nil, fmt.Errorf("failed to create authentication request: %w", err)
121+
}
122+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
123+
124+
resp, err := c.httpClient.Do(req)
125+
if err != nil {
126+
return nil, fmt.Errorf("failed to execute authentication request: %w", err)
127+
}
128+
defer func() { _ = resp.Body.Close() }()
129+
130+
if resp.StatusCode != http.StatusOK {
131+
respBody, err := io.ReadAll(resp.Body)
132+
if err != nil {
133+
return nil, fmt.Errorf("authentication request failed with status: %s: %w body: %s", resp.Status, err, string(respBody))
134+
}
135+
return nil, fmt.Errorf("authentication request failed with status: %s: %s", resp.Status, string(respBody))
136+
}
137+
138+
var authResp authResponse
139+
if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil {
140+
return nil, fmt.Errorf("failed to parse authentication response: %w", err)
141+
}
142+
143+
return &authResp, nil
144+
}

pkg/auth/auth_client_test.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
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+
"fmt"
10+
"net/http"
11+
"net/http/httptest"
12+
"sync/atomic"
13+
"testing"
14+
"time"
15+
16+
"github.com/stretchr/testify/assert"
17+
"github.com/stretchr/testify/require"
18+
)
19+
20+
func TestKeycloakClient_GetToken(t *testing.T) {
21+
tests := map[string]struct {
22+
responseBody string
23+
responseCode int
24+
wantErr bool
25+
wantToken string
26+
}{
27+
"successful token retrieval": {
28+
responseBody: `{"access_token": "test-token", "expires_in": 3600}`,
29+
responseCode: http.StatusOK,
30+
wantErr: false,
31+
wantToken: "test-token",
32+
},
33+
"failed authentication": {
34+
responseBody: `{}`,
35+
responseCode: http.StatusUnauthorized,
36+
wantErr: true,
37+
},
38+
}
39+
for name, tt := range tests {
40+
t.Run(name, func(t *testing.T) {
41+
var serverCallCount atomic.Int32
42+
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
43+
serverCallCount.Add(1)
44+
w.WriteHeader(tt.responseCode)
45+
_, err := w.Write([]byte(tt.responseBody))
46+
require.NoError(t, err)
47+
}))
48+
defer mockServer.Close()
49+
50+
client := NewKeycloakClient(http.DefaultClient, KeycloakConfig{
51+
AuthURL: mockServer.URL,
52+
// the other fields are also required in real scenario, but omit here for brevity
53+
})
54+
gotToken, err := client.GetToken(context.Background())
55+
assert.Greater(t, serverCallCount.Load(), int32(0), "server was not called")
56+
57+
if tt.wantErr {
58+
require.Error(t, err)
59+
} else {
60+
require.NoError(t, err)
61+
assert.Equal(t, tt.wantToken, gotToken)
62+
}
63+
})
64+
}
65+
}
66+
67+
type fakeClock struct {
68+
currentTime time.Time
69+
}
70+
71+
func (fc *fakeClock) Now() time.Time {
72+
return fc.currentTime
73+
}
74+
75+
func (fc *fakeClock) Advance(d time.Duration) {
76+
fc.currentTime = fc.currentTime.Add(d)
77+
}
78+
79+
func NewFakeClock(startTime time.Time) *fakeClock {
80+
return &fakeClock{currentTime: startTime}
81+
}
82+
83+
func TestKeycloakClient_GetToken_Refresh(t *testing.T) {
84+
tokenValidity := 60 * time.Second
85+
kcMockResponse := []byte(fmt.Sprintf(`{"access_token": "test-token", "expires_in": %d}`, int(tokenValidity.Seconds())))
86+
87+
tests := map[string]struct {
88+
responseBody string
89+
responseCode int
90+
requestAfter time.Duration
91+
wantServerCalled int
92+
wantToken string
93+
}{
94+
"token is cached": {
95+
requestAfter: tokenValidity - tokenRefreshMargin - time.Nanosecond,
96+
wantServerCalled: 1, // should be called only once due to caching
97+
wantToken: "test-token",
98+
},
99+
"token expiry handling": {
100+
requestAfter: tokenValidity - tokenRefreshMargin + time.Nanosecond,
101+
wantServerCalled: 2, // should be called twice due to expiry
102+
wantToken: "test-token",
103+
},
104+
}
105+
106+
for name, tc := range tests {
107+
t.Run(name, func(t *testing.T) {
108+
fakeClock := NewFakeClock(time.Now())
109+
110+
var serverCallCount atomic.Int32
111+
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
112+
serverCallCount.Add(1)
113+
w.WriteHeader(200)
114+
_, err := w.Write(kcMockResponse)
115+
require.NoError(t, err)
116+
}))
117+
defer mockServer.Close()
118+
119+
client := NewKeycloakClient(http.DefaultClient, KeycloakConfig{
120+
AuthURL: mockServer.URL,
121+
// the other fields are also required in real scenario, but omit here for brevity
122+
})
123+
client.clock = fakeClock
124+
125+
_, err := client.GetToken(context.Background())
126+
require.NoError(t, err)
127+
128+
fakeClock.Advance(tc.requestAfter)
129+
130+
gotToken, err := client.GetToken(context.Background()) // second call to test caching/refresh
131+
require.NoError(t, err)
132+
133+
assert.Equal(t, tc.wantServerCalled, int(serverCallCount.Load()), "unexpected number of server calls")
134+
assert.Equal(t, tc.wantToken, gotToken)
135+
})
136+
}
137+
}

0 commit comments

Comments
 (0)