Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .mockery.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
dir: "{{.InterfaceDir}}/mocks"
pkgname: "mocks"
structname: "{{.InterfaceName}}"
filename: "{{.InterfaceName}}.go"
all: true

packages:
github.com/greenbone/opensight-golang-libraries/pkg/notifications:
config:
recursive: true
8 changes: 5 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ GO-MOD-UPGRADE = go run github.com/oligot/go-mod-upgrade@latest
SWAG = github.com/swaggo/swag/cmd/swag@v1.16.2

INSTALL_GOMARKDOC = go install github.com/princjef/gomarkdoc/cmd/gomarkdoc@latest
INSTALL_MOCKERY = go install github.com/vektra/mockery/v2@v2.44.1
MOCKERY = github.com/vektra/mockery/v3@v3.5.1

OS="$(shell go env var GOOS | xargs)"

Expand Down Expand Up @@ -49,9 +49,11 @@ format: ## format and tidy
go fmt ./...

generate-code: ## create mocks and enums
@ echo "\033[36m Generate mocks and enums \033[0m"
@ echo "\033[36m Generate enums \033[0m"
go get github.com/abice/go-enum
go generate ./...
@ echo "\033[36m Generate mocks \033[0m"
go run $(MOCKERY) --log-level warn


.PHONY: lint
Expand Down Expand Up @@ -119,7 +121,7 @@ test-postgres:
.PHONY: generate_docs
generate_docs: check_tools
gomarkdoc -e --output '{{.Dir}}/README.md' \
--exclude-dirs .,./pkg/configReader/helper,./pkg/dbcrypt/config,./pkg/openSearch/openSearchClient/config \
--exclude-dirs .,./pkg/configReader/helper,./pkg/dbcrypt/config,./pkg/openSearch/openSearchClient/config,./pkg/notifications/mocks \
./pkg/...

check_tools:
Expand Down
70 changes: 59 additions & 11 deletions pkg/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,52 @@ Package auth provides a client to authenticate against a Keycloak server.

## Index

