diff --git a/api/api/v1/security_types.go b/api/api/v1/security_types.go index 4c8f4fc13..29f161de2 100644 --- a/api/api/v1/security_types.go +++ b/api/api/v1/security_types.go @@ -4,6 +4,15 @@ package v1 +// TokenRequestMethod defines the token endpoint authentication method (RFC 7591). +// +kubebuilder:validation:Enum=client_secret_basic;client_secret_post +type TokenRequestMethod string + +const ( + TokenRequestClientSecretBasic TokenRequestMethod = "client_secret_basic" + TokenRequestClientSecretPost TokenRequestMethod = "client_secret_post" +) + // Security defines the security configuration for the Rover // Security is optional, but if provided, exactly one of m2m or h2m must be set type Security struct { @@ -64,10 +73,9 @@ type ExternalIdentityProvider struct { // +kubebuilder:validation:Format=uri TokenEndpoint string `json:"tokenEndpoint"` - // TokenRequest is the type of token request, "body" or "header" + // TokenRequest configures the token endpoint authentication method (RFC 7591) // +kubebuilder:validation:Optional - // +kubebuilder:validation:Enum=body;header - TokenRequest string `json:"tokenRequest,omitempty"` + TokenRequest TokenRequestMethod `json:"tokenRequest,omitempty"` // GrantType defines the OAuth2 grant type to use for the token request // +kubebuilder:validation:Optional diff --git a/api/config/crd/bases/api.cp.ei.telekom.de_apiexposures.yaml b/api/config/crd/bases/api.cp.ei.telekom.de_apiexposures.yaml index 3b27fe79c..399839d01 100644 --- a/api/config/crd/bases/api.cp.ei.telekom.de_apiexposures.yaml +++ b/api/config/crd/bases/api.cp.ei.telekom.de_apiexposures.yaml @@ -154,11 +154,11 @@ spec: format: uri type: string tokenRequest: - description: TokenRequest is the type of token request, - "body" or "header" + description: TokenRequest configures the token endpoint + authentication method (RFC 7591) enum: - - body - - header + - client_secret_basic + - client_secret_post type: string required: - tokenEndpoint diff --git a/api/internal/controller/apiexposure_controller_test.go b/api/internal/controller/apiexposure_controller_test.go index 1070ade09..187e1ef22 100644 --- a/api/internal/controller/apiexposure_controller_test.go +++ b/api/internal/controller/apiexposure_controller_test.go @@ -6,6 +6,7 @@ package controller import ( "fmt" + "github.com/telekom/controlplane/api/internal/handler/util" applicationapi "github.com/telekom/controlplane/application/api/v1" @@ -156,7 +157,7 @@ func NewApiExposure(apiBasePath, zoneName string, appName string) *apiv1.ApiExpo M2M: &apiapi.Machine2MachineAuthentication{ ExternalIDP: &apiapi.ExternalIdentityProvider{ TokenEndpoint: "https://example.com/token", - TokenRequest: "header", + TokenRequest: apiapi.TokenRequestClientSecretBasic, GrantType: "client_credentials", Client: &apiapi.OAuth2ClientCredentials{ ClientId: "client-id", @@ -392,14 +393,14 @@ var _ = Describe("ApiExposure Controller", Ordered, func() { By("Creating the second APIExposure resource") thirdApiExposure.Spec.Security.M2M = &apiv1.Machine2MachineAuthentication{ ExternalIDP: &apiv1.ExternalIdentityProvider{ - TokenRequest: "sky", + TokenRequest: apiv1.TokenRequestMethod("sky"), }, Scopes: []string{"team:scope", "api:scope"}, } err := k8sClient.Create(ctx, thirdApiExposure) Expect(err).To(HaveOccurred()) Expect(apierrors.IsInvalid(err)).To(BeTrue()) - Expect(err.Error()).To(ContainSubstring("Unsupported value: \"sky\": supported values: \"body\", \"header\"")) + Expect(err.Error()).To(ContainSubstring("Unsupported value: \"sky\": supported values: \"client_secret_basic\", \"client_secret_post\"")) thirdApiExposure.Spec.Security.M2M.ExternalIDP.GrantType = "not_a_valid_grant_type" err = k8sClient.Create(ctx, thirdApiExposure) @@ -413,7 +414,7 @@ var _ = Describe("ApiExposure Controller", Ordered, func() { thirdApiExposure.Spec.Security.M2M = &apiv1.Machine2MachineAuthentication{ ExternalIDP: &apiv1.ExternalIdentityProvider{ TokenEndpoint: "https://example.com/token", - TokenRequest: "header", + TokenRequest: apiv1.TokenRequestClientSecretBasic, GrantType: "client_credentials", Client: &apiv1.OAuth2ClientCredentials{ ClientId: "team", @@ -439,7 +440,7 @@ var _ = Describe("ApiExposure Controller", Ordered, func() { g.Expect(route.Spec.Security.M2M.Scopes).To(Equal([]string{"team:scope", "api:scope"})) g.Expect(route.Spec.Security.M2M.ExternalIDP.TokenEndpoint).To(Equal("https://example.com/token")) - g.Expect(route.Spec.Security.M2M.ExternalIDP.TokenRequest).To(Equal("header")) + g.Expect(route.Spec.Security.M2M.ExternalIDP.TokenRequest).To(Equal(gatewayapi.TokenRequestClientSecretBasic)) g.Expect(route.Spec.Security.M2M.ExternalIDP.GrantType).To(Equal("client_credentials")) }, timeout, interval).Should(Succeed()) }) diff --git a/api/internal/controller/apisubscription_controller_ratelimiting_test.go b/api/internal/controller/apisubscription_controller_ratelimiting_test.go index a9b53a56a..1cc331992 100644 --- a/api/internal/controller/apisubscription_controller_ratelimiting_test.go +++ b/api/internal/controller/apisubscription_controller_ratelimiting_test.go @@ -83,7 +83,7 @@ func NewApiExposureWithRateLimit(apiBasePath, zoneName, consumerClientId string, M2M: &apiapi.Machine2MachineAuthentication{ ExternalIDP: &apiapi.ExternalIdentityProvider{ TokenEndpoint: "https://example.com/token", - TokenRequest: "header", + TokenRequest: apiapi.TokenRequestClientSecretBasic, GrantType: "client_credentials", Client: &apiapi.OAuth2ClientCredentials{ ClientId: "client-id", diff --git a/api/internal/handler/util/route_util.go b/api/internal/handler/util/route_util.go index f4d85a347..ea6cc7499 100644 --- a/api/internal/handler/util/route_util.go +++ b/api/internal/handler/util/route_util.go @@ -520,7 +520,7 @@ func mapSecurity(apiSecurity *apiapi.Security) *gatewayapi.Security { if apiSecurity.M2M.ExternalIDP != nil { security.M2M.ExternalIDP = &gatewayapi.ExternalIdentityProvider{ TokenEndpoint: apiSecurity.M2M.ExternalIDP.TokenEndpoint, - TokenRequest: apiSecurity.M2M.ExternalIDP.TokenRequest, + TokenRequest: gatewayapi.TokenRequestMethod(apiSecurity.M2M.ExternalIDP.TokenRequest), GrantType: apiSecurity.M2M.ExternalIDP.GrantType, } if apiSecurity.M2M.ExternalIDP.Basic != nil { diff --git a/discovery-server/internal/mapper/apiexposure/out.go b/discovery-server/internal/mapper/apiexposure/out.go index e84e9f551..bac3cf23c 100644 --- a/discovery-server/internal/mapper/apiexposure/out.go +++ b/discovery-server/internal/mapper/apiexposure/out.go @@ -13,6 +13,18 @@ import ( "github.com/telekom/controlplane/discovery-server/internal/mapper/status" ) +// tokenRequestCRDToAPI converts CRD tokenRequest values to discovery-server API enum values. +func tokenRequestCRDToAPI(value apiv1.TokenRequestMethod) api.OAuth2TokenRequest { + switch value { + case apiv1.TokenRequestClientSecretBasic: + return api.Header + case apiv1.TokenRequestClientSecretPost: + return api.Body + default: + return api.OAuth2TokenRequest(value) + } +} + // MapResponse maps an ApiExposure CRD to an ApiExposureResponse. func MapResponse(in *apiv1.ApiExposure) api.ApiExposureResponse { resp := api.ApiExposureResponse{ @@ -81,7 +93,7 @@ func mapSecurity(in *apiv1.ApiExposure, out *api.ApiExposureResponse) { if m2m.ExternalIDP != nil { oauth2 := api.OAuth2{ TokenEndpoint: m2m.ExternalIDP.TokenEndpoint, - TokenRequest: api.OAuth2TokenRequest(m2m.ExternalIDP.TokenRequest), + TokenRequest: tokenRequestCRDToAPI(m2m.ExternalIDP.TokenRequest), GrantType: m2m.ExternalIDP.GrantType, } diff --git a/discovery-server/internal/mapper/apiexposure/out_test.go b/discovery-server/internal/mapper/apiexposure/out_test.go index 35f644546..00e85084b 100644 --- a/discovery-server/internal/mapper/apiexposure/out_test.go +++ b/discovery-server/internal/mapper/apiexposure/out_test.go @@ -141,7 +141,7 @@ func TestMapSecurity(t *testing.T) { { name: "external idp oauth2", setup: func(in *apiv1.ApiExposure) { - in.Spec.Security = &apiv1.Security{M2M: &apiv1.Machine2MachineAuthentication{ExternalIDP: &apiv1.ExternalIdentityProvider{TokenEndpoint: "https://idp/token", TokenRequest: "body", GrantType: "client_credentials", Client: &apiv1.OAuth2ClientCredentials{ClientId: "cid", ClientSecret: "sec"}}, Scopes: []string{"s1"}}} + in.Spec.Security = &apiv1.Security{M2M: &apiv1.Machine2MachineAuthentication{ExternalIDP: &apiv1.ExternalIdentityProvider{TokenEndpoint: "https://idp/token", TokenRequest: apiv1.TokenRequestClientSecretPost, GrantType: "client_credentials", Client: &apiv1.OAuth2ClientCredentials{ClientId: "cid", ClientSecret: "sec"}}, Scopes: []string{"s1"}}} }, assert: func(t *testing.T, out api.ApiExposureResponse) { t.Helper() @@ -157,7 +157,7 @@ func TestMapSecurity(t *testing.T) { { name: "external idp oauth2 with basic credentials", setup: func(in *apiv1.ApiExposure) { - in.Spec.Security = &apiv1.Security{M2M: &apiv1.Machine2MachineAuthentication{ExternalIDP: &apiv1.ExternalIdentityProvider{TokenEndpoint: "https://idp/token", TokenRequest: "header", GrantType: "password", Basic: &apiv1.BasicAuthCredentials{Username: "bu", Password: "bp"}}}} + in.Spec.Security = &apiv1.Security{M2M: &apiv1.Machine2MachineAuthentication{ExternalIDP: &apiv1.ExternalIdentityProvider{TokenEndpoint: "https://idp/token", TokenRequest: apiv1.TokenRequestClientSecretBasic, GrantType: "password", Basic: &apiv1.BasicAuthCredentials{Username: "bu", Password: "bp"}}}} }, assert: func(t *testing.T, out api.ApiExposureResponse) { t.Helper() diff --git a/gateway/api/v1/security_types.go b/gateway/api/v1/security_types.go index ad24fd018..f9800233a 100644 --- a/gateway/api/v1/security_types.go +++ b/gateway/api/v1/security_types.go @@ -4,6 +4,15 @@ package v1 +// TokenRequestMethod defines the token endpoint authentication method (RFC 7591). +// +kubebuilder:validation:Enum=client_secret_basic;client_secret_post +type TokenRequestMethod string + +const ( + TokenRequestClientSecretBasic TokenRequestMethod = "client_secret_basic" + TokenRequestClientSecretPost TokenRequestMethod = "client_secret_post" +) + type Security struct { // DisableAccessControl disable the ACL mechanism for this route // +kubebuilder:validation:Optional @@ -112,10 +121,9 @@ type ExternalIdentityProvider struct { // +kubebuilder:validation:Format=uri TokenEndpoint string `json:"tokenEndpoint"` - // TokenRequest is the type of token request, "body" or "header" + // TokenRequest configures the token endpoint authentication method (RFC 7591) // +kubebuilder:validation:Optional - // +kubebuilder:validation:Enum=body;header - TokenRequest string `json:"tokenRequest,omitempty"` + TokenRequest TokenRequestMethod `json:"tokenRequest,omitempty"` // GrantType is the grant type for the external IDP authentication // +kubebuilder:validation:Optional // +kubebuilder:validation:Enum=client_credentials;authorization_code;password diff --git a/gateway/config/crd/bases/gateway.cp.ei.telekom.de_routes.yaml b/gateway/config/crd/bases/gateway.cp.ei.telekom.de_routes.yaml index 0f2d49955..646c7370a 100644 --- a/gateway/config/crd/bases/gateway.cp.ei.telekom.de_routes.yaml +++ b/gateway/config/crd/bases/gateway.cp.ei.telekom.de_routes.yaml @@ -197,11 +197,11 @@ spec: format: uri type: string tokenRequest: - description: TokenRequest is the type of token request, - "body" or "header" + description: TokenRequest configures the token endpoint + authentication method (RFC 7591) enum: - - body - - header + - client_secret_basic + - client_secret_post type: string required: - tokenEndpoint @@ -354,11 +354,11 @@ spec: format: uri type: string tokenRequest: - description: TokenRequest is the type of token - request, "body" or "header" + description: TokenRequest configures the token + endpoint authentication method (RFC 7591) enum: - - body - - header + - client_secret_basic + - client_secret_post type: string required: - tokenEndpoint diff --git a/gateway/internal/controller/route_controller_test.go b/gateway/internal/controller/route_controller_test.go index e582e0f4e..01d111410 100644 --- a/gateway/internal/controller/route_controller_test.go +++ b/gateway/internal/controller/route_controller_test.go @@ -144,11 +144,11 @@ var _ = Describe("Route Controller", Ordered, func() { }) It("should not accept a Route with TokenRequest=\"sky\"", func() { By("Creating the Route with TokenRequest=\"sky\"") - route.Spec.Security.M2M.ExternalIDP.TokenRequest = "sky" + route.Spec.Security.M2M.ExternalIDP.TokenRequest = gatewayv1.TokenRequestMethod("sky") err := k8sClient.Create(ctx, route) Expect(err).To(HaveOccurred()) Expect(apierrors.IsInvalid(err)).To(BeTrue()) - Expect(err.Error()).To(ContainSubstring("spec.security.m2m.externalIDP.tokenRequest: Unsupported value: \"sky\": supported values: \"body\", \"header\"")) + Expect(err.Error()).To(ContainSubstring("spec.security.m2m.externalIDP.tokenRequest: Unsupported value: \"sky\": supported values: \"client_secret_basic\", \"client_secret_post\"")) }) It("should not accept a Route with GrantType=\"not_required\"", func() { diff --git a/gateway/internal/features/builder_external_idp_test.go b/gateway/internal/features/builder_external_idp_test.go index 2e291caa6..3445bca88 100644 --- a/gateway/internal/features/builder_external_idp_test.go +++ b/gateway/internal/features/builder_external_idp_test.go @@ -215,7 +215,7 @@ func externalIDPProviderRouteOAuth() *gatewayv1.Route { M2M: &gatewayv1.Machine2MachineAuthentication{ ExternalIDP: &gatewayv1.ExternalIdentityProvider{ TokenEndpoint: "https://example.com/tokenEndpoint", - TokenRequest: "header", + TokenRequest: gatewayv1.TokenRequestClientSecretBasic, GrantType: "client_credentials", Client: &gatewayv1.OAuth2ClientCredentials{ ClientId: "gateway", @@ -243,7 +243,7 @@ func externalIDPProviderRouteBasic() *gatewayv1.Route { M2M: &gatewayv1.Machine2MachineAuthentication{ ExternalIDP: &gatewayv1.ExternalIdentityProvider{ TokenEndpoint: "https://example.com/tokenEndpoint", - TokenRequest: "header", + TokenRequest: gatewayv1.TokenRequestClientSecretBasic, GrantType: "password", Basic: &gatewayv1.BasicAuthCredentials{ Username: "user", @@ -271,7 +271,7 @@ func externalIDPProviderRouteOAuthJwt() *gatewayv1.Route { M2M: &gatewayv1.Machine2MachineAuthentication{ ExternalIDP: &gatewayv1.ExternalIdentityProvider{ TokenEndpoint: "https://example.com/tokenEndpoint", - TokenRequest: "header", + TokenRequest: gatewayv1.TokenRequestClientSecretBasic, GrantType: "client_credentials", Client: &gatewayv1.OAuth2ClientCredentials{ ClientId: "ClientId", diff --git a/gateway/internal/features/feature/external_idp.go b/gateway/internal/features/feature/external_idp.go index fb7393f3b..a94b26260 100644 --- a/gateway/internal/features/feature/external_idp.go +++ b/gateway/internal/features/feature/external_idp.go @@ -6,6 +6,7 @@ package feature import ( "context" + "fmt" "strings" "github.com/pkg/errors" @@ -146,7 +147,11 @@ func extendOauth(ctx context.Context, in plugin.OauthCredentials, providerSettin in.Scopes = strings.Join(scopes, " ") } - in.TokenRequest = providerSettings.TokenRequest + tokenRequest, err := tokenRequestToJumper(providerSettings.TokenRequest) + if err != nil { + return in, err + } + in.TokenRequest = tokenRequest in.GrantType = providerSettings.GrantType return in, nil @@ -182,3 +187,15 @@ func extendBasic(ctx context.Context, in plugin.OauthCredentials, providerSettin return in, nil } + +// tokenRequestToJumper converts CRD tokenRequest values to the values expected by the Jumper plugin. +func tokenRequestToJumper(value gatewayv1.TokenRequestMethod) (string, error) { + switch value { + case gatewayv1.TokenRequestClientSecretBasic: + return "header", nil + case gatewayv1.TokenRequestClientSecretPost: + return "body", nil + default: + return "", fmt.Errorf("unsupported tokenRequest value %q", value) + } +} diff --git a/rover-ctl/pkg/handlers/v0/rover.go b/rover-ctl/pkg/handlers/v0/rover.go index 5c5fabc77..19ca7cb62 100644 --- a/rover-ctl/pkg/handlers/v0/rover.go +++ b/rover-ctl/pkg/handlers/v0/rover.go @@ -7,6 +7,7 @@ package v0 import ( "context" "maps" + "strings" "github.com/pkg/errors" "github.com/telekom/controlplane/rover-ctl/pkg/handlers/common" @@ -65,10 +66,66 @@ func PatchRoverRequest(ctx context.Context, obj types.Object) error { } } + PatchAuthentication(spec) + obj.SetContent(spec) return nil } +// PatchAuthentication restructures spec.authentication.m2m.clientAuthMethod +// into spec.authentication.clientAuthMethod for the rover-server API format. +// It also normalizes "BODY"/"body" to "POST" since the server schema only accepts +// NONE, POST, BASIC. +func PatchAuthentication(spec map[string]any) { + auth, exists := spec["authentication"] + if !exists { + return + } + authMap, ok := auth.(map[string]any) + if !ok { + return + } + + m2m, exists := authMap["m2m"] + if !exists { + return + } + m2mMap, ok := m2m.(map[string]any) + if !ok { + return + } + + clientAuthMethod, exists := m2mMap["clientAuthMethod"] + if !exists { + return + } + + spec["authentication"] = map[string]any{ + "clientAuthMethod": normalizeClientAuthMethod(clientAuthMethod), + } +} + +// normalizeClientAuthMethod maps user-friendly aliases to the API enum values. +// "BODY"/"body" is treated as "POST" per RFC 6749. +func normalizeClientAuthMethod(value any) any { + s, ok := value.(string) + if !ok { + return value + } + switch strings.ToUpper(s) { + case "BODY": + return "POST" + case "BASIC": + return "BASIC" + case "NONE": + return "NONE" + case "POST": + return "POST" + default: + return value + } +} + func PatchExposures(exposures []any) []map[string]any { if len(exposures) == 0 { return nil diff --git a/rover-ctl/pkg/handlers/v0/rover_test.go b/rover-ctl/pkg/handlers/v0/rover_test.go index b157783ec..047c32b5a 100644 --- a/rover-ctl/pkg/handlers/v0/rover_test.go +++ b/rover-ctl/pkg/handlers/v0/rover_test.go @@ -381,6 +381,146 @@ var _ = Describe("Rover Handler", func() { }) }) + Describe("PatchAuthentication", func() { + It("should normalize 'basic' to 'BASIC' in authentication.clientAuthMethod", func() { + obj := &types.UnstructuredObject{ + Content: map[string]any{ + "spec": map[string]any{ + "authentication": map[string]any{ + "m2m": map[string]any{ + "clientAuthMethod": "basic", + }, + }, + }, + }, + } + + err := v0.PatchRoverRequest(context.Background(), obj) + Expect(err).NotTo(HaveOccurred()) + + content := obj.GetContent() + auth, ok := content["authentication"].(map[string]any) + Expect(ok).To(BeTrue()) + Expect(auth["clientAuthMethod"]).To(Equal("BASIC")) + }) + + It("should normalize 'body' to 'POST' in authentication.clientAuthMethod", func() { + obj := &types.UnstructuredObject{ + Content: map[string]any{ + "spec": map[string]any{ + "authentication": map[string]any{ + "m2m": map[string]any{ + "clientAuthMethod": "body", + }, + }, + }, + }, + } + + err := v0.PatchRoverRequest(context.Background(), obj) + Expect(err).NotTo(HaveOccurred()) + + content := obj.GetContent() + auth, ok := content["authentication"].(map[string]any) + Expect(ok).To(BeTrue()) + Expect(auth["clientAuthMethod"]).To(Equal("POST")) + }) + + It("should normalize 'BODY' to 'POST' in authentication.clientAuthMethod", func() { + obj := &types.UnstructuredObject{ + Content: map[string]any{ + "spec": map[string]any{ + "authentication": map[string]any{ + "m2m": map[string]any{ + "clientAuthMethod": "BODY", + }, + }, + }, + }, + } + + err := v0.PatchRoverRequest(context.Background(), obj) + Expect(err).NotTo(HaveOccurred()) + + content := obj.GetContent() + auth, ok := content["authentication"].(map[string]any) + Expect(ok).To(BeTrue()) + Expect(auth["clientAuthMethod"]).To(Equal("POST")) + }) + + It("should not add authentication when it is missing", func() { + obj := &types.UnstructuredObject{ + Content: map[string]any{ + "spec": map[string]any{}, + }, + } + + err := v0.PatchRoverRequest(context.Background(), obj) + Expect(err).NotTo(HaveOccurred()) + + content := obj.GetContent() + Expect(content).NotTo(HaveKey("authentication")) + }) + + It("should leave authentication untouched when clientAuthMethod has invalid value", func() { + obj := &types.UnstructuredObject{ + Content: map[string]any{ + "spec": map[string]any{ + "authentication": map[string]any{ + "m2m": map[string]any{ + "clientAuthMethod": "invalid", + }, + }, + }, + }, + } + + err := v0.PatchRoverRequest(context.Background(), obj) + Expect(err).NotTo(HaveOccurred()) + + content := obj.GetContent() + auth, ok := content["authentication"].(map[string]any) + Expect(ok).To(BeTrue()) + Expect(auth["clientAuthMethod"]).To(Equal("invalid")) + }) + + It("should leave authentication untouched when format is not a map", func() { + obj := &types.UnstructuredObject{ + Content: map[string]any{ + "spec": map[string]any{ + "authentication": "not a map", + }, + }, + } + + err := v0.PatchRoverRequest(context.Background(), obj) + Expect(err).NotTo(HaveOccurred()) + + content := obj.GetContent() + Expect(content).To(HaveKey("authentication")) + }) + + It("should leave authentication untouched when already in rover-server format", func() { + obj := &types.UnstructuredObject{ + Content: map[string]any{ + "spec": map[string]any{ + "authentication": map[string]any{ + "clientAuthMethod": "BASIC", + }, + }, + }, + } + + err := v0.PatchRoverRequest(context.Background(), obj) + Expect(err).NotTo(HaveOccurred()) + + content := obj.GetContent() + auth, ok := content["authentication"].(map[string]any) + Expect(ok).To(BeTrue()) + Expect(auth["clientAuthMethod"]).To(Equal("BASIC")) + }) + }) + Describe("ResetSecret", func() { It("should send a reset secret request and return converged status", func() { callCount := 0 diff --git a/rover-server/internal/controller/__snapshots__/suite_controller_test.snap b/rover-server/internal/controller/__snapshots__/suite_controller_test.snap index 61b527824..1415b5b5e 100644 --- a/rover-server/internal/controller/__snapshots__/suite_controller_test.snap +++ b/rover-server/internal/controller/__snapshots__/suite_controller_test.snap @@ -1889,3 +1889,79 @@ "type": "Forbidden" } --- + +[Rover Controller Update rover resource should accept clientAuthMethod BASIC as produced by rover-ctl - 1] +{ + "exposures": [ + { + "approval": "SIMPLE", + "basePath": "/eni/distr/v1", + "type": "api", + "upstream": "https://httpbin.org/anything", + "visibility": "WORLD" + } + ], + "id": "eni--hyperion--rover-local-sub", + "name": "rover-local-sub", + "status": { + "errors": [ + { + "cause": "NoApproval", + "message": "Approval is either rejected or suspended" + } + ], + "processingState": "done", + "state": "blocked", + "warnings": [ + { + "message": "Atleast one sub-resource is being processed" + } + ] + }, + "subscriptions": [ + { + "basePath": "/eni/distr/v1", + "type": "api" + } + ], + "zone": "dataplane1" +} +--- + +[Rover Controller Update rover resource should accept clientAuthMethod POST as produced by rover-ctl - 1] +{ + "exposures": [ + { + "approval": "SIMPLE", + "basePath": "/eni/distr/v1", + "type": "api", + "upstream": "https://httpbin.org/anything", + "visibility": "WORLD" + } + ], + "id": "eni--hyperion--rover-local-sub", + "name": "rover-local-sub", + "status": { + "errors": [ + { + "cause": "NoApproval", + "message": "Approval is either rejected or suspended" + } + ], + "processingState": "done", + "state": "blocked", + "warnings": [ + { + "message": "Atleast one sub-resource is being processed" + } + ] + }, + "subscriptions": [ + { + "basePath": "/eni/distr/v1", + "type": "api" + } + ], + "zone": "dataplane1" +} +--- diff --git a/rover-server/internal/controller/rover_test.go b/rover-server/internal/controller/rover_test.go index d3343512a..738a38a3f 100644 --- a/rover-server/internal/controller/rover_test.go +++ b/rover-server/internal/controller/rover_test.go @@ -220,6 +220,32 @@ var _ = Describe("Rover Controller", func() { responseGroup, err := ExecuteRequest(req, groupToken) ExpectStatusWithBody(responseGroup, err, http.StatusForbidden, "application/problem+json") }) + + It("should accept clientAuthMethod BASIC as produced by rover-ctl", func() { + body := api.RoverUpdateRequest{ + Zone: "dataplane1", + Authentication: api.Authentication{ + ClientAuthMethod: api.BASIC, + }, + } + jsonBody, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPut, "/rovers/eni--hyperion--rover-local-sub", bytes.NewReader(jsonBody)) + responseGroup, err := ExecuteRequest(req, groupToken) + ExpectStatusWithBody(responseGroup, err, http.StatusAccepted, "application/json") + }) + + It("should accept clientAuthMethod POST as produced by rover-ctl", func() { + body := api.RoverUpdateRequest{ + Zone: "dataplane1", + Authentication: api.Authentication{ + ClientAuthMethod: api.POST, + }, + } + jsonBody, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPut, "/rovers/eni--hyperion--rover-local-sub", bytes.NewReader(jsonBody)) + responseGroup, err := ExecuteRequest(req, groupToken) + ExpectStatusWithBody(responseGroup, err, http.StatusAccepted, "application/json") + }) }) Context("Reset rover secret", func() { diff --git a/rover-server/internal/mapper/rover/in/__snapshots__/exposure_security_test.snap b/rover-server/internal/mapper/rover/in/__snapshots__/exposure_security_test.snap index 94a0af11a..c24660c2c 100755 --- a/rover-server/internal/mapper/rover/in/__snapshots__/exposure_security_test.snap +++ b/rover-server/internal/mapper/rover/in/__snapshots__/exposure_security_test.snap @@ -14,7 +14,7 @@ M2M: &v1.Machine2MachineAuthentication{ ExternalIDP: &v1.ExternalIdentityProvider{ TokenEndpoint: "https://test.com/token", - TokenRequest: "basic", + TokenRequest: "client_secret_basic", GrantType: "client_credentials", Basic: (*v1.BasicAuthCredentials)(nil), Client: &v1.OAuth2ClientCredentials{ClientId:"client-id", ClientSecret:"client-secret", ClientKey:"client-key"}, @@ -30,7 +30,7 @@ M2M: &v1.Machine2MachineAuthentication{ ExternalIDP: &v1.ExternalIdentityProvider{ TokenEndpoint: "https://test.com/token", - TokenRequest: "basic", + TokenRequest: "client_secret_basic", GrantType: "password", Basic: &v1.BasicAuthCredentials{Username:"testuser", Password:"testpass"}, Client: (*v1.OAuth2ClientCredentials)(nil), diff --git a/rover-server/internal/mapper/rover/in/__snapshots__/rover_test.snap b/rover-server/internal/mapper/rover/in/__snapshots__/rover_test.snap index 904ed168f..2cef8991f 100644 --- a/rover-server/internal/mapper/rover/in/__snapshots__/rover_test.snap +++ b/rover-server/internal/mapper/rover/in/__snapshots__/rover_test.snap @@ -6,6 +6,7 @@ Spec: v1.RoverSpec{ Zone: "zone", IpRestrictions: (*v1.IpRestrictions)(nil), + Authentication: (*v1.RoverAuthentication)(nil), ClientSecret: "", Exposures: { { @@ -52,6 +53,7 @@ Spec: v1.RoverSpec{ Zone: "", IpRestrictions: (*v1.IpRestrictions)(nil), + Authentication: (*v1.RoverAuthentication)(nil), ClientSecret: "", Exposures: { { @@ -87,6 +89,7 @@ Spec: v1.RoverSpec{ Zone: "", IpRestrictions: (*v1.IpRestrictions)(nil), + Authentication: (*v1.RoverAuthentication)(nil), ClientSecret: "", Exposures: nil, Subscriptions: { @@ -131,6 +134,7 @@ Spec: v1.RoverSpec{ Zone: "zone", IpRestrictions: (*v1.IpRestrictions)(nil), + Authentication: (*v1.RoverAuthentication)(nil), ClientSecret: "", Exposures: { { @@ -218,6 +222,7 @@ Spec: v1.RoverSpec{ Zone: "zone", IpRestrictions: (*v1.IpRestrictions)(nil), + Authentication: (*v1.RoverAuthentication)(nil), ClientSecret: "", Exposures: { { @@ -280,6 +285,7 @@ Spec: v1.RoverSpec{ Zone: "zone", IpRestrictions: (*v1.IpRestrictions)(nil), + Authentication: (*v1.RoverAuthentication)(nil), ClientSecret: "supersecret", Exposures: { { @@ -326,6 +332,7 @@ Spec: v1.RoverSpec{ Zone: "", IpRestrictions: (*v1.IpRestrictions)(nil), + Authentication: (*v1.RoverAuthentication)(nil), ClientSecret: "", Exposures: nil, Subscriptions: nil, @@ -350,6 +357,7 @@ Spec: v1.RoverSpec{ Zone: "", IpRestrictions: (*v1.IpRestrictions)(nil), + Authentication: (*v1.RoverAuthentication)(nil), ClientSecret: "", Exposures: nil, Subscriptions: nil, @@ -385,6 +393,7 @@ Spec: v1.RoverSpec{ Zone: "", IpRestrictions: (*v1.IpRestrictions)(nil), + Authentication: (*v1.RoverAuthentication)(nil), ClientSecret: "", Exposures: nil, Subscriptions: nil, diff --git a/rover-server/internal/mapper/rover/in/exposure.go b/rover-server/internal/mapper/rover/in/exposure.go index 504ca0923..7d21a044b 100644 --- a/rover-server/internal/mapper/rover/in/exposure.go +++ b/rover-server/internal/mapper/rover/in/exposure.go @@ -17,6 +17,20 @@ import ( "github.com/telekom/controlplane/rover-server/internal/api" ) +// oauth2TokenRequestToCRD maps API tokenRequest values to CRD tokenRequest values. +var oauth2TokenRequestToCRD = map[string]roverv1.TokenRequestMethod{ + "body": roverv1.TokenRequestClientSecretPost, + "header": roverv1.TokenRequestClientSecretBasic, + "basic": roverv1.TokenRequestClientSecretBasic, +} + +func tokenRequestAPIToCRD(value string) roverv1.TokenRequestMethod { + if mapped, ok := oauth2TokenRequestToCRD[strings.ToLower(value)]; ok { + return mapped + } + return roverv1.TokenRequestMethod(value) +} + func mapExposure(in *api.Exposure, out *roverv1.Exposure) error { expType, err := in.Discriminator() if err != nil { @@ -134,7 +148,7 @@ func mapExposureSecurity(in api.ApiExposure, out *roverv1.ApiExposure) { // external-idp m2mSecurity.ExternalIDP = &roverv1.ExternalIdentityProvider{ TokenEndpoint: oauth2.TokenEndpoint, - TokenRequest: string(oauth2.TokenRequest), + TokenRequest: tokenRequestAPIToCRD(string(oauth2.TokenRequest)), GrantType: strings.ToLower(string(oauth2.GrantType)), } if oauth2.ClientId != "" { diff --git a/rover-server/internal/mapper/rover/in/exposure_security_test.go b/rover-server/internal/mapper/rover/in/exposure_security_test.go index 2cdfc255c..7d44499f8 100644 --- a/rover-server/internal/mapper/rover/in/exposure_security_test.go +++ b/rover-server/internal/mapper/rover/in/exposure_security_test.go @@ -69,7 +69,7 @@ var _ = Describe("Exposure Security Mapper", func() { Expect(output.Security.M2M).ToNot(BeNil()) Expect(output.Security.M2M.ExternalIDP).ToNot(BeNil()) Expect(output.Security.M2M.ExternalIDP.TokenEndpoint).To(Equal("https://test.com/token")) - Expect(output.Security.M2M.ExternalIDP.TokenRequest).To(Equal("basic")) + Expect(output.Security.M2M.ExternalIDP.TokenRequest).To(Equal(roverv1.TokenRequestClientSecretBasic)) Expect(output.Security.M2M.ExternalIDP.GrantType).To(Equal("client_credentials")) Expect(output.Security.M2M.ExternalIDP.Client).ToNot(BeNil()) Expect(output.Security.M2M.ExternalIDP.Client.ClientId).To(Equal("client-id")) diff --git a/rover-server/internal/mapper/rover/in/fuzzy_match.go b/rover-server/internal/mapper/rover/in/fuzzy_match.go index 9b1c948c8..24eba4700 100644 --- a/rover-server/internal/mapper/rover/in/fuzzy_match.go +++ b/rover-server/internal/mapper/rover/in/fuzzy_match.go @@ -4,7 +4,12 @@ package in -import roverv1 "github.com/telekom/controlplane/rover/api/v1" +import ( + "strings" + + "github.com/telekom/controlplane/rover-server/internal/api" + roverv1 "github.com/telekom/controlplane/rover/api/v1" +) // FuzzyMatchEventDeliveryType performs a fuzzy match on the input string to determine the EventDeliveryType. func FuzzyMatchEventDeliveryType(in string) roverv1.EventDeliveryType { @@ -41,3 +46,17 @@ func FuzzyMatchEventResponseFilterMode(in string) roverv1.EventResponseFilterMod return roverv1.EventResponseFilterMode(in) } } + +// FuzzyMatchClientAuthMethod performs a fuzzy match on the input string to determine the AuthenticationClientAuthMethod. +func FuzzyMatchClientAuthMethod(in string) api.AuthenticationClientAuthMethod { + switch strings.ToLower(in) { + case "basic": + return api.BASIC + case "body", "post": + return api.POST + case "none": + return api.NONE + default: + return api.AuthenticationClientAuthMethod(in) + } +} diff --git a/rover-server/internal/mapper/rover/in/fuzzy_match_test.go b/rover-server/internal/mapper/rover/in/fuzzy_match_test.go index 5b03e6fdd..863a4c657 100644 --- a/rover-server/internal/mapper/rover/in/fuzzy_match_test.go +++ b/rover-server/internal/mapper/rover/in/fuzzy_match_test.go @@ -7,6 +7,7 @@ package in import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/telekom/controlplane/rover-server/internal/api" roverv1 "github.com/telekom/controlplane/rover/api/v1" ) @@ -63,3 +64,27 @@ var _ = DescribeTable("FuzzyMatchEventResponseFilterMode", Entry("unknown passthrough", "filter", roverv1.EventResponseFilterMode("filter")), Entry("empty passthrough", "", roverv1.EventResponseFilterMode("")), ) + +var _ = DescribeTable("FuzzyMatchClientAuthMethod", + func(input string, expected api.AuthenticationClientAuthMethod) { + Expect(FuzzyMatchClientAuthMethod(input)).To(Equal(expected)) + }, + // BASIC variants + Entry("basic", "basic", api.BASIC), + Entry("Basic", "Basic", api.BASIC), + Entry("BASIC", "BASIC", api.BASIC), + // POST variants + Entry("body", "body", api.POST), + Entry("Body", "Body", api.POST), + Entry("BODY", "BODY", api.POST), + Entry("post", "post", api.POST), + Entry("Post", "Post", api.POST), + Entry("POST", "POST", api.POST), + // NONE variants + Entry("none", "none", api.NONE), + Entry("None", "None", api.NONE), + Entry("NONE", "NONE", api.NONE), + // Default passthrough + Entry("unknown passthrough", "unknown", api.AuthenticationClientAuthMethod("unknown")), + Entry("empty passthrough", "", api.AuthenticationClientAuthMethod("")), +) diff --git a/rover-server/internal/mapper/rover/in/rover.go b/rover-server/internal/mapper/rover/in/rover.go index 76b37c8c9..f7b705693 100644 --- a/rover-server/internal/mapper/rover/in/rover.go +++ b/rover-server/internal/mapper/rover/in/rover.go @@ -78,6 +78,7 @@ func MapRover(in *api.Rover, out *roverv1.Rover) error { Psiid: in.Psiid, Icto: in.Icto, }) + mapAuthentication(in, out) return nil } @@ -149,3 +150,25 @@ func mapPermissions(in *api.Rover, out *roverv1.Rover) error { return nil } + +// clientAuthMethodToCRD maps rover-server API enum values to rover CRD tokenRequest values. +var clientAuthMethodToCRD = map[api.AuthenticationClientAuthMethod]roverv1.TokenRequestMethod{ + api.BASIC: roverv1.TokenRequestClientSecretBasic, + api.POST: roverv1.TokenRequestClientSecretPost, +} + +func mapAuthentication(in *api.Rover, out *roverv1.Rover) { + method := FuzzyMatchClientAuthMethod(string(in.Authentication.ClientAuthMethod)) + if method == "" { + return + } + tokenRequest, ok := clientAuthMethodToCRD[method] + if !ok { + return + } + out.Spec.Authentication = &roverv1.RoverAuthentication{ + M2M: &roverv1.RoverM2MAuthentication{ + TokenRequest: tokenRequest, + }, + } +} diff --git a/rover-server/internal/mapper/rover/in/rover_test.go b/rover-server/internal/mapper/rover/in/rover_test.go index 294769335..cc8c3c963 100644 --- a/rover-server/internal/mapper/rover/in/rover_test.go +++ b/rover-server/internal/mapper/rover/in/rover_test.go @@ -140,6 +140,88 @@ var _ = Describe("Rover Mapper", func() { }) }) + Context("MapAuthentication", func() { + It("must map BASIC to client_secret_basic in CRD", func() { + input := &api.Rover{ + Zone: "zone", + Authentication: api.Authentication{ + ClientAuthMethod: api.BASIC, + }, + } + output := &roverv1.Rover{} + + err := MapRover(input, output) + + Expect(err).ToNot(HaveOccurred()) + Expect(output.Spec.Authentication).ToNot(BeNil()) + Expect(output.Spec.Authentication.M2M).ToNot(BeNil()) + Expect(output.Spec.Authentication.M2M.TokenRequest).To(Equal(roverv1.TokenRequestClientSecretBasic)) + }) + + It("must map POST to client_secret_post in CRD", func() { + input := &api.Rover{ + Zone: "zone", + Authentication: api.Authentication{ + ClientAuthMethod: api.POST, + }, + } + output := &roverv1.Rover{} + + err := MapRover(input, output) + + Expect(err).ToNot(HaveOccurred()) + Expect(output.Spec.Authentication).ToNot(BeNil()) + Expect(output.Spec.Authentication.M2M).ToNot(BeNil()) + Expect(output.Spec.Authentication.M2M.TokenRequest).To(Equal(roverv1.TokenRequestClientSecretPost)) + }) + + It("must not set authentication when clientAuthMethod is empty", func() { + input := &api.Rover{ + Zone: "zone", + } + output := &roverv1.Rover{} + + err := MapRover(input, output) + + Expect(err).ToNot(HaveOccurred()) + Expect(output.Spec.Authentication).To(BeNil()) + }) + + It("must fuzzy-match 'basic' to client_secret_basic in CRD", func() { + input := &api.Rover{ + Zone: "zone", + Authentication: api.Authentication{ + ClientAuthMethod: "basic", + }, + } + output := &roverv1.Rover{} + + err := MapRover(input, output) + + Expect(err).ToNot(HaveOccurred()) + Expect(output.Spec.Authentication).ToNot(BeNil()) + Expect(output.Spec.Authentication.M2M).ToNot(BeNil()) + Expect(output.Spec.Authentication.M2M.TokenRequest).To(Equal(roverv1.TokenRequestClientSecretBasic)) + }) + + It("must fuzzy-match 'body' to client_secret_post in CRD", func() { + input := &api.Rover{ + Zone: "zone", + Authentication: api.Authentication{ + ClientAuthMethod: "body", + }, + } + output := &roverv1.Rover{} + + err := MapRover(input, output) + + Expect(err).ToNot(HaveOccurred()) + Expect(output.Spec.Authentication).ToNot(BeNil()) + Expect(output.Spec.Authentication.M2M).ToNot(BeNil()) + Expect(output.Spec.Authentication.M2M.TokenRequest).To(Equal(roverv1.TokenRequestClientSecretPost)) + }) + }) + Context("MapRequest", func() { It("must map a RoverUpdateRequest to a Rover correctly", func() { output, err := MapRequest(roverUpdateRequest, resourceIdInfo) diff --git a/rover-server/internal/mapper/rover/out/__snapshots__/exposure_security_test.snap b/rover-server/internal/mapper/rover/out/__snapshots__/exposure_security_test.snap index e8e4328ed..e35994fc8 100755 --- a/rover-server/internal/mapper/rover/out/__snapshots__/exposure_security_test.snap +++ b/rover-server/internal/mapper/rover/out/__snapshots__/exposure_security_test.snap @@ -13,7 +13,7 @@ api.Oauth2{ RefreshToken: "", Scopes: nil, TokenEndpoint: "https://test.com/token", - TokenRequest: "basic", + TokenRequest: "header", Type: "oauth2", Username: "", } diff --git a/rover-server/internal/mapper/rover/out/exposure.go b/rover-server/internal/mapper/rover/out/exposure.go index 733b7714a..1b26ba0bb 100644 --- a/rover-server/internal/mapper/rover/out/exposure.go +++ b/rover-server/internal/mapper/rover/out/exposure.go @@ -14,6 +14,19 @@ import ( "github.com/telekom/controlplane/rover-server/internal/api" ) +// oauth2TokenRequestCRDToAPI maps CRD tokenRequest values to API Oauth2TokenRequest values. +var oauth2TokenRequestCRDToAPI = map[roverv1.TokenRequestMethod]api.Oauth2TokenRequest{ + roverv1.TokenRequestClientSecretBasic: api.Header, + roverv1.TokenRequestClientSecretPost: api.Body, +} + +func tokenRequestCRDToAPI(value roverv1.TokenRequestMethod) api.Oauth2TokenRequest { + if mapped, ok := oauth2TokenRequestCRDToAPI[value]; ok { + return mapped + } + return api.Oauth2TokenRequest(value) +} + func mapExposure(in *roverv1.Exposure, out *api.Exposure) error { if in.Api != nil { if err := out.FromApiExposure(mapApiExposure(in.Api)); err != nil { @@ -169,7 +182,7 @@ func mapExposureSecurity(in *roverv1.ApiExposure, out *api.ApiExposure) { if m2m.ExternalIDP != nil { oauth2 := api.Oauth2{ TokenEndpoint: m2m.ExternalIDP.TokenEndpoint, - TokenRequest: api.Oauth2TokenRequest(m2m.ExternalIDP.TokenRequest), + TokenRequest: tokenRequestCRDToAPI(m2m.ExternalIDP.TokenRequest), } if grantType := api.GrantType(m2m.ExternalIDP.GrantType); grantType != "" { diff --git a/rover-server/internal/mapper/rover/out/exposure_security_test.go b/rover-server/internal/mapper/rover/out/exposure_security_test.go index 1633f62ea..882cfcb66 100644 --- a/rover-server/internal/mapper/rover/out/exposure_security_test.go +++ b/rover-server/internal/mapper/rover/out/exposure_security_test.go @@ -51,7 +51,7 @@ var _ = Describe("Exposure Security Mapper (Out)", func() { M2M: &roverv1.Machine2MachineAuthentication{ ExternalIDP: &roverv1.ExternalIdentityProvider{ TokenEndpoint: "https://test.com/token", - TokenRequest: "basic", + TokenRequest: roverv1.TokenRequestClientSecretBasic, GrantType: "client_credentials", Client: &roverv1.OAuth2ClientCredentials{ ClientId: "client-id", @@ -73,7 +73,7 @@ var _ = Describe("Exposure Security Mapper (Out)", func() { oauth2, err := output.Security.AsOauth2() Expect(err).To(BeNil()) Expect(oauth2.TokenEndpoint).To(Equal("https://test.com/token")) - Expect(oauth2.TokenRequest).To(Equal(api.Oauth2TokenRequest("basic"))) + Expect(oauth2.TokenRequest).To(Equal(api.Oauth2TokenRequest("header"))) Expect(oauth2.GrantType).To(Equal(api.GrantType("client_credentials"))) Expect(oauth2.ClientId).To(Equal("client-id")) snaps.MatchSnapshot(GinkgoT(), oauth2) diff --git a/rover-server/internal/mapper/rover/out/rover.go b/rover-server/internal/mapper/rover/out/rover.go index 28fb14683..bac1690fd 100644 --- a/rover-server/internal/mapper/rover/out/rover.go +++ b/rover-server/internal/mapper/rover/out/rover.go @@ -46,9 +46,33 @@ func MapRover(in *roverv1.Rover, out *api.Rover) error { scalars := mapper.RoverExternalIdsToScalars(in.Spec.ExternalIds) out.Psiid = scalars.Psiid out.Icto = scalars.Icto + mapAuthentication(in, out) return nil } +// tokenRequestToAPI maps rover CRD tokenRequest values to rover-server API enum values. +var tokenRequestToAPI = map[roverv1.TokenRequestMethod]api.AuthenticationClientAuthMethod{ + roverv1.TokenRequestClientSecretBasic: api.BASIC, + roverv1.TokenRequestClientSecretPost: api.POST, +} + +func mapAuthentication(in *roverv1.Rover, out *api.Rover) { + if in.Spec.Authentication == nil || in.Spec.Authentication.M2M == nil { + return + } + tokenRequest := in.Spec.Authentication.M2M.TokenRequest + if tokenRequest == "" { + return + } + method, ok := tokenRequestToAPI[tokenRequest] + if !ok { + return + } + out.Authentication = api.Authentication{ + ClientAuthMethod: method, + } +} + func mapExposures(in *roverv1.Rover, out *api.Rover) error { if in == nil { return errors.New("input rover is nil") diff --git a/rover-server/internal/mapper/rover/out/rover_test.go b/rover-server/internal/mapper/rover/out/rover_test.go index 2d0a22fa4..9664e890b 100644 --- a/rover-server/internal/mapper/rover/out/rover_test.go +++ b/rover-server/internal/mapper/rover/out/rover_test.go @@ -8,9 +8,8 @@ import ( "github.com/gkampitakis/go-snaps/snaps" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - roverv1 "github.com/telekom/controlplane/rover/api/v1" - "github.com/telekom/controlplane/rover-server/internal/api" + roverv1 "github.com/telekom/controlplane/rover/api/v1" ) var _ = Describe("Rover Mapper", func() { @@ -60,6 +59,49 @@ var _ = Describe("Rover Mapper", func() { }) }) + Context("MapAuthentication", func() { + It("must map client_secret_basic from CRD to BASIC in API", func() { + input := rover.DeepCopy() + input.Spec.Authentication = &roverv1.RoverAuthentication{ + M2M: &roverv1.RoverM2MAuthentication{ + TokenRequest: roverv1.TokenRequestClientSecretBasic, + }, + } + output := &api.Rover{} + + err := MapRover(input, output) + + Expect(err).ToNot(HaveOccurred()) + Expect(output.Authentication.ClientAuthMethod).To(Equal(api.BASIC)) + }) + + It("must map client_secret_post from CRD to POST in API", func() { + input := rover.DeepCopy() + input.Spec.Authentication = &roverv1.RoverAuthentication{ + M2M: &roverv1.RoverM2MAuthentication{ + TokenRequest: roverv1.TokenRequestClientSecretPost, + }, + } + output := &api.Rover{} + + err := MapRover(input, output) + + Expect(err).ToNot(HaveOccurred()) + Expect(output.Authentication.ClientAuthMethod).To(Equal(api.POST)) + }) + + It("must not set authentication when it is nil", func() { + input := rover.DeepCopy() + input.Spec.Authentication = nil + output := &api.Rover{} + + err := MapRover(input, output) + + Expect(err).ToNot(HaveOccurred()) + Expect(output.Authentication).To(Equal(api.Authentication{})) + }) + }) + Context("MapRoverResponse", func() { It("must map a Rover to a RoverResponse correctly", func() { input := GetRoverWithReadyCondition(rover) diff --git a/rover/api/v1/rover_types.go b/rover/api/v1/rover_types.go index a8b153384..0756d78d9 100644 --- a/rover/api/v1/rover_types.go +++ b/rover/api/v1/rover_types.go @@ -100,6 +100,10 @@ type RoverSpec struct { // +kubebuilder:validation:Optional IpRestrictions *IpRestrictions `json:"ipRestrictions,omitempty"` + // Authentication defines the authentication configuration for this application + // +kubebuilder:validation:Optional + Authentication *RoverAuthentication `json:"authentication,omitempty"` + // ClientSecret is the secret used for client authentication // If not specified, a randomly generated secret will be used // +kubebuilder:validation:Optional @@ -202,6 +206,23 @@ type IpRestrictions struct { Deny []string `json:"deny,omitempty"` } +// RoverAuthentication defines the top-level authentication configuration for a Rover application +type RoverAuthentication struct { + // M2M defines machine-to-machine authentication settings for the application + // +kubebuilder:validation:Optional + M2M *RoverM2MAuthentication `json:"m2m,omitempty"` +} + +// RoverM2MAuthentication defines the M2M authentication settings +type RoverM2MAuthentication struct { + // TokenRequest configures the token endpoint authentication method (RFC 7591) + // This feature is currently only documented but not parsed towards the application and identity domain as it is still in discussion whether + // this should will be enforced for IDPs. + // +kubebuilder:validation:Optional + // +kubebuilder:default=client_secret_basic + TokenRequest TokenRequestMethod `json:"tokenRequest,omitempty"` +} + // Exposure defines a service that is exposed by this Rover // +kubebuilder:validation:XValidation:rule="self == null || has(self.api) || has(self.event)", message="At least one of api or event must be specified" // +kubebuilder:validation:XValidation:rule="self == null || (!has(self.api) && has(self.event)) || (has(self.api) && !has(self.event))", message="Only one of api or event can be specified (XOR relationship)" diff --git a/rover/api/v1/security_types.go b/rover/api/v1/security_types.go index 1315f9027..abcc94439 100644 --- a/rover/api/v1/security_types.go +++ b/rover/api/v1/security_types.go @@ -4,6 +4,15 @@ package v1 +// TokenRequestMethod defines the token endpoint authentication method (RFC 7591). +// +kubebuilder:validation:Enum=client_secret_basic;client_secret_post +type TokenRequestMethod string + +const ( + TokenRequestClientSecretBasic TokenRequestMethod = "client_secret_basic" + TokenRequestClientSecretPost TokenRequestMethod = "client_secret_post" +) + // Security defines the security configuration for the Rover // Security is optional, but if provided, exactly one of m2m or h2m must be set type Security struct { @@ -63,10 +72,9 @@ type ExternalIdentityProvider struct { // +kubebuilder:validation:Format=uri TokenEndpoint string `json:"tokenEndpoint"` - // TokenRequest is the type of token request, "body" or "header" + // TokenRequest configures the token endpoint authentication method (RFC 7591) // +kubebuilder:validation:Optional - // +kubebuilder:validation:Enum=body;header - TokenRequest string `json:"tokenRequest,omitempty"` + TokenRequest TokenRequestMethod `json:"tokenRequest,omitempty"` // GrantType defines the OAuth2 grant type to use for the token request // +kubebuilder:validation:Optional diff --git a/rover/api/v1/zz_generated.deepcopy.go b/rover/api/v1/zz_generated.deepcopy.go index 7e05cdf85..692916b82 100644 --- a/rover/api/v1/zz_generated.deepcopy.go +++ b/rover/api/v1/zz_generated.deepcopy.go @@ -1135,6 +1135,26 @@ func (in *Rover) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RoverAuthentication) DeepCopyInto(out *RoverAuthentication) { + *out = *in + if in.M2M != nil { + in, out := &in.M2M, &out.M2M + *out = new(RoverM2MAuthentication) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RoverAuthentication. +func (in *RoverAuthentication) DeepCopy() *RoverAuthentication { + if in == nil { + return nil + } + out := new(RoverAuthentication) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RoverList) DeepCopyInto(out *RoverList) { *out = *in @@ -1167,6 +1187,21 @@ func (in *RoverList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RoverM2MAuthentication) DeepCopyInto(out *RoverM2MAuthentication) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RoverM2MAuthentication. +func (in *RoverM2MAuthentication) DeepCopy() *RoverM2MAuthentication { + if in == nil { + return nil + } + out := new(RoverM2MAuthentication) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RoverSpec) DeepCopyInto(out *RoverSpec) { *out = *in @@ -1175,6 +1210,11 @@ func (in *RoverSpec) DeepCopyInto(out *RoverSpec) { *out = new(IpRestrictions) (*in).DeepCopyInto(*out) } + if in.Authentication != nil { + in, out := &in.Authentication, &out.Authentication + *out = new(RoverAuthentication) + (*in).DeepCopyInto(*out) + } if in.Exposures != nil { in, out := &in.Exposures, &out.Exposures *out = make([]Exposure, len(*in)) diff --git a/rover/config/crd/bases/rover.cp.ei.telekom.de_rovers.yaml b/rover/config/crd/bases/rover.cp.ei.telekom.de_rovers.yaml index 1723de180..eaaf57448 100644 --- a/rover/config/crd/bases/rover.cp.ei.telekom.de_rovers.yaml +++ b/rover/config/crd/bases/rover.cp.ei.telekom.de_rovers.yaml @@ -49,6 +49,26 @@ spec: spec: description: Spec defines the desired state of the Rover resource properties: + authentication: + description: Authentication defines the authentication configuration + for this application + properties: + m2m: + description: M2M defines machine-to-machine authentication settings + for the application + properties: + tokenRequest: + default: client_secret_basic + description: |- + TokenRequest configures the token endpoint authentication method (RFC 7591) + This feature is currently only documented but not parsed towards the application and identity domain as it is still in discussion whether + this should will be enforced for IDPs. + enum: + - client_secret_basic + - client_secret_post + type: string + type: object + type: object clientSecret: description: |- ClientSecret is the secret used for client authentication @@ -194,11 +214,11 @@ spec: format: uri type: string tokenRequest: - description: TokenRequest is the type of token - request, "body" or "header" + description: TokenRequest configures the token + endpoint authentication method (RFC 7591) enum: - - body - - header + - client_secret_basic + - client_secret_post type: string required: - tokenEndpoint diff --git a/rover/internal/controller/rover_controller_test.go b/rover/internal/controller/rover_controller_test.go index fed0c6081..75a6b495e 100644 --- a/rover/internal/controller/rover_controller_test.go +++ b/rover/internal/controller/rover_controller_test.go @@ -472,7 +472,7 @@ var _ = Describe("Rover Controller", Ordered, func() { M2M: &roverv1.Machine2MachineAuthentication{ ExternalIDP: &roverv1.ExternalIdentityProvider{ TokenEndpoint: "https://idp.example.com/token", - TokenRequest: "header", + TokenRequest: roverv1.TokenRequestClientSecretBasic, GrantType: "client_credentials", Basic: nil, Client: &roverv1.OAuth2ClientCredentials{ @@ -524,7 +524,7 @@ var _ = Describe("Rover Controller", Ordered, func() { g.Expect(apiExposure.Spec.Security.M2M.ExternalIDP.Client.ClientId).To(Equal("clientID")) g.Expect(apiExposure.Spec.Security.M2M.ExternalIDP.Client.ClientSecret).To(Equal("******")) g.Expect(apiExposure.Spec.Security.M2M.Scopes[0]).To(Equal("eIDP:scope")) - g.Expect(apiExposure.Spec.Security.M2M.ExternalIDP.TokenRequest).To(Equal("header")) + g.Expect(apiExposure.Spec.Security.M2M.ExternalIDP.TokenRequest).To(Equal(apiapi.TokenRequestClientSecretBasic)) g.Expect(apiExposure.Spec.Security.M2M.ExternalIDP.TokenEndpoint).To(Equal("https://idp.example.com/token")) g.Expect(apiExposure.Spec.Security.M2M.ExternalIDP.GrantType).To(Equal("client_credentials")) }, timeout, interval).Should(Succeed()) diff --git a/rover/internal/handler/rover/api/exposure.go b/rover/internal/handler/rover/api/exposure.go index ed5832ca1..6ffa9efd2 100644 --- a/rover/internal/handler/rover/api/exposure.go +++ b/rover/internal/handler/rover/api/exposure.go @@ -142,7 +142,7 @@ func mapSecurityToApiSecurity(roverSecurity *rover.Security) *apiapi.Security { if roverSecurity.M2M.ExternalIDP != nil { security.M2M.ExternalIDP = &apiapi.ExternalIdentityProvider{ TokenEndpoint: roverSecurity.M2M.ExternalIDP.TokenEndpoint, - TokenRequest: roverSecurity.M2M.ExternalIDP.TokenRequest, + TokenRequest: apiapi.TokenRequestMethod(roverSecurity.M2M.ExternalIDP.TokenRequest), GrantType: roverSecurity.M2M.ExternalIDP.GrantType, Client: toApiClient(roverSecurity.M2M.ExternalIDP.Client), Basic: toApiBasic(roverSecurity.M2M.ExternalIDP.Basic),