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
12 changes: 12 additions & 0 deletions api/flow-execution.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,12 @@ components:
example: "ERROR"
error:
$ref: '#/components/schemas/Error'
errorAssertion:
type: string
description: |
Optionally returned signed JWT error assertion. It carries the flow error type and description bound to
that authorization request.
example: "<jwt_token>"

Data:
type: object
Expand Down Expand Up @@ -555,6 +561,12 @@ components:
$ref: '#/components/schemas/I18nMessage'
description:
$ref: '#/components/schemas/I18nMessage'
errorAssertion:
type: string
description: |
Optionally returned signed JWT error assertion. It carries the flow error type and description bound to
that authorization request.
example: "<jwt_token>"

I18nMessage:
type: object
Expand Down
17 changes: 12 additions & 5 deletions api/oauth2.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,13 @@ paths:
post:
summary: Authorization callback
description: |
Internal endpoint invoked by the flow engine after a successful authentication ceremony.
The flow engine submits a signed assertion JWT; this endpoint validates it, issues an
authorization code, and returns the redirect URI for the client.
Internal endpoint invoked by the Gate after a flow terminates. It submits the flow's signed
assertion JWT, which this endpoint validates before acting on it. A completed flow yields an
authentication assertion and an authorization code is issued (or, for CIBA, the request is
marked authenticated). A terminally failed flow yields an error assertion instead and the
failure is propagated to the waiting request (authorization code: an error redirect; CIBA: the
request is denied or marked failed). The two are distinguished by the assertion's own claims,
so callers submit both in the same field.
tags:
- Authorization
requestBody:
Expand All @@ -190,7 +194,7 @@ paths:
example:
redirect_uri: "https://client.example.com/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=xyz&iss=https%3A%2F%2Fserver.example.com"
"400":
description: Bad Requestmissing or invalid authId / assertion.
description: Bad Request, missing authId or assertion.
content:
application/json:
schema:
Expand Down Expand Up @@ -632,7 +636,10 @@ components:
description: The authorization session identifier returned by the flow engine.
assertion:
type: string
description: Signed JWT assertion from the flow engine containing authentication result.
description: Signed JWT assertion from the flow engine carrying the terminal flow outcome. A completed flow returns an authentication assertion (relay the flow response's assertion field); a terminally failed flow returns an error assertion carrying the flow error type and description (relay the flow response's errorAssertion field). The server validates the signature and the binding to this authorization request before acting, and tells the two apart by the assertion's own claims.
type:
type: string
description: OAuth grant type of the request. Defaults to the authorization code flow when absent.

AuthCallbackResponse:
type: object
Expand Down
1 change: 1 addition & 0 deletions backend/cmd/server/config/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@
"max_jti_length": 256
},
"allow_wildcard_redirect_uri": false,
"send_server_errors_to_client": false,
"allowed_auth_methods" :["client_secret_basic", "client_secret_post", "private_key_jwt", "none"],
"allowed_response_types" : ["code"],
"allowed_grant_types" : ["client_credentials", "authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:token-exchange", "urn:openid:params:grant-type:ciba", "urn:ietf:params:oauth:grant-type:jwt-bearer"],
Expand Down
2 changes: 1 addition & 1 deletion backend/cmd/server/servicemanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,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, serverConfigService, flowConfig)
graphBuilder, jwtService, runtimeStoreProvider, transactioner, serverConfigService, flowConfig)
fatalOnError(ctx, logger, err, "Failed to initialize flow execution service")

