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
27 changes: 17 additions & 10 deletions api/flow-execution.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,15 @@ paths:
/flow/execute:
post:
summary: Execute a flow step
description: Execute a step in an authentication flow.
description: >-
Execute a step in an authentication flow. Backend/server-side applications must present
their Flow Secret in the `Flow-Secret` header (see the `FlowSecret` security scheme) when
initiating a new flow; the header is ignored for other application types.
tags:
- Flow Execution
security:
- {}
- FlowSecret: []
requestBody:
required: true
content:
Expand All @@ -44,11 +50,10 @@ paths:
applicationId: "550e8400-e29b-41d4-a716-446655440000"
flowType: "AUTHENTICATION"
initialRequestBackendApp:
summary: Initial request from a backend/server-side application
summary: Initial request from a backend/server-side application (requires the Flow-Secret header)
value:
applicationId: "550e8400-e29b-41d4-a716-446655440000"
flowType: "AUTHENTICATION"
flowSecret: "550e8400e29b41d4a716446655440000abcdef0123456789"
subSequentRequestExample:
summary: Subsequent request
value:
Expand Down Expand Up @@ -202,6 +207,15 @@ paths:
$ref: '#/components/schemas/Error'

components:
securitySchemes:
FlowSecret:
type: apiKey
in: header
name: Flow-Secret
description: >-
Flow Secret used to authenticate backend/server-side applications when initiating a flow
directly. Required for non-public clients that do not use the redirect-based
authorization_code flow; ignored for other applications.
schemas:
InitialFlowRequest:
type: object
Expand All @@ -221,13 +235,6 @@ components:
- REGISTRATION
- RECOVERY
example: "AUTHENTICATION"
flowSecret:
type: string
description: >
Flow Secret used to authenticate backend/server-side applications when
initiating a flow directly. Required for non-public clients that do not
use the redirect-based authorization_code flow; ignored for other applications.
example: "550e8400e29b41d4a716446655440000abcdef0123456789"
verbose:
type: boolean
description: When true, the response includes full UI metadata (meta/components) for prompt nodes
Expand Down
3 changes: 2 additions & 1 deletion backend/internal/flow/flowexec/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (

tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common"

serverconst "github.com/thunder-id/thunderid/internal/system/constants"
"github.com/thunder-id/thunderid/internal/system/error/apierror"
"github.com/thunder-id/thunderid/internal/system/log"
sysutils "github.com/thunder-id/thunderid/internal/system/utils"
Expand Down Expand Up @@ -58,7 +59,7 @@ func (h *flowExecutionHandler) HandleFlowExecutionRequest(w http.ResponseWriter,
action := sysutils.SanitizeString(flowR.Action)
inputs := sysutils.SanitizeStringMap(flowR.Inputs)
challengeToken := sysutils.SanitizeString(flowR.ChallengeToken)
flowSecret := sysutils.SanitizeString(flowR.FlowSecret)
flowSecret := sysutils.SanitizeString(r.Header.Get(serverconst.FlowSecretHeaderName))

flowStep, flowErr := h.flowExecService.Execute(
r.Context(), appID, executionID, flowTypeStr, verbose, action, inputs, challengeToken, flowSecret)
Expand Down
1 change: 0 additions & 1 deletion backend/internal/flow/flowexec/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,6 @@ type FlowResponse struct {
// FlowRequest represents the flow execution API request body
type FlowRequest struct {
ApplicationID string `json:"applicationId"`
FlowSecret string `json:"flowSecret,omitempty"`
FlowType string `json:"flowType"`
Verbose bool `json:"verbose,omitempty"`
ExecutionID string `json:"executionId"`
Expand Down
4 changes: 4 additions & 0 deletions backend/internal/system/constants/server_constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ const ContentTypeHeaderName = "Content-Type"
// the request's trace ID across service boundaries.
const CorrelationIDHeaderName = "X-Correlation-ID"

// FlowSecretHeaderName is the name of the header used to present a Flow Secret when initiating
// a flow directly over HTTP.
const FlowSecretHeaderName = "Flow-Secret"

// TokenTypeBearer is the token type used in bearer authentication.
const TokenTypeBearer = "Bearer"

Expand Down
2 changes: 1 addition & 1 deletion docs/content/guides/guides/applications.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ The Flow Secret is distinct from the Client Secret and serves a different purpos

Copy the Flow Secret from the application details page immediately after creation — like the Client Secret, it is shown only once. To rotate it later, open the application's settings and click **Regenerate Flow Secret**.

To initiate a new flow, pass the Flow Secret in the `flowSecret` field of the Flow Execution request. See the [Flow Execution API Reference](/docs/next/apis/#tag/flow-execution) for the full request payload.
To initiate a new flow, pass the Flow Secret in the `Flow-Secret` request header of the Flow Execution request. See the [Flow Execution API Reference](/docs/next/apis/#tag/flow-execution) for the full request payload.

Applications that cannot initiate flows directly — redirect-based apps and `client_credentials`-only Backend Services — are rejected with `403 Forbidden`. Flow-native apps must present a valid Flow Secret (`401` if missing or invalid). Flow **continuation** requests (those carrying an `executionId`) do not require the Flow Secret.

Expand Down
3 changes: 2 additions & 1 deletion tests/e2e/utils/authentication/admin-api-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ export async function getAdminToken(request: import("@playwright/test").APIReque
const applicationId = E2E_ADMIN_NATIVE_APP_ID;

const flowResponse = await request.post(`${serverUrl}/flow/execute`, {
data: { applicationId, flowSecret: E2E_ADMIN_NATIVE_FLOW_SECRET, flowType: "AUTHENTICATION" },
data: { applicationId, flowType: "AUTHENTICATION" },
headers: { "Flow-Secret": E2E_ADMIN_NATIVE_FLOW_SECRET },
ignoreHTTPSErrors: true,
});
if (!flowResponse.ok()) throw new Error(`Failed to start authentication flow: ${await flowResponse.text()}`);
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/utils/server-setup/mfa-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,9 +189,9 @@ export class MFASetup {
const flowResponse = await this.request.post(`${this.config.serverUrl}/flow/execute`, {
data: {
applicationId: adminAppId,
flowSecret: adminFlowSecret,
flowType: "AUTHENTICATION",
},
headers: { "Flow-Secret": adminFlowSecret },
ignoreHTTPSErrors: true,
});

Expand Down
18 changes: 11 additions & 7 deletions tests/integration/flow/authentication/flow_secret_flow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,15 +287,20 @@ func (ts *FlowSecretFlowTestSuite) TearDownSuite() {
}

// executeNewFlow posts a new-flow INIT request and returns the HTTP status code and parsed error
// body. It does not inject a registered Flow Secret, so the body controls exactly what is sent.
func (ts *FlowSecretFlowTestSuite) executeNewFlow(body map[string]interface{}) (int, *common.ErrorResponse) {
// body. It does not inject a registered Flow Secret, so flowSecret controls exactly what is sent
// in the Flow-Secret header (omitted entirely when empty).
func (ts *FlowSecretFlowTestSuite) executeNewFlow(body map[string]interface{}, flowSecret string) (
int, *common.ErrorResponse) {
reqBody, err := json.Marshal(body)
ts.Require().NoError(err, "failed to marshal flow request")

req, err := http.NewRequest("POST", testutils.TestServerURL+"/flow/execute", bytes.NewReader(reqBody))
ts.Require().NoError(err, "failed to create flow request")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if flowSecret != "" {
req.Header.Set(testutils.FlowSecretHeaderName, flowSecret)
}

resp, err := testutils.GetHTTPClient().Do(req)
ts.Require().NoError(err, "failed to send flow request")
Expand All @@ -320,7 +325,7 @@ func (ts *FlowSecretFlowTestSuite) TestBackendApp_MissingFlowSecret_Rejected() {
status, errResp := ts.executeNewFlow(map[string]interface{}{
"applicationId": flowSecretBackendAppID,
"flowType": "AUTHENTICATION",
})
}, "")

ts.Require().Equal(http.StatusUnauthorized, status)
ts.Require().Equal("FES-1011", errResp.Code)
Expand All @@ -330,9 +335,8 @@ func (ts *FlowSecretFlowTestSuite) TestBackendApp_MissingFlowSecret_Rejected() {
func (ts *FlowSecretFlowTestSuite) TestBackendApp_InvalidFlowSecret_Rejected() {
status, errResp := ts.executeNewFlow(map[string]interface{}{
"applicationId": flowSecretBackendAppID,
"flowSecret": "wrong-secret",
"flowType": "AUTHENTICATION",
})
}, "wrong-secret")

ts.Require().Equal(http.StatusUnauthorized, status)
ts.Require().Equal("FES-1012", errResp.Code)
Expand All @@ -344,7 +348,7 @@ func (ts *FlowSecretFlowTestSuite) TestM2MApp_DirectInitiation_Forbidden() {
status, errResp := ts.executeNewFlow(map[string]interface{}{
"applicationId": flowSecretM2MAppID,
"flowType": "AUTHENTICATION",
})
}, "")

ts.Require().Equal(http.StatusForbidden, status)
ts.Require().Equal("FES-1010", errResp.Code)
Expand All @@ -356,7 +360,7 @@ func (ts *FlowSecretFlowTestSuite) TestRedirectApp_DirectInitiation_Forbidden()
status, errResp := ts.executeNewFlow(map[string]interface{}{
"applicationId": flowSecretRedirectAppID,
"flowType": "AUTHENTICATION",
})
}, "")

ts.Require().Equal(http.StatusForbidden, status)
ts.Require().Equal("FES-1010", errResp.Code)
Expand Down
12 changes: 6 additions & 6 deletions tests/integration/flow/common/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,6 @@ func initiateFlow(appID, flowType string, verbose bool, inputs map[string]string
"applicationId": appID,
"flowType": flowType,
}
if flowSecret := testutils.GetFlowSecret(appID); flowSecret != "" {
flowReqBody["flowSecret"] = flowSecret
}
if verbose {
flowReqBody["verbose"] = true
}
Expand All @@ -82,6 +79,9 @@ func initiateFlow(appID, flowType string, verbose bool, inputs map[string]string

req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if flowSecret := testutils.GetFlowSecret(appID); flowSecret != "" {
req.Header.Set(testutils.FlowSecretHeaderName, flowSecret)
}

client := testutils.GetHTTPClient()

Expand Down Expand Up @@ -110,9 +110,6 @@ func InitiateAuthFlowWithError(appID string, inputs map[string]string) (*ErrorRe
"applicationId": appID,
"flowType": "AUTHENTICATION",
}
if flowSecret := testutils.GetFlowSecret(appID); flowSecret != "" {
flowReqBody["flowSecret"] = flowSecret
}
if len(inputs) > 0 {
flowReqBody["inputs"] = inputs
}
Expand All @@ -129,6 +126,9 @@ func InitiateAuthFlowWithError(appID string, inputs map[string]string) (*ErrorRe

req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if flowSecret := testutils.GetFlowSecret(appID); flowSecret != "" {
req.Header.Set(testutils.FlowSecretHeaderName, flowSecret)
}

client := testutils.GetHTTPClient()

Expand Down
3 changes: 3 additions & 0 deletions tests/integration/testutils/api_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ func GetFlowSecret(appID string) string {

const (
TestServerURL = "https://localhost:8095"

// FlowSecretHeaderName is the header used to present a Flow Secret to /flow/execute.
FlowSecretHeaderName = "Flow-Secret"
)

// GetHTTPClient returns a configured HTTP client for test requests with automatic auth injection
Expand Down
Loading