diff --git a/.gitignore b/.gitignore index 257b9379a5..5b2fb90030 100644 --- a/.gitignore +++ b/.gitignore @@ -134,11 +134,10 @@ docs/static/api/next/postman/ !.claude/skills # Dev-only CORS seed staged by `build.sh run` / `build.ps1 run` for the bootstrap one-shot (never committed or packaged) -backend/cmd/server/bootstrap/02-server-configurations.yaml +backend/cmd/server/bootstrap/03-dev-server-configurations.yaml # Nx build cache (generated, never committed) .nx/ # Development internals guide (not intended for commit) THUNDERID_INTERNALS_GUIDE.md - diff --git a/backend/cmd/server/bootstrap/02-server-configurations.yaml b/backend/cmd/server/bootstrap/02-server-configurations.yaml new file mode 100644 index 0000000000..4dc88d6c72 --- /dev/null +++ b/backend/cmd/server/bootstrap/02-server-configurations.yaml @@ -0,0 +1,16 @@ +resource_type: server_config +name: flow +value: + authFlow: + defaultHandle: default-flow + expirySeconds: 1800 + registrationFlow: + expirySeconds: 3600 + recoveryFlow: + expirySeconds: 1800 + signOutFlow: + defaultHandle: default-flow + expirySeconds: 1800 + userOnboardingFlow: + defaultHandle: default-flow + expirySeconds: 86400 diff --git a/backend/cmd/server/config/default.json b/backend/cmd/server/config/default.json index 2178a39dbd..efcf3c5b69 100644 --- a/backend/cmd/server/config/default.json +++ b/backend/cmd/server/config/default.json @@ -169,9 +169,6 @@ } }, "flow": { - "default_auth_flow_handle": "default-flow", - "default_signout_flow_handle": "default-flow", - "user_onboarding_flow_handle": "default-flow", "max_version_history": 10, "auto_infer_registration": false, "store": "composite" diff --git a/backend/cmd/server/servicemanager.go b/backend/cmd/server/servicemanager.go index c56c00ff55..2daa763dba 100644 --- a/backend/cmd/server/servicemanager.go +++ b/backend/cmd/server/servicemanager.go @@ -304,11 +304,17 @@ func registerServices(mux *http.ServeMux, cacheManager cache.CacheManagerInterfa emailClient := initEmailClient(ctx, logger) + // Create the flow server-config handler early so it can be registered before serverconfig is + // initialized. The handle-existence validator is injected in a second phase after flowMgtService + // is available. + flowConfigHandler := flowmgt.NewFlowConfigHandler() + // Initialize server-wide configuration after its handler dependencies. serverConfigHandlers := map[serverconfig.ConfigName]serverconfig.ServerConfigHandlerInterface{ serverconfig.ConfigNameCORS: cors.OriginHandler{}, serverconfig.ConfigNameDefaultResourceServer: resource.NewDefaultResourceServerConfigHandler(resourceService), serverconfig.ConfigNameSession: flowsession.ConfigHandler{}, + serverconfig.ConfigNameFlow: flowConfigHandler, } serverConfigService, serverConfigExporter, err := serverconfig.Initialize(mux, cacheManager, serverConfigHandlers) fatalOnError(ctx, logger, err, "Failed to initialize server config service") @@ -367,7 +373,8 @@ func registerServices(mux *http.ServeMux, cacheManager cache.CacheManagerInterfa ) flowMgtService, flowMgtExporter, err := flowmgt.Initialize( - mux, mcpServer, cacheManager, flowFactory, execRegistry, interceptorRegistry, graphBuilder) + mux, mcpServer, cacheManager, flowFactory, execRegistry, interceptorRegistry, graphBuilder, + serverConfigService, ouService, flowConfigHandler) fatalOnError(ctx, logger, err, "Failed to initialize FlowMgtService") // Two-phase initialization: inject the flow resolver into the OU service. @@ -463,7 +470,7 @@ func registerServices(mux *http.ServeMux, cacheManager cache.CacheManagerInterfa attestationProvider := initAttestationProvider(ctx, logger, runtimeCryptoSvc) flowExecService, err := flowexec.Initialize(mux, flowMgtService, actorProvider, execRegistry, interceptorRegistry, observabilitySvc, runtimeCryptoSvc, attestationProvider, - graphBuilder, runtimeStoreProvider, transactioner, flowConfig) + graphBuilder, runtimeStoreProvider, transactioner, serverConfigService, flowConfig) fatalOnError(ctx, logger, err, "Failed to initialize flow execution service") // Initialize OAuth services. diff --git a/backend/internal/flow/config/config.go b/backend/internal/flow/config/config.go index fe4d9461d9..edbd43b981 100644 --- a/backend/internal/flow/config/config.go +++ b/backend/internal/flow/config/config.go @@ -36,6 +36,22 @@ type Config struct { Session flowsession.Config } +// FlowTypeConfig holds the server-level defaults for one flow type. +type FlowTypeConfig struct { + DefaultHandle string `json:"defaultHandle,omitempty"` + ExpirySeconds int64 `json:"expirySeconds,omitempty"` +} + +// FlowSectionConfig is the value of the server-config "flow" section. It carries per-type default +// handles and context TTLs. A zero ExpirySeconds falls back to the built-in default for that type. +type FlowSectionConfig struct { + AuthFlow FlowTypeConfig `json:"authFlow"` + RegistrationFlow FlowTypeConfig `json:"registrationFlow"` + UserOnboardingFlow FlowTypeConfig `json:"userOnboardingFlow"` + RecoveryFlow FlowTypeConfig `json:"recoveryFlow"` + SignOutFlow FlowTypeConfig `json:"signOutFlow"` +} + // FromServerRuntime builds flow configuration from the global server runtime. func FromServerRuntime() Config { runtime := config.GetServerRuntime() diff --git a/backend/internal/flow/config/config_test.go b/backend/internal/flow/config/config_test.go index 4083ccaec3..6c407bc0f2 100644 --- a/backend/internal/flow/config/config_test.go +++ b/backend/internal/flow/config/config_test.go @@ -46,7 +46,7 @@ func (s *FlowConfigTestSuite) TearDownTest() { func (s *FlowConfigTestSuite) TestFromServerRuntime() { cfg := &config.Config{ - Flow: engineconfig.FlowConfig{UserOnboardingFlowHandle: "onboarding-handle"}, + Flow: engineconfig.FlowConfig{}, Server: engineconfig.ServerConfig{ HTTPOnly: true, }, @@ -56,7 +56,6 @@ func (s *FlowConfigTestSuite) TestFromServerRuntime() { result := FromServerRuntime() - s.Equal("onboarding-handle", result.Flow.UserOnboardingFlowHandle) s.False(result.SecureCookies, "HTTPOnly deployment must not mark cookies Secure") // Session config is sourced from the server-config section at the composition root, not here. s.Zero(result.Session.IdleTimeoutSeconds) diff --git a/backend/internal/flow/flowexec/constants.go b/backend/internal/flow/flowexec/constants.go index 1ab7485ade..2360eb78be 100644 --- a/backend/internal/flow/flowexec/constants.go +++ b/backend/internal/flow/flowexec/constants.go @@ -23,6 +23,7 @@ const ( defaultRegistrationFlowExpiry int64 = 3600 // 60 minutes in seconds defaultUserOnboardingFlowExpiry int64 = 86400 // 24 hours in seconds defaultRecoveryFlowExpiry int64 = 1800 // 30 minutes in seconds + defaultSignOutFlowExpiry int64 = 1800 // 30 minutes in seconds fieldFlowSecret = "flowSecret" diff --git a/backend/internal/flow/flowexec/flowDefaultsProvider_mock_test.go b/backend/internal/flow/flowexec/flowDefaultsProvider_mock_test.go new file mode 100644 index 0000000000..d60b928555 --- /dev/null +++ b/backend/internal/flow/flowexec/flowDefaultsProvider_mock_test.go @@ -0,0 +1,165 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package flowexec + +import ( + "context" + + mock "github.com/stretchr/testify/mock" + "github.com/thunder-id/thunderid/pkg/thunderidengine/common" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" +) + +// newFlowDefaultsProviderMock creates a new instance of flowDefaultsProviderMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func newFlowDefaultsProviderMock(t interface { + mock.TestingT + Cleanup(func()) +}) *flowDefaultsProviderMock { + mock := &flowDefaultsProviderMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// flowDefaultsProviderMock is an autogenerated mock type for the flowDefaultsProvider type +type flowDefaultsProviderMock struct { + mock.Mock +} + +type flowDefaultsProviderMock_Expecter struct { + mock *mock.Mock +} + +func (_m *flowDefaultsProviderMock) EXPECT() *flowDefaultsProviderMock_Expecter { + return &flowDefaultsProviderMock_Expecter{mock: &_m.Mock} +} + +// GetFlowExpirySeconds provides a mock function for the type flowDefaultsProviderMock +func (_mock *flowDefaultsProviderMock) GetFlowExpirySeconds(ctx context.Context, flowType providers.FlowType) int64 { + ret := _mock.Called(ctx, flowType) + + if len(ret) == 0 { + panic("no return value specified for GetFlowExpirySeconds") + } + + var r0 int64 + if returnFunc, ok := ret.Get(0).(func(context.Context, providers.FlowType) int64); ok { + r0 = returnFunc(ctx, flowType) + } else { + r0 = ret.Get(0).(int64) + } + return r0 +} + +// flowDefaultsProviderMock_GetFlowExpirySeconds_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetFlowExpirySeconds' +type flowDefaultsProviderMock_GetFlowExpirySeconds_Call struct { + *mock.Call +} + +// GetFlowExpirySeconds is a helper method to define mock.On call +// - ctx context.Context +// - flowType providers.FlowType +func (_e *flowDefaultsProviderMock_Expecter) GetFlowExpirySeconds(ctx interface{}, flowType interface{}) *flowDefaultsProviderMock_GetFlowExpirySeconds_Call { + return &flowDefaultsProviderMock_GetFlowExpirySeconds_Call{Call: _e.mock.On("GetFlowExpirySeconds", ctx, flowType)} +} + +func (_c *flowDefaultsProviderMock_GetFlowExpirySeconds_Call) Run(run func(ctx context.Context, flowType providers.FlowType)) *flowDefaultsProviderMock_GetFlowExpirySeconds_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 providers.FlowType + if args[1] != nil { + arg1 = args[1].(providers.FlowType) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *flowDefaultsProviderMock_GetFlowExpirySeconds_Call) Return(n int64) *flowDefaultsProviderMock_GetFlowExpirySeconds_Call { + _c.Call.Return(n) + return _c +} + +func (_c *flowDefaultsProviderMock_GetFlowExpirySeconds_Call) RunAndReturn(run func(ctx context.Context, flowType providers.FlowType) int64) *flowDefaultsProviderMock_GetFlowExpirySeconds_Call { + _c.Call.Return(run) + return _c +} + +// ResolveDefaultFlowHandle provides a mock function for the type flowDefaultsProviderMock +func (_mock *flowDefaultsProviderMock) ResolveDefaultFlowHandle(ctx context.Context, flowType providers.FlowType) (string, *common.ServiceError) { + ret := _mock.Called(ctx, flowType) + + if len(ret) == 0 { + panic("no return value specified for ResolveDefaultFlowHandle") + } + + var r0 string + var r1 *common.ServiceError + if returnFunc, ok := ret.Get(0).(func(context.Context, providers.FlowType) (string, *common.ServiceError)); ok { + return returnFunc(ctx, flowType) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, providers.FlowType) string); ok { + r0 = returnFunc(ctx, flowType) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, providers.FlowType) *common.ServiceError); ok { + r1 = returnFunc(ctx, flowType) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*common.ServiceError) + } + } + return r0, r1 +} + +// flowDefaultsProviderMock_ResolveDefaultFlowHandle_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveDefaultFlowHandle' +type flowDefaultsProviderMock_ResolveDefaultFlowHandle_Call struct { + *mock.Call +} + +// ResolveDefaultFlowHandle is a helper method to define mock.On call +// - ctx context.Context +// - flowType providers.FlowType +func (_e *flowDefaultsProviderMock_Expecter) ResolveDefaultFlowHandle(ctx interface{}, flowType interface{}) *flowDefaultsProviderMock_ResolveDefaultFlowHandle_Call { + return &flowDefaultsProviderMock_ResolveDefaultFlowHandle_Call{Call: _e.mock.On("ResolveDefaultFlowHandle", ctx, flowType)} +} + +func (_c *flowDefaultsProviderMock_ResolveDefaultFlowHandle_Call) Run(run func(ctx context.Context, flowType providers.FlowType)) *flowDefaultsProviderMock_ResolveDefaultFlowHandle_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 providers.FlowType + if args[1] != nil { + arg1 = args[1].(providers.FlowType) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *flowDefaultsProviderMock_ResolveDefaultFlowHandle_Call) Return(s string, serviceError *common.ServiceError) *flowDefaultsProviderMock_ResolveDefaultFlowHandle_Call { + _c.Call.Return(s, serviceError) + return _c +} + +func (_c *flowDefaultsProviderMock_ResolveDefaultFlowHandle_Call) RunAndReturn(run func(ctx context.Context, flowType providers.FlowType) (string, *common.ServiceError)) *flowDefaultsProviderMock_ResolveDefaultFlowHandle_Call { + _c.Call.Return(run) + return _c +} diff --git a/backend/internal/flow/flowexec/init.go b/backend/internal/flow/flowexec/init.go index e6fc103015..393399876d 100644 --- a/backend/internal/flow/flowexec/init.go +++ b/backend/internal/flow/flowexec/init.go @@ -44,6 +44,7 @@ func Initialize( graphBuilder graphbuilder.GraphBuilderInterface, storeProvider providers.RuntimeStoreProvider, transactioner providers.Transactioner, + serverConfigSvc serverConfigProvider, cfg flowconfig.Config, ) (FlowExecServiceInterface, error) { flowStore := newFlowStore(storeProvider) @@ -52,7 +53,7 @@ func Initialize( flowProvider, graphBuilder) flowExecService := newFlowExecService(flowProvider, flowStore, flowEngine, actorProvider, observabilitySvc, transactioner, cryptoSvc, attestationVerifier, - graphBuilder, cfg) + graphBuilder, serverConfigSvc, cfg) // Mark the SSO cookie Secure unless the deployment is configured to serve over plain HTTP, and // bound its lifetime to the session's configured absolute timeout (same fallback as the session diff --git a/backend/internal/flow/flowexec/serverConfigProvider_mock_test.go b/backend/internal/flow/flowexec/serverConfigProvider_mock_test.go new file mode 100644 index 0000000000..a434ca9ded --- /dev/null +++ b/backend/internal/flow/flowexec/serverConfigProvider_mock_test.go @@ -0,0 +1,109 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package flowexec + +import ( + "context" + + mock "github.com/stretchr/testify/mock" + "github.com/thunder-id/thunderid/pkg/thunderidengine/common" +) + +// newServerConfigProviderMock creates a new instance of serverConfigProviderMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func newServerConfigProviderMock(t interface { + mock.TestingT + Cleanup(func()) +}) *serverConfigProviderMock { + mock := &serverConfigProviderMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// serverConfigProviderMock is an autogenerated mock type for the serverConfigProvider type +type serverConfigProviderMock struct { + mock.Mock +} + +type serverConfigProviderMock_Expecter struct { + mock *mock.Mock +} + +func (_m *serverConfigProviderMock) EXPECT() *serverConfigProviderMock_Expecter { + return &serverConfigProviderMock_Expecter{mock: &_m.Mock} +} + +// GetMergedConfig provides a mock function for the type serverConfigProviderMock +func (_mock *serverConfigProviderMock) GetMergedConfig(ctx context.Context, name string) (any, *common.ServiceError) { + ret := _mock.Called(ctx, name) + + if len(ret) == 0 { + panic("no return value specified for GetMergedConfig") + } + + var r0 any + var r1 *common.ServiceError + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (any, *common.ServiceError)); ok { + return returnFunc(ctx, name) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) any); ok { + r0 = returnFunc(ctx, name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(any) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) *common.ServiceError); ok { + r1 = returnFunc(ctx, name) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*common.ServiceError) + } + } + return r0, r1 +} + +// serverConfigProviderMock_GetMergedConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetMergedConfig' +type serverConfigProviderMock_GetMergedConfig_Call struct { + *mock.Call +} + +// GetMergedConfig is a helper method to define mock.On call +// - ctx context.Context +// - name string +func (_e *serverConfigProviderMock_Expecter) GetMergedConfig(ctx interface{}, name interface{}) *serverConfigProviderMock_GetMergedConfig_Call { + return &serverConfigProviderMock_GetMergedConfig_Call{Call: _e.mock.On("GetMergedConfig", ctx, name)} +} + +func (_c *serverConfigProviderMock_GetMergedConfig_Call) Run(run func(ctx context.Context, name string)) *serverConfigProviderMock_GetMergedConfig_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *serverConfigProviderMock_GetMergedConfig_Call) Return(v any, serviceError *common.ServiceError) *serverConfigProviderMock_GetMergedConfig_Call { + _c.Call.Return(v, serviceError) + return _c +} + +func (_c *serverConfigProviderMock_GetMergedConfig_Call) RunAndReturn(run func(ctx context.Context, name string) (any, *common.ServiceError)) *serverConfigProviderMock_GetMergedConfig_Call { + _c.Call.Return(run) + return _c +} diff --git a/backend/internal/flow/flowexec/service.go b/backend/internal/flow/flowexec/service.go index 46acb73e66..6d936dc829 100644 --- a/backend/internal/flow/flowexec/service.go +++ b/backend/internal/flow/flowexec/service.go @@ -44,6 +44,13 @@ import ( "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" ) +// serverConfigProvider is the narrow subset of serverconfig.ServerConfigService consumed by flowexec +// for reading the "flow" section (per-type default handles and expiries). Defined locally so flowexec +// does not import the serverconfig package. +type serverConfigProvider interface { + GetMergedConfig(ctx context.Context, name string) (any, *tidcommon.ServiceError) +} + // flowExecService is the implementation of FlowExecServiceInterface type flowExecService struct { flowEngine flowEngineInterface @@ -55,6 +62,7 @@ type flowExecService struct { transactioner providers.Transactioner cryptoSvc kmprovider.RuntimeCryptoProvider attestationVerifier providers.AttestationProvider + serverConfigSvc serverConfigProvider cfg flowconfig.Config } @@ -67,6 +75,7 @@ func newFlowExecService(flowProvider providers.FlowProvider, cryptoSvc kmprovider.RuntimeCryptoProvider, attestationVerifier providers.AttestationProvider, graphBuilder graphbuilder.GraphBuilderInterface, + serverConfigSvc serverConfigProvider, cfg flowconfig.Config) FlowExecServiceInterface { return &flowExecService{ flowProvider: flowProvider, @@ -78,6 +87,7 @@ func newFlowExecService(flowProvider providers.FlowProvider, cryptoSvc: cryptoSvc, attestationVerifier: attestationVerifier, graphBuilder: graphBuilder, + serverConfigSvc: serverConfigSvc, cfg: cfg, } } @@ -417,7 +427,7 @@ func (s *flowExecService) fallbackToDefaultFlow(ctx context.Context, graphID str return nil, &tidcommon.InternalServerError } - handle := s.cfg.Flow.DefaultAuthFlowHandle + handle := s.resolveDefaultFlowHandle(ctx, providers.FlowTypeAuthentication) logger.Warn(ctx, "Configured authentication flow not found; falling back to default flow", log.String("graphID", graphID), log.String("defaultFlowHandle", handle)) @@ -430,23 +440,6 @@ func (s *flowExecService) fallbackToDefaultFlow(ctx context.Context, graphID str return flow, nil } -// getFlowExpirySeconds returns the expiry time for a flow in seconds. -func (s *flowExecService) getFlowExpirySeconds(flowType providers.FlowType) int64 { - switch flowType { - case providers.FlowTypeAuthentication: - return defaultAuthFlowExpiry - case providers.FlowTypeRegistration: - return defaultRegistrationFlowExpiry - case providers.FlowTypeUserOnboarding: - return defaultUserOnboardingFlowExpiry - case providers.FlowTypeRecovery: - return defaultRecoveryFlowExpiry - default: - // Fallback to auth flow expiry - return defaultAuthFlowExpiry - } -} - // loadPrevContext retrieves the flow context from the store based on the given details. func (s *flowExecService) loadPrevContext(ctx context.Context, executionID, action string, inputs map[string]string, logger *log.Logger) (*EngineContext, *tidcommon.ServiceError) { @@ -607,7 +600,7 @@ func (s *flowExecService) storeContext(ctx context.Context, engineCtx *EngineCon } if expirySeconds <= 0 { - expirySeconds = s.getFlowExpirySeconds(engineCtx.FlowType) + expirySeconds = s.getFlowExpirySeconds(ctx, engineCtx.FlowType) } encryptedEngineCtx, err := s.encryptEngineContext(ctx, engineCtx) @@ -730,14 +723,14 @@ func isNewFlow(executionID string) bool { // getSystemFlowGraph retrieves the flow graph for system flows by handle. func (s *flowExecService) getSystemFlowGraph(ctx context.Context, flowType providers.FlowType, logger *log.Logger) (string, *tidcommon.ServiceError) { - handle := "" switch flowType { case providers.FlowTypeUserOnboarding: - handle = s.cfg.Flow.UserOnboardingFlowHandle default: return "", &ErrorInvalidFlowType } + handle := s.resolveDefaultFlowHandle(ctx, flowType) + flow, err := s.flowProvider.GetFlowByHandle(ctx, handle, flowType) if err != nil { logger.Error(ctx, "Failed to get system flow by handle", @@ -923,6 +916,82 @@ func (s *flowExecService) getFlowContext(ctx context.Context, executionID string return dbModel, nil } +// getFlowSectionConfig reads the "flow" section from serverconfig and returns the merged value. +// Returns a zero FlowSectionConfig when the serverconfig service is not wired or the section is +// absent, letting callers fall back to built-in defaults. +func (s *flowExecService) getFlowSectionConfig(ctx context.Context) flowconfig.FlowSectionConfig { + logger := log.GetLogger().With(log.String(log.LoggerKeyComponentName, "FlowExecService")) + + if s.serverConfigSvc == nil { + return flowconfig.FlowSectionConfig{} + } + + merged, svcErr := s.serverConfigSvc.GetMergedConfig(ctx, "flow") + if svcErr != nil { + logger.Error(ctx, "Failed to retrieve merged flow section from serverconfig", + log.String("error", svcErr.Error.DefaultValue)) + return flowconfig.FlowSectionConfig{} + } + + cfg, ok := merged.(flowconfig.FlowSectionConfig) + if !ok { + logger.Error(ctx, "Unexpected type for merged flow server config; using built-in defaults", + log.String("type", fmt.Sprintf("%T", merged))) + return flowconfig.FlowSectionConfig{} + } + + return cfg +} + +// getFlowExpirySeconds returns the context TTL for the given flow type. +func (s *flowExecService) getFlowExpirySeconds(ctx context.Context, flowType providers.FlowType) int64 { + cfg := s.getFlowSectionConfig(ctx) + + switch flowType { + case providers.FlowTypeAuthentication: + return firstPositiveExpiry(cfg.AuthFlow.ExpirySeconds, defaultAuthFlowExpiry) + case providers.FlowTypeRegistration: + return firstPositiveExpiry(cfg.RegistrationFlow.ExpirySeconds, defaultRegistrationFlowExpiry) + case providers.FlowTypeUserOnboarding: + return firstPositiveExpiry(cfg.UserOnboardingFlow.ExpirySeconds, defaultUserOnboardingFlowExpiry) + case providers.FlowTypeRecovery: + return firstPositiveExpiry(cfg.RecoveryFlow.ExpirySeconds, defaultRecoveryFlowExpiry) + case providers.FlowTypeSignOut: + return firstPositiveExpiry(cfg.SignOutFlow.ExpirySeconds, defaultSignOutFlowExpiry) + default: + return defaultAuthFlowExpiry + } +} + +// resolveDefaultFlowHandle returns the server-level default handle for the given flow type, or "" +// when no default is configured. +func (s *flowExecService) resolveDefaultFlowHandle(ctx context.Context, flowType providers.FlowType) string { + cfg := s.getFlowSectionConfig(ctx) + + switch flowType { + case providers.FlowTypeAuthentication: + return cfg.AuthFlow.DefaultHandle + case providers.FlowTypeRegistration: + return cfg.RegistrationFlow.DefaultHandle + case providers.FlowTypeUserOnboarding: + return cfg.UserOnboardingFlow.DefaultHandle + case providers.FlowTypeRecovery: + return cfg.RecoveryFlow.DefaultHandle + case providers.FlowTypeSignOut: + return cfg.SignOutFlow.DefaultHandle + default: + return "" + } +} + +// firstPositiveExpiry returns the first positive value between v and fallback, or fallback if v is non-positive. +func firstPositiveExpiry(v, fallback int64) int64 { + if v > 0 { + return v + } + return fallback +} + // isContextEncrypted reports whether a context string is in encrypted form by checking for an alg field. func isContextEncrypted(context string) bool { var encCheck struct { diff --git a/backend/internal/flow/flowexec/service_test.go b/backend/internal/flow/flowexec/service_test.go index f0db33d727..a068482f23 100644 --- a/backend/internal/flow/flowexec/service_test.go +++ b/backend/internal/flow/flowexec/service_test.go @@ -78,15 +78,25 @@ func (s *stubTransactioner) Transact(ctx context.Context, txFunc func(context.Co const testUserOnboardingFlowHandle = "onboarding-handle" const testDefaultAuthFlowHandle = "default-auth-handle" -var testFlowConfig = engineconfig.FlowConfig{ - UserOnboardingFlowHandle: testUserOnboardingFlowHandle, - DefaultAuthFlowHandle: testDefaultAuthFlowHandle, -} +var testFlowConfig = engineconfig.FlowConfig{} var testFlowExecCfg = flowconfig.Config{ Flow: testFlowConfig, } +// stubServerConfig is a test implementation of serverConfigProvider that returns +// a pre-configured FlowSectionConfig for the "flow" section. +type stubServerConfig struct { + cfg flowconfig.FlowSectionConfig +} + +func (s stubServerConfig) GetMergedConfig(_ context.Context, name string) (any, *tidcommon.ServiceError) { + if name == "flow" { + return s.cfg, nil + } + return nil, nil +} + type ServiceTestSuite struct { suite.Suite } @@ -264,6 +274,10 @@ func TestInitiateFlowSuccessScenarios(t *testing.T) { transactioner: &stubTransactioner{}, cryptoSvc: mockCrypto, cfg: testFlowExecCfg, + serverConfigSvc: stubServerConfig{cfg: flowconfig.FlowSectionConfig{ + AuthFlow: flowconfig.FlowTypeConfig{DefaultHandle: testDefaultAuthFlowHandle}, + UserOnboardingFlow: flowconfig.FlowTypeConfig{DefaultHandle: testUserOnboardingFlowHandle}, + }}, } initContext := &FlowInitContext{ @@ -452,6 +466,10 @@ func TestInitiateFlowErrorScenarios(t *testing.T) { transactioner: &stubTransactioner{}, cryptoSvc: mockCrypto, cfg: testFlowExecCfg, + serverConfigSvc: stubServerConfig{cfg: flowconfig.FlowSectionConfig{ + AuthFlow: flowconfig.FlowTypeConfig{DefaultHandle: testDefaultAuthFlowHandle}, + UserOnboardingFlow: flowconfig.FlowTypeConfig{DefaultHandle: testUserOnboardingFlowHandle}, + }}, } initContext := &FlowInitContext{ @@ -511,6 +529,9 @@ func TestInitiateFlowFallsBackToDefaultFlow(t *testing.T) { transactioner: &stubTransactioner{}, cryptoSvc: mockCrypto, cfg: testFlowExecCfg, + serverConfigSvc: stubServerConfig{cfg: flowconfig.FlowSectionConfig{ + AuthFlow: flowconfig.FlowTypeConfig{DefaultHandle: testDefaultAuthFlowHandle}, + }}, } mockInboundClient.EXPECT().GetInboundClientByEntityID(mock.Anything, appID). @@ -589,6 +610,9 @@ func TestInitiateFlowFallsBackToDefaultFlow(t *testing.T) { actorProvider: actorprovider.Initialize(mockInboundClient, mockEntityProvider, noopAuthnMgr(), nil), transactioner: &stubTransactioner{}, cfg: testFlowExecCfg, + serverConfigSvc: stubServerConfig{cfg: flowconfig.FlowSectionConfig{ + AuthFlow: flowconfig.FlowTypeConfig{DefaultHandle: testDefaultAuthFlowHandle}, + }}, } mockInboundClient.EXPECT().GetInboundClientByEntityID(mock.Anything, appID). @@ -623,28 +647,28 @@ func TestGetFlowExpirySeconds(t *testing.T) { { name: "Authentication flow", flowType: providers.FlowTypeAuthentication, - expected: defaultAuthFlowExpiry, + expected: 1800, }, { name: "Registration flow", flowType: providers.FlowTypeRegistration, - expected: defaultRegistrationFlowExpiry, + expected: 3600, }, { name: "User onboarding flow", flowType: providers.FlowTypeUserOnboarding, - expected: defaultUserOnboardingFlowExpiry, + expected: 86400, }, { name: "Unknown flow type (fallback)", flowType: providers.FlowType("UNKNOWN_FLOW"), - expected: defaultAuthFlowExpiry, + expected: 1800, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := service.getFlowExpirySeconds(tt.flowType) + result := service.getFlowExpirySeconds(context.Background(), tt.flowType) assert.Equal(t, tt.expected, result) }) } @@ -1730,7 +1754,7 @@ func TestInitiateAndExecute_ZeroExpiryUsesDefault(t *testing.T) { mockCrypto.EXPECT().Encrypt(mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return([]byte("encrypted"), nil, nil) mockStore.EXPECT().StoreFlowContext(mock.Anything, mock.Anything, - mock.MatchedBy(func(exp int64) bool { return exp == defaultAuthFlowExpiry })). + mock.MatchedBy(func(exp int64) bool { return exp == int64(1800) })). Return(nil) mockEngineInner.EXPECT().Execute(mock.Anything). Return(FlowStep{Status: providers.FlowStatusIncomplete}, nil) @@ -2305,7 +2329,7 @@ func (s *ServiceTestSuite) TestSetApplicationToContext_UserOnboardingSkipped() { func (s *ServiceTestSuite) TestGetFlowExpirySeconds_RecoveryFlow() { service := &flowExecService{cfg: testFlowExecCfg} - s.Equal(defaultRecoveryFlowExpiry, service.getFlowExpirySeconds(providers.FlowTypeRecovery)) + s.Equal(int64(1800), service.getFlowExpirySeconds(context.Background(), providers.FlowTypeRecovery)) } func (s *ServiceTestSuite) TestLoadContextFromStore_EmptyExecutionID() { @@ -2353,6 +2377,9 @@ func (s *ServiceTestSuite) TestGetSystemFlowGraph_GetFlowByHandleError() { graphBuilder: mockGraphBuilder, flowProvider: mockFlowProvider, cfg: testFlowExecCfg, + serverConfigSvc: stubServerConfig{cfg: flowconfig.FlowSectionConfig{ + UserOnboardingFlow: flowconfig.FlowTypeConfig{DefaultHandle: testUserOnboardingFlowHandle}, + }}, } mockFlowProvider.EXPECT().GetFlowByHandle(mock.Anything, testUserOnboardingFlowHandle, @@ -3128,6 +3155,134 @@ func (s *ServiceTestSuite) TestLoadContextFromStore_GetFlowGraphError() { s.NotNil(svcErr) } +// ----- firstPositiveExpiry ----- + +func (s *ServiceTestSuite) TestFirstPositiveExpiry_PositiveVWins() { + s.Equal(int64(300), firstPositiveExpiry(300, 1800)) +} + +func (s *ServiceTestSuite) TestFirstPositiveExpiry_ZeroVFallsBack() { + s.Equal(int64(1800), firstPositiveExpiry(0, 1800)) +} + +func (s *ServiceTestSuite) TestFirstPositiveExpiry_NegativeVFallsBack() { + s.Equal(int64(1800), firstPositiveExpiry(-5, 1800)) +} + +// ----- resolveDefaultFlowHandle ----- + +func (s *ServiceTestSuite) TestResolveDefaultFlowHandle_Authentication() { + svc := &flowExecService{ + serverConfigSvc: stubServerConfig{cfg: flowconfig.FlowSectionConfig{ + AuthFlow: flowconfig.FlowTypeConfig{DefaultHandle: "h-auth"}, + }}, + cfg: testFlowExecCfg, + } + s.Equal("h-auth", svc.resolveDefaultFlowHandle(context.Background(), providers.FlowTypeAuthentication)) +} + +func (s *ServiceTestSuite) TestResolveDefaultFlowHandle_Registration() { + svc := &flowExecService{ + serverConfigSvc: stubServerConfig{cfg: flowconfig.FlowSectionConfig{ + RegistrationFlow: flowconfig.FlowTypeConfig{DefaultHandle: "h-reg"}, + }}, + cfg: testFlowExecCfg, + } + s.Equal("h-reg", svc.resolveDefaultFlowHandle(context.Background(), providers.FlowTypeRegistration)) +} + +func (s *ServiceTestSuite) TestResolveDefaultFlowHandle_UserOnboarding() { + svc := &flowExecService{ + serverConfigSvc: stubServerConfig{cfg: flowconfig.FlowSectionConfig{ + UserOnboardingFlow: flowconfig.FlowTypeConfig{DefaultHandle: "h-onboard"}, + }}, + cfg: testFlowExecCfg, + } + s.Equal("h-onboard", svc.resolveDefaultFlowHandle(context.Background(), providers.FlowTypeUserOnboarding)) +} + +func (s *ServiceTestSuite) TestResolveDefaultFlowHandle_Recovery() { + svc := &flowExecService{ + serverConfigSvc: stubServerConfig{cfg: flowconfig.FlowSectionConfig{ + RecoveryFlow: flowconfig.FlowTypeConfig{DefaultHandle: "h-recovery"}, + }}, + cfg: testFlowExecCfg, + } + s.Equal("h-recovery", svc.resolveDefaultFlowHandle(context.Background(), providers.FlowTypeRecovery)) +} + +func (s *ServiceTestSuite) TestResolveDefaultFlowHandle_SignOut() { + svc := &flowExecService{ + serverConfigSvc: stubServerConfig{cfg: flowconfig.FlowSectionConfig{ + SignOutFlow: flowconfig.FlowTypeConfig{DefaultHandle: "h-signout"}, + }}, + cfg: testFlowExecCfg, + } + s.Equal("h-signout", svc.resolveDefaultFlowHandle(context.Background(), providers.FlowTypeSignOut)) +} + +func (s *ServiceTestSuite) TestResolveDefaultFlowHandle_UnknownFlowTypeReturnsEmpty() { + svc := &flowExecService{ + serverConfigSvc: stubServerConfig{cfg: flowconfig.FlowSectionConfig{}}, + cfg: testFlowExecCfg, + } + s.Empty(svc.resolveDefaultFlowHandle(context.Background(), providers.FlowType("UNKNOWN"))) +} + +func (s *ServiceTestSuite) TestResolveDefaultFlowHandle_NilServerConfig() { + svc := &flowExecService{cfg: testFlowExecCfg} + s.Empty(svc.resolveDefaultFlowHandle(context.Background(), providers.FlowTypeAuthentication)) +} + +// ----- getFlowExpirySeconds with serverconfig ----- + +func (s *ServiceTestSuite) TestGetFlowExpirySeconds_AuthFlowServerConfigOverridesDefault() { + svc := &flowExecService{ + serverConfigSvc: stubServerConfig{cfg: flowconfig.FlowSectionConfig{ + AuthFlow: flowconfig.FlowTypeConfig{ExpirySeconds: 600}, + }}, + cfg: testFlowExecCfg, + } + s.Equal(int64(600), svc.getFlowExpirySeconds(context.Background(), providers.FlowTypeAuthentication)) +} + +func (s *ServiceTestSuite) TestGetFlowExpirySeconds_RegistrationFlowServerConfigOverridesDefault() { + svc := &flowExecService{ + serverConfigSvc: stubServerConfig{cfg: flowconfig.FlowSectionConfig{ + RegistrationFlow: flowconfig.FlowTypeConfig{ExpirySeconds: 7200}, + }}, + cfg: testFlowExecCfg, + } + s.Equal(int64(7200), svc.getFlowExpirySeconds(context.Background(), providers.FlowTypeRegistration)) +} + +func (s *ServiceTestSuite) TestGetFlowExpirySeconds_UserOnboardingFlowServerConfigOverridesDefault() { + svc := &flowExecService{ + serverConfigSvc: stubServerConfig{cfg: flowconfig.FlowSectionConfig{ + UserOnboardingFlow: flowconfig.FlowTypeConfig{ExpirySeconds: 43200}, + }}, + cfg: testFlowExecCfg, + } + s.Equal(int64(43200), svc.getFlowExpirySeconds(context.Background(), providers.FlowTypeUserOnboarding)) +} + +func (s *ServiceTestSuite) TestGetFlowExpirySeconds_SignOutFlowServerConfigOverridesDefault() { + svc := &flowExecService{ + serverConfigSvc: stubServerConfig{cfg: flowconfig.FlowSectionConfig{ + SignOutFlow: flowconfig.FlowTypeConfig{ExpirySeconds: 120}, + }}, + cfg: testFlowExecCfg, + } + s.Equal(int64(120), svc.getFlowExpirySeconds(context.Background(), providers.FlowTypeSignOut)) +} + +func (s *ServiceTestSuite) TestGetFlowExpirySeconds_SignOutFallsBackToDefault() { + svc := &flowExecService{ + serverConfigSvc: stubServerConfig{cfg: flowconfig.FlowSectionConfig{}}, cfg: testFlowExecCfg, + } + s.Equal(defaultSignOutFlowExpiry, svc.getFlowExpirySeconds(context.Background(), providers.FlowTypeSignOut)) +} + // noopAuthnMgr returns an authentication-provider mock with no expectations, for tests that // build a real actor provider but never exercise actor authentication. func noopAuthnMgr() *managermock.AuthnProviderManagerMock { diff --git a/backend/internal/flow/mgt/FlowMgtServiceInterface_mock_test.go b/backend/internal/flow/mgt/FlowMgtServiceInterface_mock_test.go index 5f24e25fd0..bcfcce8f97 100644 --- a/backend/internal/flow/mgt/FlowMgtServiceInterface_mock_test.go +++ b/backend/internal/flow/mgt/FlowMgtServiceInterface_mock_test.go @@ -902,6 +902,86 @@ func (_c *FlowMgtServiceInterfaceMock_ListFlows_Call) RunAndReturn(run func(ctx return _c } +// ResolveEffectiveFlowID provides a mock function for the type FlowMgtServiceInterfaceMock +func (_mock *FlowMgtServiceInterfaceMock) ResolveEffectiveFlowID(ctx context.Context, overriddenFlowID string, ouID string, flowType providers.FlowType) (string, *common.ServiceError) { + ret := _mock.Called(ctx, overriddenFlowID, ouID, flowType) + + if len(ret) == 0 { + panic("no return value specified for ResolveEffectiveFlowID") + } + + var r0 string + var r1 *common.ServiceError + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, providers.FlowType) (string, *common.ServiceError)); ok { + return returnFunc(ctx, overriddenFlowID, ouID, flowType) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, providers.FlowType) string); ok { + r0 = returnFunc(ctx, overriddenFlowID, ouID, flowType) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, providers.FlowType) *common.ServiceError); ok { + r1 = returnFunc(ctx, overriddenFlowID, ouID, flowType) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*common.ServiceError) + } + } + return r0, r1 +} + +// FlowMgtServiceInterfaceMock_ResolveEffectiveFlowID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveEffectiveFlowID' +type FlowMgtServiceInterfaceMock_ResolveEffectiveFlowID_Call struct { + *mock.Call +} + +// ResolveEffectiveFlowID is a helper method to define mock.On call +// - ctx context.Context +// - overriddenFlowID string +// - ouID string +// - flowType providers.FlowType +func (_e *FlowMgtServiceInterfaceMock_Expecter) ResolveEffectiveFlowID(ctx interface{}, overriddenFlowID interface{}, ouID interface{}, flowType interface{}) *FlowMgtServiceInterfaceMock_ResolveEffectiveFlowID_Call { + return &FlowMgtServiceInterfaceMock_ResolveEffectiveFlowID_Call{Call: _e.mock.On("ResolveEffectiveFlowID", ctx, overriddenFlowID, ouID, flowType)} +} + +func (_c *FlowMgtServiceInterfaceMock_ResolveEffectiveFlowID_Call) Run(run func(ctx context.Context, overriddenFlowID string, ouID string, flowType providers.FlowType)) *FlowMgtServiceInterfaceMock_ResolveEffectiveFlowID_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 providers.FlowType + if args[3] != nil { + arg3 = args[3].(providers.FlowType) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *FlowMgtServiceInterfaceMock_ResolveEffectiveFlowID_Call) Return(s string, serviceError *common.ServiceError) *FlowMgtServiceInterfaceMock_ResolveEffectiveFlowID_Call { + _c.Call.Return(s, serviceError) + return _c +} + +func (_c *FlowMgtServiceInterfaceMock_ResolveEffectiveFlowID_Call) RunAndReturn(run func(ctx context.Context, overriddenFlowID string, ouID string, flowType providers.FlowType) (string, *common.ServiceError)) *FlowMgtServiceInterfaceMock_ResolveEffectiveFlowID_Call { + _c.Call.Return(run) + return _c +} + // RestoreFlowVersion provides a mock function for the type FlowMgtServiceInterfaceMock func (_mock *FlowMgtServiceInterfaceMock) RestoreFlowVersion(ctx context.Context, flowID string, version int) (*providers.CompleteFlowDefinition, *common.ServiceError) { ret := _mock.Called(ctx, flowID, version) diff --git a/backend/internal/flow/mgt/init.go b/backend/internal/flow/mgt/init.go index c373e34ce0..ef7f1a7655 100644 --- a/backend/internal/flow/mgt/init.go +++ b/backend/internal/flow/mgt/init.go @@ -19,9 +19,11 @@ package flowmgt import ( + "context" "net/http" "strings" + tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -38,6 +40,11 @@ import ( "github.com/thunder-id/thunderid/internal/system/middleware" ) +// serverConfigProvider is the minimal subset of serverconfig.ServerConfigService consumed by flowmgt. +type serverConfigProvider interface { + GetMergedConfig(ctx context.Context, name string) (any, *tidcommon.ServiceError) +} + // Initialize initializes the flow management service and registers HTTP routes. func Initialize( mux *http.ServeMux, @@ -47,6 +54,9 @@ func Initialize( executorRegistry executor.ExecutorRegistryInterface, interceptorRegistry interceptor.InterceptorRegistryInterface, graphBuilder graphbuilder.GraphBuilderInterface, + serverConfigSvc serverConfigProvider, + ouSvc ouProvider, + configHandler *FlowConfigHandler, ) (FlowMgtServiceInterface, declarativeresource.ResourceExporter, error) { flowValidator := newFlowValidator(executorRegistry, interceptorRegistry, graphBuilder) store, compositeStore, transactioner, err := initializeStore(cacheManager, flowValidator) @@ -57,9 +67,17 @@ func Initialize( inferenceService := newFlowInferenceService() service := newFlowMgtService( store, inferenceService, graphBuilder, executorRegistry, - interceptorRegistry, flowValidator, compositeStore, transactioner, + interceptorRegistry, flowValidator, compositeStore, transactioner, serverConfigSvc, ouSvc, ) + // TODO: Check whether this can be improved to avoid injecting configHandler to flow mgt service + if configHandler != nil { + configHandler.SetHandleValidator(func(ctx context.Context, handle string, flowType providers.FlowType) bool { + _, svcErr := service.GetFlowByHandle(ctx, handle, flowType) + return svcErr == nil + }) + } + handler := newFlowMgtHandler(service) registerRoutes(mux, handler) diff --git a/backend/internal/flow/mgt/ouProvider_mock_test.go b/backend/internal/flow/mgt/ouProvider_mock_test.go new file mode 100644 index 0000000000..0f5a36b8b1 --- /dev/null +++ b/backend/internal/flow/mgt/ouProvider_mock_test.go @@ -0,0 +1,108 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package flowmgt + +import ( + "context" + + mock "github.com/stretchr/testify/mock" + "github.com/thunder-id/thunderid/pkg/thunderidengine/common" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" +) + +// newOuProviderMock creates a new instance of ouProviderMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func newOuProviderMock(t interface { + mock.TestingT + Cleanup(func()) +}) *ouProviderMock { + mock := &ouProviderMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// ouProviderMock is an autogenerated mock type for the ouProvider type +type ouProviderMock struct { + mock.Mock +} + +type ouProviderMock_Expecter struct { + mock *mock.Mock +} + +func (_m *ouProviderMock) EXPECT() *ouProviderMock_Expecter { + return &ouProviderMock_Expecter{mock: &_m.Mock} +} + +// GetOrganizationUnit provides a mock function for the type ouProviderMock +func (_mock *ouProviderMock) GetOrganizationUnit(ctx context.Context, id string) (providers.OrganizationUnit, *common.ServiceError) { + ret := _mock.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for GetOrganizationUnit") + } + + var r0 providers.OrganizationUnit + var r1 *common.ServiceError + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (providers.OrganizationUnit, *common.ServiceError)); ok { + return returnFunc(ctx, id) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) providers.OrganizationUnit); ok { + r0 = returnFunc(ctx, id) + } else { + r0 = ret.Get(0).(providers.OrganizationUnit) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) *common.ServiceError); ok { + r1 = returnFunc(ctx, id) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*common.ServiceError) + } + } + return r0, r1 +} + +// ouProviderMock_GetOrganizationUnit_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetOrganizationUnit' +type ouProviderMock_GetOrganizationUnit_Call struct { + *mock.Call +} + +// GetOrganizationUnit is a helper method to define mock.On call +// - ctx context.Context +// - id string +func (_e *ouProviderMock_Expecter) GetOrganizationUnit(ctx interface{}, id interface{}) *ouProviderMock_GetOrganizationUnit_Call { + return &ouProviderMock_GetOrganizationUnit_Call{Call: _e.mock.On("GetOrganizationUnit", ctx, id)} +} + +func (_c *ouProviderMock_GetOrganizationUnit_Call) Run(run func(ctx context.Context, id string)) *ouProviderMock_GetOrganizationUnit_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *ouProviderMock_GetOrganizationUnit_Call) Return(organizationUnit providers.OrganizationUnit, serviceError *common.ServiceError) *ouProviderMock_GetOrganizationUnit_Call { + _c.Call.Return(organizationUnit, serviceError) + return _c +} + +func (_c *ouProviderMock_GetOrganizationUnit_Call) RunAndReturn(run func(ctx context.Context, id string) (providers.OrganizationUnit, *common.ServiceError)) *ouProviderMock_GetOrganizationUnit_Call { + _c.Call.Return(run) + return _c +} diff --git a/backend/internal/flow/mgt/serverConfigProvider_mock_test.go b/backend/internal/flow/mgt/serverConfigProvider_mock_test.go new file mode 100644 index 0000000000..3023063c5b --- /dev/null +++ b/backend/internal/flow/mgt/serverConfigProvider_mock_test.go @@ -0,0 +1,109 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package flowmgt + +import ( + "context" + + mock "github.com/stretchr/testify/mock" + "github.com/thunder-id/thunderid/pkg/thunderidengine/common" +) + +// newServerConfigProviderMock creates a new instance of serverConfigProviderMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func newServerConfigProviderMock(t interface { + mock.TestingT + Cleanup(func()) +}) *serverConfigProviderMock { + mock := &serverConfigProviderMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// serverConfigProviderMock is an autogenerated mock type for the serverConfigProvider type +type serverConfigProviderMock struct { + mock.Mock +} + +type serverConfigProviderMock_Expecter struct { + mock *mock.Mock +} + +func (_m *serverConfigProviderMock) EXPECT() *serverConfigProviderMock_Expecter { + return &serverConfigProviderMock_Expecter{mock: &_m.Mock} +} + +// GetMergedConfig provides a mock function for the type serverConfigProviderMock +func (_mock *serverConfigProviderMock) GetMergedConfig(ctx context.Context, name string) (any, *common.ServiceError) { + ret := _mock.Called(ctx, name) + + if len(ret) == 0 { + panic("no return value specified for GetMergedConfig") + } + + var r0 any + var r1 *common.ServiceError + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (any, *common.ServiceError)); ok { + return returnFunc(ctx, name) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) any); ok { + r0 = returnFunc(ctx, name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(any) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) *common.ServiceError); ok { + r1 = returnFunc(ctx, name) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*common.ServiceError) + } + } + return r0, r1 +} + +// serverConfigProviderMock_GetMergedConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetMergedConfig' +type serverConfigProviderMock_GetMergedConfig_Call struct { + *mock.Call +} + +// GetMergedConfig is a helper method to define mock.On call +// - ctx context.Context +// - name string +func (_e *serverConfigProviderMock_Expecter) GetMergedConfig(ctx interface{}, name interface{}) *serverConfigProviderMock_GetMergedConfig_Call { + return &serverConfigProviderMock_GetMergedConfig_Call{Call: _e.mock.On("GetMergedConfig", ctx, name)} +} + +func (_c *serverConfigProviderMock_GetMergedConfig_Call) Run(run func(ctx context.Context, name string)) *serverConfigProviderMock_GetMergedConfig_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *serverConfigProviderMock_GetMergedConfig_Call) Return(v any, serviceError *common.ServiceError) *serverConfigProviderMock_GetMergedConfig_Call { + _c.Call.Return(v, serviceError) + return _c +} + +func (_c *serverConfigProviderMock_GetMergedConfig_Call) RunAndReturn(run func(ctx context.Context, name string) (any, *common.ServiceError)) *serverConfigProviderMock_GetMergedConfig_Call { + _c.Call.Return(run) + return _c +} diff --git a/backend/internal/flow/mgt/server_config.go b/backend/internal/flow/mgt/server_config.go new file mode 100644 index 0000000000..c72024fd28 --- /dev/null +++ b/backend/internal/flow/mgt/server_config.go @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package flowmgt + +import ( + "context" + "encoding/json" + "fmt" + + flowconfig "github.com/thunder-id/thunderid/internal/flow/config" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" +) + +// FlowHandleValidatorFunc is a function that reports whether a flow handle names a valid flow of +// the given type. Injected after the flow management service is initialized. +type FlowHandleValidatorFunc func( + ctx context.Context, handle string, flowType providers.FlowType) bool + +// FlowConfigHandler decodes, validates, and merges the server-config "flow" section. It implements +// the serverconfig.ServerConfigHandlerInterface structurally so this package does not need to import +// serverconfig. A FlowHandleValidatorFunc may be injected after construction to enable handle +// existence checks on API PUT; declarative-load-time Validate skips these checks (validator is nil). +type FlowConfigHandler struct { + validator FlowHandleValidatorFunc +} + +// NewFlowConfigHandler creates a FlowConfigHandler with no handle validator. Call SetHandleValidator +// after the flow management service is initialized to enable handle-existence validation on API PUT. +// TODO: This doesn't align with the dependency injection pattern, however this is the pattern followed +// in the entire code base. Revisit this later. +func NewFlowConfigHandler() *FlowConfigHandler { + return &FlowConfigHandler{} +} + +// SetHandleValidator injects the function used to verify that a flow handle exists. It should be +// called exactly once, after flowmgt.Initialize returns. +func (h *FlowConfigHandler) SetHandleValidator(fn FlowHandleValidatorFunc) { + h.validator = fn +} + +// Decode parses a raw JSON flow-section value into FlowSectionConfig. Empty input yields a zero +// FlowSectionConfig, which resolves to built-in defaults at read time. +func (h *FlowConfigHandler) Decode(raw json.RawMessage) (any, error) { + if len(raw) == 0 { + return flowconfig.FlowSectionConfig{}, nil + } + + var cfg flowconfig.FlowSectionConfig + if err := json.Unmarshal(raw, &cfg); err != nil { + return nil, err + } + + return cfg, nil +} + +// Validate checks structural constraints on the incoming FlowSectionConfig. When a validator is present, +// it additionally verifies that every non-empty handle references an existing flow of the correct type. +func (h *FlowConfigHandler) Validate(incoming, _, _ any) error { + cfg, ok := incoming.(flowconfig.FlowSectionConfig) + if !ok { + return fmt.Errorf("flow: unexpected config type %T", incoming) + } + + type entry struct { + tc flowconfig.FlowTypeConfig + flowType providers.FlowType + field string + } + entries := []entry{ + {cfg.AuthFlow, providers.FlowTypeAuthentication, "authFlow"}, + {cfg.RegistrationFlow, providers.FlowTypeRegistration, "registrationFlow"}, + {cfg.UserOnboardingFlow, providers.FlowTypeUserOnboarding, "userOnboardingFlow"}, + {cfg.RecoveryFlow, providers.FlowTypeRecovery, "recoveryFlow"}, + {cfg.SignOutFlow, providers.FlowTypeSignOut, "signOutFlow"}, + } + ctx := context.Background() + + for _, e := range entries { + if e.tc.ExpirySeconds < 0 { + return fmt.Errorf("flow: %s.expirySeconds must be >= 0", e.field) + } + if e.tc.DefaultHandle != "" && h.validator != nil { + if !h.validator(ctx, e.tc.DefaultHandle, e.flowType) { + return fmt.Errorf("flow: %s.defaultHandle %q does not reference an existing flow of the correct type", + e.field, e.tc.DefaultHandle) + } + } + } + + return nil +} + +// Merge overlays the writable (db) layer onto the read-only (declarative) layer. A non-empty +// writable handle or a positive writable expiry wins for its sub-field; otherwise the read-only +// value stands. +func (h *FlowConfigHandler) Merge(readOnly, writable any) any { + ro, _ := readOnly.(flowconfig.FlowSectionConfig) + wr, _ := writable.(flowconfig.FlowSectionConfig) + return flowconfig.FlowSectionConfig{ + AuthFlow: mergeFlowTypeConfig(ro.AuthFlow, wr.AuthFlow), + RegistrationFlow: mergeFlowTypeConfig(ro.RegistrationFlow, wr.RegistrationFlow), + UserOnboardingFlow: mergeFlowTypeConfig(ro.UserOnboardingFlow, wr.UserOnboardingFlow), + RecoveryFlow: mergeFlowTypeConfig(ro.RecoveryFlow, wr.RecoveryFlow), + SignOutFlow: mergeFlowTypeConfig(ro.SignOutFlow, wr.SignOutFlow), + } +} + +// mergeFlowTypeConfig overlays the writable (db) layer onto the read-only (declarative) layer +// for a single flow type. +func mergeFlowTypeConfig(ro, wr flowconfig.FlowTypeConfig) flowconfig.FlowTypeConfig { + merged := ro + if wr.DefaultHandle != "" { + merged.DefaultHandle = wr.DefaultHandle + } + if wr.ExpirySeconds > 0 { + merged.ExpirySeconds = wr.ExpirySeconds + } + return merged +} diff --git a/backend/internal/flow/mgt/server_config_test.go b/backend/internal/flow/mgt/server_config_test.go new file mode 100644 index 0000000000..ea056e1649 --- /dev/null +++ b/backend/internal/flow/mgt/server_config_test.go @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package flowmgt + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/suite" + + flowconfig "github.com/thunder-id/thunderid/internal/flow/config" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" +) + +type FlowConfigHandlerTestSuite struct { + suite.Suite + handler *FlowConfigHandler +} + +func TestFlowConfigHandlerTestSuite(t *testing.T) { + suite.Run(t, new(FlowConfigHandlerTestSuite)) +} + +func (s *FlowConfigHandlerTestSuite) SetupTest() { + s.handler = NewFlowConfigHandler() +} + +func (s *FlowConfigHandlerTestSuite) TestDecode_NilInput() { + result, err := s.handler.Decode(nil) + s.NoError(err) + s.Equal(flowconfig.FlowSectionConfig{}, result) +} + +func (s *FlowConfigHandlerTestSuite) TestDecode_EmptyBytes() { + result, err := s.handler.Decode(json.RawMessage{}) + s.NoError(err) + s.Equal(flowconfig.FlowSectionConfig{}, result) +} + +func (s *FlowConfigHandlerTestSuite) TestDecode_ValidJSON() { + raw := json.RawMessage(`{"authFlow":{"defaultHandle":"my-auth","expirySeconds":900}}`) + + result, err := s.handler.Decode(raw) + s.Require().NoError(err) + + cfg, ok := result.(flowconfig.FlowSectionConfig) + s.Require().True(ok) + s.Equal("my-auth", cfg.AuthFlow.DefaultHandle) + s.Equal(int64(900), cfg.AuthFlow.ExpirySeconds) +} + +func (s *FlowConfigHandlerTestSuite) TestDecode_InvalidJSON() { + _, err := s.handler.Decode(json.RawMessage(`{invalid`)) + s.Error(err) +} + +func (s *FlowConfigHandlerTestSuite) TestValidate_WrongType() { + err := s.handler.Validate("not-a-config", nil, nil) + s.Error(err) +} + +func (s *FlowConfigHandlerTestSuite) TestValidate_NegativeExpiry() { + cfg := flowconfig.FlowSectionConfig{ + AuthFlow: flowconfig.FlowTypeConfig{ExpirySeconds: -1}, + } + err := s.handler.Validate(cfg, nil, nil) + s.Error(err) +} + +func (s *FlowConfigHandlerTestSuite) TestValidate_ValidConfig() { + cfg := flowconfig.FlowSectionConfig{ + AuthFlow: flowconfig.FlowTypeConfig{DefaultHandle: "", ExpirySeconds: 1800}, + RegistrationFlow: flowconfig.FlowTypeConfig{ExpirySeconds: 3600}, + RecoveryFlow: flowconfig.FlowTypeConfig{ExpirySeconds: 1800}, + SignOutFlow: flowconfig.FlowTypeConfig{ExpirySeconds: 1800}, + } + err := s.handler.Validate(cfg, nil, nil) + s.NoError(err) +} + +func (s *FlowConfigHandlerTestSuite) TestValidate_HandleValidatorCalled() { + called := false + s.handler.SetHandleValidator(func(_ context.Context, handle string, _ providers.FlowType) bool { + called = true + return handle == "valid-handle" + }) + + cfg := flowconfig.FlowSectionConfig{ + AuthFlow: flowconfig.FlowTypeConfig{DefaultHandle: "valid-handle"}, + } + err := s.handler.Validate(cfg, nil, nil) + s.NoError(err) + s.True(called) +} + +func (s *FlowConfigHandlerTestSuite) TestValidate_HandleValidatorRejectsUnknown() { + s.handler.SetHandleValidator(func(_ context.Context, _ string, _ providers.FlowType) bool { + return false + }) + + cfg := flowconfig.FlowSectionConfig{ + SignOutFlow: flowconfig.FlowTypeConfig{DefaultHandle: "nonexistent"}, + } + err := s.handler.Validate(cfg, nil, nil) + s.Error(err) +} + +func (s *FlowConfigHandlerTestSuite) TestValidate_NoValidatorSkipsHandleCheck() { + cfg := flowconfig.FlowSectionConfig{ + AuthFlow: flowconfig.FlowTypeConfig{DefaultHandle: "any-handle"}, + } + err := s.handler.Validate(cfg, nil, nil) + s.NoError(err) +} + +func (s *FlowConfigHandlerTestSuite) TestMerge_WritableWins() { + ro := flowconfig.FlowSectionConfig{ + AuthFlow: flowconfig.FlowTypeConfig{DefaultHandle: "ro-handle", ExpirySeconds: 1800}, + } + wr := flowconfig.FlowSectionConfig{ + AuthFlow: flowconfig.FlowTypeConfig{DefaultHandle: "wr-handle", ExpirySeconds: 900}, + } + + result := s.handler.Merge(ro, wr).(flowconfig.FlowSectionConfig) + s.Equal("wr-handle", result.AuthFlow.DefaultHandle) + s.Equal(int64(900), result.AuthFlow.ExpirySeconds) +} + +func (s *FlowConfigHandlerTestSuite) TestMerge_ReadOnlyFallsBackWhenWritableZero() { + ro := flowconfig.FlowSectionConfig{ + AuthFlow: flowconfig.FlowTypeConfig{DefaultHandle: "ro-handle", ExpirySeconds: 1800}, + } + wr := flowconfig.FlowSectionConfig{ + AuthFlow: flowconfig.FlowTypeConfig{DefaultHandle: "", ExpirySeconds: 0}, + } + + result := s.handler.Merge(ro, wr).(flowconfig.FlowSectionConfig) + s.Equal("ro-handle", result.AuthFlow.DefaultHandle) + s.Equal(int64(1800), result.AuthFlow.ExpirySeconds) +} + +func (s *FlowConfigHandlerTestSuite) TestMerge_NilInputsReturnZero() { + result := s.handler.Merge(nil, nil).(flowconfig.FlowSectionConfig) + s.Equal(flowconfig.FlowSectionConfig{}, result) +} + +func (s *FlowConfigHandlerTestSuite) TestMergeFlowTypeConfig_WritableHandleWins() { + ro := flowconfig.FlowTypeConfig{DefaultHandle: "ro", ExpirySeconds: 500} + wr := flowconfig.FlowTypeConfig{DefaultHandle: "wr", ExpirySeconds: 0} + + merged := mergeFlowTypeConfig(ro, wr) + s.Equal("wr", merged.DefaultHandle) + s.Equal(int64(500), merged.ExpirySeconds) +} + +func (s *FlowConfigHandlerTestSuite) TestMergeFlowTypeConfig_WritableExpiryWins() { + ro := flowconfig.FlowTypeConfig{DefaultHandle: "ro", ExpirySeconds: 500} + wr := flowconfig.FlowTypeConfig{DefaultHandle: "", ExpirySeconds: 900} + + merged := mergeFlowTypeConfig(ro, wr) + s.Equal("ro", merged.DefaultHandle) + s.Equal(int64(900), merged.ExpirySeconds) +} diff --git a/backend/internal/flow/mgt/service.go b/backend/internal/flow/mgt/service.go index 74e40e2cd6..4a279cecd2 100644 --- a/backend/internal/flow/mgt/service.go +++ b/backend/internal/flow/mgt/service.go @@ -29,6 +29,7 @@ import ( tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" "github.com/thunder-id/thunderid/internal/flow/common" + flowconfig "github.com/thunder-id/thunderid/internal/flow/config" "github.com/thunder-id/thunderid/internal/flow/core" "github.com/thunder-id/thunderid/internal/flow/executor" "github.com/thunder-id/thunderid/internal/flow/graphbuilder" @@ -73,6 +74,15 @@ type FlowMgtServiceInterface interface { *resourcedependency.DependenciesResponse, *tidcommon.ServiceError) GetResourceDependencies( ctx context.Context, resourceType, id string) ([]resourcedependency.ResourceDependency, error) + ResolveEffectiveFlowID(ctx context.Context, overriddenFlowID, ouID string, flowType providers.FlowType) ( + string, *tidcommon.ServiceError) +} + +// ouProvider is the minimal subset of the OU service consumed by flowmgt for OU-level default +// flow resolution. Defined locally so flowmgt does not import internal/ou (avoids an import cycle +// since ou already depends on flowmgt via SetOUFlowResolver). +type ouProvider interface { + GetOrganizationUnit(ctx context.Context, id string) (providers.OrganizationUnit, *tidcommon.ServiceError) } // flowMgtService is the default implementation of the FlowMgtServiceInterface. @@ -86,6 +96,8 @@ type flowMgtService struct { compositeStore *compositeFlowStore transactioner providers.Transactioner dependencyRegistry resourcedependency.Registry + serverConfigSvc serverConfigProvider + ouSvc ouProvider logger *log.Logger } @@ -99,6 +111,8 @@ func newFlowMgtService( flowValidator FlowValidatorInterface, compositeStore *compositeFlowStore, transactioner providers.Transactioner, + serverConfigSvc serverConfigProvider, + ouSvc ouProvider, ) FlowMgtServiceInterface { return &flowMgtService{ store: store, @@ -109,6 +123,8 @@ func newFlowMgtService( flowValidator: flowValidator, compositeStore: compositeStore, transactioner: transactioner, + serverConfigSvc: serverConfigSvc, + ouSvc: ouSvc, logger: log.GetLogger().With(log.String(log.LoggerKeyComponentName, loggerComponentName)), } } @@ -670,6 +686,95 @@ func (s *flowMgtService) IsValidFlow( return flow.FlowType == flowType, nil } +// ResolveEffectiveFlowID resolves the effective flow ID to use based on the provided overridden flow ID, +// organization unit ID, and flow type. +func (s *flowMgtService) ResolveEffectiveFlowID(ctx context.Context, overriddenFlowID, ouID string, + flowType providers.FlowType) (string, *tidcommon.ServiceError) { + if overriddenFlowID != "" { + return overriddenFlowID, nil + } + + if ouID != "" && s.ouSvc != nil { + ou, ouErr := s.ouSvc.GetOrganizationUnit(ctx, ouID) + if ouErr != nil { + s.logger.Warn(ctx, "Failed to look up OU for flow resolution; falling back to server default", + log.String("ouID", ouID), log.String("error", ouErr.Error.DefaultValue)) + } else if id := ouFlowIDForType(ou, flowType); id != "" { + return id, nil + } + } + + handle := s.resolveDefaultFlowHandle(ctx, flowType) + if handle == "" { + return "", nil + } + + flow, svcErr := s.GetFlowByHandle(ctx, handle, flowType) + if svcErr != nil { + return "", svcErr + } + + return flow.ID, nil +} + +// ouFlowIDForType returns the flow ID for the given flow type from the organization unit. +func ouFlowIDForType(ou providers.OrganizationUnit, flowType providers.FlowType) string { + switch flowType { + case providers.FlowTypeAuthentication: + return ou.AuthFlowID + case providers.FlowTypeRegistration: + return ou.RegistrationFlowID + case providers.FlowTypeUserOnboarding: + return ou.UserOnboardingFlowID + case providers.FlowTypeRecovery: + return ou.RecoveryFlowID + case providers.FlowTypeSignOut: + return ou.SignOutFlowID + } + return "" +} + +// getFlowSectionConfig returns the merged FlowSectionConfig from the server-config "flow" section. +func (s *flowMgtService) getFlowSectionConfig(ctx context.Context) flowconfig.FlowSectionConfig { + if s.serverConfigSvc == nil { + return flowconfig.FlowSectionConfig{} + } + + merged, svcErr := s.serverConfigSvc.GetMergedConfig(ctx, "flow") + if svcErr != nil { + s.logger.Warn(ctx, "Failed to read flow server config; using empty section defaults") + return flowconfig.FlowSectionConfig{} + } + + cfg, ok := merged.(flowconfig.FlowSectionConfig) + if !ok { + return flowconfig.FlowSectionConfig{} + } + + return cfg +} + +// resolveDefaultFlowHandle returns the server-level default handle configured for the given flow +// type, or "" when no default is configured. +func (s *flowMgtService) resolveDefaultFlowHandle(ctx context.Context, flowType providers.FlowType) string { + cfg := s.getFlowSectionConfig(ctx) + + switch flowType { + case providers.FlowTypeAuthentication: + return cfg.AuthFlow.DefaultHandle + case providers.FlowTypeRegistration: + return cfg.RegistrationFlow.DefaultHandle + case providers.FlowTypeUserOnboarding: + return cfg.UserOnboardingFlow.DefaultHandle + case providers.FlowTypeRecovery: + return cfg.RecoveryFlow.DefaultHandle + case providers.FlowTypeSignOut: + return cfg.SignOutFlow.DefaultHandle + default: + return "" + } +} + // buildPaginationLinks constructs pagination links for the flow list response. func buildPaginationLinks(limit, offset, totalCount int) []Link { links := make([]Link, 0) diff --git a/backend/internal/flow/mgt/service_test.go b/backend/internal/flow/mgt/service_test.go index 602135bbe9..f035fdfa06 100644 --- a/backend/internal/flow/mgt/service_test.go +++ b/backend/internal/flow/mgt/service_test.go @@ -32,6 +32,7 @@ import ( "github.com/stretchr/testify/suite" "github.com/thunder-id/thunderid/internal/flow/common" + flowconfig "github.com/thunder-id/thunderid/internal/flow/config" "github.com/thunder-id/thunderid/internal/flow/executor" "github.com/thunder-id/thunderid/internal/system/config" "github.com/thunder-id/thunderid/internal/system/resourcedependency" @@ -97,7 +98,7 @@ func (s *FlowMgtServiceTestSuite) SetupTest() { s.mockInterceptorRegistry = interceptormock.NewInterceptorRegistryInterfaceMock(s.T()) s.mockValidator = NewFlowValidatorInterfaceMock(s.T()) s.service = newFlowMgtService(s.mockStore, s.mockInference, s.mockGraphBuilder, - s.mockExecutorRegistry, s.mockInterceptorRegistry, s.mockValidator, nil, &stubTransactioner{}) + s.mockExecutorRegistry, s.mockInterceptorRegistry, s.mockValidator, nil, &stubTransactioner{}, nil, nil) // UpdateFlow / DeleteFlow / RestoreFlowVersion invalidate the store cache post-transaction. // The mock is applied here so individual tests don't need to repeat the expectation. @@ -1650,7 +1651,7 @@ func (s *FlowMgtServiceTestSuite) TestTryInferRegistrationFlow_Success() { mockInterceptorRegistry := interceptormock.NewInterceptorRegistryInterfaceMock(s.T()) mockValidator := NewFlowValidatorInterfaceMock(s.T()) service := newFlowMgtService(s.mockStore, s.mockInference, s.mockGraphBuilder, - mockExecutorRegistry, mockInterceptorRegistry, mockValidator, nil, &stubTransactioner{}) + mockExecutorRegistry, mockInterceptorRegistry, mockValidator, nil, &stubTransactioner{}, nil, nil) authFlowDef := &FlowDefinition{ Handle: "auth-flow", @@ -1712,7 +1713,7 @@ func (s *FlowMgtServiceTestSuite) TestTryInferRegistrationFlow_SkipsNonAuthFlow( mockInterceptorRegistry := interceptormock.NewInterceptorRegistryInterfaceMock(s.T()) mockValidator := NewFlowValidatorInterfaceMock(s.T()) service := newFlowMgtService(s.mockStore, s.mockInference, s.mockGraphBuilder, - mockExecutorRegistry, mockInterceptorRegistry, mockValidator, nil, &stubTransactioner{}) + mockExecutorRegistry, mockInterceptorRegistry, mockValidator, nil, &stubTransactioner{}, nil, nil) regFlowDef := &FlowDefinition{ Handle: "reg-flow", @@ -1742,7 +1743,7 @@ func (s *FlowMgtServiceTestSuite) TestTryInferRegistrationFlow_HandlesInferenceE mockInterceptorRegistry := interceptormock.NewInterceptorRegistryInterfaceMock(s.T()) mockValidator := NewFlowValidatorInterfaceMock(s.T()) service := newFlowMgtService(s.mockStore, s.mockInference, s.mockGraphBuilder, - mockExecutorRegistry, mockInterceptorRegistry, mockValidator, nil, &stubTransactioner{}) + mockExecutorRegistry, mockInterceptorRegistry, mockValidator, nil, &stubTransactioner{}, nil, nil) authFlowDef := &FlowDefinition{ Handle: "auth-flow", @@ -1774,7 +1775,7 @@ func (s *FlowMgtServiceTestSuite) TestTryInferRegistrationFlow_HandlesStoreError mockInterceptorRegistry := interceptormock.NewInterceptorRegistryInterfaceMock(s.T()) mockValidator := NewFlowValidatorInterfaceMock(s.T()) service := newFlowMgtService(s.mockStore, s.mockInference, s.mockGraphBuilder, - mockExecutorRegistry, mockInterceptorRegistry, mockValidator, nil, &stubTransactioner{}) + mockExecutorRegistry, mockInterceptorRegistry, mockValidator, nil, &stubTransactioner{}, nil, nil) authFlowDef := &FlowDefinition{ Handle: "auth-flow", @@ -1816,7 +1817,7 @@ func (s *FlowMgtServiceTestSuite) TestTryInferRegistrationFlow_DisabledAutoInfer mockInterceptorRegistry := interceptormock.NewInterceptorRegistryInterfaceMock(s.T()) mockValidator := NewFlowValidatorInterfaceMock(s.T()) service := newFlowMgtService(s.mockStore, s.mockInference, s.mockGraphBuilder, - mockExecutorRegistry, mockInterceptorRegistry, mockValidator, nil, &stubTransactioner{}) + mockExecutorRegistry, mockInterceptorRegistry, mockValidator, nil, &stubTransactioner{}, nil, nil) authFlowDef := &FlowDefinition{ Handle: "auth-flow", @@ -1846,7 +1847,7 @@ func (s *FlowMgtServiceTestSuite) TestTryInferRegistrationFlow_SkipsPasskeyRegis mockInterceptorRegistry := interceptormock.NewInterceptorRegistryInterfaceMock(s.T()) mockValidator := NewFlowValidatorInterfaceMock(s.T()) service := newFlowMgtService(s.mockStore, s.mockInference, s.mockGraphBuilder, - mockExecutorRegistry, mockInterceptorRegistry, mockValidator, nil, &stubTransactioner{}) + mockExecutorRegistry, mockInterceptorRegistry, mockValidator, nil, &stubTransactioner{}, nil, nil) // Auth flow with PasskeyAuthExecutor in register_start and register_finish modes authFlowDef := &FlowDefinition{ @@ -2237,3 +2238,173 @@ func (s *FlowMgtServiceTestSuite) TestGetReachableCallTargets_StoreErrorMapsToIn s.Require().NotNil(err) s.Equal(tidcommon.InternalServerError.Code, err.Code) } + +// ----- ResolveEffectiveFlowID ----- + +func (s *FlowMgtServiceTestSuite) TestResolveEffectiveFlowID_OverriddenIDWins() { + svc := newFlowMgtService(s.mockStore, s.mockInference, s.mockGraphBuilder, + s.mockExecutorRegistry, s.mockInterceptorRegistry, s.mockValidator, nil, &stubTransactioner{}, nil, nil) + + id, svcErr := svc.ResolveEffectiveFlowID( + context.Background(), "override-id", "ou-1", providers.FlowTypeAuthentication) + + s.Nil(svcErr) + s.Equal("override-id", id) +} + +func (s *FlowMgtServiceTestSuite) TestResolveEffectiveFlowID_OUFlowIDUsedWhenNoOverride() { + mockOU := newOuProviderMock(s.T()) + svc := newFlowMgtService(s.mockStore, s.mockInference, s.mockGraphBuilder, + s.mockExecutorRegistry, s.mockInterceptorRegistry, s.mockValidator, nil, &stubTransactioner{}, nil, mockOU) + + ou := providers.OrganizationUnit{AuthFlowID: "ou-auth-flow"} + mockOU.EXPECT().GetOrganizationUnit(mock.Anything, "ou-1").Return(ou, nil) + + id, svcErr := svc.ResolveEffectiveFlowID(context.Background(), "", "ou-1", providers.FlowTypeAuthentication) + + s.Nil(svcErr) + s.Equal("ou-auth-flow", id) +} + +func (s *FlowMgtServiceTestSuite) TestResolveEffectiveFlowID_ServerDefaultUsedWhenOUHasNoFlowID() { + mockOU := newOuProviderMock(s.T()) + mockSC := newServerConfigProviderMock(s.T()) + svc := newFlowMgtService(s.mockStore, s.mockInference, s.mockGraphBuilder, + s.mockExecutorRegistry, s.mockInterceptorRegistry, s.mockValidator, nil, &stubTransactioner{}, mockSC, mockOU) + + mockOU.EXPECT().GetOrganizationUnit(mock.Anything, "ou-1").Return(providers.OrganizationUnit{}, nil) + + flowconfig := flowconfig.FlowSectionConfig{ + AuthFlow: flowconfig.FlowTypeConfig{DefaultHandle: "default-auth"}, + } + mockSC.EXPECT().GetMergedConfig(mock.Anything, "flow").Return(flowconfig, nil) + + completeFlow := &providers.CompleteFlowDefinition{ + ID: "server-default-id", Handle: "default-auth", FlowType: providers.FlowTypeAuthentication, + } + s.mockStore.EXPECT().GetFlowByHandle( + mock.Anything, "default-auth", providers.FlowTypeAuthentication, + ).Return(completeFlow, nil) + + id, svcErr := svc.ResolveEffectiveFlowID(context.Background(), "", "ou-1", providers.FlowTypeAuthentication) + + s.Nil(svcErr) + s.Equal("server-default-id", id) +} + +func (s *FlowMgtServiceTestSuite) TestResolveEffectiveFlowID_EmptyWhenNoDefaultConfigured() { + mockSC := newServerConfigProviderMock(s.T()) + svc := newFlowMgtService(s.mockStore, s.mockInference, s.mockGraphBuilder, + s.mockExecutorRegistry, s.mockInterceptorRegistry, s.mockValidator, nil, &stubTransactioner{}, mockSC, nil) + + mockSC.EXPECT().GetMergedConfig(mock.Anything, "flow").Return(flowconfig.FlowSectionConfig{}, nil) + + id, svcErr := svc.ResolveEffectiveFlowID(context.Background(), "", "", providers.FlowTypeRegistration) + + s.Nil(svcErr) + s.Empty(id) +} + +func (s *FlowMgtServiceTestSuite) TestResolveEffectiveFlowID_OULookupErrorFallsThrough() { + mockOU := newOuProviderMock(s.T()) + mockSC := newServerConfigProviderMock(s.T()) + svc := newFlowMgtService(s.mockStore, s.mockInference, s.mockGraphBuilder, + s.mockExecutorRegistry, s.mockInterceptorRegistry, s.mockValidator, nil, &stubTransactioner{}, mockSC, mockOU) + + mockOU.EXPECT().GetOrganizationUnit(mock.Anything, "ou-1"). + Return(providers.OrganizationUnit{}, &tidcommon.InternalServerError) + mockSC.EXPECT().GetMergedConfig(mock.Anything, "flow").Return(flowconfig.FlowSectionConfig{}, nil) + + id, svcErr := svc.ResolveEffectiveFlowID(context.Background(), "", "ou-1", providers.FlowTypeAuthentication) + + s.Nil(svcErr) + s.Empty(id) +} + +// ----- ouFlowIDForType ----- + +func (s *FlowMgtServiceTestSuite) TestOUFlowIDForType_AllTypes() { + ou := providers.OrganizationUnit{ + AuthFlowID: "auth-id", + RegistrationFlowID: "reg-id", + UserOnboardingFlowID: "onboard-id", + RecoveryFlowID: "recovery-id", + SignOutFlowID: "signout-id", + } + + s.Equal("auth-id", ouFlowIDForType(ou, providers.FlowTypeAuthentication)) + s.Equal("reg-id", ouFlowIDForType(ou, providers.FlowTypeRegistration)) + s.Equal("onboard-id", ouFlowIDForType(ou, providers.FlowTypeUserOnboarding)) + s.Equal("recovery-id", ouFlowIDForType(ou, providers.FlowTypeRecovery)) + s.Equal("signout-id", ouFlowIDForType(ou, providers.FlowTypeSignOut)) + s.Empty(ouFlowIDForType(ou, providers.FlowType("UNKNOWN"))) +} + +// ----- getFlowSectionConfig ----- + +func (s *FlowMgtServiceTestSuite) TestGetFlowSectionConfig_NilServerConfigSvc() { + svc := newFlowMgtService(s.mockStore, s.mockInference, s.mockGraphBuilder, + s.mockExecutorRegistry, s.mockInterceptorRegistry, s.mockValidator, nil, &stubTransactioner{}, nil, nil) + + cfg := svc.(*flowMgtService).getFlowSectionConfig(context.Background()) + + s.Equal(flowconfig.FlowSectionConfig{}, cfg) +} + +func (s *FlowMgtServiceTestSuite) TestGetFlowSectionConfig_ServerConfigError() { + mockSC := newServerConfigProviderMock(s.T()) + svc := newFlowMgtService(s.mockStore, s.mockInference, s.mockGraphBuilder, + s.mockExecutorRegistry, s.mockInterceptorRegistry, s.mockValidator, nil, &stubTransactioner{}, mockSC, nil) + + mockSC.EXPECT().GetMergedConfig(mock.Anything, "flow").Return(nil, &tidcommon.InternalServerError) + + cfg := svc.(*flowMgtService).getFlowSectionConfig(context.Background()) + + s.Equal(flowconfig.FlowSectionConfig{}, cfg) +} + +func (s *FlowMgtServiceTestSuite) TestGetFlowSectionConfig_WrongType() { + mockSC := newServerConfigProviderMock(s.T()) + svc := newFlowMgtService(s.mockStore, s.mockInference, s.mockGraphBuilder, + s.mockExecutorRegistry, s.mockInterceptorRegistry, s.mockValidator, nil, &stubTransactioner{}, mockSC, nil) + + mockSC.EXPECT().GetMergedConfig(mock.Anything, "flow").Return("not-a-flow-section-config", nil) + + cfg := svc.(*flowMgtService).getFlowSectionConfig(context.Background()) + + s.Equal(flowconfig.FlowSectionConfig{}, cfg) +} + +// ----- resolveDefaultFlowHandle ----- + +func (s *FlowMgtServiceTestSuite) TestResolveDefaultFlowHandle_AllFlowTypes() { + mockSC := newServerConfigProviderMock(s.T()) + svc := newFlowMgtService(s.mockStore, s.mockInference, s.mockGraphBuilder, + s.mockExecutorRegistry, s.mockInterceptorRegistry, s.mockValidator, nil, &stubTransactioner{}, mockSC, nil) + + section := flowconfig.FlowSectionConfig{ + AuthFlow: flowconfig.FlowTypeConfig{DefaultHandle: "h-auth"}, + RegistrationFlow: flowconfig.FlowTypeConfig{DefaultHandle: "h-reg"}, + UserOnboardingFlow: flowconfig.FlowTypeConfig{DefaultHandle: "h-onboard"}, + RecoveryFlow: flowconfig.FlowTypeConfig{DefaultHandle: "h-recovery"}, + SignOutFlow: flowconfig.FlowTypeConfig{DefaultHandle: "h-signout"}, + } + mockSC.EXPECT().GetMergedConfig(mock.Anything, "flow").Return(section, nil).Times(6) + + testCases := []struct { + flowType providers.FlowType + expected string + }{ + {providers.FlowTypeAuthentication, "h-auth"}, + {providers.FlowTypeRegistration, "h-reg"}, + {providers.FlowTypeUserOnboarding, "h-onboard"}, + {providers.FlowTypeRecovery, "h-recovery"}, + {providers.FlowTypeSignOut, "h-signout"}, + {providers.FlowType("UNKNOWN"), ""}, + } + + for _, tc := range testCases { + handle := svc.(*flowMgtService).resolveDefaultFlowHandle(context.Background(), tc.flowType) + s.Equal(tc.expected, handle) + } +} diff --git a/backend/internal/inboundclient/ouProvider_mock_test.go b/backend/internal/inboundclient/ouProvider_mock_test.go new file mode 100644 index 0000000000..e59926b72d --- /dev/null +++ b/backend/internal/inboundclient/ouProvider_mock_test.go @@ -0,0 +1,108 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package inboundclient + +import ( + "context" + + mock "github.com/stretchr/testify/mock" + "github.com/thunder-id/thunderid/pkg/thunderidengine/common" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" +) + +// newOuProviderMock creates a new instance of ouProviderMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func newOuProviderMock(t interface { + mock.TestingT + Cleanup(func()) +}) *ouProviderMock { + mock := &ouProviderMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// ouProviderMock is an autogenerated mock type for the ouProvider type +type ouProviderMock struct { + mock.Mock +} + +type ouProviderMock_Expecter struct { + mock *mock.Mock +} + +func (_m *ouProviderMock) EXPECT() *ouProviderMock_Expecter { + return &ouProviderMock_Expecter{mock: &_m.Mock} +} + +// GetOrganizationUnit provides a mock function for the type ouProviderMock +func (_mock *ouProviderMock) GetOrganizationUnit(ctx context.Context, id string) (providers.OrganizationUnit, *common.ServiceError) { + ret := _mock.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for GetOrganizationUnit") + } + + var r0 providers.OrganizationUnit + var r1 *common.ServiceError + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (providers.OrganizationUnit, *common.ServiceError)); ok { + return returnFunc(ctx, id) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) providers.OrganizationUnit); ok { + r0 = returnFunc(ctx, id) + } else { + r0 = ret.Get(0).(providers.OrganizationUnit) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) *common.ServiceError); ok { + r1 = returnFunc(ctx, id) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*common.ServiceError) + } + } + return r0, r1 +} + +// ouProviderMock_GetOrganizationUnit_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetOrganizationUnit' +type ouProviderMock_GetOrganizationUnit_Call struct { + *mock.Call +} + +// GetOrganizationUnit is a helper method to define mock.On call +// - ctx context.Context +// - id string +func (_e *ouProviderMock_Expecter) GetOrganizationUnit(ctx interface{}, id interface{}) *ouProviderMock_GetOrganizationUnit_Call { + return &ouProviderMock_GetOrganizationUnit_Call{Call: _e.mock.On("GetOrganizationUnit", ctx, id)} +} + +func (_c *ouProviderMock_GetOrganizationUnit_Call) Run(run func(ctx context.Context, id string)) *ouProviderMock_GetOrganizationUnit_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *ouProviderMock_GetOrganizationUnit_Call) Return(organizationUnit providers.OrganizationUnit, serviceError *common.ServiceError) *ouProviderMock_GetOrganizationUnit_Call { + _c.Call.Return(organizationUnit, serviceError) + return _c +} + +func (_c *ouProviderMock_GetOrganizationUnit_Call) RunAndReturn(run func(ctx context.Context, id string) (providers.OrganizationUnit, *common.ServiceError)) *ouProviderMock_GetOrganizationUnit_Call { + _c.Call.Return(run) + return _c +} diff --git a/backend/internal/inboundclient/service.go b/backend/internal/inboundclient/service.go index 96f57d957e..59cba9e2e5 100644 --- a/backend/internal/inboundclient/service.go +++ b/backend/internal/inboundclient/service.go @@ -585,61 +585,71 @@ func BuildOAuthClient( return client } -// resolveFlowDefaults fills AuthFlowID, RegistrationFlowID, RecoveryFlowID, and SignOutFlowID with system -// defaults when empty, using the auth flow's handle to locate matching flows of each type. +// resolveFlowDefaults fills AuthFlowID, RegistrationFlowID, RecoveryFlowID, and SignOutFlowID +// using a uniform chain: explicit override → OU default → server default (only auth has a +// server-level default configured; registration/recovery/signout server defaults are intentionally +// left empty to prevent CALL-node mismatches across independently configured flows). func (s *inboundClientService) resolveFlowDefaults(ctx context.Context, c *inboundmodel.InboundClient) error { if s.flowMgt == nil || c == nil { return nil } - if c.AuthFlowID == "" { - defaultHandle := config.GetServerRuntime().Config.Flow.DefaultAuthFlowHandle - flow, svcErr := s.flowMgt.GetFlowByHandle(ctx, defaultHandle, providers.FlowTypeAuthentication) - if svcErr != nil { - if svcErr.Type == tidcommon.ServerErrorType { - return ErrFKFlowServerError - } - return ErrFKFlowDefinitionRetrievalFailed + + ouID := "" + if s.entityProvider != nil && c.ID != "" { + if e, epErr := s.entityProvider.GetEntity(c.ID); epErr == nil && e != nil { + ouID = e.OUID } - c.AuthFlowID = flow.ID } - if c.RegistrationFlowID == "" && c.AuthFlowID != "" && config.GetServerRuntime().Config.Flow.AutoInferRegistration { - authFlow, svcErr := s.flowMgt.GetFlow(ctx, c.AuthFlowID) + + resolve := func(flowID string, flowType providers.FlowType) (string, error) { + id, svcErr := s.flowMgt.ResolveEffectiveFlowID(ctx, flowID, ouID, flowType) if svcErr != nil { if svcErr.Type == tidcommon.ServerErrorType { - return ErrFKFlowServerError + return "", ErrFKFlowServerError } - return ErrFKFlowDefinitionRetrievalFailed - } - regFlow, svcErr := s.flowMgt.GetFlowByHandle(ctx, authFlow.Handle, providers.FlowTypeRegistration) - if svcErr != nil { - if svcErr.Type == tidcommon.ServerErrorType { - return ErrFKFlowServerError + if svcErr.Code == flowmgt.ErrorFlowNotFound.Code { + switch flowType { + case providers.FlowTypeSignOut, providers.FlowTypeRegistration, + providers.FlowTypeRecovery, providers.FlowTypeUserOnboarding: + // Optional flows: leave unconfigured rather than failing. + return "", nil + } } - return ErrFKFlowDefinitionRetrievalFailed + return "", ErrFKFlowDefinitionRetrievalFailed } - c.RegistrationFlowID = regFlow.ID + return id, nil } + + authID, err := resolve(c.AuthFlowID, providers.FlowTypeAuthentication) + if err != nil { + return err + } + c.AuthFlowID = authID + + regID, err := resolve(c.RegistrationFlowID, providers.FlowTypeRegistration) + if err != nil { + return err + } + c.RegistrationFlowID = regID + if c.RegistrationFlowID == "" { + c.IsRegistrationFlowEnabled = false + } + + recID, err := resolve(c.RecoveryFlowID, providers.FlowTypeRecovery) + if err != nil { + return err + } + c.RecoveryFlowID = recID if c.RecoveryFlowID == "" { - // If a recovery flow is not defined, disable recovery flow for the application. c.IsRecoveryFlowEnabled = false } - if c.SignOutFlowID == "" { - defaultHandle := config.GetServerRuntime().Config.Flow.DefaultSignOutFlowHandle - if defaultHandle != "" { - flow, svcErr := s.flowMgt.GetFlowByHandle(ctx, defaultHandle, providers.FlowTypeSignOut) - switch { - case svcErr == nil: - c.SignOutFlowID = flow.ID - case svcErr.Type == tidcommon.ServerErrorType: - return ErrFKFlowServerError - case svcErr.Code == flowmgt.ErrorFlowNotFound.Code: - // Sign-out is optional; if the default sign-out flow does not exist, leave it - // unconfigured rather than failing. - default: - return ErrFKFlowDefinitionRetrievalFailed - } - } + + signOutID, err := resolve(c.SignOutFlowID, providers.FlowTypeSignOut) + if err != nil { + return err } + c.SignOutFlowID = signOutID + return nil } diff --git a/backend/internal/inboundclient/service_test.go b/backend/internal/inboundclient/service_test.go index 0fc9726745..6f31aa07cc 100644 --- a/backend/internal/inboundclient/service_test.go +++ b/backend/internal/inboundclient/service_test.go @@ -1560,81 +1560,105 @@ func (suite *InboundClientServiceTestSuite) TestResolveFlowDefaults_RecoveryFlow assert.Equal(suite.T(), "recovery-1", c.RecoveryFlowID) } -func (suite *InboundClientServiceTestSuite) TestResolveFlowDefaults_AppliesDefaultSignOutFlowWhenEmpty() { - originalSignOutHandle := sysconfig.GetServerRuntime().Config.Flow.DefaultSignOutFlowHandle - suite.T().Cleanup(func() { - sysconfig.GetServerRuntime().Config.Flow.DefaultSignOutFlowHandle = originalSignOutHandle - }) - sysconfig.GetServerRuntime().Config.Flow.DefaultSignOutFlowHandle = testDefaultSignOutFlowHandle +// All four flow types use the same explicit -> OU -> server default chain via ResolveEffectiveFlowID. +func (suite *InboundClientServiceTestSuite) TestResolveFlowDefaults_ResolvesAllFlowTypes() { flowMgt := flowmgtmock.NewFlowMgtServiceInterfaceMock(suite.T()) - flowMgt.EXPECT().GetFlowByHandle(mock.Anything, testDefaultSignOutFlowHandle, providers.FlowTypeSignOut). - Return(&providers.CompleteFlowDefinition{ID: "signout-default"}, nil).Once() + flowMgt.EXPECT().ResolveEffectiveFlowID( + mock.Anything, "auth-1", "", providers.FlowTypeAuthentication).Return("auth-1", nil).Once() + flowMgt.EXPECT().ResolveEffectiveFlowID( + mock.Anything, "reg-1", "", providers.FlowTypeRegistration).Return("reg-1", nil).Once() + flowMgt.EXPECT().ResolveEffectiveFlowID( + mock.Anything, "rec-1", "", providers.FlowTypeRecovery).Return("rec-1", nil).Once() + flowMgt.EXPECT().ResolveEffectiveFlowID( + mock.Anything, "so-1", "", providers.FlowTypeSignOut).Return("so-1", nil).Once() svc := &inboundClientService{flowMgt: flowMgt} - c := &inboundmodel.InboundClient{ID: "p1", AuthFlowID: "auth-1"} + c := &inboundmodel.InboundClient{ + ID: "p1", + AuthFlowID: "auth-1", + RegistrationFlowID: "reg-1", + IsRegistrationFlowEnabled: true, + RecoveryFlowID: "rec-1", + IsRecoveryFlowEnabled: true, + SignOutFlowID: "so-1", + } err := svc.resolveFlowDefaults(context.Background(), c) assert.NoError(suite.T(), err) - assert.Equal(suite.T(), "signout-default", c.SignOutFlowID) + assert.Equal(suite.T(), "auth-1", c.AuthFlowID) + assert.Equal(suite.T(), "reg-1", c.RegistrationFlowID) + assert.True(suite.T(), c.IsRegistrationFlowEnabled) + assert.Equal(suite.T(), "rec-1", c.RecoveryFlowID) + assert.True(suite.T(), c.IsRecoveryFlowEnabled) + assert.Equal(suite.T(), "so-1", c.SignOutFlowID) } -func (suite *InboundClientServiceTestSuite) TestResolveFlowDefaults_KeepsConfiguredSignOutFlow() { - svc := &inboundClientService{flowMgt: flowmgtmock.NewFlowMgtServiceInterfaceMock(suite.T())} - c := &inboundmodel.InboundClient{ID: "p1", AuthFlowID: "auth-1", SignOutFlowID: "signout-1"} +// Registration/recovery/signout resolve to empty when no explicit or OU override exists +// (their server-default handles are intentionally unconfigured). +func (suite *InboundClientServiceTestSuite) TestResolveFlowDefaults_NonAuthFlowsEmptyWhenNoOverride() { + flowMgt := flowmgtmock.NewFlowMgtServiceInterfaceMock(suite.T()) + flowMgt.EXPECT().ResolveEffectiveFlowID( + mock.Anything, "auth-1", "", providers.FlowTypeAuthentication).Return("auth-1", nil).Once() + flowMgt.EXPECT().ResolveEffectiveFlowID( + mock.Anything, "", "", providers.FlowTypeRegistration).Return("", nil).Once() + flowMgt.EXPECT().ResolveEffectiveFlowID( + mock.Anything, "", "", providers.FlowTypeRecovery).Return("", nil).Once() + flowMgt.EXPECT().ResolveEffectiveFlowID( + mock.Anything, "", "", providers.FlowTypeSignOut).Return("", nil).Once() + svc := &inboundClientService{flowMgt: flowMgt} + c := &inboundmodel.InboundClient{ID: "p1", AuthFlowID: "auth-1"} err := svc.resolveFlowDefaults(context.Background(), c) assert.NoError(suite.T(), err) - assert.Equal(suite.T(), "signout-1", c.SignOutFlowID) + assert.Empty(suite.T(), c.RegistrationFlowID) + assert.False(suite.T(), c.IsRegistrationFlowEnabled) + assert.Empty(suite.T(), c.RecoveryFlowID) + assert.False(suite.T(), c.IsRecoveryFlowEnabled) + assert.Empty(suite.T(), c.SignOutFlowID) } -// The default sign-out flow lookup maps a server error to ErrFKFlowServerError, treats a -// not-found flow as optional (skipped), and surfaces any other retrieval error. -func (suite *InboundClientServiceTestSuite) TestResolveFlowDefaults_DefaultSignOutFlowLookupErrors() { - originalSignOutHandle := sysconfig.GetServerRuntime().Config.Flow.DefaultSignOutFlowHandle - suite.T().Cleanup(func() { - sysconfig.GetServerRuntime().Config.Flow.DefaultSignOutFlowHandle = originalSignOutHandle - }) - sysconfig.GetServerRuntime().Config.Flow.DefaultSignOutFlowHandle = testDefaultSignOutFlowHandle - +// ResolveEffectiveFlowID errors are mapped to the correct sentinel errors for each flow type. +func (suite *InboundClientServiceTestSuite) TestResolveFlowDefaults_ResolveErrors() { tests := []struct { name string - lookupErr *tidcommon.ServiceError + flowType providers.FlowType + resolveErr *tidcommon.ServiceError expectedErr error }{ - {"server error", &tidcommon.ServiceError{Type: tidcommon.ServerErrorType, Code: "SRV"}, ErrFKFlowServerError}, - {"not found is skipped", &flowmgt.ErrorFlowNotFound, nil}, - { - "other retrieval error", + {"auth server error", providers.FlowTypeAuthentication, + &tidcommon.ServiceError{Type: tidcommon.ServerErrorType, Code: "SRV"}, ErrFKFlowServerError}, + {"auth other error", providers.FlowTypeAuthentication, &tidcommon.ServiceError{Type: tidcommon.ClientErrorType, Code: "OTHER"}, - ErrFKFlowDefinitionRetrievalFailed, - }, + ErrFKFlowDefinitionRetrievalFailed}, + {"reg server error", providers.FlowTypeRegistration, + &tidcommon.ServiceError{Type: tidcommon.ServerErrorType, Code: "SRV"}, ErrFKFlowServerError}, + {"rec server error", providers.FlowTypeRecovery, + &tidcommon.ServiceError{Type: tidcommon.ServerErrorType, Code: "SRV"}, ErrFKFlowServerError}, + {"signout server error", providers.FlowTypeSignOut, + &tidcommon.ServiceError{Type: tidcommon.ServerErrorType, Code: "SRV"}, ErrFKFlowServerError}, } - for _, tt := range tests { suite.Run(tt.name, func() { flowMgt := flowmgtmock.NewFlowMgtServiceInterfaceMock(suite.T()) - flowMgt.EXPECT().GetFlowByHandle(mock.Anything, testDefaultSignOutFlowHandle, providers.FlowTypeSignOut). - Return(nil, tt.lookupErr).Once() + if tt.flowType != providers.FlowTypeAuthentication { + flowMgt.EXPECT().ResolveEffectiveFlowID( + mock.Anything, mock.Anything, "", providers.FlowTypeAuthentication).Return("auth-1", nil).Once() + } + if tt.flowType == providers.FlowTypeRecovery || tt.flowType == providers.FlowTypeSignOut { + flowMgt.EXPECT().ResolveEffectiveFlowID( + mock.Anything, mock.Anything, "", providers.FlowTypeRegistration).Return("", nil).Once() + } + if tt.flowType == providers.FlowTypeSignOut { + flowMgt.EXPECT().ResolveEffectiveFlowID( + mock.Anything, mock.Anything, "", providers.FlowTypeRecovery).Return("", nil).Once() + } + flowMgt.EXPECT().ResolveEffectiveFlowID( + mock.Anything, mock.Anything, "", tt.flowType).Return("", tt.resolveErr).Once() svc := &inboundClientService{flowMgt: flowMgt} c := &inboundmodel.InboundClient{ID: "p1", AuthFlowID: "auth-1"} err := svc.resolveFlowDefaults(context.Background(), c) - if tt.expectedErr != nil { - assert.ErrorIs(suite.T(), err, tt.expectedErr) - } else { - assert.NoError(suite.T(), err) - } - assert.Empty(suite.T(), c.SignOutFlowID) + assert.ErrorIs(suite.T(), err, tt.expectedErr) }) } } -// When no default sign-out flow handle is configured, resolution does not attempt a lookup. -func (suite *InboundClientServiceTestSuite) TestResolveFlowDefaults_NoDefaultSignOutFlowHandleConfigured() { - sysconfig.GetServerRuntime().Config.Flow.DefaultSignOutFlowHandle = "" - svc := &inboundClientService{flowMgt: flowmgtmock.NewFlowMgtServiceInterfaceMock(suite.T())} - c := &inboundmodel.InboundClient{ID: "p1", AuthFlowID: "auth-1"} - err := svc.resolveFlowDefaults(context.Background(), c) - assert.NoError(suite.T(), err) - assert.Empty(suite.T(), c.SignOutFlowID) -} - // ----- ResolveInboundAuthProfileHandles ----- func (suite *InboundClientServiceTestSuite) TestResolveInboundAuthProfileHandles_NilFlowMgtIsNoOp() { @@ -2137,8 +2161,6 @@ func (suite *InboundClientServiceTestSuite) TestGetOAuthClientByClientID_NilEnti const testServiceEntityID = "ent-1" -const testDefaultSignOutFlowHandle = "default-flow" - func (suite *InboundClientServiceTestSuite) TestGetOAuthClientByClientID_GetEntityNotFound() { id := testServiceEntityID ep := entityprovidermock.NewEntityProviderInterfaceMock(suite.T()) diff --git a/backend/internal/ou/error_constants.go b/backend/internal/ou/error_constants.go index ae08631ba3..3eaf3c1976 100644 --- a/backend/internal/ou/error_constants.go +++ b/backend/internal/ou/error_constants.go @@ -266,6 +266,20 @@ var ( DefaultValue: "The signOutFlowId does not reference an existing sign-out flow", }, } + // ErrorInvalidUserOnboardingFlowID is the error returned when userOnboardingFlowId does not + // reference an existing user onboarding flow. + ErrorInvalidUserOnboardingFlowID = tidcommon.ServiceError{ + Type: tidcommon.ClientErrorType, + Code: "OU-1019", + Error: tidcommon.I18nMessage{ + Key: "error.ouservice.invalid_user_onboarding_flow_id", + DefaultValue: "Invalid default user onboarding flow ID", + }, + ErrorDescription: tidcommon.I18nMessage{ + Key: "error.ouservice.invalid_user_onboarding_flow_id_description", + DefaultValue: "The userOnboardingFlowId does not reference an existing user onboarding flow", + }, + } ) // Error variables diff --git a/backend/internal/ou/handler.go b/backend/internal/ou/handler.go index 91e7d94e0e..41871299bb 100644 --- a/backend/internal/ou/handler.go +++ b/backend/internal/ou/handler.go @@ -287,6 +287,7 @@ func (ouh *organizationUnitHandler) sanitizeOrganizationUnitRequest( RecoveryFlowID: request.RecoveryFlowID, IsRecoveryFlowEnabled: request.IsRecoveryFlowEnabled, SignOutFlowID: request.SignOutFlowID, + UserOnboardingFlowID: request.UserOnboardingFlowID, LogoURL: request.LogoURL, TosURI: request.TosURI, PolicyURI: request.PolicyURI, diff --git a/backend/internal/ou/model.go b/backend/internal/ou/model.go index a8af3ef92d..e2616416d2 100644 --- a/backend/internal/ou/model.go +++ b/backend/internal/ou/model.go @@ -39,6 +39,7 @@ type OrganizationUnitRequest struct { RecoveryFlowID string `json:"recoveryFlowId,omitempty"` IsRecoveryFlowEnabled bool `json:"isRecoveryFlowEnabled"` SignOutFlowID string `json:"signOutFlowId,omitempty"` + UserOnboardingFlowID string `json:"userOnboardingFlowId,omitempty"` LogoURL string `json:"logoUrl,omitempty" native:"omitempty,url,max=2048"` TosURI string `json:"tosUri,omitempty" native:"omitempty,url,max=2048"` PolicyURI string `json:"policyUri,omitempty" native:"omitempty,url,max=2048"` diff --git a/backend/internal/ou/service.go b/backend/internal/ou/service.go index 730f0c6b10..3e43bc1fb0 100644 --- a/backend/internal/ou/service.go +++ b/backend/internal/ou/service.go @@ -408,6 +408,7 @@ func (ous *organizationUnitService) CreateOrganizationUnit( RecoveryFlowID: request.RecoveryFlowID, IsRecoveryFlowEnabled: request.IsRecoveryFlowEnabled, SignOutFlowID: request.SignOutFlowID, + UserOnboardingFlowID: request.UserOnboardingFlowID, LogoURL: request.LogoURL, TosURI: request.TosURI, PolicyURI: request.PolicyURI, @@ -724,6 +725,7 @@ func (ous *organizationUnitService) updateOUInternal( RecoveryFlowID: request.RecoveryFlowID, IsRecoveryFlowEnabled: request.IsRecoveryFlowEnabled, SignOutFlowID: request.SignOutFlowID, + UserOnboardingFlowID: request.UserOnboardingFlowID, LogoURL: request.LogoURL, TosURI: request.TosURI, PolicyURI: request.PolicyURI, @@ -1297,6 +1299,11 @@ func (ous *organizationUnitService) validateDefaultFlows( ctx, request.SignOutFlowID, providers.FlowTypeSignOut, &ErrorInvalidSignOutFlowID); svcErr != nil { return svcErr } + if svcErr := ous.validateDefaultFlowID( + ctx, request.UserOnboardingFlowID, providers.FlowTypeUserOnboarding, + &ErrorInvalidUserOnboardingFlowID); svcErr != nil { + return svcErr + } return nil } diff --git a/backend/internal/ou/store.go b/backend/internal/ou/store.go index a8617ce45b..6956700122 100644 --- a/backend/internal/ou/store.go +++ b/backend/internal/ou/store.go @@ -635,6 +635,11 @@ func buildOrganizationUnitFromResultRow( return providers.OrganizationUnit{}, err } + userOnboardingFlowID, err := extractStringFromOUMetadata(ouMetadataData, "user_onboarding_flow_id") + if err != nil { + return providers.OrganizationUnit{}, err + } + logoURL, err := extractStringFromOUMetadata(ouMetadataData, "logo_url") if err != nil { return providers.OrganizationUnit{}, err @@ -679,6 +684,7 @@ func buildOrganizationUnitFromResultRow( RecoveryFlowID: recoveryFlowID, IsRecoveryFlowEnabled: isRecoveryFlowEnabled, SignOutFlowID: signOutFlowID, + UserOnboardingFlowID: userOnboardingFlowID, LogoURL: logoURL, TosURI: tosURI, PolicyURI: policyURI, @@ -767,6 +773,7 @@ func getOUMetadataDataBytes(ou *providers.OrganizationUnit) ([]byte, error) { "recovery_flow_id": ou.RecoveryFlowID, "is_recovery_flow_enabled": ou.IsRecoveryFlowEnabled, "signout_flow_id": ou.SignOutFlowID, + "user_onboarding_flow_id": ou.UserOnboardingFlowID, "logo_url": ou.LogoURL, "tos_uri": ou.TosURI, "policy_uri": ou.PolicyURI, diff --git a/backend/internal/ou/store_test.go b/backend/internal/ou/store_test.go index c153502f59..261c641a3a 100644 --- a/backend/internal/ou/store_test.go +++ b/backend/internal/ou/store_test.go @@ -723,7 +723,7 @@ func (suite *OrganizationUnitStoreTestSuite) TestOUStore_UpdateOrganizationUnit( `{"auth_flow_id":"","cookie_policy_uri":"","is_recovery_flow_enabled":false,`+ `"is_registration_flow_enabled":false,"layout_id":"","logo_url":"",`+ `"policy_uri":"","recovery_flow_id":"","registration_flow_id":"",`+ - `"signout_flow_id":"","theme_id":"","tos_uri":""}`, + `"signout_flow_id":"","theme_id":"","tos_uri":"","user_onboarding_flow_id":""}`, mock.Anything, testDeploymentID, ). @@ -768,7 +768,8 @@ func (suite *OrganizationUnitStoreTestSuite) TestOUStore_UpdateOrganizationUnit( `"layout_id":"layout-456","logo_url":"https://example.com/logo.png",`+ `"policy_uri":"","recovery_flow_id":"recovery-flow-123",`+ `"registration_flow_id":"registration-flow-123",`+ - `"signout_flow_id":"signout-flow-123","theme_id":"theme-123","tos_uri":""}`, + `"signout_flow_id":"signout-flow-123","theme_id":"theme-123",`+ + `"tos_uri":"","user_onboarding_flow_id":""}`, mock.Anything, testDeploymentID, ). @@ -793,7 +794,7 @@ func (suite *OrganizationUnitStoreTestSuite) TestOUStore_UpdateOrganizationUnit( `{"auth_flow_id":"","cookie_policy_uri":"","is_recovery_flow_enabled":false,`+ `"is_registration_flow_enabled":false,"layout_id":"","logo_url":"",`+ `"policy_uri":"","recovery_flow_id":"","registration_flow_id":"",`+ - `"signout_flow_id":"","theme_id":"","tos_uri":""}`, + `"signout_flow_id":"","theme_id":"","tos_uri":"","user_onboarding_flow_id":""}`, mock.Anything, testDeploymentID, ). @@ -1403,7 +1404,7 @@ func (suite *OrganizationUnitStoreTestSuite) TestOUStore_CreateOrganizationUnit( `{"auth_flow_id":"","cookie_policy_uri":"","is_recovery_flow_enabled":false,`+ `"is_registration_flow_enabled":false,"layout_id":"","logo_url":"",`+ `"policy_uri":"","recovery_flow_id":"","registration_flow_id":"",`+ - `"signout_flow_id":"","theme_id":"","tos_uri":""}`, + `"signout_flow_id":"","theme_id":"","tos_uri":"","user_onboarding_flow_id":""}`, testDeploymentID, mock.Anything, mock.Anything, @@ -1445,7 +1446,8 @@ func (suite *OrganizationUnitStoreTestSuite) TestOUStore_CreateOrganizationUnit( `"layout_id":"layout-456","logo_url":"https://example.com/logo.png",`+ `"policy_uri":"","recovery_flow_id":"recovery-flow-123",`+ `"registration_flow_id":"registration-flow-123",`+ - `"signout_flow_id":"signout-flow-123","theme_id":"theme-123","tos_uri":""}`, + `"signout_flow_id":"signout-flow-123","theme_id":"theme-123",`+ + `"tos_uri":"","user_onboarding_flow_id":""}`, testDeploymentID, mock.Anything, mock.Anything, @@ -1476,7 +1478,7 @@ func (suite *OrganizationUnitStoreTestSuite) TestOUStore_CreateOrganizationUnit( `{"auth_flow_id":"","cookie_policy_uri":"","is_recovery_flow_enabled":false,`+ `"is_registration_flow_enabled":false,"layout_id":"","logo_url":"",`+ `"policy_uri":"","recovery_flow_id":"","registration_flow_id":"",`+ - `"signout_flow_id":"","theme_id":"","tos_uri":""}`, + `"signout_flow_id":"","theme_id":"","tos_uri":"","user_onboarding_flow_id":""}`, testDeploymentID, mock.Anything, mock.Anything, diff --git a/backend/internal/serverconfig/constants.go b/backend/internal/serverconfig/constants.go index d464ec629f..3bd2625c5d 100644 --- a/backend/internal/serverconfig/constants.go +++ b/backend/internal/serverconfig/constants.go @@ -29,6 +29,8 @@ const ( ConfigNameDefaultResourceServer ConfigName = "defaultResourceServer" // ConfigNameSession is the configuration key for the SSO session lifetime timeouts. ConfigNameSession ConfigName = "session" + // ConfigNameFlow is the configuration key for the flow defaults. + ConfigNameFlow ConfigName = "flow" ) // supportedConfigNames lists all the supported server configuration names. @@ -36,6 +38,7 @@ var supportedConfigNames = []ConfigName{ ConfigNameCORS, ConfigNameDefaultResourceServer, ConfigNameSession, + ConfigNameFlow, } // IsValid reports whether the config name is one of the supported values. diff --git a/backend/internal/system/i18n/core/defaults.go b/backend/internal/system/i18n/core/defaults.go index 7f32c4a683..1a8be25a4e 100644 --- a/backend/internal/system/i18n/core/defaults.go +++ b/backend/internal/system/i18n/core/defaults.go @@ -905,6 +905,8 @@ var defaultMessages = map[string]string{ "error.ouservice.invalid_request_format_description": "The request body is malformed, contains invalid data, or required fields are missing/empty", "error.ouservice.invalid_signout_flow_id": "Invalid default sign-out flow ID", "error.ouservice.invalid_signout_flow_id_description": "The signOutFlowId does not reference an existing sign-out flow", + "error.ouservice.invalid_user_onboarding_flow_id": "Invalid default user onboarding flow ID", + "error.ouservice.invalid_user_onboarding_flow_id_description": "The userOnboardingFlowId does not reference an existing user onboarding flow", "error.ouservice.missing_ou_id": "Invalid request format", "error.ouservice.missing_ou_id_description": "Organization unit ID is required", "error.ouservice.organization_unit_handle_conflict": "Organization unit handle conflict", diff --git a/backend/pkg/thunderidengine/config/config.go b/backend/pkg/thunderidengine/config/config.go index 1e46e650c1..df968680d3 100644 --- a/backend/pkg/thunderidengine/config/config.go +++ b/backend/pkg/thunderidengine/config/config.go @@ -275,20 +275,17 @@ type TokenExchangeConfig struct { // FlowConfig holds the configuration details for the flow service. type FlowConfig struct { - DefaultAuthFlowHandle string `yaml:"default_auth_flow_handle" json:"default_auth_flow_handle"` - DefaultSignOutFlowHandle string `yaml:"default_signout_flow_handle" json:"default_signout_flow_handle"` - UserOnboardingFlowHandle string `yaml:"user_onboarding_flow_handle" json:"user_onboarding_flow_handle"` - MaxVersionHistory int `yaml:"max_version_history" json:"max_version_history"` - AutoInferRegistration bool `yaml:"auto_infer_registration" json:"auto_infer_registration"` - Store string `yaml:"store" json:"store"` + MaxVersionHistory int `yaml:"max_version_history" json:"max_version_history"` + AutoInferRegistration bool `yaml:"auto_infer_registration" json:"auto_infer_registration"` + Store string `yaml:"store" json:"store"` // Executors lists built-in executor names to register (e.g. CredentialsAuthExecutor). // When empty, all built-in executors are registered. When set, only listed executors // are available; omit only executors you intentionally disable on this node. - Executors []string `yaml:"executors" json:"executors"` + Executors []string `yaml:"executors" json:"executors"` // Interceptors lists built-in interceptor names to register (e.g. CaptchaInterceptor). // When empty, all built-in interceptors are registered. When set, only listed interceptors // are available; omit only interceptors you intentionally disable on this node. - Interceptors []string `yaml:"interceptors" json:"interceptors"` + Interceptors []string `yaml:"interceptors" json:"interceptors"` } // RequiredClaim defines a claim name and expected value that must be present in the token. diff --git a/backend/pkg/thunderidengine/engine.go b/backend/pkg/thunderidengine/engine.go index def5d97b15..4f2793de79 100644 --- a/backend/pkg/thunderidengine/engine.go +++ b/backend/pkg/thunderidengine/engine.go @@ -172,7 +172,7 @@ func New(mux *http.ServeMux, opts ...Option) *Engine { engineCtx.flowExecService, err = flowexec.Initialize(mux, engineCtx.flowProvider, engineCtx.actorProvider, engineCtx.execRegistry, engineCtx.interceptorRegistry, engineCtx.observabilitySvc, engineCtx.runtimeCryptoSvc, engineCtx.attestationProvider, engineCtx.graphBuilder, - engineCtx.runtimeStoreProvider, engineCtx.transactioner, flowConfig) + engineCtx.runtimeStoreProvider, engineCtx.transactioner, nil, flowConfig) if err != nil { logger.Fatal(ctx, "Failed to initialize flow execution service", log.Error(err)) } diff --git a/backend/pkg/thunderidengine/providers/model.go b/backend/pkg/thunderidengine/providers/model.go index 96a49de675..c724d2c13d 100644 --- a/backend/pkg/thunderidengine/providers/model.go +++ b/backend/pkg/thunderidengine/providers/model.go @@ -64,8 +64,9 @@ type OrganizationUnit struct { IsRegistrationFlowEnabled bool `json:"isRegistrationFlowEnabled" yaml:"isRegistrationFlowEnabled"` RecoveryFlowID string `json:"recoveryFlowId,omitempty" yaml:"recoveryFlowId,omitempty"` IsRecoveryFlowEnabled bool `json:"isRecoveryFlowEnabled" yaml:"isRecoveryFlowEnabled"` - SignOutFlowID string `json:"signOutFlowId,omitempty" yaml:"signOutFlowId,omitempty"` - LogoURL string `json:"logoUrl,omitempty" yaml:"logoUrl,omitempty"` + SignOutFlowID string `json:"signOutFlowId,omitempty" yaml:"signOutFlowId,omitempty"` + UserOnboardingFlowID string `json:"userOnboardingFlowId,omitempty" yaml:"userOnboardingFlowId,omitempty"` + LogoURL string `json:"logoUrl,omitempty" yaml:"logoUrl,omitempty"` TosURI string `json:"tosUri,omitempty" yaml:"tosUri,omitempty"` PolicyURI string `json:"policyUri,omitempty" yaml:"policyUri,omitempty"` CookiePolicyURI string `json:"cookiePolicyUri,omitempty" yaml:"cookiePolicyUri,omitempty"` @@ -88,8 +89,9 @@ type OrganizationUnitRequestWithID struct { IsRegistrationFlowEnabled bool `json:"isRegistrationFlowEnabled" yaml:"isRegistrationFlowEnabled"` RecoveryFlowID string `json:"recoveryFlowId,omitempty" yaml:"recoveryFlowId,omitempty"` IsRecoveryFlowEnabled bool `json:"isRecoveryFlowEnabled" yaml:"isRecoveryFlowEnabled"` - SignOutFlowID string `json:"signOutFlowId,omitempty" yaml:"signOutFlowId,omitempty"` - LogoURL string `json:"logoUrl,omitempty" yaml:"logoUrl,omitempty" native:"omitempty,url,max=2048"` + SignOutFlowID string `json:"signOutFlowId,omitempty" yaml:"signOutFlowId,omitempty"` + UserOnboardingFlowID string `json:"userOnboardingFlowId,omitempty" yaml:"userOnboardingFlowId,omitempty"` + LogoURL string `json:"logoUrl,omitempty" yaml:"logoUrl,omitempty" native:"omitempty,url,max=2048"` TosURI string `json:"tosUri,omitempty" yaml:"tosUri,omitempty" native:"omitempty,url,max=2048"` PolicyURI string `json:"policyUri,omitempty" yaml:"policyUri,omitempty" native:"omitempty,url,max=2048"` CookiePolicyURI string `json:"cookiePolicyUri,omitempty" yaml:"cookiePolicyUri,omitempty" native:"url,max=2048"` diff --git a/backend/tests/mocks/flow/flowexecmock/flowDefaultsProvider_mock.go b/backend/tests/mocks/flow/flowexecmock/flowDefaultsProvider_mock.go new file mode 100644 index 0000000000..663423faaa --- /dev/null +++ b/backend/tests/mocks/flow/flowexecmock/flowDefaultsProvider_mock.go @@ -0,0 +1,165 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package flowexecmock + +import ( + "context" + + mock "github.com/stretchr/testify/mock" + "github.com/thunder-id/thunderid/pkg/thunderidengine/common" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" +) + +// newFlowDefaultsProviderMock creates a new instance of flowDefaultsProviderMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func newFlowDefaultsProviderMock(t interface { + mock.TestingT + Cleanup(func()) +}) *flowDefaultsProviderMock { + mock := &flowDefaultsProviderMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// flowDefaultsProviderMock is an autogenerated mock type for the flowDefaultsProvider type +type flowDefaultsProviderMock struct { + mock.Mock +} + +type flowDefaultsProviderMock_Expecter struct { + mock *mock.Mock +} + +func (_m *flowDefaultsProviderMock) EXPECT() *flowDefaultsProviderMock_Expecter { + return &flowDefaultsProviderMock_Expecter{mock: &_m.Mock} +} + +// GetFlowExpirySeconds provides a mock function for the type flowDefaultsProviderMock +func (_mock *flowDefaultsProviderMock) GetFlowExpirySeconds(ctx context.Context, flowType providers.FlowType) int64 { + ret := _mock.Called(ctx, flowType) + + if len(ret) == 0 { + panic("no return value specified for GetFlowExpirySeconds") + } + + var r0 int64 + if returnFunc, ok := ret.Get(0).(func(context.Context, providers.FlowType) int64); ok { + r0 = returnFunc(ctx, flowType) + } else { + r0 = ret.Get(0).(int64) + } + return r0 +} + +// flowDefaultsProviderMock_GetFlowExpirySeconds_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetFlowExpirySeconds' +type flowDefaultsProviderMock_GetFlowExpirySeconds_Call struct { + *mock.Call +} + +// GetFlowExpirySeconds is a helper method to define mock.On call +// - ctx context.Context +// - flowType providers.FlowType +func (_e *flowDefaultsProviderMock_Expecter) GetFlowExpirySeconds(ctx interface{}, flowType interface{}) *flowDefaultsProviderMock_GetFlowExpirySeconds_Call { + return &flowDefaultsProviderMock_GetFlowExpirySeconds_Call{Call: _e.mock.On("GetFlowExpirySeconds", ctx, flowType)} +} + +func (_c *flowDefaultsProviderMock_GetFlowExpirySeconds_Call) Run(run func(ctx context.Context, flowType providers.FlowType)) *flowDefaultsProviderMock_GetFlowExpirySeconds_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 providers.FlowType + if args[1] != nil { + arg1 = args[1].(providers.FlowType) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *flowDefaultsProviderMock_GetFlowExpirySeconds_Call) Return(n int64) *flowDefaultsProviderMock_GetFlowExpirySeconds_Call { + _c.Call.Return(n) + return _c +} + +func (_c *flowDefaultsProviderMock_GetFlowExpirySeconds_Call) RunAndReturn(run func(ctx context.Context, flowType providers.FlowType) int64) *flowDefaultsProviderMock_GetFlowExpirySeconds_Call { + _c.Call.Return(run) + return _c +} + +// ResolveDefaultFlowHandle provides a mock function for the type flowDefaultsProviderMock +func (_mock *flowDefaultsProviderMock) ResolveDefaultFlowHandle(ctx context.Context, flowType providers.FlowType) (string, *common.ServiceError) { + ret := _mock.Called(ctx, flowType) + + if len(ret) == 0 { + panic("no return value specified for ResolveDefaultFlowHandle") + } + + var r0 string + var r1 *common.ServiceError + if returnFunc, ok := ret.Get(0).(func(context.Context, providers.FlowType) (string, *common.ServiceError)); ok { + return returnFunc(ctx, flowType) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, providers.FlowType) string); ok { + r0 = returnFunc(ctx, flowType) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, providers.FlowType) *common.ServiceError); ok { + r1 = returnFunc(ctx, flowType) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*common.ServiceError) + } + } + return r0, r1 +} + +// flowDefaultsProviderMock_ResolveDefaultFlowHandle_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveDefaultFlowHandle' +type flowDefaultsProviderMock_ResolveDefaultFlowHandle_Call struct { + *mock.Call +} + +// ResolveDefaultFlowHandle is a helper method to define mock.On call +// - ctx context.Context +// - flowType providers.FlowType +func (_e *flowDefaultsProviderMock_Expecter) ResolveDefaultFlowHandle(ctx interface{}, flowType interface{}) *flowDefaultsProviderMock_ResolveDefaultFlowHandle_Call { + return &flowDefaultsProviderMock_ResolveDefaultFlowHandle_Call{Call: _e.mock.On("ResolveDefaultFlowHandle", ctx, flowType)} +} + +func (_c *flowDefaultsProviderMock_ResolveDefaultFlowHandle_Call) Run(run func(ctx context.Context, flowType providers.FlowType)) *flowDefaultsProviderMock_ResolveDefaultFlowHandle_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 providers.FlowType + if args[1] != nil { + arg1 = args[1].(providers.FlowType) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *flowDefaultsProviderMock_ResolveDefaultFlowHandle_Call) Return(s string, serviceError *common.ServiceError) *flowDefaultsProviderMock_ResolveDefaultFlowHandle_Call { + _c.Call.Return(s, serviceError) + return _c +} + +func (_c *flowDefaultsProviderMock_ResolveDefaultFlowHandle_Call) RunAndReturn(run func(ctx context.Context, flowType providers.FlowType) (string, *common.ServiceError)) *flowDefaultsProviderMock_ResolveDefaultFlowHandle_Call { + _c.Call.Return(run) + return _c +} diff --git a/backend/tests/mocks/flow/flowexecmock/serverConfigProvider_mock.go b/backend/tests/mocks/flow/flowexecmock/serverConfigProvider_mock.go new file mode 100644 index 0000000000..bd5d710869 --- /dev/null +++ b/backend/tests/mocks/flow/flowexecmock/serverConfigProvider_mock.go @@ -0,0 +1,109 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package flowexecmock + +import ( + "context" + + mock "github.com/stretchr/testify/mock" + "github.com/thunder-id/thunderid/pkg/thunderidengine/common" +) + +// newServerConfigProviderMock creates a new instance of serverConfigProviderMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func newServerConfigProviderMock(t interface { + mock.TestingT + Cleanup(func()) +}) *serverConfigProviderMock { + mock := &serverConfigProviderMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// serverConfigProviderMock is an autogenerated mock type for the serverConfigProvider type +type serverConfigProviderMock struct { + mock.Mock +} + +type serverConfigProviderMock_Expecter struct { + mock *mock.Mock +} + +func (_m *serverConfigProviderMock) EXPECT() *serverConfigProviderMock_Expecter { + return &serverConfigProviderMock_Expecter{mock: &_m.Mock} +} + +// GetMergedConfig provides a mock function for the type serverConfigProviderMock +func (_mock *serverConfigProviderMock) GetMergedConfig(ctx context.Context, name string) (any, *common.ServiceError) { + ret := _mock.Called(ctx, name) + + if len(ret) == 0 { + panic("no return value specified for GetMergedConfig") + } + + var r0 any + var r1 *common.ServiceError + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (any, *common.ServiceError)); ok { + return returnFunc(ctx, name) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) any); ok { + r0 = returnFunc(ctx, name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(any) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) *common.ServiceError); ok { + r1 = returnFunc(ctx, name) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*common.ServiceError) + } + } + return r0, r1 +} + +// serverConfigProviderMock_GetMergedConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetMergedConfig' +type serverConfigProviderMock_GetMergedConfig_Call struct { + *mock.Call +} + +// GetMergedConfig is a helper method to define mock.On call +// - ctx context.Context +// - name string +func (_e *serverConfigProviderMock_Expecter) GetMergedConfig(ctx interface{}, name interface{}) *serverConfigProviderMock_GetMergedConfig_Call { + return &serverConfigProviderMock_GetMergedConfig_Call{Call: _e.mock.On("GetMergedConfig", ctx, name)} +} + +func (_c *serverConfigProviderMock_GetMergedConfig_Call) Run(run func(ctx context.Context, name string)) *serverConfigProviderMock_GetMergedConfig_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *serverConfigProviderMock_GetMergedConfig_Call) Return(v any, serviceError *common.ServiceError) *serverConfigProviderMock_GetMergedConfig_Call { + _c.Call.Return(v, serviceError) + return _c +} + +func (_c *serverConfigProviderMock_GetMergedConfig_Call) RunAndReturn(run func(ctx context.Context, name string) (any, *common.ServiceError)) *serverConfigProviderMock_GetMergedConfig_Call { + _c.Call.Return(run) + return _c +} diff --git a/backend/tests/mocks/flow/flowmgtmock/FlowMgtServiceInterface_mock.go b/backend/tests/mocks/flow/flowmgtmock/FlowMgtServiceInterface_mock.go index d75f7130da..21ecfb41e3 100644 --- a/backend/tests/mocks/flow/flowmgtmock/FlowMgtServiceInterface_mock.go +++ b/backend/tests/mocks/flow/flowmgtmock/FlowMgtServiceInterface_mock.go @@ -903,6 +903,86 @@ func (_c *FlowMgtServiceInterfaceMock_ListFlows_Call) RunAndReturn(run func(ctx return _c } +// ResolveEffectiveFlowID provides a mock function for the type FlowMgtServiceInterfaceMock +func (_mock *FlowMgtServiceInterfaceMock) ResolveEffectiveFlowID(ctx context.Context, overriddenFlowID string, ouID string, flowType providers.FlowType) (string, *common.ServiceError) { + ret := _mock.Called(ctx, overriddenFlowID, ouID, flowType) + + if len(ret) == 0 { + panic("no return value specified for ResolveEffectiveFlowID") + } + + var r0 string + var r1 *common.ServiceError + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, providers.FlowType) (string, *common.ServiceError)); ok { + return returnFunc(ctx, overriddenFlowID, ouID, flowType) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, providers.FlowType) string); ok { + r0 = returnFunc(ctx, overriddenFlowID, ouID, flowType) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, providers.FlowType) *common.ServiceError); ok { + r1 = returnFunc(ctx, overriddenFlowID, ouID, flowType) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*common.ServiceError) + } + } + return r0, r1 +} + +// FlowMgtServiceInterfaceMock_ResolveEffectiveFlowID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveEffectiveFlowID' +type FlowMgtServiceInterfaceMock_ResolveEffectiveFlowID_Call struct { + *mock.Call +} + +// ResolveEffectiveFlowID is a helper method to define mock.On call +// - ctx context.Context +// - overriddenFlowID string +// - ouID string +// - flowType providers.FlowType +func (_e *FlowMgtServiceInterfaceMock_Expecter) ResolveEffectiveFlowID(ctx interface{}, overriddenFlowID interface{}, ouID interface{}, flowType interface{}) *FlowMgtServiceInterfaceMock_ResolveEffectiveFlowID_Call { + return &FlowMgtServiceInterfaceMock_ResolveEffectiveFlowID_Call{Call: _e.mock.On("ResolveEffectiveFlowID", ctx, overriddenFlowID, ouID, flowType)} +} + +func (_c *FlowMgtServiceInterfaceMock_ResolveEffectiveFlowID_Call) Run(run func(ctx context.Context, overriddenFlowID string, ouID string, flowType providers.FlowType)) *FlowMgtServiceInterfaceMock_ResolveEffectiveFlowID_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 providers.FlowType + if args[3] != nil { + arg3 = args[3].(providers.FlowType) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *FlowMgtServiceInterfaceMock_ResolveEffectiveFlowID_Call) Return(s string, serviceError *common.ServiceError) *FlowMgtServiceInterfaceMock_ResolveEffectiveFlowID_Call { + _c.Call.Return(s, serviceError) + return _c +} + +func (_c *FlowMgtServiceInterfaceMock_ResolveEffectiveFlowID_Call) RunAndReturn(run func(ctx context.Context, overriddenFlowID string, ouID string, flowType providers.FlowType) (string, *common.ServiceError)) *FlowMgtServiceInterfaceMock_ResolveEffectiveFlowID_Call { + _c.Call.Return(run) + return _c +} + // RestoreFlowVersion provides a mock function for the type FlowMgtServiceInterfaceMock func (_mock *FlowMgtServiceInterfaceMock) RestoreFlowVersion(ctx context.Context, flowID string, version int) (*providers.CompleteFlowDefinition, *common.ServiceError) { ret := _mock.Called(ctx, flowID, version) diff --git a/backend/tests/mocks/flow/flowmgtmock/ouProvider_mock.go b/backend/tests/mocks/flow/flowmgtmock/ouProvider_mock.go new file mode 100644 index 0000000000..321d58ec34 --- /dev/null +++ b/backend/tests/mocks/flow/flowmgtmock/ouProvider_mock.go @@ -0,0 +1,108 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package flowmgtmock + +import ( + "context" + + mock "github.com/stretchr/testify/mock" + "github.com/thunder-id/thunderid/pkg/thunderidengine/common" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" +) + +// newOuProviderMock creates a new instance of ouProviderMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func newOuProviderMock(t interface { + mock.TestingT + Cleanup(func()) +}) *ouProviderMock { + mock := &ouProviderMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// ouProviderMock is an autogenerated mock type for the ouProvider type +type ouProviderMock struct { + mock.Mock +} + +type ouProviderMock_Expecter struct { + mock *mock.Mock +} + +func (_m *ouProviderMock) EXPECT() *ouProviderMock_Expecter { + return &ouProviderMock_Expecter{mock: &_m.Mock} +} + +// GetOrganizationUnit provides a mock function for the type ouProviderMock +func (_mock *ouProviderMock) GetOrganizationUnit(ctx context.Context, id string) (providers.OrganizationUnit, *common.ServiceError) { + ret := _mock.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for GetOrganizationUnit") + } + + var r0 providers.OrganizationUnit + var r1 *common.ServiceError + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (providers.OrganizationUnit, *common.ServiceError)); ok { + return returnFunc(ctx, id) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) providers.OrganizationUnit); ok { + r0 = returnFunc(ctx, id) + } else { + r0 = ret.Get(0).(providers.OrganizationUnit) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) *common.ServiceError); ok { + r1 = returnFunc(ctx, id) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*common.ServiceError) + } + } + return r0, r1 +} + +// ouProviderMock_GetOrganizationUnit_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetOrganizationUnit' +type ouProviderMock_GetOrganizationUnit_Call struct { + *mock.Call +} + +// GetOrganizationUnit is a helper method to define mock.On call +// - ctx context.Context +// - id string +func (_e *ouProviderMock_Expecter) GetOrganizationUnit(ctx interface{}, id interface{}) *ouProviderMock_GetOrganizationUnit_Call { + return &ouProviderMock_GetOrganizationUnit_Call{Call: _e.mock.On("GetOrganizationUnit", ctx, id)} +} + +func (_c *ouProviderMock_GetOrganizationUnit_Call) Run(run func(ctx context.Context, id string)) *ouProviderMock_GetOrganizationUnit_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *ouProviderMock_GetOrganizationUnit_Call) Return(organizationUnit providers.OrganizationUnit, serviceError *common.ServiceError) *ouProviderMock_GetOrganizationUnit_Call { + _c.Call.Return(organizationUnit, serviceError) + return _c +} + +func (_c *ouProviderMock_GetOrganizationUnit_Call) RunAndReturn(run func(ctx context.Context, id string) (providers.OrganizationUnit, *common.ServiceError)) *ouProviderMock_GetOrganizationUnit_Call { + _c.Call.Return(run) + return _c +} diff --git a/backend/tests/mocks/flow/flowmgtmock/serverConfigProvider_mock.go b/backend/tests/mocks/flow/flowmgtmock/serverConfigProvider_mock.go new file mode 100644 index 0000000000..560380cbfb --- /dev/null +++ b/backend/tests/mocks/flow/flowmgtmock/serverConfigProvider_mock.go @@ -0,0 +1,109 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package flowmgtmock + +import ( + "context" + + mock "github.com/stretchr/testify/mock" + "github.com/thunder-id/thunderid/pkg/thunderidengine/common" +) + +// newServerConfigProviderMock creates a new instance of serverConfigProviderMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func newServerConfigProviderMock(t interface { + mock.TestingT + Cleanup(func()) +}) *serverConfigProviderMock { + mock := &serverConfigProviderMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// serverConfigProviderMock is an autogenerated mock type for the serverConfigProvider type +type serverConfigProviderMock struct { + mock.Mock +} + +type serverConfigProviderMock_Expecter struct { + mock *mock.Mock +} + +func (_m *serverConfigProviderMock) EXPECT() *serverConfigProviderMock_Expecter { + return &serverConfigProviderMock_Expecter{mock: &_m.Mock} +} + +// GetMergedConfig provides a mock function for the type serverConfigProviderMock +func (_mock *serverConfigProviderMock) GetMergedConfig(ctx context.Context, name string) (any, *common.ServiceError) { + ret := _mock.Called(ctx, name) + + if len(ret) == 0 { + panic("no return value specified for GetMergedConfig") + } + + var r0 any + var r1 *common.ServiceError + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (any, *common.ServiceError)); ok { + return returnFunc(ctx, name) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) any); ok { + r0 = returnFunc(ctx, name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(any) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) *common.ServiceError); ok { + r1 = returnFunc(ctx, name) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*common.ServiceError) + } + } + return r0, r1 +} + +// serverConfigProviderMock_GetMergedConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetMergedConfig' +type serverConfigProviderMock_GetMergedConfig_Call struct { + *mock.Call +} + +// GetMergedConfig is a helper method to define mock.On call +// - ctx context.Context +// - name string +func (_e *serverConfigProviderMock_Expecter) GetMergedConfig(ctx interface{}, name interface{}) *serverConfigProviderMock_GetMergedConfig_Call { + return &serverConfigProviderMock_GetMergedConfig_Call{Call: _e.mock.On("GetMergedConfig", ctx, name)} +} + +func (_c *serverConfigProviderMock_GetMergedConfig_Call) Run(run func(ctx context.Context, name string)) *serverConfigProviderMock_GetMergedConfig_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *serverConfigProviderMock_GetMergedConfig_Call) Return(v any, serviceError *common.ServiceError) *serverConfigProviderMock_GetMergedConfig_Call { + _c.Call.Return(v, serviceError) + return _c +} + +func (_c *serverConfigProviderMock_GetMergedConfig_Call) RunAndReturn(run func(ctx context.Context, name string) (any, *common.ServiceError)) *serverConfigProviderMock_GetMergedConfig_Call { + _c.Call.Return(run) + return _c +} diff --git a/build.ps1 b/build.ps1 index 5336cc3136..69817a05a6 100644 --- a/build.ps1 +++ b/build.ps1 @@ -656,7 +656,7 @@ function Prepare-Backend-For-Packaging { Write-Host "Copying bootstrap scripts..." Copy-Item -Path (Join-Path $BACKEND_DIR "bootstrap") -Destination $package_folder -Recurse -Force # Never ship the dev-only CORS seed that Run stages into the source bootstrap dir. - Remove-Item -Path (Join-Path $package_folder "bootstrap/02-server-configurations.yaml") -Force -ErrorAction SilentlyContinue + Remove-Item -Path (Join-Path $package_folder "bootstrap/03-dev-server-configurations.yaml") -Force -ErrorAction SilentlyContinue # Key material is not generated into the distribution; setup.ps1 generates it per deployment. } @@ -1561,7 +1561,7 @@ value: - "https://localhost:$GATE_APP_DEFAULT_PORT" - "https://localhost:$CONSOLE_APP_DEFAULT_PORT" "@ - Set-Content -Path (Join-Path $BACKEND_DIR "bootstrap/02-server-configurations.yaml") -Value $devServerConfig + Set-Content -Path (Join-Path $BACKEND_DIR "bootstrap/03-dev-server-configurations.yaml") -Value $devServerConfig # Local dev only: default to admin/admin if not supplied. This path never produces a # shared or distributed artifact, so a fixed default here is acceptable. diff --git a/build.sh b/build.sh index 2b111feacf..e6b56fa002 100755 --- a/build.sh +++ b/build.sh @@ -486,7 +486,7 @@ function prepare_backend_for_packaging() { echo "Copying bootstrap scripts..." cp -r "$BACKEND_DIR/bootstrap" "$DIST_DIR/$PRODUCT_FOLDER/" # Never ship the dev-only CORS seed that `run` stages into the source bootstrap dir. - rm -f "$DIST_DIR/$PRODUCT_FOLDER/bootstrap/02-server-configurations.yaml" + rm -f "$DIST_DIR/$PRODUCT_FOLDER/bootstrap/03-dev-server-configurations.yaml" # Key material is not generated into the distribution; setup.sh generates it per deployment. } @@ -1051,7 +1051,7 @@ function run() { # Dev-only: seed CORS allowed origins for the Gate and Console apps so they can call # the backend without manual configuration. Regenerated on every run and picked up by # the bootstrap one-shot; it is git-ignored and never packaged (see build()). - cat > "$BACKEND_DIR/bootstrap/02-server-configurations.yaml" < "$BACKEND_DIR/bootstrap/03-dev-server-configurations.yaml" <.defaultHandle` | Handle of the flow to use when the application and the organization unit do not pin a specific flow. Empty string means no server default, and the flow type is treated as not configured at the server level. | +| `.expirySeconds` | How long (in seconds) a started flow context remains valid before it expires. Must be a positive integer. | + +Supported flow types are `authFlow`, `registrationFlow`, `userOnboardingFlow`, `recoveryFlow`, and `signOutFlow`. + +**Resolution order:** When an inbound request starts a flow, the effective flow is resolved as follows: + +1. Application-level override (the flow ID pinned on the application or agent) +2. Organization unit default (the flow ID configured on the owning OU) +3. Server default from this section (the handle is looked up to find the flow ID) + +If none of the layers supplies a value, registration, recovery, user-onboarding, and sign-out are left not configured (the feature is treated as disabled for that client). Authentication has no such fallback: a missing auth flow causes startup validation to fail. + +Configure flow defaults in either of these ways: + +- **Declarative** - create `config/resources/server_configs/flow.yaml`, then restart: + ```yaml + name: flow + value: + authFlow: + defaultHandle: default-flow + expirySeconds: 1800 + registrationFlow: + expirySeconds: 3600 + recoveryFlow: + expirySeconds: 1800 + signOutFlow: + expirySeconds: 1800 + ``` +- **Runtime** - send the new value to `PUT /server-config/flow`; it is applied immediately and merged with any declarative file: + ```http + PUT /server-config/flow + Content-Type: application/json + + { + "authFlow": { "defaultHandle": "default-flow", "expirySeconds": 1800 }, + "registrationFlow": { "expirySeconds": 3600 } + } + ``` + +:::note +Set `defaultHandle` only for flow types that have a corresponding flow in your deployment. The handle is validated when the configuration is written. An unknown handle or a handle belonging to a different flow type is rejected with `SCF-1003`. For optional types (registration, recovery, user-onboarding, sign-out), omit `defaultHandle` to leave the server-level default empty. +::: + +:::note +`deployment.yaml` previously held `flow.default_auth_flow_handle`, `flow.default_signout_flow_handle`, and `flow.user_onboarding_flow_handle`. These keys are no longer read. Move any values you relied on into the `flow` server-config section using one of the methods above. +::: + ## Passkey Configuration WebAuthn/Passkey settings (typically defined in `deployment.yaml`). diff --git a/tests/integration/flow/flowmeta/flowmeta_api_test.go b/tests/integration/flow/flowmeta/flowmeta_api_test.go index 966ecf340e..b93dc8c35f 100644 --- a/tests/integration/flow/flowmeta/flowmeta_api_test.go +++ b/tests/integration/flow/flowmeta/flowmeta_api_test.go @@ -25,8 +25,8 @@ import ( "net/http" "testing" - "github.com/thunder-id/thunderid/tests/integration/testutils" "github.com/stretchr/testify/suite" + "github.com/thunder-id/thunderid/tests/integration/testutils" ) const ( @@ -61,6 +61,7 @@ type FlowMetaAPITestSuite struct { appID string ouID string isolatedAuthFlowID string + isolatedRegFlowID string } func TestFlowMetaAPITestSuite(t *testing.T) { @@ -79,6 +80,12 @@ func (suite *FlowMetaAPITestSuite) SetupSuite() { suite.isolatedAuthFlowID = isolatedAuthID testApp.AuthFlowID = isolatedAuthID + // Create isolated registration flow so IsRegistrationFlowEnabled is preserved after resolveFlowDefaults. + isolatedRegID, err := testutils.CreateIsolatedRegistrationFlow("flowmeta-api-isolated-reg") + suite.Require().NoError(err, "Failed to create isolated registration flow") + suite.isolatedRegFlowID = isolatedRegID + testApp.RegistrationFlowID = isolatedRegID + // Create Application testApp.OUID = suite.ouID appID, err := testutils.CreateApplication(testApp) @@ -100,6 +107,12 @@ func (suite *FlowMetaAPITestSuite) TearDownSuite() { } } + if suite.isolatedRegFlowID != "" { + if err := testutils.DeleteFlow(suite.isolatedRegFlowID); err != nil { + suite.T().Logf("Failed to delete isolated registration flow during teardown: %v", err) + } + } + if suite.ouID != "" { err := testutils.DeleteOrganizationUnit(suite.ouID) if err != nil { diff --git a/tests/integration/resources/declarative_resources/agents/agent-declarative-1.yaml b/tests/integration/resources/declarative_resources/agents/agent-declarative-1.yaml index b8c2fa5754..ed02890f4a 100644 --- a/tests/integration/resources/declarative_resources/agents/agent-declarative-1.yaml +++ b/tests/integration/resources/declarative_resources/agents/agent-declarative-1.yaml @@ -5,6 +5,8 @@ type: default name: Declarative Test Agent description: A declarative test agent for integration testing authFlowId: decl-flow-1 +registrationFlowId: decl-reg-flow-1 +recoveryFlowId: decl-recovery-flow-1 attributes: department: engineering environment: test diff --git a/tests/integration/resources/declarative_resources/agents/agent-declarative-confidential.yaml b/tests/integration/resources/declarative_resources/agents/agent-declarative-confidential.yaml index 32678fcc97..608c7045db 100644 --- a/tests/integration/resources/declarative_resources/agents/agent-declarative-confidential.yaml +++ b/tests/integration/resources/declarative_resources/agents/agent-declarative-confidential.yaml @@ -5,6 +5,8 @@ type: default name: Declarative Confidential Test Agent description: A declarative confidential test agent for integration testing authFlowId: decl-flow-1 +registrationFlowId: decl-reg-flow-1 +recoveryFlowId: decl-recovery-flow-1 inboundAuthConfig: - type: "oauth2" config: diff --git a/tests/integration/testutils/api_utils.go b/tests/integration/testutils/api_utils.go index 98a489558e..87902409db 100644 --- a/tests/integration/testutils/api_utils.go +++ b/tests/integration/testutils/api_utils.go @@ -1649,6 +1649,45 @@ func CreateIsolatedAuthFlow(handle string) (string, error) { }) } +// CreateIsolatedRegistrationFlow creates a minimal REGISTRATION flow suitable for tests that need +// an app with IsRegistrationFlowEnabled set without triggering cross-type reference validation. +// The handle is caller-supplied so tests can craft unique values per suite and clean up +// deterministically. +func CreateIsolatedRegistrationFlow(handle string) (string, error) { + return CreateFlow(Flow{ + Name: "Isolated Registration Flow " + handle, + FlowType: "REGISTRATION", + Handle: handle, + Nodes: []map[string]interface{}{ + { + "id": "start", + "type": "START", + "onSuccess": "user_type_resolver", + }, + { + "id": "user_type_resolver", + "type": "TASK_EXECUTION", + "executor": map[string]interface{}{ + "name": "UserTypeResolver", + }, + "onSuccess": "provisioning", + }, + { + "id": "provisioning", + "type": "TASK_EXECUTION", + "executor": map[string]interface{}{ + "name": "ProvisioningExecutor", + }, + "onSuccess": "end", + }, + { + "id": "end", + "type": "END", + }, + }, + }) +} + // DeleteFlow deletes a flow by ID func DeleteFlow(flowID string) error { req, err := http.NewRequest("DELETE", TestServerURL+"/flows/"+flowID, nil)