// Initialize OAuth services.
Expand Down
20 changes: 20 additions & 0 deletions backend/internal/flow/common/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ const (
DataStepTimeout = "stepTimeout"
// DataInviteLink is the key used for the invite link in the flow response additional data.
DataInviteLink = "inviteLink"
// DataCallbackType is the OAuth grant type surfaced on the terminal flow response's additional data.
DataCallbackType = "callbackType"
Comment thread
ThaminduDilshan marked this conversation as resolved.
// DataEmailSent is the key used to indicate that an email was sent successfully in the flow response.
DataEmailSent = "emailSent"
// DataSMSSent is the key used to indicate that an SMS was sent successfully in the flow response.
Expand All @@ -86,6 +88,20 @@ const (
DataOpenID4VPWalletURI = "openid4vpWalletUri"
)

// Error assertion claims.
const (
ClaimAuthorizationRequestID = "authorization_request_id"
ClaimFlowErrorType = "flow_error_type"
ClaimFlowErrorDescription = "flow_error_description"
)

// FlowErrorType defines the type of error that occurred during flow execution.
const (
FlowErrorTypeServer = "server_error"
FlowErrorTypeClient = "client_error"
Comment thread
ThaminduDilshan marked this conversation as resolved.
FlowErrorTypeEndUser = "end_user_error"
)

// DefaultHTTPTimeout defines the default timeout duration for HTTP requests.
const DefaultHTTPTimeout = 5 * time.Second

Expand Down Expand Up @@ -200,6 +216,10 @@ const (
// RuntimeKeyAuthorizationRequestID holds the auth request identifier bound to the current flow
// execution (the OAuth authorize authId or the CIBA auth_req_id), if applicable.
RuntimeKeyAuthorizationRequestID = "authorizationRequestId"
// RuntimeKeyCallbackType holds the OAuth grant type of the initiating request, seeded by the OAuth
// initiator and surfaced onto the terminal flow response as DataCallbackType so the Gate/SDK routes
// to the correct callback handler. Absent for non-OAuth flows.
RuntimeKeyCallbackType = "callbackType"
// RuntimeKeySSOSessionPresent is the prefix of the per-checkpoint flag recording whether the
// SSO-Check node found a live session that already has this checkpoint's snapshot ("true") or not.
// It is scoped per checkpoint via SSOCheckpointKey; the paired Session node reads it to choose
Expand Down
3 changes: 0 additions & 3 deletions backend/internal/flow/executor/auth_assert_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,6 @@ func (a *authAssertExecutor) Execute(ctx *providers.NodeContext) (*providers.Exe

execResp.Status = providers.ExecComplete
execResp.Assertion = token
if callbackType, ok := ctx.NodeProperties[propertyKeyCallbackType].(string); ok && callbackType != "" {
execResp.AdditionalData[propertyKeyCallbackType] = callbackType
}
} else {
execResp.Status = providers.ExecFailure
execResp.Error = &ErrUserNotAuthenticated
Expand Down
54 changes: 0 additions & 54 deletions backend/internal/flow/executor/auth_assert_executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1622,60 +1622,6 @@ func (suite *AuthAssertExecutorTestSuite) TestIntersectPermissionSpaceList_NoOve
assert.Equal(suite.T(), "", intersectPermissionSpaceList("a b", "c d"))
}

func (suite *AuthAssertExecutorTestSuite) TestExecute_CallbackType_EmittedWhenSet() {
ctx := &providers.NodeContext{
ExecutionID: "flow-ciba",
EntityID: "app-1",
FlowType: providers.FlowTypeAuthentication,
AuthUser: newTestAuthenticatedAuthUser(),
NodeProperties: map[string]interface{}{
propertyKeyCallbackType: "urn:openid:params:grant-type:ciba",
},
RuntimeData: map[string]string{},
ExecutionHistory: map[string]*providers.NodeExecutionRecord{},
}

suite.setupGetEntityReference("INTERNAL", testAuthOUID)
suite.setupGetUserAttributesEmpty()

suite.mockJWTService.On("GenerateJWT", mock.Anything, "user-123", mock.Anything, mock.Anything,
mock.Anything, mock.Anything, mock.Anything).Return("jwt-token", int64(3600), nil)

resp, err := suite.executor.Execute(ctx)

assert.NoError(suite.T(), err)
assert.NotNil(suite.T(), resp)
assert.Equal(suite.T(), providers.ExecComplete, resp.Status)
assert.Equal(suite.T(), "jwt-token", resp.Assertion)
assert.Equal(suite.T(), "urn:openid:params:grant-type:ciba", resp.AdditionalData[propertyKeyCallbackType])
}

func (suite *AuthAssertExecutorTestSuite) TestExecute_CallbackType_AbsentWhenNotSet() {
ctx := &providers.NodeContext{
ExecutionID: "flow-authcode",
EntityID: "app-1",
FlowType: providers.FlowTypeAuthentication,
AuthUser: newTestAuthenticatedAuthUser(),
NodeProperties: map[string]interface{}{},
RuntimeData: map[string]string{},
ExecutionHistory: map[string]*providers.NodeExecutionRecord{},
}

suite.setupGetEntityReference("INTERNAL", testAuthOUID)
suite.setupGetUserAttributesEmpty()

suite.mockJWTService.On("GenerateJWT", mock.Anything, "user-123", mock.Anything, mock.Anything,
mock.Anything, mock.Anything, mock.Anything).Return("jwt-token", int64(3600), nil)

resp, err := suite.executor.Execute(ctx)

assert.NoError(suite.T(), err)
assert.NotNil(suite.T(), resp)
assert.Equal(suite.T(), providers.ExecComplete, resp.Status)
_, hasCallbackType := resp.AdditionalData[propertyKeyCallbackType]
assert.False(suite.T(), hasCallbackType, "callbackType must not be present for auth code flows")
}

func (suite *AuthAssertExecutorTestSuite) TestResolveSubject() {
const defaultSub = "entity-123"
tests := []struct {
Expand Down
24 changes: 22 additions & 2 deletions backend/internal/flow/flowexec/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,11 @@ func (h *flowExecutionHandler) HandleFlowExecutionRequest(w http.ResponseWriter,
flowSecret, attestationToken)

if flowErr != nil {
handleFlowError(r.Context(), w, flowErr)
errorAssertion := ""
if flowStep != nil {
errorAssertion = flowStep.ErrorAssertion
}
handleFlowError(r.Context(), w, flowErr, errorAssertion)
return
}

Expand Down Expand Up @@ -96,6 +100,7 @@ func (h *flowExecutionHandler) HandleFlowExecutionRequest(w http.ResponseWriter,
Type: string(flowStep.Type),
Data: flowStep.Data,
Assertion: flowStep.Assertion,
ErrorAssertion: flowStep.ErrorAssertion,
Error: stepErrorResp,
ChallengeToken: flowStep.ChallengeToken,
}
Expand All @@ -106,8 +111,17 @@ func (h *flowExecutionHandler) HandleFlowExecutionRequest(w http.ResponseWriter,
log.String(log.LoggerKeyExecutionID, flowResp.ExecutionID))
}

// flowErrorResponse is the error body for a failed flow execution. It carries the standard API error
// plus a signed error assertion (when the flow was OAuth-initiated) that the Gate/SDK relays to the
// OAuth callback so the failure reaches the waiting authorization request.
type flowErrorResponse struct {
apierror.ErrorResponse
ErrorAssertion string `json:"errorAssertion,omitempty"`
}

// handleFlowError handles errors that occur during flow execution as an API error response.
func handleFlowError(ctx context.Context, w http.ResponseWriter, flowErr *tidcommon.ServiceError) {
func handleFlowError(ctx context.Context, w http.ResponseWriter, flowErr *tidcommon.ServiceError,
errorAssertion string) {
errResp := apierror.ErrorResponse{
Code: flowErr.Code,
Message: flowErr.Error,
Expand All @@ -127,6 +141,12 @@ func handleFlowError(ctx context.Context, w http.ResponseWriter, flowErr *tidcom
}
}

if errorAssertion != "" {
sysutils.WriteSuccessResponse(ctx, w, statusCode,
flowErrorResponse{ErrorResponse: errResp, ErrorAssertion: errorAssertion})
return
}

sysutils.WriteErrorResponse(ctx, w, statusCode, errResp)
}

Expand Down
51 changes: 48 additions & 3 deletions backend/internal/flow/flowexec/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package flowexec
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
Expand Down Expand Up @@ -48,13 +49,13 @@ func (s *HandlerTestSuite) TestHandleFlowError_ClientError_Returns400() {
DefaultValue: "bad request",
},
}
handleFlowError(context.Background(), w, svcErr)
handleFlowError(context.Background(), w, svcErr, "")
s.Equal(http.StatusBadRequest, w.Code)
}

func (s *HandlerTestSuite) TestHandleFlowError_ForbiddenError_Returns403() {
w := httptest.NewRecorder()
handleFlowError(context.Background(), w, &ErrorDirectFlowInitiationNotPermitted)
handleFlowError(context.Background(), w, &ErrorDirectFlowInitiationNotPermitted, "")
s.Equal(http.StatusForbidden, w.Code)
}

Expand All @@ -68,7 +69,7 @@ func (s *HandlerTestSuite) TestHandleFlowError_ServerError_Returns500() {
DefaultValue: "internal error",
},
}
handleFlowError(context.Background(), w, svcErr)
handleFlowError(context.Background(), w, svcErr, "")
s.Equal(http.StatusInternalServerError, w.Code)
}

