From daf07d2a55ff263702b86727006f0bb86910194b Mon Sep 17 00:00:00 2001 From: Nuthara Andrahennedi Date: Fri, 24 Jul 2026 15:07:03 +0530 Subject: [PATCH] Move passkey origins to application-level configuration --- api/application.yaml | 23 ++ backend/cmd/server/servicemanager.go | 2 +- backend/internal/actorprovider/utils.go | 11 +- .../application/declarative_resource.go | 1 + .../declarative_resource_internal_test.go | 28 +++ backend/internal/application/handler.go | 5 + backend/internal/application/handler_test.go | 63 +++++ backend/internal/application/init.go | 4 +- backend/internal/application/init_test.go | 4 + backend/internal/application/service.go | 92 ++++++- backend/internal/application/service_test.go | 90 +++++++ backend/internal/authn/passkey/model.go | 4 + backend/internal/authn/passkey/service.go | 8 +- backend/internal/authn/passkey/utils.go | 9 + backend/internal/authn/passkey/utils_test.go | 22 ++ .../flow/executor/passkey_executor.go | 4 + .../flow/executor/passkey_executor_test.go | 109 +++++++++ backend/internal/inboundclient/store.go | 23 +- backend/internal/inboundclient/store_test.go | 43 +++- backend/internal/serverconfig/service.go | 5 + backend/internal/serverconfig/service_test.go | 1 + .../pkg/thunderidengine/providers/model.go | 6 +- .../EditAdvancedSettings.tsx | 21 +- .../advanced-settings/PasskeysSection.tsx | 173 +++++++++++++ .../__tests__/EditAdvancedSettings.test.tsx | 152 ++++++++++++ .../__tests__/PasskeysSection.test.tsx | 228 ++++++++++++++++++ .../applications/models/application.ts | 7 + frontend/packages/i18n/src/locales/en-US.ts | 10 + 28 files changed, 1116 insertions(+), 32 deletions(-) create mode 100644 frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/PasskeysSection.tsx create mode 100644 frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/PasskeysSection.test.tsx diff --git a/api/application.yaml b/api/application.yaml index 936243ce70..89ebb81a6f 100644 --- a/api/application.yaml +++ b/api/application.yaml @@ -116,6 +116,7 @@ paths: validityPeriod: 3600 userAttributes: ["email", "username"] allowedUserTypes: ["employee", "customer"] + passkeyAllowedOrigins: ["https://myapp.example.com", "https://myapp-staging.example.com"] loginConsent: validityPeriod: 3600 inboundAuthConfig: @@ -192,6 +193,7 @@ paths: validityPeriod: 3600 userAttributes: ["email", "username"] allowedUserTypes: ["employee", "customer"] + passkeyAllowedOrigins: ["https://myapp.example.com", "https://myapp-staging.example.com"] loginConsent: enabled: true validityPeriod: 3600 @@ -336,6 +338,7 @@ paths: validityPeriod: 3600 userAttributes: ["email", "username"] allowedUserTypes: ["employee", "customer"] + passkeyAllowedOrigins: ["https://myapp.example.com", "https://myapp-staging.example.com"] loginConsent: enabled: true validityPeriod: 3600 @@ -466,6 +469,7 @@ paths: validityPeriod: 3600 userAttributes: ["email", "username"] allowedUserTypes: ["employee", "customer"] + passkeyAllowedOrigins: ["https://myapp.example.com", "https://myapp-staging.example.com"] loginConsent: validityPeriod: 3600 inboundAuthConfig: @@ -543,6 +547,7 @@ paths: validityPeriod: 3600 userAttributes: ["email", "username"] allowedUserTypes: ["employee", "customer"] + passkeyAllowedOrigins: ["https://myapp.example.com", "https://myapp-staging.example.com"] loginConsent: enabled: true validityPeriod: 3600 @@ -849,6 +854,12 @@ components: type: string description: Array of allowed user types for this application. example: ["employee", "customer", "partner"] + passkeyAllowedOrigins: + type: array + items: + type: string + description: Allowed origins for WebAuthn/passkey operations for this application. When set, overrides the server-level passkey allowed origins for flow-based passkey operations. + example: ["https://myapp.example.com", "https://myapp-staging.example.com"] loginConsent: type: object properties: @@ -988,6 +999,12 @@ components: type: string description: Array of allowed user types for this application. example: ["employee", "customer", "partner"] + passkeyAllowedOrigins: + type: array + items: + type: string + description: Allowed origins for WebAuthn/passkey operations for this application. When set, overrides the server-level passkey allowed origins for flow-based passkey operations. + example: ["https://myapp.example.com", "https://myapp-staging.example.com"] loginConsent: type: object properties: @@ -1109,6 +1126,12 @@ components: type: string description: Array of allowed user types for this application. example: ["employee", "customer", "partner"] + passkeyAllowedOrigins: + type: array + items: + type: string + description: Allowed origins for WebAuthn/passkey operations for this application. When set, overrides the server-level passkey allowed origins for flow-based passkey operations. + example: ["https://myapp.example.com", "https://myapp-staging.example.com"] loginConsent: type: object properties: diff --git a/backend/cmd/server/servicemanager.go b/backend/cmd/server/servicemanager.go index f6cfe65df4..c56c00ff55 100644 --- a/backend/cmd/server/servicemanager.go +++ b/backend/cmd/server/servicemanager.go @@ -399,7 +399,7 @@ func registerServices(mux *http.ServeMux, cacheManager cache.CacheManagerInterfa // TODO: Remove entityService dependency after finalizing declarative resource loading pattern applicationService, applicationExporter, err := application.Initialize( mux, mcpServer, entityProvider, entityService, inboundClientService, ouService, i18nService, - runtimeCryptoSvc) + runtimeCryptoSvc, serverConfigService) fatalOnError(ctx, logger, err, "Failed to initialize ApplicationService") exporters = append(exporters, applicationExporter) diff --git a/backend/internal/actorprovider/utils.go b/backend/internal/actorprovider/utils.go index c009021f0b..a1fa7d124f 100644 --- a/backend/internal/actorprovider/utils.go +++ b/backend/internal/actorprovider/utils.go @@ -54,11 +54,12 @@ func assembleApplication( app := &providers.Application{ ID: client.ID, InboundAuthProfile: providers.InboundAuthProfile{ - AuthFlowID: client.AuthFlowID, - SignOutFlowID: client.SignOutFlowID, - Assertion: client.Assertion, - LoginConsent: client.LoginConsent, - AllowedUserTypes: client.AllowedUserTypes, + AuthFlowID: client.AuthFlowID, + SignOutFlowID: client.SignOutFlowID, + Assertion: client.Assertion, + LoginConsent: client.LoginConsent, + AllowedUserTypes: client.AllowedUserTypes, + PasskeyAllowedOrigins: client.PasskeyAllowedOrigins, }, } diff --git a/backend/internal/application/declarative_resource.go b/backend/internal/application/declarative_resource.go index 349c273b90..09193bb214 100644 --- a/backend/internal/application/declarative_resource.go +++ b/backend/internal/application/declarative_resource.go @@ -189,6 +189,7 @@ func parseToApplicationDTO(data []byte) (*model.ApplicationDTO, error) { LayoutID: appRequest.LayoutID, Assertion: appRequest.Assertion, AllowedUserTypes: appRequest.AllowedUserTypes, + PasskeyAllowedOrigins: appRequest.PasskeyAllowedOrigins, LoginConsent: appRequest.LoginConsent, }, Type: appRequest.Type, diff --git a/backend/internal/application/declarative_resource_internal_test.go b/backend/internal/application/declarative_resource_internal_test.go index 65577eb376..ab2123eb82 100644 --- a/backend/internal/application/declarative_resource_internal_test.go +++ b/backend/internal/application/declarative_resource_internal_test.go @@ -74,6 +74,9 @@ certificate: allowedUserTypes: - internal - external +passkeyAllowedOrigins: + - https://app.example.com + - https://login.example.com ` appDTO, err := parseToApplicationDTO([]byte(yamlData)) @@ -99,6 +102,7 @@ allowedUserTypes: assert.NotNil(s.T(), appDTO.Assertion) assert.Equal(s.T(), int64(3600), appDTO.Assertion.ValidityPeriod) assert.Equal(s.T(), 2, len(appDTO.AllowedUserTypes)) + assert.Equal(s.T(), []string{"https://app.example.com", "https://login.example.com"}, appDTO.PasskeyAllowedOrigins) } func (s *ParseToApplicationDTOTestSuite) TestParseToApplicationDTO_MinimalFields() { @@ -513,3 +517,27 @@ func (s *ParseToApplicationDTOTestSuite) TestParseToApplicationDTO_OUHandlePasse assert.Equal(s.T(), "default", appDTO.OUHandle) assert.Empty(s.T(), appDTO.OUID) } + +func (s *ParseToApplicationDTOTestSuite) TestParseToApplicationDTO_PasskeyAllowedOriginsParsed() { + yamlData := []byte(` +id: passkey-app +name: Passkey Application +passkeyAllowedOrigins: + - https://app.example.com + - https://login.example.com +`) + + appDTO, err := parseToApplicationDTO(yamlData) + + assert.NoError(s.T(), err) + assert.Equal(s.T(), []string{"https://app.example.com", "https://login.example.com"}, appDTO.PasskeyAllowedOrigins) +} + +func (s *ParseToApplicationDTOTestSuite) TestParseToApplicationDTO_PasskeyAllowedOriginsAbsent() { + yamlData := []byte("id: no-passkey-app\nname: No Passkey App\n") + + appDTO, err := parseToApplicationDTO(yamlData) + + assert.NoError(s.T(), err) + assert.Nil(s.T(), appDTO.PasskeyAllowedOrigins) +} diff --git a/backend/internal/application/handler.go b/backend/internal/application/handler.go index ba5d2ddc0f..0679d0aea4 100644 --- a/backend/internal/application/handler.go +++ b/backend/internal/application/handler.go @@ -81,6 +81,7 @@ func (ah *applicationHandler) HandleApplicationPostRequest(w http.ResponseWriter LayoutID: appRequest.LayoutID, Assertion: appRequest.Assertion, AllowedUserTypes: appRequest.AllowedUserTypes, + PasskeyAllowedOrigins: appRequest.PasskeyAllowedOrigins, LoginConsent: appRequest.LoginConsent, Attestation: appRequest.Attestation, }, @@ -119,6 +120,7 @@ func (ah *applicationHandler) HandleApplicationPostRequest(w http.ResponseWriter LayoutID: createdAppDTO.LayoutID, Assertion: createdAppDTO.Assertion, AllowedUserTypes: createdAppDTO.AllowedUserTypes, + PasskeyAllowedOrigins: createdAppDTO.PasskeyAllowedOrigins, LoginConsent: createdAppDTO.LoginConsent, Attestation: createdAppDTO.Attestation, }, @@ -200,6 +202,7 @@ func (ah *applicationHandler) HandleApplicationGetRequest(w http.ResponseWriter, LayoutID: appDTO.LayoutID, Assertion: appDTO.Assertion, AllowedUserTypes: appDTO.AllowedUserTypes, + PasskeyAllowedOrigins: appDTO.PasskeyAllowedOrigins, LoginConsent: appDTO.LoginConsent, Attestation: appDTO.Attestation, }, @@ -343,6 +346,7 @@ func (ah *applicationHandler) HandleApplicationPutRequest(w http.ResponseWriter, LayoutID: appRequest.LayoutID, Assertion: appRequest.Assertion, AllowedUserTypes: appRequest.AllowedUserTypes, + PasskeyAllowedOrigins: appRequest.PasskeyAllowedOrigins, LoginConsent: appRequest.LoginConsent, Attestation: appRequest.Attestation, }, @@ -381,6 +385,7 @@ func (ah *applicationHandler) HandleApplicationPutRequest(w http.ResponseWriter, LayoutID: updatedAppDTO.LayoutID, Assertion: updatedAppDTO.Assertion, AllowedUserTypes: updatedAppDTO.AllowedUserTypes, + PasskeyAllowedOrigins: updatedAppDTO.PasskeyAllowedOrigins, LoginConsent: updatedAppDTO.LoginConsent, Attestation: updatedAppDTO.Attestation, }, diff --git a/backend/internal/application/handler_test.go b/backend/internal/application/handler_test.go index 8c9baa090b..a9b854d2c0 100644 --- a/backend/internal/application/handler_test.go +++ b/backend/internal/application/handler_test.go @@ -2433,3 +2433,66 @@ func (suite *HandlerTestSuite) TestHandleApplicationGetRequest_EmptyResponseType mockService.AssertExpectations(suite.T()) } + +func (suite *HandlerTestSuite) TestHandleApplicationPostRequest_ForwardsPasskeyAllowedOrigins() { + mockService := NewApplicationServiceInterfaceMock(suite.T()) + handler := newApplicationHandler(mockService) + + origins := []string{"https://app.example.com", "https://other.example.com"} + appRequest := model.ApplicationRequest{ + OUID: "ou-123", + Name: "TestApp", + } + appRequest.PasskeyAllowedOrigins = origins + + expectedApp := &model.ApplicationDTO{ID: "test-app-id", Name: "TestApp"} + expectedApp.PasskeyAllowedOrigins = origins + + mockService.On("CreateApplication", mock.Anything, + mock.MatchedBy(func(dto *model.ApplicationDTO) bool { + return assert.Equal(suite.T(), origins, dto.PasskeyAllowedOrigins) + }), + ).Return(expectedApp, nil) + + body, _ := json.Marshal(appRequest) + req := httptest.NewRequest(http.MethodPost, "/applications", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.HandleApplicationPostRequest(w, req) + + assert.Equal(suite.T(), http.StatusCreated, w.Code) + mockService.AssertExpectations(suite.T()) +} + +func (suite *HandlerTestSuite) TestHandleApplicationPutRequest_ForwardsPasskeyAllowedOrigins() { + mockService := NewApplicationServiceInterfaceMock(suite.T()) + handler := newApplicationHandler(mockService) + + origins := []string{"https://app.example.com"} + appRequest := model.ApplicationRequest{ + OUID: "ou-123", + Name: "UpdatedApp", + } + appRequest.PasskeyAllowedOrigins = origins + + expectedApp := &model.ApplicationDTO{ID: "test-app-id", Name: "UpdatedApp"} + expectedApp.PasskeyAllowedOrigins = origins + + mockService.On("UpdateApplication", mock.Anything, "test-app-id", + mock.MatchedBy(func(dto *model.ApplicationDTO) bool { + return assert.Equal(suite.T(), origins, dto.PasskeyAllowedOrigins) + }), + ).Return(expectedApp, nil) + + body, _ := json.Marshal(appRequest) + req := httptest.NewRequest(http.MethodPut, "/applications/test-app-id", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.SetPathValue("id", "test-app-id") + w := httptest.NewRecorder() + + handler.HandleApplicationPutRequest(w, req) + + assert.Equal(suite.T(), http.StatusOK, w.Code) + mockService.AssertExpectations(suite.T()) +} diff --git a/backend/internal/application/init.go b/backend/internal/application/init.go index 0db79aa94b..ce2d2a9dba 100644 --- a/backend/internal/application/init.go +++ b/backend/internal/application/init.go @@ -29,6 +29,7 @@ import ( "github.com/thunder-id/thunderid/internal/entityprovider" "github.com/thunder-id/thunderid/internal/inboundclient" oupkg "github.com/thunder-id/thunderid/internal/ou" + "github.com/thunder-id/thunderid/internal/serverconfig" serverconst "github.com/thunder-id/thunderid/internal/system/constants" declarativeresource "github.com/thunder-id/thunderid/internal/system/declarative_resource" i18nmgt "github.com/thunder-id/thunderid/internal/system/i18n/mgt" @@ -46,9 +47,10 @@ func Initialize( ouService oupkg.OrganizationUnitServiceInterface, i18nService i18nmgt.I18nServiceInterface, cryptoSvc kmprovider.RuntimeCryptoProvider, + serverConfigSvc serverconfig.ServerConfigService, ) (ApplicationServiceInterface, declarativeresource.ResourceExporter, error) { appService := newApplicationService( - inboundClient, entityProvider, ouService, i18nService, cryptoSvc, + inboundClient, entityProvider, ouService, i18nService, cryptoSvc, serverConfigSvc, ) if err := entityService.LoadIndexedAttributes(getAppIndexedAttributes()); err != nil { diff --git a/backend/internal/application/init_test.go b/backend/internal/application/init_test.go index bbeb7c2919..95272ce1ab 100644 --- a/backend/internal/application/init_test.go +++ b/backend/internal/application/init_test.go @@ -159,6 +159,7 @@ func (suite *InitTestSuite) TestInitialize_WithDeclarativeResourcesDisabled() { nil, // ouService - not needed for this test nil, // i18nService - not needed for this test nil, // cryptoSvc - not needed for this test + nil, // serverConfigSvc - not needed for this test ) // Assert @@ -202,6 +203,7 @@ func (suite *InitTestSuite) TestInitialize_WithMCPServer() { nil, // ouService - not needed for this test nil, // i18nService - not needed for this test nil, // cryptoSvc - not needed for this test + nil, // serverConfigSvc - not needed for this test ) // Assert @@ -593,6 +595,7 @@ func TestInitialize_Standalone(t *testing.T) { nil, // ouService - not needed for this test nil, // i18nService - not needed for this test nil, // cryptoSvc - not needed for this test + nil, // serverConfigSvc - not needed for this test ) // Assert @@ -644,6 +647,7 @@ func TestInitialize_WithDeclarativeResources_Standalone(t *testing.T) { nil, // ouService - not needed for this test nil, // i18nService - not needed for this test nil, // cryptoSvc - not needed for this test + nil, // serverConfigSvc - not needed for this test ) // Assert diff --git a/backend/internal/application/service.go b/backend/internal/application/service.go index f435d50844..64c7fc50ff 100644 --- a/backend/internal/application/service.go +++ b/backend/internal/application/service.go @@ -20,16 +20,12 @@ package application import ( "context" + "encoding/json" "errors" "fmt" "slices" "strings" - tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" - "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" - - "encoding/json" - "github.com/thunder-id/thunderid/internal/application/model" "github.com/thunder-id/thunderid/internal/cert" "github.com/thunder-id/thunderid/internal/entityprovider" @@ -37,14 +33,18 @@ import ( inboundmodel "github.com/thunder-id/thunderid/internal/inboundclient/model" oauthutils "github.com/thunder-id/thunderid/internal/oauth/oauth2/utils" oupkg "github.com/thunder-id/thunderid/internal/ou" + "github.com/thunder-id/thunderid/internal/serverconfig" "github.com/thunder-id/thunderid/internal/system/config" serverconst "github.com/thunder-id/thunderid/internal/system/constants" + "github.com/thunder-id/thunderid/internal/system/cors" "github.com/thunder-id/thunderid/internal/system/cryptolib" i18nmgt "github.com/thunder-id/thunderid/internal/system/i18n/mgt" kmprovider "github.com/thunder-id/thunderid/internal/system/kmprovider/common" "github.com/thunder-id/thunderid/internal/system/log" "github.com/thunder-id/thunderid/internal/system/resourcedependency" sysutils "github.com/thunder-id/thunderid/internal/system/utils" + tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" ) // ApplicationServiceInterface defines the interface for the application service. @@ -75,6 +75,7 @@ type applicationService struct { i18nService i18nmgt.I18nServiceInterface cryptoSvc kmprovider.RuntimeCryptoProvider dependencyRegistry resourcedependency.Registry + serverConfigService serverconfig.ServerConfigService } // newApplicationService creates a new instance of ApplicationService. @@ -84,6 +85,7 @@ func newApplicationService( ouService oupkg.OrganizationUnitServiceInterface, i18nService i18nmgt.I18nServiceInterface, cryptoSvc kmprovider.RuntimeCryptoProvider, + serverConfigSvc serverconfig.ServerConfigService, ) ApplicationServiceInterface { return &applicationService{ logger: log.GetLogger().With(log.String(log.LoggerKeyComponentName, "ApplicationService")), @@ -92,6 +94,7 @@ func newApplicationService( ouService: ouService, i18nService: i18nService, cryptoSvc: cryptoSvc, + serverConfigService: serverConfigSvc, } } @@ -186,6 +189,8 @@ func (as *applicationService) CreateApplication(ctx context.Context, app *model. return nil, &tidcommon.InternalServerError } + as.syncPasskeyOriginsToCORS(ctx, processedDTO.PasskeyAllowedOrigins) + appForReturn := *app appForReturn.AuthFlowID = inboundClient.AuthFlowID appForReturn.RegistrationFlowID = inboundClient.RegistrationFlowID @@ -439,6 +444,8 @@ func (as *applicationService) UpdateApplication(ctx context.Context, appID strin return nil, svcErr } + as.syncPasskeyOriginsToCORS(ctx, processedDTO.PasskeyAllowedOrigins) + appForReturn := *app appForReturn.AuthFlowID = inboundClient.AuthFlowID appForReturn.RegistrationFlowID = inboundClient.RegistrationFlowID @@ -821,6 +828,7 @@ func toInboundClient(dto *model.ApplicationProcessedDTO) inboundmodel.InboundCli Assertion: dto.Assertion, LoginConsent: dto.LoginConsent, AllowedUserTypes: dto.AllowedUserTypes, + PasskeyAllowedOrigins: dto.PasskeyAllowedOrigins, Attestation: dto.Attestation, } @@ -876,6 +884,7 @@ func toProcessedDTO( Assertion: dao.Assertion, LoginConsent: dao.LoginConsent, AllowedUserTypes: dao.AllowedUserTypes, + PasskeyAllowedOrigins: dao.PasskeyAllowedOrigins, Attestation: dao.Attestation.WithoutCredentials(), }, } @@ -1812,6 +1821,7 @@ func buildApplicationResponse(dto *model.ApplicationProcessedDTO) *providers.App LayoutID: dto.LayoutID, Assertion: dto.Assertion, AllowedUserTypes: dto.AllowedUserTypes, + PasskeyAllowedOrigins: dto.PasskeyAllowedOrigins, LoginConsent: dto.LoginConsent, Attestation: dto.Attestation, }, @@ -1923,6 +1933,7 @@ func buildBaseApplicationProcessedDTO(appID string, app *model.ApplicationDTO, LayoutID: app.LayoutID, Assertion: assertion, AllowedUserTypes: app.AllowedUserTypes, + PasskeyAllowedOrigins: app.PasskeyAllowedOrigins, LoginConsent: app.LoginConsent, Attestation: app.Attestation, }, @@ -2007,6 +2018,7 @@ func buildReturnApplicationDTO( LayoutID: app.LayoutID, Assertion: assertion, AllowedUserTypes: app.AllowedUserTypes, + PasskeyAllowedOrigins: app.PasskeyAllowedOrigins, LoginConsent: app.LoginConsent, Attestation: app.Attestation.WithoutCredentials(), }, @@ -2118,3 +2130,73 @@ func (as *applicationService) cleanupStaleI18nKeys( } return nil } + +// syncPasskeyOriginsToCORS merges the application's passkey allowed origins into the CORS writable +// layer so the browser can reach the ThunderID server from those origins. This is additive: origins +// already present are skipped; origins removed from an application are not pruned here. +func (as *applicationService) syncPasskeyOriginsToCORS(ctx context.Context, origins []string) { + if as.serverConfigService == nil || len(origins) == 0 { + return + } + + writableAny, svcErr := as.serverConfigService.GetWritableConfig(ctx, string(serverconfig.ConfigNameCORS)) + if svcErr != nil { + as.logger.Warn(ctx, "Failed to read CORS writable config for passkey origin sync", + log.String("error", svcErr.ErrorDescription.DefaultValue)) + return + } + + cfg, _ := writableAny.(cors.OriginConfig) + + existingJSON, err := json.Marshal(cfg.AllowedOrigins) + if err != nil { + as.logger.Warn(ctx, "Failed to marshal existing CORS allowed origins", log.Error(err)) + return + } + existingItems := make([]json.RawMessage, 0, len(cfg.AllowedOrigins)) + _ = json.Unmarshal(existingJSON, &existingItems) + + existingLiterals := make(map[string]struct{}, len(existingItems)) + for _, item := range existingItems { + var s string + if json.Unmarshal(item, &s) == nil { + existingLiterals[s] = struct{}{} + } + } + + added := false + for _, origin := range origins { + trimmed := strings.TrimSpace(origin) + if trimmed == "" || strings.Contains(trimmed, "*") || trimmed == "null" { + continue + } + if _, err := cors.ParseOrigin(trimmed); err != nil { + as.logger.Debug(ctx, "Skipping invalid passkey origin for CORS sync", + log.String("origin", trimmed), log.String("error", err.Error())) + continue + } + if _, found := existingLiterals[trimmed]; found { + continue + } + originJSON, _ := json.Marshal(trimmed) + existingItems = append(existingItems, json.RawMessage(originJSON)) + existingLiterals[trimmed] = struct{}{} + added = true + } + + if !added { + return + } + + newConfigJSON, err := json.Marshal(map[string]any{"allowedOrigins": existingItems}) + if err != nil { + as.logger.Warn(ctx, "Failed to marshal updated CORS config", log.Error(err)) + return + } + if svcErr := as.serverConfigService.SetConfig( + ctx, serverconfig.ConfigNameCORS, json.RawMessage(newConfigJSON), + ); svcErr != nil { + as.logger.Warn(ctx, "Failed to update CORS config with passkey allowed origins", + log.String("error", svcErr.ErrorDescription.DefaultValue)) + } +} diff --git a/backend/internal/application/service_test.go b/backend/internal/application/service_test.go index b4f00b6496..21003e014d 100644 --- a/backend/internal/application/service_test.go +++ b/backend/internal/application/service_test.go @@ -38,8 +38,10 @@ import ( "github.com/thunder-id/thunderid/internal/entityprovider" "github.com/thunder-id/thunderid/internal/inboundclient" inboundmodel "github.com/thunder-id/thunderid/internal/inboundclient/model" + "github.com/thunder-id/thunderid/internal/serverconfig" "github.com/thunder-id/thunderid/internal/system/config" serverconst "github.com/thunder-id/thunderid/internal/system/constants" + "github.com/thunder-id/thunderid/internal/system/cors" "github.com/thunder-id/thunderid/internal/system/log" "github.com/thunder-id/thunderid/internal/system/resourcedependency" "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" @@ -48,6 +50,7 @@ import ( "github.com/thunder-id/thunderid/tests/mocks/i18n/mgtmock" "github.com/thunder-id/thunderid/tests/mocks/inboundclientmock" "github.com/thunder-id/thunderid/tests/mocks/oumock" + "github.com/thunder-id/thunderid/tests/mocks/serverconfigmock" ) const testServiceAppID = "app123" @@ -4345,3 +4348,90 @@ func (suite *ServiceTestSuite) TestDeleteApplication_EntityDeleteFailsAfterCasca assert.Equal(suite.T(), 1, cascadeCalls) ep.AssertCalled(suite.T(), "DeleteEntity", mock.Anything) } + +// ----- syncPasskeyOriginsToCORS ----- + +func (suite *ServiceTestSuite) TestSyncPasskeyOriginsToCORS_NilService_Noop() { + svc := &applicationService{serverConfigService: nil} + // Should not panic when serverConfigService is nil + svc.syncPasskeyOriginsToCORS(context.Background(), []string{"https://app.example.com"}) +} + +func (suite *ServiceTestSuite) TestSyncPasskeyOriginsToCORS_EmptyOrigins_Noop() { + mockCfgSvc := serverconfigmock.NewServerConfigServiceMock(suite.T()) + svc := &applicationService{serverConfigService: mockCfgSvc} + // No mock expectations — GetWritableConfig must not be called with empty input + svc.syncPasskeyOriginsToCORS(context.Background(), nil) + svc.syncPasskeyOriginsToCORS(context.Background(), []string{}) +} + +func (suite *ServiceTestSuite) TestSyncPasskeyOriginsToCORS_AddsNewOrigins() { + mockCfgSvc := serverconfigmock.NewServerConfigServiceMock(suite.T()) + svc := &applicationService{ + serverConfigService: mockCfgSvc, + logger: log.GetLogger(), + } + + existing := cors.OriginConfig{} + mockCfgSvc.On("GetWritableConfig", mock.Anything, string(serverconfig.ConfigNameCORS)). + Return(existing, (*tidcommon.ServiceError)(nil)) + + var capturedRaw json.RawMessage + mockCfgSvc.On("SetConfig", mock.Anything, serverconfig.ConfigNameCORS, mock.MatchedBy( + func(raw json.RawMessage) bool { + capturedRaw = raw + return true + })).Return((*tidcommon.ServiceError)(nil)) + + svc.syncPasskeyOriginsToCORS(context.Background(), []string{"https://app.example.com"}) + + var saved map[string]json.RawMessage + require.NoError(suite.T(), json.Unmarshal(capturedRaw, &saved)) + var origins []string + require.NoError(suite.T(), json.Unmarshal(saved["allowedOrigins"], &origins)) + assert.Contains(suite.T(), origins, "https://app.example.com") +} + +func (suite *ServiceTestSuite) TestSyncPasskeyOriginsToCORS_SkipsDuplicates() { + mockCfgSvc := serverconfigmock.NewServerConfigServiceMock(suite.T()) + svc := &applicationService{ + serverConfigService: mockCfgSvc, + logger: log.GetLogger(), + } + + existing := cors.OriginConfig{} + _ = json.Unmarshal([]byte(`{"allowedOrigins":["https://app.example.com"]}`), &existing) + mockCfgSvc.On("GetWritableConfig", mock.Anything, string(serverconfig.ConfigNameCORS)). + Return(existing, (*tidcommon.ServiceError)(nil)) + // SetConfig must NOT be called because nothing changed + + svc.syncPasskeyOriginsToCORS(context.Background(), []string{"https://app.example.com"}) +} + +func (suite *ServiceTestSuite) TestSyncPasskeyOriginsToCORS_SkipsInvalidOrigins() { + mockCfgSvc := serverconfigmock.NewServerConfigServiceMock(suite.T()) + svc := &applicationService{ + serverConfigService: mockCfgSvc, + logger: log.GetLogger(), + } + + invalidOrigins := []string{ + "", // empty + " ", // whitespace only + "*", // bare wildcard + "http://*.example.com", // wildcard in hostname + "null", // null origin + "ftp://example.com", // unsupported scheme + "https://example.com/path", // has path + "https://example.com?q=1", // has query + "https://example.com#frag", // has fragment + "https://exam\tple.com", // control character + "https://user:pass@host.com", // userinfo + } + + mockCfgSvc.On("GetWritableConfig", mock.Anything, string(serverconfig.ConfigNameCORS)). + Return(cors.OriginConfig{}, (*tidcommon.ServiceError)(nil)) + // SetConfig must NOT be called because all origins are invalid + + svc.syncPasskeyOriginsToCORS(context.Background(), invalidOrigins) +} diff --git a/backend/internal/authn/passkey/model.go b/backend/internal/authn/passkey/model.go index 00517b88c6..cda01e090c 100644 --- a/backend/internal/authn/passkey/model.go +++ b/backend/internal/authn/passkey/model.go @@ -33,6 +33,7 @@ type PasskeyRegistrationStartRequest struct { RelyingPartyName string AuthenticatorSelection *AuthenticatorSelection Attestation string + AllowedOrigins []string } // PasskeyRegistrationStartData represents the data returned when initiating passkey registration. @@ -61,12 +62,14 @@ type PasskeyRegistrationFinishRequest struct { ClientDataJSON string AttestationObject string SessionToken string + AllowedOrigins []string } // PasskeyAuthenticationStartRequest represents the request to start passkey authentication. type PasskeyAuthenticationStartRequest struct { UserID string RelyingPartyID string + AllowedOrigins []string } // PasskeyAuthenticationStartData represents the data returned when initiating passkey authentication. @@ -101,6 +104,7 @@ type PasskeyAuthenticationFinishRequest struct { Signature string UserHandle string SessionToken string + AllowedOrigins []string } // PasskeyFinishRequest represents the request to complete passkey authentication. diff --git a/backend/internal/authn/passkey/service.go b/backend/internal/authn/passkey/service.go index 765fe8b50a..6ace2b5dfa 100644 --- a/backend/internal/authn/passkey/service.go +++ b/backend/internal/authn/passkey/service.go @@ -125,7 +125,7 @@ func (w *passkeyService) StartRegistration( webAuthnUser := newWebAuthnUserFromEntity(coreEntity, credentials) // Initialize WebAuthn service with relying party configuration - rpOrigins := getConfiguredOrigins() + rpOrigins := resolveAllowedOrigins(req.AllowedOrigins) webAuthnService, err := newDefaultWebAuthnService(req.RelyingPartyID, rpDisplayName, rpOrigins) if err != nil { logger.Error(ctx, "Failed to initialize WebAuthn service", log.String("error", err.Error())) @@ -245,7 +245,7 @@ func (w *passkeyService) FinishRegistration(ctx context.Context, req *PasskeyReg webAuthnUser := newWebAuthnUserFromEntity(coreEntity, credentials) // Initialize WebAuthn service with relying party configuration - rpOrigins := getConfiguredOrigins() + rpOrigins := resolveAllowedOrigins(req.AllowedOrigins) webAuthnService, err := newDefaultWebAuthnService(relyingPartyID, relyingPartyID, rpOrigins) if err != nil { logger.Error(ctx, "Failed to initialize WebAuthn service", log.String("error", err.Error())) @@ -301,7 +301,7 @@ func (w *passkeyService) StartAuthentication(ctx context.Context, req *PasskeyAu } // Initialize WebAuthn service with relying party configuration - rpOrigins := getConfiguredOrigins() + rpOrigins := resolveAllowedOrigins(req.AllowedOrigins) webAuthnService, err := newDefaultWebAuthnService(req.RelyingPartyID, req.RelyingPartyID, rpOrigins) if err != nil { logger.Error(ctx, "Failed to initialize WebAuthn service", log.String("error", err.Error())) @@ -452,7 +452,7 @@ func (w *passkeyService) FinishAuthentication(ctx context.Context, req *PasskeyA webAuthnUser := newWebAuthnUserFromEntity(coreEntity, credentials) // Initialize WebAuthn service with relying party configuration - rpOrigins := getConfiguredOrigins() + rpOrigins := resolveAllowedOrigins(req.AllowedOrigins) webAuthnService, err := newDefaultWebAuthnService(relyingPartyID, relyingPartyID, rpOrigins) if err != nil { logger.Error(ctx, "Failed to initialize WebAuthn service", log.String("error", err.Error())) diff --git a/backend/internal/authn/passkey/utils.go b/backend/internal/authn/passkey/utils.go index 86affc0d5d..683bfcf577 100644 --- a/backend/internal/authn/passkey/utils.go +++ b/backend/internal/authn/passkey/utils.go @@ -34,6 +34,15 @@ const ( defaultOriginHTTP = "https://localhost:8090" ) +// resolveAllowedOrigins returns overrideOrigins when non-empty; otherwise falls back to +// the server-level configured origins from deployment configuration. +func resolveAllowedOrigins(overrideOrigins []string) []string { + if len(overrideOrigins) > 0 { + return overrideOrigins + } + return getConfiguredOrigins() +} + // getConfiguredOrigins retrieves the allowed origins from runtime configuration. func getConfiguredOrigins() []string { // Default origins if not configured diff --git a/backend/internal/authn/passkey/utils_test.go b/backend/internal/authn/passkey/utils_test.go index b9d1733746..8519c0f42d 100644 --- a/backend/internal/authn/passkey/utils_test.go +++ b/backend/internal/authn/passkey/utils_test.go @@ -44,6 +44,28 @@ func (suite *UtilsTestSuite) TestGetConfiguredOrigins() { suite.NotEmpty(origins) } +func (suite *UtilsTestSuite) TestResolveOrigins_WithOverride() { + override := []string{"https://app.example.com", "https://mobile.example.com"} + + result := resolveAllowedOrigins(override) + + suite.Equal(override, result) +} + +func (suite *UtilsTestSuite) TestResolveOrigins_FallbackToServerConfig() { + result := resolveAllowedOrigins(nil) + + suite.NotNil(result) + suite.NotEmpty(result) +} + +func (suite *UtilsTestSuite) TestResolveOrigins_EmptySliceFallsBack() { + result := resolveAllowedOrigins([]string{}) + + suite.NotNil(result) + suite.NotEmpty(result) +} + func (suite *UtilsTestSuite) TestParseUserAttributes_ValidJSON() { attrs := json.RawMessage(`{"name":"John Doe","email":"john@example.com"}`) diff --git a/backend/internal/flow/executor/passkey_executor.go b/backend/internal/flow/executor/passkey_executor.go index 2027d55f73..24afc8f72a 100644 --- a/backend/internal/flow/executor/passkey_executor.go +++ b/backend/internal/flow/executor/passkey_executor.go @@ -196,6 +196,7 @@ func (p *passkeyAuthExecutor) executeChallenge(ctx *providers.NodeContext, startReq := &passkey.PasskeyAuthenticationStartRequest{ UserID: userID, // May be empty for usernameless flow RelyingPartyID: relyingPartyID, + AllowedOrigins: ctx.Application.PasskeyAllowedOrigins, } initResponse, svcErr := p.authnProvider.InitiateAuthentication(ctx.Context, passkey.CredentialType, startReq, nil) if svcErr != nil { @@ -305,6 +306,7 @@ func (p *passkeyAuthExecutor) validatePasskey(ctx *providers.NodeContext, execRe Signature: signature, UserHandle: userHandle, SessionToken: sessionToken, + AllowedOrigins: ctx.Application.PasskeyAllowedOrigins, } credentials := map[string]interface{}{passkey.CredentialType: passkeyCredential} authUser, authenticatedClaims, svcErr := p.authnProvider.AuthenticateUser( @@ -369,6 +371,7 @@ func (p *passkeyAuthExecutor) executeRegisterStart(ctx *providers.NodeContext, // Optional: Get authenticator selection and attestation from node properties AuthenticatorSelection: p.getAuthenticatorSelection(ctx), Attestation: p.getAttestation(ctx), + AllowedOrigins: ctx.Application.PasskeyAllowedOrigins, } // Start passkey registration @@ -465,6 +468,7 @@ func (p *passkeyAuthExecutor) executeRegisterFinish(ctx *providers.NodeContext, ClientDataJSON: clientDataJSON, AttestationObject: attestationObject, SessionToken: sessionToken, + AllowedOrigins: ctx.Application.PasskeyAllowedOrigins, } credentials := map[string]interface{}{passkey.CredentialType: finishReq} authUser, authenticatedClaims, svcErr := p.authnProvider.Enroll( diff --git a/backend/internal/flow/executor/passkey_executor_test.go b/backend/internal/flow/executor/passkey_executor_test.go index 3a21c96f0c..7a67d13a1f 100644 --- a/backend/internal/flow/executor/passkey_executor_test.go +++ b/backend/internal/flow/executor/passkey_executor_test.go @@ -933,3 +933,112 @@ func (suite *PasskeyAuthExecutorTestSuite) TestExecuteRegisterStart_UserIDFromUs assert.NotNil(suite.T(), resp) assert.Equal(suite.T(), providers.ExecComplete, resp.Status) } + +// --- Tests verifying app-level PasskeyAllowedOrigins flows through to request structs --- + +func (suite *PasskeyAuthExecutorTestSuite) TestExecuteChallenge_PassesAppAllowedOrigins() { + appOrigins := []string{"https://app.example.com", "https://mobile.example.com"} + ctx := createPasskeyNodeContext(passkeyExecutorModeChallenge, providers.FlowTypeAuthentication) + ctx.RuntimeData[userAttributeUserID] = testPasskeyUserID + ctx.Application.PasskeyAllowedOrigins = appOrigins + + expectedStartData := &passkey.PasskeyAuthenticationStartData{ + SessionToken: testSessionToken, + PublicKeyCredentialRequestOptions: passkey.PublicKeyCredentialRequestOptions{Challenge: "dGVzdA=="}, + } + + suite.mockAuthnProvider.On("InitiateAuthentication", mock.Anything, passkey.CredentialType, mock.MatchedBy( + func(req *passkey.PasskeyAuthenticationStartRequest) bool { + return req.RelyingPartyID == testRelyingPartyID && + len(req.AllowedOrigins) == 2 && + req.AllowedOrigins[0] == appOrigins[0] && + req.AllowedOrigins[1] == appOrigins[1] + }), mock.Anything).Return(expectedStartData, nil) + + resp, err := suite.executor.Execute(ctx) + + assert.NoError(suite.T(), err) + assert.NotNil(suite.T(), resp) + assert.Equal(suite.T(), providers.ExecComplete, resp.Status) +} + +func (suite *PasskeyAuthExecutorTestSuite) TestExecuteVerify_PassesAppAllowedOrigins() { + appOrigins := []string{"https://app.example.com"} + ctx := createPasskeyNodeContext(passkeyExecutorModeVerify, providers.FlowTypeAuthentication) + ctx.RuntimeData[userAttributeUserID] = testPasskeyUserID + ctx.RuntimeData[runtimePasskeySessionToken] = testSessionToken + ctx.Application.PasskeyAllowedOrigins = appOrigins + ctx.UserInputs = map[string]string{ + inputCredentialID: testCredentialIDValue, + inputClientDataJSON: "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0In0", + inputAuthenticatorData: "authenticator-data", + inputSignature: "signature-data", + inputUserHandle: "user-handle", + } + + authenticatedAuthUser := newPasskeyAuthenticatedUser() + suite.mockAuthnProvider.On("AuthenticateUser", mock.Anything, mock.Anything, mock.MatchedBy( + func(creds map[string]interface{}) bool { + req, ok := creds[passkey.CredentialType].(*passkey.PasskeyAuthenticationFinishRequest) + return ok && len(req.AllowedOrigins) == 1 && req.AllowedOrigins[0] == appOrigins[0] + }), mock.Anything, mock.Anything, mock.Anything). + Return(authenticatedAuthUser, providers.AuthenticatedClaims{}, nil) + + resp, err := suite.executor.Execute(ctx) + + assert.NoError(suite.T(), err) + assert.NotNil(suite.T(), resp) + assert.Equal(suite.T(), providers.ExecComplete, resp.Status) + assert.True(suite.T(), resp.AuthUser.IsAuthenticated()) +} + +func (suite *PasskeyAuthExecutorTestSuite) TestExecuteRegisterStart_PassesAppAllowedOrigins() { + appOrigins := []string{"https://app.example.com"} + ctx := createPasskeyNodeContext(passkeyExecutorModeRegStart, providers.FlowTypeRegistration) + ctx.RuntimeData[userAttributeUserID] = testPasskeyUserID + ctx.Application.PasskeyAllowedOrigins = appOrigins + + expectedStartData := &passkey.PasskeyRegistrationStartData{ + SessionToken: testSessionToken, + PublicKeyCredentialCreationOptions: passkey.PublicKeyCredentialCreationOptions{Challenge: "cmVnQ2hhbGw="}, + } + + suite.mockAuthnProvider.On("InitiateEnrollment", mock.Anything, passkey.CredentialType, mock.MatchedBy( + func(req *passkey.PasskeyRegistrationStartRequest) bool { + return req.UserID == testPasskeyUserID && + len(req.AllowedOrigins) == 1 && + req.AllowedOrigins[0] == appOrigins[0] + }), mock.Anything).Return(expectedStartData, nil) + + resp, err := suite.executor.Execute(ctx) + + assert.NoError(suite.T(), err) + assert.NotNil(suite.T(), resp) + assert.Equal(suite.T(), providers.ExecComplete, resp.Status) +} + +func (suite *PasskeyAuthExecutorTestSuite) TestExecuteRegisterFinish_PassesAppAllowedOrigins() { + appOrigins := []string{"https://app.example.com"} + ctx := createPasskeyNodeContext(passkeyExecutorModeRegFinish, providers.FlowTypeRegistration) + ctx.RuntimeData[userAttributeUserID] = testPasskeyUserID + ctx.RuntimeData[runtimePasskeySessionToken] = testSessionToken + ctx.Application.PasskeyAllowedOrigins = appOrigins + ctx.UserInputs = map[string]string{ + inputCredentialID: testCredentialIDValue, + inputClientDataJSON: "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIn0", + inputAttestationObject: "attestation-object-data", + } + + authUser := newPasskeyAuthenticatedUser() + suite.mockAuthnProvider.On("Enroll", mock.Anything, mock.Anything, mock.MatchedBy( + func(creds map[string]interface{}) bool { + req, ok := creds[passkey.CredentialType].(*passkey.PasskeyRegistrationFinishRequest) + return ok && len(req.AllowedOrigins) == 1 && req.AllowedOrigins[0] == appOrigins[0] + }), mock.Anything, mock.Anything, mock.Anything).Return(authUser, providers.AuthenticatedClaims{}, nil) + + resp, err := suite.executor.Execute(ctx) + + assert.NoError(suite.T(), err) + assert.NotNil(suite.T(), resp) + assert.Equal(suite.T(), providers.ExecComplete, resp.Status) +} diff --git a/backend/internal/inboundclient/store.go b/backend/internal/inboundclient/store.go index 9bc812c1bd..7dfee27e3d 100644 --- a/backend/internal/inboundclient/store.go +++ b/backend/internal/inboundclient/store.go @@ -36,11 +36,12 @@ import ( // inboundClientJSONBlob is the internal structure for marshaling/unmarshaling the // PROPERTIES column. type inboundClientJSONBlob struct { - Assertion *inboundmodel.AssertionConfig `json:"assertion,omitempty"` - LoginConsent *inboundmodel.LoginConsentConfig `json:"loginConsent,omitempty"` - AllowedUserTypes []string `json:"allowedUserTypes,omitempty"` - Attestation *providers.AttestationConfig `json:"attestation,omitempty"` - Properties map[string]interface{} `json:"properties,omitempty"` + Assertion *inboundmodel.AssertionConfig `json:"assertion,omitempty"` + LoginConsent *inboundmodel.LoginConsentConfig `json:"loginConsent,omitempty"` + AllowedUserTypes []string `json:"allowedUserTypes,omitempty"` + PasskeyAllowedOrigins []string `json:"passkeyAllowedOrigins,omitempty"` + Attestation *providers.AttestationConfig `json:"attestation,omitempty"` + Properties map[string]interface{} `json:"properties,omitempty"` } // inboundClientStoreInterface defines persistence operations for inbound clients. @@ -110,11 +111,12 @@ func marshalInboundClient(c inboundmodel.InboundClient) ( err error, ) { blob := inboundClientJSONBlob{ - Assertion: c.Assertion, - LoginConsent: c.LoginConsent, - AllowedUserTypes: c.AllowedUserTypes, - Attestation: c.Attestation, - Properties: c.Properties, + Assertion: c.Assertion, + LoginConsent: c.LoginConsent, + AllowedUserTypes: c.AllowedUserTypes, + PasskeyAllowedOrigins: c.PasskeyAllowedOrigins, + Attestation: c.Attestation, + Properties: c.Properties, } propertiesBytes, err = marshalNullableJSON(blob) if err != nil { @@ -502,6 +504,7 @@ func buildInboundClientFromRow(ctx context.Context, row map[string]interface{}) client.Assertion = blob.Assertion client.LoginConsent = blob.LoginConsent client.AllowedUserTypes = blob.AllowedUserTypes + client.PasskeyAllowedOrigins = blob.PasskeyAllowedOrigins client.Attestation = blob.Attestation client.Properties = blob.Properties } diff --git a/backend/internal/inboundclient/store_test.go b/backend/internal/inboundclient/store_test.go index a6bd42007a..579a32bdf3 100644 --- a/backend/internal/inboundclient/store_test.go +++ b/backend/internal/inboundclient/store_test.go @@ -97,8 +97,9 @@ func (suite *InboundClientStoreTestSuite) TestBuildInboundClientFromRow_Success( LoginConsent: &inboundmodel.LoginConsentConfig{ ValidityPeriod: 5400, }, - AllowedUserTypes: []string{"admin", "user"}, - Properties: map[string]interface{}{"template": "spa"}, + AllowedUserTypes: []string{"admin", "user"}, + PasskeyAllowedOrigins: []string{"https://app.example.com"}, + Properties: map[string]interface{}{"template": "spa"}, } blobBytes, _ := json.Marshal(blob) @@ -131,10 +132,48 @@ func (suite *InboundClientStoreTestSuite) TestBuildInboundClientFromRow_Success( suite.NotNil(result.LoginConsent) suite.Equal(int64(5400), result.LoginConsent.ValidityPeriod) suite.Equal([]string{"admin", "user"}, result.AllowedUserTypes) + suite.Equal([]string{"https://app.example.com"}, result.PasskeyAllowedOrigins) suite.NotNil(result.Properties) suite.Equal("spa", result.Properties["template"]) } +func (suite *InboundClientStoreTestSuite) TestPasskeyAllowedOrigins_RoundTrip() { + origins := []string{"https://app.example.com", "https://mobile.example.com"} + + blob := inboundClientJSONBlob{ + PasskeyAllowedOrigins: origins, + } + blobBytes, _ := json.Marshal(blob) + + row := map[string]interface{}{ + "entity_id": testEntityID, + "properties": string(blobBytes), + } + result, err := buildInboundClientFromRow(context.Background(), row) + + suite.NoError(err) + suite.NotNil(result) + suite.Equal(origins, result.PasskeyAllowedOrigins) +} + +func (suite *InboundClientStoreTestSuite) TestBuildInboundClientFromRow_NoPasskeyAllowedOrigins() { + blob := inboundClientJSONBlob{ + AllowedUserTypes: []string{"user"}, + } + blobBytes, _ := json.Marshal(blob) + + row := map[string]interface{}{ + "entity_id": "app1", + "properties": string(blobBytes), + } + + result, err := buildInboundClientFromRow(context.Background(), row) + + suite.NoError(err) + suite.NotNil(result) + suite.Nil(result.PasskeyAllowedOrigins) +} + func (suite *InboundClientStoreTestSuite) TestBuildInboundClientFromRow_InvalidID() { row := map[string]interface{}{ "entity_id": 123, // Invalid type diff --git a/backend/internal/serverconfig/service.go b/backend/internal/serverconfig/service.go index 6f60981675..8a9cbaf90e 100644 --- a/backend/internal/serverconfig/service.go +++ b/backend/internal/serverconfig/service.go @@ -21,6 +21,7 @@ package serverconfig import ( "context" "encoding/json" + "sync" "github.com/thunder-id/thunderid/internal/system/log" "github.com/thunder-id/thunderid/pkg/thunderidengine/common" @@ -43,6 +44,7 @@ type serverConfigService struct { store serverConfigStoreInterface handlers map[ConfigName]ServerConfigHandlerInterface logger *log.Logger + configMu sync.Mutex } // newServerConfigService creates a new instance of serverConfigService. Handlers are injected at @@ -149,6 +151,9 @@ func (s *serverConfigService) SetConfig(ctx context.Context, return &ErrorInvalidConfigValue } + s.configMu.Lock() + defer s.configMu.Unlock() + rawLayers, err := s.store.GetServerConfig(ctx, name) if err != nil { s.logger.Error(ctx, "Failed to get current server config", log.Error(err)) diff --git a/backend/internal/serverconfig/service_test.go b/backend/internal/serverconfig/service_test.go index 249dc75d71..e47f94bd81 100644 --- a/backend/internal/serverconfig/service_test.go +++ b/backend/internal/serverconfig/service_test.go @@ -71,6 +71,7 @@ const ( writableVal = "decoded-writable" incomingVal = "decoded-incoming" mergedVal = "decoded-merged" + patchedVal = "patched" ) // --- ListConfigNames --- diff --git a/backend/pkg/thunderidengine/providers/model.go b/backend/pkg/thunderidengine/providers/model.go index db7f830c4f..96a49de675 100644 --- a/backend/pkg/thunderidengine/providers/model.go +++ b/backend/pkg/thunderidengine/providers/model.go @@ -715,6 +715,7 @@ type InboundClient struct { Assertion *AssertionConfig LoginConsent *LoginConsentConfig AllowedUserTypes []string + PasskeyAllowedOrigins []string // Attestation holds the optional platform attestation config that lets a mobile client prove // its binary identity to initiate a flow directly, independent of any protocol profile. Attestation *AttestationConfig @@ -1031,8 +1032,9 @@ type InboundAuthProfile struct { LayoutID string `json:"layoutId,omitempty" yaml:"layoutId,omitempty" jsonschema:"Layout configuration ID. Optional. Customizes the screen structure and component positioning of login pages."` Assertion *AssertionConfig `json:"assertion,omitempty" yaml:"assertion,omitempty" jsonschema:"Assertion configuration. Optional. Customize assertion validity periods and included user attributes."` LoginConsent *LoginConsentConfig `json:"loginConsent,omitempty" yaml:"loginConsent,omitempty" jsonschema:"Login consent configuration settings."` - AllowedUserTypes []string `json:"allowedUserTypes,omitempty" yaml:"allowedUserTypes,omitempty" jsonschema:"Allowed user types. Optional. Restricts which user types can authenticate to and register against this resource."` - Attestation *AttestationConfig `json:"attestation,omitempty" yaml:"attestation,omitempty" jsonschema:"Platform attestation configuration. Optional. Enables a mobile client to initiate flows directly by proving its binary identity (e.g. Google Play Integrity), regardless of protocol. The service account credentials are write-only and never returned in responses."` + AllowedUserTypes []string `json:"allowedUserTypes,omitempty" yaml:"allowedUserTypes,omitempty" jsonschema:"Allowed user types. Optional. Restricts which user types can authenticate to and register against this resource."` + PasskeyAllowedOrigins []string `json:"passkeyAllowedOrigins,omitempty" yaml:"passkeyAllowedOrigins,omitempty" jsonschema:"Allowed origins for WebAuthn/passkey operations for this application. Optional. When set, overrides the server-level passkey allowed origins for flow-based passkey operations."` + Attestation *AttestationConfig `json:"attestation,omitempty" yaml:"attestation,omitempty" jsonschema:"Platform attestation configuration. Optional. Enables a mobile client to initiate flows directly by proving its binary identity (e.g. Google Play Integrity), regardless of protocol. The service account credentials are write-only and never returned in responses."` } // OAuthConfigWithSecret is the wire input shape and the create/update echo response shape. diff --git a/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/EditAdvancedSettings.tsx b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/EditAdvancedSettings.tsx index 1a1acdbf51..41456bc5ed 100644 --- a/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/EditAdvancedSettings.tsx +++ b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/EditAdvancedSettings.tsx @@ -24,6 +24,7 @@ import CertificateSection from './CertificateSection'; import IdentityAssertionsSection from './IdentityAssertionsSection'; import MetadataSection from './MetadataSection'; import OAuth2ConfigSection from './OAuth2ConfigSection'; +import PasskeysSection from './PasskeysSection'; import type {Application} from '../../../models/application'; import type {ApplicationTemplate} from '../../../models/application-templates'; import type {InboundAuthConfig} from '../../../models/inbound-auth'; @@ -98,12 +99,16 @@ export default function EditAdvancedSettings({ // Identity assertions and attestation validate independently; each is tracked separately so one // resolving doesn't clobber the other's still-invalid state when both report to the single // upward onValidationChange prop. + // Identity assertions, attestation, and passkeys validate independently; each is tracked + // separately so one resolving doesn't clobber the other's still-invalid state when both report + // to the single upward onValidationChange prop. const [identityAssertionsInvalid, setIdentityAssertionsInvalid] = useState(false); const [attestationInvalid, setAttestationInvalid] = useState(false); + const [passkeysInvalid, setPasskeysInvalid] = useState(false); useEffect(() => { - onValidationChange?.(identityAssertionsInvalid || attestationInvalid); - }, [identityAssertionsInvalid, attestationInvalid, onValidationChange]); + onValidationChange?.(identityAssertionsInvalid || attestationInvalid || passkeysInvalid); + }, [identityAssertionsInvalid, attestationInvalid, passkeysInvalid, onValidationChange]); const handleOAuth2ConfigChange = (updates: Partial) => { const currentInboundAuth: InboundAuthConfig[] = editedApp.inboundAuthConfig ?? application.inboundAuthConfig ?? []; @@ -128,6 +133,12 @@ export default function EditAdvancedSettings({ // the user clearing attestation. Only fall back to the stored value when the field is untouched. const currentAttestation = 'attestation' in editedApp ? editedApp.attestation : application.attestation; + const handlePasskeysChange = (origins: string[]): void => { + onFieldChange('passkeyAllowedOrigins', origins); + }; + + const currentPasskeyOrigins = editedApp.passkeyAllowedOrigins ?? application.passkeyAllowedOrigins ?? []; + const handleTokenConfigChange = (tokenUpdates: Partial, oauth2Updates: Partial = {}) => { const currentInboundAuth: InboundAuthConfig[] = editedApp.inboundAuthConfig ?? application.inboundAuthConfig ?? []; const updatedInboundAuth = currentInboundAuth.map((auth) => @@ -189,6 +200,12 @@ export default function EditAdvancedSettings({ onValidationChange={setAttestationInvalid} /> )} + ); diff --git a/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/PasskeysSection.tsx b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/PasskeysSection.tsx new file mode 100644 index 0000000000..a886f106c9 --- /dev/null +++ b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/PasskeysSection.tsx @@ -0,0 +1,173 @@ +/** + * 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. + */ + +import {SettingsCard} from '@thunderid/components'; +import {Box, Button, FormControl, IconButton, Stack, TextField, Tooltip} from '@wso2/oxygen-ui'; +import {Plus, Trash} from '@wso2/oxygen-ui-icons-react'; +import {useEffect, useState, type JSX} from 'react'; +import {useTranslation} from 'react-i18next'; + +interface PasskeysSectionProps { + /** + * The current list of allowed origins for passkey operations. + */ + allowedOrigins?: string[]; + /** + * Callback invoked when the list changes. Omit to render read-only. + */ + onPasskeysChange?: (origins: string[]) => void; + /** + * Whether inputs should be disabled (e.g. read-only resource). + */ + disabled?: boolean; + /** + * Called whenever the section's validation state changes. `true` means at least one origin is + * empty or not a valid URL, so the parent can block Save. + */ + onValidationChange?: (hasErrors: boolean) => void; +} + +const isValidURL = (value: string): boolean => { + try { + return Boolean(new URL(value)); + } catch { + return false; + } +}; + +export default function PasskeysSection({ + allowedOrigins = undefined, + onPasskeysChange = undefined, + disabled = false, + onValidationChange = undefined, +}: PasskeysSectionProps): JSX.Element | null { + const {t} = useTranslation(); + const [errors, setErrors] = useState>({}); + + const origins = allowedOrigins ?? []; + const isEditable = Boolean(onPasskeysChange) && !disabled; + + const hasInvalidOrigins = origins.length > 0 && origins.some((o) => !o.trim() || !isValidURL(o)); + + useEffect(() => { + onValidationChange?.(hasInvalidOrigins); + }, [hasInvalidOrigins, onValidationChange]); + + const commit = (next: string[]): void => { + if (!isEditable) return; + onPasskeysChange?.(next); + }; + + const handleChange = (index: number, value: string): void => { + const next = [...origins]; + next[index] = value; + if (value.trim()) { + setErrors((prev) => { + const copy = {...prev}; + delete copy[index]; + return copy; + }); + } + commit(next); + }; + + const handleBlur = (index: number): void => { + const value = origins[index] ?? ''; + if (!value.trim()) { + setErrors((prev) => ({ + ...prev, + [index]: t('applications:edit.advanced.passkeys.allowedOrigins.error.empty', 'Origin cannot be empty'), + })); + return; + } + if (!isValidURL(value)) { + setErrors((prev) => ({ + ...prev, + [index]: t('applications:edit.advanced.passkeys.allowedOrigins.error.invalid', 'Enter a valid URL'), + })); + } + }; + + const handleAdd = (): void => { + commit([...origins, '']); + }; + + const handleRemove = (index: number): void => { + const next = origins.filter((_, i) => i !== index); + setErrors((prev) => { + const copy: Record = {}; + Object.entries(prev).forEach(([key, value]) => { + const i = parseInt(key, 10); + if (i < index) copy[i] = value; + else if (i > index) copy[i - 1] = value; + }); + return copy; + }); + commit(next); + }; + + return ( + + + + {origins.map((origin, index) => ( + // eslint-disable-next-line react/no-array-index-key + + + handleChange(index, e.target.value)} + onBlur={() => handleBlur(index)} + error={!!errors[index]} + helperText={errors[index]} + placeholder={t( + 'applications:edit.advanced.passkeys.allowedOrigins.placeholder', + 'https://app.example.com', + )} + disabled={!isEditable} + /> + + {isEditable && ( + + handleRemove(index)} color="error" sx={{mt: 1}}> + + + + )} + + ))} + {isEditable && ( + + + + )} + + + + ); +} diff --git a/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/EditAdvancedSettings.test.tsx b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/EditAdvancedSettings.test.tsx index 7483f4416b..c489e55f2d 100644 --- a/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/EditAdvancedSettings.test.tsx +++ b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/EditAdvancedSettings.test.tsx @@ -17,6 +17,7 @@ */ import {fireEvent, render, screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; import CertificateTypes from '../../../../constants/certificate-types'; import type {Application} from '../../../../models/application'; @@ -381,4 +382,155 @@ describe('EditAdvancedSettings', () => { expect(onValidationChange).toHaveBeenLastCalledWith(true); }); }); + + describe('PasskeysSection Integration', () => { + it('renders the Passkeys section', () => { + render( + , + ); + + expect(screen.getByText('applications:edit.advanced.labels.passkeys')).toBeInTheDocument(); + }); + + it('displays existing passkeyAllowedOrigins from the application', () => { + const appWithOrigins: Application = { + ...mockApplication, + passkeyAllowedOrigins: ['https://app.example.com'], + }; + + render( + , + ); + + expect(screen.getByDisplayValue('https://app.example.com')).toBeInTheDocument(); + }); + + it('prefers editedApp.passkeyAllowedOrigins over application.passkeyAllowedOrigins', () => { + const appWithOrigins: Application = { + ...mockApplication, + passkeyAllowedOrigins: ['https://old.example.com'], + }; + + render( + , + ); + + expect(screen.getByDisplayValue('https://new.example.com')).toBeInTheDocument(); + expect(screen.queryByDisplayValue('https://old.example.com')).not.toBeInTheDocument(); + }); + + it('calls onFieldChange with passkeyAllowedOrigins when origins change', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click( + screen.getByRole('button', {name: /applications:edit.advanced.passkeys.allowedOrigins.addOrigin/i}), + ); + + expect(mockOnFieldChange).toHaveBeenCalledWith('passkeyAllowedOrigins', ['']); + }); + + it('passes disabled=true to PasskeysSection when the application is read-only', () => { + const readOnlyApp: Application = {...mockApplication, isReadOnly: true}; + + render( + , + ); + + // In read-only mode the Add Origin button should not be present + expect(screen.queryByRole('button', {name: /Add Origin/i})).not.toBeInTheDocument(); + }); + + it('reports hasErrors=true via onValidationChange when an origin is empty', () => { + const onValidationChange = vi.fn(); + + render( + , + ); + + // An empty string origin is immediately invalid; the effect fires on mount. + expect(onValidationChange).toHaveBeenCalledWith(true); + }); + + it('reports hasErrors=true via onValidationChange when an origin is not a valid URL', () => { + const onValidationChange = vi.fn(); + + render( + , + ); + + expect(onValidationChange).toHaveBeenCalledWith(true); + }); + + it('reports hasErrors=false via onValidationChange when all origins are valid URLs', () => { + const onValidationChange = vi.fn(); + + render( + , + ); + + expect(onValidationChange).toHaveBeenLastCalledWith(false); + }); + + it('reports hasErrors=false via onValidationChange when the origins list is empty', () => { + const onValidationChange = vi.fn(); + + render( + , + ); + + expect(onValidationChange).toHaveBeenLastCalledWith(false); + }); + }); }); diff --git a/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/PasskeysSection.test.tsx b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/PasskeysSection.test.tsx new file mode 100644 index 0000000000..9009eb1f9d --- /dev/null +++ b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/PasskeysSection.test.tsx @@ -0,0 +1,228 @@ +/** + * 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. + */ + +import {render, screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {describe, it, expect, vi, beforeEach} from 'vitest'; +import PasskeysSection from '../PasskeysSection'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string) => fallback ?? key, + }), +})); + +describe('PasskeysSection', () => { + const mockOnChange = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Rendering', () => { + it('renders the section title and description', () => { + render(); + + expect(screen.getByText('Passkey Allowed Origins')).toBeInTheDocument(); + expect( + screen.getByText('Allowed origins for passkey operations initiated through this application.'), + ).toBeInTheDocument(); + }); + + it('renders an existing origin', () => { + render(); + + expect(screen.getByDisplayValue('https://app.example.com')).toBeInTheDocument(); + }); + + it('renders multiple existing origins', () => { + render( + , + ); + + expect(screen.getByDisplayValue('https://app.example.com')).toBeInTheDocument(); + expect(screen.getByDisplayValue('https://mobile.example.com')).toBeInTheDocument(); + }); + + it('renders no inputs when allowedOrigins is empty', () => { + render(); + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + }); + + it('shows the Add Origin button when editable', () => { + render(); + + expect(screen.getByRole('button', {name: /Add Origin/i})).toBeInTheDocument(); + }); + + it('hides the Add Origin and Delete buttons when read-only (no onChange)', () => { + render(); + + expect(screen.queryByRole('button', {name: /Add Origin/i})).not.toBeInTheDocument(); + expect(screen.queryByRole('button', {name: /Delete/i})).not.toBeInTheDocument(); + }); + + it('hides the Add Origin and Delete buttons when disabled', () => { + render(); + + expect(screen.queryByRole('button', {name: /Add Origin/i})).not.toBeInTheDocument(); + expect(screen.queryByRole('button', {name: /Delete/i})).not.toBeInTheDocument(); + }); + + it('renders inputs as disabled when disabled prop is true', () => { + render(); + + expect(screen.getByDisplayValue('https://app.example.com')).toBeDisabled(); + }); + }); + + describe('Adding origins', () => { + it('appends an empty string when "Add Origin" is clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', {name: /Add Origin/i})); + + expect(mockOnChange).toHaveBeenCalledWith(['https://app.example.com', '']); + }); + + it('appends to an empty list', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', {name: /Add Origin/i})); + + expect(mockOnChange).toHaveBeenCalledWith(['']); + }); + }); + + describe('Removing origins', () => { + it('removes an origin when its Delete button is clicked', async () => { + const user = userEvent.setup(); + render( + , + ); + + const deleteButtons = screen.getAllByRole('button', {name: /Delete/i}); + await user.click(deleteButtons[0]); + + expect(mockOnChange).toHaveBeenCalledWith(['https://mobile.example.com']); + }); + + it('removes the last origin, leaving an empty list', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', {name: /Delete/i})); + + expect(mockOnChange).toHaveBeenCalledWith([]); + }); + + it('shifts error indices down after removing an earlier entry', async () => { + const user = userEvent.setup(); + render(); + + // Blur the first and third inputs to trigger empty-field errors on those two + const inputs = screen.getAllByRole('textbox'); + await user.click(inputs[0]); + await user.tab(); + await user.click(inputs[2]); + await user.tab(); + + expect(screen.getAllByText('Origin cannot be empty')).toHaveLength(2); + + // Remove the middle (index 1) entry — the remaining two error entries should shift + const deleteButtons = screen.getAllByRole('button', {name: /Delete/i}); + await user.click(deleteButtons[1]); + + // onChange should be called with the two remaining empty strings + expect(mockOnChange).toHaveBeenCalledWith(['', '']); + }); + }); + + describe('Editing origins', () => { + it('calls onChange on each keystroke', async () => { + const user = userEvent.setup({delay: null}); + render(); + + const input = screen.getByPlaceholderText('https://app.example.com'); + await user.type(input, 'h'); + + expect(mockOnChange).toHaveBeenCalledWith(['h']); + }); + + it('clears a field error while the user is typing', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByPlaceholderText('https://app.example.com'); + + // Blur to trigger the empty error + await user.click(input); + await user.tab(); + expect(screen.getByText('Origin cannot be empty')).toBeInTheDocument(); + + // Type something — the error should disappear + await user.type(input, 'h'); + expect(screen.queryByText('Origin cannot be empty')).not.toBeInTheDocument(); + }); + }); + + describe('Validation', () => { + it('shows an empty-field error when an empty input is blurred', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByPlaceholderText('https://app.example.com'); + await user.click(input); + await user.tab(); + + expect(screen.getByText('Origin cannot be empty')).toBeInTheDocument(); + }); + + it('shows an invalid-URL error for a non-URL value on blur', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByDisplayValue('not a url'); + await user.click(input); + await user.tab(); + + expect(screen.getByText('Enter a valid URL')).toBeInTheDocument(); + }); + + it('shows no inline error for a valid URL after blur', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByDisplayValue('https://app.example.com'); + await user.click(input); + await user.tab(); + + expect(screen.queryByText('Origin cannot be empty')).not.toBeInTheDocument(); + expect(screen.queryByText('Enter a valid URL')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/apps/console/src/features/applications/models/application.ts b/frontend/apps/console/src/features/applications/models/application.ts index d67d648266..01c1e586d1 100644 --- a/frontend/apps/console/src/features/applications/models/application.ts +++ b/frontend/apps/console/src/features/applications/models/application.ts @@ -338,6 +338,13 @@ export interface Application { */ attestation?: AttestationConfig | null; + /** + * Allowed origins for WebAuthn/passkey operations initiated through this application. + * When set, overrides the server-level passkey allowed origins for flow-based passkey operations. + * @example ['https://app.example.com', 'https://mobile.example.com'] + */ + passkeyAllowedOrigins?: string[]; + /** * Whether this application is read-only (declarative/immutable) */ diff --git a/frontend/packages/i18n/src/locales/en-US.ts b/frontend/packages/i18n/src/locales/en-US.ts index 9af1da88ce..4905afb59a 100644 --- a/frontend/packages/i18n/src/locales/en-US.ts +++ b/frontend/packages/i18n/src/locales/en-US.ts @@ -2496,6 +2496,16 @@ const translations = { 'edit.advanced.attestation.hint.bundleId': 'The iOS bundle identifier that must match the attested app.', 'edit.advanced.attestation.error.appleIncomplete': 'Both Team ID and Bundle ID are required together.', + /* Passkeys section */ + 'edit.advanced.labels.passkeys': 'Passkey Allowed Origins', + 'edit.advanced.passkeys.intro': 'Allowed origins for passkey operations initiated through this application.', + 'edit.advanced.passkeys.serverFallbackHint': + 'Origins listed here are automatically included in the server CORS allowed origins.', + 'edit.advanced.passkeys.allowedOrigins.placeholder': 'https://app.example.com', + 'edit.advanced.passkeys.allowedOrigins.addOrigin': 'Add Origin', + 'edit.advanced.passkeys.allowedOrigins.error.empty': 'Origin cannot be empty', + 'edit.advanced.passkeys.allowedOrigins.error.invalid': 'Enter a valid URL', + /* -------------------- Edit page -------------------- */ // Common 'edit.page.error': 'Failed to load application information',