feat: add settings for limiting what passkeys can be used (#1662)

Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
This commit is contained in:
Elias Schneider
2026-08-07 11:17:38 +02:00
committed by GitHub
co-authored by Alessandro Segala
parent 448d271c94
commit d6a47243db
22 changed files with 521 additions and 57 deletions
@@ -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)
+26
View File
@@ -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) {
+19
View File
@@ -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
@@ -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).
+1
View File
@@ -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"
+3
View File
@@ -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"`
+12 -2
View File
@@ -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
}
+43 -3
View File
@@ -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() {
+81 -1
View File
@@ -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))
+20 -1
View File
@@ -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. <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>Wildcards</link> 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",
@@ -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);
}
</script>
<div>
@@ -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}
>
<LucideMinus class="size-4" />
@@ -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}
@@ -12,6 +12,6 @@
<SelectPrimitive.Group
bind:ref
data-slot="select-group"
class={cn('scroll-my-1.5 p-1.5', className)}
class={cn('scroll-my-1.5', className)}
{...restProps}
/>
@@ -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}
@@ -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[];
};
+1
View File
@@ -19,6 +19,7 @@ const codeMessages: Record<string, () => 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()
};
+2 -2
View File
@@ -271,10 +271,10 @@
<Button
class="flex-1"
variant="secondary"
disabled={isLoading}
isLoading={deviceLoginDecision === 'deny' || isLoading}
onclick={() => decideDeviceLogin('deny')}
>
{#if deviceLoginDecision === 'deny'}<Spinner data-icon="inline-start" />{/if}
<Spinner data-icon="inline-start" />
{m.deny()}
</Button>
<Button class="flex-1" {isLoading} onclick={() => decideDeviceLogin('approve')}>
@@ -13,6 +13,7 @@
import AppConfigEmailForm from './forms/app-config-email-form.svelte';
import AppConfigGeneralForm from './forms/app-config-general-form.svelte';
import AppConfigLdapForm from './forms/app-config-ldap-form.svelte';
import AppConfigPasskeysForm from './forms/app-config-passkeys-form.svelte';
import AppConfigSignupDefaultsForm from './forms/app-config-signup-defaults-form.svelte';
import UpdateApplicationImages from './update-application-images.svelte';
@@ -105,6 +106,9 @@
<Tabs.Trigger value="user-creation">
{m.user_creation()}
</Tabs.Trigger>
<Tabs.Trigger value="passkeys">
{m.passkeys()}
</Tabs.Trigger>
<Tabs.Trigger value="email">
{m.email()}
</Tabs.Trigger>
@@ -114,13 +118,10 @@
<Tabs.Trigger value="oidc">
{m.oidc()}
</Tabs.Trigger>
<Tabs.Trigger value="images">
{m.images()}
</Tabs.Trigger>
</Tabs.List>
</div>
<Tabs.Content value="general" id="application-configuration-general">
<Tabs.Content value="general" id="application-configuration-general" class="flex flex-col gap-4">
<Card.Root>
<Card.Header>
<Card.Title>{m.general()}</Card.Title>
@@ -129,6 +130,16 @@
<AppConfigGeneralForm {appConfig} callback={updateAppConfig} />
</Card.Content>
</Card.Root>
<Card.Root id="application-configuration-images">
<Card.Header>
<Card.Title>{m.images()}</Card.Title>
<Card.Description>{m.configure_application_images()}</Card.Description>
</Card.Header>
<Card.Content>
<UpdateApplicationImages callback={updateImages} />
</Card.Content>
</Card.Root>
</Tabs.Content>
<Tabs.Content value="user-creation" id="application-configuration-signup-defaults">
@@ -143,6 +154,18 @@
</Card.Root>
</Tabs.Content>
<Tabs.Content value="passkeys" id="application-configuration-passkeys">
<Card.Root>
<Card.Header>
<Card.Title>{m.passkeys()}</Card.Title>
<Card.Description>{m.configure_passkey_settings()}</Card.Description>
</Card.Header>
<Card.Content>
<AppConfigPasskeysForm {appConfig} callback={updateAppConfig} />
</Card.Content>
</Card.Root>
</Tabs.Content>
<Tabs.Content value="email" id="application-configuration-email">
<Card.Root>
<Card.Header>
@@ -180,16 +203,4 @@
</Card.Content>
</Card.Root>
</Tabs.Content>
<Tabs.Content value="images" id="application-configuration-images">
<Card.Root>
<Card.Header>
<Card.Title>{m.images()}</Card.Title>
<Card.Description>{m.configure_application_images()}</Card.Description>
</Card.Header>
<Card.Content>
<UpdateApplicationImages callback={updateImages} />
</Card.Content>
</Card.Root>
</Tabs.Content>
</Tabs.Root>
@@ -1,5 +1,6 @@
<script lang="ts">
import FormInput from '$lib/components/form/form-input.svelte';
import FormattedMessage from '$lib/components/formatted-message.svelte';
import UrlListInput from '$lib/components/form/url-list-input.svelte';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
@@ -16,7 +17,9 @@
callback: (updatedConfig: Partial<AllAppConfig>) => Promise<void>;
} = $props();
let cimdUrlAllowlist: string[] = $derived(appConfig.cimdUrlAllowlist || []);
let cimdUrlAllowlist: string[] = $derived(
appConfig.cimdUrlAllowlist?.length ? appConfig.cimdUrlAllowlist : ['']
);
let isLoading = $state(false);
async function onSubmit() {
@@ -31,10 +34,14 @@
}
</script>
{#snippet cimdUrlAllowlistDescription()}
<FormattedMessage message={m.cimd_url_allowlist_description} />
{/snippet}
<form onsubmit={preventDefault(onSubmit)}>
<fieldset class="flex flex-col gap-5" disabled={$appConfigStore.uiConfigDisabled}>
<FormInput label={m.cimd_url_allowlist()} description={m.cimd_url_allowlist_description()}>
<UrlListInput bind:urls={cimdUrlAllowlist} testIdPrefix="cimd-url-allowlist" />
<FormInput label={m.cimd_url_allowlist()} description={cimdUrlAllowlistDescription}>
<UrlListInput bind:urls={cimdUrlAllowlist} testIdPrefix="cimd-url-allowlist" keepAtLeastOne />
</FormInput>
<div class="flex justify-end pt-2">
@@ -0,0 +1,175 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Field from '$lib/components/ui/field';
import * as Select from '$lib/components/ui/select';
import { Switch } from '$lib/components/ui/switch';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { AllAppConfig } from '$lib/types/application-configuration.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner';
import { z } from 'zod/v4';
let {
callback,
appConfig
}: {
appConfig: AllAppConfig;
callback: (appConfig: Partial<AllAppConfig>) => Promise<void>;
} = $props();
let isLoading = $state(false);
const formSchema = z.object({
webauthnUserVerification: z.enum(['required', 'preferred']),
webauthnAllowSyncedPasskeys: z.boolean(),
webauthnAuthenticatorAttachment: z.enum(['any', 'platform', 'cross-platform'])
});
const initialConfig = {
webauthnUserVerification: appConfig.webauthnUserVerification,
webauthnAllowSyncedPasskeys: appConfig.webauthnAllowSyncedPasskeys,
webauthnAuthenticatorAttachment: appConfig.webauthnAuthenticatorAttachment
};
const userVerificationOptions = {
required: {
label: m.user_verification_required(),
description: m.user_verification_required_description()
},
preferred: {
label: m.user_verification_preferred(),
description: m.user_verification_preferred_description()
}
};
const authenticatorAttachmentOptions = {
any: {
label: m.any_authenticator(),
description: m.any_authenticator_description()
},
platform: {
label: m.device_passkeys_only(),
description: m.device_passkeys_only_description()
},
'cross-platform': {
label: m.external_security_keys_only(),
description: m.external_security_keys_only_description()
}
};
let { inputs, ...form } = $derived(createForm(formSchema, initialConfig));
async function onSubmit() {
const data = form.validate();
if (!data) return;
isLoading = true;
try {
await callback(data);
toast.success(m.passkey_configuration_updated_successfully());
} finally {
isLoading = false;
}
}
</script>
<form onsubmit={preventDefault(onSubmit)}>
<fieldset disabled={$appConfigStore.uiConfigDisabled}>
<Field.Group>
<Field.Field data-invalid={!!$inputs.webauthnUserVerification.error}>
<div>
<Field.Label for="passkey-user-verification">{m.user_verification()}</Field.Label>
<Field.Description>{m.user_verification_description()}</Field.Description>
</div>
<Select.Root
type="single"
value={$inputs.webauthnUserVerification.value}
onValueChange={(value) =>
($inputs.webauthnUserVerification.value = value as 'required' | 'preferred')}
>
<Select.Trigger
id="passkey-user-verification"
class="w-full"
aria-label={m.user_verification()}
aria-invalid={!!$inputs.webauthnUserVerification.error}
placeholder={m.user_verification()}
>
{userVerificationOptions[$inputs.webauthnUserVerification.value]?.label}
</Select.Trigger>
<Select.Content>
<Select.Group>
{#each Object.entries(userVerificationOptions) as [value, option] (value)}
<Select.Item {value} label={option.label}>
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{option.label}</span>
<span class="text-muted-foreground text-xs">{option.description}</span>
</div>
</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
</Field.Field>
<Field.Field orientation="horizontal">
<Field.Content>
<div>
<Field.Label for="allow-synced-passkeys">{m.allow_synced_passkeys()}</Field.Label>
<Field.Description>{m.allow_synced_passkeys_description()}</Field.Description>
</div>
</Field.Content>
<Switch
id="allow-synced-passkeys"
class="my-auto"
bind:checked={$inputs.webauthnAllowSyncedPasskeys.value}
/>
</Field.Field>
<Field.Field data-invalid={!!$inputs.webauthnAuthenticatorAttachment.error}>
<div>
<Field.Label for="passkey-authenticator-type"
>{m.allowed_authenticator_type()}</Field.Label
>
<Field.Description>{m.allowed_authenticator_type_description()}</Field.Description>
</div>
<Select.Root
type="single"
value={$inputs.webauthnAuthenticatorAttachment.value}
onValueChange={(value) =>
($inputs.webauthnAuthenticatorAttachment.value = value as
'any' | 'platform' | 'cross-platform')}
>
<Select.Trigger
id="passkey-authenticator-type"
class="w-full"
aria-label={m.allowed_authenticator_type()}
aria-invalid={!!$inputs.webauthnAuthenticatorAttachment.error}
placeholder={m.allowed_authenticator_type()}
>
{authenticatorAttachmentOptions[$inputs.webauthnAuthenticatorAttachment.value]?.label}
</Select.Trigger>
<Select.Content>
<Select.Group>
{#each Object.entries(authenticatorAttachmentOptions) as [value, option] (value)}
<Select.Item {value} label={option.label}>
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{option.label}</span>
<span class="text-muted-foreground text-xs">{option.description}</span>
</div>
</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
</Field.Field>
<Field.Field orientation="horizontal" class="justify-end">
<Button type="submit" disabled={$appConfigStore.uiConfigDisabled} {isLoading}>
{m.save()}
</Button>
</Field.Field>
</Field.Group>
</fieldset>
</form>
@@ -59,10 +59,12 @@
<fieldset class="flex flex-col gap-5" disabled={$appConfigStore.uiConfigDisabled}>
<div class="grid gap-2">
<Field.Field>
<Field.Label for="enable-user-signup">{m.enable_user_signups()}</Field.Label>
<Field.Description>
{m.enable_user_signups_description()}
</Field.Description>
<div>
<Field.Label for="enable-user-signup">{m.enable_user_signups()}</Field.Label>
<Field.Description>
{m.enable_user_signups_description()}
</Field.Description>
</div>
<Select.Root
type="single"
value={allowUserSignups}
@@ -107,17 +109,21 @@
</div>
<Field.Field>
<Field.Label for="default-groups">{m.user_groups()}</Field.Label>
<Field.Description>
{m.user_creation_groups_description()}
</Field.Description>
<div>
<Field.Label for="default-groups">{m.user_groups()}</Field.Label>
<Field.Description>
{m.user_creation_groups_description()}
</Field.Description>
</div>
<UserGroupInput bind:selectedGroupIds />
</Field.Field>
<Field.Field>
<Field.Label>{m.custom_claims()}</Field.Label>
<Field.Description>
{m.user_creation_claims_description()}
</Field.Description>
<div>
<Field.Label>{m.custom_claims()}</Field.Label>
<Field.Description>
{m.user_creation_claims_description()}
</Field.Description>
</div>
<CustomClaimsInput bind:customClaims />
</Field.Field>
+50 -6
View File
@@ -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'