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); + }