From 26f0cb3e802f5c1f02926777f5e490176e6fdf0b Mon Sep 17 00:00:00 2001 From: icerzack Date: Thu, 2 Apr 2026 15:43:24 +0300 Subject: [PATCH 1/4] feat: add oidc federations --- iam.go | 5 + iam_test.go | 4 + iamerrors/iamerrors.go | 8 + service/federations/oidc/doc.go | 2 + service/federations/oidc/groupmappings/doc.go | 3 + .../oidc/groupmappings/requests.go | 190 +++++++++ .../oidc/groupmappings/requests_test.go | 342 ++++++++++++++++ .../federations/oidc/groupmappings/schemas.go | 17 + service/federations/oidc/requests.go | 203 ++++++++++ service/federations/oidc/requests_test.go | 379 ++++++++++++++++++ service/federations/oidc/schemas.go | 63 +++ service/federations/oidc/testdata/fixtures.go | 78 ++++ 12 files changed, 1294 insertions(+) create mode 100644 service/federations/oidc/doc.go create mode 100644 service/federations/oidc/groupmappings/doc.go create mode 100644 service/federations/oidc/groupmappings/requests.go create mode 100644 service/federations/oidc/groupmappings/requests_test.go create mode 100644 service/federations/oidc/groupmappings/schemas.go create mode 100644 service/federations/oidc/requests.go create mode 100644 service/federations/oidc/requests_test.go create mode 100644 service/federations/oidc/schemas.go create mode 100644 service/federations/oidc/testdata/fixtures.go diff --git a/iam.go b/iam.go index 91e9470..12da8d4 100644 --- a/iam.go +++ b/iam.go @@ -7,6 +7,7 @@ import ( "github.com/selectel/iam-go/iamerrors" baseclient "github.com/selectel/iam-go/internal/client" + "github.com/selectel/iam-go/service/federations/oidc" "github.com/selectel/iam-go/service/federations/saml" "github.com/selectel/iam-go/service/groups" "github.com/selectel/iam-go/service/roles" @@ -66,6 +67,9 @@ type Client struct { // SAMLFederations instance is used to make requests against Selectel IAM API and manage SAML Federations. // It also contains Certificates service, which is used to manage certificates. SAMLFederations *saml.Service + + // OIDCFederations instance is used to make requests against Selectel IAM API and manage OIDC Federations. + OIDCFederations *oidc.Service } type AuthOpts struct { @@ -141,6 +145,7 @@ func New(opts ...Option) (*Client, error) { c.Roles = roles.New(c.baseClient) c.S3Credentials = s3credentials.New(c.baseClient) c.SAMLFederations = saml.New(c.baseClient) + c.OIDCFederations = oidc.New(c.baseClient) return c, nil } diff --git a/iam_test.go b/iam_test.go index d6546a3..c4c338f 100644 --- a/iam_test.go +++ b/iam_test.go @@ -10,6 +10,7 @@ import ( "github.com/selectel/iam-go/iamerrors" baseclient "github.com/selectel/iam-go/internal/client" + "github.com/selectel/iam-go/service/federations/oidc" "github.com/selectel/iam-go/service/federations/saml" "github.com/selectel/iam-go/service/groups" "github.com/selectel/iam-go/service/roles" @@ -66,6 +67,7 @@ func TestNew(t *testing.T) { Roles: roles.New(baseClient), S3Credentials: s3credentials.New(baseClient), SAMLFederations: saml.New(baseClient), + OIDCFederations: oidc.New(baseClient), } }, expectedError: nil, @@ -110,6 +112,7 @@ func TestNew(t *testing.T) { Roles: roles.New(baseClient), S3Credentials: s3credentials.New(baseClient), SAMLFederations: saml.New(baseClient), + OIDCFederations: oidc.New(baseClient), } }, expectedError: nil, @@ -147,6 +150,7 @@ func TestNew(t *testing.T) { Roles: roles.New(baseClient), S3Credentials: s3credentials.New(baseClient), SAMLFederations: saml.New(baseClient), + OIDCFederations: oidc.New(baseClient), } }, expectedError: nil, diff --git a/iamerrors/iamerrors.go b/iamerrors/iamerrors.go index cb2b188..a23c8b5 100644 --- a/iamerrors/iamerrors.go +++ b/iamerrors/iamerrors.go @@ -38,6 +38,10 @@ var ( ErrFederationCertificateNotFound = errors.New("FEDERATION_CERTIFICATE_NOT_FOUND") ErrFederationMaxAgeHoursRequired = errors.New("FEDERATION_MAX_AGE_HOURS_REQUIRED") ErrFederationNotFound = errors.New("FEDERATION_NOT_FOUND") + ErrFederationClientIDRequired = errors.New("FEDERATION_CLIENT_ID_REQUIRED") + ErrFederationAuthURLRequired = errors.New("FEDERATION_AUTH_URL_REQUIRED") + ErrFederationTokenURLRequired = errors.New("FEDERATION_TOKEN_URL_REQUIRED") + ErrFederationJWKSURLRequired = errors.New("FEDERATION_JWKS_URL_REQUIRED") ErrCredentialNameRequired = errors.New("CREDENTIAL_NAME_REQUIRED") ErrCredentialAccessKeyRequired = errors.New("CREDENTIAL_ACCESS_KEY_REQUIRED") @@ -86,6 +90,10 @@ var ( ErrFederationCertificateNotFound.Error(): ErrFederationCertificateNotFound, ErrFederationNotFound.Error(): ErrFederationNotFound, ErrFederationMaxAgeHoursRequired.Error(): ErrFederationMaxAgeHoursRequired, + ErrFederationClientIDRequired.Error(): ErrFederationClientIDRequired, + ErrFederationAuthURLRequired.Error(): ErrFederationAuthURLRequired, + ErrFederationTokenURLRequired.Error(): ErrFederationTokenURLRequired, + ErrFederationJWKSURLRequired.Error(): ErrFederationJWKSURLRequired, ErrUserOrGroupNotFound.Error(): ErrUserOrGroupNotFound, ErrServiceUserNameRequired.Error(): ErrServiceUserNameRequired, ErrServiceUserPasswordRequired.Error(): ErrServiceUserPasswordRequired, diff --git a/service/federations/oidc/doc.go b/service/federations/oidc/doc.go new file mode 100644 index 0000000..d73f49b --- /dev/null +++ b/service/federations/oidc/doc.go @@ -0,0 +1,2 @@ +// Package oidc provides a set of functions for interacting with the Selectel OIDC Federations API. +package oidc diff --git a/service/federations/oidc/groupmappings/doc.go b/service/federations/oidc/groupmappings/doc.go new file mode 100644 index 0000000..2556eee --- /dev/null +++ b/service/federations/oidc/groupmappings/doc.go @@ -0,0 +1,3 @@ +// Package groupmappings provides a set of functions for interacting with +// the Selectel OIDC Federation Group Mappings API. +package groupmappings diff --git a/service/federations/oidc/groupmappings/requests.go b/service/federations/oidc/groupmappings/requests.go new file mode 100644 index 0000000..1964f09 --- /dev/null +++ b/service/federations/oidc/groupmappings/requests.go @@ -0,0 +1,190 @@ +package groupmappings + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/url" + + "github.com/selectel/iam-go/iamerrors" + "github.com/selectel/iam-go/internal/client" +) + +const apiVersion = "v1" + +// Service is used to communicate with the OIDC Federations Group Mappings API. +type Service struct { + baseClient *client.BaseClient +} + +// New Initialises Service with the given client. +func New(baseClient *client.BaseClient) *Service { + return &Service{ + baseClient: baseClient, + } +} + +// List returns a list of mappings for the OIDC Federation. +func (s *Service) List(ctx context.Context, federationID string) (*GroupMappingsResponse, error) { + if federationID == "" { + return nil, iamerrors.Error{Err: iamerrors.ErrFederationIDRequired, Desc: "No federationID was provided."} + } + + path, err := url.JoinPath(apiVersion, "federations", "oidc", federationID, "group-mappings") + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + response, err := s.baseClient.DoRequest(ctx, client.DoRequestInput{ + Body: nil, + Method: http.MethodGet, + Path: path, + }) + if err != nil { + //nolint:wrapcheck // DoRequest already wraps the error. + return nil, err + } + + var mappings GroupMappingsResponse + err = client.UnmarshalJSON(response, &mappings) + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + return &mappings, nil +} + +// Update updates mappings for the OIDC Federation. +func (s *Service) Update( + ctx context.Context, federationID string, input GroupMappingsRequest, +) error { + if federationID == "" { + return iamerrors.Error{Err: iamerrors.ErrFederationIDRequired, Desc: "No federationID was provided."} + } + + path, err := url.JoinPath(apiVersion, "federations", "oidc", federationID, "group-mappings") + if err != nil { + return iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + body, err := json.Marshal(input) + if err != nil { + return iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + _, err = s.baseClient.DoRequest(ctx, client.DoRequestInput{ + Body: bytes.NewReader(body), + Method: http.MethodPut, + Path: path, + }) + if err != nil { + //nolint:wrapcheck // DoRequest already wraps the error. + return err + } + + return nil +} + +// Add creates mapping between internal and external group. +func (s *Service) Add( + ctx context.Context, federationID, groupID, externalGroupID string, +) error { + path, err := buildExternalGroupMappingPath(federationID, groupID, externalGroupID) + if err != nil { + return err + } + + _, err = s.baseClient.DoRequest(ctx, client.DoRequestInput{ + Body: nil, + Method: http.MethodPut, + Path: path, + }) + if err != nil { + //nolint:wrapcheck // DoRequest already wraps the error. + return err + } + + return nil +} + +// Delete deletes mapping between internal and external group. +func (s *Service) Delete( + ctx context.Context, federationID, groupID, externalGroupID string, +) error { + path, err := buildExternalGroupMappingPath(federationID, groupID, externalGroupID) + if err != nil { + return err + } + + _, err = s.baseClient.DoRequest(ctx, client.DoRequestInput{ + Body: nil, + Method: http.MethodDelete, + Path: path, + }) + if err != nil { + //nolint:wrapcheck // DoRequest already wraps the error. + return err + } + + return nil +} + +// Exists checks that internal and external groups are mapped. +func (s *Service) Exists( + ctx context.Context, federationID, groupID, externalGroupID string, +) (bool, error) { + path, err := buildExternalGroupMappingPath(federationID, groupID, externalGroupID) + if err != nil { + return false, err + } + + _, err = s.baseClient.DoRequest(ctx, client.DoRequestInput{ + Body: nil, + Method: http.MethodHead, + Path: path, + }) + if err != nil { + if errors.Is(err, iamerrors.ErrFederationNotFound) || + errors.Is(err, iamerrors.ErrGroupNotFound) || + errors.Is(err, iamerrors.ErrUserOrGroupNotFound) { + return false, nil + } + + //nolint:wrapcheck // DoRequest already wraps the error. + return false, err + } + + return true, nil +} + +func buildExternalGroupMappingPath( + federationID, groupID, externalGroupID string, +) (string, error) { + if federationID == "" { + return "", iamerrors.Error{Err: iamerrors.ErrFederationIDRequired, Desc: "No federationID was provided."} + } + if groupID == "" { + return "", iamerrors.Error{Err: iamerrors.ErrGroupIDRequired, Desc: "No groupID was provided."} + } + if externalGroupID == "" { + return "", iamerrors.Error{Err: iamerrors.ErrInputDataRequired, Desc: "No externalGroupID was provided."} + } + + path, err := url.JoinPath( + apiVersion, + "federations", + "oidc", + federationID, + "group-mappings", + groupID, + "external-groups", + externalGroupID, + ) + if err != nil { + return "", iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + return path, nil +} diff --git a/service/federations/oidc/groupmappings/requests_test.go b/service/federations/oidc/groupmappings/requests_test.go new file mode 100644 index 0000000..6d7dbcf --- /dev/null +++ b/service/federations/oidc/groupmappings/requests_test.go @@ -0,0 +1,342 @@ +package groupmappings + +import ( + "context" + "net/http" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/selectel/iam-go/iamerrors" + "github.com/selectel/iam-go/internal/client" + "github.com/selectel/iam-go/service/federations/oidc/testdata" +) + +const ( + federationGroupMappingsURL = "v1/federations/oidc/123/group-mappings" + federationExternalGroupMappingURL = "v1/federations/oidc/123/group-mappings/456/external-groups/external-group" +) + +func TestList(t *testing.T) { + tests := []struct { + name string + prepare func() + expectedResponse *GroupMappingsResponse + expectedError error + }{ + { + name: "ok", + prepare: func() { + httpmock.RegisterResponder( + http.MethodGet, testdata.TestURL+federationGroupMappingsURL, + func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusOK, testdata.TestGroupMappingsResponse) + return resp, nil + }) + }, + expectedResponse: &GroupMappingsResponse{ + GroupMappings: []GroupMapping{ + { + InternalGroupID: "456", + ExternalGroupID: "external-group", + }, + }, + }, + expectedError: nil, + }, + { + name: "error", + prepare: func() { + httpmock.RegisterResponder( + http.MethodGet, testdata.TestURL+federationGroupMappingsURL, + func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusForbidden, testdata.TestDoRequestErr) + return resp, nil + }) + }, + expectedResponse: nil, + expectedError: iamerrors.ErrForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + api := New(&client.BaseClient{ + HTTPClient: &http.Client{}, + APIUrl: testdata.TestURL, + AuthMethod: &client.KeystoneTokenAuth{KeystoneToken: testdata.TestToken}, + }) + + httpmock.ActivateNonDefault(api.baseClient.HTTPClient) + defer httpmock.DeactivateAndReset() + + tt.prepare() + + ctx := context.Background() + actual, err := api.List(ctx, "123") + + require.ErrorIs(err, tt.expectedError) + assert.Equal(tt.expectedResponse, actual) + }) + } +} + +func TestUpdate(t *testing.T) { + tests := []struct { + name string + prepare func() + input GroupMappingsRequest + expectedError error + }{ + { + name: "ok", + prepare: func() { + httpmock.RegisterResponder( + http.MethodPut, testdata.TestURL+federationGroupMappingsURL, + func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusNoContent, "") + return resp, nil + }) + }, + input: GroupMappingsRequest{ + GroupMappings: []GroupMapping{ + { + InternalGroupID: "456", + ExternalGroupID: "external-group", + }, + }, + }, + expectedError: nil, + }, + { + name: "error", + prepare: func() { + httpmock.RegisterResponder( + http.MethodPut, testdata.TestURL+federationGroupMappingsURL, + func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusForbidden, testdata.TestDoRequestErr) + return resp, nil + }) + }, + input: GroupMappingsRequest{ + GroupMappings: []GroupMapping{ + { + InternalGroupID: "456", + ExternalGroupID: "external-group", + }, + }, + }, + expectedError: iamerrors.ErrForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require := require.New(t) + + api := New(&client.BaseClient{ + HTTPClient: &http.Client{}, + APIUrl: testdata.TestURL, + AuthMethod: &client.KeystoneTokenAuth{KeystoneToken: testdata.TestToken}, + }) + + httpmock.ActivateNonDefault(api.baseClient.HTTPClient) + defer httpmock.DeactivateAndReset() + + tt.prepare() + + ctx := context.Background() + err := api.Update(ctx, "123", tt.input) + + require.ErrorIs(err, tt.expectedError) + }) + } +} + +func TestAdd(t *testing.T) { + tests := []struct { + name string + prepare func() + expectedError error + }{ + { + name: "ok", + prepare: func() { + httpmock.RegisterResponder( + http.MethodPut, testdata.TestURL+federationExternalGroupMappingURL, + func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusNoContent, "") + return resp, nil + }) + }, + expectedError: nil, + }, + { + name: "error", + prepare: func() { + httpmock.RegisterResponder( + http.MethodPut, testdata.TestURL+federationExternalGroupMappingURL, + func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusForbidden, testdata.TestDoRequestErr) + return resp, nil + }) + }, + expectedError: iamerrors.ErrForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require := require.New(t) + + api := New(&client.BaseClient{ + HTTPClient: &http.Client{}, + APIUrl: testdata.TestURL, + AuthMethod: &client.KeystoneTokenAuth{KeystoneToken: testdata.TestToken}, + }) + + httpmock.ActivateNonDefault(api.baseClient.HTTPClient) + defer httpmock.DeactivateAndReset() + + tt.prepare() + + ctx := context.Background() + err := api.Add(ctx, "123", "456", "external-group") + + require.ErrorIs(err, tt.expectedError) + }) + } +} + +func TestDelete(t *testing.T) { + tests := []struct { + name string + prepare func() + expectedError error + }{ + { + name: "ok", + prepare: func() { + httpmock.RegisterResponder( + http.MethodDelete, testdata.TestURL+federationExternalGroupMappingURL, + func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusNoContent, "") + return resp, nil + }) + }, + expectedError: nil, + }, + { + name: "error", + prepare: func() { + httpmock.RegisterResponder( + http.MethodDelete, testdata.TestURL+federationExternalGroupMappingURL, + func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusForbidden, testdata.TestDoRequestErr) + return resp, nil + }) + }, + expectedError: iamerrors.ErrForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require := require.New(t) + + api := New(&client.BaseClient{ + HTTPClient: &http.Client{}, + APIUrl: testdata.TestURL, + AuthMethod: &client.KeystoneTokenAuth{KeystoneToken: testdata.TestToken}, + }) + + httpmock.ActivateNonDefault(api.baseClient.HTTPClient) + defer httpmock.DeactivateAndReset() + + tt.prepare() + + ctx := context.Background() + err := api.Delete(ctx, "123", "456", "external-group") + + require.ErrorIs(err, tt.expectedError) + }) + } +} + +func TestExists(t *testing.T) { + tests := []struct { + name string + prepare func() + expectedExists bool + expectedError error + }{ + { + name: "exists", + prepare: func() { + httpmock.RegisterResponder( + http.MethodHead, testdata.TestURL+federationExternalGroupMappingURL, + func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusNoContent, "") + return resp, nil + }) + }, + expectedExists: true, + expectedError: nil, + }, + { + name: "not found", + prepare: func() { + httpmock.RegisterResponder( + http.MethodHead, testdata.TestURL+federationExternalGroupMappingURL, + func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusNotFound, testdata.TestUserOrGroupNotFoundErr) + return resp, nil + }) + }, + expectedExists: false, + expectedError: nil, + }, + { + name: "error", + prepare: func() { + httpmock.RegisterResponder( + http.MethodHead, testdata.TestURL+federationExternalGroupMappingURL, + func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusForbidden, testdata.TestDoRequestErr) + return resp, nil + }) + }, + expectedExists: false, + expectedError: iamerrors.ErrForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + api := New(&client.BaseClient{ + HTTPClient: &http.Client{}, + APIUrl: testdata.TestURL, + AuthMethod: &client.KeystoneTokenAuth{KeystoneToken: testdata.TestToken}, + }) + + httpmock.ActivateNonDefault(api.baseClient.HTTPClient) + defer httpmock.DeactivateAndReset() + + tt.prepare() + + ctx := context.Background() + exists, err := api.Exists(ctx, "123", "456", "external-group") + + require.ErrorIs(err, tt.expectedError) + assert.Equal(tt.expectedExists, exists) + }) + } +} diff --git a/service/federations/oidc/groupmappings/schemas.go b/service/federations/oidc/groupmappings/schemas.go new file mode 100644 index 0000000..b72f200 --- /dev/null +++ b/service/federations/oidc/groupmappings/schemas.go @@ -0,0 +1,17 @@ +package groupmappings + +// GroupMapping represents mapping between internal and external group. +type GroupMapping struct { + InternalGroupID string `json:"internal_group_id"` + ExternalGroupID string `json:"external_group_id"` +} + +// GroupMappingsRequest is used to set options for Update method. +type GroupMappingsRequest struct { + GroupMappings []GroupMapping `json:"group_mappings"` +} + +// GroupMappingsResponse represents all mappings for the specified Federation. +type GroupMappingsResponse struct { + GroupMappings []GroupMapping `json:"group_mappings"` +} diff --git a/service/federations/oidc/requests.go b/service/federations/oidc/requests.go new file mode 100644 index 0000000..5900c87 --- /dev/null +++ b/service/federations/oidc/requests.go @@ -0,0 +1,203 @@ +package oidc + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/url" + + "github.com/selectel/iam-go/iamerrors" + "github.com/selectel/iam-go/internal/client" + "github.com/selectel/iam-go/service/federations/oidc/groupmappings" +) + +const apiVersion = "v1" + +// Service is used to communicate with the OIDC Federations API. +type Service struct { + GroupMappings *groupmappings.Service + baseClient *client.BaseClient +} + +// New Initialises Service with the given client. +func New(baseClient *client.BaseClient) *Service { + return &Service{ + GroupMappings: groupmappings.New(baseClient), + baseClient: baseClient, + } +} + +// List returns a list of OIDC Federations for the account. +func (s *Service) List(ctx context.Context) (*ListResponse, error) { + path, err := url.JoinPath(apiVersion, "federations", "oidc") + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + response, err := s.baseClient.DoRequest(ctx, client.DoRequestInput{ + Body: nil, + Method: http.MethodGet, + Path: path, + }) + if err != nil { + //nolint:wrapcheck // DoRequest already wraps the error. + return nil, err + } + + var federations ListResponse + err = client.UnmarshalJSON(response, &federations) + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + return &federations, nil +} + +// Get returns an info of OIDC Federation with federationID. +func (s *Service) Get(ctx context.Context, federationID string) (*GetResponse, error) { + if federationID == "" { + return nil, iamerrors.Error{Err: iamerrors.ErrFederationIDRequired, Desc: "No federationID was provided."} + } + + path, err := url.JoinPath(apiVersion, "federations", "oidc", federationID) + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + response, err := s.baseClient.DoRequest(ctx, client.DoRequestInput{ + Body: nil, + Method: http.MethodGet, + Path: path, + }) + if err != nil { + //nolint:wrapcheck // DoRequest already wraps the error. + return nil, err + } + + var federation GetResponse + err = client.UnmarshalJSON(response, &federation) + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + return &federation, nil +} + +// Create creates a new OIDC Federation. +func (s *Service) Create(ctx context.Context, input CreateRequest) (*CreateResponse, error) { + if input.Name == "" { + return nil, iamerrors.Error{ + Err: iamerrors.ErrFederationNameRequired, + Desc: "No Name for Federation was provided.", + } + } + if input.ClientID == "" { + return nil, iamerrors.Error{ + Err: iamerrors.ErrFederationClientIDRequired, + Desc: "No Client ID for Federation was provided.", + } + } + if input.AuthURL == "" { + return nil, iamerrors.Error{ + Err: iamerrors.ErrFederationAuthURLRequired, + Desc: "No Auth URL for Federation was provided.", + } + } + if input.TokenURL == "" { + return nil, iamerrors.Error{ + Err: iamerrors.ErrFederationTokenURLRequired, + Desc: "No Token URL for Federation was provided.", + } + } + if input.JWKSURL == "" { + return nil, iamerrors.Error{ + Err: iamerrors.ErrFederationJWKSURLRequired, + Desc: "No JWKS URL for Federation was provided.", + } + } + if input.SessionMaxAgeHours == 0 { + return nil, iamerrors.Error{ + Err: iamerrors.ErrFederationMaxAgeHoursRequired, + Desc: "No Max Age Hours for Federation was provided.", + } + } + + path, err := url.JoinPath(apiVersion, "federations", "oidc") + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + body, err := json.Marshal(input) + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + response, err := s.baseClient.DoRequest(ctx, client.DoRequestInput{ + Body: bytes.NewReader(body), + Method: http.MethodPost, + Path: path, + }) + if err != nil { + //nolint:wrapcheck // DoRequest already wraps the error. + return nil, err + } + + var federation CreateResponse + err = client.UnmarshalJSON(response, &federation) + if err != nil { + return nil, iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + return &federation, nil +} + +// Update updates existing OIDC Federation. +func (s *Service) Update(ctx context.Context, federationID string, input UpdateRequest) error { + if federationID == "" { + return iamerrors.Error{Err: iamerrors.ErrFederationIDRequired, Desc: "No federationID was provided."} + } + + path, err := url.JoinPath(apiVersion, "federations", "oidc", federationID) + if err != nil { + return iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + body, err := json.Marshal(input) + if err != nil { + return iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + _, err = s.baseClient.DoRequest(ctx, client.DoRequestInput{ + Body: bytes.NewReader(body), + Method: http.MethodPatch, + Path: path, + }) + if err != nil { + //nolint:wrapcheck // DoRequest already wraps the error. + return err + } + + return nil +} + +// Delete deletes an OIDC Federation from the account. +func (s *Service) Delete(ctx context.Context, federationID string) error { + if federationID == "" { + return iamerrors.Error{Err: iamerrors.ErrFederationIDRequired, Desc: "No federationID was provided."} + } + + path, err := url.JoinPath(apiVersion, "federations", "oidc", federationID) + if err != nil { + return iamerrors.Error{Err: iamerrors.ErrInternalAppError, Desc: err.Error()} + } + + _, err = s.baseClient.DoRequest(ctx, client.DoRequestInput{ + Body: nil, + Method: http.MethodDelete, + Path: path, + }) + if err != nil { + //nolint:wrapcheck // DoRequest already wraps the error. + return err + } + + return nil +} diff --git a/service/federations/oidc/requests_test.go b/service/federations/oidc/requests_test.go new file mode 100644 index 0000000..d671daf --- /dev/null +++ b/service/federations/oidc/requests_test.go @@ -0,0 +1,379 @@ +package oidc + +import ( + "context" + "net/http" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/selectel/iam-go/iamerrors" + "github.com/selectel/iam-go/internal/client" + "github.com/selectel/iam-go/service/federations/oidc/testdata" +) + +const ( + federationsURL = "v1/federations/oidc" + federationsIDURL = "v1/federations/oidc/123" +) + +func TestList(t *testing.T) { + tests := []struct { + name string + prepare func() + expectedResponse *ListResponse + expectedError error + }{ + { + name: "ok", + prepare: func() { + httpmock.RegisterResponder( + http.MethodGet, testdata.TestURL+federationsURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusOK, testdata.TestListFederationsResponse) + return resp, nil + }) + }, + expectedResponse: &ListResponse{ + []Federation{ + { + ID: "123", + AccountID: "123", + Name: "test_name", + Description: "test_description", + ClientID: "test_client_id", + AuthURL: "https://idp.example.com/authorize", + TokenURL: "https://idp.example.com/token", + JWKSURL: "https://idp.example.com/.well-known/jwks.json", + SessionMaxAgeHours: 24, + }, + }, + }, + expectedError: nil, + }, + { + name: "error", + prepare: func() { + httpmock.RegisterResponder( + http.MethodGet, testdata.TestURL+federationsURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusForbidden, testdata.TestDoRequestErr) + return resp, nil + }) + }, + expectedResponse: nil, + expectedError: iamerrors.ErrForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + federationsAPI := New(&client.BaseClient{ + HTTPClient: &http.Client{}, + APIUrl: testdata.TestURL, + AuthMethod: &client.KeystoneTokenAuth{KeystoneToken: testdata.TestToken}, + }) + + httpmock.ActivateNonDefault(federationsAPI.baseClient.HTTPClient) + defer httpmock.DeactivateAndReset() + + tt.prepare() + + ctx := context.Background() + actual, err := federationsAPI.List(ctx) + + require.ErrorIs(err, tt.expectedError) + + assert.Equal(tt.expectedResponse, actual) + }) + } +} + +func TestGet(t *testing.T) { + tests := []struct { + name string + prepare func() + expectedResponse *GetResponse + expectedError error + }{ + { + name: "ok", + prepare: func() { + httpmock.RegisterResponder( + http.MethodGet, testdata.TestURL+federationsIDURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusOK, testdata.TestGetFederationResponse) + return resp, nil + }) + }, + expectedResponse: &GetResponse{ + Federation: Federation{ + ID: "123", + AccountID: "123", + Name: "test_name", + Description: "test_description", + Alias: "test_alias", + ClientID: "test_client_id", + ClientSecret: "test_client_secret", + AuthURL: "https://idp.example.com/authorize", + TokenURL: "https://idp.example.com/token", + JWKSURL: "https://idp.example.com/.well-known/jwks.json", + SessionMaxAgeHours: 24, + AutoUsersCreation: true, + EnableGroupMapping: true, + }, + }, + expectedError: nil, + }, + { + name: "error", + prepare: func() { + httpmock.RegisterResponder( + http.MethodGet, testdata.TestURL+federationsIDURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusForbidden, testdata.TestDoRequestErr) + return resp, nil + }) + }, + expectedResponse: nil, + expectedError: iamerrors.ErrForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + federationsAPI := New(&client.BaseClient{ + HTTPClient: &http.Client{}, + APIUrl: testdata.TestURL, + AuthMethod: &client.KeystoneTokenAuth{KeystoneToken: testdata.TestToken}, + }) + + httpmock.ActivateNonDefault(federationsAPI.baseClient.HTTPClient) + defer httpmock.DeactivateAndReset() + + tt.prepare() + + ctx := context.Background() + actual, err := federationsAPI.Get(ctx, "123") + + require.ErrorIs(err, tt.expectedError) + + assert.Equal(tt.expectedResponse, actual) + }) + } +} + +func TestCreate(t *testing.T) { + tests := []struct { + name string + prepare func() + input CreateRequest + expectedResponse *CreateResponse + expectedError error + }{ + { + name: "ok", + prepare: func() { + httpmock.RegisterResponder( + http.MethodPost, testdata.TestURL+federationsURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusOK, testdata.TestCreateFederationResponse) + return resp, nil + }) + }, + input: CreateRequest{ + Name: "test_name", + Description: "test_description", + ClientID: "test_client_id", + ClientSecret: "test_client_secret", + AuthURL: "https://idp.example.com/authorize", + TokenURL: "https://idp.example.com/token", + JWKSURL: "https://idp.example.com/.well-known/jwks.json", + SessionMaxAgeHours: 24, + }, + expectedResponse: &CreateResponse{ + Federation: Federation{ + ID: "123", + AccountID: "123", + Name: "test_name", + Description: "test_description", + Alias: "test_alias", + ClientID: "test_client_id", + ClientSecret: "test_client_secret", + AuthURL: "https://idp.example.com/authorize", + TokenURL: "https://idp.example.com/token", + JWKSURL: "https://idp.example.com/.well-known/jwks.json", + SessionMaxAgeHours: 24, + AutoUsersCreation: true, + EnableGroupMapping: true, + }, + }, + expectedError: nil, + }, + { + name: "error", + prepare: func() { + httpmock.RegisterResponder( + http.MethodPost, testdata.TestURL+federationsURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusForbidden, testdata.TestDoRequestErr) + return resp, nil + }) + }, + input: CreateRequest{ + Name: "test_name", + Description: "test_description", + ClientID: "test_client_id", + AuthURL: "https://idp.example.com/authorize", + TokenURL: "https://idp.example.com/token", + JWKSURL: "https://idp.example.com/.well-known/jwks.json", + SessionMaxAgeHours: 24, + }, + expectedResponse: nil, + expectedError: iamerrors.ErrForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + federationsAPI := New(&client.BaseClient{ + HTTPClient: &http.Client{}, + APIUrl: testdata.TestURL, + AuthMethod: &client.KeystoneTokenAuth{KeystoneToken: testdata.TestToken}, + }) + + httpmock.ActivateNonDefault(federationsAPI.baseClient.HTTPClient) + defer httpmock.DeactivateAndReset() + + tt.prepare() + + ctx := context.Background() + actual, err := federationsAPI.Create(ctx, tt.input) + + require.ErrorIs(err, tt.expectedError) + + assert.Equal(tt.expectedResponse, actual) + }) + } +} + +func TestUpdate(t *testing.T) { + tests := []struct { + name string + prepare func() + expectedError error + }{ + { + name: "ok", + prepare: func() { + httpmock.RegisterResponder( + http.MethodPatch, testdata.TestURL+federationsIDURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusNoContent, "") + return resp, nil + }) + }, + expectedError: nil, + }, + { + name: "error", + prepare: func() { + httpmock.RegisterResponder( + http.MethodPatch, testdata.TestURL+federationsIDURL, func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusForbidden, testdata.TestDoRequestErr) + return resp, nil + }) + }, + expectedError: iamerrors.ErrForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require := require.New(t) + + federationsAPI := New(&client.BaseClient{ + HTTPClient: &http.Client{}, + APIUrl: testdata.TestURL, + AuthMethod: &client.KeystoneTokenAuth{KeystoneToken: testdata.TestToken}, + }) + + httpmock.ActivateNonDefault(federationsAPI.baseClient.HTTPClient) + defer httpmock.DeactivateAndReset() + + tt.prepare() + + desc := "test_description" + ctx := context.Background() + err := federationsAPI.Update(ctx, "123", UpdateRequest{ + Name: "test_name", + Description: &desc, + ClientID: "test_client_id", + AuthURL: "https://idp.example.com/authorize", + TokenURL: "https://idp.example.com/token", + JWKSURL: "https://idp.example.com/.well-known/jwks.json", + }) + + require.ErrorIs(err, tt.expectedError) + }) + } +} + +func TestDelete(t *testing.T) { + tests := []struct { + name string + prepare func() + expectedError error + }{ + { + name: "ok", + prepare: func() { + httpmock.RegisterResponder( + http.MethodDelete, testdata.TestURL+federationsIDURL, + func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusNoContent, "") + return resp, nil + }) + }, + expectedError: nil, + }, + { + name: "error", + prepare: func() { + httpmock.RegisterResponder( + http.MethodDelete, testdata.TestURL+federationsIDURL, + func(r *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(http.StatusForbidden, testdata.TestDoRequestErr) + return resp, nil + }) + }, + expectedError: iamerrors.ErrForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require := require.New(t) + + federationsAPI := New(&client.BaseClient{ + HTTPClient: &http.Client{}, + APIUrl: testdata.TestURL, + AuthMethod: &client.KeystoneTokenAuth{KeystoneToken: testdata.TestToken}, + }) + + httpmock.ActivateNonDefault(federationsAPI.baseClient.HTTPClient) + defer httpmock.DeactivateAndReset() + + tt.prepare() + + ctx := context.Background() + err := federationsAPI.Delete(ctx, "123") + + require.ErrorIs(err, tt.expectedError) + }) + } +} diff --git a/service/federations/oidc/schemas.go b/service/federations/oidc/schemas.go new file mode 100644 index 0000000..87f0fda --- /dev/null +++ b/service/federations/oidc/schemas.go @@ -0,0 +1,63 @@ +package oidc + +// Federation represents basic information about an OIDC Federation. +type Federation struct { + ID string `json:"id"` + AccountID string `json:"account_id"` + Name string `json:"name"` + Description string `json:"description"` + Alias string `json:"alias"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + AuthURL string `json:"auth_url"` + TokenURL string `json:"token_url"` + JWKSURL string `json:"jwks_url"` + SessionMaxAgeHours int `json:"session_max_age_hours"` + AutoUsersCreation bool `json:"auto_users_creation"` + EnableGroupMapping bool `json:"enable_group_mappings"` //nolint:tagliatelle +} + +// ListResponse represents all OIDC federations in account. +type ListResponse struct { + Federations []Federation `json:"federations"` +} + +// CreateResponse represents a configured OIDC Federation. +type CreateResponse struct { + Federation +} + +// GetResponse represents an existing OIDC Federation. +type GetResponse struct { + Federation +} + +// CreateRequest is used to set options for Create method. +type CreateRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Alias string `json:"alias,omitempty"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret,omitempty"` + AuthURL string `json:"auth_url"` + TokenURL string `json:"token_url"` + JWKSURL string `json:"jwks_url"` + SessionMaxAgeHours int `json:"session_max_age_hours"` + AutoUsersCreation bool `json:"auto_users_creation"` + EnableGroupMapping bool `json:"enable_group_mappings"` //nolint:tagliatelle +} + +// UpdateRequest is used to set options for Update method. +type UpdateRequest struct { + Name string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Alias string `json:"alias,omitempty"` + ClientID string `json:"client_id,omitempty"` + ClientSecret string `json:"client_secret,omitempty"` + AuthURL string `json:"auth_url,omitempty"` + TokenURL string `json:"token_url,omitempty"` + JWKSURL string `json:"jwks_url,omitempty"` + SessionMaxAgeHours int `json:"session_max_age_hours,omitempty"` + AutoUsersCreation *bool `json:"auto_users_creation,omitempty"` + EnableGroupMapping *bool `json:"enable_group_mappings,omitempty"` //nolint:tagliatelle +} diff --git a/service/federations/oidc/testdata/fixtures.go b/service/federations/oidc/testdata/fixtures.go new file mode 100644 index 0000000..3eb0a7e --- /dev/null +++ b/service/federations/oidc/testdata/fixtures.go @@ -0,0 +1,78 @@ +package testdata + +const ( + TestToken = "test-token" + TestURL = "http://example.org/" +) + +const TestListFederationsResponse = `{ + "federations": [ + { + "id": "123", + "account_id": "123", + "name": "test_name", + "description": "test_description", + "client_id": "test_client_id", + "auth_url": "https://idp.example.com/authorize", + "token_url": "https://idp.example.com/token", + "jwks_url": "https://idp.example.com/.well-known/jwks.json", + "session_max_age_hours": 24 + } + ] +}` + +const TestGetFederationResponse = `{ + "id": "123", + "account_id": "123", + "name": "test_name", + "description": "test_description", + "alias": "test_alias", + "client_id": "test_client_id", + "client_secret": "test_client_secret", + "auth_url": "https://idp.example.com/authorize", + "token_url": "https://idp.example.com/token", + "jwks_url": "https://idp.example.com/.well-known/jwks.json", + "session_max_age_hours": 24, + "auto_users_creation": true, + "enable_group_mappings": true +}` + +const TestCreateFederationResponse = `{ + "id": "123", + "account_id": "123", + "name": "test_name", + "description": "test_description", + "alias": "test_alias", + "client_id": "test_client_id", + "client_secret": "test_client_secret", + "auth_url": "https://idp.example.com/authorize", + "token_url": "https://idp.example.com/token", + "jwks_url": "https://idp.example.com/.well-known/jwks.json", + "session_max_age_hours": 24, + "auto_users_creation": true, + "enable_group_mappings": true +}` + +const TestDoRequestErr = `{ + "code": "REQUEST_FORBIDDEN", + "message": "You don't have permission to do this" +}` + +const TestFederationNotFoundErr = `{ + "code": "FEDERATION_NOT_FOUND", + "message": "Federation not found" +}` + +const TestGroupMappingsResponse = `{ + "group_mappings": [ + { + "internal_group_id": "456", + "external_group_id": "external-group" + } + ] +}` + +const TestUserOrGroupNotFoundErr = `{ + "code": "USER_OR_GROUP_NOT_FOUND", + "message": "User or group not found" +}` From 3923cf49755df89ef583a99d9b3b7883763581e5 Mon Sep 17 00:00:00 2001 From: icerzack Date: Thu, 2 Apr 2026 16:13:14 +0300 Subject: [PATCH 2/4] fix, style: add missin issuer field, fix linter errors --- iamerrors/iamerrors.go | 1 + service/federations/oidc/requests.go | 35 +++++++++++++++++------ service/federations/oidc/requests_test.go | 12 ++++---- service/federations/oidc/schemas.go | 10 ++++--- service/federations/saml/requests_test.go | 8 +++--- service/federations/saml/schemas.go | 2 +- 6 files changed, 45 insertions(+), 23 deletions(-) diff --git a/iamerrors/iamerrors.go b/iamerrors/iamerrors.go index a23c8b5..485f579 100644 --- a/iamerrors/iamerrors.go +++ b/iamerrors/iamerrors.go @@ -39,6 +39,7 @@ var ( ErrFederationMaxAgeHoursRequired = errors.New("FEDERATION_MAX_AGE_HOURS_REQUIRED") ErrFederationNotFound = errors.New("FEDERATION_NOT_FOUND") ErrFederationClientIDRequired = errors.New("FEDERATION_CLIENT_ID_REQUIRED") + ErrFederationClientSecretRequired = errors.New("FEDERATION_CLIENT_SECRET_REQUIRED") ErrFederationAuthURLRequired = errors.New("FEDERATION_AUTH_URL_REQUIRED") ErrFederationTokenURLRequired = errors.New("FEDERATION_TOKEN_URL_REQUIRED") ErrFederationJWKSURLRequired = errors.New("FEDERATION_JWKS_URL_REQUIRED") diff --git a/service/federations/oidc/requests.go b/service/federations/oidc/requests.go index 5900c87..a8bf70e 100644 --- a/service/federations/oidc/requests.go +++ b/service/federations/oidc/requests.go @@ -82,44 +82,63 @@ func (s *Service) Get(ctx context.Context, federationID string) (*GetResponse, e return &federation, nil } -// Create creates a new OIDC Federation. -func (s *Service) Create(ctx context.Context, input CreateRequest) (*CreateResponse, error) { +func validateCreateRequest(input CreateRequest) error { if input.Name == "" { - return nil, iamerrors.Error{ + return iamerrors.Error{ Err: iamerrors.ErrFederationNameRequired, Desc: "No Name for Federation was provided.", } } + if input.Issuer == "" { + return iamerrors.Error{ + Err: iamerrors.ErrFederationIssuerRequired, + Desc: "No Issuer for Federation was provided.", + } + } if input.ClientID == "" { - return nil, iamerrors.Error{ + return iamerrors.Error{ Err: iamerrors.ErrFederationClientIDRequired, Desc: "No Client ID for Federation was provided.", } } + if input.ClientSecret == "" { + return iamerrors.Error{ + Err: iamerrors.ErrFederationClientSecretRequired, + Desc: "No Client Secret for Federation was provided.", + } + } if input.AuthURL == "" { - return nil, iamerrors.Error{ + return iamerrors.Error{ Err: iamerrors.ErrFederationAuthURLRequired, Desc: "No Auth URL for Federation was provided.", } } if input.TokenURL == "" { - return nil, iamerrors.Error{ + return iamerrors.Error{ Err: iamerrors.ErrFederationTokenURLRequired, Desc: "No Token URL for Federation was provided.", } } if input.JWKSURL == "" { - return nil, iamerrors.Error{ + return iamerrors.Error{ Err: iamerrors.ErrFederationJWKSURLRequired, Desc: "No JWKS URL for Federation was provided.", } } if input.SessionMaxAgeHours == 0 { - return nil, iamerrors.Error{ + return iamerrors.Error{ Err: iamerrors.ErrFederationMaxAgeHoursRequired, Desc: "No Max Age Hours for Federation was provided.", } } + return nil +} + +// Create creates a new OIDC Federation. +func (s *Service) Create(ctx context.Context, input CreateRequest) (*CreateResponse, error) { + if err := validateCreateRequest(input); err != nil { + return nil, err + } path, err := url.JoinPath(apiVersion, "federations", "oidc") if err != nil { diff --git a/service/federations/oidc/requests_test.go b/service/federations/oidc/requests_test.go index d671daf..9c99472 100644 --- a/service/federations/oidc/requests_test.go +++ b/service/federations/oidc/requests_test.go @@ -45,7 +45,7 @@ func TestList(t *testing.T) { ClientID: "test_client_id", AuthURL: "https://idp.example.com/authorize", TokenURL: "https://idp.example.com/token", - JWKSURL: "https://idp.example.com/.well-known/jwks.json", + JWKSUrl: "https://idp.example.com/.well-known/jwks.json", SessionMaxAgeHours: 24, }, }, @@ -119,7 +119,7 @@ func TestGet(t *testing.T) { ClientSecret: "test_client_secret", AuthURL: "https://idp.example.com/authorize", TokenURL: "https://idp.example.com/token", - JWKSURL: "https://idp.example.com/.well-known/jwks.json", + JWKSUrl: "https://idp.example.com/.well-known/jwks.json", SessionMaxAgeHours: 24, AutoUsersCreation: true, EnableGroupMapping: true, @@ -191,7 +191,7 @@ func TestCreate(t *testing.T) { ClientSecret: "test_client_secret", AuthURL: "https://idp.example.com/authorize", TokenURL: "https://idp.example.com/token", - JWKSURL: "https://idp.example.com/.well-known/jwks.json", + JWKSUrl: "https://idp.example.com/.well-known/jwks.json", SessionMaxAgeHours: 24, }, expectedResponse: &CreateResponse{ @@ -205,7 +205,7 @@ func TestCreate(t *testing.T) { ClientSecret: "test_client_secret", AuthURL: "https://idp.example.com/authorize", TokenURL: "https://idp.example.com/token", - JWKSURL: "https://idp.example.com/.well-known/jwks.json", + JWKSUrl: "https://idp.example.com/.well-known/jwks.json", SessionMaxAgeHours: 24, AutoUsersCreation: true, EnableGroupMapping: true, @@ -228,7 +228,7 @@ func TestCreate(t *testing.T) { ClientID: "test_client_id", AuthURL: "https://idp.example.com/authorize", TokenURL: "https://idp.example.com/token", - JWKSURL: "https://idp.example.com/.well-known/jwks.json", + JWKSUrl: "https://idp.example.com/.well-known/jwks.json", SessionMaxAgeHours: 24, }, expectedResponse: nil, @@ -315,7 +315,7 @@ func TestUpdate(t *testing.T) { ClientID: "test_client_id", AuthURL: "https://idp.example.com/authorize", TokenURL: "https://idp.example.com/token", - JWKSURL: "https://idp.example.com/.well-known/jwks.json", + JWKSUrl: "https://idp.example.com/.well-known/jwks.json", }) require.ErrorIs(err, tt.expectedError) diff --git a/service/federations/oidc/schemas.go b/service/federations/oidc/schemas.go index 87f0fda..96ce920 100644 --- a/service/federations/oidc/schemas.go +++ b/service/federations/oidc/schemas.go @@ -7,11 +7,12 @@ type Federation struct { Name string `json:"name"` Description string `json:"description"` Alias string `json:"alias"` + Issuer string `json:"issuer"` ClientID string `json:"client_id"` ClientSecret string `json:"client_secret"` AuthURL string `json:"auth_url"` TokenURL string `json:"token_url"` - JWKSURL string `json:"jwks_url"` + JWKSURL string `json:"jwks_url"` //nolint:tagliatelle SessionMaxAgeHours int `json:"session_max_age_hours"` AutoUsersCreation bool `json:"auto_users_creation"` EnableGroupMapping bool `json:"enable_group_mappings"` //nolint:tagliatelle @@ -37,11 +38,12 @@ type CreateRequest struct { Name string `json:"name"` Description string `json:"description,omitempty"` Alias string `json:"alias,omitempty"` + Issuer string `json:"issuer"` ClientID string `json:"client_id"` - ClientSecret string `json:"client_secret,omitempty"` + ClientSecret string `json:"client_secret"` AuthURL string `json:"auth_url"` TokenURL string `json:"token_url"` - JWKSURL string `json:"jwks_url"` + JWKSURL string `json:"jwks_url"` //nolint:tagliatelle SessionMaxAgeHours int `json:"session_max_age_hours"` AutoUsersCreation bool `json:"auto_users_creation"` EnableGroupMapping bool `json:"enable_group_mappings"` //nolint:tagliatelle @@ -56,7 +58,7 @@ type UpdateRequest struct { ClientSecret string `json:"client_secret,omitempty"` AuthURL string `json:"auth_url,omitempty"` TokenURL string `json:"token_url,omitempty"` - JWKSURL string `json:"jwks_url,omitempty"` + JWKSUrl string `json:"jwks_url,omitempty"` //nolint:tagliatelle SessionMaxAgeHours int `json:"session_max_age_hours,omitempty"` AutoUsersCreation *bool `json:"auto_users_creation,omitempty"` EnableGroupMapping *bool `json:"enable_group_mappings,omitempty"` //nolint:tagliatelle diff --git a/service/federations/saml/requests_test.go b/service/federations/saml/requests_test.go index b5d0ae0..ccca010 100644 --- a/service/federations/saml/requests_test.go +++ b/service/federations/saml/requests_test.go @@ -48,7 +48,7 @@ func TestList(t *testing.T) { Name: "test_name", Description: "test_description", Issuer: "test_issuer", - SSOUrl: "test_sso_url", + SSOURL: "test_sso_url", SignAuthnRequests: true, ForceAuthn: true, SessionMaxAgeHours: 1, @@ -121,7 +121,7 @@ func TestGet(t *testing.T) { Description: "test_description", Alias: "test_alias", Issuer: "test_issuer", - SSOUrl: "test_sso_url", + SSOURL: "test_sso_url", SignAuthnRequests: true, ForceAuthn: true, SessionMaxAgeHours: 1, @@ -205,7 +205,7 @@ func TestCreate(t *testing.T) { Description: "test_description", Alias: "test_alias", Issuer: "test_issuer", - SSOUrl: "test_sso_url", + SSOURL: "test_sso_url", SignAuthnRequests: true, ForceAuthn: true, SessionMaxAgeHours: 1, @@ -288,7 +288,7 @@ func TestUpdate(t *testing.T) { Description: "test_description", Alias: "test_alias", Issuer: "test_issuer", - SSOUrl: "test_sso_url", + SSOURL: "test_sso_url", SignAuthnRequests: true, ForceAuthn: true, SessionMaxAgeHours: 1, diff --git a/service/federations/saml/schemas.go b/service/federations/saml/schemas.go index 72ca3ec..1293fdf 100644 --- a/service/federations/saml/schemas.go +++ b/service/federations/saml/schemas.go @@ -8,7 +8,7 @@ type Federation struct { Description string `json:"description"` Alias string `json:"alias"` Issuer string `json:"issuer"` - SSOUrl string `json:"sso_url"` + SSOURL string `json:"sso_url"` SignAuthnRequests bool `json:"sign_authn_requests"` ForceAuthn bool `json:"force_authn"` SessionMaxAgeHours int `json:"session_max_age_hours"` From e1c28a28dde582c41044ea473da14d857cc1c79a Mon Sep 17 00:00:00 2001 From: icerzack Date: Thu, 2 Apr 2026 16:20:18 +0300 Subject: [PATCH 3/4] fix, style: additional fixes --- service/federations/oidc/requests_test.go | 17 ++++++++++++----- service/federations/oidc/testdata/fixtures.go | 4 ++++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/service/federations/oidc/requests_test.go b/service/federations/oidc/requests_test.go index 9c99472..4fd5718 100644 --- a/service/federations/oidc/requests_test.go +++ b/service/federations/oidc/requests_test.go @@ -42,10 +42,12 @@ func TestList(t *testing.T) { AccountID: "123", Name: "test_name", Description: "test_description", + Issuer: "https://idp.example.com", ClientID: "test_client_id", + ClientSecret: "test_client_secret", AuthURL: "https://idp.example.com/authorize", TokenURL: "https://idp.example.com/token", - JWKSUrl: "https://idp.example.com/.well-known/jwks.json", + JWKSURL: "https://idp.example.com/.well-known/jwks.json", SessionMaxAgeHours: 24, }, }, @@ -115,11 +117,12 @@ func TestGet(t *testing.T) { Name: "test_name", Description: "test_description", Alias: "test_alias", + Issuer: "https://idp.example.com", ClientID: "test_client_id", ClientSecret: "test_client_secret", AuthURL: "https://idp.example.com/authorize", TokenURL: "https://idp.example.com/token", - JWKSUrl: "https://idp.example.com/.well-known/jwks.json", + JWKSURL: "https://idp.example.com/.well-known/jwks.json", SessionMaxAgeHours: 24, AutoUsersCreation: true, EnableGroupMapping: true, @@ -187,11 +190,12 @@ func TestCreate(t *testing.T) { input: CreateRequest{ Name: "test_name", Description: "test_description", + Issuer: "https://idp.example.com", ClientID: "test_client_id", ClientSecret: "test_client_secret", AuthURL: "https://idp.example.com/authorize", TokenURL: "https://idp.example.com/token", - JWKSUrl: "https://idp.example.com/.well-known/jwks.json", + JWKSURL: "https://idp.example.com/.well-known/jwks.json", SessionMaxAgeHours: 24, }, expectedResponse: &CreateResponse{ @@ -201,11 +205,12 @@ func TestCreate(t *testing.T) { Name: "test_name", Description: "test_description", Alias: "test_alias", + Issuer: "https://idp.example.com", ClientID: "test_client_id", ClientSecret: "test_client_secret", AuthURL: "https://idp.example.com/authorize", TokenURL: "https://idp.example.com/token", - JWKSUrl: "https://idp.example.com/.well-known/jwks.json", + JWKSURL: "https://idp.example.com/.well-known/jwks.json", SessionMaxAgeHours: 24, AutoUsersCreation: true, EnableGroupMapping: true, @@ -225,10 +230,12 @@ func TestCreate(t *testing.T) { input: CreateRequest{ Name: "test_name", Description: "test_description", + Issuer: "https://idp.example.com", ClientID: "test_client_id", + ClientSecret: "test_client_secret", AuthURL: "https://idp.example.com/authorize", TokenURL: "https://idp.example.com/token", - JWKSUrl: "https://idp.example.com/.well-known/jwks.json", + JWKSURL: "https://idp.example.com/.well-known/jwks.json", SessionMaxAgeHours: 24, }, expectedResponse: nil, diff --git a/service/federations/oidc/testdata/fixtures.go b/service/federations/oidc/testdata/fixtures.go index 3eb0a7e..e3ef9e5 100644 --- a/service/federations/oidc/testdata/fixtures.go +++ b/service/federations/oidc/testdata/fixtures.go @@ -12,7 +12,9 @@ const TestListFederationsResponse = `{ "account_id": "123", "name": "test_name", "description": "test_description", + "issuer": "https://idp.example.com", "client_id": "test_client_id", + "client_secret": "test_client_secret", "auth_url": "https://idp.example.com/authorize", "token_url": "https://idp.example.com/token", "jwks_url": "https://idp.example.com/.well-known/jwks.json", @@ -27,6 +29,7 @@ const TestGetFederationResponse = `{ "name": "test_name", "description": "test_description", "alias": "test_alias", + "issuer": "https://idp.example.com", "client_id": "test_client_id", "client_secret": "test_client_secret", "auth_url": "https://idp.example.com/authorize", @@ -43,6 +46,7 @@ const TestCreateFederationResponse = `{ "name": "test_name", "description": "test_description", "alias": "test_alias", + "issuer": "https://idp.example.com", "client_id": "test_client_id", "client_secret": "test_client_secret", "auth_url": "https://idp.example.com/authorize", From 00c244ccdefbf409541ae549fb78e385aad31d35 Mon Sep 17 00:00:00 2001 From: icerzack Date: Thu, 2 Apr 2026 16:25:28 +0300 Subject: [PATCH 4/4] style: linter fixes --- service/federations/oidc/requests_test.go | 2 +- service/federations/oidc/schemas.go | 3 ++- service/federations/saml/schemas.go | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/service/federations/oidc/requests_test.go b/service/federations/oidc/requests_test.go index 4fd5718..2b68e23 100644 --- a/service/federations/oidc/requests_test.go +++ b/service/federations/oidc/requests_test.go @@ -322,7 +322,7 @@ func TestUpdate(t *testing.T) { ClientID: "test_client_id", AuthURL: "https://idp.example.com/authorize", TokenURL: "https://idp.example.com/token", - JWKSUrl: "https://idp.example.com/.well-known/jwks.json", + JWKSURL: "https://idp.example.com/.well-known/jwks.json", }) require.ErrorIs(err, tt.expectedError) diff --git a/service/federations/oidc/schemas.go b/service/federations/oidc/schemas.go index 96ce920..b478fc9 100644 --- a/service/federations/oidc/schemas.go +++ b/service/federations/oidc/schemas.go @@ -54,11 +54,12 @@ type UpdateRequest struct { Name string `json:"name,omitempty"` Description *string `json:"description,omitempty"` Alias string `json:"alias,omitempty"` + Issuer string `json:"issuer"` ClientID string `json:"client_id,omitempty"` ClientSecret string `json:"client_secret,omitempty"` AuthURL string `json:"auth_url,omitempty"` TokenURL string `json:"token_url,omitempty"` - JWKSUrl string `json:"jwks_url,omitempty"` //nolint:tagliatelle + JWKSURL string `json:"jwks_url,omitempty"` //nolint:tagliatelle SessionMaxAgeHours int `json:"session_max_age_hours,omitempty"` AutoUsersCreation *bool `json:"auto_users_creation,omitempty"` EnableGroupMapping *bool `json:"enable_group_mappings,omitempty"` //nolint:tagliatelle diff --git a/service/federations/saml/schemas.go b/service/federations/saml/schemas.go index 96fdc39..15dbc39 100644 --- a/service/federations/saml/schemas.go +++ b/service/federations/saml/schemas.go @@ -8,7 +8,7 @@ type Federation struct { Description string `json:"description"` Alias string `json:"alias"` Issuer string `json:"issuer"` - SSOURL string `json:"sso_url"` + SSOURL string `json:"sso_url"` //nolint:tagliatelle SignAuthnRequests bool `json:"sign_authn_requests"` ForceAuthn bool `json:"force_authn"` SessionMaxAgeHours int `json:"session_max_age_hours"`