diff --git a/api/flow-execution.yaml b/api/flow-execution.yaml index 617df8295f..e210bd403a 100644 --- a/api/flow-execution.yaml +++ b/api/flow-execution.yaml @@ -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: @@ -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: @@ -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 @@ -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 diff --git a/backend/internal/flow/flowexec/handler.go b/backend/internal/flow/flowexec/handler.go index a082aa2e83..75d9a49e1b 100644 --- a/backend/internal/flow/flowexec/handler.go +++ b/backend/internal/flow/flowexec/handler.go @@ -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" @@ -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) diff --git a/backend/internal/flow/flowexec/model.go b/backend/internal/flow/flowexec/model.go index b6b58a76c9..b97eb2aa28 100644 --- a/backend/internal/flow/flowexec/model.go +++ b/backend/internal/flow/flowexec/model.go @@ -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"` diff --git a/backend/internal/system/constants/server_constants.go b/backend/internal/system/constants/server_constants.go index a6d94ffe93..fc5dab3c02 100644 --- a/backend/internal/system/constants/server_constants.go +++ b/backend/internal/system/constants/server_constants.go @@ -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" diff --git a/docs/content/guides/guides/applications.mdx b/docs/content/guides/guides/applications.mdx index ad097358a6..ef38a1f727 100644 --- a/docs/content/guides/guides/applications.mdx +++ b/docs/content/guides/guides/applications.mdx @@ -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. diff --git a/tests/e2e/utils/authentication/admin-api-auth.ts b/tests/e2e/utils/authentication/admin-api-auth.ts index 871c74961f..ac847133dc 100644 --- a/tests/e2e/utils/authentication/admin-api-auth.ts +++ b/tests/e2e/utils/authentication/admin-api-auth.ts @@ -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()}`); diff --git a/tests/e2e/utils/server-setup/mfa-setup.ts b/tests/e2e/utils/server-setup/mfa-setup.ts index fdb81a9137..c574c7825d 100644 --- a/tests/e2e/utils/server-setup/mfa-setup.ts +++ b/tests/e2e/utils/server-setup/mfa-setup.ts @@ -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, }); diff --git a/tests/integration/flow/authentication/flow_secret_flow_test.go b/tests/integration/flow/authentication/flow_secret_flow_test.go index 24e4ea9485..dabd243eeb 100644 --- a/tests/integration/flow/authentication/flow_secret_flow_test.go +++ b/tests/integration/flow/authentication/flow_secret_flow_test.go @@ -287,8 +287,10 @@ 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") @@ -296,6 +298,9 @@ func (ts *FlowSecretFlowTestSuite) executeNewFlow(body map[string]interface{}) ( 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") @@ -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) @@ -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) @@ -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) @@ -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) diff --git a/tests/integration/flow/common/utils.go b/tests/integration/flow/common/utils.go index c31b35ce48..2773c16c0e 100644 --- a/tests/integration/flow/common/utils.go +++ b/tests/integration/flow/common/utils.go @@ -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 } @@ -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() @@ -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 } @@ -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() diff --git a/tests/integration/testutils/api_utils.go b/tests/integration/testutils/api_utils.go index 2692c3a5be..1e74a94b53 100644 --- a/tests/integration/testutils/api_utils.go +++ b/tests/integration/testutils/api_utils.go @@ -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