- [type ClientCredentials](<#ClientCredentials>)
- [type Clock](<#Clock>)
- [type Credentials](<#Credentials>)
- [type KeycloakClient](<#KeycloakClient>)
- [func NewKeycloakClient\(httpClient \*http.Client, cfg KeycloakConfig\) \*KeycloakClient](<#NewKeycloakClient>)
- [func NewKeycloakClient\(httpClient \*http.Client, cfg KeycloakConfig, credentials Credentials\) \*KeycloakClient](<#NewKeycloakClient>)
- [func \(c \*KeycloakClient\) GetToken\(ctx context.Context\) \(string, error\)](<#KeycloakClient.GetToken>)
- [type KeycloakConfig](<#KeycloakConfig>)
- [type ResourceOwnerCredentials](<#ResourceOwnerCredentials>)


<a name="ClientCredentials"></a>
## type [ClientCredentials](<https://github.com/greenbone/opensight-golang-libraries/blob/main/pkg/auth/auth_client.go#L53-L56>)

ClientCredentials to authenticate via \`Client credentials grant\` flow. Ref: https://www.keycloak.org/docs/latest/server_admin/index.html#_client_credentials_grant

```go
type ClientCredentials struct {
ClientID string
ClientSecret string
}
```

<a name="Clock"></a>
## type [Clock](<https://github.com/greenbone/opensight-golang-libraries/blob/main/pkg/auth/auth_client.go#L20-L22>)



```go
type Clock interface {
Now() time.Time
}
```

<a name="Credentials"></a>
## type [Credentials](<https://github.com/greenbone/opensight-golang-libraries/blob/main/pkg/auth/auth_client.go#L47-L49>)

Credentials holds the required credentials and determines the used auth type.

```go
type Credentials interface {
// contains filtered or unexported methods
}
```

<a name="KeycloakClient"></a>
## type [KeycloakClient](<https://github.com/greenbone/opensight-golang-libraries/blob/main/pkg/auth/auth_client.go#L42-L47>)
## type [KeycloakClient](<https://github.com/greenbone/opensight-golang-libraries/blob/main/pkg/auth/auth_client.go#L86-L94>)

KeycloakClient can be used to authenticate against a Keycloak server.

Expand All @@ -30,38 +68,48 @@ type KeycloakClient struct {
```

<a name="NewKeycloakClient"></a>
### func [NewKeycloakClient](<https://github.com/greenbone/opensight-golang-libraries/blob/main/pkg/auth/auth_client.go#L50>)
### func [NewKeycloakClient](<https://github.com/greenbone/opensight-golang-libraries/blob/main/pkg/auth/auth_client.go#L97>)

```go
func NewKeycloakClient(httpClient *http.Client, cfg KeycloakConfig) *KeycloakClient
func NewKeycloakClient(httpClient *http.Client, cfg KeycloakConfig, credentials Credentials) *KeycloakClient
```

NewKeycloakClient creates a new KeycloakClient.
NewKeycloakClient creates a new KeycloakClient. Passed [Credentials](<#Credentials>) determines the used auth type.

<a name="KeycloakClient.GetToken"></a>
### func \(\*KeycloakClient\) [GetToken](<https://github.com/greenbone/opensight-golang-libraries/blob/main/pkg/auth/auth_client.go#L60>)
### func \(\*KeycloakClient\) [GetToken](<https://github.com/greenbone/opensight-golang-libraries/blob/main/pkg/auth/auth_client.go#L107>)

```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
GetToken retrieves a valid access token. The token is cached and refreshed before expiry.

<a name="KeycloakConfig"></a>
## type [KeycloakConfig](<https://github.com/greenbone/opensight-golang-libraries/blob/main/pkg/auth/auth_client.go#L23-L29>)
## type [KeycloakConfig](<https://github.com/greenbone/opensight-golang-libraries/blob/main/pkg/auth/auth_client.go#L31-L34>)

KeycloakConfig holds the credentials and configuration details

```go
type KeycloakConfig struct {
ClientID string
Username string
Password string
AuthURL string
KeycloakRealm string
}
```

<a name="ResourceOwnerCredentials"></a>
## type [ResourceOwnerCredentials](<https://github.com/greenbone/opensight-golang-libraries/blob/main/pkg/auth/auth_client.go#L60-L64>)

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

```go
type ResourceOwnerCredentials struct {
ClientID string
Username string
Password string
}
```

Generated by [gomarkdoc](<https://github.com/princjef/gomarkdoc>)


Expand Down
76 changes: 54 additions & 22 deletions pkg/auth/auth_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,8 @@ const tokenRefreshMargin = 10 * time.Second

// KeycloakConfig holds the credentials and configuration details
type KeycloakConfig struct {
ClientID string
Username string
Password string //nolint:gosec
AuthURL string
KeycloakRealm string
AuthURL string
Realm string
}

type tokenInfo struct {
Expand All @@ -46,28 +43,67 @@ type authResponse struct {
ExpiresIn int `json:"expires_in"` // in seconds
}

// Credentials holds the required credentials and determines the used auth type.
type Credentials interface {
Comment thread
efernasier marked this conversation as resolved.
constructUrlValues() url.Values // unexported method to prevent external implementation
}

// ClientCredentials to authenticate via `Client credentials grant` flow.
// Ref: https://www.keycloak.org/docs/latest/server_admin/index.html#_client_credentials_grant
type ClientCredentials struct {
Comment thread
efernasier marked this conversation as resolved.
ClientID string
ClientSecret string
}

func (c ClientCredentials) constructUrlValues() url.Values {
urlValues := url.Values{}
urlValues.Set("grant_type", "client_credentials")
urlValues.Set("client_id", c.ClientID)
urlValues.Set("client_secret", c.ClientSecret)

return urlValues
}

// 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
type ResourceOwnerCredentials struct {
ClientID string
Username string
Password string
}

func (c ResourceOwnerCredentials) constructUrlValues() url.Values {
urlValues := url.Values{}
urlValues.Set("grant_type", "password")
urlValues.Set("client_id", c.ClientID)
urlValues.Set("username", c.Username)
urlValues.Set("password", c.Password)

return urlValues
}

// KeycloakClient can be used to authenticate against a Keycloak server.
type KeycloakClient struct {
httpClient *http.Client
cfg KeycloakConfig
tokenInfo tokenInfo
tokenMutex sync.RWMutex
httpClient *http.Client
cfg KeycloakConfig
credentials Credentials
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 {
// NewKeycloakClient creates a new KeycloakClient. Passed [Credentials] determines the used auth type.
func NewKeycloakClient(httpClient *http.Client, cfg KeycloakConfig, credentials Credentials) *KeycloakClient {
return &KeycloakClient{
httpClient: httpClient,
cfg: cfg,
clock: realClock{},
httpClient: httpClient,
cfg: cfg,
credentials: credentials,
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 {
Expand Down Expand Up @@ -106,14 +142,10 @@ func (c *KeycloakClient) getCachedToken() (token string, ok bool) {
}

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")
data := c.credentials.constructUrlValues()

authenticationURL := fmt.Sprintf("%s/realms/%s/protocol/openid-connect/token",
c.cfg.AuthURL, c.cfg.KeycloakRealm)
c.cfg.AuthURL, c.cfg.Realm)

req, err := http.NewRequestWithContext(ctx, http.MethodPost, authenticationURL, strings.NewReader(data.Encode()))
if err != nil {
Expand Down
58 changes: 47 additions & 11 deletions pkg/auth/auth_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"sync/atomic"
"testing"
"time"
Expand All @@ -19,21 +20,50 @@ import (

func TestKeycloakClient_GetToken(t *testing.T) {
tests := map[string]struct {
responseBody string
responseCode int
wantErr bool
wantToken string
credentials Credentials
responseBody string
responseCode int
wantUrlValues url.Values
wantErr bool
wantToken string
}{
"successful token retrieval": {
"successful token retrieval (client credentials)": {
credentials: ClientCredentials{ClientID: "test-client-id", ClientSecret: "test-client-secret"},

responseBody: `{"access_token": "test-token", "expires_in": 3600}`,
responseCode: http.StatusOK,
wantUrlValues: url.Values{
"grant_type": {"client_credentials"},
"client_id": {"test-client-id"},
"client_secret": {"test-client-secret"},
},
wantErr: false,
wantToken: "test-token",
},
"successful token retrieval (resource owner credentials)": {
credentials: ResourceOwnerCredentials{ClientID: "test-client-id", Username: "test-user", Password: "test-password"},

responseBody: `{"access_token": "test-token", "expires_in": 3600}`,
responseCode: http.StatusOK,
wantErr: false,
wantToken: "test-token",
wantUrlValues: url.Values{
"grant_type": {"password"},
"client_id": {"test-client-id"},
"username": {"test-user"},
"password": {"test-password"},
},
wantErr: false,
wantToken: "test-token",
},
"failed authentication": {
credentials: ClientCredentials{ClientID: "invalid-client-id", ClientSecret: "invalid-client-secret"},
responseBody: `{}`,
responseCode: http.StatusUnauthorized,
wantErr: true,
wantUrlValues: url.Values{
"grant_type": {"client_credentials"},
"client_id": {"invalid-client-id"},
"client_secret": {"invalid-client-secret"},
},
wantErr: true,
},
}
for name, tt := range tests {
Expand All @@ -43,16 +73,22 @@ func TestKeycloakClient_GetToken(t *testing.T) {
w http.ResponseWriter, r *http.Request,
) {
serverCallCount.Add(1)

// Verify required URL parameters are present
err := r.ParseForm()
require.NoError(t, err)
require.Equal(t, tt.wantUrlValues, r.Form)

w.WriteHeader(tt.responseCode)
_, err := w.Write([]byte(tt.responseBody))
_, 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
})
}, tt.credentials)
gotToken, err := client.GetToken(context.Background())
assert.Greater(t, serverCallCount.Load(), int32(0), "server was not called")

Expand Down Expand Up @@ -123,7 +159,7 @@ func TestKeycloakClient_GetToken_Refresh(t *testing.T) {
client := NewKeycloakClient(http.DefaultClient, KeycloakConfig{
AuthURL: mockServer.URL,
// the other fields are also required in real scenario, but omit here for brevity
})
}, ClientCredentials{})
client.clock = fakeClock

_, err := client.GetToken(context.Background())
Expand Down
Loading
Loading