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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions backend/internal/flow/flowexec/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,9 @@ type EngineContext struct {
// SSOHandleIn carries the inbound SSO handle for this request. It is transient: read from
// the transport at the start of execution and never persisted with the flow context.
SSOHandleIn string
// SSOFlowVersion is the current active version of this flow's definition, resolved via
// the flow management provider. Transient; used by the SSO-Check node to reject sessions
// established at an incompatible flow version.
// SSOFlowVersion is the current active version of this flow's definition, captured from the
// flow fetched when the context is loaded. Transient; used by the SSO-Check node to reject
// sessions established at an incompatible flow version.
SSOFlowVersion int
}

Expand Down
48 changes: 2 additions & 46 deletions backend/internal/flow/flowexec/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ import (
"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"
"github.com/thunder-id/thunderid/internal/flow/session"
sysContext "github.com/thunder-id/thunderid/internal/system/context"
Expand Down Expand Up @@ -132,14 +131,6 @@ func (s *flowExecService) Execute(ctx context.Context,

// Resolve the inbound SSO handle for this flow from the request-scoped transport inputs.
applyInboundSSO(engineCtx, ctx)
// Resolve the active flow version whenever the flow establishes or consults an SSO session.
// Both paths need it: the save path (fresh login, which carries no inbound handle) stamps the
// version onto the new session, and the check path compares against it. Gating this on an
// inbound handle would save sessions at version 0 and then fail the version check on the next
// login. Flows that use no SSO session skip the lookup.
if flowUsesSSOSession(engineCtx.Graph) {
engineCtx.SSOFlowVersion = s.resolveActiveFlowVersion(ctx, engineCtx, logger)
}

flowStep, flowErr := s.flowEngine.Execute(engineCtx)

Expand Down Expand Up @@ -195,43 +186,6 @@ func applyInboundSSO(engineCtx *EngineContext, ctx context.Context) {
engineCtx.SSOHandleIn = inbound.HandleFor(engineCtx.Graph.GetID())
}

// flowUsesSSOSession reports whether the flow graph contains a node that establishes or
// consults an SSO session (the Session or SSO-Check executors). It gates the active-flow-version
// lookup so only SSO-capable flows pay for it.
func flowUsesSSOSession(graph core.GraphInterface) bool {
if graph == nil {
return false
}
for _, node := range graph.GetNodes() {
execNode, ok := node.(core.ExecutorBackedNodeInterface)
if !ok {
continue
}
name := execNode.GetExecutorName()
if name == executor.ExecutorNameSession || name == executor.ExecutorNameSSOCheck {
return true
}
}
return false
}

// resolveActiveFlowVersion returns the current active version of the flow, or 0 when it
// cannot be determined. A 0 (unknown) version makes the SSO-Check node treat any saved
// session as version-incompatible, i.e. it falls back to full authentication.
func (s *flowExecService) resolveActiveFlowVersion(ctx context.Context, engineCtx *EngineContext,
logger *log.Logger) int {
if engineCtx.Graph == nil {
return 0
}
def, svcErr := s.flowProvider.GetFlow(ctx, engineCtx.Graph.GetID())
if svcErr != nil || def == nil {
logger.Debug(ctx, "Could not resolve active flow version for SSO check",
log.String("flowID", engineCtx.Graph.GetID()))
return 0
}
return def.ActiveVersion
}

// initContext initializes a new flow context with the given details.
func (s *flowExecService) loadNewContext(ctx context.Context, appID, flowTypeStr string, verbose bool,
action string, inputs map[string]string, flowSecret string, logger *log.Logger) (
Expand Down Expand Up @@ -368,6 +322,7 @@ func (s *flowExecService) initContext(ctx context.Context, appID string, flowTyp
}

engineCtx.FlowType = flow.FlowType
engineCtx.SSOFlowVersion = flow.ActiveVersion
graph, svcErr := s.graphBuilder.GetGraph(ctx, flow)
if svcErr != nil {
logger.Error(ctx, "Error retrieving graph from graph builder",
Expand Down Expand Up @@ -467,6 +422,7 @@ func (s *flowExecService) loadContextFromStore(ctx context.Context, executionID
log.String(log.LoggerKeyExecutionID, executionID), log.Error(err))
return nil, &tidcommon.InternalServerError
}
engineContext.SSOFlowVersion = flow.ActiveVersion

// Set application context if required
if err := s.setApplicationToContext(&engineContext, logger); err != nil {
Expand Down
47 changes: 0 additions & 47 deletions backend/internal/flow/flowexec/service_sso_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,12 @@ import (
"context"
"testing"

"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"

"github.com/thunder-id/thunderid/internal/flow/common"
"github.com/thunder-id/thunderid/internal/flow/core"
"github.com/thunder-id/thunderid/internal/flow/executor"
"github.com/thunder-id/thunderid/internal/flow/session"
"github.com/thunder-id/thunderid/internal/system/cache"
"github.com/thunder-id/thunderid/internal/system/config"
"github.com/thunder-id/thunderid/internal/system/log"
"github.com/thunder-id/thunderid/pkg/thunderidengine/providers"
)

Expand All @@ -58,17 +54,6 @@ func (s *ServiceSSOTestSuite) newTestGraph() core.GraphInterface {
return flowFactory.CreateGraph(testFlowID, providers.FlowTypeAuthentication, 1)
}

// newGraphWithExecutor builds a single-node graph whose node is backed by the given executor.
func (s *ServiceSSOTestSuite) newGraphWithExecutor(executorName string) core.GraphInterface {
flowFactory, _ := core.Initialize(cache.Initialize(config.GetServerRuntime().Config.Cache, "test-deployment"))
graph := flowFactory.CreateGraph(testFlowID, providers.FlowTypeAuthentication, 1)
node, err := flowFactory.CreateNode("n1", string(common.NodeTypeTaskExecution), nil, false, false)
s.Require().NoError(err)
node.(core.ExecutorBackedNodeInterface).SetExecutorName(executorName)
s.Require().NoError(graph.AddNode(node))
return graph
}

func (s *ServiceSSOTestSuite) TestApplyInboundSSO_SelectsHandleForFlow() {
engineCtx := &EngineContext{Graph: s.newTestGraph()}

Expand Down Expand Up @@ -99,35 +84,3 @@ func (s *ServiceSSOTestSuite) TestApplyInboundSSO_NilGraph() {

s.Empty(engineCtx.SSOHandleIn)
}

func (s *ServiceSSOTestSuite) TestResolveActiveFlowVersion_FromProvider() {
provider := NewFlowProviderMock(s.T())
provider.EXPECT().GetFlow(mock.Anything, testFlowID).
Return(&providers.CompleteFlowDefinition{ID: testFlowID, ActiveVersion: 5}, nil)
svc := &flowExecService{flowProvider: provider}
engineCtx := &EngineContext{Graph: s.newTestGraph()}

version := svc.resolveActiveFlowVersion(context.Background(), engineCtx, log.GetLogger())

s.Equal(5, version)
}

func (s *ServiceSSOTestSuite) TestResolveActiveFlowVersion_NilGraph() {
// A nil graph short-circuits before the provider is consulted, so no GetFlow call is expected.
svc := &flowExecService{flowProvider: NewFlowProviderMock(s.T())}

version := svc.resolveActiveFlowVersion(context.Background(), &EngineContext{}, log.GetLogger())

s.Equal(0, version)
}

// TestFlowUsesSSOSession covers the version-lookup gate: a flow that establishes (Session) or consults
// (SSO-Check) a session must have its active version resolved on every path — including the
// fresh-login save path, which carries no inbound handle. Gating the version lookup on an inbound
// handle would persist sessions at version 0 and fail the version check on the next login.
func (s *ServiceSSOTestSuite) TestFlowUsesSSOSession() {
s.True(flowUsesSSOSession(s.newGraphWithExecutor(executor.ExecutorNameSession)))
s.True(flowUsesSSOSession(s.newGraphWithExecutor(executor.ExecutorNameSSOCheck)))
s.False(flowUsesSSOSession(s.newGraphWithExecutor(executor.ExecutorNameCredentialsAuth)))
s.False(flowUsesSSOSession(nil))
}
64 changes: 56 additions & 8 deletions backend/internal/flow/graphbuilder/graph_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,7 @@ package graphbuilder

import (
"context"
"encoding/json"
"errors"
"os"
"testing"

"github.com/thunder-id/thunderid/pkg/thunderidengine/providers"
Expand Down Expand Up @@ -1965,12 +1963,62 @@ func (s *GraphBuilderTestSuite) TestConfigureNodePrompts_InvalidRegexFailsBuild(
// registered executors, and builds into a graph with all expected nodes. It exercises the
// real executor registry so a typo'd executor name or dangling node reference fails here.
func (s *GraphBuilderTestSuite) TestSSOFlowDefinitionBuilds() {
raw, err := os.ReadFile("testdata/sso_flow.json")
s.Require().NoError(err)

var def providers.CompleteFlowDefinition
s.Require().NoError(json.Unmarshal(raw, &def))
def.ID = "auth-sso-flow-test"
def := providers.CompleteFlowDefinition{
ID: "auth-sso-flow-test",
Name: "Default SSO Authentication Flow",
Handle: "default-sso-flow",
FlowType: providers.FlowTypeAuthentication,
Nodes: []providers.NodeDefinition{
{ID: "start", Type: "START", OnSuccess: "sso_check"},
{
ID: "sso_check",
Type: "TASK_EXECUTION",
Executor: &providers.ExecutorDefinition{Name: executor.ExecutorNameSSOCheck},
Properties: map[string]interface{}{common.NodePropertyCheckpointRef: "session"},
OnSuccess: "session",
OnFailure: "prompt_credentials",
},
{
ID: "basic_auth",
Type: "TASK_EXECUTION",
Executor: &providers.ExecutorDefinition{Name: executor.ExecutorNameCredentialsAuth},
OnSuccess: "session",
OnIncomplete: "prompt_credentials",
},
{
ID: "prompt_credentials",
Type: "PROMPT",
Prompts: []providers.PromptDefinition{
{
Inputs: []providers.InputDefinition{
{Ref: "input_001", Identifier: "username", Type: "TEXT_INPUT", Required: true},
{Ref: "input_002", Identifier: "password", Type: "PASSWORD_INPUT", Required: true},
},
Action: &providers.ActionDefinition{Ref: "action_001", NextNode: "basic_auth"},
},
},
},
{
ID: "session",
Type: "TASK_EXECUTION",
Executor: &providers.ExecutorDefinition{Name: executor.ExecutorNameSession},
OnSuccess: "authorization_check",
},
{
ID: "authorization_check",
Type: "TASK_EXECUTION",
Executor: &providers.ExecutorDefinition{Name: executor.ExecutorNameAuthorization},
OnSuccess: "auth_assert",
},
{
ID: "auth_assert",
Type: "TASK_EXECUTION",
Executor: &providers.ExecutorDefinition{Name: executor.ExecutorNameAuthAssert},
OnSuccess: "end",
},
{ID: "end", Type: "END"},
},
}

flowFactory, graphCache := core.Initialize(
cache.Initialize(config.GetServerRuntime().Config.Cache, "test-deployment"))
Expand Down
127 changes: 0 additions & 127 deletions backend/internal/flow/graphbuilder/testdata/sso_flow.json

This file was deleted.

Loading
Loading