diff --git a/.mockery.yaml b/.mockery.yaml new file mode 100644 index 0000000..d99e41f --- /dev/null +++ b/.mockery.yaml @@ -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 diff --git a/Makefile b/Makefile index 1c22287..d9e334d 100644 --- a/Makefile +++ b/Makefile @@ -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)" @@ -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 @@ -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: diff --git a/pkg/auth/README.md b/pkg/auth/README.md index dd5b420..531c3c5 100644 --- a/pkg/auth/README.md +++ b/pkg/auth/README.md @@ -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>) + +## type [ClientCredentials]() + +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 +} +``` + + +## type [Clock]() + + + +```go +type Clock interface { + Now() time.Time +} +``` + + +## type [Credentials]() + +Credentials holds the required credentials and determines the used auth type. + +```go +type Credentials interface { + // contains filtered or unexported methods +} +``` + -## type [KeycloakClient]() +## type [KeycloakClient]() KeycloakClient can be used to authenticate against a Keycloak server. @@ -30,38 +68,48 @@ type KeycloakClient struct { ``` -### func [NewKeycloakClient]() +### func [NewKeycloakClient]() ```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. -### func \(\*KeycloakClient\) [GetToken]() +### 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 +GetToken retrieves a valid access token. The token is cached and refreshed before expiry. -## type [KeycloakConfig]() +## type [KeycloakConfig]() KeycloakConfig holds the credentials and configuration details ```go type KeycloakConfig struct { - ClientID string - Username string - Password string AuthURL string KeycloakRealm string } ``` + +## type [ResourceOwnerCredentials]() + +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]() diff --git a/pkg/auth/auth_client.go b/pkg/auth/auth_client.go index c1c26d0..addefc8 100644 --- a/pkg/auth/auth_client.go +++ b/pkg/auth/auth_client.go @@ -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 { @@ -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 { + 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 { + 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 { @@ -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 { diff --git a/pkg/auth/auth_client_test.go b/pkg/auth/auth_client_test.go index 2eb1d22..59445e9 100644 --- a/pkg/auth/auth_client_test.go +++ b/pkg/auth/auth_client_test.go @@ -9,6 +9,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "net/url" "sync/atomic" "testing" "time" @@ -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 { @@ -43,8 +73,14 @@ 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() @@ -52,7 +88,7 @@ func TestKeycloakClient_GetToken(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 - }) + }, tt.credentials) gotToken, err := client.GetToken(context.Background()) assert.Greater(t, serverCallCount.Load(), int32(0), "server was not called") @@ -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()) diff --git a/pkg/notifications/README.md b/pkg/notifications/README.md index 46c8e42..c38ec9b 100644 --- a/pkg/notifications/README.md +++ b/pkg/notifications/README.md @@ -12,16 +12,44 @@ Package Notifications provides a client to communicate with the OpenSight Notifi ## Index +- [Variables](<#variables>) +- [type AuthClient](<#AuthClient>) - [type Client](<#Client>) - - [func NewClient\(httpClient \*http.Client, config Config, authCfg auth.KeycloakConfig\) \*Client](<#NewClient>) + - [func NewClient\(httpClient \*http.Client, config Config, authClient AuthClient\) \*Client](<#NewClient>) - [func \(c \*Client\) CreateNotification\(ctx context.Context, notification Notification\) error](<#Client.CreateNotification>) + - [func \(c \*Client\) RegisterOrigins\(ctx context.Context, serviceID string, origins \[\]Origin\) error](<#Client.RegisterOrigins>) - [type Config](<#Config>) - [type Level](<#Level>) - [type Notification](<#Notification>) +- [type Origin](<#Origin>) +## Variables + + + +```go +var AllowedLevels = []Level{ + LevelInfo, + LevelWarning, + LevelError, + LevelUrgent, +} +``` + + +## type [AuthClient]() + + + +```go +type AuthClient interface { + GetToken(ctx context.Context) (string, error) +} +``` + -## type [Client]() +## type [Client]() Client can be used to send notifications @@ -32,16 +60,16 @@ type Client struct { ``` -### func [NewClient]() +### func [NewClient]() ```go -func NewClient(httpClient *http.Client, config Config, authCfg auth.KeycloakConfig) *Client +func NewClient(httpClient *http.Client, config Config, authClient AuthClient) *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 @@ -49,8 +77,17 @@ 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\) [RegisterOrigins]() + +```go +func (c *Client) RegisterOrigins(ctx context.Context, serviceID string, origins []Origin) error +``` + + + -## type [Config]() +## type [Config]() Config configures the notification service client @@ -79,6 +116,7 @@ const ( LevelInfo Level = "info" LevelWarning Level = "warning" LevelError Level = "error" + LevelUrgent Level = "urgent" ) ``` @@ -101,6 +139,18 @@ type Notification struct { } ``` + +## type [Origin]() + + + +```go +type Origin struct { + Name string `json:"name"` + Class string `json:"class"` +} +``` + Generated by [gomarkdoc]() diff --git a/pkg/notifications/mocks/AuthClient.go b/pkg/notifications/mocks/AuthClient.go new file mode 100644 index 0000000..6d0618d --- /dev/null +++ b/pkg/notifications/mocks/AuthClient.go @@ -0,0 +1,98 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + + mock "github.com/stretchr/testify/mock" +) + +// NewAuthClient creates a new instance of AuthClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewAuthClient(t interface { + mock.TestingT + Cleanup(func()) +}) *AuthClient { + mock := &AuthClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// AuthClient is an autogenerated mock type for the AuthClient type +type AuthClient struct { + mock.Mock +} + +type AuthClient_Expecter struct { + mock *mock.Mock +} + +func (_m *AuthClient) EXPECT() *AuthClient_Expecter { + return &AuthClient_Expecter{mock: &_m.Mock} +} + +// GetToken provides a mock function for the type AuthClient +func (_mock *AuthClient) GetToken(ctx context.Context) (string, error) { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetToken") + } + + var r0 string + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context) (string, error)); ok { + return returnFunc(ctx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context) string); ok { + r0 = returnFunc(ctx) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = returnFunc(ctx) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// AuthClient_GetToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetToken' +type AuthClient_GetToken_Call struct { + *mock.Call +} + +// GetToken is a helper method to define mock.On call +// - ctx context.Context +func (_e *AuthClient_Expecter) GetToken(ctx interface{}) *AuthClient_GetToken_Call { + return &AuthClient_GetToken_Call{Call: _e.mock.On("GetToken", ctx)} +} + +func (_c *AuthClient_GetToken_Call) Run(run func(ctx context.Context)) *AuthClient_GetToken_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *AuthClient_GetToken_Call) Return(s string, err error) *AuthClient_GetToken_Call { + _c.Call.Return(s, err) + return _c +} + +func (_c *AuthClient_GetToken_Call) RunAndReturn(run func(ctx context.Context) (string, error)) *AuthClient_GetToken_Call { + _c.Call.Return(run) + return _c +} diff --git a/pkg/notifications/notification.go b/pkg/notifications/notification.go index 1491a50..6ad48b7 100644 --- a/pkg/notifications/notification.go +++ b/pkg/notifications/notification.go @@ -15,7 +15,6 @@ import ( "net/url" "time" - "github.com/greenbone/opensight-golang-libraries/pkg/auth" "github.com/greenbone/opensight-golang-libraries/pkg/retryableRequest" ) @@ -28,7 +27,7 @@ const ( // Client can be used to send notifications type Client struct { httpClient *http.Client - authClient *auth.KeycloakClient + authClient AuthClient notificationServiceAddress string maxRetries int retryWaitMin time.Duration @@ -43,11 +42,13 @@ type Config struct { RetryWaitMax time.Duration } +type AuthClient interface { + GetToken(ctx context.Context) (string, error) +} + // 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, authCfg auth.KeycloakConfig) *Client { - authClient := auth.NewKeycloakClient(httpClient, authCfg) - +func NewClient(httpClient *http.Client, config Config, authClient AuthClient) *Client { return &Client{ httpClient: httpClient, authClient: authClient, diff --git a/pkg/notifications/notification_test.go b/pkg/notifications/notification_test.go index dbe6937..cde3268 100644 --- a/pkg/notifications/notification_test.go +++ b/pkg/notifications/notification_test.go @@ -14,16 +14,17 @@ import ( "testing" "time" - "github.com/greenbone/opensight-golang-libraries/pkg/auth" + "github.com/greenbone/opensight-golang-libraries/pkg/notifications/mocks" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) type serverErrors struct { // set at most one of the fields to true - fatalFail bool - retryableFail bool - authenticationFail bool - unexpectedResponse bool + fatalFail bool + retryableFail bool + authClientFail bool + persistentInternalError bool } const checkForCurrentTimestamp = "marker to check for current timestamp" @@ -94,14 +95,14 @@ func TestClient_CreateNotification(t *testing.T) { { name: "client fails on authentication error", notification: notification, - serverErrors: serverErrors{authenticationFail: true}, + serverErrors: serverErrors{authClientFail: true}, wantNotificationSent: wantNotification, wantErr: true, }, { name: "sending notification fails after maximum number of retries", notification: notification, - serverErrors: serverErrors{unexpectedResponse: true}, + serverErrors: serverErrors{persistentInternalError: true}, wantNotificationSent: wantNotification, wantErr: true, }, @@ -110,9 +111,6 @@ func TestClient_CreateNotification(t *testing.T) { t.Run(tt.name, func(t *testing.T) { var serverCallCount atomic.Int32 - mockAuthServer := setupMockAuthServer(t, tt.serverErrors.authenticationFail) - defer mockAuthServer.Close() - mockNotificationServer := setupMockNotificationServer(t, &serverCallCount, tt.wantNotificationSent, tt.serverErrors) defer mockNotificationServer.Close() @@ -124,17 +122,17 @@ func TestClient_CreateNotification(t *testing.T) { RetryWaitMax: time.Second, } - authentication := auth.KeycloakConfig{ - ClientID: "client_id", - Username: "username", - Password: "password", - AuthURL: mockAuthServer.URL, + authClient := mocks.NewAuthClient(t) + if tt.serverErrors.authClientFail { + authClient.EXPECT().GetToken(mock.Anything).Return("", assert.AnError).Times(1) + } else { + authClient.EXPECT().GetToken(mock.Anything).Return("mock-token", nil).Times(1) } - client := NewClient(http.DefaultClient, config, authentication) + client := NewClient(http.DefaultClient, config, authClient) err := client.CreateNotification(context.Background(), tt.notification) - if !tt.serverErrors.authenticationFail { + if !tt.serverErrors.authClientFail { assert.Greater(t, serverCallCount.Load(), int32(0), "notification server was not called") } @@ -147,18 +145,6 @@ func TestClient_CreateNotification(t *testing.T) { } } -func setupMockAuthServer(t *testing.T, failAuth bool) *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if failAuth { - w.WriteHeader(http.StatusUnauthorized) - return - } - w.Write([]byte(`{"access_token": "mock-token", "expires_in": 3600}`)) - err := json.NewEncoder(w).Encode(map[string]any{}) - require.NoError(t, err) - })) -} - func setupMockNotificationServer(t *testing.T, serverCallCount *atomic.Int32, wantNotification notificationModel, errors serverErrors, ) *httptest.Server { @@ -204,7 +190,7 @@ func setupMockNotificationServer(t *testing.T, serverCallCount *atomic.Int32, return } } - if errors.unexpectedResponse { + if errors.persistentInternalError { w.WriteHeader(http.StatusInternalServerError) return } diff --git a/pkg/swagger/README.md b/pkg/swagger/README.md index 74c73de..61b3b2e 100644 --- a/pkg/swagger/README.md +++ b/pkg/swagger/README.md @@ -12,6 +12,7 @@ To use the latest swagger-ui (by using github.com/swaggo/files/v2) we needed to ## Sources used To compile the new package we took the gin-echo package (https://github.com/swaggo/echo-swagger/blob/master/swagger.go) as a reference and added the gin based functionality. +*Note:* It is not yet compatible with `Authorization Code + PKCE` login flow. If that becomes necessary see open PR of related package: https://github.com/swaggo/echo-swagger/pull/100 as a start. ## Usage @@ -50,18 +51,25 @@ import "github.com/greenbone/opensight-golang-libraries/pkg/swagger" ## Index -- [Variables](<#variables>) -- [func DeepLinking\(deepLinking bool\) func\(\*Config\)](<#DeepLinking>) -- [func DocExpansion\(docExpansion string\) func\(\*Config\)](<#DocExpansion>) -- [func DomID\(domID string\) func\(\*Config\)](<#DomID>) -- [func GinWrapHandler\(options ...func\(\*Config\)\) gin.HandlerFunc](<#GinWrapHandler>) -- [func InstanceName\(instanceName string\) func\(\*Config\)](<#InstanceName>) -- [func OAuth\(config \*OAuthConfig\) func\(\*Config\)](<#OAuth>) -- [func PersistAuthorization\(persistAuthorization bool\) func\(\*Config\)](<#PersistAuthorization>) -- [func SyntaxHighlight\(syntaxHighlight bool\) func\(\*Config\)](<#SyntaxHighlight>) -- [func URL\(url string\) func\(\*Config\)](<#URL>) -- [type Config](<#Config>) -- [type OAuthConfig](<#OAuthConfig>) +- [ginSwagger](#ginswagger) + - [Reason for implementation](#reason-for-implementation) + - [Sources used](#sources-used) + - [Usage](#usage) +- [License](#license) +- [ginSwagger](#ginswagger-1) + - [Index](#index) + - [Variables](#variables) + - [func DeepLinking](#func-deeplinking) + - [func DocExpansion](#func-docexpansion) + - [func DomID](#func-domid) + - [func GinWrapHandler](#func-ginwraphandler) + - [func InstanceName](#func-instancename) + - [func OAuth](#func-oauth) + - [func PersistAuthorization](#func-persistauthorization) + - [func SyntaxHighlight](#func-syntaxhighlight) + - [func URL](#func-url) + - [type Config](#type-config) + - [type OAuthConfig](#type-oauthconfig) ## Variables diff --git a/pkg/swagger/ginSwagger.go b/pkg/swagger/ginSwagger.go index 89d6171..6faf53a 100644 --- a/pkg/swagger/ginSwagger.go +++ b/pkg/swagger/ginSwagger.go @@ -9,6 +9,7 @@ import ( "net/http" "path/filepath" "regexp" + "strings" "github.com/gin-gonic/gin" swaggerFiles "github.com/swaggo/files/v2" @@ -26,6 +27,7 @@ type Config struct { PersistAuthorization bool SyntaxHighlight bool OAuth *OAuthConfig + CSPConnectSrc []string } type OAuthConfig struct { @@ -82,6 +84,15 @@ func OAuth(config *OAuthConfig) func(*Config) { } } +// CSPConnectSrc adds URLs to the Content Security Policy's connect-src directive. +// This is necessary to allow the Swagger UI to communicate with Keycloak for authentication. +// Note: The connect-src directive expects only scheme://host:port without paths +func CSPConnectSrc(urls ...string) func(*Config) { + return func(c *Config) { + c.CSPConnectSrc = append(c.CSPConnectSrc, urls...) + } +} + func newConfig(configFns ...func(*Config)) *Config { config := Config{ URLs: []string{"doc.json", "doc.yaml"}, @@ -106,13 +117,23 @@ var WrapHandler = GinWrapHandler() func GinWrapHandler(options ...func(*Config)) gin.HandlerFunc { config := newConfig(options...) index, _ := template.New("swagger_index.html").Parse(indexTemplate) - re := regexp.MustCompile(`^(.*/)([^?].*)?[?|.]*$`) + re := regexp.MustCompile(`^(.*/)([^?]*)?(\?.*)?$`) // e.g. /api/users?sort=name => [ "api/", "users", "?sort=name" ] return func(c *gin.Context) { // Set security headers to protect against ClickJacking and XSS c.Header("X-Frame-Options", "DENY") c.Header("X-XSS-Protection", "1; mode=block") - c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; frame-ancestors 'none'") + csp := []string{ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net", + "frame-ancestors 'none'", + } + if len(config.CSPConnectSrc) > 0 { + sources := append(config.CSPConnectSrc, "'self'") + connectSrc := "connect-src " + strings.Join(sources, " ") + csp = append(csp, connectSrc) + } + c.Header("Content-Security-Policy", strings.Join(csp, "; ")) c.Header("X-Content-Type-Options", "nosniff") if c.Request.Method != http.MethodGet {