diff --git a/api/agent.yaml b/api/agent.yaml
index 1e0b5ac9e8..a060e283b7 100644
--- a/api/agent.yaml
+++ b/api/agent.yaml
@@ -2041,6 +2041,12 @@ components:
type: string
description: User types that can sign up through this agent.
example: ["employee", "contractor"]
+ allowedAgentTypes:
+ type: array
+ items:
+ type: string
+ description: Agent types allowed to sign in to this agent. An agent can authenticate only when its agent type is listed here; when the list is empty no agent can sign in.
+ example: ["default"]
inboundAuthConfig:
type: array
items:
@@ -2117,6 +2123,11 @@ components:
items:
type: string
example: ["employee", "contractor"]
+ allowedAgentTypes:
+ type: array
+ items:
+ type: string
+ example: ["default"]
inboundAuthConfig:
type: array
items:
@@ -2191,6 +2202,11 @@ components:
items:
type: string
example: ["employee", "contractor"]
+ allowedAgentTypes:
+ type: array
+ items:
+ type: string
+ example: ["default"]
inboundAuthConfig:
type: array
items:
@@ -2268,6 +2284,11 @@ components:
items:
type: string
example: ["employee", "contractor"]
+ allowedAgentTypes:
+ type: array
+ items:
+ type: string
+ example: ["default"]
inboundAuthConfig:
type: array
items:
diff --git a/api/application.yaml b/api/application.yaml
index 9ad06cf142..82a7a39272 100644
--- a/api/application.yaml
+++ b/api/application.yaml
@@ -854,6 +854,12 @@ components:
type: string
description: User types allowed to sign up through this application.
example: ["employee", "customer", "partner"]
+ allowedAgentTypes:
+ type: array
+ items:
+ type: string
+ description: Agent types allowed to sign in to this application. An agent can authenticate only when its agent type is listed here; when the list is empty no agent can sign in.
+ example: ["default"]
passkeyAllowedOrigins:
type: array
items:
@@ -1000,6 +1006,12 @@ components:
type: string
description: User types allowed to sign up through this application.
example: ["employee", "customer", "partner"]
+ allowedAgentTypes:
+ type: array
+ items:
+ type: string
+ description: Agent types allowed to sign in to this application. An agent can authenticate only when its agent type is listed here; when the list is empty no agent can sign in.
+ example: ["default"]
passkeyAllowedOrigins:
type: array
items:
@@ -1127,6 +1139,12 @@ components:
type: string
description: User types allowed to sign up through this application.
example: ["employee", "customer", "partner"]
+ allowedAgentTypes:
+ type: array
+ items:
+ type: string
+ description: Agent types allowed to sign in to this application. An agent can authenticate only when its agent type is listed here; when the list is empty no agent can sign in.
+ example: ["default"]
passkeyAllowedOrigins:
type: array
items:
diff --git a/backend/internal/actorprovider/utils.go b/backend/internal/actorprovider/utils.go
index 070d538246..e9fe673239 100644
--- a/backend/internal/actorprovider/utils.go
+++ b/backend/internal/actorprovider/utils.go
@@ -44,6 +44,7 @@ func assembleApplication(
Assertion: client.Assertion,
LoginConsent: client.LoginConsent,
AllowedUserTypes: client.AllowedUserTypes,
+ AllowedAgentTypes: client.AllowedAgentTypes,
SubjectAttribute: client.SubjectAttribute,
PasskeyAllowedOrigins: client.PasskeyAllowedOrigins,
},
diff --git a/backend/internal/agent/declarative_resource.go b/backend/internal/agent/declarative_resource.go
index 2d67c347fb..0719d2ef1a 100644
--- a/backend/internal/agent/declarative_resource.go
+++ b/backend/internal/agent/declarative_resource.go
@@ -225,6 +225,7 @@ func makeAgentEntityParser(
Assertion: req.Assertion,
LoginConsent: req.LoginConsent,
AllowedUserTypes: req.AllowedUserTypes,
+ AllowedAgentTypes: req.AllowedAgentTypes,
PasskeyAllowedOrigins: req.PasskeyAllowedOrigins,
Attestation: req.Attestation,
},
@@ -297,6 +298,7 @@ func makeAgentInboundParser(agentSvc AgentServiceInterface) func([]byte) (*inbou
Assertion: req.Assertion,
LoginConsent: req.LoginConsent,
AllowedUserTypes: req.AllowedUserTypes,
+ AllowedAgentTypes: req.AllowedAgentTypes,
PasskeyAllowedOrigins: req.PasskeyAllowedOrigins,
Attestation: req.Attestation,
},
diff --git a/backend/internal/agent/error_constants.go b/backend/internal/agent/error_constants.go
index c4cb3e4871..cafb04a7bd 100644
--- a/backend/internal/agent/error_constants.go
+++ b/backend/internal/agent/error_constants.go
@@ -373,6 +373,20 @@ var (
},
}
+ // ErrorInvalidAllowedAgentType is returned when an allowed agent type does not exist.
+ ErrorInvalidAllowedAgentType = tidcommon.ServiceError{
+ Type: tidcommon.ClientErrorType,
+ Code: "AGT-1043",
+ Error: tidcommon.I18nMessage{
+ Key: "error.agentservice.invalid_allowed_agent_type",
+ DefaultValue: "Invalid agent type",
+ },
+ ErrorDescription: tidcommon.I18nMessage{
+ Key: "error.agentservice.invalid_allowed_agent_type_description",
+ DefaultValue: "One or more specified allowed agent types are invalid",
+ },
+ }
+
// ErrorThemeNotFound is returned when the referenced theme does not exist.
ErrorThemeNotFound = tidcommon.ServiceError{
Type: tidcommon.ClientErrorType,
diff --git a/backend/internal/agent/handler.go b/backend/internal/agent/handler.go
index 3021cb558f..7032fe0e3e 100644
--- a/backend/internal/agent/handler.go
+++ b/backend/internal/agent/handler.go
@@ -97,6 +97,7 @@ func (h *agentHandler) HandleAgentPostRequest(w http.ResponseWriter, r *http.Req
Assertion: req.Assertion,
LoginConsent: req.LoginConsent,
AllowedUserTypes: req.AllowedUserTypes,
+ AllowedAgentTypes: req.AllowedAgentTypes,
PasskeyAllowedOrigins: req.PasskeyAllowedOrigins,
Attestation: req.Attestation,
},
diff --git a/backend/internal/agent/service.go b/backend/internal/agent/service.go
index c8c4b3a19a..0e92a5ceeb 100644
--- a/backend/internal/agent/service.go
+++ b/backend/internal/agent/service.go
@@ -148,7 +148,7 @@ func (s *agentService) CreateAgent(ctx context.Context, agent *model.Agent) (
agent.Type, agent.Name, agent.Description, agent.LogoURL, createdEntity.Attributes,
authFlowID, regFlowID, agent.IsRegistrationFlowEnabled,
agent.ThemeID, agent.LayoutID, assertion, loginConsent,
- agent.AllowedUserTypes, inboundConfigs)
+ agent.AllowedUserTypes, agent.AllowedAgentTypes, inboundConfigs)
resp.OUID = agent.OUID
s.populateOUHandleForComplete(ctx, resp)
return resp, nil
@@ -340,7 +340,7 @@ func (s *agentService) UpdateAgent(ctx context.Context, agentID string,
req.Type, req.Name, req.Description, req.LogoURL, req.Attributes,
authFlowID, regFlowID, resolvedClient.IsRegistrationFlowEnabled,
req.ThemeID, req.LayoutID, assertion, loginConsent,
- req.AllowedUserTypes, inboundConfigs)
+ req.AllowedUserTypes, req.AllowedAgentTypes, inboundConfigs)
resp.OUID = ouID
s.populateOUHandleForComplete(ctx, resp)
return resp, nil
@@ -689,7 +689,7 @@ func (s *agentService) ValidateAgent(ctx context.Context, agent *model.Agent, ex
client := buildInboundClientRecord("", agent.AuthFlowID, agent.RegistrationFlowID,
agent.IsRegistrationFlowEnabled, agent.ThemeID, agent.LayoutID, agent.Assertion,
- agent.LoginConsent, agent.AllowedUserTypes, agent.SubjectAttribute)
+ agent.LoginConsent, agent.AllowedUserTypes, agent.AllowedAgentTypes, agent.SubjectAttribute)
if needsInboundClient(agent) {
oauthProfile := buildOAuthProfile(agent.InboundAuthConfig)
@@ -902,7 +902,7 @@ func (s *agentService) createInboundForAgent(ctx context.Context, agentID string
inboundmodel.InboundClient, *providers.OAuthProfile, *tidcommon.ServiceError) {
client := buildInboundClientRecord(agentID, agent.AuthFlowID, agent.RegistrationFlowID,
agent.IsRegistrationFlowEnabled, agent.ThemeID, agent.LayoutID, agent.Assertion,
- agent.LoginConsent, agent.AllowedUserTypes, agent.SubjectAttribute)
+ agent.LoginConsent, agent.AllowedUserTypes, agent.AllowedAgentTypes, agent.SubjectAttribute)
setLogoProperty(&client, agent.LogoURL)
seedClientSubTypeAttribute(agent.InboundAuthConfig)
@@ -966,7 +966,7 @@ func (s *agentService) reconcileInboundForUpdate(ctx context.Context, agentID st
client := buildInboundClientRecord(agentID, profile.AuthFlowID, profile.RegistrationFlowID,
req.IsRegistrationFlowEnabled, req.ThemeID, req.LayoutID, req.Assertion,
- req.LoginConsent, req.AllowedUserTypes, nil)
+ req.LoginConsent, req.AllowedUserTypes, req.AllowedAgentTypes, nil)
setLogoProperty(&client, req.LogoURL)
oauthProfile := buildOAuthProfile(req.InboundAuthConfig)
hasSecret := clientSecret != ""
@@ -1030,6 +1030,7 @@ func (s *agentService) composeGetResponse(ctx context.Context, e *providers.Enti
resp.Assertion = inbound.Assertion
resp.LoginConsent = inbound.LoginConsent
resp.AllowedUserTypes = inbound.AllowedUserTypes
+ resp.AllowedAgentTypes = inbound.AllowedAgentTypes
resp.LogoURL = logoURLFromProperties(inbound.Properties)
oauth, oauthErr := s.inboundClientService.GetOAuthProfileByEntityID(ctx, e.ID)
@@ -1170,6 +1171,7 @@ func needsInboundClient(agent *model.Agent) bool {
agent.Assertion != nil ||
agent.LoginConsent != nil ||
len(agent.AllowedUserTypes) > 0 ||
+ len(agent.AllowedAgentTypes) > 0 ||
len(agent.InboundAuthConfig) > 0
}
@@ -1188,6 +1190,7 @@ func updateNeedsInboundClient(req *model.UpdateAgentRequest) bool {
req.Assertion != nil ||
req.LoginConsent != nil ||
len(req.AllowedUserTypes) > 0 ||
+ len(req.AllowedAgentTypes) > 0 ||
len(req.InboundAuthConfig) > 0
}
@@ -1356,7 +1359,7 @@ func readSystemAttributes(raw json.RawMessage) (name, description, owner, client
// buildInboundClientRecord constructs an InboundClient record from the agent's identity and inbound auth fields.
func buildInboundClientRecord(agentID, authFlowID, regFlowID string, isRegEnabled bool,
themeID, layoutID string, assertion *inboundmodel.AssertionConfig,
- loginConsent *inboundmodel.LoginConsentConfig, allowedUserTypes []string,
+ loginConsent *inboundmodel.LoginConsentConfig, allowedUserTypes, allowedAgentTypes []string,
subjectAttribute map[string]string) inboundmodel.InboundClient {
return inboundmodel.InboundClient{
ID: agentID,
@@ -1368,6 +1371,7 @@ func buildInboundClientRecord(agentID, authFlowID, regFlowID string, isRegEnable
Assertion: assertion,
LoginConsent: loginConsent,
AllowedUserTypes: allowedUserTypes,
+ AllowedAgentTypes: allowedAgentTypes,
SubjectAttribute: subjectAttribute,
}
}
@@ -1513,7 +1517,7 @@ func convertGrantAndResponseTypes(
func buildCompleteResponse(agentID, owner, clientID, clientSecret, agentType, name, description, logoURL string,
attributes json.RawMessage, authFlowID, regFlowID string, isRegEnabled bool,
themeID, layoutID string, assertion *inboundmodel.AssertionConfig,
- loginConsent *inboundmodel.LoginConsentConfig, allowedUserTypes []string,
+ loginConsent *inboundmodel.LoginConsentConfig, allowedUserTypes, allowedAgentTypes []string,
inboundAuthConfig []providers.InboundAuthConfigWithSecret,
) *model.AgentCompleteResponse {
resp := &model.AgentCompleteResponse{
@@ -1533,6 +1537,7 @@ func buildCompleteResponse(agentID, owner, clientID, clientSecret, agentType, na
Assertion: assertion,
LoginConsent: loginConsent,
AllowedUserTypes: allowedUserTypes,
+ AllowedAgentTypes: allowedAgentTypes,
},
}
if len(inboundAuthConfig) > 0 {
@@ -1838,8 +1843,12 @@ func translateInboundClientFKError(err error) *tidcommon.ServiceError {
return &ErrorLayoutNotFound
case errors.Is(err, inboundclient.ErrFKInvalidUserType):
return &ErrorInvalidUserType
+ case errors.Is(err, inboundclient.ErrFKInvalidAgentType):
+ return &ErrorInvalidAllowedAgentType
case errors.Is(err, inboundclient.ErrUserSchemaLookupFailed):
return &tidcommon.InternalServerError
+ case errors.Is(err, inboundclient.ErrAgentSchemaLookupFailed):
+ return &tidcommon.InternalServerError
case errors.Is(err, inboundclient.ErrUniqueAttributeLookupFailed):
return &tidcommon.InternalServerError
case errors.Is(err, inboundclient.ErrFKInvalidSubjectAttributeMapping):
diff --git a/backend/internal/application/declarative_resource.go b/backend/internal/application/declarative_resource.go
index 7f542f806f..89548aaf56 100644
--- a/backend/internal/application/declarative_resource.go
+++ b/backend/internal/application/declarative_resource.go
@@ -175,6 +175,7 @@ func parseToApplicationDTO(data []byte) (*model.ApplicationDTO, error) {
Assertion: appRequest.Assertion,
LoginConsent: appRequest.LoginConsent,
AllowedUserTypes: appRequest.AllowedUserTypes,
+ AllowedAgentTypes: appRequest.AllowedAgentTypes,
PasskeyAllowedOrigins: appRequest.PasskeyAllowedOrigins,
Attestation: appRequest.Attestation,
},
diff --git a/backend/internal/application/error_constants.go b/backend/internal/application/error_constants.go
index cc0569e1d7..b2d6a200d2 100644
--- a/backend/internal/application/error_constants.go
+++ b/backend/internal/application/error_constants.go
@@ -326,6 +326,20 @@ var (
DefaultValue: "One or more user types in allowed_user_types do not exist in the system",
},
}
+ // ErrorInvalidAgentType is the error returned when an invalid agent type is provided in
+ // allowedAgentTypes.
+ ErrorInvalidAgentType = tidcommon.ServiceError{
+ Type: tidcommon.ClientErrorType,
+ Code: "APP-1046",
+ Error: tidcommon.I18nMessage{
+ Key: "error.applicationservice.invalid_agent_type",
+ DefaultValue: "Invalid agent type",
+ },
+ ErrorDescription: tidcommon.I18nMessage{
+ Key: "error.applicationservice.invalid_agent_type_description",
+ DefaultValue: "One or more agent types in allowedAgentTypes do not exist in the system",
+ },
+ }
// ErrorThemeNotFound is the error returned when theme is not found.
ErrorThemeNotFound = tidcommon.ServiceError{
Type: tidcommon.ClientErrorType,
diff --git a/backend/internal/application/handler.go b/backend/internal/application/handler.go
index e9ebd251f0..9726afd2af 100644
--- a/backend/internal/application/handler.go
+++ b/backend/internal/application/handler.go
@@ -67,6 +67,7 @@ func (ah *applicationHandler) HandleApplicationPostRequest(w http.ResponseWriter
Assertion: appRequest.Assertion,
LoginConsent: appRequest.LoginConsent,
AllowedUserTypes: appRequest.AllowedUserTypes,
+ AllowedAgentTypes: appRequest.AllowedAgentTypes,
PasskeyAllowedOrigins: appRequest.PasskeyAllowedOrigins,
Attestation: appRequest.Attestation,
},
@@ -106,6 +107,7 @@ func (ah *applicationHandler) HandleApplicationPostRequest(w http.ResponseWriter
Assertion: createdAppDTO.Assertion,
LoginConsent: createdAppDTO.LoginConsent,
AllowedUserTypes: createdAppDTO.AllowedUserTypes,
+ AllowedAgentTypes: createdAppDTO.AllowedAgentTypes,
PasskeyAllowedOrigins: createdAppDTO.PasskeyAllowedOrigins,
Attestation: createdAppDTO.Attestation,
},
@@ -188,6 +190,7 @@ func (ah *applicationHandler) HandleApplicationGetRequest(w http.ResponseWriter,
Assertion: appDTO.Assertion,
LoginConsent: appDTO.LoginConsent,
AllowedUserTypes: appDTO.AllowedUserTypes,
+ AllowedAgentTypes: appDTO.AllowedAgentTypes,
PasskeyAllowedOrigins: appDTO.PasskeyAllowedOrigins,
Attestation: appDTO.Attestation,
},
@@ -332,6 +335,7 @@ func (ah *applicationHandler) HandleApplicationPutRequest(w http.ResponseWriter,
Assertion: appRequest.Assertion,
LoginConsent: appRequest.LoginConsent,
AllowedUserTypes: appRequest.AllowedUserTypes,
+ AllowedAgentTypes: appRequest.AllowedAgentTypes,
PasskeyAllowedOrigins: appRequest.PasskeyAllowedOrigins,
Attestation: appRequest.Attestation,
},
@@ -371,6 +375,7 @@ func (ah *applicationHandler) HandleApplicationPutRequest(w http.ResponseWriter,
Assertion: updatedAppDTO.Assertion,
LoginConsent: updatedAppDTO.LoginConsent,
AllowedUserTypes: updatedAppDTO.AllowedUserTypes,
+ AllowedAgentTypes: updatedAppDTO.AllowedAgentTypes,
PasskeyAllowedOrigins: updatedAppDTO.PasskeyAllowedOrigins,
Attestation: updatedAppDTO.Attestation,
},
diff --git a/backend/internal/application/service.go b/backend/internal/application/service.go
index 74c429bab8..50d10d4f52 100644
--- a/backend/internal/application/service.go
+++ b/backend/internal/application/service.go
@@ -831,6 +831,7 @@ func toInboundClient(dto *model.ApplicationProcessedDTO) inboundmodel.InboundCli
Assertion: dto.Assertion,
LoginConsent: dto.LoginConsent,
AllowedUserTypes: dto.AllowedUserTypes,
+ AllowedAgentTypes: dto.AllowedAgentTypes,
SubjectAttribute: dto.SubjectAttribute,
PasskeyAllowedOrigins: dto.PasskeyAllowedOrigins,
Attestation: dto.Attestation,
@@ -888,6 +889,7 @@ func toProcessedDTO(
Assertion: dao.Assertion,
LoginConsent: dao.LoginConsent,
AllowedUserTypes: dao.AllowedUserTypes,
+ AllowedAgentTypes: dao.AllowedAgentTypes,
SubjectAttribute: dao.SubjectAttribute,
PasskeyAllowedOrigins: dao.PasskeyAllowedOrigins,
Attestation: dao.Attestation.WithoutCredentials(),
@@ -1584,8 +1586,12 @@ func translateInboundClientFKError(err error) *tidcommon.ServiceError {
return &ErrorLayoutNotFound
case errors.Is(err, inboundclient.ErrFKInvalidUserType):
return &ErrorInvalidUserType
+ case errors.Is(err, inboundclient.ErrFKInvalidAgentType):
+ return &ErrorInvalidAgentType
case errors.Is(err, inboundclient.ErrUserSchemaLookupFailed):
return &tidcommon.InternalServerError
+ case errors.Is(err, inboundclient.ErrAgentSchemaLookupFailed):
+ return &tidcommon.InternalServerError
case errors.Is(err, inboundclient.ErrUniqueAttributeLookupFailed):
return &tidcommon.InternalServerError
case errors.Is(err, inboundclient.ErrFKInvalidSubjectAttributeMapping):
@@ -1847,6 +1853,7 @@ func buildApplicationResponse(dto *model.ApplicationProcessedDTO) *providers.App
LayoutID: dto.LayoutID,
Assertion: dto.Assertion,
AllowedUserTypes: dto.AllowedUserTypes,
+ AllowedAgentTypes: dto.AllowedAgentTypes,
SubjectAttribute: dto.SubjectAttribute,
PasskeyAllowedOrigins: dto.PasskeyAllowedOrigins,
LoginConsent: dto.LoginConsent,
@@ -1960,6 +1967,7 @@ func buildBaseApplicationProcessedDTO(appID string, app *model.ApplicationDTO,
LayoutID: app.LayoutID,
Assertion: assertion,
AllowedUserTypes: app.AllowedUserTypes,
+ AllowedAgentTypes: app.AllowedAgentTypes,
SubjectAttribute: app.SubjectAttribute,
PasskeyAllowedOrigins: app.PasskeyAllowedOrigins,
LoginConsent: app.LoginConsent,
@@ -2046,6 +2054,7 @@ func buildReturnApplicationDTO(
LayoutID: app.LayoutID,
Assertion: assertion,
AllowedUserTypes: app.AllowedUserTypes,
+ AllowedAgentTypes: app.AllowedAgentTypes,
SubjectAttribute: app.SubjectAttribute,
PasskeyAllowedOrigins: app.PasskeyAllowedOrigins,
LoginConsent: app.LoginConsent,
diff --git a/backend/internal/authnprovider/common/subject_constraints.go b/backend/internal/authnprovider/common/subject_constraints.go
new file mode 100644
index 0000000000..00d6d8f619
--- /dev/null
+++ b/backend/internal/authnprovider/common/subject_constraints.go
@@ -0,0 +1,50 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package common
+
+import (
+ "context"
+ "slices"
+
+ "github.com/thunder-id/thunderid/pkg/thunderidengine/providers"
+)
+
+// SubjectTypeConstraints are the request-scoped limits on which entities the application/agent driving
+// the current authentication accepts as its subject. They are carried on the Go context.Context rather
+// than a NodeContext field or the AuthnMetadata contract, so they reach the authentication providers
+// below the flow graph: a flow author cannot omit the check by rewriting a flow definition, and no
+// executor has to opt in.
+type SubjectTypeConstraints struct {
+ // AllowedUserTypes are the user type names accepted as a subject
+ AllowedUserTypes []string
+ // AllowedAgentTypes are the agent type names accepted as a subject. Empty accepts no agent.
+ AllowedAgentTypes []string
+}
+
+// PermitsSubject reports whether an entity of the given category and type may authenticate under
+// these constraints. Only agent types are subject to these constraints for now.
+// Constraints on user types can be enforced after ongoing discussions are complete on the topic.
+func (c SubjectTypeConstraints) PermitsSubject(category providers.EntityCategory, entityType string) bool {
+ if category == providers.EntityCategoryAgent {
+ return slices.Contains(c.AllowedAgentTypes, entityType)
+ }
+ return true
+}
+
+type subjectTypeConstraintsContextKey struct{}
+
+// WithSubjectTypeConstraints returns a context carrying the subject type constraints of the
+// application/agent driving the current authentication.
+func WithSubjectTypeConstraints(ctx context.Context, c SubjectTypeConstraints) context.Context {
+ return context.WithValue(ctx, subjectTypeConstraintsContextKey{}, c)
+}
+
+// SubjectTypeConstraintsFrom returns the subject type constraints carried on the context. The
+// second return value reports whether any were set: entry points that are not scoped to an
+// application (the credentials authentication API, admin operations) set none, and callers must
+// skip the check rather than fall back to the zero value, which denies every agent.
+func SubjectTypeConstraintsFrom(ctx context.Context) (SubjectTypeConstraints, bool) {
+ c, ok := ctx.Value(subjectTypeConstraintsContextKey{}).(SubjectTypeConstraints)
+ return c, ok
+}
diff --git a/backend/internal/authnprovider/common/subject_constraints_test.go b/backend/internal/authnprovider/common/subject_constraints_test.go
new file mode 100644
index 0000000000..6711d1dda7
--- /dev/null
+++ b/backend/internal/authnprovider/common/subject_constraints_test.go
@@ -0,0 +1,71 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package common
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+
+ "github.com/thunder-id/thunderid/pkg/thunderidengine/providers"
+)
+
+type SubjectTypeConstraintsTestSuite struct {
+ suite.Suite
+}
+
+func TestSubjectTypeConstraintsTestSuite(t *testing.T) {
+ suite.Run(t, new(SubjectTypeConstraintsTestSuite))
+}
+
+func (suite *SubjectTypeConstraintsTestSuite) TestPermitsSubject() {
+ constraints := SubjectTypeConstraints{
+ AllowedUserTypes: []string{"customer"},
+ AllowedAgentTypes: []string{"default"},
+ }
+
+ testCases := []struct {
+ name string
+ constraints SubjectTypeConstraints
+ category providers.EntityCategory
+ entityType string
+ expected bool
+ }{
+ {"any user type", constraints, providers.EntityCategoryUser, "employee", true},
+ {"listed agent type", constraints, providers.EntityCategoryAgent, "default", true},
+ {"unlisted agent type", constraints, providers.EntityCategoryAgent, "privileged", false},
+ {
+ "empty agent list accepts no agent",
+ SubjectTypeConstraints{}, providers.EntityCategoryAgent, "default", false,
+ },
+ }
+
+ for _, tc := range testCases {
+ suite.Run(tc.name, func() {
+ assert.Equal(suite.T(), tc.expected,
+ tc.constraints.PermitsSubject(tc.category, tc.entityType))
+ })
+ }
+}
+
+func (suite *SubjectTypeConstraintsTestSuite) TestContextRoundTrip() {
+ in := SubjectTypeConstraints{
+ AllowedUserTypes: []string{"customer"},
+ AllowedAgentTypes: []string{"default"},
+ }
+
+ out, ok := SubjectTypeConstraintsFrom(WithSubjectTypeConstraints(context.Background(), in))
+
+ assert.True(suite.T(), ok)
+ assert.Equal(suite.T(), in, out)
+}
+
+func (suite *SubjectTypeConstraintsTestSuite) TestContextWithoutConstraints() {
+ out, ok := SubjectTypeConstraintsFrom(context.Background())
+
+ assert.False(suite.T(), ok)
+ assert.Equal(suite.T(), SubjectTypeConstraints{}, out)
+}
diff --git a/backend/internal/authnprovider/manager/error_constants.go b/backend/internal/authnprovider/manager/error_constants.go
index 2ae5d41740..8cd634f5d9 100644
--- a/backend/internal/authnprovider/manager/error_constants.go
+++ b/backend/internal/authnprovider/manager/error_constants.go
@@ -113,4 +113,19 @@ var (
DefaultValue: "The entity reference fetch was rejected by the provider",
},
}
+
+ // ErrorSubjectNotAllowed is returned when the authenticated entity's category and type are not
+ // accepted as a subject by the application/agent driving the authentication.
+ ErrorSubjectNotAllowed = tidcommon.ServiceError{
+ Type: tidcommon.ClientErrorType,
+ Code: "AUTHN-MGR-1011",
+ Error: tidcommon.I18nMessage{
+ Key: "error.authnmgrservice.subject_not_allowed",
+ DefaultValue: "Subject not allowed",
+ },
+ ErrorDescription: tidcommon.I18nMessage{
+ Key: "error.authnmgrservice.subject_not_allowed_description",
+ DefaultValue: "The authenticated subject is not allowed to sign in to this application",
+ },
+ }
)
diff --git a/backend/internal/authnprovider/manager/manager.go b/backend/internal/authnprovider/manager/manager.go
index a4a1c8f8c6..f5d012bf1e 100644
--- a/backend/internal/authnprovider/manager/manager.go
+++ b/backend/internal/authnprovider/manager/manager.go
@@ -194,6 +194,10 @@ func (m *authnProviderManager) AuthenticateUser(ctx context.Context, identifiers
return authUser, nil, &ErrorAuthenticationFailed
}
}
+ if svcErr := m.checkSubjectAllowed(ctx, authResult.EntityReference); svcErr != nil {
+ return authUser, nil, svcErr
+ }
+
authUser, svcErr = m.updateAuthUser(ctx, authResult, authUser, selectedProviderName)
if svcErr != nil {
return authUser, nil, svcErr
@@ -266,9 +270,34 @@ func (m *authnProviderManager) GetEntityReference(ctx context.Context, authUser
seen = true
}
+ if svcErr := m.checkSubjectAllowed(ctx, entityRef); svcErr != nil {
+ return authUser, nil, svcErr
+ }
+
return authUser, entityRef, nil
}
+// checkSubjectAllowed rejects a resolved subject whose category and type the application/ agent driving the
+// current authentication does not accept. A nil reference means the subject is not resolved yet (the provider
+// returned an entity reference token for an entity it has not provisioned), and the check applies once
+// GetEntityReference resolves it.
+func (m *authnProviderManager) checkSubjectAllowed(
+ ctx context.Context, entityRef *providers.EntityReference) *tidcommon.ServiceError {
+ if entityRef == nil {
+ return nil
+ }
+ constraints, ok := authnprovidercm.SubjectTypeConstraintsFrom(ctx)
+ if !ok || constraints.PermitsSubject(
+ providers.EntityCategory(entityRef.EntityCategory), entityRef.EntityType) {
+ return nil
+ }
+ m.logger.Debug(ctx, "resolved subject is not allowed for the application",
+ log.String("entityId", entityRef.EntityID),
+ log.String("entityCategory", entityRef.EntityCategory),
+ log.String("entityType", entityRef.EntityType))
+ return &ErrorSubjectNotAllowed
+}
+
// GetUserAvailableAttributes returns the merged attributes available across
// every provider's state in the AuthUser.
func (m *authnProviderManager) GetUserAvailableAttributes(ctx context.Context,
@@ -374,6 +403,10 @@ func (m *authnProviderManager) Enroll(ctx context.Context, identifiers, credenti
return authUser, nil, &ErrorEnrollmentFailed
}
}
+ if svcErr := m.checkSubjectAllowed(ctx, authResult.EntityReference); svcErr != nil {
+ return authUser, nil, svcErr
+ }
+
authUser, svcErr = m.updateAuthUser(ctx, authResult, authUser, selectedProviderName)
if svcErr != nil {
return authUser, nil, svcErr
diff --git a/backend/internal/authnprovider/manager/manager_test.go b/backend/internal/authnprovider/manager/manager_test.go
index 121cc4308b..547b2c672c 100644
--- a/backend/internal/authnprovider/manager/manager_test.go
+++ b/backend/internal/authnprovider/manager/manager_test.go
@@ -266,6 +266,97 @@ func (s *ManagerTestSuite) TestAuthenticateUser_InvalidRequest() {
)
}
+func (s *ManagerTestSuite) TestAuthenticateUser_ResolvedAgentRejectedByConstraints() {
+ credentials := map[string]interface{}{"password": "secret"}
+ meta := &providers.AuthnMetadata{}
+ ctx := authnprovidercm.WithSubjectTypeConstraints(context.Background(),
+ authnprovidercm.SubjectTypeConstraints{AllowedUserTypes: []string{"customer"}})
+
+ s.mockProvider.On("Authenticate", ctx, mock.Anything, credentials, meta).
+ Return(&providers.AuthnResult{
+ EntityReference: &providers.EntityReference{
+ EntityID: "agent-1", EntityCategory: "agent", EntityType: "default", OUID: "ou-1",
+ },
+ Attributes: &providers.AttributesResponse{},
+ }, (*tidcommon.ServiceError)(nil))
+
+ returnedAuthUser, rtAttrs, svcErr := s.mgr.AuthenticateUser(ctx, nil, credentials,
+ nil, meta, providers.AuthUser{})
+
+ s.Require().NotNil(svcErr)
+ s.Equal(ErrorSubjectNotAllowed.Code, svcErr.Code)
+ s.Nil(rtAttrs)
+ s.False(returnedAuthUser.IsAuthenticated())
+}
+
+func (s *ManagerTestSuite) TestAuthenticateUser_ResolvedAgentAllowedByConstraints() {
+ credentials := map[string]interface{}{"password": "secret"}
+ meta := &providers.AuthnMetadata{}
+ entityRef := &providers.EntityReference{
+ EntityID: "agent-1", EntityCategory: "agent", EntityType: "default", OUID: "ou-1",
+ }
+ ctx := authnprovidercm.WithSubjectTypeConstraints(context.Background(),
+ authnprovidercm.SubjectTypeConstraints{AllowedAgentTypes: []string{"default"}})
+
+ s.mockProvider.On("Authenticate", ctx, mock.Anything, credentials, meta).
+ Return(&providers.AuthnResult{
+ EntityReference: entityRef,
+ Attributes: &providers.AttributesResponse{},
+ }, (*tidcommon.ServiceError)(nil))
+
+ returnedAuthUser, _, svcErr := s.mgr.AuthenticateUser(ctx, nil, credentials,
+ nil, meta, providers.AuthUser{})
+
+ s.Nil(svcErr)
+ s.True(returnedAuthUser.IsAuthenticated())
+ st, ok := returnedAuthUser.StateFor(defaultProviderName)
+ s.True(ok)
+ s.Equal(entityRef, st.EntityReference)
+}
+
+// A provider that has not resolved the subject yet returns an entity reference token instead of a
+// reference, so there is no category or type to check. GetEntityReference applies the check later.
+func (s *ManagerTestSuite) TestAuthenticateUser_UnresolvedSubjectDefersConstraintCheck() {
+ credentials := map[string]interface{}{"password": "secret"}
+ meta := &providers.AuthnMetadata{}
+ entityRefToken := map[string]interface{}{"sub": "agent-1"}
+ ctx := authnprovidercm.WithSubjectTypeConstraints(context.Background(),
+ authnprovidercm.SubjectTypeConstraints{})
+
+ s.mockProvider.On("Authenticate", ctx, mock.Anything, credentials, meta).
+ Return(&providers.AuthnResult{
+ EntityReferenceToken: entityRefToken,
+ AttributeToken: entityRefToken,
+ }, (*tidcommon.ServiceError)(nil))
+
+ returnedAuthUser, _, svcErr := s.mgr.AuthenticateUser(ctx, nil, credentials,
+ nil, meta, providers.AuthUser{})
+
+ s.Nil(svcErr)
+ s.True(returnedAuthUser.IsAuthenticated())
+}
+
+// Entry points that are not scoped to an application (the credentials authentication API) carry no
+// constraints, so the check is skipped rather than defaulting to deny.
+func (s *ManagerTestSuite) TestAuthenticateUser_AgentAllowedWhenNoConstraintsOnContext() {
+ credentials := map[string]interface{}{"password": "secret"}
+ meta := &providers.AuthnMetadata{}
+
+ s.mockProvider.On("Authenticate", context.Background(), mock.Anything, credentials, meta).
+ Return(&providers.AuthnResult{
+ EntityReference: &providers.EntityReference{
+ EntityID: "agent-1", EntityCategory: "agent", EntityType: "default", OUID: "ou-1",
+ },
+ Attributes: &providers.AttributesResponse{},
+ }, (*tidcommon.ServiceError)(nil))
+
+ returnedAuthUser, _, svcErr := s.mgr.AuthenticateUser(context.Background(), nil, credentials,
+ nil, meta, providers.AuthUser{})
+
+ s.Nil(svcErr)
+ s.True(returnedAuthUser.IsAuthenticated())
+}
+
func (s *ManagerTestSuite) assertAuthenticateUserClientErrorMapping(
providerErrorCode, providerError, providerErrorDescription, expectedServiceErrorCode string,
) {
@@ -511,6 +602,37 @@ func (s *ManagerTestSuite) TestGetEntityReference_AlreadyResolved() {
s.mockProvider.AssertNotCalled(s.T(), "GetEntityReference")
}
+func (s *ManagerTestSuite) TestGetEntityReference_ResolvedAgentRejectedByConstraints() {
+ // An SSO checkpoint replays an already-resolved agent reference into an application that allows
+ // no agent type, so the manager rejects it without consulting the provider.
+ entityRef := &providers.EntityReference{
+ EntityID: "agent-1", EntityCategory: "agent", EntityType: "default", OUID: "ou-1",
+ }
+ authUser := authenticatedAuthUserWithResolved(entityRef, &providers.AttributesResponse{})
+ ctx := authnprovidercm.WithSubjectTypeConstraints(context.Background(),
+ authnprovidercm.SubjectTypeConstraints{AllowedUserTypes: []string{"customer"}})
+
+ _, retRef, svcErr := s.mgr.GetEntityReference(ctx, authUser)
+
+ s.Nil(retRef)
+ s.Require().NotNil(svcErr)
+ s.Equal(ErrorSubjectNotAllowed.Code, svcErr.Code)
+}
+
+func (s *ManagerTestSuite) TestGetEntityReference_ResolvedAgentAllowedByConstraints() {
+ entityRef := &providers.EntityReference{
+ EntityID: "agent-1", EntityCategory: "agent", EntityType: "default", OUID: "ou-1",
+ }
+ authUser := authenticatedAuthUserWithResolved(entityRef, &providers.AttributesResponse{})
+ ctx := authnprovidercm.WithSubjectTypeConstraints(context.Background(),
+ authnprovidercm.SubjectTypeConstraints{AllowedAgentTypes: []string{"default"}})
+
+ _, retRef, svcErr := s.mgr.GetEntityReference(ctx, authUser)
+
+ s.Nil(svcErr)
+ s.Equal(entityRef, retRef)
+}
+
func (s *ManagerTestSuite) TestGetEntityReference_FetchFromProvider() {
entityRefToken := map[string]interface{}{"userID": "user-1"}
authUser := authenticatedAuthUserWithTokens(entityRefToken, "attr-tok")
@@ -922,6 +1044,27 @@ func (s *ManagerTestSuite) TestEnroll_Success() {
s.Equal(entityRefToken, st.EntityReferenceToken)
}
+func (s *ManagerTestSuite) TestEnroll_ResolvedAgentRejectedByConstraints() {
+ credentials := map[string]interface{}{"passkey": "cred"}
+ meta := &providers.AuthnMetadata{}
+ ctx := authnprovidercm.WithSubjectTypeConstraints(context.Background(),
+ authnprovidercm.SubjectTypeConstraints{AllowedUserTypes: []string{"customer"}})
+
+ s.mockProvider.On("Enroll", ctx, map[string]interface{}(nil), credentials, meta).
+ Return(&providers.AuthnResult{
+ EntityReference: &providers.EntityReference{
+ EntityID: "agent-1", EntityCategory: "agent", EntityType: "default", OUID: "ou-1",
+ },
+ Attributes: &providers.AttributesResponse{},
+ }, (*tidcommon.ServiceError)(nil))
+
+ authUser, _, svcErr := s.mgr.Enroll(ctx, nil, credentials, nil, meta, providers.AuthUser{})
+
+ s.Require().NotNil(svcErr)
+ s.Equal(ErrorSubjectNotAllowed.Code, svcErr.Code)
+ s.False(authUser.IsAuthenticated())
+}
+
func (s *ManagerTestSuite) TestEnroll_ServerError() {
credentials := map[string]interface{}{"passkey": "cred"}
s.mockProvider.On("Enroll", context.Background(), mock.Anything, credentials, mock.Anything).
diff --git a/backend/internal/flow/flowexec/engine.go b/backend/internal/flow/flowexec/engine.go
index 9e60a4156f..12531fd2e7 100644
--- a/backend/internal/flow/flowexec/engine.go
+++ b/backend/internal/flow/flowexec/engine.go
@@ -14,6 +14,7 @@ import (
tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common"
+ authnprovidercm "github.com/thunder-id/thunderid/internal/authnprovider/common"
"github.com/thunder-id/thunderid/internal/flow/common"
"github.com/thunder-id/thunderid/internal/flow/core"
"github.com/thunder-id/thunderid/internal/flow/executor"
@@ -164,10 +165,21 @@ func (fe *flowEngine) executeNodePackage(ctx *EngineContext,
FlowID: ssoFlowID(ctx),
FlowVersion: ctx.SSOFlowVersion,
})
+ // The application's subject type constraints ride on the context for the same reason: the authn
+ // providers enforce them below the flow graph, so no node has to carry or check them. Only an
+ // authentication flow signs a subject in to the application; the other flow types resolve the
+ // eligible entity types through their own executors.
+ nodeCtxContext := ssoCtx
+ if ctx.FlowType == providers.FlowTypeAuthentication {
+ nodeCtxContext = authnprovidercm.WithSubjectTypeConstraints(ssoCtx, authnprovidercm.SubjectTypeConstraints{
+ AllowedUserTypes: ctx.Application.AllowedUserTypes,
+ AllowedAgentTypes: ctx.Application.AllowedAgentTypes,
+ })
+ }
fe.replayPromptInputs(ctx)
nodeCtx := &providers.NodeContext{
- Context: ssoCtx,
+ Context: nodeCtxContext,
ExecutionID: ctx.ExecutionID,
FlowType: ctx.FlowType,
EntityID: ctx.AppID,
diff --git a/backend/internal/flow/flowexec/engine_test.go b/backend/internal/flow/flowexec/engine_test.go
index 9d7bd76d5b..06e8fe9993 100644
--- a/backend/internal/flow/flowexec/engine_test.go
+++ b/backend/internal/flow/flowexec/engine_test.go
@@ -15,6 +15,7 @@ import (
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
+ authnprovidercm "github.com/thunder-id/thunderid/internal/authnprovider/common"
"github.com/thunder-id/thunderid/internal/flow/common"
"github.com/thunder-id/thunderid/internal/flow/core"
"github.com/thunder-id/thunderid/internal/system/log"
@@ -2648,6 +2649,63 @@ func (s *EngineTestSuite) TestExecuteNodePackage_SkipsNodeWhenShouldExecuteFalse
s.NotNil(err, "missing OnSkip target should surface an internal server error")
}
+// executeNodePackageCapturingContext runs one node and returns the context the engine handed it.
+func (s *EngineTestSuite) executeNodePackageCapturingContext(flowType providers.FlowType,
+ app providers.Application) context.Context {
+ t := s.T()
+ var captured context.Context
+ mockNode := coremock.NewNodeInterfaceMock(t)
+ mockNode.On("GetID").Return("n1").Maybe()
+ mockNode.On("GetType").Return(common.NodeTypeStart).Maybe()
+ mockNode.On("ShouldExecute", mock.Anything).Return(true)
+ mockNode.On("GetProperties").Return(map[string]interface{}(nil)).Maybe()
+ mockNode.On("Execute", mock.Anything).Run(func(args mock.Arguments) {
+ captured = args.Get(0).(*providers.NodeContext).Context
+ }).Return(&common.NodeResponse{Status: common.NodeStatusComplete}, nil)
+
+ fe := &flowEngine{
+ logger: log.GetLogger(),
+ observabilitySvc: setupNodePackageMockObs(t),
+ }
+ ctx := &EngineContext{
+ Context: context.Background(),
+ ExecutionID: "exec-constraints",
+ FlowType: flowType,
+ Application: app,
+ CurrentNode: mockNode,
+ UserInputs: map[string]string{},
+ ExecutionHistory: map[string]*providers.NodeExecutionRecord{},
+ }
+
+ _, _, err := fe.executeNodePackage(ctx, mockNode, &FlowStep{}, 0)
+ s.Nil(err)
+ s.Require().NotNil(captured)
+ return captured
+}
+
+func (s *EngineTestSuite) TestExecuteNodePackage_CarriesSubjectTypeConstraintsOnAuthenticationFlow() {
+ app := providers.Application{}
+ app.AllowedUserTypes = []string{"customer"}
+ app.AllowedAgentTypes = []string{"default"}
+
+ nodeCtx := s.executeNodePackageCapturingContext(providers.FlowTypeAuthentication, app)
+
+ constraints, ok := authnprovidercm.SubjectTypeConstraintsFrom(nodeCtx)
+ s.True(ok)
+ s.Equal([]string{"customer"}, constraints.AllowedUserTypes)
+ s.Equal([]string{"default"}, constraints.AllowedAgentTypes)
+}
+
+func (s *EngineTestSuite) TestExecuteNodePackage_OmitsSubjectTypeConstraintsOnOtherFlowTypes() {
+ app := providers.Application{}
+ app.AllowedAgentTypes = []string{"default"}
+
+ nodeCtx := s.executeNodePackageCapturingContext(providers.FlowTypeRegistration, app)
+
+ _, ok := authnprovidercm.SubjectTypeConstraintsFrom(nodeCtx)
+ s.False(ok)
+}
+
func (s *EngineTestSuite) TestExecuteNodePackage_CompletesAndReturnsNilNextNode() {
t := s.T()
mockNode := coremock.NewNodeInterfaceMock(t)
diff --git a/backend/internal/inboundclient/error_constants.go b/backend/internal/inboundclient/error_constants.go
index f1aea73686..002d7a45bf 100644
--- a/backend/internal/inboundclient/error_constants.go
+++ b/backend/internal/inboundclient/error_constants.go
@@ -51,10 +51,16 @@ var (
ErrFKInvalidUserType = errors.New("invalid user type")
// ErrFKInvalidSubjectAttributeMapping is returned when the specified subject attribute mapping is invalid.
ErrFKInvalidSubjectAttributeMapping = errors.New("invalid subject attribute mapping")
+ // ErrFKInvalidAgentType is returned when the specified agent type is invalid.
+ ErrFKInvalidAgentType = errors.New("invalid agent type")
// ErrUserSchemaLookupFailed is returned when the user-schema service fails (e.g. DB outage)
// while validating allowed user types. Distinct from ErrFKInvalidUserType so the handler
// can map it to a server error instead of a client validation error.
ErrUserSchemaLookupFailed = errors.New("user schema lookup failed")
+ // ErrAgentSchemaLookupFailed is returned when the agent-schema service fails (e.g. DB outage)
+ // while validating allowed agent types. Distinct from ErrFKInvalidAgentType so the handler
+ // can map it to a server error instead of a client validation error.
+ ErrAgentSchemaLookupFailed = errors.New("agent schema lookup failed")
// ErrUniqueAttributeLookupFailed is returned when the user-schema service fails (e.g. DB outage)
// while retrieving unique attributes for validation. Distinct from ErrFKInvalidSubjectAttributeMapping
// so the handler can map it to a server error instead of a client validation error.
diff --git a/backend/internal/inboundclient/model/inbound_client.go b/backend/internal/inboundclient/model/inbound_client.go
index a3310d63f0..8b3dbfc38f 100644
--- a/backend/internal/inboundclient/model/inbound_client.go
+++ b/backend/internal/inboundclient/model/inbound_client.go
@@ -40,7 +40,8 @@ type InboundAuthProfileReq struct {
LayoutID string `json:"layoutId,omitempty" yaml:"layoutId,omitempty" jsonschema:"Layout configuration ID. Optional. Customizes the screen structure and component positioning of login pages."`
Assertion *providers.AssertionConfig `json:"assertion,omitempty" yaml:"assertion,omitempty" jsonschema:"Assertion configuration. Optional. Customize assertion validity periods and included user attributes."`
LoginConsent *providers.LoginConsentConfig `json:"loginConsent,omitempty" yaml:"loginConsent,omitempty" jsonschema:"Login consent configuration settings."`
- AllowedUserTypes []string `json:"allowedUserTypes,omitempty" yaml:"allowedUserTypes,omitempty" jsonschema:"Allowed user types. Optional. Restricts which user types can register or sign up through this resource."`
+ AllowedUserTypes []string `json:"allowedUserTypes,omitempty" yaml:"allowedUserTypes,omitempty" jsonschema:"Allowed user types. Optional. Restricts which user types can authenticate to, register, or sign up through this resource."`
+ AllowedAgentTypes []string `json:"allowedAgentTypes,omitempty" yaml:"allowedAgentTypes,omitempty" jsonschema:"Allowed agent types. Optional. Agents may authenticate to this resource only when their agent type is listed here; when the list is empty no agent can authenticate."`
SubjectAttribute map[string]string `json:"subjectAttribute,omitempty" yaml:"subjectAttribute,omitempty" jsonschema:"Per-user-type mapping of the schema attribute to use as the token subject (sub) claim, keyed by user type name. The attribute must be unique, required, and string-typed in that user type's schema. When no entry applies, the user's ID is used as the subject."`
PasskeyAllowedOrigins []string `json:"passkeyAllowedOrigins,omitempty" yaml:"passkeyAllowedOrigins,omitempty" jsonschema:"Allowed origins for WebAuthn/passkey operations for this application. Optional. When set, overrides the server-level passkey allowed origins for flow-based passkey operations."`
Attestation *providers.AttestationConfig `json:"attestation,omitempty" yaml:"attestation,omitempty" jsonschema:"Platform attestation configuration. Optional. Enables a mobile client to initiate flows directly by proving its binary identity (e.g. Google Play Integrity), regardless of protocol. The service account credentials are write-only and never returned in responses."`
diff --git a/backend/internal/inboundclient/service.go b/backend/internal/inboundclient/service.go
index 67cb6e5497..e6a49c659f 100644
--- a/backend/internal/inboundclient/service.go
+++ b/backend/internal/inboundclient/service.go
@@ -1242,6 +1242,9 @@ func (s *inboundClientService) validateFKs(ctx context.Context, c *inboundmodel.
if err := s.validateAllowedUserTypes(ctx, c.AllowedUserTypes); err != nil {
return err
}
+ if err := s.validateAllowedAgentTypes(ctx, c.AllowedAgentTypes); err != nil {
+ return err
+ }
return nil
}
@@ -1333,23 +1336,44 @@ func (s *inboundClientService) validateLayoutID(ctx context.Context, layoutID st
func (s *inboundClientService) validateAllowedUserTypes(
ctx context.Context, allowedUserTypes []string,
) error {
- if len(allowedUserTypes) == 0 || s.entityType == nil {
+ return s.validateAllowedEntityTypes(ctx, entitytype.TypeCategoryUser, allowedUserTypes,
+ ErrFKInvalidUserType, ErrUserSchemaLookupFailed)
+}
+
+// validateAllowedAgentTypes validates that each allowed agent type corresponds to an existing agent type.
+func (s *inboundClientService) validateAllowedAgentTypes(
+ ctx context.Context, allowedAgentTypes []string,
+) error {
+ return s.validateAllowedEntityTypes(ctx, entitytype.TypeCategoryAgent, allowedAgentTypes,
+ ErrFKInvalidAgentType, ErrAgentSchemaLookupFailed)
+}
+
+// validateAllowedEntityTypes validates that each name in allowedTypes corresponds to an existing
+// entity type in the given category. invalidErr is returned for an unknown name; lookupErr is
+// returned when the entity type service itself fails, so the caller can tell a client validation
+// failure from a server fault.
+func (s *inboundClientService) validateAllowedEntityTypes(
+ ctx context.Context, category entitytype.TypeCategory, allowedTypes []string,
+ invalidErr, lookupErr error,
+) error {
+ if len(allowedTypes) == 0 || s.entityType == nil {
return nil
}
- existingUserTypes := make(map[string]bool)
+ existingTypes := make(map[string]bool)
limit := serverconst.MaxPageSize
offset := 0
for {
// Runtime context: skip authorization checks when fetching entity types.
entityTypeList, svcErr := s.entityType.GetEntityTypeList(
- security.WithRuntimeContext(ctx), entitytype.TypeCategoryUser, limit, offset, false)
+ security.WithRuntimeContext(ctx), category, limit, offset, false)
if svcErr != nil {
- s.logger.Error(ctx, "Failed to retrieve user type list for validation",
+ s.logger.Error(ctx, "Failed to retrieve entity type list for validation",
+ log.String("category", string(category)),
log.String("error", svcErr.Error.DefaultValue), log.String("code", svcErr.Code))
- return ErrUserSchemaLookupFailed
+ return lookupErr
}
for _, schema := range entityTypeList.Types {
- existingUserTypes[schema.Name] = true
+ existingTypes[schema.Name] = true
}
if len(entityTypeList.Types) == 0 ||
offset+len(entityTypeList.Types) >= entityTypeList.TotalResults {
@@ -1357,9 +1381,9 @@ func (s *inboundClientService) validateAllowedUserTypes(
}
offset += limit
}
- for _, userType := range allowedUserTypes {
- if userType == "" || !existingUserTypes[userType] {
- return ErrFKInvalidUserType
+ for _, entityTypeName := range allowedTypes {
+ if entityTypeName == "" || !existingTypes[entityTypeName] {
+ return invalidErr
}
}
return nil
diff --git a/backend/internal/inboundclient/service_test.go b/backend/internal/inboundclient/service_test.go
index f761b6fd3b..238e0c917f 100644
--- a/backend/internal/inboundclient/service_test.go
+++ b/backend/internal/inboundclient/service_test.go
@@ -2102,6 +2102,43 @@ func (suite *InboundClientServiceTestSuite) TestValidateAllowedUserTypes_Service
assert.ErrorIs(suite.T(), err, ErrUserSchemaLookupFailed)
}
+func (suite *InboundClientServiceTestSuite) TestValidateAllowedAgentTypes_NoOpWhenEmpty() {
+ svc := &inboundClientService{}
+ assert.NoError(suite.T(), svc.validateAllowedAgentTypes(context.Background(), nil))
+}
+
+func (suite *InboundClientServiceTestSuite) TestValidateAllowedAgentTypes_AllExist() {
+ us := entitytypemock.NewEntityTypeServiceInterfaceMock(suite.T())
+ us.EXPECT().GetEntityTypeList(mock.Anything, entitytypepkg.TypeCategoryAgent, mock.Anything, 0, false).Return(
+ &entitytypepkg.EntityTypeListResponse{
+ TotalResults: 1,
+ Types: []entitytypepkg.EntityTypeListItem{{Name: "default"}},
+ }, nil)
+ svc := &inboundClientService{entityType: us, logger: log.GetLogger()}
+ assert.NoError(suite.T(), svc.validateAllowedAgentTypes(context.Background(), []string{"default"}))
+}
+
+func (suite *InboundClientServiceTestSuite) TestValidateAllowedAgentTypes_MissingType() {
+ us := entitytypemock.NewEntityTypeServiceInterfaceMock(suite.T())
+ us.EXPECT().GetEntityTypeList(mock.Anything, entitytypepkg.TypeCategoryAgent, mock.Anything, 0, false).Return(
+ &entitytypepkg.EntityTypeListResponse{
+ TotalResults: 1,
+ Types: []entitytypepkg.EntityTypeListItem{{Name: "default"}},
+ }, nil)
+ svc := &inboundClientService{entityType: us, logger: log.GetLogger()}
+ err := svc.validateAllowedAgentTypes(context.Background(), []string{"ghost"})
+ assert.ErrorIs(suite.T(), err, ErrFKInvalidAgentType)
+}
+
+func (suite *InboundClientServiceTestSuite) TestValidateAllowedAgentTypes_ServiceErrorPropagated() {
+ us := entitytypemock.NewEntityTypeServiceInterfaceMock(suite.T())
+ us.EXPECT().GetEntityTypeList(mock.Anything, entitytypepkg.TypeCategoryAgent, mock.Anything, 0, false).
+ Return(nil, &tidcommon.ServiceError{Code: "ERR"})
+ svc := &inboundClientService{entityType: us, logger: log.GetLogger()}
+ err := svc.validateAllowedAgentTypes(context.Background(), []string{"default"})
+ assert.ErrorIs(suite.T(), err, ErrAgentSchemaLookupFailed)
+}
+
// ----- resolveFlowDefaults -----
func (suite *InboundClientServiceTestSuite) TestResolveFlowDefaults_NilOrNoMgtIsNoOp() {
diff --git a/backend/internal/inboundclient/store.go b/backend/internal/inboundclient/store.go
index ec6d6e01a3..510e7da1ab 100644
--- a/backend/internal/inboundclient/store.go
+++ b/backend/internal/inboundclient/store.go
@@ -24,6 +24,7 @@ type inboundClientJSONBlob struct {
Assertion *inboundmodel.AssertionConfig `json:"assertion,omitempty"`
LoginConsent *inboundmodel.LoginConsentConfig `json:"loginConsent,omitempty"`
AllowedUserTypes []string `json:"allowedUserTypes,omitempty"`
+ AllowedAgentTypes []string `json:"allowedAgentTypes,omitempty"`
SubjectAttribute map[string]string `json:"subjectAttribute,omitempty"`
PasskeyAllowedOrigins []string `json:"passkeyAllowedOrigins,omitempty"`
Attestation *providers.AttestationConfig `json:"attestation,omitempty"`
@@ -100,6 +101,7 @@ func marshalInboundClient(c inboundmodel.InboundClient) (
Assertion: c.Assertion,
LoginConsent: c.LoginConsent,
AllowedUserTypes: c.AllowedUserTypes,
+ AllowedAgentTypes: c.AllowedAgentTypes,
SubjectAttribute: c.SubjectAttribute,
PasskeyAllowedOrigins: c.PasskeyAllowedOrigins,
Attestation: c.Attestation,
@@ -491,6 +493,7 @@ func buildInboundClientFromRow(ctx context.Context, row map[string]interface{})
client.Assertion = blob.Assertion
client.LoginConsent = blob.LoginConsent
client.AllowedUserTypes = blob.AllowedUserTypes
+ client.AllowedAgentTypes = blob.AllowedAgentTypes
client.SubjectAttribute = blob.SubjectAttribute
client.PasskeyAllowedOrigins = blob.PasskeyAllowedOrigins
client.Attestation = blob.Attestation
diff --git a/backend/internal/inboundclient/store_test.go b/backend/internal/inboundclient/store_test.go
index 9871d5f59b..c813536e61 100644
--- a/backend/internal/inboundclient/store_test.go
+++ b/backend/internal/inboundclient/store_test.go
@@ -83,6 +83,7 @@ func (suite *InboundClientStoreTestSuite) TestBuildInboundClientFromRow_Success(
ValidityPeriod: 5400,
},
AllowedUserTypes: []string{"admin", "user"},
+ AllowedAgentTypes: []string{"default"},
PasskeyAllowedOrigins: []string{"https://app.example.com"},
Properties: map[string]interface{}{"template": "spa"},
}
@@ -117,6 +118,7 @@ func (suite *InboundClientStoreTestSuite) TestBuildInboundClientFromRow_Success(
suite.NotNil(result.LoginConsent)
suite.Equal(int64(5400), result.LoginConsent.ValidityPeriod)
suite.Equal([]string{"admin", "user"}, result.AllowedUserTypes)
+ suite.Equal([]string{"default"}, result.AllowedAgentTypes)
suite.Equal([]string{"https://app.example.com"}, result.PasskeyAllowedOrigins)
suite.NotNil(result.Properties)
suite.Equal("spa", result.Properties["template"])
diff --git a/backend/internal/system/i18n/core/defaults.go b/backend/internal/system/i18n/core/defaults.go
index f0b8a619a6..4a342dbfd9 100644
--- a/backend/internal/system/i18n/core/defaults.go
+++ b/backend/internal/system/i18n/core/defaults.go
@@ -54,6 +54,8 @@ var defaultMessages = map[string]string{
"error.agentservice.invalid_agent_name_description": "The agent name must be provided and non-empty",
"error.agentservice.invalid_agent_type": "Invalid agent type",
"error.agentservice.invalid_agent_type_description": "The agent type must be provided",
+ "error.agentservice.invalid_allowed_agent_type": "Invalid agent type",
+ "error.agentservice.invalid_allowed_agent_type_description": "One or more specified allowed agent types are invalid",
"error.agentservice.invalid_auth_flow_id": "Invalid auth flow ID",
"error.agentservice.invalid_auth_flow_id_description": "The provided authentication flow ID is invalid",
"error.agentservice.invalid_certificate_type": "Invalid certificate type",
@@ -169,6 +171,8 @@ var defaultMessages = map[string]string{
"error.applicationservice.invalid_acr_values": "Invalid ACR value",
"error.applicationservice.invalid_acr_values_description": "One or more ACR values in acr_values are not recognized by the system",
"error.applicationservice.invalid_acr_values_unrecognized": "ACR value '{{param(acr)}}' is not recognized by the system",
+ "error.applicationservice.invalid_agent_type": "Invalid agent type",
+ "error.applicationservice.invalid_agent_type_description": "One or more agent types in allowedAgentTypes do not exist in the system",
"error.applicationservice.invalid_application_id": "Invalid application ID",
"error.applicationservice.invalid_application_id_description": "The provided application ID is invalid or empty",
"error.applicationservice.invalid_application_name": "Invalid application name",
@@ -286,6 +290,8 @@ var defaultMessages = map[string]string{
"error.authnmgrservice.get_entity_reference_client_error_description": "The entity reference fetch was rejected by the provider",
"error.authnmgrservice.invalid_request": "Invalid request",
"error.authnmgrservice.invalid_request_description": "The authentication request is invalid",
+ "error.authnmgrservice.subject_not_allowed": "Subject not allowed",
+ "error.authnmgrservice.subject_not_allowed_description": "The authenticated subject is not allowed to sign in to this application",
"error.authnmgrservice.user_not_found": "User not found",
"error.authnmgrservice.user_not_found_description": "No user found matching the provided identifiers",
"error.authnotpservice.error_processing_otp": "Error processing OTP",
diff --git a/backend/internal/system/importer/service.go b/backend/internal/system/importer/service.go
index dd244eb69e..9238f22745 100644
--- a/backend/internal/system/importer/service.go
+++ b/backend/internal/system/importer/service.go
@@ -958,6 +958,7 @@ func applicationRequestToDTO(req *appmodel.ApplicationRequestWithID) *appmodel.A
Assertion: req.Assertion,
LoginConsent: req.LoginConsent,
AllowedUserTypes: req.AllowedUserTypes,
+ AllowedAgentTypes: req.AllowedAgentTypes,
},
Type: req.Type,
Template: req.Template,
diff --git a/backend/internal/system/importer/service_adapters.go b/backend/internal/system/importer/service_adapters.go
index 9a994e9db2..431c2350ef 100644
--- a/backend/internal/system/importer/service_adapters.go
+++ b/backend/internal/system/importer/service_adapters.go
@@ -961,6 +961,7 @@ func (s *importService) importAgent(
Assertion: req.Assertion,
LoginConsent: req.LoginConsent,
AllowedUserTypes: req.AllowedUserTypes,
+ AllowedAgentTypes: req.AllowedAgentTypes,
PasskeyAllowedOrigins: req.PasskeyAllowedOrigins,
Attestation: req.Attestation,
},
diff --git a/backend/pkg/thunderidengine/providers/model.go b/backend/pkg/thunderidengine/providers/model.go
index 6bcc82887a..f4d0418e5c 100644
--- a/backend/pkg/thunderidengine/providers/model.go
+++ b/backend/pkg/thunderidengine/providers/model.go
@@ -706,6 +706,7 @@ type InboundClient struct {
Assertion *AssertionConfig
LoginConsent *LoginConsentConfig
AllowedUserTypes []string
+ AllowedAgentTypes []string
SubjectAttribute map[string]string
PasskeyAllowedOrigins []string
// Attestation holds the optional platform attestation config that lets a mobile client prove
@@ -1034,7 +1035,8 @@ type InboundAuthProfile struct {
LayoutID string `json:"layoutId,omitempty" yaml:"layoutId,omitempty" jsonschema:"Layout configuration ID. Optional. Customizes the screen structure and component positioning of login pages."`
Assertion *AssertionConfig `json:"assertion,omitempty" yaml:"assertion,omitempty" jsonschema:"Assertion configuration. Optional. Customize assertion validity periods and included user attributes."`
LoginConsent *LoginConsentConfig `json:"loginConsent,omitempty" yaml:"loginConsent,omitempty" jsonschema:"Login consent configuration settings."`
- AllowedUserTypes []string `json:"allowedUserTypes,omitempty" yaml:"allowedUserTypes,omitempty" jsonschema:"Allowed user types. Optional. Restricts which user types can register or sign up through this resource."`
+ AllowedUserTypes []string `json:"allowedUserTypes,omitempty" yaml:"allowedUserTypes,omitempty" jsonschema:"Allowed user types. Optional. Restricts which user types can authenticate to, register, or sign up through this resource."`
+ AllowedAgentTypes []string `json:"allowedAgentTypes,omitempty" yaml:"allowedAgentTypes,omitempty" jsonschema:"Allowed agent types. Optional. Agents may authenticate to this resource only when their agent type is listed here; when the list is empty no agent can authenticate."`
SubjectAttribute map[string]string `json:"subjectAttribute,omitempty" yaml:"subjectAttribute,omitempty" jsonschema:"Per-user-type mapping of the schema attribute to use as the token subject (sub) claim, keyed by user type name. The attribute must be unique, required, and string-typed in that user type's schema. When no entry applies, the user's ID is used as the subject."`
PasskeyAllowedOrigins []string `json:"passkeyAllowedOrigins,omitempty" yaml:"passkeyAllowedOrigins,omitempty" jsonschema:"Allowed origins for WebAuthn/passkey operations for this application. Optional. When set, overrides the server-level passkey allowed origins for flow-based passkey operations."`
Attestation *AttestationConfig `json:"attestation,omitempty" yaml:"attestation,omitempty" jsonschema:"Platform attestation configuration. Optional. Enables a mobile client to initiate flows directly by proving its binary identity (e.g. Google Play Integrity), regardless of protocol. The service account credentials are write-only and never returned in responses."`
diff --git a/docs/content/guides/agents/manage-agents.mdx b/docs/content/guides/agents/manage-agents.mdx
index 094e4a7f10..e2d9221a45 100644
--- a/docs/content/guides/agents/manage-agents.mdx
+++ b/docs/content/guides/agents/manage-agents.mdx
@@ -103,6 +103,7 @@ The **Advanced** tab gathers the agent's OAuth and operational settings:
- **Operating Mode** has a **Delegated mode** toggle that sets whether the agent acts only on its own or is extended to also act for a signed-in user. On its own it uses its own credentials (`client_credentials`); with Delegated mode on, it additionally signs users in (`authorization_code` with PKCE). See [Agent Access on Behalf of Users](../authentication/on-behalf-of-user).
- **Owner** is changed here; see [Identifiers and owner](#identifiers-and-owner) for what it means.
- **Allowed User Types** restricts self-service registration to the selected user types. If none are selected, users cannot register through the agent; existing users can still sign in. See [Agent Access on Behalf of Users](../authentication/on-behalf-of-user).
+- **Agent Sign-In** controls whether other agents can sign in to this agent. Turn it on to let agents sign in, and leave it off to block every agent. This setting does not affect users.
- **Grant Types** are the OAuth grants the agent may use. Acting on its own (`client_credentials`) and handing a task to another agent (token exchange) are available in either operating mode; signing users in (`authorization_code`), keeping that access without a new sign-in (`refresh_token`), and out-of-band approval (CIBA) become available once the agent is extended to act for users. See [Agent Token](../authentication/agent-own-token) and [Agent Access on Behalf of Users](../authentication/on-behalf-of-user).
- **Authorized Redirect URIs** are the callback URLs allowed for the `authorization_code` grant. See [Agent Access on Behalf of Users](../authentication/on-behalf-of-user).
- **Client Authentication Method** is how the agent authenticates to the token endpoint, with a client secret or a private key JWT. See [Agent Authentication](../authentication).
diff --git a/docs/content/guides/applications/application-settings.mdx b/docs/content/guides/applications/application-settings.mdx
index 483cb956fd..05d7cc8365 100644
--- a/docs/content/guides/applications/application-settings.mdx
+++ b/docs/content/guides/applications/application-settings.mdx
@@ -19,11 +19,12 @@ The **Application ID** and **Client ID** are shown on the application's General
## Control Who Can Register
-Under **Access** on the General tab, you can configure which users can register with this application and provide the application's URL and redirect URIs. The **Allowed User Types** and **Authorized Redirect URIs** settings apply to applications that let users sign in and are hidden for applications that use only the `client_credentials` grant.
+Under **Access** on the General tab, you can configure which users can register with this application and provide the application's URL and redirect URIs. The **Allowed User Types**, **Agent Sign-In**, and **Authorized Redirect URIs** settings apply to applications that let users sign in and are hidden for applications that use only the `client_credentials` grant.
| Setting | Description |
|---------|-------------|
| **Allowed User Types** | Restricts self-service registration to users of the selected types. If none are selected, self-service registration is unavailable for this application. This setting does not block existing users from signing in. |
+| **Agent Sign-In** | Controls whether agents can sign in to this application. Turn it on to let agents sign in, and leave it off to block every agent. This setting does not affect users. |
| **Application URL** | The homepage URL of your application. |
| **Authorized Redirect URIs** | The URLs sends users back to after authentication. Register every URI your application uses.
diff --git a/docs/versioned_docs/version-v1.0.x/guides/agents/manage-agents.mdx b/docs/versioned_docs/version-v1.0.x/guides/agents/manage-agents.mdx
index 094e4a7f10..e2d9221a45 100644
--- a/docs/versioned_docs/version-v1.0.x/guides/agents/manage-agents.mdx
+++ b/docs/versioned_docs/version-v1.0.x/guides/agents/manage-agents.mdx
@@ -103,6 +103,7 @@ The **Advanced** tab gathers the agent's OAuth and operational settings:
- **Operating Mode** has a **Delegated mode** toggle that sets whether the agent acts only on its own or is extended to also act for a signed-in user. On its own it uses its own credentials (`client_credentials`); with Delegated mode on, it additionally signs users in (`authorization_code` with PKCE). See [Agent Access on Behalf of Users](../authentication/on-behalf-of-user).
- **Owner** is changed here; see [Identifiers and owner](#identifiers-and-owner) for what it means.
- **Allowed User Types** restricts self-service registration to the selected user types. If none are selected, users cannot register through the agent; existing users can still sign in. See [Agent Access on Behalf of Users](../authentication/on-behalf-of-user).
+- **Agent Sign-In** controls whether other agents can sign in to this agent. Turn it on to let agents sign in, and leave it off to block every agent. This setting does not affect users.
- **Grant Types** are the OAuth grants the agent may use. Acting on its own (`client_credentials`) and handing a task to another agent (token exchange) are available in either operating mode; signing users in (`authorization_code`), keeping that access without a new sign-in (`refresh_token`), and out-of-band approval (CIBA) become available once the agent is extended to act for users. See [Agent Token](../authentication/agent-own-token) and [Agent Access on Behalf of Users](../authentication/on-behalf-of-user).
- **Authorized Redirect URIs** are the callback URLs allowed for the `authorization_code` grant. See [Agent Access on Behalf of Users](../authentication/on-behalf-of-user).
- **Client Authentication Method** is how the agent authenticates to the token endpoint, with a client secret or a private key JWT. See [Agent Authentication](../authentication).
diff --git a/docs/versioned_docs/version-v1.0.x/guides/applications/application-settings.mdx b/docs/versioned_docs/version-v1.0.x/guides/applications/application-settings.mdx
index b62d17ca3b..c79da02578 100644
--- a/docs/versioned_docs/version-v1.0.x/guides/applications/application-settings.mdx
+++ b/docs/versioned_docs/version-v1.0.x/guides/applications/application-settings.mdx
@@ -19,11 +19,12 @@ The **Application ID** and **Client ID** are shown on the application's General
## Control Who Can Register
-Under **Access** on the General tab, you can configure which users can register with this application and provide the application's URL and redirect URIs. The **Allowed User Types** and **Authorized Redirect URIs** settings apply to applications that let users sign in and are hidden for applications that use only the `client_credentials` grant.
+Under **Access** on the General tab, you can configure which users can register with this application and provide the application's URL and redirect URIs. The **Allowed User Types**, **Agent Sign-In**, and **Authorized Redirect URIs** settings apply to applications that let users sign in and are hidden for applications that use only the `client_credentials` grant.
| Setting | Description |
|---------|-------------|
| **Allowed User Types** | Restricts self-service registration to users of the selected types. If none are selected, self-service registration is unavailable for this application. This setting does not block existing users from signing in. |
+| **Agent Sign-In** | Controls whether agents can sign in to this application. Turn it on to let agents sign in, and leave it off to block every agent. This setting does not affect users. |
| **Application URL** | The homepage URL of your application. |
| **Authorized Redirect URIs** | The URLs sends users back to after authentication. Register every URI your application uses.
diff --git a/frontend/apps/console/src/features/agents/components/edit-agent/advanced-settings/AgentSignInSection.tsx b/frontend/apps/console/src/features/agents/components/edit-agent/advanced-settings/AgentSignInSection.tsx
new file mode 100644
index 0000000000..014e06b7f1
--- /dev/null
+++ b/frontend/apps/console/src/features/agents/components/edit-agent/advanced-settings/AgentSignInSection.tsx
@@ -0,0 +1,61 @@
+// Copyright 2026 The ThunderID Authors
+// SPDX-License-Identifier: Apache-2.0
+
+import {SettingsCard} from '@thunderid/components';
+import {Switch} from '@wso2/oxygen-ui';
+import type {JSX} from 'react';
+import {useTranslation} from 'react-i18next';
+import {deriveOAuth2Flags} from '../../../../applications/utils/oauth2Rules';
+import {DEFAULT_AGENT_TYPE_NAME, type Agent, type OAuthAgentConfig} from '../../../models/agent';
+
+interface AgentSignInSectionProps {
+ agent: Agent;
+ editedAgent: Partial;
+ oauth2Config?: OAuthAgentConfig;
+ onFieldChange: (field: keyof Agent, value: unknown) => void;
+}
+
+export default function AgentSignInSection({
+ agent,
+ editedAgent,
+ oauth2Config = undefined,
+ onFieldChange,
+}: AgentSignInSectionProps): JSX.Element | null {
+ const {t} = useTranslation();
+
+ // Agent sign-in only matters when an agent can actually sign in through this agent — the same
+ // dependency on authorization_code that the allowed user types section has.
+ const isApplicable = Boolean(oauth2Config && deriveOAuth2Flags(oauth2Config).hasAuthorizationCodeGrant);
+
+ if (!isApplicable) return null;
+
+ // Agent access is a single on/off choice in the console for now: on means the default agent type
+ // (the only type) is the only allowed one, off means no agent type is allowed.
+ const isEnabled = (editedAgent.allowedAgentTypes ?? agent.allowedAgentTypes ?? []).length > 0;
+
+ const handleToggle = (enabled: boolean): void => {
+ onFieldChange('allowedAgentTypes', enabled ? [DEFAULT_AGENT_TYPE_NAME] : []);
+ };
+
+ const toggleLabel = t('agents:edit.advanced.agentSignIn.toggle.label', 'Enable Agent Sign-In');
+
+ return (
+ handleToggle(event.target.checked)}
+ disabled={agent.isReadOnly}
+ slotProps={{input: {'aria-label': toggleLabel}}}
+ />
+ }
+ >
+ {null}
+
+ );
+}
diff --git a/frontend/apps/console/src/features/agents/components/edit-agent/advanced-settings/EditAdvancedSettings.tsx b/frontend/apps/console/src/features/agents/components/edit-agent/advanced-settings/EditAdvancedSettings.tsx
index 5c095697c9..899353a7ba 100644
--- a/frontend/apps/console/src/features/agents/components/edit-agent/advanced-settings/EditAdvancedSettings.tsx
+++ b/frontend/apps/console/src/features/agents/components/edit-agent/advanced-settings/EditAdvancedSettings.tsx
@@ -7,6 +7,7 @@ import type {OAuth2Config} from '@thunderid/configure-applications';
import {Box, FormControlLabel, Stack, Switch, Typography} from '@wso2/oxygen-ui';
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
+import AgentSignInSection from './AgentSignInSection';
import AllowedUserTypesSection from './AllowedUserTypesSection';
import DangerZoneSection from './DangerZoneSection';
import OperationModesSection from './OperationModesSection';
@@ -98,6 +99,12 @@ export default function EditAdvancedSettings({
oauth2Config={oauth2Config}
onFieldChange={onFieldChange}
/>
+ ({
+ useTranslation: () => ({
+ t: (key: string, fallback?: string) => fallback ?? key,
+ }),
+}));
+
+describe('AgentSignInSection', () => {
+ const mockOnFieldChange = vi.fn();
+
+ const agent: Agent = {
+ id: 'agent-1',
+ ouId: 'ou-1',
+ type: 'default',
+ name: 'Test Agent',
+ };
+
+ const delegationEnabledConfig: OAuthAgentConfig = {
+ grantTypes: ['authorization_code'],
+ responseTypes: ['code'],
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('returns null when oauth2Config is undefined', () => {
+ const {container} = render();
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('returns null when Delegated mode is off', () => {
+ const {container} = render(
+ ,
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders the section title and description when Delegated mode is on', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Agent Sign-In')).toBeInTheDocument();
+ expect(screen.getByText('Allow agents to sign in through this agent using the sign-in flow.')).toBeInTheDocument();
+ });
+
+ it('renders the toggle off when no agent type is allowed', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByLabelText('Enable Agent Sign-In')).not.toBeChecked();
+ });
+
+ it('renders the toggle on when an agent type is allowed', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByLabelText('Enable Agent Sign-In')).toBeChecked();
+ });
+
+ it('prioritizes editedAgent.allowedAgentTypes over agent.allowedAgentTypes', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByLabelText('Enable Agent Sign-In')).not.toBeChecked();
+ });
+
+ it('allows the default agent type when the toggle is turned on', async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ await user.click(screen.getByLabelText('Enable Agent Sign-In'));
+
+ expect(mockOnFieldChange).toHaveBeenCalledWith('allowedAgentTypes', ['default']);
+ });
+
+ it('clears every allowed agent type when the toggle is turned off', async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ await user.click(screen.getByLabelText('Enable Agent Sign-In'));
+
+ expect(mockOnFieldChange).toHaveBeenCalledWith('allowedAgentTypes', []);
+ });
+
+ it('disables the toggle for a read-only agent', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByLabelText('Enable Agent Sign-In')).toBeDisabled();
+ });
+});
diff --git a/frontend/apps/console/src/features/agents/components/edit-agent/advanced-settings/__tests__/EditAdvancedSettings.test.tsx b/frontend/apps/console/src/features/agents/components/edit-agent/advanced-settings/__tests__/EditAdvancedSettings.test.tsx
index 7cc59b9a11..f107781fda 100644
--- a/frontend/apps/console/src/features/agents/components/edit-agent/advanced-settings/__tests__/EditAdvancedSettings.test.tsx
+++ b/frontend/apps/console/src/features/agents/components/edit-agent/advanced-settings/__tests__/EditAdvancedSettings.test.tsx
@@ -33,6 +33,10 @@ vi.mock('../AllowedUserTypesSection', () => ({
default: () =>