From d6a47243db5a9c49addb357e3af86b30bbddf1ad Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Fri, 7 Aug 2026 11:17:38 +0200 Subject: [PATCH] feat: add settings for limiting what passkeys can be used (#1662) Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com> --- backend/internal/appconfig/appconfig_actor.go | 15 +- backend/internal/appconfig/model.go | 26 +++ backend/internal/appconfig/model_test.go | 19 ++ backend/internal/apperror/constructors.go | 4 + backend/internal/apperror/error.go | 1 + backend/internal/dto/app_config_dto.go | 3 + backend/internal/webauthn/handler.go | 14 +- backend/internal/webauthn/service.go | 46 ++++- backend/internal/webauthn/service_test.go | 82 +++++++- frontend/messages/en.json | 21 ++- .../lib/components/form/url-list-input.svelte | 15 +- .../ui/select/select-content.svelte | 2 +- .../components/ui/select/select-group.svelte | 2 +- .../components/ui/select/select-item.svelte | 2 +- .../types/application-configuration.type.ts | 4 + frontend/src/lib/utils/error-util.ts | 1 + frontend/src/routes/device/+page.svelte | 4 +- .../application-configuration/+page.svelte | 43 +++-- .../app-config-dynamic-clients-form.svelte | 13 +- .../forms/app-config-passkeys-form.svelte | 175 ++++++++++++++++++ .../app-config-signup-defaults-form.svelte | 30 +-- tests/specs/application-configuration.spec.ts | 56 +++++- 22 files changed, 521 insertions(+), 57 deletions(-) create mode 100644 frontend/src/routes/settings/admin/application-configuration/forms/app-config-passkeys-form.svelte diff --git a/backend/internal/appconfig/appconfig_actor.go b/backend/internal/appconfig/appconfig_actor.go index ab678991..a5d9840d 100644 --- a/backend/internal/appconfig/appconfig_actor.go +++ b/backend/internal/appconfig/appconfig_actor.go @@ -59,13 +59,16 @@ func (a *appConfigActor) Bootstrap(parentCtx context.Context, data actor.Envelop return fmt.Errorf("error retrieving actor state: %w", err) } - // If we already have a state, nothing else to do + // Upgrade existing state with defaults added by newer Pocket ID versions if state != nil { - return nil + state = state.Clone() + if !state.applyDefaults() { + return nil + } } - // Check if the request data contains legacy config to init from - if data != nil { + // Check if a new state should be initialized from legacy configuration + if state == nil && data != nil { payload := appConfigActorBootstrap{} err = data.Decode(&payload) if err != nil { @@ -80,12 +83,12 @@ func (a *appConfigActor) Bootstrap(parentCtx context.Context, data actor.Envelop } } - // If we still have no state, generate a new default config + // Initialize a new state with defaults when no legacy configuration exists if state == nil { state = getDefaultConfig() } - // Save the updated state + // Persist new and upgraded state eagerly so every later activation sees the complete model ctx, cancel = context.WithTimeout(parentCtx, 10*time.Second) defer cancel() err = a.client.SetState(ctx, state, nil) diff --git a/backend/internal/appconfig/model.go b/backend/internal/appconfig/model.go index 676069c4..ff74e66b 100644 --- a/backend/internal/appconfig/model.go +++ b/backend/internal/appconfig/model.go @@ -61,6 +61,10 @@ type AppConfigModel struct { LdapAttributeGroupName AppConfigValue `json:"ldapAttributeGroupName"` LdapAdminGroupName AppConfigValue `json:"ldapAdminGroupName"` LdapSoftDeleteUsers AppConfigValue `json:"ldapSoftDeleteUsers" type:"bool"` + // WebAuthn + WebauthnUserVerification AppConfigValue `json:"webauthnUserVerification"` + WebauthnAllowSyncedPasskeys AppConfigValue `json:"webauthnAllowSyncedPasskeys" type:"bool"` + WebauthnAuthenticatorAttachment AppConfigValue `json:"webauthnAuthenticatorAttachment"` // OIDC CIMDURLAllowlist AppConfigValue `json:"cimdUrlAllowlist"` // JSON-encoded array of strings } @@ -147,11 +151,33 @@ func getDefaultConfig() *AppConfigModel { LdapAttributeGroupName: "", LdapAdminGroupName: "", LdapSoftDeleteUsers: "true", + // WebAuthn + WebauthnUserVerification: "required", + WebauthnAllowSyncedPasskeys: "true", + WebauthnAuthenticatorAttachment: "any", // OIDC CIMDURLAllowlist: "[]", } } +// applyDefaults fills empty properties from the default configuration and reports whether the model changed +func (m *AppConfigModel) applyDefaults() bool { + defaults := reflect.ValueOf(getDefaultConfig()).Elem() + values := reflect.ValueOf(m).Elem() + changed := false + + for i := range values.NumField() { + if values.Field(i).String() != "" || defaults.Field(i).String() == "" { + continue + } + + values.Field(i).Set(defaults.Field(i)) + changed = true + } + + return changed +} + // Replace updates every configuration property with the values from the input DTO // An empty string value resets the corresponding property to its default value func (m *AppConfigModel) Replace(input dto.AppConfigUpdateDto) { diff --git a/backend/internal/appconfig/model_test.go b/backend/internal/appconfig/model_test.go index 961760f3..267ed753 100644 --- a/backend/internal/appconfig/model_test.go +++ b/backend/internal/appconfig/model_test.go @@ -106,6 +106,25 @@ func TestAppConfigModel_Replace(t *testing.T) { }) } +func TestAppConfigModel_ApplyDefaults(t *testing.T) { + m := &AppConfigModel{AppName: "Custom Name"} + + assert.True(t, m.applyDefaults()) + assert.Equal(t, AppConfigValue("Custom Name"), m.AppName) + + defaults := reflect.ValueOf(getDefaultConfig()).Elem() + values := reflect.ValueOf(m).Elem() + modelType := values.Type() + for i := range values.NumField() { + if modelType.Field(i).Name == "AppName" { + continue + } + assert.Equal(t, defaults.Field(i).Interface(), values.Field(i).Interface(), modelType.Field(i).Name) + } + + assert.False(t, m.applyDefaults()) +} + func TestAppConfigModel_Clone(t *testing.T) { t.Run("clones every property", func(t *testing.T) { // Populate every property with a unique marker so we can assert each one is copied diff --git a/backend/internal/apperror/constructors.go b/backend/internal/apperror/constructors.go index 96c8e52e..3a73716e 100644 --- a/backend/internal/apperror/constructors.go +++ b/backend/internal/apperror/constructors.go @@ -122,6 +122,10 @@ func PasskeyUserVerificationRequired(cause error) *Error { return Wrap(cause, CodePasskeyUserVerificationRequired, http.StatusBadRequest, "Your passkey couldn't verify you. If you're using a security key, configure a FIDO2 PIN and try again") } +func SyncedPasskeyNotAllowed() *Error { + return New(CodeSyncedPasskeyNotAllowed, http.StatusBadRequest, "Synced passkeys are not allowed") +} + func ReservedClaim(key string) *Error { return New(CodeReservedClaim, http.StatusBadRequest, fmt.Sprintf("Claim %s is reserved and can't be used", key)). WithDetail("key", key). diff --git a/backend/internal/apperror/error.go b/backend/internal/apperror/error.go index 22d4ba6c..08197f8c 100644 --- a/backend/internal/apperror/error.go +++ b/backend/internal/apperror/error.go @@ -26,6 +26,7 @@ const ( CodeInvalidWebAuthnResponse Code = "invalid_webauthn_response" CodeWebAuthnAuthenticationFailed Code = "webauthn_authentication_failed" CodePasskeyUserVerificationRequired Code = "passkey_user_verification_required" + CodeSyncedPasskeyNotAllowed Code = "synced_passkey_not_allowed" CodeInvalidWebAuthnSession Code = "invalid_webauthn_session" CodeUserNotFound Code = "user_not_found" CodeUserDisabled Code = "user_disabled" diff --git a/backend/internal/dto/app_config_dto.go b/backend/internal/dto/app_config_dto.go index a5835edc..6cebfec2 100644 --- a/backend/internal/dto/app_config_dto.go +++ b/backend/internal/dto/app_config_dto.go @@ -50,6 +50,9 @@ type AppConfigUpdateDto struct { LdapAttributeGroupName string `json:"ldapAttributeGroupName"` LdapAdminGroupName string `json:"ldapAdminGroupName"` LdapSoftDeleteUsers string `json:"ldapSoftDeleteUsers"` + WebauthnUserVerification string `json:"webauthnUserVerification" binding:"required,oneof=required preferred"` + WebauthnAllowSyncedPasskeys string `json:"webauthnAllowSyncedPasskeys" binding:"required,oneof=true false"` + WebauthnAuthenticatorAttachment string `json:"webauthnAuthenticatorAttachment" binding:"required,oneof=any platform cross-platform"` EmailOneTimeAccessAsAdminEnabled string `json:"emailOneTimeAccessAsAdminEnabled" binding:"required"` EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required"` EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"` diff --git a/backend/internal/webauthn/handler.go b/backend/internal/webauthn/handler.go index 06b5954f..7d905f1d 100644 --- a/backend/internal/webauthn/handler.go +++ b/backend/internal/webauthn/handler.go @@ -40,13 +40,18 @@ func (h *handler) beginRegistration(c *gin.Context) error { } func (h *handler) verifyRegistration(c *gin.Context) error { + dbConfig, err := h.appConfig.GetConfig(c.Request.Context()) + if err != nil { + return fmt.Errorf("error loading app configuration: %w", err) + } + sessionID, err := c.Cookie(cookie.SessionIdCookieName) if err != nil { return apperror.MissingSessionID() } userID := c.GetString("userID") - credential, err := h.service.VerifyRegistration(c.Request.Context(), sessionID, userID, c.Request, c.ClientIP()) + credential, err := h.service.VerifyRegistration(c.Request.Context(), dbConfig, sessionID, userID, c.Request, c.ClientIP()) if err != nil { return err } @@ -61,7 +66,12 @@ func (h *handler) verifyRegistration(c *gin.Context) error { } func (h *handler) beginLogin(c *gin.Context) error { - options, err := h.service.BeginLogin(c.Request.Context()) + dbConfig, err := h.appConfig.GetConfig(c.Request.Context()) + if err != nil { + return fmt.Errorf("error loading app configuration: %w", err) + } + + options, err := h.service.BeginLogin(c.Request.Context(), dbConfig) if err != nil { return err } diff --git a/backend/internal/webauthn/service.go b/backend/internal/webauthn/service.go index 2780b5a2..35302c7c 100644 --- a/backend/internal/webauthn/service.go +++ b/backend/internal/webauthn/service.go @@ -93,6 +93,10 @@ func (s *Service) BeginRegistration(ctx context.Context, dbConfig *appconfig.App options, session, err := s.webAuthn.BeginRegistration( &user, + gowebauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{ + AuthenticatorAttachment: authenticatorAttachment(dbConfig), + UserVerification: userVerificationRequirement(dbConfig), + }), gowebauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired), gowebauthn.WithExclusions(user.WebAuthnCredentialDescriptors()), gowebauthn.WithExtensions(map[string]any{"credProps": true}), // Required for Firefox Android to properly save the key in Google password manager @@ -128,7 +132,7 @@ func (s *Service) BeginRegistration(ctx context.Context, dbConfig *appconfig.App }, nil } -func (s *Service) VerifyRegistration(ctx context.Context, sessionID string, userID string, r *http.Request, ipAddress string) (model.WebauthnCredential, error) { +func (s *Service) VerifyRegistration(ctx context.Context, dbConfig *appconfig.AppConfigModel, sessionID string, userID string, r *http.Request, ipAddress string) (model.WebauthnCredential, error) { tx := s.db.Begin() defer func() { tx.Rollback() @@ -171,6 +175,9 @@ func (s *Service) VerifyRegistration(ctx context.Context, sessionID string, user if err != nil { return model.WebauthnCredential{}, classifyPasskeyError(err, apperror.InvalidWebAuthnResponse) } + if err := validateCredentialPolicy(dbConfig, credential); err != nil { + return model.WebauthnCredential{}, err + } // Determine passkey name using AAGUID and User-Agent passkeyName := s.determinePasskeyName(credential.Authenticator.AAGUID) @@ -214,8 +221,10 @@ func (s *Service) determinePasskeyName(aaguid []byte) string { return "New Passkey" // Default fallback } -func (s *Service) BeginLogin(ctx context.Context) (*PublicKeyCredentialRequestOptions, error) { - options, session, err := s.webAuthn.BeginDiscoverableLogin() +func (s *Service) BeginLogin(ctx context.Context, dbConfig *appconfig.AppConfigModel) (*PublicKeyCredentialRequestOptions, error) { + options, session, err := s.webAuthn.BeginDiscoverableLogin( + gowebauthn.WithUserVerification(userVerificationRequirement(dbConfig)), + ) if err != nil { return nil, err } @@ -408,6 +417,37 @@ func (s *Service) updateWebAuthnConfig(dbConfig *appconfig.AppConfigModel) { s.webAuthn.Config.RPDisplayName = dbConfig.AppName.String() } +func userVerificationRequirement(dbConfig *appconfig.AppConfigModel) protocol.UserVerificationRequirement { + if dbConfig.WebauthnUserVerification == "preferred" { + return protocol.VerificationPreferred + } + + return protocol.VerificationRequired +} + +func authenticatorAttachment(dbConfig *appconfig.AppConfigModel) protocol.AuthenticatorAttachment { + switch dbConfig.WebauthnAuthenticatorAttachment { + case "platform": + return protocol.Platform + case "cross-platform": + return protocol.CrossPlatform + default: + return "" + } +} + +func allowsSyncedPasskeys(dbConfig *appconfig.AppConfigModel) bool { + return dbConfig.WebauthnAllowSyncedPasskeys.IsTrue() +} + +func validateCredentialPolicy(dbConfig *appconfig.AppConfigModel, credential *gowebauthn.Credential) error { + if !allowsSyncedPasskeys(dbConfig) && credential.Flags.BackupEligible { + return apperror.SyncedPasskeyNotAllowed() + } + + return nil +} + func (s *Service) CreateReauthenticationTokenWithAccessToken(ctx context.Context, accessToken string) (string, error) { tx := s.db.Begin() defer func() { diff --git a/backend/internal/webauthn/service_test.go b/backend/internal/webauthn/service_test.go index e67a94b2..ebdd90f8 100644 --- a/backend/internal/webauthn/service_test.go +++ b/backend/internal/webauthn/service_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/go-webauthn/webauthn/protocol" + gowebauthn "github.com/go-webauthn/webauthn/webauthn" "github.com/lestrrat-go/jwx/v3/jwt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -143,6 +144,85 @@ func TestWebAuthnDisplayNameUsesRequestConfig(t *testing.T) { require.Equal(t, "Custom App", service.webAuthn.Config.RPDisplayName) } +func TestBeginCeremoniesUseRequestConfig(t *testing.T) { + tests := []struct { + name string + userVerification appconfig.AppConfigValue + authenticator appconfig.AppConfigValue + wantUserVerification protocol.UserVerificationRequirement + wantAuthenticator protocol.AuthenticatorAttachment + }{ + { + name: "required verification with any authenticator", + userVerification: "required", + authenticator: "any", + wantUserVerification: protocol.VerificationRequired, + wantAuthenticator: "", + }, + { + name: "required verification with a platform authenticator", + userVerification: "required", + authenticator: "platform", + wantUserVerification: protocol.VerificationRequired, + wantAuthenticator: protocol.Platform, + }, + { + name: "preferred verification with a cross-platform authenticator", + userVerification: "preferred", + authenticator: "cross-platform", + wantUserVerification: protocol.VerificationPreferred, + wantAuthenticator: protocol.CrossPlatform, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + user := model.User{ + Base: model.Base{ID: "configured-user"}, + Username: "configured-user", + } + require.NoError(t, db.Create(&user).Error) + + service, err := newService(Dependencies{ + DB: db, + AppURL: "https://example.com", + }) + require.NoError(t, err) + + dbConfig := &appconfig.AppConfigModel{ + AppName: "Configured App", + WebauthnUserVerification: tc.userVerification, + WebauthnAuthenticatorAttachment: tc.authenticator, + } + + registration, err := service.BeginRegistration(t.Context(), dbConfig, user.ID) + require.NoError(t, err) + assert.Equal(t, tc.wantUserVerification, registration.Response.AuthenticatorSelection.UserVerification) + assert.Equal(t, tc.wantAuthenticator, registration.Response.AuthenticatorSelection.AuthenticatorAttachment) + assert.Equal(t, protocol.ResidentKeyRequirementRequired, registration.Response.AuthenticatorSelection.ResidentKey) + + login, err := service.BeginLogin(t.Context(), dbConfig) + require.NoError(t, err) + assert.Equal(t, tc.wantUserVerification, login.Response.UserVerification) + }) + } +} + +func TestSyncedPasskeyPolicy(t *testing.T) { + credential := &gowebauthn.Credential{ + Flags: gowebauthn.CredentialFlags{BackupEligible: true}, + } + + require.NoError(t, validateCredentialPolicy(&appconfig.AppConfigModel{WebauthnAllowSyncedPasskeys: "true"}, credential)) + + err := validateCredentialPolicy(&appconfig.AppConfigModel{WebauthnAllowSyncedPasskeys: "false"}, credential) + require.True(t, apperror.IsCode(err, apperror.CodeSyncedPasskeyNotAllowed)) + + credential.Flags.BackupEligible = false + require.NoError(t, validateCredentialPolicy(&appconfig.AppConfigModel{WebauthnAllowSyncedPasskeys: "false"}, credential)) +} + func TestClassifyPasskeyErrorRecognizesMissingUserVerification(t *testing.T) { rpIDHash := make([]byte, 32) authenticatorData := protocol.AuthenticatorData{ @@ -213,7 +293,7 @@ func TestCeremoniesRejectSessionThatDoesNotExist(t *testing.T) { t.Run("registration rejects an unknown session", func(t *testing.T) { service := setupService(t) - _, err := service.VerifyRegistration(t.Context(), "does-not-exist", userID, nil, "127.0.0.1") + _, err := service.VerifyRegistration(t.Context(), &appconfig.AppConfigModel{}, "does-not-exist", userID, nil, "127.0.0.1") require.Error(t, err) assert.True(t, apperror.IsCode(err, apperror.CodeInvalidWebAuthnSession)) diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 4d83c4fa..8a368501 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -118,6 +118,25 @@ "it_is_recommended_to_add_more_than_one_passkey": "It is recommended to add more than one passkey to avoid losing access to your account.", "account_details": "Account Details", "passkeys": "Passkeys", + "configure_passkey_settings": "Control which passkeys users can register and how they verify themselves.", + "user_verification": "User verification", + "user_verification_description": "Choose whether a biometric or PIN is required when registering or using a passkey.", + "user_verification_required": "Required", + "user_verification_required_description": "Require biometric or PIN verification. Passkeys that cannot verify the user will not work.", + "user_verification_preferred": "Preferred", + "user_verification_preferred_description": "Use biometric or PIN verification when available, but allow touch-only security keys.", + "allow_synced_passkeys": "Allow synced passkeys", + "allow_synced_passkeys_description": "Allow new passkeys that can be backed up and used across multiple devices.", + "allowed_authenticator_type": "Allowed authenticator type", + "allowed_authenticator_type_description": "Choose which type of authenticator users can use when registering a new passkey.", + "any_authenticator": "Any passkey", + "any_authenticator_description": "Allow both device passkeys and external security keys.", + "device_passkeys_only": "Device passkeys only", + "device_passkeys_only_description": "Allow only passkeys built into the user's device.", + "external_security_keys_only": "External security keys only", + "external_security_keys_only_description": "Allow only roaming authenticators such as USB or NFC security keys.", + "passkey_configuration_updated_successfully": "Passkey configuration updated successfully", + "synced_passkeys_not_allowed": "Synced passkeys are not allowed by your administrator", "manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Manage your passkeys that you can use to authenticate yourself.", "manage_this_users_passkeys": "Manage this user's passkeys.", "add_passkey": "Add Passkey", @@ -309,7 +328,7 @@ "client_id_metadata_documents": "Client ID Metadata Documents", "client_id_metadata_documents_description": "Client ID Metadata Documents (CIMD) let OAuth clients identify themselves using a URL. No preregistration necessary.", "cimd_url_allowlist": "Allowed metadata document URLs", - "cimd_url_allowlist_description": "Restrict which client ID metadata document URLs are accepted. Wildcards are supported. An empty list blocks all URLs.", + "cimd_url_allowlist_description": "Restrict which client ID metadata document URLs are accepted. {#link href=|https://pocket-id.org/docs/advanced/callback-url-wildcards|}Wildcards{/link} are supported. An empty list blocks all URLs.", "refresh": "Refresh", "oidc_client_metadata_refreshed_successfully": "Client metadata document refreshed successfully", "create_new_client_secret": "Create new client secret", diff --git a/frontend/src/lib/components/form/url-list-input.svelte b/frontend/src/lib/components/form/url-list-input.svelte index 081e0e01..6add418c 100644 --- a/frontend/src/lib/components/form/url-list-input.svelte +++ b/frontend/src/lib/components/form/url-list-input.svelte @@ -8,13 +8,24 @@ urls = $bindable(), error = null, testIdPrefix = 'url', - disabled = false + disabled = false, + keepAtLeastOne = false }: { urls: string[]; error?: string | null; testIdPrefix?: string; disabled?: boolean; + keepAtLeastOne?: boolean; } = $props(); + + function removeUrl(index: number) { + if (keepAtLeastOne && urls.length === 1) { + urls = ['']; + return; + } + + urls = urls.filter((_, urlIndex) => urlIndex !== index); + }
@@ -34,7 +45,7 @@ variant="outline" size="sm" aria-label={m.remove_url({ identifier: url || i + 1 })} - onclick={() => (urls = urls.filter((_, index) => index !== i))} + onclick={() => removeUrl(i)} {disabled} > diff --git a/frontend/src/lib/components/ui/select/select-content.svelte b/frontend/src/lib/components/ui/select/select-content.svelte index a084642d..076209d9 100644 --- a/frontend/src/lib/components/ui/select/select-content.svelte +++ b/frontend/src/lib/components/ui/select/select-content.svelte @@ -27,7 +27,7 @@ {preventScroll} data-slot="select-content" class={cn( - 'px-1 py-2 text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/5 dark:ring-foreground/10 min-w-36 rounded-3xl shadow-lg ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 isolate z-50 overflow-x-hidden overflow-y-auto animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!', + 'p-1.5 text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/5 dark:ring-foreground/10 min-w-36 rounded-2xl shadow-lg ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 isolate z-50 overflow-x-hidden overflow-y-auto animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!', className )} {...restProps} diff --git a/frontend/src/lib/components/ui/select/select-group.svelte b/frontend/src/lib/components/ui/select/select-group.svelte index 4df02d3f..c8fa3367 100644 --- a/frontend/src/lib/components/ui/select/select-group.svelte +++ b/frontend/src/lib/components/ui/select/select-group.svelte @@ -12,6 +12,6 @@ diff --git a/frontend/src/lib/components/ui/select/select-item.svelte b/frontend/src/lib/components/ui/select/select-item.svelte index 7bbd29e1..d1568408 100644 --- a/frontend/src/lib/components/ui/select/select-item.svelte +++ b/frontend/src/lib/components/ui/select/select-item.svelte @@ -18,7 +18,7 @@ {value} data-slot="select-item" class={cn( - "focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2.5 rounded-2xl py-1.5 pr-8 pl-3 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 focus:bg-accent data-highlighted:bg-accent data-highlighted:text-accent-foreground focus:text-accent-foreground relative flex w-full cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", + "focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2.5 rounded-xl py-1.5 pr-8 pl-3 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 focus:bg-accent data-highlighted:bg-accent data-highlighted:text-accent-foreground focus:text-accent-foreground relative flex w-full cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", className )} {...restProps} diff --git a/frontend/src/lib/types/application-configuration.type.ts b/frontend/src/lib/types/application-configuration.type.ts index 490c0540..8cf5ce3f 100644 --- a/frontend/src/lib/types/application-configuration.type.ts +++ b/frontend/src/lib/types/application-configuration.type.ts @@ -52,6 +52,10 @@ export type AllAppConfig = AppConfig & { ldapAttributeGroupName: string; ldapAdminGroupName: string; ldapSoftDeleteUsers: boolean; + // WebAuthn + webauthnUserVerification: 'required' | 'preferred'; + webauthnAllowSyncedPasskeys: boolean; + webauthnAuthenticatorAttachment: 'any' | 'platform' | 'cross-platform'; // OIDC cimdUrlAllowlist: string[]; }; diff --git a/frontend/src/lib/utils/error-util.ts b/frontend/src/lib/utils/error-util.ts index b1f8f697..ae335f13 100644 --- a/frontend/src/lib/utils/error-util.ts +++ b/frontend/src/lib/utils/error-util.ts @@ -19,6 +19,7 @@ const codeMessages: Record string> = { invalid_webauthn_response: () => m.passkey_response_invalid(), webauthn_authentication_failed: () => m.passkey_verification_failed(), passkey_user_verification_required: () => m.passkey_user_verification_required(), + synced_passkey_not_allowed: () => m.synced_passkeys_not_allowed(), device_login_expired: () => m.device_login_request_expired() }; diff --git a/frontend/src/routes/device/+page.svelte b/frontend/src/routes/device/+page.svelte index e18e8a5d..b0574e85 100644 --- a/frontend/src/routes/device/+page.svelte +++ b/frontend/src/routes/device/+page.svelte @@ -271,10 +271,10 @@
- + {m.general()} @@ -129,6 +130,16 @@ + + + + {m.images()} + {m.configure_application_images()} + + + + + @@ -143,6 +154,18 @@ + + + + {m.passkeys()} + {m.configure_passkey_settings()} + + + + + + + @@ -180,16 +203,4 @@ - - - - - {m.images()} - {m.configure_application_images()} - - - - - - diff --git a/frontend/src/routes/settings/admin/application-configuration/forms/app-config-dynamic-clients-form.svelte b/frontend/src/routes/settings/admin/application-configuration/forms/app-config-dynamic-clients-form.svelte index e4e09a2b..a27b31f0 100644 --- a/frontend/src/routes/settings/admin/application-configuration/forms/app-config-dynamic-clients-form.svelte +++ b/frontend/src/routes/settings/admin/application-configuration/forms/app-config-dynamic-clients-form.svelte @@ -1,5 +1,6 @@ +{#snippet cimdUrlAllowlistDescription()} + +{/snippet} +
- - + +
diff --git a/frontend/src/routes/settings/admin/application-configuration/forms/app-config-passkeys-form.svelte b/frontend/src/routes/settings/admin/application-configuration/forms/app-config-passkeys-form.svelte new file mode 100644 index 00000000..35c68f92 --- /dev/null +++ b/frontend/src/routes/settings/admin/application-configuration/forms/app-config-passkeys-form.svelte @@ -0,0 +1,175 @@ + + + +
+ + +
+ {m.user_verification()} + {m.user_verification_description()} +
+ + ($inputs.webauthnUserVerification.value = value as 'required' | 'preferred')} + > + + {userVerificationOptions[$inputs.webauthnUserVerification.value]?.label} + + + + {#each Object.entries(userVerificationOptions) as [value, option] (value)} + +
+ {option.label} + {option.description} +
+
+ {/each} +
+
+
+
+ + + +
+ {m.allow_synced_passkeys()} + {m.allow_synced_passkeys_description()} +
+
+ +
+ + +
+ {m.allowed_authenticator_type()} + {m.allowed_authenticator_type_description()} +
+ + ($inputs.webauthnAuthenticatorAttachment.value = value as + 'any' | 'platform' | 'cross-platform')} + > + + {authenticatorAttachmentOptions[$inputs.webauthnAuthenticatorAttachment.value]?.label} + + + + {#each Object.entries(authenticatorAttachmentOptions) as [value, option] (value)} + +
+ {option.label} + {option.description} +
+
+ {/each} +
+
+
+
+ + + + +
+
+ diff --git a/frontend/src/routes/settings/admin/application-configuration/forms/app-config-signup-defaults-form.svelte b/frontend/src/routes/settings/admin/application-configuration/forms/app-config-signup-defaults-form.svelte index 444ca875..9411ae7a 100644 --- a/frontend/src/routes/settings/admin/application-configuration/forms/app-config-signup-defaults-form.svelte +++ b/frontend/src/routes/settings/admin/application-configuration/forms/app-config-signup-defaults-form.svelte @@ -59,10 +59,12 @@
- {m.enable_user_signups()} - - {m.enable_user_signups_description()} - +
+ {m.enable_user_signups()} + + {m.enable_user_signups_description()} + +
- {m.user_groups()} - - {m.user_creation_groups_description()} - +
+ {m.user_groups()} + + {m.user_creation_groups_description()} + +
- {m.custom_claims()} - - {m.user_creation_claims_description()} - +
+ {m.custom_claims()} + + {m.user_creation_claims_description()} + +
diff --git a/tests/specs/application-configuration.spec.ts b/tests/specs/application-configuration.spec.ts index b02fd477..d9bae7d0 100644 --- a/tests/specs/application-configuration.spec.ts +++ b/tests/specs/application-configuration.spec.ts @@ -96,6 +96,54 @@ test.describe('Update user creation configuration', () => { }); }); +test('Update passkey configuration', async ({ page }) => { + await page.getByRole('tab', { name: 'Passkeys' }).click(); + + const userVerification = page.getByLabel('User verification'); + const authenticatorType = page.getByLabel('Allowed authenticator type'); + const allowSyncedPasskeys = page.getByRole('switch', { name: 'Allow synced passkeys' }); + + await expect(userVerification).toContainText('Required'); + await userVerification.click(); + await page.getByRole('option', { name: 'Preferred' }).click(); + + await expect(authenticatorType).toContainText('Any passkey'); + await authenticatorType.click(); + await page.getByRole('option', { name: 'External security keys only' }).click(); + + await expect(allowSyncedPasskeys).toBeChecked(); + await allowSyncedPasskeys.click(); + + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(page.locator('[data-type="success"]')).toHaveText( + 'Passkey configuration updated successfully' + ); + + const registrationResponse = await page.request.get('/api/webauthn/register/start'); + expect(registrationResponse.ok()).toBeTruthy(); + await expect(registrationResponse.json()).resolves.toMatchObject({ + authenticatorSelection: { + authenticatorAttachment: 'cross-platform', + userVerification: 'preferred' + } + }); + + const loginResponse = await page.request.get('/api/webauthn/login/start'); + expect(loginResponse.ok()).toBeTruthy(); + await expect(loginResponse.json()).resolves.toMatchObject({ + userVerification: 'preferred' + }); + + await page.reload(); + await page.getByRole('tab', { name: 'Passkeys' }).click(); + + await expect(page.getByLabel('User verification')).toContainText('Preferred'); + await expect(page.getByLabel('Allowed authenticator type')).toContainText( + 'External security keys only' + ); + await expect(page.getByRole('switch', { name: 'Allow synced passkeys' })).not.toBeChecked(); +}); + test('Update email configuration', async ({ page }) => { await page.getByRole('tab', { name: 'Email' }).click(); @@ -129,10 +177,6 @@ test('Update email configuration', async ({ page }) => { }); test.describe('Update application images', () => { - test.beforeEach(async ({ page }) => { - await page.getByRole('tab', { name: 'Images' }).click(); - }); - test('should upload images', async ({ page }) => { await page.getByLabel('Favicon').setInputFiles('resources/images/w3-schools-favicon.ico'); await page @@ -144,7 +188,7 @@ test.describe('Update application images', () => { .getByLabel('Default Profile Picture') .setInputFiles('resources/images/pingvin-share-logo.png'); await page.getByLabel('Background Image').setInputFiles('resources/images/clouds.jpg'); - await page.getByRole('button', { name: 'Save' }).click(); + await page.getByRole('button', { name: 'Save', exact: true }).nth(1).click(); await expect(page.locator('[data-type="success"]')).toHaveText( 'Images updated successfully. It may take a few minutes to update.' @@ -171,7 +215,7 @@ test.describe('Update application images', () => { const emailLogoInput = page.getByLabel('Email Logo'); await emailLogoInput.setInputFiles('resources/images/cloud-logo.svg'); - await page.getByRole('button', { name: 'Save' }).click(); + await page.getByRole('button', { name: 'Save', exact: true }).nth(1).click(); await expect(page.locator('[data-type="error"]')).toHaveText( 'File must be of type PNG or JPEG'