Expand Down Expand Up @@ -252,3 +253,47 @@ func (s *HandlerTestSuite) TestHandleFlowExecutionRequest_StepWithError() {
h.HandleFlowExecutionRequest(w, req)
s.Equal(http.StatusOK, w.Code)
}

func (s *HandlerTestSuite) TestHandleFlowError_WithErrorAssertion_IncludesAssertionInBody() {
w := httptest.NewRecorder()
svcErr := &tidcommon.ServiceError{
Code: "FES-1013",
Type: tidcommon.ClientErrorType,
Error: tidcommon.I18nMessage{
Key: "client.error",
DefaultValue: "bad request",
},
}

handleFlowError(context.Background(), w, svcErr, "signed-error-assertion")

s.Equal(http.StatusBadRequest, w.Code)

var body map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &body))
s.Equal("FES-1013", body["code"])
s.Equal("signed-error-assertion", body["errorAssertion"])
s.NotNil(body["message"])
}

func (s *HandlerTestSuite) TestHandleFlowError_WithoutErrorAssertion_OmitsAssertionField() {
w := httptest.NewRecorder()
svcErr := &tidcommon.ServiceError{
Code: "FES-4001",
Type: tidcommon.ClientErrorType,
Error: tidcommon.I18nMessage{
Key: "client.error",
DefaultValue: "bad request",
},
}

handleFlowError(context.Background(), w, svcErr, "")

s.Equal(http.StatusBadRequest, w.Code)

var body map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &body))
s.Equal("FES-4001", body["code"])
_, hasAssertion := body["errorAssertion"]
s.False(hasAssertion)
}
4 changes: 3 additions & 1 deletion backend/internal/flow/flowexec/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/thunder-id/thunderid/internal/flow/graphbuilder"
"github.com/thunder-id/thunderid/internal/flow/interceptor"
"github.com/thunder-id/thunderid/internal/flow/session"
"github.com/thunder-id/thunderid/internal/system/jose/jwt"
"github.com/thunder-id/thunderid/internal/system/middleware"
"github.com/thunder-id/thunderid/pkg/thunderidengine/providers"
)
Expand All @@ -26,6 +27,7 @@ func Initialize(
cryptoSvc providers.RuntimeCryptoProvider,
attestationVerifier providers.AttestationProvider,
graphBuilder graphbuilder.GraphBuilderInterface,
jwtService jwt.JWTServiceInterface,
storeProvider providers.RuntimeStoreProvider,
transactioner providers.Transactioner,
serverConfigSvc serverConfigProvider,
Expand All @@ -37,7 +39,7 @@ func Initialize(
flowProvider, graphBuilder)
flowExecService := newFlowExecService(flowProvider, flowStore, flowEngine,
actorProvider, observabilitySvc, transactioner, cryptoSvc, attestationVerifier,
graphBuilder, serverConfigSvc, cfg)
graphBuilder, jwtService, 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
Expand Down
2 changes: 2 additions & 0 deletions backend/internal/flow/flowexec/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ type FlowStep struct {
ChallengeToken string
Data FlowData
Assertion string
ErrorAssertion string
Error *tidcommon.ServiceError

// SSOHandleOut / SSOFlowID carry an SSO session handle minted during this step back to the
Expand Down Expand Up @@ -255,6 +256,7 @@ type FlowResponse struct {
ChallengeToken string `json:"challengeToken,omitempty"`
Data FlowData `json:"data,omitempty"`
Assertion string `json:"assertion,omitempty"`
ErrorAssertion string `json:"errorAssertion,omitempty"`
Error *apierror.ErrorResponse `json:"error,omitempty"`
}

Expand Down
Loading
Loading