From d04165af611c26b931336b0f4558aef0b74b70bf Mon Sep 17 00:00:00 2001 From: Malith-19 Date: Thu, 23 Jul 2026 18:18:05 +0530 Subject: [PATCH] Add application type attribute. --- api/application.yaml | 48 +++++- .../bootstrap/01-default-resources.yaml | 1 + .../application/application_type_test.go | 156 ++++++++++++++++++ backend/internal/application/constants.go | 1 + .../application/declarative_resource.go | 3 +- .../internal/application/error_constants.go | 44 ++++- backend/internal/application/handler.go | 7 +- .../internal/application/model/application.go | 155 +++++++++-------- .../internal/application/model/constants.go | 16 +- backend/internal/application/model/utils.go | 41 +++++ backend/internal/application/service.go | 77 ++++++--- backend/internal/application/service_test.go | 102 +++++++++++- backend/internal/application/tools.go | 4 + backend/internal/flow/flowexec/constants.go | 4 + .../internal/flow/flowexec/error_constants.go | 15 ++ backend/internal/flow/flowexec/service.go | 47 ++++-- .../internal/flow/flowexec/service_test.go | 138 +++++++++++++++- backend/internal/oauth/oauth2/dcr/service.go | 9 +- backend/internal/system/i18n/core/defaults.go | 8 + backend/internal/system/importer/service.go | 1 + .../internal/system/importer/service_test.go | 22 +++ .../pkg/thunderidengine/providers/model.go | 1 + .../applications/application-settings.mdx | 2 +- .../applications/manage-applications.mdx | 15 +- .../import-resources.mdx | 7 +- .../authentication/integration-models.mdx | 18 +- .../ConfigureExperience.tsx | 21 ++- .../__tests__/ConfigureExperience.test.tsx | 41 +++++ .../general-settings/EditGeneralSettings.tsx | 13 +- .../edit-application/mcp/McpConnectTab.tsx | 19 ++- .../platform-based/backend.json | 1 + .../platform-based/browser.json | 1 + .../platform-based/custom.json | 1 + .../platform-based/full-stack.json | 1 + .../platform-based/mobile.json | 1 + .../platform-based/wallet.json | 1 + .../technology-based/express.json | 1 + .../technology-based/mcp-client.json | 1 + .../technology-based/nextjs.json | 1 + .../technology-based/node.json | 1 + .../technology-based/nuxt.json | 1 + .../technology-based/other.json | 1 + .../technology-based/react.json | 1 + .../technology-based/vanilla-js.json | 1 + .../technology-based/vue.json | 1 + .../models/application-templates.ts | 9 +- .../applications/models/application.ts | 27 ++- .../pages/ApplicationCreatePage.tsx | 41 ++--- .../__tests__/ApplicationCreatePage.test.tsx | 75 ++++++++- .../__tests__/resolveApplicationType.test.ts | 70 ++++++++ .../utils/resolveApplicationType.ts | 67 ++++++++ frontend/packages/i18n/src/locales/en-US.ts | 2 + .../samples/resource.yaml | 1 + .../thunderid-config/thunderid-config.yaml | 1 + .../basic/thunderid-config.yaml | 1 + .../multi-auth/thunderid-config.yaml | 1 + .../app-native/thunderid-config.yaml | 2 + .../redirect/thunderid-config.yaml | 4 + .../applications/application-edit.spec.ts | 2 + tests/e2e/thunderid-config.yaml | 1 + .../application/application_api_test.go | 6 + .../flow_reference_validation_test.go | 1 + tests/integration/application/model.go | 1 + .../composite/composite_mode_api_test.go | 1 + tests/integration/export/export_api_test.go | 5 +- tests/integration/export/model.go | 1 + .../apple_attestation_flow_test.go | 13 +- .../authentication/attestation_flow_test.go | 14 +- .../oauth/authz/acr_values_test.go | 9 +- .../oauth/authz/authz_scope_test.go | 3 + tests/integration/oauth/authz/authz_test.go | 2 + tests/integration/oauth/ciba/ciba_test.go | 1 + .../oauth/claims/claims_parameter_test.go | 1 + .../oauth/dpop/enforcement_test.go | 13 +- tests/integration/oauth/dpop/suite_test.go | 17 +- tests/integration/oauth/par/par_test.go | 3 +- .../oauth/revocation/revocation_test.go | 1 + tests/integration/oauth/sso/suite_test.go | 1 + .../oauth/token/cc_app_authz_test.go | 1 + .../token/default_resource_server_test.go | 2 + .../oauth/token/refresh_token_test.go | 1 + tests/integration/oauth/token/tfid_test.go | 1 + tests/integration/oauth/token/token_test.go | 2 + .../oauth/token/tokenexchange_test.go | 2 + .../oauth/userinfo/userinfo_test.go | 2 + .../host_wildcard_redirect_uri_test.go | 4 +- .../wildcard/wildcard_redirect_uri_test.go | 4 +- tests/integration/openid4vci/issuance_test.go | 1 + .../applications/app-declarative-1.yaml | 1 + .../app-declarative-confidential.yaml | 1 + tests/integration/testutils/api_utils.go | 13 +- tests/integration/testutils/models.go | 13 +- 92 files changed, 1268 insertions(+), 228 deletions(-) create mode 100644 backend/internal/application/application_type_test.go create mode 100644 backend/internal/application/model/utils.go create mode 100644 frontend/apps/console/src/features/applications/utils/__tests__/resolveApplicationType.test.ts create mode 100644 frontend/apps/console/src/features/applications/utils/resolveApplicationType.ts diff --git a/api/application.yaml b/api/application.yaml index 0d105f8527..936243ce70 100644 --- a/api/application.yaml +++ b/api/application.yaml @@ -746,7 +746,7 @@ components: schemas: ApplicationRequest: type: object - required: [name, ouId] + required: [name, ouId, type] properties: ouId: type: string @@ -796,6 +796,13 @@ components: format: uuid description: The ID of the layout configuration associated with this application. example: "770e8400-e29b-41d4-a716-446655440002" + type: + type: string + enum: [browser, fullstack, mobile, m2m, custom] + description: > + The canonical application type (platform/client class). Required at creation and + immutable thereafter. Use custom for applications that do not fit the other classes. + example: "browser" template: type: string description: The template type of the application. @@ -859,10 +866,11 @@ components: flowSecret: type: string description: > - Flow Secret for backend/server-side applications — non-public clients that do not use the - redirect-based authorization_code flow. Used to authenticate when initiating a flow directly - via the Flow Execution API. When omitted on creation, a secret is generated automatically for - eligible applications and returned once in the creation response. + Flow Secret used to authenticate when initiating a flow directly via the Flow Execution API. + Issued only to full-stack and custom applications that either have no OAuth 2.0 configuration + (embedded) or are configured as a confidential, non-redirect OAuth 2.0 client. Browser, mobile, + and machine-to-machine applications are never issued one. When omitted on creation, a secret is + generated automatically for eligible applications and returned once in the creation response. example: "550e8400e29b41d4a716446655440000abcdef0123456789" ApplicationCompleteResponse: @@ -918,6 +926,13 @@ components: format: uuid description: The ID of the layout configuration associated with this application. example: "660e8400-e29b-41d4-a716-446655440001" + type: + type: string + enum: [browser, fullstack, mobile, m2m, custom] + description: > + The canonical application type (platform/client class). Required at creation and + immutable thereafter. Use custom for applications that do not fit the other classes. + example: "browser" template: type: string description: The template type of the application. @@ -925,10 +940,11 @@ components: flowSecret: type: string description: > - Flow Secret for backend/server-side applications. Returned only in the creation response - for eligible applications (non-public clients that do not use the redirect-based - authorization_code flow). Used to authenticate when initiating a flow directly via the - Flow Execution API. + Flow Secret used to authenticate when initiating a flow directly via the Flow Execution API. + Issued and returned once in the creation response only for full-stack and custom applications + that either have no OAuth 2.0 configuration (embedded) or are configured as a confidential, + non-redirect OAuth 2.0 client. Browser, mobile, and machine-to-machine applications are never + issued one. example: "550e8400e29b41d4a716446655440000abcdef0123456789" url: type: string @@ -1040,6 +1056,13 @@ components: format: uuid description: The ID of the layout configuration associated with this application. example: "770e8400-e29b-41d4-a716-446655440002" + type: + type: string + enum: [browser, fullstack, mobile, m2m, custom] + description: > + The canonical application type (platform/client class). Required at creation and + immutable thereafter. Use custom for applications that do not fit the other classes. + example: "browser" template: type: string description: The template type of the application. @@ -1155,6 +1178,13 @@ components: format: uuid description: The ID of the layout configuration associated with this application. example: "770e8400-e29b-41d4-a716-446655440002" + type: + type: string + enum: [browser, fullstack, mobile, m2m, custom] + description: > + The canonical application type (platform/client class). Required at creation and + immutable thereafter. Use custom for applications that do not fit the other classes. + example: "browser" template: type: string description: The template type of the application. diff --git a/backend/cmd/server/bootstrap/01-default-resources.yaml b/backend/cmd/server/bootstrap/01-default-resources.yaml index e4a9035aee..60bc7b402a 100644 --- a/backend/cmd/server/bootstrap/01-default-resources.yaml +++ b/backend/cmd/server/bootstrap/01-default-resources.yaml @@ -4981,6 +4981,7 @@ resource_type: application id: 01900000-0000-7000-8000-000000000060 name: Console description: Management application for ThunderID +type: browser ouId: 01900000-0000-7000-8000-000000000001 url: "{{ .PUBLIC_URL }}/console" logoUrl: "avatar:shape=rounded,variant=anonymous_entity,content=cube,colors=0,bg=#64be90" diff --git a/backend/internal/application/application_type_test.go b/backend/internal/application/application_type_test.go new file mode 100644 index 0000000000..92ffe0d2d7 --- /dev/null +++ b/backend/internal/application/application_type_test.go @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package application + +import ( + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/thunder-id/thunderid/internal/application/model" + inboundmodel "github.com/thunder-id/thunderid/internal/inboundclient/model" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" +) + +type ApplicationTypeTestSuite struct { + suite.Suite +} + +func TestApplicationTypeTestSuite(t *testing.T) { + suite.Run(t, new(ApplicationTypeTestSuite)) +} + +// TestToInboundClientPersistsType verifies the application type is packed into the inbound client +// properties for persistence. +func (s *ApplicationTypeTestSuite) TestToInboundClientPersistsType() { + dto := &model.ApplicationProcessedDTO{ID: "app-1", Type: model.ApplicationTypeMobile} + + dao := toInboundClient(dto) + + s.Equal("mobile", dao.Properties[propType]) +} + +// TestToInboundClientOmitsEmptyType verifies an unset type is not written to properties. +func (s *ApplicationTypeTestSuite) TestToInboundClientOmitsEmptyType() { + dto := &model.ApplicationProcessedDTO{ID: "app-1"} + + dao := toInboundClient(dto) + + _, ok := dao.Properties[propType] + s.False(ok) +} + +// TestToProcessedDTOReadsType verifies a persisted type is read back onto the DTO. +func (s *ApplicationTypeTestSuite) TestToProcessedDTOReadsType() { + dao := &inboundmodel.InboundClient{ + ID: "app-1", + Properties: map[string]interface{}{propType: "browser"}, + } + + dto := toProcessedDTO(nil, dao, nil) + + s.Equal(model.ApplicationTypeBrowser, dto.Type) +} + +// TestToProcessedDTOEmptyWhenTypeAbsent verifies applications without a stored type resolve to an +// empty type (no implicit default is applied). +func (s *ApplicationTypeTestSuite) TestToProcessedDTOEmptyWhenTypeAbsent() { + withProps := toProcessedDTO(nil, &inboundmodel.InboundClient{ + ID: "app-1", + Properties: map[string]interface{}{}, + }, nil) + s.Equal(model.ApplicationType(""), withProps.Type) + + nilProps := toProcessedDTO(nil, &inboundmodel.InboundClient{ID: "app-2"}, nil) + s.Equal(model.ApplicationType(""), nilProps.Type) +} + +// TestBuildBasicApplicationResponseType verifies the list-view response reads the stored type and +// leaves it empty when absent (no implicit default). +func (s *ApplicationTypeTestSuite) TestBuildBasicApplicationResponseType() { + withType := buildBasicApplicationResponse(inboundmodel.InboundClient{ + ID: "app-1", + Properties: map[string]interface{}{propType: "m2m"}, + }, nil) + s.Equal(model.ApplicationTypeM2M, withType.Type) + + absent := buildBasicApplicationResponse(inboundmodel.InboundClient{ID: "app-2"}, nil) + s.Equal(model.ApplicationType(""), absent.Type) +} + +// TestFlowSecretIneligibleByType verifies browser, mobile, and m2m apps are never issued a Flow +// Secret, decided by their type alone regardless of OAuth config shape. +func (s *ApplicationTypeTestSuite) TestFlowSecretIneligibleByType() { + embedded := &providers.InboundAuthConfigWithSecret{ + Type: providers.OAuthInboundAuthType, + OAuthConfig: &providers.OAuthConfigWithSecret{ + GrantTypes: []providers.GrantType{ + providers.GrantTypeClientCredentials, + providers.GrantTypeTokenExchange, + }, + }, + } + for _, appType := range []model.ApplicationType{ + model.ApplicationTypeBrowser, + model.ApplicationTypeMobile, + model.ApplicationTypeM2M, + } { + s.False(isFlowSecretEligible(appType, nil), "type %q should not be eligible", appType) + s.False(isFlowSecretEligible(appType, embedded), "type %q should not be eligible", appType) + } +} + +// TestFullStackAndCustomFlowSecretEligibility verifies full-stack and custom apps derive eligibility +// from the OAuth config shape: only confidential, non-redirect (embedded) clients are eligible. +func (s *ApplicationTypeTestSuite) TestFullStackAndCustomFlowSecretEligibility() { + embedded := &providers.InboundAuthConfigWithSecret{ + Type: providers.OAuthInboundAuthType, + OAuthConfig: &providers.OAuthConfigWithSecret{ + GrantTypes: []providers.GrantType{ + providers.GrantTypeClientCredentials, + providers.GrantTypeTokenExchange, + }, + TokenEndpointAuthMethod: providers.TokenEndpointAuthMethodClientSecretBasic, + }, + } + redirect := &providers.InboundAuthConfigWithSecret{ + Type: providers.OAuthInboundAuthType, + OAuthConfig: &providers.OAuthConfigWithSecret{ + GrantTypes: []providers.GrantType{providers.GrantTypeAuthorizationCode}, + }, + } + m2mShaped := &providers.InboundAuthConfigWithSecret{ + Type: providers.OAuthInboundAuthType, + OAuthConfig: &providers.OAuthConfigWithSecret{ + GrantTypes: []providers.GrantType{providers.GrantTypeClientCredentials}, + }, + } + + for _, appType := range []model.ApplicationType{ + model.ApplicationTypeFullStack, + model.ApplicationTypeCustom, + } { + // Embedded app with no OAuth config, and confidential non-redirect app, are eligible. + s.True(isFlowSecretEligible(appType, nil), "type %q embedded should be eligible", appType) + s.True(isFlowSecretEligible(appType, embedded), "type %q embedded should be eligible", appType) + // Redirect and m2m-shaped apps are not eligible. + s.False(isFlowSecretEligible(appType, redirect), "type %q redirect should not be eligible", appType) + s.False(isFlowSecretEligible(appType, m2mShaped), "type %q m2m-shaped should not be eligible", appType) + } +} diff --git a/backend/internal/application/constants.go b/backend/internal/application/constants.go index 2c0c563536..fce650fdcd 100644 --- a/backend/internal/application/constants.go +++ b/backend/internal/application/constants.go @@ -34,6 +34,7 @@ const ( propTosURI = "tos_uri" propPolicyURI = "policy_uri" propContacts = "contacts" + propType = "type" propTemplate = "template" propMetadata = "metadata" propOAuthConfig = "oauth_config" diff --git a/backend/internal/application/declarative_resource.go b/backend/internal/application/declarative_resource.go index 9b17b17480..349c273b90 100644 --- a/backend/internal/application/declarative_resource.go +++ b/backend/internal/application/declarative_resource.go @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * Copyright (c) 2025-2026, WSO2 LLC. (https://www.wso2.com). * * WSO2 LLC. licenses this file to you under the Apache License, * Version 2.0 (the "License"); you may not use this file except @@ -191,6 +191,7 @@ func parseToApplicationDTO(data []byte) (*model.ApplicationDTO, error) { AllowedUserTypes: appRequest.AllowedUserTypes, LoginConsent: appRequest.LoginConsent, }, + Type: appRequest.Type, Template: appRequest.Template, FlowSecret: appRequest.FlowSecret, URL: appRequest.URL, diff --git a/backend/internal/application/error_constants.go b/backend/internal/application/error_constants.go index 257c572828..82684db622 100644 --- a/backend/internal/application/error_constants.go +++ b/backend/internal/application/error_constants.go @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * Copyright (c) 2025-2026, WSO2 LLC. (https://www.wso2.com). * * WSO2 LLC. licenses this file to you under the Apache License, * Version 2.0 (the "License"); you may not use this file except @@ -520,4 +520,46 @@ var ( "Both must point to the same {{param(flowType)}} flow.", }, } + // ErrorInvalidApplicationType is returned when an application is created or updated with an + // unrecognized type value. + ErrorInvalidApplicationType = tidcommon.ServiceError{ + Type: tidcommon.ClientErrorType, + Code: "APP-1040", + Error: tidcommon.I18nMessage{ + Key: "error.applicationservice.invalid_application_type", + DefaultValue: "Invalid application type", + }, + ErrorDescription: tidcommon.I18nMessage{ + Key: "error.applicationservice.invalid_application_type_description", + DefaultValue: "The provided application type is not supported. It must be one of: " + + "browser, fullstack, mobile, m2m, custom.", + }, + } + // ErrorApplicationTypeImmutable is returned when an update attempts to change the application type. + ErrorApplicationTypeImmutable = tidcommon.ServiceError{ + Type: tidcommon.ClientErrorType, + Code: "APP-1041", + Error: tidcommon.I18nMessage{ + Key: "error.applicationservice.application_type_immutable", + DefaultValue: "Application type cannot be changed", + }, + ErrorDescription: tidcommon.I18nMessage{ + Key: "error.applicationservice.application_type_immutable_description", + DefaultValue: "The application type is set at creation and cannot be modified.", + }, + } + // ErrorApplicationTypeRequired is returned when an application is created without a type. + ErrorApplicationTypeRequired = tidcommon.ServiceError{ + Type: tidcommon.ClientErrorType, + Code: "APP-1042", + Error: tidcommon.I18nMessage{ + Key: "error.applicationservice.application_type_required", + DefaultValue: "Application type is required", + }, + ErrorDescription: tidcommon.I18nMessage{ + Key: "error.applicationservice.application_type_required_description", + DefaultValue: "An application type must be provided. It must be one of: " + + "browser, fullstack, mobile, m2m, custom.", + }, + } ) diff --git a/backend/internal/application/handler.go b/backend/internal/application/handler.go index 8a38008ca3..ba5d2ddc0f 100644 --- a/backend/internal/application/handler.go +++ b/backend/internal/application/handler.go @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * Copyright (c) 2025-2026, WSO2 LLC. (https://www.wso2.com). * * WSO2 LLC. licenses this file to you under the Apache License, * Version 2.0 (the "License"); you may not use this file except @@ -84,6 +84,7 @@ func (ah *applicationHandler) HandleApplicationPostRequest(w http.ResponseWriter LoginConsent: appRequest.LoginConsent, Attestation: appRequest.Attestation, }, + Type: appRequest.Type, Template: appRequest.Template, FlowSecret: appRequest.FlowSecret, URL: appRequest.URL, @@ -121,6 +122,7 @@ func (ah *applicationHandler) HandleApplicationPostRequest(w http.ResponseWriter LoginConsent: createdAppDTO.LoginConsent, Attestation: createdAppDTO.Attestation, }, + Type: createdAppDTO.Type, Template: createdAppDTO.Template, FlowSecret: createdAppDTO.FlowSecret, URL: createdAppDTO.URL, @@ -201,6 +203,7 @@ func (ah *applicationHandler) HandleApplicationGetRequest(w http.ResponseWriter, LoginConsent: appDTO.LoginConsent, Attestation: appDTO.Attestation, }, + Type: model.ApplicationType(appDTO.Type), Template: appDTO.Template, URL: appDTO.URL, LogoURL: appDTO.LogoURL, @@ -343,6 +346,7 @@ func (ah *applicationHandler) HandleApplicationPutRequest(w http.ResponseWriter, LoginConsent: appRequest.LoginConsent, Attestation: appRequest.Attestation, }, + Type: appRequest.Type, Template: appRequest.Template, FlowSecret: appRequest.FlowSecret, URL: appRequest.URL, @@ -380,6 +384,7 @@ func (ah *applicationHandler) HandleApplicationPutRequest(w http.ResponseWriter, LoginConsent: updatedAppDTO.LoginConsent, Attestation: updatedAppDTO.Attestation, }, + Type: updatedAppDTO.Type, Template: updatedAppDTO.Template, URL: updatedAppDTO.URL, LogoURL: updatedAppDTO.LogoURL, diff --git a/backend/internal/application/model/application.go b/backend/internal/application/model/application.go index a181a7a968..cfcc081848 100644 --- a/backend/internal/application/model/application.go +++ b/backend/internal/application/model/application.go @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * Copyright (c) 2025-2026, WSO2 LLC. (https://www.wso2.com). * * WSO2 LLC. licenses this file to you under the Apache License, * Version 2.0 (the "License"); you may not use this file except @@ -28,13 +28,14 @@ import ( // ApplicationDTO represents the data transfer object for application service operations. type ApplicationDTO struct { - ID string `json:"id,omitempty" jsonschema:"Application ID. Auto-generated unique identifier."` - OUID string `json:"ouId,omitempty" jsonschema:"Organization unit ID. The OU this application belongs to."` - OUHandle string `json:"ouHandle,omitempty" jsonschema:"Organization unit handle. Resolved to an ID by the service layer."` - Name string `json:"name" jsonschema:"Application name."` - Description string `json:"description,omitempty" jsonschema:"Optional description of the application's purpose or functionality."` - Template string `json:"template,omitempty" jsonschema:"Application template. Optional. Pre-configured application type template."` - FlowSecret string `json:"flowSecret,omitempty" jsonschema:"Flow Secret. Returned once on creation for backend/server-side apps — non-public clients that do not use the redirect-based authorization_code flow. Used to authenticate when initiating a flow directly via the Flow Execution API."` + ID string `json:"id,omitempty" jsonschema:"Application ID. Auto-generated unique identifier."` + OUID string `json:"ouId,omitempty" jsonschema:"Organization unit ID. The OU this application belongs to."` + OUHandle string `json:"ouHandle,omitempty" jsonschema:"Organization unit handle. Resolved to an ID by the service layer."` + Name string `json:"name" jsonschema:"Application name."` + Description string `json:"description,omitempty" jsonschema:"Optional description of the application's purpose or functionality."` + Type ApplicationType `json:"type,omitempty" jsonschema:"Application type. Canonical platform/client class: browser, fullstack, mobile, m2m, or custom. Required at creation and immutable."` + Template string `json:"template,omitempty" jsonschema:"Application template. Optional. Display metadata identifying the frontend template used to create the application."` + FlowSecret string `json:"flowSecret,omitempty" jsonschema:"Flow Secret used to authenticate when initiating a flow directly via the Flow Execution API. Issued once on creation only to full-stack and custom applications that either have no OAuth 2.0 configuration (embedded) or are configured as a confidential, non-redirect OAuth 2.0 client. Browser, mobile, and m2m applications are never issued one."` URL string `json:"url,omitempty" jsonschema:"Application home URL. Optional. The main URL where your application is hosted."` LogoURL string `json:"logoUrl,omitempty" jsonschema:"Logo image URL. Optional. Displayed in login pages and application listings."` @@ -60,6 +61,7 @@ type BasicApplicationDTO struct { SignOutFlowID string ThemeID string LayoutID string + Type ApplicationType Template string ClientID string LogoURL string @@ -68,11 +70,12 @@ type BasicApplicationDTO struct { // ApplicationProcessedDTO represents the processed data transfer object for application service operations. type ApplicationProcessedDTO struct { - ID string `yaml:"id,omitempty"` - OUID string `yaml:"ouId,omitempty"` - Name string `yaml:"name,omitempty"` - Description string `yaml:"description,omitempty"` - Template string `yaml:"template,omitempty"` + ID string `yaml:"id,omitempty"` + OUID string `yaml:"ouId,omitempty"` + Name string `yaml:"name,omitempty"` + Description string `yaml:"description,omitempty"` + Type ApplicationType `yaml:"type,omitempty"` + Template string `yaml:"template,omitempty"` URL string `yaml:"url,omitempty"` LogoURL string `yaml:"logoUrl,omitempty"` @@ -87,16 +90,17 @@ type ApplicationProcessedDTO struct { // ApplicationRequest represents the request structure for creating or updating an application. type ApplicationRequest struct { - OUID string `json:"ouId,omitempty" yaml:"ouId,omitempty"` - Name string `json:"name" yaml:"name" native:"required,min=3,max=100"` - Description string `json:"description" yaml:"description"` - Template string `json:"template,omitempty" yaml:"template,omitempty"` - FlowSecret string `json:"flowSecret,omitempty" yaml:"flowSecret,omitempty"` - URL string `json:"url,omitempty" yaml:"url,omitempty" native:"omitempty,url,max=2048"` - LogoURL string `json:"logoUrl,omitempty" yaml:"logoUrl,omitempty" native:"omitempty,url,max=2048"` - TosURI string `json:"tosUri,omitempty" yaml:"tosUri,omitempty" native:"omitempty,url,max=2048"` - PolicyURI string `json:"policyUri,omitempty" yaml:"policyUri,omitempty" native:"omitempty,url,max=2048"` - Contacts []string `json:"contacts,omitempty" yaml:"contacts,omitempty"` + OUID string `json:"ouId,omitempty" yaml:"ouId,omitempty"` + Name string `json:"name" yaml:"name" native:"required,min=3,max=100"` + Description string `json:"description" yaml:"description"` + Type ApplicationType `json:"type,omitempty" yaml:"type,omitempty"` + Template string `json:"template,omitempty" yaml:"template,omitempty"` + FlowSecret string `json:"flowSecret,omitempty" yaml:"flowSecret,omitempty"` + URL string `json:"url,omitempty" yaml:"url,omitempty" native:"omitempty,url,max=2048"` + LogoURL string `json:"logoUrl,omitempty" yaml:"logoUrl,omitempty" native:"omitempty,url,max=2048"` + TosURI string `json:"tosUri,omitempty" yaml:"tosUri,omitempty" native:"omitempty,url,max=2048"` + PolicyURI string `json:"policyUri,omitempty" yaml:"policyUri,omitempty" native:"omitempty,url,max=2048"` + Contacts []string `json:"contacts,omitempty" yaml:"contacts,omitempty"` providers.InboundAuthProfile `yaml:",inline"` InboundAuthConfig []providers.InboundAuthConfigWithSecret `json:"inboundAuthConfig,omitempty" yaml:"inboundAuthConfig,omitempty"` @@ -105,18 +109,19 @@ type ApplicationRequest struct { // ApplicationRequestWithID represents the request structure for importing an application using file based runtime. type ApplicationRequestWithID struct { - ID string `json:"id" yaml:"id"` - OUID string `json:"ouId,omitempty" yaml:"ouId,omitempty"` - OUHandle string `json:"ouHandle,omitempty" yaml:"ouHandle,omitempty"` - Name string `json:"name" yaml:"name"` - Description string `json:"description" yaml:"description"` - Template string `json:"template,omitempty" yaml:"template,omitempty"` - FlowSecret string `json:"flowSecret,omitempty" yaml:"flowSecret,omitempty"` - URL string `json:"url,omitempty" yaml:"url,omitempty"` - LogoURL string `json:"logoUrl,omitempty" yaml:"logoUrl,omitempty"` - TosURI string `json:"tosUri,omitempty" yaml:"tosUri,omitempty"` - PolicyURI string `json:"policyUri,omitempty" yaml:"policyUri,omitempty"` - Contacts []string `json:"contacts,omitempty" yaml:"contacts,omitempty"` + ID string `json:"id" yaml:"id"` + OUID string `json:"ouId,omitempty" yaml:"ouId,omitempty"` + OUHandle string `json:"ouHandle,omitempty" yaml:"ouHandle,omitempty"` + Name string `json:"name" yaml:"name"` + Description string `json:"description" yaml:"description"` + Type ApplicationType `json:"type,omitempty" yaml:"type,omitempty"` + Template string `json:"template,omitempty" yaml:"template,omitempty"` + FlowSecret string `json:"flowSecret,omitempty" yaml:"flowSecret,omitempty"` + URL string `json:"url,omitempty" yaml:"url,omitempty"` + LogoURL string `json:"logoUrl,omitempty" yaml:"logoUrl,omitempty"` + TosURI string `json:"tosUri,omitempty" yaml:"tosUri,omitempty"` + PolicyURI string `json:"policyUri,omitempty" yaml:"policyUri,omitempty"` + Contacts []string `json:"contacts,omitempty" yaml:"contacts,omitempty"` providers.InboundAuthProfile `yaml:",inline"` InboundAuthConfig []providers.InboundAuthConfigWithSecret `json:"inboundAuthConfig,omitempty" yaml:"inboundAuthConfig,omitempty"` @@ -125,18 +130,19 @@ type ApplicationRequestWithID struct { // ApplicationCompleteResponse represents the complete response structure for an application. type ApplicationCompleteResponse struct { - ID string `json:"id,omitempty"` - OUID string `json:"ouId,omitempty"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - ClientID string `json:"clientId,omitempty"` - Template string `json:"template,omitempty"` - FlowSecret string `json:"flowSecret,omitempty"` - URL string `json:"url,omitempty"` - LogoURL string `json:"logoUrl,omitempty"` - TosURI string `json:"tosUri,omitempty"` - PolicyURI string `json:"policyUri,omitempty"` - Contacts []string `json:"contacts,omitempty"` + ID string `json:"id,omitempty"` + OUID string `json:"ouId,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + ClientID string `json:"clientId,omitempty"` + Type ApplicationType `json:"type,omitempty"` + Template string `json:"template,omitempty"` + FlowSecret string `json:"flowSecret,omitempty"` + URL string `json:"url,omitempty"` + LogoURL string `json:"logoUrl,omitempty"` + TosURI string `json:"tosUri,omitempty"` + PolicyURI string `json:"policyUri,omitempty"` + Contacts []string `json:"contacts,omitempty"` providers.InboundAuthProfile InboundAuthConfig []providers.InboundAuthConfigWithSecret `json:"inboundAuthConfig,omitempty"` @@ -145,17 +151,18 @@ type ApplicationCompleteResponse struct { // ApplicationGetResponse represents the response structure for getting an application. type ApplicationGetResponse struct { - ID string `json:"id,omitempty"` - OUID string `json:"ouId,omitempty"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - ClientID string `json:"clientId,omitempty"` - Template string `json:"template,omitempty"` - URL string `json:"url,omitempty"` - LogoURL string `json:"logoUrl,omitempty"` - TosURI string `json:"tosUri,omitempty"` - PolicyURI string `json:"policyUri,omitempty"` - Contacts []string `json:"contacts,omitempty"` + ID string `json:"id,omitempty"` + OUID string `json:"ouId,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + ClientID string `json:"clientId,omitempty"` + Type ApplicationType `json:"type,omitempty"` + Template string `json:"template,omitempty"` + URL string `json:"url,omitempty"` + LogoURL string `json:"logoUrl,omitempty"` + TosURI string `json:"tosUri,omitempty"` + PolicyURI string `json:"policyUri,omitempty"` + Contacts []string `json:"contacts,omitempty"` providers.InboundAuthProfile InboundAuthConfig []inboundmodel.InboundAuthConfig `json:"inboundAuthConfig,omitempty"` @@ -166,21 +173,23 @@ type ApplicationGetResponse struct { // Only carries the subset of inbound-profile fields that make sense in the list view, so it // does not embed InboundAuthProfile (which carries Assertion/LoginConsent/etc.). type BasicApplicationResponse struct { - ID string `json:"id,omitempty" jsonschema:"Application ID."` - Name string `json:"name" jsonschema:"Application name."` - Description string `json:"description,omitempty" jsonschema:"Application description."` - ClientID string `json:"clientId,omitempty" jsonschema:"OAuth Client ID."` - LogoURL string `json:"logoUrl,omitempty" jsonschema:"Logo URL."` - AuthFlowID string `json:"authFlowId,omitempty" jsonschema:"Authentication Flow ID."` - RegistrationFlowID string `json:"registrationFlowId,omitempty" jsonschema:"Registration Flow ID."` - IsRegistrationFlowEnabled bool `json:"isRegistrationFlowEnabled" jsonschema:"Registration enabled status."` - RecoveryFlowID string `json:"recoveryFlowId,omitempty" jsonschema:"Recovery Flow ID."` - IsRecoveryFlowEnabled bool `json:"isRecoveryFlowEnabled" jsonschema:"Recovery enabled status."` - SignOutFlowID string `json:"signOutFlowId,omitempty" jsonschema:"Sign-out flow ID."` - ThemeID string `json:"themeId,omitempty" jsonschema:"Theme ID."` - LayoutID string `json:"layoutId,omitempty" jsonschema:"Layout ID."` - Template string `json:"template,omitempty" jsonschema:"Application Template."` - IsReadOnly bool `json:"isReadOnly" jsonschema:"Indicates if the application is read-only (declarative/immutable)."` + ID string `json:"id,omitempty" jsonschema:"Application ID."` + Name string `json:"name" jsonschema:"Application name."` + Description string `json:"description,omitempty" jsonschema:"Application description."` + ClientID string `json:"clientId,omitempty" jsonschema:"OAuth Client ID."` + LogoURL string `json:"logoUrl,omitempty" jsonschema:"Logo URL."` + AuthFlowID string `json:"authFlowId,omitempty" jsonschema:"Authentication Flow ID."` + RegistrationFlowID string `json:"registrationFlowId,omitempty" jsonschema:"Registration Flow ID."` + IsRegistrationFlowEnabled bool `json:"isRegistrationFlowEnabled" jsonschema:"Registration enabled status."` + RecoveryFlowID string `json:"recoveryFlowId,omitempty" jsonschema:"Recovery Flow ID."` + IsRecoveryFlowEnabled bool `json:"isRecoveryFlowEnabled" jsonschema:"Recovery enabled status."` + SignOutFlowID string `json:"signOutFlowId,omitempty" jsonschema:"Sign-out flow ID."` + IsSignOutFlowEnabled bool `json:"isSignOutFlowEnabled" jsonschema:"Sign-out enabled status."` + ThemeID string `json:"themeId,omitempty" jsonschema:"Theme ID."` + LayoutID string `json:"layoutId,omitempty" jsonschema:"Layout ID."` + Type ApplicationType `json:"type,omitempty" jsonschema:"Application type (browser, fullstack, mobile, m2m, custom)."` + Template string `json:"template,omitempty" jsonschema:"Application Template."` + IsReadOnly bool `json:"isReadOnly" jsonschema:"Indicates if the application is read-only (declarative/immutable)."` } // ApplicationListResponse represents the response structure for listing applications. diff --git a/backend/internal/application/model/constants.go b/backend/internal/application/model/constants.go index eeb26acd45..d35d79b590 100644 --- a/backend/internal/application/model/constants.go +++ b/backend/internal/application/model/constants.go @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * Copyright (c) 2025-2026, WSO2 LLC. (https://www.wso2.com). * * WSO2 LLC. licenses this file to you under the Apache License, * Version 2.0 (the "License"); you may not use this file except @@ -22,6 +22,20 @@ import ( "errors" ) +// ApplicationType identifies the platform/client class of an application. It is the canonical +// discriminator the backend uses to apply type-specific behavior (Flow Secret issuance, direct +// flow initiation, and so on). The free-form Template string remains display metadata only. +type ApplicationType string + +// Supported application types. +const ( + ApplicationTypeBrowser ApplicationType = "browser" + ApplicationTypeFullStack ApplicationType = "fullstack" + ApplicationTypeMobile ApplicationType = "mobile" + ApplicationTypeM2M ApplicationType = "m2m" + ApplicationTypeCustom ApplicationType = "custom" +) + // ApplicationNotFoundError is the error returned when an application is not found. var ApplicationNotFoundError error = errors.New("application not found") diff --git a/backend/internal/application/model/utils.go b/backend/internal/application/model/utils.go new file mode 100644 index 0000000000..8edc6037ed --- /dev/null +++ b/backend/internal/application/model/utils.go @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package model + +// IsValidApplicationType reports whether t is a recognized application type. +func IsValidApplicationType(t ApplicationType) bool { + switch t { + case ApplicationTypeBrowser, ApplicationTypeFullStack, ApplicationTypeMobile, + ApplicationTypeM2M, ApplicationTypeCustom: + return true + default: + return false + } +} + +// ResolveApplicationType converts a raw stored type value into the canonical ApplicationType, +// defaulting to ApplicationTypeCustom when raw is empty or unrecognized. This covers applications +// created before the type attribute existed, or holding a legacy/corrupted value, so callers treat +// them as unconstrained rather than failing. +func ResolveApplicationType(raw string) ApplicationType { + if t := ApplicationType(raw); IsValidApplicationType(t) { + return t + } + return ApplicationTypeCustom +} diff --git a/backend/internal/application/service.go b/backend/internal/application/service.go index 1fe0bcb33f..f435d50844 100644 --- a/backend/internal/application/service.go +++ b/backend/internal/application/service.go @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * Copyright (c) 2025-2026, WSO2 LLC. (https://www.wso2.com). * * WSO2 LLC. licenses this file to you under the Apache License, * Version 2.0 (the "License"); you may not use this file except @@ -146,7 +146,7 @@ func (as *applicationService) CreateApplication(ctx context.Context, app *model. // value for such apps is ignored. For eligible apps, an explicitly provided value (e.g. // declarative resources) is preserved; otherwise one is generated. flowSecret := "" - if isFlowSecretEligible(inboundAuthConfig) { + if isFlowSecretEligible(app.Type, inboundAuthConfig) { flowSecret = app.FlowSecret if flowSecret == "" { generatedFlowSecret, secretErr := oauthutils.GenerateOAuth2ClientSecret() @@ -237,6 +237,11 @@ func (as *applicationService) ValidateApplication(ctx context.Context, app *mode return nil, nil, svcErr } + // A type is required on creation; there is no implicit default. + if app.Type == "" { + return nil, nil, &ErrorApplicationTypeRequired + } + appID := app.ID if appID == "" { var err error @@ -487,7 +492,7 @@ func (as *applicationService) updateEntityDataForApplicationUpdate(ctx context.C // public nor redirect-based — may have one set; a value supplied for an ineligible app is // ignored. Credential updates merge, so this preserves the stored client secret, and an empty // value leaves the existing Flow Secret intact. - if app.FlowSecret != "" && isFlowSecretEligible(inboundAuthConfig) { + if app.FlowSecret != "" && isFlowSecretEligible(app.Type, inboundAuthConfig) { flowSecretJSON, marshalErr := buildSystemCredentials("", app.FlowSecret) if marshalErr != nil { as.logger.Error(ctx, "Failed to build flow secret credentials for update", log.Error(marshalErr)) @@ -533,27 +538,28 @@ func (as *applicationService) updateEntityDataForApplicationUpdate(ctx context.C return nil } -// isFlowSecretEligible reports whether an application may hold a Flow Secret. Eligible apps initiate -// flows directly: embedded apps with no OAuth config, or confidential non-redirect apps. Public, -// redirect (authorization_code), and machine-to-machine (client_credentials as the only grant) apps -// are not eligible. -func isFlowSecretEligible(inboundAuthConfig *providers.InboundAuthConfigWithSecret) bool { - if inboundAuthConfig == nil || inboundAuthConfig.OAuthConfig == nil { - return true - } - oauthConfig := inboundAuthConfig.OAuthConfig - if oauthConfig.PublicClient { - return false - } - if slices.Contains(oauthConfig.GrantTypes, providers.GrantTypeAuthorizationCode) { - return false - } - // Machine-to-machine apps use client_credentials as their only grant; they obtain tokens directly - // and do not initiate flows, so they are not issued a Flow Secret. - if isM2MGrantSet(oauthConfig.GrantTypes) { +// isFlowSecretEligible reports whether an application may hold a Flow Secret. Browser (public +// redirect), mobile (attestation), and m2m (direct token) apps never hold one. Full-stack and custom +// apps derive eligibility from the OAuth config shape: only confidential, non-redirect clients (or +// embedded apps with no OAuth config) are eligible. +func isFlowSecretEligible(appType model.ApplicationType, + inboundAuthConfig *providers.InboundAuthConfigWithSecret) bool { + switch appType { + case model.ApplicationTypeBrowser, model.ApplicationTypeMobile, model.ApplicationTypeM2M: return false + default: + if inboundAuthConfig == nil || inboundAuthConfig.OAuthConfig == nil { + return true + } + oauthConfig := inboundAuthConfig.OAuthConfig + if oauthConfig.PublicClient { + return false + } + if slices.Contains(oauthConfig.GrantTypes, providers.GrantTypeAuthorizationCode) { + return false + } + return !isM2MGrantSet(oauthConfig.GrantTypes) } - return true } // isM2MGrantSet reports whether client_credentials is the only configured grant type. @@ -835,6 +841,9 @@ func toInboundClient(dto *model.ApplicationProcessedDTO) inboundmodel.InboundCli if len(dto.Contacts) > 0 { props[propContacts] = dto.Contacts } + if dto.Type != "" { + props[propType] = string(dto.Type) + } if dto.Template != "" { props[propTemplate] = dto.Template } @@ -912,6 +921,9 @@ func toProcessedDTO( } } } + if t, ok := dao.Properties[propType].(string); ok { + dto.Type = model.ApplicationType(t) + } if template, ok := dao.Properties[propTemplate].(string); ok { dto.Template = template } @@ -1103,6 +1115,13 @@ func (as *applicationService) validateApplicationForUpdate( return nil, nil, svcErr } + // The application type is immutable. Reject a request that attempts to change it; inherit the + // existing type when the request omits it. + if app.Type != "" && app.Type != existingApp.Type { + return nil, nil, &ErrorApplicationTypeImmutable + } + app.Type = existingApp.Type + inboundAuthConfig, svcErr := as.processInboundAuthConfig(ctx, app, existingApp) if svcErr != nil { return nil, nil, svcErr @@ -1144,6 +1163,14 @@ func (as *applicationService) validateApplicationFields( if app.LogoURL != "" && !sysutils.IsValidLogoURI(app.LogoURL) { return &ErrorInvalidLogoURL } + // Reject an unrecognized application type. Requiring a type (create) and enforcing + // immutability (update) are handled by the respective callers. + switch app.Type { + case "", model.ApplicationTypeBrowser, model.ApplicationTypeFullStack, + model.ApplicationTypeMobile, model.ApplicationTypeM2M, model.ApplicationTypeCustom: + default: + return &ErrorInvalidApplicationType + } // Reject requests with more than one OAuth-typed inbound auth entry — at most one // inbound auth config per protocol per application is allowed. isOAuthConfig := false @@ -1788,6 +1815,7 @@ func buildApplicationResponse(dto *model.ApplicationProcessedDTO) *providers.App LoginConsent: dto.LoginConsent, Attestation: dto.Attestation, }, + Type: string(dto.Type), Template: dto.Template, URL: dto.URL, LogoURL: dto.LogoURL, @@ -1844,6 +1872,9 @@ func buildBasicApplicationResponse( IsReadOnly: cfg.IsReadOnly, } if cfg.Properties != nil { + if t, ok := cfg.Properties[propType].(string); ok { + resp.Type = model.ApplicationType(t) + } if t, ok := cfg.Properties[propTemplate].(string); ok { resp.Template = t } @@ -1895,6 +1926,7 @@ func buildBaseApplicationProcessedDTO(appID string, app *model.ApplicationDTO, LoginConsent: app.LoginConsent, Attestation: app.Attestation, }, + Type: app.Type, Template: app.Template, URL: app.URL, LogoURL: app.LogoURL, @@ -1978,6 +2010,7 @@ func buildReturnApplicationDTO( LoginConsent: app.LoginConsent, Attestation: app.Attestation.WithoutCredentials(), }, + Type: app.Type, Template: app.Template, URL: app.URL, LogoURL: app.LogoURL, diff --git a/backend/internal/application/service_test.go b/backend/internal/application/service_test.go index 617c10cb7c..b4f00b6496 100644 --- a/backend/internal/application/service_test.go +++ b/backend/internal/application/service_test.go @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * Copyright (c) 2025-2026, WSO2 LLC. (https://www.wso2.com). * * WSO2 LLC. licenses this file to you under the Apache License, * Version 2.0 (the "License"); you may not use this file except @@ -882,6 +882,79 @@ func (suite *ServiceTestSuite) TestValidateApplicationForUpdate_Success() { assert.Equal(suite.T(), "Test App", result.Name) } +func (suite *ServiceTestSuite) TestValidateApplicationForUpdate_TypeImmutable() { + testConfig := &config.Config{ + DeclarativeResources: config.DeclarativeResources{ + Enabled: false, + }, + } + config.ResetServerRuntime() + err := config.InitializeServerRuntime("/tmp/test", testConfig) + require.NoError(suite.T(), err) + defer config.ResetServerRuntime() + + service, mockStore := suite.setupTestService() + + existingApp := &model.ApplicationProcessedDTO{ + ID: testServiceAppID, + Name: "Test App", + Type: model.ApplicationTypeMobile, + } + + app := &model.ApplicationDTO{ + Name: "Test App", + OUID: testOUID, + Type: model.ApplicationTypeBrowser, + URL: "https://example.com", + LogoURL: "https://example.com/logo.png", + } + + mockStore.On("IsDeclarative", mock.Anything, testServiceAppID).Maybe().Return(false) + mockLoadFullApplication(mockStore, service, existingApp) + + result, _, svcErr := service.validateApplicationForUpdate(context.Background(), testServiceAppID, app) + + assert.Nil(suite.T(), result) + require.NotNil(suite.T(), svcErr) + assert.Equal(suite.T(), ErrorApplicationTypeImmutable.Code, svcErr.Code) +} + +func (suite *ServiceTestSuite) TestValidateApplicationForUpdate_TypeInheritedWhenOmitted() { + testConfig := &config.Config{ + DeclarativeResources: config.DeclarativeResources{ + Enabled: false, + }, + } + config.ResetServerRuntime() + err := config.InitializeServerRuntime("/tmp/test", testConfig) + require.NoError(suite.T(), err) + defer config.ResetServerRuntime() + + service, mockStore := suite.setupTestService() + + existingApp := &model.ApplicationProcessedDTO{ + ID: testServiceAppID, + Name: "Test App", + Type: model.ApplicationTypeMobile, + } + + app := &model.ApplicationDTO{ + Name: "Test App", + OUID: testOUID, + URL: "https://example.com", + LogoURL: "https://example.com/logo.png", + } + + mockStore.On("IsDeclarative", mock.Anything, testServiceAppID).Maybe().Return(false) + mockLoadFullApplication(mockStore, service, existingApp) + + result, _, svcErr := service.validateApplicationForUpdate(context.Background(), testServiceAppID, app) + + require.Nil(suite.T(), svcErr) + assert.NotNil(suite.T(), result) + assert.Equal(suite.T(), model.ApplicationTypeMobile, app.Type) +} + func (suite *ServiceTestSuite) TestDeleteApplication_EmptyAppID() { testConfig := &config.Config{ DeclarativeResources: config.DeclarativeResources{ @@ -1470,6 +1543,7 @@ func (suite *ServiceTestSuite) runCreateApplicationStoreErrorTest() { service, mockStore := suite.setupTestService() app := &model.ApplicationDTO{ + Type: model.ApplicationTypeFullStack, Name: "Test App", OUID: testOUID, InboundAuthProfile: providers.InboundAuthProfile{ @@ -1685,6 +1759,7 @@ func (suite *ServiceTestSuite) TestCreateApplication_WithAttestation_EncryptsAnd }).Return(nil) app := &model.ApplicationDTO{ + Type: model.ApplicationTypeMobile, Name: "Mobile App", OUID: testOUID, InboundAuthProfile: providers.InboundAuthProfile{ @@ -1818,6 +1893,7 @@ func (suite *ServiceTestSuite) TestCreateApplication_WithOAuthCertificate_Succes service, mockStore := suite.setupTestService() app := &model.ApplicationDTO{ + Type: model.ApplicationTypeFullStack, Name: "Test OAuth Cert App", OUID: testOUID, InboundAuthProfile: providers.InboundAuthProfile{ @@ -1868,6 +1944,7 @@ func (suite *ServiceTestSuite) TestCreateApplication_IssuesFlowSecretForEmbedded // An embedded server-side app: no OAuth config, so no OAuth profile. app := &model.ApplicationDTO{ + Type: model.ApplicationTypeFullStack, Name: "Embedded App", OUID: testOUID, InboundAuthProfile: providers.InboundAuthProfile{ @@ -1909,6 +1986,7 @@ func (suite *ServiceTestSuite) TestCreateApplication_NoFlowSecretForM2MClient() // A machine-to-machine app using client_credentials only. It obtains tokens directly and cannot // consume a flow assertion, so it gets no Flow Secret. A caller-supplied FlowSecret is ignored. app := &model.ApplicationDTO{ + Type: model.ApplicationTypeM2M, Name: "M2M App", OUID: testOUID, FlowSecret: "caller-supplied-secret", @@ -1961,6 +2039,7 @@ func (suite *ServiceTestSuite) TestCreateApplication_NoFlowSecretForRedirectClie // client secret but no Flow Secret, since it cannot initiate flows directly. A caller-supplied // FlowSecret must be ignored for such an ineligible app. app := &model.ApplicationDTO{ + Type: model.ApplicationTypeFullStack, Name: "Full-stack App", OUID: testOUID, FlowSecret: "caller-supplied-secret", @@ -2016,6 +2095,7 @@ func (suite *ServiceTestSuite) TestCreateApplication_NoFlowSecretForPublicClient // A browser SPA: public client, no client secret. A caller-supplied FlowSecret must be ignored. app := &model.ApplicationDTO{ + Type: model.ApplicationTypeBrowser, Name: "SPA App", OUID: testOUID, FlowSecret: "caller-supplied-secret", @@ -2076,6 +2156,7 @@ func (suite *ServiceTestSuite) TestCreateApplication_StoreErrorWithOAuthCertRoll service, mockStore := suite.setupTestService() app := &model.ApplicationDTO{ + Type: model.ApplicationTypeFullStack, Name: "Test OAuth Cert App", OUID: testOUID, InboundAuthProfile: providers.InboundAuthProfile{ @@ -3023,6 +3104,7 @@ func (suite *ServiceTestSuite) TestCreateApplication_DeclarativeMode() { service, _ := suite.setupTestService() app := &model.ApplicationDTO{ + Type: model.ApplicationTypeFullStack, Name: "Test App", OUID: testOUID, } @@ -3958,6 +4040,7 @@ func (suite *ServiceTestSuite) TestCreateApplication_CreateInboundClientFailsAnd service, mockStore := suite.setupTestService() app := &model.ApplicationDTO{ + Type: model.ApplicationTypeFullStack, Name: "Test App", OUID: testOUID, InboundAuthProfile: providers.InboundAuthProfile{ @@ -3980,6 +4063,22 @@ func (suite *ServiceTestSuite) TestCreateApplication_CreateInboundClientFailsAnd assert.Equal(suite.T(), &tidcommon.InternalServerError, svcErr) } +func (suite *ServiceTestSuite) TestValidateApplication_TypeRequired() { + service, _ := suite.setupTestService() + + app := &model.ApplicationDTO{ + Name: "Test App", + OUID: testOUID, + } + + processed, inboundAuthConfig, svcErr := service.ValidateApplication(context.Background(), app) + + assert.Nil(suite.T(), processed) + assert.Nil(suite.T(), inboundAuthConfig) + assert.NotNil(suite.T(), svcErr) + assert.Equal(suite.T(), &ErrorApplicationTypeRequired, svcErr) +} + func (suite *ServiceTestSuite) TestValidateApplication_InboundClientValidateError() { service, mockStore := suite.setupTestService() @@ -3995,6 +4094,7 @@ func (suite *ServiceTestSuite) TestValidateApplication_InboundClientValidateErro app := &model.ApplicationDTO{ Name: "Test App", OUID: testOUID, + Type: model.ApplicationTypeFullStack, } processed, inboundAuthConfig, svcErr := service.ValidateApplication(context.Background(), app) diff --git a/backend/internal/application/tools.go b/backend/internal/application/tools.go index 01f6592ec2..ccdd31d9db 100644 --- a/backend/internal/application/tools.go +++ b/backend/internal/application/tools.go @@ -229,6 +229,7 @@ func (t *applicationTools) getApplicationTemplates( "spa": { OUID: "", Name: "", + Type: model.ApplicationTypeBrowser, InboundAuthProfile: providers.InboundAuthProfile{ ThemeID: "", }, @@ -261,6 +262,7 @@ func (t *applicationTools) getApplicationTemplates( "mobile": { OUID: "", Name: "", + Type: model.ApplicationTypeMobile, InboundAuthProfile: providers.InboundAuthProfile{ ThemeID: "", }, @@ -293,6 +295,7 @@ func (t *applicationTools) getApplicationTemplates( "server": { OUID: "", Name: "", + Type: model.ApplicationTypeFullStack, InboundAuthProfile: providers.InboundAuthProfile{ ThemeID: "", }, @@ -324,6 +327,7 @@ func (t *applicationTools) getApplicationTemplates( "m2m": { OUID: "", Name: "", + Type: model.ApplicationTypeM2M, InboundAuthConfig: []providers.InboundAuthConfigWithSecret{ { Type: providers.OAuthInboundAuthType, diff --git a/backend/internal/flow/flowexec/constants.go b/backend/internal/flow/flowexec/constants.go index 46c3fd2647..1ab7485ade 100644 --- a/backend/internal/flow/flowexec/constants.go +++ b/backend/internal/flow/flowexec/constants.go @@ -25,6 +25,10 @@ const ( defaultRecoveryFlowExpiry int64 = 1800 // 30 minutes in seconds fieldFlowSecret = "flowSecret" + + // applicationTypePropertyKey is the InboundClient.Properties key under which the application + // type is stored. + applicationTypePropertyKey = "type" ) // flowInitiationMode classifies how an application is permitted to initiate a new authentication diff --git a/backend/internal/flow/flowexec/error_constants.go b/backend/internal/flow/flowexec/error_constants.go index 92fd365e32..ed02950b38 100644 --- a/backend/internal/flow/flowexec/error_constants.go +++ b/backend/internal/flow/flowexec/error_constants.go @@ -239,3 +239,18 @@ var ErrorAttestationInvalid = tidcommon.ServiceError{ DefaultValue: "The provided attestation token is invalid", }, } + +// ErrorAttestationNotConfigured defines the error when a mobile application initiates a flow but has +// no platform attestation configured. Mobile apps must configure attestation to access the flow APIs. +var ErrorAttestationNotConfigured = tidcommon.ServiceError{ + Code: "FES-1016", + Type: tidcommon.ClientErrorType, + Error: tidcommon.I18nMessage{ + Key: "error.flowexecservice.attestation_not_configured", + DefaultValue: "Attestation not configured", + }, + ErrorDescription: tidcommon.I18nMessage{ + Key: "error.flowexecservice.attestation_not_configured_description", + DefaultValue: "Mobile applications must configure platform attestation to initiate a flow", + }, +} diff --git a/backend/internal/flow/flowexec/service.go b/backend/internal/flow/flowexec/service.go index 16fbdb2560..efc454121f 100644 --- a/backend/internal/flow/flowexec/service.go +++ b/backend/internal/flow/flowexec/service.go @@ -28,6 +28,7 @@ import ( tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" "github.com/thunder-id/thunderid/internal/actorprovider" + appmodel "github.com/thunder-id/thunderid/internal/application/model" authnprovidercm "github.com/thunder-id/thunderid/internal/authnprovider/common" "github.com/thunder-id/thunderid/internal/flow/common" flowconfig "github.com/thunder-id/thunderid/internal/flow/config" @@ -246,6 +247,10 @@ func (s *flowExecService) checkDirectFlowInitiationAllowed(ctx context.Context, if svcErr.Code == actorprovider.ErrorActorNotFound.Code { return &ErrorInvalidAppID } + // Surface client errors (e.g. mobile attestation not configured) as-is. + if svcErr.Type == tidcommon.ClientErrorType { + return svcErr + } logger.Error(ctx, "Failed to resolve flow initiation mode for guard", log.String("appID", appID)) return &tidcommon.InternalServerError @@ -295,42 +300,50 @@ func (s *flowExecService) verifyAttestation(ctx context.Context, return nil } -// resolveFlowInitiationMode derives how the given application is permitted to initiate a new -// authentication flow, using neutral actor data resolved through the actor layer. A non-existent -// application returns ErrorActorNotFound so the caller can distinguish an unknown app from a -// backend app. The resolved OAuth profile (nil for embedded apps) is returned for downstream -// credential checks. +// resolveFlowInitiationMode decides how an application may initiate a flow, based on its type. An +// unknown application returns ErrorActorNotFound so the caller can map it to an invalid app. func (s *flowExecService) resolveFlowInitiationMode( ctx context.Context, appID string, ) (flowInitiationMode, *providers.AttestationConfig, *tidcommon.ServiceError) { - // The inbound client is protocol-agnostic and exists for every valid application. Platform - // attestation is a client-level binary-identity check, so it is resolved here first and takes - // precedence regardless of whether the application also has an OAuth2 protocol profile. An - // unknown application surfaces as ErrorActorNotFound so the caller can map it to an invalid app. client, clientErr := s.actorProvider.GetInboundClientByID(ctx, appID) if clientErr != nil { return 0, nil, clientErr } - if client.Attestation != nil && (client.Attestation.Android != nil || client.Attestation.Apple != nil) { + + rawAppType, _ := client.Properties[applicationTypePropertyKey].(string) + switch appmodel.ResolveApplicationType(rawAppType) { + case appmodel.ApplicationTypeM2M, appmodel.ApplicationTypeBrowser: + // M2M apps get tokens directly; browser apps are public redirect clients. Neither runs flows. + return flowInitiationNotPermitted, nil, nil + case appmodel.ApplicationTypeMobile: + // Mobile apps authenticate with platform attestation, which must be configured first. + if client.Attestation == nil || (client.Attestation.Android == nil && client.Attestation.Apple == nil) { + return 0, nil, &ErrorAttestationNotConfigured + } return flowInitiationAttestation, client.Attestation, nil + default: + // Full-stack and custom apps may be embedded or redirect-based; derive from the OAuth profile. + return s.resolveFlowInitiationModeFromProfile(ctx, appID) } +} - // No attestation configured: classify by protocol profile. +// resolveFlowInitiationModeFromProfile derives the mode from the OAuth profile, for types that do +// not encode embedded vs redirect in the type itself. +func (s *flowExecService) resolveFlowInitiationModeFromProfile( + ctx context.Context, appID string, +) (flowInitiationMode, *providers.AttestationConfig, *tidcommon.ServiceError) { profile, svcErr := s.actorProvider.GetOAuthProfileByID(ctx, appID) if svcErr != nil && svcErr.Code != actorprovider.ErrorActorNotFound.Code { return 0, nil, svcErr } - // No protocol profile means a server-side embedded app: it initiates flows directly by - // presenting its Flow Secret. + // No profile means an embedded app; it initiates flows directly with its Flow Secret. if profile == nil { return flowInitiationFlowSecret, nil, nil } - // A redirect-based (authorization_code) profile — public or confidential — must initiate flows - // through the protocol component, not via a direct HTTP call. A machine-to-machine app - // (client_credentials as its only grant) obtains tokens directly and does not run flows. Neither - // may initiate a flow directly. + // Redirect (authorization_code) apps initiate through the OAuth component; M2M apps get tokens + // directly. Neither may initiate a flow directly. if slices.Contains(profile.GrantTypes, string(providers.GrantTypeAuthorizationCode)) || isClientCredentialsOnly(profile.GrantTypes) { return flowInitiationNotPermitted, nil, nil diff --git a/backend/internal/flow/flowexec/service_test.go b/backend/internal/flow/flowexec/service_test.go index ef2904c610..f0db33d727 100644 --- a/backend/internal/flow/flowexec/service_test.go +++ b/backend/internal/flow/flowexec/service_test.go @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * Copyright (c) 2025-2026, WSO2 LLC. (https://www.wso2.com). * * WSO2 LLC. licenses this file to you under the Apache License, * Version 2.0 (the "License"); you may not use this file except @@ -35,6 +35,7 @@ import ( "github.com/stretchr/testify/suite" "github.com/thunder-id/thunderid/internal/actorprovider" + "github.com/thunder-id/thunderid/internal/application/model" authncm "github.com/thunder-id/thunderid/internal/authn/common" authnprovidercm "github.com/thunder-id/thunderid/internal/authnprovider/common" "github.com/thunder-id/thunderid/internal/entityprovider" @@ -2385,6 +2386,105 @@ func (s *ServiceTestSuite) TestSetApplicationToContext_BuildApplicationError() { s.Equal(tidcommon.InternalServerError.Code, svcErr.Code) } +// --- resolveFlowInitiationMode (type-driven) --- + +// The flow-initiation mode is resolved from the application type. Machine-to-machine, browser, and +// mobile (without attestation) apps may not initiate a flow directly and never consult the OAuth +// profile. Full-stack and custom apps derive the mode from their profile. +func (s *ServiceTestSuite) TestResolveFlowInitiationMode_ByType() { + const appID = "test-app" + tokenExchange := string(providers.GrantTypeTokenExchange) + + cases := []struct { + name string + appType model.ApplicationType + attestation *providers.AttestationConfig + profile *providers.OAuthProfile + profileErr *tidcommon.ServiceError + expectMode flowInitiationMode + expectErrCode string + }{ + {name: "m2m not permitted", appType: model.ApplicationTypeM2M, expectMode: flowInitiationNotPermitted}, + { + name: "browser not permitted", appType: model.ApplicationTypeBrowser, + expectMode: flowInitiationNotPermitted, + }, + { + name: "mobile without attestation errors", appType: model.ApplicationTypeMobile, + expectErrCode: ErrorAttestationNotConfigured.Code, + }, + { + name: "mobile with attestation uses attestation", appType: model.ApplicationTypeMobile, + attestation: &providers.AttestationConfig{Apple: &providers.AppleAttestationConfig{}}, + expectMode: flowInitiationAttestation, + }, + { + name: "fullstack redirect not permitted", appType: model.ApplicationTypeFullStack, + profile: &providers.OAuthProfile{GrantTypes: []string{"authorization_code"}}, + expectMode: flowInitiationNotPermitted, + }, + { + name: "fullstack embedded uses flow secret", appType: model.ApplicationTypeFullStack, + profile: &providers.OAuthProfile{GrantTypes: []string{"client_credentials", tokenExchange}}, + expectMode: flowInitiationFlowSecret, + }, + { + name: "fullstack without profile uses flow secret", appType: model.ApplicationTypeFullStack, + profileErr: &actorprovider.ErrorActorNotFound, expectMode: flowInitiationFlowSecret, + }, + { + name: "custom embedded uses flow secret", appType: model.ApplicationTypeCustom, + profile: &providers.OAuthProfile{GrantTypes: []string{"client_credentials", tokenExchange}}, + expectMode: flowInitiationFlowSecret, + }, + { + name: "custom redirect not permitted", appType: model.ApplicationTypeCustom, + profile: &providers.OAuthProfile{GrantTypes: []string{"authorization_code"}}, + expectMode: flowInitiationNotPermitted, + }, + { + name: "custom m2m-shaped not permitted", appType: model.ApplicationTypeCustom, + profile: &providers.OAuthProfile{GrantTypes: []string{"client_credentials"}}, + expectMode: flowInitiationNotPermitted, + }, + } + + for _, tc := range cases { + s.Run(tc.name, func() { + t := s.T() + mockActorProvider := actorprovidermock.NewActorProviderMock(t) + + client := &providers.InboundClient{ID: appID, Attestation: tc.attestation} + if tc.appType != "" { + client.Properties = map[string]interface{}{ + applicationTypePropertyKey: string(tc.appType), + } + } + mockActorProvider.EXPECT().GetInboundClientByID(mock.Anything, appID).Return(client, nil) + + // The OAuth profile is consulted for full-stack and custom apps. + if tc.appType == model.ApplicationTypeFullStack || tc.appType == model.ApplicationTypeCustom { + mockActorProvider.EXPECT().GetOAuthProfileByID(mock.Anything, appID).Return(tc.profile, tc.profileErr) + } + + service := &flowExecService{actorProvider: mockActorProvider} + + mode, attestation, svcErr := service.resolveFlowInitiationMode(context.Background(), appID) + + if tc.expectErrCode != "" { + s.NotNil(svcErr) + s.Equal(tc.expectErrCode, svcErr.Code) + return + } + s.Nil(svcErr) + s.Equal(tc.expectMode, mode) + if tc.expectMode == flowInitiationAttestation { + s.NotNil(attestation) + } + }) + } +} + // --- checkDirectFlowInitiationAllowed --- // A new flow is rejected at initiation when the app is classified as RedirectOnly (an @@ -2769,8 +2869,11 @@ func (s *ServiceTestSuite) TestCheckDirectFlowInitiationAllowed_NonAuthFlowAllow // attestationClient returns an inbound client configured with Android attestation, holding an // already-encrypted service account credential. func attestationClient() *providers.InboundClient { - return &providers.InboundClient{ + client := &providers.InboundClient{ ID: "mobile-app", + Properties: map[string]interface{}{ + applicationTypePropertyKey: string(model.ApplicationTypeMobile), + }, Attestation: &providers.AttestationConfig{ Android: &providers.AndroidAttestationConfig{ PackageName: "com.example.app", @@ -2778,6 +2881,31 @@ func attestationClient() *providers.InboundClient { }, }, } + return client +} + +// A mobile app that has not configured attestation cannot initiate a flow. +func (s *ServiceTestSuite) TestCheckDirectFlowInitiationAllowed_AttestationNotConfigured() { + t := s.T() + mockActorProvider := actorprovidermock.NewActorProviderMock(t) + mobileClient := &providers.InboundClient{ + ID: "mobile-app", + Properties: map[string]interface{}{ + applicationTypePropertyKey: string(model.ApplicationTypeMobile), + }, + } + mockActorProvider.EXPECT().GetInboundClientByID(mock.Anything, "mobile-app").Return(mobileClient, nil) + + service := &flowExecService{ + actorProvider: mockActorProvider, + attestationVerifier: attestationprovidermock.NewAttestationProviderMock(t), + cfg: testFlowExecCfg, + } + + svcErr := service.checkDirectFlowInitiationAllowed(context.Background(), "mobile-app", + providers.FlowTypeAuthentication, "", "", log.GetLogger()) + s.NotNil(svcErr) + s.Equal(ErrorAttestationNotConfigured.Code, svcErr.Code) } // A mobile app with attestation configured but no token presented is rejected before any @@ -2871,12 +2999,16 @@ func (s *ServiceTestSuite) TestCheckDirectFlowInitiationAllowed_AttestationValid // appleAttestationClient returns an inbound client configured with Apple App Attest attestation. func appleAttestationClient() *providers.InboundClient { - return &providers.InboundClient{ + client := &providers.InboundClient{ ID: "mobile-app", + Properties: map[string]interface{}{ + applicationTypePropertyKey: string(model.ApplicationTypeMobile), + }, Attestation: &providers.AttestationConfig{ Apple: &providers.AppleAttestationConfig{TeamID: "TEAM123", BundleID: "com.example.app"}, }, } + return client } // An Apple-configured client also resolves to attestation-based flow initiation, and a token verified diff --git a/backend/internal/oauth/oauth2/dcr/service.go b/backend/internal/oauth/oauth2/dcr/service.go index 7201a96f4e..754aac3973 100644 --- a/backend/internal/oauth/oauth2/dcr/service.go +++ b/backend/internal/oauth/oauth2/dcr/service.go @@ -265,9 +265,12 @@ func (ds *dcrService) convertDCRToApplication(request *DCRRegistrationRequest) ( } appDTO := &model.ApplicationDTO{ - ID: appID, - OUID: request.OUID, - Name: appName, + ID: appID, + OUID: request.OUID, + Name: appName, + // Dynamic Client Registration (RFC 7591) has no concept of ThunderID's application type, and + // a DCR-registered client can take any shape, so it is always registered as custom. + Type: model.ApplicationTypeCustom, URL: request.ClientURI, LogoURL: request.LogoURI, InboundAuthConfig: inboundAuthConfig, diff --git a/backend/internal/system/i18n/core/defaults.go b/backend/internal/system/i18n/core/defaults.go index b041681551..3e90afef1a 100644 --- a/backend/internal/system/i18n/core/defaults.go +++ b/backend/internal/system/i18n/core/defaults.go @@ -151,6 +151,10 @@ var defaultMessages = map[string]string{ "error.applicationservice.application_is_nil_description": "The provided application object is nil", "error.applicationservice.application_not_found": "Application not found", "error.applicationservice.application_not_found_description": "The requested application could not be found", + "error.applicationservice.application_type_immutable": "Application type cannot be changed", + "error.applicationservice.application_type_immutable_description": "The application type is set at creation and cannot be modified.", + "error.applicationservice.application_type_required": "Application type is required", + "error.applicationservice.application_type_required_description": "An application type must be provided. It must be one of: browser, fullstack, mobile, m2m, custom.", "error.applicationservice.application_with_client_id_already_exists": "Application with client ID already exists", "error.applicationservice.application_with_client_id_already_exists_description": "An application with the same client ID already exists", "error.applicationservice.auth_code_requires_code_response_type_description": "authorization_code grant type requires 'code' response type", @@ -180,6 +184,8 @@ var defaultMessages = map[string]string{ "error.applicationservice.invalid_application_id_description": "The provided application ID is invalid or empty", "error.applicationservice.invalid_application_name": "Invalid application name", "error.applicationservice.invalid_application_name_description": "The provided application name is invalid or empty", + "error.applicationservice.invalid_application_type": "Invalid application type", + "error.applicationservice.invalid_application_type_description": "The provided application type is not supported. It must be one of: browser, fullstack, mobile, m2m, custom.", "error.applicationservice.invalid_application_url": "Invalid application URL", "error.applicationservice.invalid_application_url_description": "The provided application URL is not a valid URI", "error.applicationservice.invalid_auth_flow_id": "Invalid auth flow ID", @@ -533,6 +539,8 @@ var defaultMessages = map[string]string{ "error.flowexecservice.application_retrieval_error_description": "Error while retrieving application details", "error.flowexecservice.attestation_invalid": "Authentication failed", "error.flowexecservice.attestation_invalid_description": "The provided attestation token is invalid", + "error.flowexecservice.attestation_not_configured": "Attestation not configured", + "error.flowexecservice.attestation_not_configured_description": "Mobile applications must configure platform attestation to initiate a flow", "error.flowexecservice.attestation_required": "Authentication required", "error.flowexecservice.attestation_required_description": "Mobile applications must present a valid attestation token to initiate a new flow", "error.flowexecservice.direct_flow_initiation_not_permitted": "Direct flow initiation not permitted", diff --git a/backend/internal/system/importer/service.go b/backend/internal/system/importer/service.go index a05c2e4c0a..4e8bf1476c 100644 --- a/backend/internal/system/importer/service.go +++ b/backend/internal/system/importer/service.go @@ -971,6 +971,7 @@ func applicationRequestToDTO(req *appmodel.ApplicationRequestWithID) *appmodel.A LoginConsent: req.LoginConsent, AllowedUserTypes: req.AllowedUserTypes, }, + Type: req.Type, Template: req.Template, FlowSecret: req.FlowSecret, URL: req.URL, diff --git a/backend/internal/system/importer/service_test.go b/backend/internal/system/importer/service_test.go index 0bf398d92e..28cc73509c 100644 --- a/backend/internal/system/importer/service_test.go +++ b/backend/internal/system/importer/service_test.go @@ -2307,6 +2307,28 @@ func TestImportResources_ApplicationOUHandlePassedToService(t *testing.T) { assert.Equal(t, "default", appSvc.created[0].OUHandle) } +func TestImportResources_ApplicationTypePassedToService(t *testing.T) { + appSvc := &fakeApplicationService{existing: map[string]*providers.Application{}} + svc := newImportService( + appSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + ) + + resp, err := svc.ImportResources(context.Background(), &ImportRequest{ + Content: strings.Join([]string{ + "resource_type: application", + "name: My App", + "type: browser", + "", + }, "\n"), + }) + + require.Nil(t, err) + require.Len(t, resp.Results, 1) + assert.Equal(t, statusSuccess, resp.Results[0].Status) + require.Len(t, appSvc.created, 1) + assert.Equal(t, model.ApplicationTypeBrowser, appSvc.created[0].Type) +} + func TestImportResources_ApplicationAuthFlowHandlePassedToService(t *testing.T) { appSvc := &fakeApplicationService{existing: map[string]*providers.Application{}} svc := newImportService( diff --git a/backend/pkg/thunderidengine/providers/model.go b/backend/pkg/thunderidengine/providers/model.go index f9b14390aa..465266d1a7 100644 --- a/backend/pkg/thunderidengine/providers/model.go +++ b/backend/pkg/thunderidengine/providers/model.go @@ -989,6 +989,7 @@ type Application struct { OUID string `yaml:"ouId,omitempty" json:"ouId,omitempty" jsonschema:"Organization unit ID. The OU this application belongs to."` Name string `yaml:"name,omitempty" json:"name,omitempty" jsonschema:"Application name."` Description string `yaml:"description,omitempty" json:"description,omitempty" jsonschema:"Optional description of the application's purpose."` + Type string `yaml:"type,omitempty" json:"type,omitempty" jsonschema:"Application type (browser, fullstack, mobile, m2m, custom)."` Template string `yaml:"template,omitempty" json:"template,omitempty" jsonschema:"Template used to create the application."` URL string `yaml:"url,omitempty" json:"url,omitempty" jsonschema:"Application home URL."` diff --git a/docs/content/guides/applications/application-settings.mdx b/docs/content/guides/applications/application-settings.mdx index ee4f4d63ee..a4696c4e5d 100644 --- a/docs/content/guides/applications/application-settings.mdx +++ b/docs/content/guides/applications/application-settings.mdx @@ -116,7 +116,7 @@ This section applies to OAuth 2.0 / OIDC applications only. The **Advanced Settings** tab shows the OAuth 2.0 settings configured at creation. | Setting | Description | |---------|-------------| -| **Grant Types** | The OAuth 2.0 flows this application can use. Supported values: `authorization_code`, `refresh_token`, `client_credentials`, `urn:ietf:params:oauth:grant-type:token-exchange`. Applications configured with `authorization_code` cannot initiate new flows directly via `POST /flow/execute`. They must begin authentication through the Authorization endpoint (`GET /oauth2/authorize`), which initiates the flow internally. | +| **Grant Types** | The OAuth 2.0 flows this application can use. Supported values: `authorization_code`, `refresh_token`, `client_credentials`, `urn:ietf:params:oauth:grant-type:token-exchange`, `urn:ietf:params:oauth:grant-type:jwt-bearer`. Whether this application can initiate flows directly via `POST /flow/execute` depends on its [application type](../manage-applications#application-types) and, for Full-stack and Custom applications, its grant configuration. See [Direct Initiation Restriction](../../key-concepts/authentication/integration-models.mdx#app-native) for the full rule. | | **Response Types** | The response types the application can request (for example, `code`). | | **Token Endpoint Auth Method** | How the application authenticates at the token endpoint: `client_secret_basic` (default), `client_secret_post`, `private_key_jwt`, or `none` (public clients). | | **Public Client** | Whether this is a public client that cannot securely store a client secret. | diff --git a/docs/content/guides/applications/manage-applications.mdx b/docs/content/guides/applications/manage-applications.mdx index 974da182c6..a282256383 100644 --- a/docs/content/guides/applications/manage-applications.mdx +++ b/docs/content/guides/applications/manage-applications.mdx @@ -22,14 +22,19 @@ OAuth 2.0 applications additionally hold: ## Application Types - provides four application types. Choose the one that matches your architecture. + provides five application types. Choose the one that matches your architecture. | Type | Description | Client Secret | |------|-------------|---------------| -| ⚛️ **Browser App** | For single-page applications (React, Angular, Vue) that run entirely in the browser.| No - public client | -| 🌐 **Full-stack App** | For server-rendered web applications (Express, Spring, .NET) where the backend handles the token exchange. | Yes - confidential client | -| 📱 **Mobile App** | For native or cross-platform mobile apps (Swift, Kotlin, React Native, Flutter).| No - public client | -| 🤖 **Backend Service** | For server-to-server communication with no user interaction (background jobs, internal APIs, microservices). | Yes - confidential client | +| **Browser App** | For single-page applications (React, Angular, Vue) that run entirely in the browser.| No - public client | +| **Full-stack App** | For server-rendered web applications (Express, Spring, .NET) where the backend handles the token exchange. | Yes - confidential client | +| **Mobile App** | For native or cross-platform mobile apps (Swift, Kotlin, React Native, Flutter).| No - public client | +| **Backend Service** | For server-to-server communication with no user interaction (background jobs, internal APIs, microservices). | Yes - confidential client | +| **Custom App** | For applications that do not fit the other types, with full control over configuration. | Depends on configuration | + +The application type determines whether issues a Flow Secret and whether the application can call the Flow Execution API directly to sign users in. See [Integration Models](../../key-concepts/authentication/integration-models.mdx#app-native) for how each type behaves. + +You set the application type when you create the application, and you cannot change it afterward. If you're creating applications through the REST API or a declarative resource instead of the Console, provide the `type` attribute with one of `browser`, `fullstack`, `mobile`, `m2m`, or `custom`; the API rejects a create request that omits it. ## Create an Application diff --git a/docs/content/guides/declarative-configurations/import-resources.mdx b/docs/content/guides/declarative-configurations/import-resources.mdx index cf80479804..6d40879160 100644 --- a/docs/content/guides/declarative-configurations/import-resources.mdx +++ b/docs/content/guides/declarative-configurations/import-resources.mdx @@ -12,6 +12,10 @@ description: Import declarative YAML resources into runtime stores and control m Declarative resource attributes use camelCase (for example `ouId`, `authFlowId`, `clientId`), matching the REST API. ::: +:::note +An application resource requires a `type` attribute (`browser`, `fullstack`, `mobile`, `m2m`, or `custom`). The type is set on creation and does not change on a later import. See [Application Types](../applications/manage-applications.mdx#application-types). +::: + Use this API when you want to: - Bootstrap resources from a YAML bundle. @@ -104,7 +108,7 @@ curl -X POST "https://localhost:8090/import" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ - "content": "resource_type: application\nname: Console\nauthFlowId: {{.AUTH_FLOW_ID}}\n", + "content": "resource_type: application\nname: Console\ntype: browser\nauthFlowId: {{.AUTH_FLOW_ID}}\n", "variables": { "AUTH_FLOW_ID": "edc013d0-e893-4dc0-990c-3e1d203e005b" }, @@ -140,6 +144,7 @@ If a handle cannot be resolved (because the target resource does not exist or th ```yaml resource_type: application name: My App +type: fullstack ouHandle: default authFlowHandle: default-basic-flow registrationFlowHandle: default-registration-flow diff --git a/docs/content/key-concepts/authentication/integration-models.mdx b/docs/content/key-concepts/authentication/integration-models.mdx index e881075a2a..eb4ea31ea4 100644 --- a/docs/content/key-concepts/authentication/integration-models.mdx +++ b/docs/content/key-concepts/authentication/integration-models.mdx @@ -52,13 +52,17 @@ The core principle is **shared responsibility**: enforces authen Choose this approach when your application must render its own authentication screens (no redirects to hosted pages) while enforces authentication logic, MFA, and policies server-side. This model suits native mobile and desktop apps, or confidential server-side applications, where the client can be trusted to drive the flow directly. :::warning -App-native **sign-in** is **not supported for browser-based single-page applications (SPAs)** such as React, Angular, or Vue apps. SPAs are public clients that cannot be verified when calling the Flow Execution API directly, so they must sign users in through the [Redirect-Based](#redirect-based) model with the OAuth 2.0 Authorization Code grant and PKCE. Registration and recovery have no redirect-based equivalent today, so SPAs continue to drive those flows through the Flow Execution API. +App-native **sign-in** is **not supported for browser-based single-page applications (SPAs)** such as React, Angular, or Vue apps. SPAs must register as the `browser` [application type](../../guides/applications/manage-applications.mdx#application-types), which can only sign users in through the [Redirect-Based](#redirect-based) model with the OAuth 2.0 Authorization Code grant and PKCE. Registration and recovery have no redirect-based equivalent today, so SPAs continue to drive those flows through the Flow Execution API. ::: -:::note[Grant Type Requirement] -This restriction applies to **authentication** flows only. The Flow Execution API rejects a new authentication-flow initiation request (`flowType: AUTHENTICATION` with no `executionId`) from an application configured with the `authorization_code` grant type. Such applications must initiate authentication through the [Authorization endpoint](../../../apis#tag/authorization) (`GET /oauth2/authorize`), which then drives the flow internally. +:::note[Direct Initiation Restriction] +This restriction applies to new **authentication** and **sign-out** flow initiation only (`flowType: AUTHENTICATION` or `SIGN_OUT` with no `executionId`). Continuation requests that carry a valid `executionId` are always accepted, and registration, recovery, and user onboarding flows are never restricted because they have no redirect-based equivalent. -Registration, recovery, and user onboarding flows are **not** restricted because they have no redirect-based equivalent and can still be initiated directly through the Flow Execution API regardless of grant type. Continuation requests that carry a valid `executionId` are also always accepted. +Whether an application may initiate one of these flows directly depends on its [application type](../../guides/applications/manage-applications.mdx#application-types): + +- **Browser** and **Backend Service (m2m)** applications can never initiate these flows directly. A browser application must sign users in through the [Authorization endpoint](../../../apis#tag/authorization) (`GET /oauth2/authorize`), which drives the flow internally. A Backend Service application authenticates itself with the `client_credentials` grant instead of signing in a user. +- **Mobile** applications must present a valid platform attestation token. See [Attestation](#attestation). +- **Full-stack** and **Custom** applications are derived from their OAuth 2.0 configuration. An application with no OAuth 2.0 configuration, or one that is confidential and does not use the `authorization_code` grant, authenticates with a Flow Secret. An application configured with the `authorization_code` grant, or with `client_credentials` as its only grant, cannot initiate the flow directly. ::: ### Key Features @@ -82,9 +86,9 @@ See the [API Reference](../../../apis#tag/flow-execution) for endpoint specifica ### Attestation -A confidential server-side application authenticates at flow initiation with a Flow Secret, but a mobile application cannot safely hold one. Instead, a mobile client proves its identity through platform attestation. supports Google [Play Integrity](https://developer.android.com/google/play/integrity) for Android clients and Apple [App Attest](https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server) for iOS clients. When an application configures attestation, it may initiate a sign-in flow directly by presenting a platform attestation token, which verifies against the registered identity before starting the flow. +A confidential server-side application authenticates at flow initiation with a Flow Secret, but a mobile application cannot safely hold one. Instead, a **Mobile** [application type](../../guides/applications/manage-applications.mdx#application-types) proves its identity through platform attestation. supports Google [Play Integrity](https://developer.android.com/google/play/integrity) for Android clients and Apple [App Attest](https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server) for iOS clients. A Mobile application must configure attestation and present a valid attestation token to initiate a sign-in flow directly; verifies the token against the registered identity before starting the flow. -Attestation is a client-level setting configured at the application level, independent of the OAuth 2.0 protocol, so it applies to any application type, including embedded apps with no OAuth 2.0 configuration. When attestation is configured, it takes precedence: the application must present a valid attestation token to initiate a flow, and any Flow Secret is ignored. Attestation is optional; when enabled, an application configures exactly one platform. See [Configure Platform Attestation](../../guides/applications/application-settings.mdx#configure-platform-attestation) for how to configure it. +Attestation is a client-level setting configured at the application level, independent of the OAuth 2.0 protocol. It is optional, and when enabled, an application configures exactly one platform. A Mobile application that has not configured attestation is rejected outright when it attempts to initiate a flow directly. Attestation is only consulted for the Mobile application type: configuring it on any other application type has no effect on direct flow initiation. See [Configure Platform Attestation](../../guides/applications/application-settings.mdx#configure-platform-attestation) for how to configure it. To initiate a new flow, the mobile client obtains a platform attestation token and presents it in the `Attestation-Token` request header of the Flow Execution request: @@ -99,7 +103,7 @@ Attestation-Token: } ``` -An attested mobile app that omits the token is rejected with `401 Unauthorized`, and an invalid or malformed token is also rejected with `401 Unauthorized`. Flow **continuation** requests (those carrying an `executionId`) do not require an attestation token. +A Mobile application that has not configured attestation is rejected with `400 Bad Request`. Once attestation is configured, an application that omits the token is rejected with `401 Unauthorized`, and an invalid or malformed token is also rejected with `401 Unauthorized`. Flow **continuation** requests (those carrying an `executionId`) do not require an attestation token. ### Integration Modes diff --git a/frontend/apps/console/src/features/applications/components/create-application/ConfigureExperience.tsx b/frontend/apps/console/src/features/applications/components/create-application/ConfigureExperience.tsx index af2869b067..df163026e5 100644 --- a/frontend/apps/console/src/features/applications/components/create-application/ConfigureExperience.tsx +++ b/frontend/apps/console/src/features/applications/components/create-application/ConfigureExperience.tsx @@ -1,5 +1,5 @@ /** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * Copyright (c) 2025-2026, WSO2 LLC. (https://www.wso2.com). * * WSO2 LLC. licenses this file to you under the Apache License, * Version 2.0 (the "License"); you may not use this file except @@ -32,8 +32,9 @@ import { Autocomplete, TextField, Grid, + Alert, } from '@wso2/oxygen-ui'; -import {ExternalLink, Code, User} from '@wso2/oxygen-ui-icons-react'; +import {ExternalLink, Code, User, Info} from '@wso2/oxygen-ui-icons-react'; import type {JSX, ChangeEvent} from 'react'; import {useEffect} from 'react'; import {useTranslation} from 'react-i18next'; @@ -62,6 +63,13 @@ export interface ConfigureExperienceProps { */ allowEmbeddedApproach?: boolean; + /** + * Whether choosing the embedded approach requires configuring platform attestation. Mobile apps + * authenticate to the Flow Execution API via attestation rather than a flow secret, so a notice is + * shown when they select the embedded approach. Defaults to false. + */ + embeddedRequiresAttestation?: boolean; + /** * Callback function to broadcast whether this step is ready to proceed */ @@ -127,6 +135,7 @@ export default function ConfigureExperience({ selectedApproach, onApproachChange, allowEmbeddedApproach = true, + embeddedRequiresAttestation = false, onReadyChange = undefined, userTypes = [], selectedUserTypes = [], @@ -295,6 +304,14 @@ export default function ConfigureExperience({ )} + + {allowEmbeddedApproach && + embeddedRequiresAttestation && + selectedApproach === ApplicationCreateFlowSignInApproach.EMBEDDED && ( + }> + {t('applications:onboarding.configure.approach.native.attestationNotice')} + + )} diff --git a/frontend/apps/console/src/features/applications/components/create-application/__tests__/ConfigureExperience.test.tsx b/frontend/apps/console/src/features/applications/components/create-application/__tests__/ConfigureExperience.test.tsx index fa04987d71..105573ae81 100644 --- a/frontend/apps/console/src/features/applications/components/create-application/__tests__/ConfigureExperience.test.tsx +++ b/frontend/apps/console/src/features/applications/components/create-application/__tests__/ConfigureExperience.test.tsx @@ -106,6 +106,47 @@ describe('ConfigureExperience', () => { expect(mockOnApproachChange).toHaveBeenCalledWith(ApplicationCreateFlowSignInApproach.INBUILT); }); + + it('should show the attestation notice for mobile apps when embedded is selected', () => { + render( + , + ); + + expect( + screen.getByText('applications:onboarding.configure.approach.native.attestationNotice'), + ).toBeInTheDocument(); + }); + + it('should not show the attestation notice when the inbuilt approach is selected', () => { + render( + , + ); + + expect( + screen.queryByText('applications:onboarding.configure.approach.native.attestationNotice'), + ).not.toBeInTheDocument(); + }); + + it('should not show the attestation notice when attestation is not required', () => { + render( + , + ); + + expect( + screen.queryByText('applications:onboarding.configure.approach.native.attestationNotice'), + ).not.toBeInTheDocument(); + }); }); describe('User Interactions', () => { diff --git a/frontend/apps/console/src/features/applications/components/edit-application/general-settings/EditGeneralSettings.tsx b/frontend/apps/console/src/features/applications/components/edit-application/general-settings/EditGeneralSettings.tsx index e074b342d7..6575a01d38 100644 --- a/frontend/apps/console/src/features/applications/components/edit-application/general-settings/EditGeneralSettings.tsx +++ b/frontend/apps/console/src/features/applications/components/edit-application/general-settings/EditGeneralSettings.tsx @@ -27,6 +27,7 @@ import QuickCopySection from './QuickCopySection'; import type {Application} from '../../../models/application'; import {TokenEndpointAuthMethods} from '../../../models/oauth'; import type {OAuth2Config} from '../../../models/oauth'; +import resolveApplicationType from '../../../utils/resolveApplicationType'; import ApplicationDeleteDialog from '../../ApplicationDeleteDialog'; import ClientSecretSuccessDialog from '../../ClientSecretSuccessDialog'; import RegenerateFlowSecretDialog from '../../RegenerateFlowSecretDialog'; @@ -111,13 +112,15 @@ export default function EditGeneralSettings({ oauth2Config?.tokenEndpointAuthMethod === TokenEndpointAuthMethods.CLIENT_SECRET_BASIC || oauth2Config?.tokenEndpointAuthMethod === TokenEndpointAuthMethods.CLIENT_SECRET_POST; - // Only flow-native apps are issued a Flow Secret and can rotate it: embedded apps with no OAuth - // profile, or confidential non-redirect apps. Public, redirect (authorization_code), and - // machine-to-machine (client_credentials as the only grant) apps get no Flow Secret. + // Only flow-native apps are issued a Flow Secret and can rotate it: full-stack or custom apps + // using the embedded (non-redirect) sign-in option. Browser (public redirect), mobile + // (attestation), and m2m (direct token) apps never hold one. The canonical application type is the + // discriminator, falling back to the OAuth config shape for legacy/custom apps. + const resolvedType = resolveApplicationType(application.type, oauth2Config); const grantTypes = oauth2Config?.grantTypes ?? []; - const isM2MClient = grantTypes.length === 1 && grantTypes[0] === 'client_credentials'; const isFlowNativeClient = - !oauth2Config || (!oauth2Config.publicClient && !grantTypes.includes('authorization_code') && !isM2MClient); + (resolvedType === 'fullstack' || resolvedType === 'custom') && + (!oauth2Config || (!oauth2Config.publicClient && !grantTypes.includes('authorization_code'))); const handleRegenerateClick = useCallback((): void => { setRegenerateDialogOpen(true); diff --git a/frontend/apps/console/src/features/applications/components/edit-application/mcp/McpConnectTab.tsx b/frontend/apps/console/src/features/applications/components/edit-application/mcp/McpConnectTab.tsx index 62becc915c..c5e49d551b 100644 --- a/frontend/apps/console/src/features/applications/components/edit-application/mcp/McpConnectTab.tsx +++ b/frontend/apps/console/src/features/applications/components/edit-application/mcp/McpConnectTab.tsx @@ -24,10 +24,9 @@ import {useCallback, useState} from 'react'; import {useTranslation} from 'react-i18next'; import McpAccessSection from './McpAccessSection'; import type {Application} from '../../../models/application'; -import {McpClientTypes} from '../../../models/mcp-client'; import {TokenEndpointAuthMethods} from '../../../models/oauth'; import type {OAuth2Config} from '../../../models/oauth'; -import deriveMcpClientType from '../../../utils/deriveMcpClientType'; +import resolveApplicationType from '../../../utils/resolveApplicationType'; import ApplicationDeleteDialog from '../../ApplicationDeleteDialog'; import ClientSecretSuccessDialog from '../../ClientSecretSuccessDialog'; import CopyableField from '../../common/CopyableField'; @@ -126,20 +125,22 @@ export default function McpConnectTab({ const [flowSecretDialogOpen, setFlowSecretDialogOpen] = useState(false); const [newFlowSecret, setNewFlowSecret] = useState(''); - const clientType = deriveMcpClientType(oauth2Config?.grantTypes); - const isM2m = clientType === McpClientTypes.M2M; + // The canonical application type (explicit type, falling back to config shape) drives the + // client-type badge, rather than inferring M2M from grant shape. + const resolvedType = resolveApplicationType(application.type, oauth2Config); + const isM2m = resolvedType === 'm2m'; const isConfidentialClient = oauth2Config?.tokenEndpointAuthMethod === TokenEndpointAuthMethods.CLIENT_SECRET_BASIC || oauth2Config?.tokenEndpointAuthMethod === TokenEndpointAuthMethods.CLIENT_SECRET_POST; - // Only flow-native apps are issued a Flow Secret and can rotate it: embedded apps with no OAuth - // profile, or confidential non-redirect apps. Public, redirect (authorization_code), and - // machine-to-machine (client_credentials as the only grant) apps get no Flow Secret. + // Only flow-native apps are issued a Flow Secret and can rotate it: full-stack or custom apps + // using the embedded (non-redirect) sign-in option. Browser (public redirect) and m2m (direct + // token) apps never hold one, regardless of OAuth config shape. const grantTypes = oauth2Config?.grantTypes ?? []; - const isM2MClient = grantTypes.length === 1 && grantTypes[0] === 'client_credentials'; const isFlowNativeClient = - !oauth2Config || (!oauth2Config.publicClient && !grantTypes.includes('authorization_code') && !isM2MClient); + (resolvedType === 'fullstack' || resolvedType === 'custom') && + (!oauth2Config || (!oauth2Config.publicClient && !grantTypes.includes('authorization_code'))); const handleRegenerateClick = useCallback((): void => { setRegenerateDialogOpen(true); diff --git a/frontend/apps/console/src/features/applications/data/application-templates/platform-based/backend.json b/frontend/apps/console/src/features/applications/data/application-templates/platform-based/backend.json index 6c9ae94a4d..4797d024f1 100644 --- a/frontend/apps/console/src/features/applications/data/application-templates/platform-based/backend.json +++ b/frontend/apps/console/src/features/applications/data/application-templates/platform-based/backend.json @@ -1,5 +1,6 @@ { "id": "backend", + "type": "m2m", "displayName": "Backend", "description": "Machine-to-machine backend service", "creationFlow": { diff --git a/frontend/apps/console/src/features/applications/data/application-templates/platform-based/browser.json b/frontend/apps/console/src/features/applications/data/application-templates/platform-based/browser.json index 5aed6d9406..6d7c471769 100644 --- a/frontend/apps/console/src/features/applications/data/application-templates/platform-based/browser.json +++ b/frontend/apps/console/src/features/applications/data/application-templates/platform-based/browser.json @@ -1,5 +1,6 @@ { "id": "browser", + "type": "browser", "displayName": "Browser", "description": "Web application running in browser", "defaults": { diff --git a/frontend/apps/console/src/features/applications/data/application-templates/platform-based/custom.json b/frontend/apps/console/src/features/applications/data/application-templates/platform-based/custom.json index c11c52c2b9..80a2259b2d 100644 --- a/frontend/apps/console/src/features/applications/data/application-templates/platform-based/custom.json +++ b/frontend/apps/console/src/features/applications/data/application-templates/platform-based/custom.json @@ -1,5 +1,6 @@ { "id": "custom", + "type": "custom", "displayName": "Custom", "description": "Fully customizable application with all configuration options available", "creationFlow": { diff --git a/frontend/apps/console/src/features/applications/data/application-templates/platform-based/full-stack.json b/frontend/apps/console/src/features/applications/data/application-templates/platform-based/full-stack.json index 622db60422..cd71cff9f2 100644 --- a/frontend/apps/console/src/features/applications/data/application-templates/platform-based/full-stack.json +++ b/frontend/apps/console/src/features/applications/data/application-templates/platform-based/full-stack.json @@ -1,5 +1,6 @@ { "id": "server", + "type": "fullstack", "displayName": "Full-Stack", "description": "Apps with both server and client code", "defaults": { diff --git a/frontend/apps/console/src/features/applications/data/application-templates/platform-based/mobile.json b/frontend/apps/console/src/features/applications/data/application-templates/platform-based/mobile.json index b35fecdd64..23ad35deac 100644 --- a/frontend/apps/console/src/features/applications/data/application-templates/platform-based/mobile.json +++ b/frontend/apps/console/src/features/applications/data/application-templates/platform-based/mobile.json @@ -1,5 +1,6 @@ { "id": "mobile", + "type": "mobile", "displayName": "Mobile", "description": "Native mobile application", "defaults": { diff --git a/frontend/apps/console/src/features/applications/data/application-templates/platform-based/wallet.json b/frontend/apps/console/src/features/applications/data/application-templates/platform-based/wallet.json index 70035e2c76..3aa62d97a9 100644 --- a/frontend/apps/console/src/features/applications/data/application-templates/platform-based/wallet.json +++ b/frontend/apps/console/src/features/applications/data/application-templates/platform-based/wallet.json @@ -1,5 +1,6 @@ { "id": "wallet", + "type": "mobile", "displayName": "Digital Wallet", "description": "OpenID4VCI wallet that requests verifiable credentials", "creationFlow": { diff --git a/frontend/apps/console/src/features/applications/data/application-templates/technology-based/express.json b/frontend/apps/console/src/features/applications/data/application-templates/technology-based/express.json index 0bd4d0959f..a96174d625 100644 --- a/frontend/apps/console/src/features/applications/data/application-templates/technology-based/express.json +++ b/frontend/apps/console/src/features/applications/data/application-templates/technology-based/express.json @@ -1,5 +1,6 @@ { "id": "express", + "type": "fullstack", "displayName": "Express", "description": "Server-side Node.js application built with Express", "defaults": { diff --git a/frontend/apps/console/src/features/applications/data/application-templates/technology-based/mcp-client.json b/frontend/apps/console/src/features/applications/data/application-templates/technology-based/mcp-client.json index caf2396197..26b8145d20 100644 --- a/frontend/apps/console/src/features/applications/data/application-templates/technology-based/mcp-client.json +++ b/frontend/apps/console/src/features/applications/data/application-templates/technology-based/mcp-client.json @@ -1,5 +1,6 @@ { "id": "mcp-client", + "type": "browser", "displayName": "MCP Client", "description": "AI application that connects to MCP servers using the Model Context Protocol", "creationFlow": { diff --git a/frontend/apps/console/src/features/applications/data/application-templates/technology-based/nextjs.json b/frontend/apps/console/src/features/applications/data/application-templates/technology-based/nextjs.json index e734e7d06a..5d5682d502 100644 --- a/frontend/apps/console/src/features/applications/data/application-templates/technology-based/nextjs.json +++ b/frontend/apps/console/src/features/applications/data/application-templates/technology-based/nextjs.json @@ -1,5 +1,6 @@ { "id": "nextjs", + "type": "fullstack", "displayName": "Next.js", "description": "Server-side rendered application built with Next.js", "defaults": { diff --git a/frontend/apps/console/src/features/applications/data/application-templates/technology-based/node.json b/frontend/apps/console/src/features/applications/data/application-templates/technology-based/node.json index b1306f25b6..cc6ab51d31 100644 --- a/frontend/apps/console/src/features/applications/data/application-templates/technology-based/node.json +++ b/frontend/apps/console/src/features/applications/data/application-templates/technology-based/node.json @@ -1,5 +1,6 @@ { "id": "node", + "type": "fullstack", "displayName": "Node.js", "description": "Backend service built with Node.js", "creationFlow": { diff --git a/frontend/apps/console/src/features/applications/data/application-templates/technology-based/nuxt.json b/frontend/apps/console/src/features/applications/data/application-templates/technology-based/nuxt.json index c6e5c1a0ce..f80c204308 100644 --- a/frontend/apps/console/src/features/applications/data/application-templates/technology-based/nuxt.json +++ b/frontend/apps/console/src/features/applications/data/application-templates/technology-based/nuxt.json @@ -1,5 +1,6 @@ { "id": "nuxt", + "type": "fullstack", "displayName": "Nuxt", "description": "Full-stack Vue framework with server-side rendering", "defaults": { diff --git a/frontend/apps/console/src/features/applications/data/application-templates/technology-based/other.json b/frontend/apps/console/src/features/applications/data/application-templates/technology-based/other.json index 279a84d291..f291a73f26 100644 --- a/frontend/apps/console/src/features/applications/data/application-templates/technology-based/other.json +++ b/frontend/apps/console/src/features/applications/data/application-templates/technology-based/other.json @@ -1,5 +1,6 @@ { "id": "other", + "type": "browser", "displayName": "Other", "description": "Custom application with standard OAuth2 configuration", "defaults": { diff --git a/frontend/apps/console/src/features/applications/data/application-templates/technology-based/react.json b/frontend/apps/console/src/features/applications/data/application-templates/technology-based/react.json index d0976dc21c..17ee086bdf 100644 --- a/frontend/apps/console/src/features/applications/data/application-templates/technology-based/react.json +++ b/frontend/apps/console/src/features/applications/data/application-templates/technology-based/react.json @@ -1,5 +1,6 @@ { "id": "react", + "type": "browser", "displayName": "React", "description": "Single Page Application built with React", "defaults": { diff --git a/frontend/apps/console/src/features/applications/data/application-templates/technology-based/vanilla-js.json b/frontend/apps/console/src/features/applications/data/application-templates/technology-based/vanilla-js.json index 9985292dfa..5f4fba9e86 100644 --- a/frontend/apps/console/src/features/applications/data/application-templates/technology-based/vanilla-js.json +++ b/frontend/apps/console/src/features/applications/data/application-templates/technology-based/vanilla-js.json @@ -1,5 +1,6 @@ { "id": "vanilla-js", + "type": "browser", "displayName": "JavaScript", "description": "Browser application built with vanilla JavaScript", "defaults": { diff --git a/frontend/apps/console/src/features/applications/data/application-templates/technology-based/vue.json b/frontend/apps/console/src/features/applications/data/application-templates/technology-based/vue.json index 187a909412..14e4550175 100644 --- a/frontend/apps/console/src/features/applications/data/application-templates/technology-based/vue.json +++ b/frontend/apps/console/src/features/applications/data/application-templates/technology-based/vue.json @@ -1,5 +1,6 @@ { "id": "vue", + "type": "browser", "displayName": "Vue", "description": "Single Page Application built with Vue 3", "defaults": { diff --git a/frontend/apps/console/src/features/applications/models/application-templates.ts b/frontend/apps/console/src/features/applications/models/application-templates.ts index 74b73060a4..3054d1a7e3 100644 --- a/frontend/apps/console/src/features/applications/models/application-templates.ts +++ b/frontend/apps/console/src/features/applications/models/application-templates.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * Copyright (c) 2025-2026, WSO2 LLC. (https://www.wso2.com). * * WSO2 LLC. licenses this file to you under the Apache License, * Version 2.0 (the "License"); you may not use this file except @@ -17,6 +17,7 @@ */ import type {JSX} from 'react'; +import type {ApplicationType} from './application'; import type {CreationFlow} from './creation-flow'; import type {InboundAuthConfig} from './inbound-auth'; import type {OAuth2Config} from './oauth'; @@ -179,6 +180,12 @@ export interface ApplicationTemplate { * Templates without a `creationFlow` use the default user-facing flow. */ creationFlow?: CreationFlow; + /** + * Canonical backend application type this template maps to. Sent as the application `type` on + * creation. Templates that can resolve to more than one type (e.g. MCP client) may override this + * at creation time based on user selections. + */ + type?: ApplicationType; /** * Description of the template */ diff --git a/frontend/apps/console/src/features/applications/models/application.ts b/frontend/apps/console/src/features/applications/models/application.ts index 37d9834d5d..d67d648266 100644 --- a/frontend/apps/console/src/features/applications/models/application.ts +++ b/frontend/apps/console/src/features/applications/models/application.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * Copyright (c) 2025-2026, WSO2 LLC. (https://www.wso2.com). * * WSO2 LLC. licenses this file to you under the Apache License, * Version 2.0 (the "License"); you may not use this file except @@ -20,6 +20,20 @@ import type {InboundAuthConfig} from './inbound-auth'; import type {AttestationConfig} from './oauth'; import type {AssertionConfig} from './token'; +/** + * Canonical application type (platform/client class). + * + * Mirrors the backend application `type` enum. Set at creation and immutable thereafter. + * - `browser`: public client, redirect-only (SPA). + * - `fullstack`: confidential client with a server component. + * - `mobile`: native/public client, uses platform attestation. + * - `m2m`: machine-to-machine, `client_credentials` only. + * - `custom`: no enforced constraints (escape hatch / legacy). + * + * @public + */ +export type ApplicationType = 'browser' | 'fullstack' | 'mobile' | 'm2m' | 'custom'; + /** * Application Response Model (Basic) * @@ -60,6 +74,7 @@ export type BasicApplication = Pick< | 'authFlowId' | 'registrationFlowId' | 'isRegistrationFlowEnabled' + | 'type' | 'template' | 'isReadOnly' > & { @@ -252,9 +267,17 @@ export interface Application { */ layoutId?: string; + /** + * Canonical application type (platform/client class). + * Set at creation and immutable thereafter. The backend uses it to apply type-specific behavior; + * prefer it over inferring the type from the OAuth config shape. + */ + type?: ApplicationType; + /** * Application template identifier - * Indicates which template was used to create this application + * Indicates which frontend template was used to create this application. Display metadata only, + * not used for behavior decisions (use `type` instead). * @example 'react', 'nextjs', 'browser', 'mobile' */ template?: string; diff --git a/frontend/apps/console/src/features/applications/pages/ApplicationCreatePage.tsx b/frontend/apps/console/src/features/applications/pages/ApplicationCreatePage.tsx index f77c4edca2..0f845fa51d 100644 --- a/frontend/apps/console/src/features/applications/pages/ApplicationCreatePage.tsx +++ b/frontend/apps/console/src/features/applications/pages/ApplicationCreatePage.tsx @@ -1,5 +1,5 @@ /** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * Copyright (c) 2025-2026, WSO2 LLC. (https://www.wso2.com). * * WSO2 LLC. licenses this file to you under the Apache License, * Version 2.0 (the "License"); you may not use this file except @@ -45,7 +45,7 @@ import McpConnectComplete from '../components/create-application/mcp/McpConnectC import ShowClientSecret from '../components/create-application/ShowClientSecret'; import TemplateConstants from '../constants/template-constants'; import useApplicationCreate from '../contexts/ApplicationCreate/useApplicationCreate'; -import type {Application} from '../models/application'; +import type {Application, ApplicationType} from '../models/application'; import { ApplicationCreateFlowConfiguration, ApplicationCreateFlowSignInApproach, @@ -56,6 +56,7 @@ import {McpClientTypes} from '../models/mcp-client'; import {OAuth2GrantTypes, TokenEndpointAuthMethods, type OAuth2Config} from '../models/oauth'; import type {CreateApplicationRequest} from '../models/requests'; import getConfigurationTypeFromTemplate from '../utils/getConfigurationTypeFromTemplate'; +import resolveApplicationType from '../utils/resolveApplicationType'; import resolveCreationFlow from '../utils/resolveCreationFlow'; import GatePreview from '@/components/GatePreview/GatePreview'; import buildPreviewMock from '@/components/GatePreview/mocks/buildPreviewMock'; @@ -222,22 +223,17 @@ export default function ApplicationCreatePage(): JSX.Element { const creationFlow = useMemo(() => resolveCreationFlow(selectedTemplateConfig), [selectedTemplateConfig]); - // Browser-based SPAs are public clients that must use the redirect-based flow, so the - // embedded (native) sign-in approach is not offered for them. Native mobile apps and digital - // wallets are also public clients but legitimately use app-native flows, so they are excluded - // from this rule. - const isBrowserSpaTemplate = useMemo((): boolean => { - if ( - selectedPlatform === PlatformApplicationTemplate.MOBILE || - selectedPlatform === PlatformApplicationTemplate.WALLET - ) { - return false; - } - const oauthConfig = selectedTemplateConfig?.defaults?.inboundAuthConfig?.find( - (config) => config.type === 'oauth2', - )?.config; - return oauthConfig?.publicClient === true; - }, [selectedTemplateConfig, selectedPlatform]); + // The canonical application type, resolved once from the template, falling back to the OAuth + // profile for legacy/custom templates without an explicit type. + const resolvedApplicationType = useMemo( + () => resolveApplicationType(selectedTemplateConfig?.type, oauthConfig ?? undefined), + [selectedTemplateConfig, oauthConfig], + ); + + // The embedded (native) sign-in approach is only supported by full-stack and mobile applications. + // Browser (SPA) apps are public clients that must use the redirect-based flow, and m2m apps never + // reach this step. Derived from the canonical application type rather than the OAuth config shape. + const allowEmbeddedApproach = resolvedApplicationType === 'fullstack' || resolvedApplicationType === 'mobile'; const needsConfigure = useMemo((): boolean => { const isPasskeyEnabled = !selectedAuthFlow && (integrations[AuthenticatorTypes.PASSKEY] ?? false); @@ -304,6 +300,11 @@ export default function ApplicationCreatePage(): JSX.Element { ? `${templateId}${TemplateConstants.EMBEDDED_SUFFIX}` : templateId; + // The canonical application type resolved above. The MCP client template can resolve to two + // types, so the machine-to-machine selection overrides it. + const applicationType: ApplicationType = + isMcpClientTemplate && mcpClientType === McpClientTypes.M2M ? 'm2m' : resolvedApplicationType; + // The mcp-client template branches the oauth2 config off the template-seeded config (never // rebuilt from scratch): user-delegated keeps the seeded authorization_code/refresh_token/PKCE/ // public-client shape and adds the collected redirect URIs; machine-to-machine overrides it to a @@ -328,6 +329,7 @@ export default function ApplicationCreatePage(): JSX.Element { ...(hostingUrl && {url: hostingUrl}), ...(authFlowId && {authFlowId}), ...(effectiveOuId && {ouId: effectiveOuId}), + ...(applicationType && {type: applicationType}), ...(finalTemplateId && {template: finalTemplateId}), ...(includesDesign && { logoUrl: appLogo ?? undefined, @@ -578,7 +580,8 @@ export default function ApplicationCreatePage(): JSX.Element { seed(null, 'WALLET', { id: 'wallet', + type: 'mobile', creationFlow: { steps: ['NAME', 'ORGANIZATION_UNIT', 'CONFIGURE', 'DESIGN', 'OPTIONS', 'EXPERIENCE', 'COMPLETE'], }, @@ -431,6 +432,7 @@ function TemplateSeeder(): JSX.Element { onClick={() => seed(null, 'BROWSER', { id: 'browser', + type: 'browser', defaults: { inboundAuthConfig: [ { @@ -444,6 +446,32 @@ function TemplateSeeder(): JSX.Element { > Select Browser +