diff --git a/.ci-tools/phpstan-baseline.neon b/.ci-tools/phpstan-baseline.neon
index 8c6c0822..5c9ae491 100644
--- a/.ci-tools/phpstan-baseline.neon
+++ b/.ci-tools/phpstan-baseline.neon
@@ -3222,6 +3222,138 @@ parameters:
count: 1
path: ../src/webauthn/src/AuthenticationExtensions/LargeBlobInputExtension.php
+ -
+ rawMessage: Class "Webauthn\AuthenticationExtensions\PaymentExtension" is not allowed to extend "Webauthn\AuthenticationExtensions\AuthenticationExtension".
+ identifier: ergebnis.noExtends
+ count: 1
+ path: ../src/webauthn/src/AuthenticationExtensions/PaymentExtension.php
+
+ -
+ rawMessage: Cannot access offset 'payeeName' on mixed.
+ identifier: offsetAccess.nonOffsetAccessible
+ count: 1
+ path: ../src/webauthn/src/AuthenticationExtensions/PaymentExtensionOutputChecker.php
+
+ -
+ rawMessage: Cannot access offset 'payeeOrigin' on mixed.
+ identifier: offsetAccess.nonOffsetAccessible
+ count: 1
+ path: ../src/webauthn/src/AuthenticationExtensions/PaymentExtensionOutputChecker.php
+
+ -
+ rawMessage: Cannot access offset 'rpId' on mixed.
+ identifier: offsetAccess.nonOffsetAccessible
+ count: 1
+ path: ../src/webauthn/src/AuthenticationExtensions/PaymentExtensionOutputChecker.php
+
+ -
+ rawMessage: 'Construct empty() is not allowed. Use more strict comparison.'
+ identifier: empty.notAllowed
+ count: 2
+ path: ../src/webauthn/src/AuthenticationExtensions/PaymentExtensionOutputChecker.php
+
+ -
+ rawMessage: Constructor in Webauthn\AuthenticationExtensions\PaymentExtensionOutputChecker has parameter $expectedPayeeName with default value.
+ identifier: ergebnis.noConstructorParameterWithDefaultValue
+ count: 1
+ path: ../src/webauthn/src/AuthenticationExtensions/PaymentExtensionOutputChecker.php
+
+ -
+ rawMessage: Constructor in Webauthn\AuthenticationExtensions\PaymentExtensionOutputChecker has parameter $expectedPayeeOrigin with default value.
+ identifier: ergebnis.noConstructorParameterWithDefaultValue
+ count: 1
+ path: ../src/webauthn/src/AuthenticationExtensions/PaymentExtensionOutputChecker.php
+
+ -
+ rawMessage: Constructor in Webauthn\AuthenticationExtensions\PaymentExtensionOutputChecker has parameter $expectedRpId with default value.
+ identifier: ergebnis.noConstructorParameterWithDefaultValue
+ count: 1
+ path: ../src/webauthn/src/AuthenticationExtensions/PaymentExtensionOutputChecker.php
+
+ -
+ rawMessage: 'Language construct isset() should not be used.'
+ identifier: ergebnis.noIsset
+ count: 4
+ path: ../src/webauthn/src/AuthenticationExtensions/PaymentExtensionOutputChecker.php
+
+ -
+ rawMessage: 'Method Webauthn\AuthenticationExtensions\PaymentExtensionOutputChecker::__construct() has parameter $expectedPayeeName with a nullable type declaration.'
+ identifier: ergebnis.noParameterWithNullableTypeDeclaration
+ count: 1
+ path: ../src/webauthn/src/AuthenticationExtensions/PaymentExtensionOutputChecker.php
+
+ -
+ rawMessage: 'Method Webauthn\AuthenticationExtensions\PaymentExtensionOutputChecker::__construct() has parameter $expectedPayeeName with null as default value.'
+ identifier: ergebnis.noParameterWithNullDefaultValue
+ count: 1
+ path: ../src/webauthn/src/AuthenticationExtensions/PaymentExtensionOutputChecker.php
+
+ -
+ rawMessage: 'Method Webauthn\AuthenticationExtensions\PaymentExtensionOutputChecker::__construct() has parameter $expectedPayeeOrigin with a nullable type declaration.'
+ identifier: ergebnis.noParameterWithNullableTypeDeclaration
+ count: 1
+ path: ../src/webauthn/src/AuthenticationExtensions/PaymentExtensionOutputChecker.php
+
+ -
+ rawMessage: 'Method Webauthn\AuthenticationExtensions\PaymentExtensionOutputChecker::__construct() has parameter $expectedPayeeOrigin with null as default value.'
+ identifier: ergebnis.noParameterWithNullDefaultValue
+ count: 1
+ path: ../src/webauthn/src/AuthenticationExtensions/PaymentExtensionOutputChecker.php
+
+ -
+ rawMessage: 'Method Webauthn\AuthenticationExtensions\PaymentExtensionOutputChecker::__construct() has parameter $expectedRpId with a nullable type declaration.'
+ identifier: ergebnis.noParameterWithNullableTypeDeclaration
+ count: 1
+ path: ../src/webauthn/src/AuthenticationExtensions/PaymentExtensionOutputChecker.php
+
+ -
+ rawMessage: 'Method Webauthn\AuthenticationExtensions\PaymentExtensionOutputChecker::__construct() has parameter $expectedRpId with null as default value.'
+ identifier: ergebnis.noParameterWithNullDefaultValue
+ count: 1
+ path: ../src/webauthn/src/AuthenticationExtensions/PaymentExtensionOutputChecker.php
+
+ -
+ rawMessage: 'Method Webauthn\AuthenticationExtensions\PaymentExtensionOutputChecker::validatePayeeName() has parameter $paymentData with no value type specified in iterable type array.'
+ identifier: missingType.iterableValue
+ count: 1
+ path: ../src/webauthn/src/AuthenticationExtensions/PaymentExtensionOutputChecker.php
+
+ -
+ rawMessage: 'Method Webauthn\AuthenticationExtensions\PaymentExtensionOutputChecker::validatePayeeOrigin() has parameter $paymentData with no value type specified in iterable type array.'
+ identifier: missingType.iterableValue
+ count: 1
+ path: ../src/webauthn/src/AuthenticationExtensions/PaymentExtensionOutputChecker.php
+
+ -
+ rawMessage: 'Method Webauthn\AuthenticationExtensions\PaymentExtensionOutputChecker::validateRequiredFields() has parameter $paymentData with no value type specified in iterable type array.'
+ identifier: missingType.iterableValue
+ count: 1
+ path: ../src/webauthn/src/AuthenticationExtensions/PaymentExtensionOutputChecker.php
+
+ -
+ rawMessage: 'Method Webauthn\AuthenticationExtensions\PaymentExtensionOutputChecker::validateRpId() has parameter $paymentData with no value type specified in iterable type array.'
+ identifier: missingType.iterableValue
+ count: 1
+ path: ../src/webauthn/src/AuthenticationExtensions/PaymentExtensionOutputChecker.php
+
+ -
+ rawMessage: 'Part $actualPayeeName (mixed) of encapsed string cannot be cast to string.'
+ identifier: encapsedStringPart.nonString
+ count: 1
+ path: ../src/webauthn/src/AuthenticationExtensions/PaymentExtensionOutputChecker.php
+
+ -
+ rawMessage: 'Part $actualPayeeOrigin (mixed) of encapsed string cannot be cast to string.'
+ identifier: encapsedStringPart.nonString
+ count: 1
+ path: ../src/webauthn/src/AuthenticationExtensions/PaymentExtensionOutputChecker.php
+
+ -
+ rawMessage: 'Part $actualRpId (mixed) of encapsed string cannot be cast to string.'
+ identifier: encapsedStringPart.nonString
+ count: 1
+ path: ../src/webauthn/src/AuthenticationExtensions/PaymentExtensionOutputChecker.php
+
-
rawMessage: Class "Webauthn\AuthenticationExtensions\PseudoRandomFunctionInputExtension" is not allowed to extend "Webauthn\AuthenticationExtensions\AuthenticationExtension".
identifier: ergebnis.noExtends
@@ -4284,6 +4416,120 @@ parameters:
count: 1
path: ../src/webauthn/src/Denormalizer/AuthenticatorResponseDenormalizer.php
+ -
+ rawMessage: Constructor of Webauthn\SecurePaymentConfirmation\CollectedClientAdditionalPaymentData is invoked with named argument for parameter $instrument.
+ identifier: ergebnis.noNamedArgument
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/CollectedClientAdditionalPaymentDataDenormalizer.php
+
+ -
+ rawMessage: Constructor of Webauthn\SecurePaymentConfirmation\CollectedClientAdditionalPaymentData is invoked with named argument for parameter $payeeName.
+ identifier: ergebnis.noNamedArgument
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/CollectedClientAdditionalPaymentDataDenormalizer.php
+
+ -
+ rawMessage: Constructor of Webauthn\SecurePaymentConfirmation\CollectedClientAdditionalPaymentData is invoked with named argument for parameter $payeeOrigin.
+ identifier: ergebnis.noNamedArgument
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/CollectedClientAdditionalPaymentDataDenormalizer.php
+
+ -
+ rawMessage: Constructor of Webauthn\SecurePaymentConfirmation\CollectedClientAdditionalPaymentData is invoked with named argument for parameter $rpId.
+ identifier: ergebnis.noNamedArgument
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/CollectedClientAdditionalPaymentDataDenormalizer.php
+
+ -
+ rawMessage: Constructor of Webauthn\SecurePaymentConfirmation\CollectedClientAdditionalPaymentData is invoked with named argument for parameter $topOrigin.
+ identifier: ergebnis.noNamedArgument
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/CollectedClientAdditionalPaymentDataDenormalizer.php
+
+ -
+ rawMessage: Constructor of Webauthn\SecurePaymentConfirmation\CollectedClientAdditionalPaymentData is invoked with named argument for parameter $total.
+ identifier: ergebnis.noNamedArgument
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/CollectedClientAdditionalPaymentDataDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\CollectedClientAdditionalPaymentDataDenormalizer::denormalize() has parameter $format with a nullable type declaration.'
+ identifier: ergebnis.noParameterWithNullableTypeDeclaration
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/CollectedClientAdditionalPaymentDataDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\CollectedClientAdditionalPaymentDataDenormalizer::denormalize() has parameter $format with null as default value.'
+ identifier: ergebnis.noParameterWithNullDefaultValue
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/CollectedClientAdditionalPaymentDataDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\CollectedClientAdditionalPaymentDataDenormalizer::getSupportedTypes() has parameter $format with a nullable type declaration.'
+ identifier: ergebnis.noParameterWithNullableTypeDeclaration
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/CollectedClientAdditionalPaymentDataDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\CollectedClientAdditionalPaymentDataDenormalizer::normalize() has parameter $format with a nullable type declaration.'
+ identifier: ergebnis.noParameterWithNullableTypeDeclaration
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/CollectedClientAdditionalPaymentDataDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\CollectedClientAdditionalPaymentDataDenormalizer::normalize() has parameter $format with null as default value.'
+ identifier: ergebnis.noParameterWithNullDefaultValue
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/CollectedClientAdditionalPaymentDataDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\CollectedClientAdditionalPaymentDataDenormalizer::supportsDenormalization() has parameter $format with a nullable type declaration.'
+ identifier: ergebnis.noParameterWithNullableTypeDeclaration
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/CollectedClientAdditionalPaymentDataDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\CollectedClientAdditionalPaymentDataDenormalizer::supportsDenormalization() has parameter $format with null as default value.'
+ identifier: ergebnis.noParameterWithNullDefaultValue
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/CollectedClientAdditionalPaymentDataDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\CollectedClientAdditionalPaymentDataDenormalizer::supportsNormalization() has parameter $format with a nullable type declaration.'
+ identifier: ergebnis.noParameterWithNullableTypeDeclaration
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/CollectedClientAdditionalPaymentDataDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\CollectedClientAdditionalPaymentDataDenormalizer::supportsNormalization() has parameter $format with null as default value.'
+ identifier: ergebnis.noParameterWithNullDefaultValue
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/CollectedClientAdditionalPaymentDataDenormalizer.php
+
+ -
+ rawMessage: 'Parameter $payeeName of class Webauthn\SecurePaymentConfirmation\CollectedClientAdditionalPaymentData constructor expects string, mixed given.'
+ identifier: argument.type
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/CollectedClientAdditionalPaymentDataDenormalizer.php
+
+ -
+ rawMessage: 'Parameter $payeeOrigin of class Webauthn\SecurePaymentConfirmation\CollectedClientAdditionalPaymentData constructor expects string, mixed given.'
+ identifier: argument.type
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/CollectedClientAdditionalPaymentDataDenormalizer.php
+
+ -
+ rawMessage: 'Parameter $rpId of class Webauthn\SecurePaymentConfirmation\CollectedClientAdditionalPaymentData constructor expects string, mixed given.'
+ identifier: argument.type
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/CollectedClientAdditionalPaymentDataDenormalizer.php
+
+ -
+ rawMessage: 'Parameter $topOrigin of class Webauthn\SecurePaymentConfirmation\CollectedClientAdditionalPaymentData constructor expects string, mixed given.'
+ identifier: argument.type
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/CollectedClientAdditionalPaymentDataDenormalizer.php
+
-
rawMessage: Cannot cast mixed to string.
identifier: cast.string
@@ -4338,6 +4584,66 @@ parameters:
count: 1
path: ../src/webauthn/src/Denormalizer/CollectedClientDataDenormalizer.php
+ -
+ rawMessage: Constructor of Webauthn\SecurePaymentConfirmation\CollectedClientPaymentData is invoked with named argument for parameter $payment.
+ identifier: ergebnis.noNamedArgument
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/CollectedClientPaymentDataDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\CollectedClientPaymentDataDenormalizer::denormalize() has parameter $format with a nullable type declaration.'
+ identifier: ergebnis.noParameterWithNullableTypeDeclaration
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/CollectedClientPaymentDataDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\CollectedClientPaymentDataDenormalizer::denormalize() has parameter $format with null as default value.'
+ identifier: ergebnis.noParameterWithNullDefaultValue
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/CollectedClientPaymentDataDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\CollectedClientPaymentDataDenormalizer::getSupportedTypes() has parameter $format with a nullable type declaration.'
+ identifier: ergebnis.noParameterWithNullableTypeDeclaration
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/CollectedClientPaymentDataDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\CollectedClientPaymentDataDenormalizer::normalize() has parameter $format with a nullable type declaration.'
+ identifier: ergebnis.noParameterWithNullableTypeDeclaration
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/CollectedClientPaymentDataDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\CollectedClientPaymentDataDenormalizer::normalize() has parameter $format with null as default value.'
+ identifier: ergebnis.noParameterWithNullDefaultValue
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/CollectedClientPaymentDataDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\CollectedClientPaymentDataDenormalizer::supportsDenormalization() has parameter $format with a nullable type declaration.'
+ identifier: ergebnis.noParameterWithNullableTypeDeclaration
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/CollectedClientPaymentDataDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\CollectedClientPaymentDataDenormalizer::supportsDenormalization() has parameter $format with null as default value.'
+ identifier: ergebnis.noParameterWithNullDefaultValue
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/CollectedClientPaymentDataDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\CollectedClientPaymentDataDenormalizer::supportsNormalization() has parameter $format with a nullable type declaration.'
+ identifier: ergebnis.noParameterWithNullableTypeDeclaration
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/CollectedClientPaymentDataDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\CollectedClientPaymentDataDenormalizer::supportsNormalization() has parameter $format with null as default value.'
+ identifier: ergebnis.noParameterWithNullDefaultValue
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/CollectedClientPaymentDataDenormalizer.php
+
-
rawMessage: 'Method Webauthn\Denormalizer\ExtensionDescriptorDenormalizer::denormalize() has parameter $format with a nullable type declaration.'
identifier: ergebnis.noParameterWithNullableTypeDeclaration
@@ -4380,6 +4686,174 @@ parameters:
count: 1
path: ../src/webauthn/src/Denormalizer/ExtensionDescriptorDenormalizer.php
+ -
+ rawMessage: Constructor of Webauthn\SecurePaymentConfirmation\PaymentCredentialInstrument is invoked with named argument for parameter $displayName.
+ identifier: ergebnis.noNamedArgument
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/PaymentCredentialInstrumentDenormalizer.php
+
+ -
+ rawMessage: Constructor of Webauthn\SecurePaymentConfirmation\PaymentCredentialInstrument is invoked with named argument for parameter $icon.
+ identifier: ergebnis.noNamedArgument
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/PaymentCredentialInstrumentDenormalizer.php
+
+ -
+ rawMessage: Constructor of Webauthn\SecurePaymentConfirmation\PaymentCredentialInstrument is invoked with named argument for parameter $iconMustBeShown.
+ identifier: ergebnis.noNamedArgument
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/PaymentCredentialInstrumentDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\PaymentCredentialInstrumentDenormalizer::denormalize() has parameter $format with a nullable type declaration.'
+ identifier: ergebnis.noParameterWithNullableTypeDeclaration
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/PaymentCredentialInstrumentDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\PaymentCredentialInstrumentDenormalizer::denormalize() has parameter $format with null as default value.'
+ identifier: ergebnis.noParameterWithNullDefaultValue
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/PaymentCredentialInstrumentDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\PaymentCredentialInstrumentDenormalizer::getSupportedTypes() has parameter $format with a nullable type declaration.'
+ identifier: ergebnis.noParameterWithNullableTypeDeclaration
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/PaymentCredentialInstrumentDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\PaymentCredentialInstrumentDenormalizer::normalize() has parameter $format with a nullable type declaration.'
+ identifier: ergebnis.noParameterWithNullableTypeDeclaration
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/PaymentCredentialInstrumentDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\PaymentCredentialInstrumentDenormalizer::normalize() has parameter $format with null as default value.'
+ identifier: ergebnis.noParameterWithNullDefaultValue
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/PaymentCredentialInstrumentDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\PaymentCredentialInstrumentDenormalizer::supportsDenormalization() has parameter $format with a nullable type declaration.'
+ identifier: ergebnis.noParameterWithNullableTypeDeclaration
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/PaymentCredentialInstrumentDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\PaymentCredentialInstrumentDenormalizer::supportsDenormalization() has parameter $format with null as default value.'
+ identifier: ergebnis.noParameterWithNullDefaultValue
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/PaymentCredentialInstrumentDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\PaymentCredentialInstrumentDenormalizer::supportsNormalization() has parameter $format with a nullable type declaration.'
+ identifier: ergebnis.noParameterWithNullableTypeDeclaration
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/PaymentCredentialInstrumentDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\PaymentCredentialInstrumentDenormalizer::supportsNormalization() has parameter $format with null as default value.'
+ identifier: ergebnis.noParameterWithNullDefaultValue
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/PaymentCredentialInstrumentDenormalizer.php
+
+ -
+ rawMessage: 'Parameter $displayName of class Webauthn\SecurePaymentConfirmation\PaymentCredentialInstrument constructor expects string, mixed given.'
+ identifier: argument.type
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/PaymentCredentialInstrumentDenormalizer.php
+
+ -
+ rawMessage: 'Parameter $icon of class Webauthn\SecurePaymentConfirmation\PaymentCredentialInstrument constructor expects string, mixed given.'
+ identifier: argument.type
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/PaymentCredentialInstrumentDenormalizer.php
+
+ -
+ rawMessage: 'Parameter $iconMustBeShown of class Webauthn\SecurePaymentConfirmation\PaymentCredentialInstrument constructor expects bool, mixed given.'
+ identifier: argument.type
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/PaymentCredentialInstrumentDenormalizer.php
+
+ -
+ rawMessage: Constructor of Webauthn\SecurePaymentConfirmation\PaymentCurrencyAmount is invoked with named argument for parameter $currency.
+ identifier: ergebnis.noNamedArgument
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/PaymentCurrencyAmountDenormalizer.php
+
+ -
+ rawMessage: Constructor of Webauthn\SecurePaymentConfirmation\PaymentCurrencyAmount is invoked with named argument for parameter $value.
+ identifier: ergebnis.noNamedArgument
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/PaymentCurrencyAmountDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\PaymentCurrencyAmountDenormalizer::denormalize() has parameter $format with a nullable type declaration.'
+ identifier: ergebnis.noParameterWithNullableTypeDeclaration
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/PaymentCurrencyAmountDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\PaymentCurrencyAmountDenormalizer::denormalize() has parameter $format with null as default value.'
+ identifier: ergebnis.noParameterWithNullDefaultValue
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/PaymentCurrencyAmountDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\PaymentCurrencyAmountDenormalizer::getSupportedTypes() has parameter $format with a nullable type declaration.'
+ identifier: ergebnis.noParameterWithNullableTypeDeclaration
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/PaymentCurrencyAmountDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\PaymentCurrencyAmountDenormalizer::normalize() has parameter $format with a nullable type declaration.'
+ identifier: ergebnis.noParameterWithNullableTypeDeclaration
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/PaymentCurrencyAmountDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\PaymentCurrencyAmountDenormalizer::normalize() has parameter $format with null as default value.'
+ identifier: ergebnis.noParameterWithNullDefaultValue
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/PaymentCurrencyAmountDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\PaymentCurrencyAmountDenormalizer::supportsDenormalization() has parameter $format with a nullable type declaration.'
+ identifier: ergebnis.noParameterWithNullableTypeDeclaration
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/PaymentCurrencyAmountDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\PaymentCurrencyAmountDenormalizer::supportsDenormalization() has parameter $format with null as default value.'
+ identifier: ergebnis.noParameterWithNullDefaultValue
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/PaymentCurrencyAmountDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\PaymentCurrencyAmountDenormalizer::supportsNormalization() has parameter $format with a nullable type declaration.'
+ identifier: ergebnis.noParameterWithNullableTypeDeclaration
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/PaymentCurrencyAmountDenormalizer.php
+
+ -
+ rawMessage: 'Method Webauthn\Denormalizer\PaymentCurrencyAmountDenormalizer::supportsNormalization() has parameter $format with null as default value.'
+ identifier: ergebnis.noParameterWithNullDefaultValue
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/PaymentCurrencyAmountDenormalizer.php
+
+ -
+ rawMessage: 'Parameter $currency of class Webauthn\SecurePaymentConfirmation\PaymentCurrencyAmount constructor expects string, mixed given.'
+ identifier: argument.type
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/PaymentCurrencyAmountDenormalizer.php
+
+ -
+ rawMessage: 'Parameter $value of class Webauthn\SecurePaymentConfirmation\PaymentCurrencyAmount constructor expects string, mixed given.'
+ identifier: argument.type
+ count: 1
+ path: ../src/webauthn/src/Denormalizer/PaymentCurrencyAmountDenormalizer.php
+
-
rawMessage: 'Method Webauthn\Denormalizer\PublicKeyCredentialDenormalizer::denormalize() has parameter $format with a nullable type declaration.'
identifier: ergebnis.noParameterWithNullableTypeDeclaration
@@ -8256,6 +8730,36 @@ parameters:
count: 1
path: ../src/webauthn/src/PublicKeyCredentialUserEntity.php
+ -
+ rawMessage: Class Webauthn\SecurePaymentConfirmation\CollectedClientAdditionalPaymentData is neither abstract nor final.
+ identifier: ergebnis.final
+ count: 1
+ path: ../src/webauthn/src/SecurePaymentConfirmation/CollectedClientAdditionalPaymentData.php
+
+ -
+ rawMessage: Class Webauthn\SecurePaymentConfirmation\CollectedClientPaymentData is neither abstract nor final.
+ identifier: ergebnis.final
+ count: 1
+ path: ../src/webauthn/src/SecurePaymentConfirmation/CollectedClientPaymentData.php
+
+ -
+ rawMessage: Class Webauthn\SecurePaymentConfirmation\PaymentCredentialInstrument is neither abstract nor final.
+ identifier: ergebnis.final
+ count: 1
+ path: ../src/webauthn/src/SecurePaymentConfirmation/PaymentCredentialInstrument.php
+
+ -
+ rawMessage: Constructor in Webauthn\SecurePaymentConfirmation\PaymentCredentialInstrument has parameter $iconMustBeShown with default value.
+ identifier: ergebnis.noConstructorParameterWithDefaultValue
+ count: 1
+ path: ../src/webauthn/src/SecurePaymentConfirmation/PaymentCredentialInstrument.php
+
+ -
+ rawMessage: Class Webauthn\SecurePaymentConfirmation\PaymentCurrencyAmount is neither abstract nor final.
+ identifier: ergebnis.final
+ count: 1
+ path: ../src/webauthn/src/SecurePaymentConfirmation/PaymentCurrencyAmount.php
+
-
rawMessage: Class Webauthn\Signal\AllAcceptedCredentials is neither abstract nor final.
identifier: ergebnis.final
diff --git a/.gitattributes b/.gitattributes
index 3fb41046..1eaf424e 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -3,6 +3,7 @@
/.ci-tools export-ignore
/.github export-ignore
/bin export-ignore
+/docs export-ignore
/tests export-ignore
/.editorconfig export-ignore
/.gitattributes export-ignore
diff --git a/docs/examples/QUICKSTART.md b/docs/examples/QUICKSTART.md
new file mode 100644
index 00000000..c9cd1442
--- /dev/null
+++ b/docs/examples/QUICKSTART.md
@@ -0,0 +1,306 @@
+# Secure Payment Confirmation - Quick Start Guide
+
+## ๐ Choose Your Implementation
+
+You have **two options** to implement Secure Payment Confirmation:
+
+### Option A: Standalone Controller (Full Control)
+**Time:** ~30 minutes | **Complexity:** Medium | **Flexibility:** High
+
+### Option B: Bundle Configuration (Quick Setup)
+**Time:** ~15 minutes | **Complexity:** Low | **Flexibility:** Medium
+
+---
+
+## Option A: Standalone Controller
+
+### Step 1: Copy the controller
+```bash
+cp docs/examples/payment-controller-standalone.php src/Controller/PaymentController.php
+cp docs/examples/payment-service-example.php src/Service/PaymentService.php
+```
+
+### Step 2: Create the entity
+```bash
+php bin/console make:entity PaymentTransaction
+```
+
+Add the properties from `PaymentTransactionEntity` in `payment-service-example.php`.
+
+### Step 3: Create migration
+```bash
+php bin/console make:migration
+php bin/console doctrine:migrations:migrate
+```
+
+### Step 4: Register routes
+```yaml
+# config/routes.yaml
+payment_options:
+ path: /payment/options
+ controller: App\Controller\PaymentController::options
+ methods: [POST]
+
+payment_verify:
+ path: /payment/verify
+ controller: App\Controller\PaymentController::verify
+ methods: [POST]
+```
+
+### Step 5: Test it!
+```html
+
+
+```
+
+**โ
Done!** Your payment system is ready.
+
+---
+
+## Option B: Bundle Configuration
+
+### Step 1: Configure the bundle
+```bash
+# Add to config/packages/webauthn.yaml
+cat >> config/packages/webauthn.yaml << 'EOF'
+
+ # Payment profile
+ request_profiles:
+ payment:
+ rp_id: '%env(WEBAUTHN_RP_ID)%'
+ challenge_length: 32
+ timeout: 60000
+ user_verification: 'required'
+
+ controllers:
+ enabled: true
+ request:
+ payment:
+ profile: 'payment'
+ options_path: '/payment/options'
+ result_path: '/payment/verify'
+ options_handler: App\Webauthn\Handler\PaymentOptionsHandler
+ success_handler: App\Webauthn\Handler\PaymentSuccessHandler
+ failure_handler: App\Webauthn\Handler\PaymentFailureHandler
+EOF
+```
+
+### Step 2: Create handlers
+```bash
+mkdir -p src/Webauthn/Handler
+cp docs/examples/payment-handlers.php src/Webauthn/Handler/
+```
+
+Edit the file to split into three separate handler classes.
+
+### Step 3: Create the service
+```bash
+cp docs/examples/payment-service-example.php src/Service/PaymentService.php
+```
+
+### Step 4: Create the entity (same as Option A)
+```bash
+php bin/console make:entity PaymentTransaction
+php bin/console make:migration
+php bin/console doctrine:migrations:migrate
+```
+
+### Step 5: Test it!
+Same HTML as Option A - routes are automatically created by the bundle!
+
+**โ
Done!** Your payment system is ready with less code.
+
+---
+
+## ๐งช Testing Your Implementation
+
+### 1. Check browser support
+```javascript
+const isSupported = 'PaymentRequest' in window &&
+ 'PublicKeyCredential' in window;
+console.log('SPC supported:', isSupported);
+```
+
+### 2. Test the flow
+
+1. **Create a test transaction:**
+ ```php
+ $transaction = $paymentService->createTransaction(
+ userId: 'user123',
+ amount: '99.99',
+ currency: 'EUR',
+ payeeName: 'Test Merchant',
+ payeeOrigin: 'https://merchant.example.com'
+ );
+ ```
+
+2. **Open the payment page** in Chrome 105+
+
+3. **Click "Confirm Payment"** - Browser should show payment UI
+
+4. **Authenticate** with biometrics or security key
+
+5. **Check the result** - Transaction should be marked as "confirmed"
+
+### 3. Debug issues
+
+```bash
+# Check Symfony logs
+tail -f var/log/dev.log
+
+# Check WebAuthn events in browser console
+# Open DevTools > Console
+# Click payment button
+# Look for webauthn:* events
+```
+
+---
+
+## ๐ Security Checklist
+
+Before going to production:
+
+- [ ] โ
Payment amounts fetched from server-side database (NEVER from client)
+- [ ] โ
Transaction IDs are cryptographically random
+- [ ] โ
User verification is set to "required" for payments
+- [ ] โ
HTTPS enabled (required for WebAuthn)
+- [ ] โ
Transaction expiry implemented (e.g., 15 minutes)
+- [ ] โ
Proper error handling and logging
+- [ ] โ
Rate limiting on payment endpoints
+- [ ] โ
CSRF protection enabled
+- [ ] โ
CSP headers configured
+
+---
+
+## ๐ฑ Frontend Examples
+
+### Using Stimulus (Recommended)
+```html
+
+```
+
+### Using Vanilla JavaScript
+```html
+
+
+
+```
+
+---
+
+## ๐ Common Issues
+
+### "WebAuthn not supported"
+- โ
Use HTTPS (required, except localhost)
+- โ
Test in Chrome 105+ or Edge 105+
+- โ
Firefox/Safari don't support SPC yet
+
+### "Transaction not found"
+- โ
Check transaction ID is correct
+- โ
Verify transaction hasn't expired
+- โ
Check database connection
+
+### "Payment extension not present"
+- โ
Verify payment extension is added in options handler
+- โ
Check `isPayment: true` is set
+- โ
Ensure all required fields are present
+
+### "Signature verification failed"
+- โ
RP ID must match credential registration
+- โ
Origin must match
+- โ
Challenge must not be expired
+- โ
Credential must exist in database
+
+---
+
+## ๐ Next Steps
+
+1. **Read the full documentation:** `docs/examples/README.md`
+2. **Review security considerations:** Especially server-side validation
+3. **Implement error handling:** For better user experience
+4. **Add monitoring:** Log all payment attempts
+5. **Test with real devices:** Try different authenticators
+
+---
+
+## ๐ก Pro Tips
+
+1. **Always validate payment data server-side** - Never trust the client!
+2. **Use short expiry times** - 15 minutes is recommended
+3. **Log everything** - Payment attempts, failures, successes
+4. **Test thoroughly** - Different browsers, devices, authenticators
+5. **Have a fallback** - Not all users have compatible devices
+
+---
+
+## ๐ฏ Complete Flow Diagram
+
+```
+User clicks "Pay"
+ โ
+Frontend sends transactionId to /payment/options
+ โ
+Server fetches REAL payment data from database
+ โ
+Server creates WebAuthn options with payment extension
+ โ
+Browser shows payment UI with amount/merchant
+ โ
+User confirms with biometrics
+ โ
+Frontend sends credential to /payment/verify
+ โ
+Server validates signature
+ โ
+Server processes payment
+ โ
+Success! Redirect to confirmation page
+```
+
+---
+
+## Need Help?
+
+- ๐ Full documentation: `docs/examples/README.md`
+- ๐ Report issues: GitHub Issues
+- ๐ฌ Discussions: GitHub Discussions
+- ๐ง Security issues: security@example.com (use your actual security contact)
+
+Happy coding! ๐
diff --git a/docs/examples/README.md b/docs/examples/README.md
new file mode 100644
index 00000000..ada8b2ae
--- /dev/null
+++ b/docs/examples/README.md
@@ -0,0 +1,329 @@
+# Secure Payment Confirmation (SPC) - Implementation Examples
+
+This directory contains complete examples for implementing Secure Payment Confirmation using the Webauthn Framework.
+
+## ๐ Table of Contents
+
+- [Overview](#overview)
+- [Approach 1: Standalone Controller](#approach-1-standalone-controller)
+- [Approach 2: Bundle Configuration](#approach-2-bundle-configuration)
+- [Security Considerations](#security-considerations)
+- [Frontend Integration](#frontend-integration)
+- [Testing](#testing)
+
+## Overview
+
+Secure Payment Confirmation (SPC) is a Web API that allows websites to request authentication from a WebAuthn credential as part of a payment transaction. This provides strong authentication for payments using biometrics or security keys.
+
+**Key Features:**
+- ๐ Strong authentication for payments
+- ๐ซ Prevents client-side tampering of payment amounts
+- ๐ฏ Native browser payment UI
+- โ
Reduces fraud and chargebacks
+
+## Approach 1: Standalone Controller
+
+**Best for:** Full control over the payment flow, custom business logic, complex payment scenarios
+
+**Files:**
+- `payment-controller-standalone.php` - Complete standalone controller implementation
+
+**Pros:**
+- โ
Complete control over every step
+- โ
Easy to customize for complex business logic
+- โ
No bundle configuration needed
+- โ
Direct access to all WebAuthn APIs
+
+**Cons:**
+- โ More boilerplate code
+- โ Manual route configuration
+- โ Need to handle serialization/deserialization
+
+**Usage:**
+
+```php
+// Register the routes in config/routes.yaml
+payment_options:
+ path: /payment/options
+ controller: App\Controller\PaymentController::options
+ methods: [POST]
+
+payment_verify:
+ path: /payment/verify
+ controller: App\Controller\PaymentController::verify
+ methods: [POST]
+```
+
+**Implementation Steps:**
+
+1. **Copy the controller:**
+ ```bash
+ cp docs/examples/payment-controller-standalone.php src/Controller/PaymentController.php
+ ```
+
+2. **Implement PaymentServiceInterface:**
+ ```php
+ class PaymentService implements PaymentServiceInterface
+ {
+ public function getTransaction(string $id): ?PaymentTransaction
+ {
+ // Fetch from database
+ return $this->repository->find($id);
+ }
+
+ public function processPayment(string $id, array $data): bool
+ {
+ // Process payment in your system
+ return true;
+ }
+ }
+ ```
+
+3. **Register the service:**
+ ```yaml
+ # config/services.yaml
+ App\Controller\PaymentController:
+ arguments:
+ $paymentService: '@App\Service\PaymentService'
+ ```
+
+## Approach 2: Bundle Configuration
+
+**Best for:** Quick setup, consistency with other WebAuthn endpoints, less code to maintain
+
+**Files:**
+- `payment-bundle-configuration.yaml` - Bundle configuration
+- `payment-handlers.php` - Custom handlers for payment logic
+
+**Pros:**
+- โ
Minimal boilerplate code
+- โ
Automatic route generation
+- โ
Consistent with registration/authentication endpoints
+- โ
Easy to maintain and upgrade
+- โ
Built-in serialization/deserialization
+
+**Cons:**
+- โ Less flexibility for complex customizations
+- โ Need to understand bundle's handler system
+
+**Usage:**
+
+1. **Configure the bundle:**
+ ```bash
+ cp docs/examples/payment-bundle-configuration.yaml config/packages/webauthn.yaml
+ ```
+
+2. **Create custom handlers:**
+ ```bash
+ mkdir -p src/Webauthn/Handler
+ cp docs/examples/payment-handlers.php src/Webauthn/Handler/
+ ```
+
+3. **Register handlers as services:**
+ ```yaml
+ # config/services.yaml
+ App\Webauthn\Handler\:
+ resource: '../src/Webauthn/Handler/*'
+ tags: ['controller.service_arguments']
+ ```
+
+4. **Routes are automatically created:**
+ - `POST /payment/options` - Generate payment options
+ - `POST /payment/verify` - Verify payment confirmation
+
+## Security Considerations
+
+### โ ๏ธ CRITICAL: Server-Side Validation
+
+**NEVER trust payment data from the client!**
+
+```php
+// โ BAD - Client can manipulate this
+$amount = $request->request->get('amount');
+$payee = $request->request->get('payee');
+
+// โ
GOOD - Server fetches from database
+$transactionId = $request->request->get('transactionId');
+$transaction = $this->paymentService->getTransaction($transactionId);
+$amount = $transaction->getAmount(); // From YOUR database
+$payee = $transaction->getPayeeName(); // From YOUR database
+```
+
+### Security Checklist
+
+- [x] Payment amounts fetched from server-side database
+- [x] Transaction IDs are cryptographically random
+- [x] User verification is required for payments
+- [x] Payment extension is validated in responses
+- [x] WebAuthn signature is verified
+- [x] Challenge is validated (prevents replay attacks)
+- [x] Credential counter is updated (prevents cloning)
+- [x] HTTPS is enforced
+- [x] CSP headers are configured
+
+### Payment Extension Structure
+
+```json
+{
+ "isPayment": true,
+ "rpId": "example.com",
+ "topOrigin": "https://example.com",
+ "payeeName": "Merchant Name",
+ "payeeOrigin": "https://merchant.example.com",
+ "total": {
+ "value": "99.99",
+ "currency": "EUR"
+ },
+ "instrument": {
+ "displayName": "Visa ****1234",
+ "icon": "https://bank.example.com/icon.png"
+ }
+}
+```
+
+**Important Fields:**
+
+- `isPayment`: Must be `true` to trigger SPC
+- `rpId`: Your relying party ID (usually your domain)
+- `topOrigin`: The origin of the top-level page
+- `payeeName`: Merchant name shown to user
+- `total.value`: Amount as string (e.g., "99.99")
+- `total.currency`: ISO 4217 currency code (e.g., "EUR", "USD")
+
+## Frontend Integration
+
+### Option 1: Using Stimulus Controller (Recommended)
+
+```html
+
+```
+
+**Why reuse authentication-controller?**
+- The authentication controller already handles all WebAuthn ceremony steps
+- Extensions (including `payment`) are automatically processed
+- Less JavaScript code to maintain
+- Consistent behavior across all WebAuthn operations
+
+### Option 2: Vanilla JavaScript
+
+```javascript
+import { startAuthentication } from '@simplewebauthn/browser';
+
+// Step 1: Get options
+const optionsResponse = await fetch('/payment/options', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ transactionId: 'txn_abc123' })
+});
+const options = await optionsResponse.json();
+
+// Step 2: Start authentication (browser shows payment UI)
+const credential = await startAuthentication({ optionsJSON: options });
+
+// Step 3: Verify
+const result = await fetch('/payment/verify', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(credential)
+});
+```
+
+See `payment-frontend.html` for complete examples.
+
+## Testing
+
+### Browser Support
+
+Secure Payment Confirmation requires:
+- โ
Chrome/Edge 105+
+- โ Firefox (not yet supported)
+- โ Safari (not yet supported)
+
+### Testing Locally
+
+1. **Use HTTPS locally:**
+ ```bash
+ symfony server:start --port=8000
+ ```
+
+2. **Test with Chrome:**
+ - Enable WebAuthn testing: `chrome://flags/#enable-web-authentication-testing-api`
+ - Use virtual authenticator for testing
+
+3. **Check browser support:**
+ ```javascript
+ const isSupported = 'PaymentRequest' in window &&
+ 'PublicKeyCredential' in window;
+ ```
+
+### Integration Tests
+
+```php
+// tests/Controller/PaymentControllerTest.php
+public function testPaymentOptions(): void
+{
+ $client = static::createClient();
+ $client->request('POST', '/payment/options', [], [],
+ ['CONTENT_TYPE' => 'application/json'],
+ json_encode(['transactionId' => 'test_txn_123'])
+ );
+
+ $this->assertResponseIsSuccessful();
+ $response = json_decode($client->getResponse()->getContent(), true);
+
+ // Verify payment extension is present
+ $this->assertArrayHasKey('extensions', $response);
+ $this->assertArrayHasKey('payment', $response['extensions']);
+
+ // Verify payment data
+ $payment = $response['extensions']['payment'];
+ $this->assertTrue($payment['isPayment']);
+ $this->assertEquals('99.99', $payment['total']['value']);
+}
+```
+
+## Troubleshooting
+
+### "Payment extension not present"
+- โ
Verify payment extension is added in options handler
+- โ
Check that `isPayment: true` is set
+- โ
Ensure browser supports SPC
+
+### "Transaction not found"
+- โ
Verify transaction ID exists in database
+- โ
Check transaction ID is correctly sent from client
+- โ
Ensure transaction hasn't expired
+
+### "WebAuthn unsupported"
+- โ
Must use HTTPS (or localhost)
+- โ
Check browser compatibility
+- โ
Verify credentials are registered for this RP ID
+
+### "Signature validation failed"
+- โ
Check RP ID matches registered credential
+- โ
Verify origin matches
+- โ
Ensure challenge hasn't expired
+- โ
Check clock synchronization
+
+## Additional Resources
+
+- [W3C Secure Payment Confirmation Spec](https://www.w3.org/TR/secure-payment-confirmation/)
+- [WebAuthn Specification](https://www.w3.org/TR/webauthn-2/)
+- [Webauthn Framework Documentation](https://webauthn-doc.spomky-labs.com/)
+
+## Support
+
+For issues or questions:
+- ๐ Read the main documentation at `/docs/secure-payment-confirmation.md`
+- ๐ Report bugs on GitHub
+- ๐ฌ Join discussions on GitHub Discussions
diff --git a/docs/examples/payment-bundle-configuration.yaml b/docs/examples/payment-bundle-configuration.yaml
new file mode 100644
index 00000000..b734f347
--- /dev/null
+++ b/docs/examples/payment-bundle-configuration.yaml
@@ -0,0 +1,69 @@
+# config/packages/webauthn.yaml
+#
+# Example: Configuring Payment endpoints using Webauthn Bundle's built-in controllers
+#
+# This approach leverages the bundle's automatic controller creation and routing.
+# You only need to:
+# 1. Configure the bundle
+# 2. Create custom handlers for payment-specific logic
+# 3. Use the automatically generated routes
+#
+# Benefits:
+# - Less boilerplate code
+# - Automatic route generation
+# - Consistent with other WebAuthn endpoints (registration, authentication)
+# - Easy to maintain and upgrade
+
+webauthn:
+ # Global configuration
+ credential_repository: App\Repository\PublicKeyCredentialSourceRepository
+ user_repository: App\Repository\PublicKeyCredentialUserEntityRepository
+
+ # Request profiles define the WebAuthn options for authentication
+ request_profiles:
+ # Standard authentication profile
+ default:
+ rp_id: 'example.com'
+ challenge_length: 32
+ timeout: 60000
+ user_verification: 'preferred'
+
+ # Payment-specific profile
+ # This will be used for Secure Payment Confirmation
+ payment:
+ rp_id: 'example.com'
+ challenge_length: 32
+ timeout: 60000
+ user_verification: 'required' # Require user verification for payments
+ # Extensions will be added dynamically in the custom options handler
+
+ # Controllers configuration
+ controllers:
+ enabled: true
+
+ # Payment endpoints configuration
+ request:
+ # Standard authentication endpoint (already exists)
+ default:
+ profile: 'default'
+ options_path: '/authentication/options'
+ result_path: '/authentication/verify'
+ options_handler: Webauthn\Bundle\Security\Handler\DefaultRequestOptionsHandler
+ success_handler: Webauthn\Bundle\Service\DefaultSuccessHandler
+ failure_handler: Webauthn\Bundle\Service\DefaultFailureHandler
+
+ # Payment endpoint - uses the bundle's automatic controller generation
+ payment:
+ profile: 'payment' # Use the payment profile defined above
+ options_path: '/payment/options'
+ result_path: '/payment/verify'
+ # Custom handlers for payment-specific logic
+ options_handler: App\Webauthn\Handler\PaymentOptionsHandler
+ success_handler: App\Webauthn\Handler\PaymentSuccessHandler
+ failure_handler: App\Webauthn\Handler\PaymentFailureHandler
+
+# The bundle will automatically:
+# 1. Create routes at /payment/options and /payment/verify
+# 2. Wire up the controllers with your custom handlers
+# 3. Handle WebAuthn serialization/deserialization
+# 4. Validate signatures and assertions
diff --git a/docs/examples/payment-controller-standalone.php b/docs/examples/payment-controller-standalone.php
new file mode 100644
index 00000000..80b693a6
--- /dev/null
+++ b/docs/examples/payment-controller-standalone.php
@@ -0,0 +1,289 @@
+getContent(), true, 512, JSON_THROW_ON_ERROR);
+ $transactionId = $data['transactionId'] ?? null;
+
+ if ($transactionId === null) {
+ return new JsonResponse(['error' => 'Transaction ID required'], Response::HTTP_BAD_REQUEST);
+ }
+
+ // 2. SECURITY: Fetch payment details from YOUR secure database
+ // Never trust payment details from the client!
+ $transaction = $this->paymentService->getTransaction($transactionId);
+
+ if ($transaction === null) {
+ return new JsonResponse(['error' => 'Transaction not found'], Response::HTTP_NOT_FOUND);
+ }
+
+ // 3. Get user entity (the payer)
+ $userEntity = $this->userRepository->findOneByUsername($transaction->getPayerUsername());
+
+ if ($userEntity === null) {
+ return new JsonResponse(['error' => 'User not found'], Response::HTTP_NOT_FOUND);
+ }
+
+ // 4. Get user's registered credentials
+ $credentialSources = $this->credentialRepository->findAllForUserEntity($userEntity);
+ $allowedCredentials = array_map(
+ static fn($source) => PublicKeyCredentialDescriptor::create(
+ $source->type,
+ $source->publicKeyCredentialId
+ ),
+ $credentialSources
+ );
+
+ // 5. Create the Payment extension with server-validated data using typed objects
+ $paymentData = CollectedClientAdditionalPaymentData::create(
+ rpId: $transaction->getRpId(), // e.g., 'example.com'
+ topOrigin: $transaction->getTopOrigin(), // e.g., 'https://example.com'
+ payeeName: $transaction->getPayeeName(), // Merchant name
+ payeeOrigin: $transaction->getPayeeOrigin(), // Merchant origin
+ total: PaymentCurrencyAmount::create(
+ currency: $transaction->getCurrency(), // e.g., 'EUR'
+ value: $transaction->getAmount() // e.g., '99.99'
+ ),
+ instrument: PaymentCredentialInstrument::create(
+ displayName: $transaction->getInstrumentDisplayName(), // e.g., 'Visa ****1234'
+ icon: $transaction->getInstrumentIcon() // e.g., 'https://bank.example/icon.png'
+ )
+ );
+
+ // Build the extension (isPayment flag + payment data)
+ $paymentExtension = AuthenticationExtension::create('payment', array_merge(
+ ['isPayment' => true],
+ (array) $paymentData // Cast to array or use serializer
+ ));
+
+ $extensions = AuthenticationExtensions::create([$paymentExtension]);
+
+ // 6. Create WebAuthn request options
+ $options = $this->optionsFactory->create(
+ 'default', // or your custom profile name
+ $allowedCredentials,
+ null, // userVerification (null = use profile default)
+ $extensions
+ );
+
+ // 7. Store options for later verification
+ $this->optionsStorage->store(Item::create($options, $userEntity));
+
+ // 8. Return options to client
+ return new JsonResponse(
+ $this->serializer->serialize($options, 'json'),
+ Response::HTTP_OK,
+ [],
+ true
+ );
+
+ } catch (\Throwable $e) {
+ $this->logger->error('Payment options generation failed', [
+ 'exception' => $e->getMessage(),
+ 'trace' => $e->getTraceAsString(),
+ ]);
+
+ return new JsonResponse([
+ 'error' => 'Failed to generate payment options',
+ ], Response::HTTP_INTERNAL_SERVER_ERROR);
+ }
+ }
+
+ /**
+ * Step 2: Verify the payment confirmation
+ *
+ * Client sends: PublicKeyCredential with payment extension output
+ * Server validates and processes the payment
+ */
+ #[Route('/payment/verify', name: 'payment_verify', methods: ['POST'])]
+ public function verify(Request $request): JsonResponse
+ {
+ try {
+ // 1. Deserialize the credential response
+ $credential = $this->serializer->deserialize(
+ $request->getContent(),
+ PublicKeyCredential::class,
+ 'json'
+ );
+
+ // 2. Validate that payment extension is present in the response
+ if (!isset($credential->response->clientExtensionResults['payment'])) {
+ return new JsonResponse([
+ 'success' => false,
+ 'error' => 'Payment extension not present in credential response',
+ ], Response::HTTP_BAD_REQUEST);
+ }
+
+ // 3. Retrieve stored options
+ $storedItem = $this->optionsStorage->get();
+ $publicKeyCredentialRequestOptions = $storedItem->publicKeyCredentialOptions;
+
+ if (!$publicKeyCredentialRequestOptions instanceof PublicKeyCredentialRequestOptions) {
+ return new JsonResponse([
+ 'success' => false,
+ 'error' => 'Invalid stored options',
+ ], Response::HTTP_BAD_REQUEST);
+ }
+
+ // 4. Get credential source
+ $credentialSource = $this->credentialRepository->findOneByCredentialId(
+ $credential->rawId
+ );
+
+ if ($credentialSource === null) {
+ return new JsonResponse([
+ 'success' => false,
+ 'error' => 'Credential not found',
+ ], Response::HTTP_NOT_FOUND);
+ }
+
+ // 5. Verify the WebAuthn assertion (includes signature validation)
+ $credentialSource = $this->assertionValidator->check(
+ $credentialSource,
+ $credential->response,
+ $publicKeyCredentialRequestOptions,
+ $request->getHost(),
+ $credentialSource->userHandle
+ );
+
+ // 6. Update credential counter (prevents replay attacks)
+ $this->credentialRepository->saveCredentialSource($credentialSource);
+
+ // 7. IMPORTANT: Process the payment in your system
+ // At this point, the user has confirmed the payment via WebAuthn
+ $paymentExtensionOutput = $credential->response->clientExtensionResults['payment'];
+
+ // Extract transaction ID from request or stored data
+ // (You may want to store this in the session/storage)
+ $transactionId = $storedItem->userData['transactionId'] ?? null;
+
+ if ($transactionId !== null) {
+ $this->paymentService->processPayment($transactionId, [
+ 'credentialId' => base64_encode($credential->rawId),
+ 'paymentConfirmed' => true,
+ 'timestamp' => time(),
+ ]);
+ }
+
+ // 8. Clear stored options
+ $this->optionsStorage->clear();
+
+ return new JsonResponse([
+ 'success' => true,
+ 'message' => 'Payment confirmed successfully',
+ ]);
+
+ } catch (\Throwable $e) {
+ $this->logger->error('Payment verification failed', [
+ 'exception' => $e->getMessage(),
+ 'trace' => $e->getTraceAsString(),
+ ]);
+
+ return new JsonResponse([
+ 'success' => false,
+ 'error' => 'Payment verification failed',
+ ], Response::HTTP_INTERNAL_SERVER_ERROR);
+ }
+ }
+}
+
+/**
+ * Interface for your payment service
+ * Implement this according to your business logic
+ */
+interface PaymentServiceInterface
+{
+ public function getTransaction(string $transactionId): ?PaymentTransaction;
+ public function processPayment(string $transactionId, array $data): bool;
+}
+
+/**
+ * Example Payment Transaction DTO
+ */
+class PaymentTransaction
+{
+ public function __construct(
+ private readonly string $id,
+ private readonly string $payerUsername,
+ private readonly string $rpId,
+ private readonly string $topOrigin,
+ private readonly string $payeeName,
+ private readonly string $payeeOrigin,
+ private readonly string $amount,
+ private readonly string $currency,
+ private readonly string $instrumentDisplayName,
+ private readonly string $instrumentIcon,
+ ) {
+ }
+
+ public function getId(): string { return $this->id; }
+ public function getPayerUsername(): string { return $this->payerUsername; }
+ public function getRpId(): string { return $this->rpId; }
+ public function getTopOrigin(): string { return $this->topOrigin; }
+ public function getPayeeName(): string { return $this->payeeName; }
+ public function getPayeeOrigin(): string { return $this->payeeOrigin; }
+ public function getAmount(): string { return $this->amount; }
+ public function getCurrency(): string { return $this->currency; }
+ public function getInstrumentDisplayName(): string { return $this->instrumentDisplayName; }
+ public function getInstrumentIcon(): string { return $this->instrumentIcon; }
+}
diff --git a/docs/examples/payment-frontend.html b/docs/examples/payment-frontend.html
new file mode 100644
index 00000000..520dff64
--- /dev/null
+++ b/docs/examples/payment-frontend.html
@@ -0,0 +1,211 @@
+
+
+
+
+
+ Secure Payment Confirmation - Example
+
+
+
+ Secure Payment Confirmation
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/examples/payment-handlers.php b/docs/examples/payment-handlers.php
new file mode 100644
index 00000000..011f8386
--- /dev/null
+++ b/docs/examples/payment-handlers.php
@@ -0,0 +1,280 @@
+serializer->serialize($publicKeyCredentialRequestOptions, 'json'),
+ Response::HTTP_OK,
+ [],
+ true
+ );
+ }
+}
+
+/**
+ * Enhanced Payment Options Handler with Transaction Support
+ *
+ * This version shows how to add the payment extension dynamically
+ * based on transaction data from your database.
+ */
+class PaymentOptionsHandlerWithTransaction implements RequestOptionsHandler
+{
+ public function __construct(
+ private readonly SerializerInterface $serializer,
+ private readonly PaymentServiceInterface $paymentService,
+ ) {
+ }
+
+ public function onRequestOptions(
+ PublicKeyCredentialRequestOptions $publicKeyCredentialRequestOptions,
+ ?PublicKeyCredentialUserEntity $userEntity,
+ ?Request $request = null
+ ): Response {
+ // IMPORTANT: For SECURITY, you should NEVER trust payment data from the client!
+ // Instead, use the transaction ID to fetch data from your database:
+
+ // 1. Guard: No request, serialize and return as-is
+ if ($request === null) {
+ return $this->serializeOptions($publicKeyCredentialRequestOptions);
+ }
+
+ // 2. Extract transaction ID from request
+ $content = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
+ $transactionId = $content['transactionId'] ?? null;
+
+ // 3. Guard: No transaction ID, return as-is
+ if ($transactionId === null) {
+ return $this->serializeOptions($publicKeyCredentialRequestOptions);
+ }
+
+ // 4. SECURITY: Fetch actual payment details from YOUR database
+ $transaction = $this->paymentService->getTransaction($transactionId);
+
+ // 5. Guard: Transaction not found, return as-is
+ if ($transaction === null) {
+ return $this->serializeOptions($publicKeyCredentialRequestOptions);
+ }
+
+ // 6. Build payment extension using typed objects
+ $paymentData = CollectedClientAdditionalPaymentData::create(
+ $transaction->getRpId(),
+ $transaction->getTopOrigin(),
+ $transaction->getPayeeName(),
+ $transaction->getPayeeOrigin(),
+ PaymentCurrencyAmount::create(
+ $transaction->getCurrency(),
+ $transaction->getAmount()
+ ),
+ PaymentCredentialInstrument::create(
+ $transaction->getInstrumentDisplayName(),
+ $transaction->getInstrumentIcon()
+ )
+ );
+
+ // 7. Add payment extension
+ $publicKeyCredentialRequestOptions->extensions[] = AuthenticationExtension::create('payment', $paymentData);
+
+ return $this->serializeOptions($publicKeyCredentialRequestOptions);
+ }
+
+ private function serializeOptions(PublicKeyCredentialRequestOptions $options): JsonResponse
+ {
+ return new JsonResponse(
+ $this->serializer->serialize($options, 'json'),
+ Response::HTTP_OK,
+ [],
+ true
+ );
+ }
+}
+
+/**
+ * Success Handler for Payment
+ *
+ * Called when payment confirmation is successful
+ */
+class PaymentSuccessHandler implements SuccessHandler
+{
+ public function __construct(
+ private readonly PaymentServiceInterface $paymentService,
+ ) {
+ }
+
+ /**
+ * Called by the bundle's AssertionResponseController after successful validation
+ *
+ * NEW: Now receives the credential, options, and user entity directly!
+ */
+ public function onSuccess(
+ Request $request,
+ ?PublicKeyCredential $publicKeyCredential = null,
+ ?PublicKeyCredentialOptions $publicKeyCredentialOptions = null,
+ ?PublicKeyCredentialUserEntity $userEntity = null
+ ): Response {
+ // At this point, the bundle has already:
+ // 1. Validated the WebAuthn signature
+ // 2. Checked the challenge
+ // 3. Verified the credential exists and belongs to the user
+ // 4. Updated the credential counter
+
+ // Guard: No credential provided
+ if ($publicKeyCredential === null) {
+ return new JsonResponse([
+ 'success' => false,
+ 'error' => 'No credential provided',
+ ], Response::HTTP_BAD_REQUEST);
+ }
+
+ // Guard: Payment extension not present
+ if (!isset($publicKeyCredential->response->clientExtensionResults['payment'])) {
+ return new JsonResponse([
+ 'success' => false,
+ 'error' => 'Payment extension not present',
+ ], Response::HTTP_BAD_REQUEST);
+ }
+
+ // Extract transaction ID from request
+ $content = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
+ $transactionId = $content['transactionId'] ?? null;
+
+ // Guard: No transaction ID
+ if ($transactionId === null) {
+ return new JsonResponse([
+ 'success' => false,
+ 'error' => 'Transaction ID missing',
+ ], Response::HTTP_BAD_REQUEST);
+ }
+
+ // Process the payment in your system
+ $this->paymentService->processPayment($transactionId, [
+ 'credentialId' => base64_encode($publicKeyCredential->rawId),
+ 'userHandle' => $userEntity?->id,
+ 'userId' => $userEntity?->name,
+ 'timestamp' => time(),
+ 'confirmed' => true,
+ ]);
+
+ return new JsonResponse([
+ 'success' => true,
+ 'message' => 'Payment confirmed successfully',
+ 'transactionId' => $transactionId,
+ ]);
+ }
+}
+
+/**
+ * Failure Handler for Payment
+ *
+ * Called when payment confirmation fails
+ */
+class PaymentFailureHandler implements FailureHandler
+{
+ public function onFailure(Request $request, ?Throwable $exception = null): Response
+ {
+ // Log the error for security monitoring
+ // You might want to inject a logger here
+
+ return new JsonResponse([
+ 'success' => false,
+ 'error' => 'Payment confirmation failed',
+ 'message' => $exception?->getMessage() ?? '',
+ ], Response::HTTP_BAD_REQUEST);
+ }
+}
+
+/**
+ * Payment Service Interface
+ * Implement this according to your business logic
+ */
+interface PaymentServiceInterface
+{
+ /**
+ * Retrieve transaction details from database
+ */
+ public function getTransaction(string $transactionId): ?PaymentTransaction;
+
+ /**
+ * Process the confirmed payment
+ */
+ public function processPayment(string $transactionId, array $data): bool;
+}
+
+/**
+ * Example Payment Transaction DTO
+ */
+class PaymentTransaction
+{
+ public function __construct(
+ private readonly string $id,
+ private readonly string $rpId,
+ private readonly string $topOrigin,
+ private readonly string $payeeName,
+ private readonly string $payeeOrigin,
+ private readonly string $amount,
+ private readonly string $currency,
+ private readonly string $instrumentDisplayName,
+ private readonly string $instrumentIcon,
+ ) {
+ }
+
+ public function getId(): string { return $this->id; }
+ public function getRpId(): string { return $this->rpId; }
+ public function getTopOrigin(): string { return $this->topOrigin; }
+ public function getPayeeName(): string { return $this->payeeName; }
+ public function getPayeeOrigin(): string { return $this->payeeOrigin; }
+ public function getAmount(): string { return $this->amount; }
+ public function getCurrency(): string { return $this->currency; }
+ public function getInstrumentDisplayName(): string { return $this->instrumentDisplayName; }
+ public function getInstrumentIcon(): string { return $this->instrumentIcon; }
+}
diff --git a/docs/examples/payment-service-example.php b/docs/examples/payment-service-example.php
new file mode 100644
index 00000000..28f93c72
--- /dev/null
+++ b/docs/examples/payment-service-example.php
@@ -0,0 +1,337 @@
+transactionRepository->findOneBy([
+ 'transactionId' => $transactionId,
+ 'status' => 'pending', // Only allow pending transactions
+ ]);
+
+ if ($transaction === null) {
+ $this->logger->warning('Transaction not found', [
+ 'transactionId' => $transactionId,
+ ]);
+ return null;
+ }
+
+ // Check if transaction has expired
+ if ($transaction->isExpired()) {
+ $this->logger->warning('Transaction expired', [
+ 'transactionId' => $transactionId,
+ 'expiresAt' => $transaction->getExpiresAt(),
+ ]);
+ return null;
+ }
+
+ return $transaction;
+
+ } catch (\Exception $e) {
+ $this->logger->error('Error fetching transaction', [
+ 'transactionId' => $transactionId,
+ 'error' => $e->getMessage(),
+ ]);
+ return null;
+ }
+ }
+
+ /**
+ * Process a confirmed payment
+ *
+ * Called after successful WebAuthn verification
+ */
+ public function processPayment(string $transactionId, array $data): bool
+ {
+ try {
+ $transaction = $this->transactionRepository->findOneBy([
+ 'transactionId' => $transactionId,
+ ]);
+
+ if ($transaction === null) {
+ $this->logger->error('Cannot process payment: transaction not found', [
+ 'transactionId' => $transactionId,
+ ]);
+ return false;
+ }
+
+ // Verify transaction is in correct state
+ if ($transaction->getStatus() !== 'pending') {
+ $this->logger->warning('Transaction not in pending state', [
+ 'transactionId' => $transactionId,
+ 'status' => $transaction->getStatus(),
+ ]);
+ return false;
+ }
+
+ // Mark as confirmed
+ $transaction->setStatus('confirmed');
+ $transaction->setConfirmedAt(new \DateTimeImmutable());
+ $transaction->setCredentialId($data['credentialId']);
+
+ $this->entityManager->persist($transaction);
+ $this->entityManager->flush();
+
+ $this->logger->info('Payment confirmed', [
+ 'transactionId' => $transactionId,
+ 'amount' => $transaction->getAmount(),
+ 'currency' => $transaction->getCurrency(),
+ ]);
+
+ // Here you would integrate with your payment processor
+ // Examples:
+ // - Stripe: $this->stripeService->capturePayment($transaction);
+ // - Bank: $this->bankService->transferFunds($transaction);
+ // - Internal: $this->accountService->debitAccount($transaction);
+
+ return true;
+
+ } catch (\Exception $e) {
+ $this->logger->error('Error processing payment', [
+ 'transactionId' => $transactionId,
+ 'error' => $e->getMessage(),
+ 'trace' => $e->getTraceAsString(),
+ ]);
+
+ return false;
+ }
+ }
+
+ /**
+ * Create a new payment transaction
+ *
+ * Call this when initiating a payment flow
+ */
+ public function createTransaction(
+ string $userId,
+ string $amount,
+ string $currency,
+ string $payeeName,
+ string $payeeOrigin,
+ array $metadata = []
+ ): PaymentTransaction {
+ $transaction = new PaymentTransaction();
+ $transaction->setTransactionId($this->generateTransactionId());
+ $transaction->setUserId($userId);
+ $transaction->setAmount($amount);
+ $transaction->setCurrency($currency);
+ $transaction->setPayeeName($payeeName);
+ $transaction->setPayeeOrigin($payeeOrigin);
+ $transaction->setRpId($_ENV['WEBAUTHN_RP_ID'] ?? 'example.com');
+ $transaction->setTopOrigin($_ENV['APP_URL'] ?? 'https://example.com');
+ $transaction->setStatus('pending');
+ $transaction->setCreatedAt(new \DateTimeImmutable());
+ $transaction->setExpiresAt(new \DateTimeImmutable('+15 minutes'));
+ $transaction->setMetadata($metadata);
+
+ // Set instrument details (e.g., from user's saved payment methods)
+ $transaction->setInstrumentDisplayName($metadata['instrumentDisplayName'] ?? 'Default Payment Method');
+ $transaction->setInstrumentIcon($metadata['instrumentIcon'] ?? 'https://example.com/icon.png');
+
+ $this->entityManager->persist($transaction);
+ $this->entityManager->flush();
+
+ $this->logger->info('Payment transaction created', [
+ 'transactionId' => $transaction->getTransactionId(),
+ 'amount' => $amount,
+ 'currency' => $currency,
+ ]);
+
+ return $transaction;
+ }
+
+ /**
+ * Generate a cryptographically secure transaction ID
+ */
+ private function generateTransactionId(): string
+ {
+ return 'txn_' . bin2hex(random_bytes(16));
+ }
+
+ /**
+ * Cancel a transaction
+ */
+ public function cancelTransaction(string $transactionId): bool
+ {
+ try {
+ $transaction = $this->transactionRepository->findOneBy([
+ 'transactionId' => $transactionId,
+ ]);
+
+ if ($transaction === null) {
+ return false;
+ }
+
+ $transaction->setStatus('cancelled');
+ $transaction->setCancelledAt(new \DateTimeImmutable());
+
+ $this->entityManager->persist($transaction);
+ $this->entityManager->flush();
+
+ $this->logger->info('Payment transaction cancelled', [
+ 'transactionId' => $transactionId,
+ ]);
+
+ return true;
+
+ } catch (\Exception $e) {
+ $this->logger->error('Error cancelling transaction', [
+ 'transactionId' => $transactionId,
+ 'error' => $e->getMessage(),
+ ]);
+
+ return false;
+ }
+ }
+
+ /**
+ * Get transaction status
+ */
+ public function getTransactionStatus(string $transactionId): ?string
+ {
+ $transaction = $this->transactionRepository->findOneBy([
+ 'transactionId' => $transactionId,
+ ]);
+
+ return $transaction?->getStatus();
+ }
+}
+
+/**
+ * Example Doctrine Entity for Payment Transactions
+ *
+ * Create this entity in your project:
+ * php bin/console make:entity PaymentTransaction
+ */
+#[\Doctrine\ORM\Mapping\Entity]
+#[\Doctrine\ORM\Mapping\Table(name: 'payment_transactions')]
+class PaymentTransactionEntity
+{
+ #[\Doctrine\ORM\Mapping\Id]
+ #[\Doctrine\ORM\Mapping\GeneratedValue]
+ #[\Doctrine\ORM\Mapping\Column]
+ private ?int $id = null;
+
+ #[\Doctrine\ORM\Mapping\Column(length: 255, unique: true)]
+ private string $transactionId;
+
+ #[\Doctrine\ORM\Mapping\Column(length: 255)]
+ private string $userId;
+
+ #[\Doctrine\ORM\Mapping\Column(length: 50)]
+ private string $status; // pending, confirmed, cancelled, failed
+
+ #[\Doctrine\ORM\Mapping\Column(length: 20)]
+ private string $amount;
+
+ #[\Doctrine\ORM\Mapping\Column(length: 3)]
+ private string $currency;
+
+ #[\Doctrine\ORM\Mapping\Column(length: 255)]
+ private string $payeeName;
+
+ #[\Doctrine\ORM\Mapping\Column(length: 255)]
+ private string $payeeOrigin;
+
+ #[\Doctrine\ORM\Mapping\Column(length: 255)]
+ private string $rpId;
+
+ #[\Doctrine\ORM\Mapping\Column(length: 255)]
+ private string $topOrigin;
+
+ #[\Doctrine\ORM\Mapping\Column(length: 255)]
+ private string $instrumentDisplayName;
+
+ #[\Doctrine\ORM\Mapping\Column(length: 255, nullable: true)]
+ private ?string $instrumentIcon = null;
+
+ #[\Doctrine\ORM\Mapping\Column(type: 'json')]
+ private array $metadata = [];
+
+ #[\Doctrine\ORM\Mapping\Column(length: 255, nullable: true)]
+ private ?string $credentialId = null;
+
+ #[\Doctrine\ORM\Mapping\Column]
+ private \DateTimeImmutable $createdAt;
+
+ #[\Doctrine\ORM\Mapping\Column]
+ private \DateTimeImmutable $expiresAt;
+
+ #[\Doctrine\ORM\Mapping\Column(nullable: true)]
+ private ?\DateTimeImmutable $confirmedAt = null;
+
+ #[\Doctrine\ORM\Mapping\Column(nullable: true)]
+ private ?\DateTimeImmutable $cancelledAt = null;
+
+ // Getters and setters...
+
+ public function isExpired(): bool
+ {
+ return $this->expiresAt < new \DateTimeImmutable();
+ }
+
+ public function getId(): ?int { return $this->id; }
+ public function getTransactionId(): string { return $this->transactionId; }
+ public function setTransactionId(string $transactionId): self { $this->transactionId = $transactionId; return $this; }
+ public function getUserId(): string { return $this->userId; }
+ public function setUserId(string $userId): self { $this->userId = $userId; return $this; }
+ public function getStatus(): string { return $this->status; }
+ public function setStatus(string $status): self { $this->status = $status; return $this; }
+ public function getAmount(): string { return $this->amount; }
+ public function setAmount(string $amount): self { $this->amount = $amount; return $this; }
+ public function getCurrency(): string { return $this->currency; }
+ public function setCurrency(string $currency): self { $this->currency = $currency; return $this; }
+ public function getPayeeName(): string { return $this->payeeName; }
+ public function setPayeeName(string $payeeName): self { $this->payeeName = $payeeName; return $this; }
+ public function getPayeeOrigin(): string { return $this->payeeOrigin; }
+ public function setPayeeOrigin(string $payeeOrigin): self { $this->payeeOrigin = $payeeOrigin; return $this; }
+ public function getRpId(): string { return $this->rpId; }
+ public function setRpId(string $rpId): self { $this->rpId = $rpId; return $this; }
+ public function getTopOrigin(): string { return $this->topOrigin; }
+ public function setTopOrigin(string $topOrigin): self { $this->topOrigin = $topOrigin; return $this; }
+ public function getInstrumentDisplayName(): string { return $this->instrumentDisplayName; }
+ public function setInstrumentDisplayName(string $name): self { $this->instrumentDisplayName = $name; return $this; }
+ public function getInstrumentIcon(): ?string { return $this->instrumentIcon; }
+ public function setInstrumentIcon(?string $icon): self { $this->instrumentIcon = $icon; return $this; }
+ public function getMetadata(): array { return $this->metadata; }
+ public function setMetadata(array $metadata): self { $this->metadata = $metadata; return $this; }
+ public function getCredentialId(): ?string { return $this->credentialId; }
+ public function setCredentialId(?string $credentialId): self { $this->credentialId = $credentialId; return $this; }
+ public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
+ public function setCreatedAt(\DateTimeImmutable $createdAt): self { $this->createdAt = $createdAt; return $this; }
+ public function getExpiresAt(): \DateTimeImmutable { return $this->expiresAt; }
+ public function setExpiresAt(\DateTimeImmutable $expiresAt): self { $this->expiresAt = $expiresAt; return $this; }
+ public function getConfirmedAt(): ?\DateTimeImmutable { return $this->confirmedAt; }
+ public function setConfirmedAt(?\DateTimeImmutable $confirmedAt): self { $this->confirmedAt = $confirmedAt; return $this; }
+ public function getCancelledAt(): ?\DateTimeImmutable { return $this->cancelledAt; }
+ public function setCancelledAt(?\DateTimeImmutable $cancelledAt): self { $this->cancelledAt = $cancelledAt; return $this; }
+}
diff --git a/docs/secure-payment-confirmation.md b/docs/secure-payment-confirmation.md
new file mode 100644
index 00000000..d7d731a5
--- /dev/null
+++ b/docs/secure-payment-confirmation.md
@@ -0,0 +1,568 @@
+# Secure Payment Confirmation (SPC)
+
+Secure Payment Confirmation (SPC) is a Web API that allows customers to authenticate with a payment provider using WebAuthn. This provides a streamlined and secure checkout experience.
+
+## Table of Contents
+
+- [Overview](#overview)
+- [Installation](#installation)
+- [PHP Usage](#php-usage)
+ - [Creating Payment Extension](#creating-payment-extension)
+ - [Validating Payment Data](#validating-payment-data)
+ - [Data Structures](#data-structures)
+- [JavaScript/Stimulus Usage](#javascriptstimulus-usage)
+- [Complete Example](#complete-example)
+- [W3C Specification](#w3c-specification)
+
+## Overview
+
+SPC enables customers to authenticate card payments using WebAuthn, providing:
+
+- **Strong authentication** via FIDO2/WebAuthn
+- **User-friendly experience** with biometric authentication
+- **Regulatory compliance** (e.g., PSD2 SCA in Europe)
+- **Fraud prevention** through device-bound credentials
+
+## Installation
+
+The SPC support is included in the core `webauthn-framework/webauthn` package:
+
+```bash
+composer require web-auth/webauthn-framework
+```
+
+For Stimulus controllers (optional):
+
+```bash
+npm install @web-auth/webauthn-stimulus
+```
+
+## PHP Usage
+
+### Creating Payment Extension
+
+To request a payment confirmation, create a payment extension when building your `PublicKeyCredentialRequestOptions`:
+
+```php
+use Webauthn\AuthenticationExtensions\PaymentExtension;
+use Webauthn\SecurePaymentConfirmation\PaymentCurrencyAmount;
+use Webauthn\SecurePaymentConfirmation\PaymentCredentialInstrument;
+use Webauthn\PublicKeyCredentialRequestOptions;
+
+// Create payment data
+$amount = PaymentCurrencyAmount::create('USD', '99.99');
+$instrument = PaymentCredentialInstrument::create(
+ displayName: 'Visa โขโขโขโข 1234',
+ icon: 'https://example.com/visa-icon.png',
+ iconMustBeShown: true
+);
+
+// Create payment extension
+$paymentExtension = PaymentExtension::register(
+ rpId: 'example.com',
+ topOrigin: 'https://merchant.example.com',
+ payeeName: 'Merchant Store',
+ payeeOrigin: 'https://merchant.example.com',
+ amount: $amount,
+ instrument: $instrument
+);
+
+// Add to authentication options
+$publicKeyCredentialRequestOptions = PublicKeyCredentialRequestOptions::create(
+ challenge: random_bytes(32),
+ rpId: 'example.com',
+ extensions: new AuthenticationExtensions([$paymentExtension])
+);
+```
+
+### Validating Payment Data
+
+After receiving the authentication response, validate the payment data:
+
+```php
+use Webauthn\AuthenticationExtensions\PaymentExtensionOutputChecker;
+use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler;
+
+// Create payment extension output checker
+$paymentChecker = new PaymentExtensionOutputChecker(
+ expectedPayeeName: 'Merchant Store',
+ expectedPayeeOrigin: 'https://merchant.example.com',
+ expectedRpId: 'example.com'
+);
+
+// Add to extension output checker handler
+$extensionOutputCheckerHandler = ExtensionOutputCheckerHandler::create();
+$extensionOutputCheckerHandler->add($paymentChecker);
+
+// The checker will be called during the assertion verification process
+$publicKeyCredentialSource = $authenticatorAssertionResponseValidator->check(
+ publicKeyCredentialSource: $publicKeyCredentialSource,
+ authenticatorAssertionResponse: $authenticatorAssertionResponse,
+ publicKeyCredentialRequestOptions: $publicKeyCredentialRequestOptions,
+ host: 'example.com',
+ userHandle: $userHandle,
+ extensionOutputCheckerHandler: $extensionOutputCheckerHandler
+);
+```
+
+### Data Structures
+
+#### PaymentCurrencyAmount
+
+Represents the transaction amount:
+
+```php
+use Webauthn\SecurePaymentConfirmation\PaymentCurrencyAmount;
+
+$amount = PaymentCurrencyAmount::create(
+ currency: 'USD', // ISO 4217 currency code
+ value: '99.99' // Amount as string
+);
+
+// Properties are readonly
+echo $amount->currency; // 'USD'
+echo $amount->value; // '99.99'
+```
+
+#### PaymentCredentialInstrument
+
+Represents the payment instrument (e.g., credit card):
+
+```php
+use Webauthn\SecurePaymentConfirmation\PaymentCredentialInstrument;
+
+$instrument = PaymentCredentialInstrument::create(
+ displayName: 'Visa โขโขโขโข 1234',
+ icon: 'https://example.com/visa-icon.png',
+ iconMustBeShown: true // Optional, defaults to true
+);
+```
+
+#### CollectedClientAdditionalPaymentData
+
+Contains the payment data collected from the client:
+
+```php
+use Webauthn\SecurePaymentConfirmation\CollectedClientAdditionalPaymentData;
+
+$paymentData = CollectedClientAdditionalPaymentData::create(
+ rpId: 'example.com',
+ topOrigin: 'https://merchant.example.com',
+ payeeName: 'Merchant Store',
+ payeeOrigin: 'https://merchant.example.com',
+ total: $amount,
+ instrument: $instrument
+);
+```
+
+#### CollectedClientPaymentData
+
+Top-level wrapper for payment data:
+
+```php
+use Webauthn\SecurePaymentConfirmation\CollectedClientPaymentData;
+
+$clientPaymentData = CollectedClientPaymentData::create($paymentData);
+```
+
+## JavaScript/Stimulus Usage
+
+### Using the Payment Controller
+
+The Stimulus payment controller simplifies SPC integration on the client side.
+
+**SECURITY NOTICE:** For security reasons, payment details (amount, payee, etc.) are NOT passed via HTML attributes, as these can be tampered with by the client. Instead, you pass a secure `transaction-id`, and the server fetches the actual payment details from its database.
+
+```html
+
+```
+
+### Controller Options
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `optionsUrl` | String | `/payment/options` | URL to fetch payment options |
+| `resultUrl` | String | `/payment/verify` | URL to verify payment result |
+| `transactionId` | String | - | **Secure transaction ID** (server fetches payment details) |
+| `submitViaForm` | Boolean | `false` | Submit credential via form instead of API |
+| `successRedirectUri` | String | - | URI to redirect to on success |
+
+**Security Note:** Payment details (amount, payee, merchant) are **intentionally NOT configurable** via HTML attributes to prevent client-side tampering. The server must fetch these from its database using the `transactionId`.
+
+### Controller Events
+
+The payment controller dispatches several custom events:
+
+```javascript
+// Connection event
+document.addEventListener('webauthn:payment:connect', (event) => {
+ console.log('Payment controller connected');
+ console.log('Transaction ID:', event.detail.transactionId);
+});
+
+// Options request/response events
+document.addEventListener('webauthn:payment:options:request', (event) => {
+ console.log('Requesting payment options for transaction:', event.detail.data.transactionId);
+});
+
+document.addEventListener('webauthn:payment:options:success', (event) => {
+ console.log('Payment options received', event.detail.options);
+});
+
+document.addEventListener('webauthn:payment:options:error', (event) => {
+ console.error('Failed to get payment options', event.detail.error);
+});
+
+// Credential received event
+document.addEventListener('webauthn:payment:credential', (event) => {
+ console.log('Payment credential created', event.detail.credential);
+});
+
+// Verification events
+document.addEventListener('webauthn:payment:verify:request', (event) => {
+ console.log('Verifying payment credential', event.detail.credential);
+});
+
+document.addEventListener('webauthn:payment:verify:success', (event) => {
+ console.log('Payment verified successfully', event.detail.result);
+});
+
+document.addEventListener('webauthn:payment:verify:error', (event) => {
+ console.error('Payment verification failed', event.detail.error);
+});
+
+// Error event
+document.addEventListener('webauthn:payment:error', (event) => {
+ console.error('Payment error', event.detail.error);
+ if (event.detail.code) {
+ console.error('Error code:', event.detail.code);
+ }
+});
+
+// Unsupported browser
+document.addEventListener('webauthn:unsupported', () => {
+ alert('Your browser does not support WebAuthn');
+});
+```
+
+## Complete Example
+
+### Backend (PHP)
+
+```php
+getContent(), true);
+
+ // SECURITY: Fetch payment details from database using transaction ID
+ // This prevents client-side tampering of amounts/payee
+ $transactionId = $data['transactionId'] ?? null;
+ if (!$transactionId) {
+ return new JsonResponse(['error' => 'Transaction ID required'], 400);
+ }
+
+ // Get transaction from secure database
+ $transaction = $this->transactionRepository->findOneBy(['id' => $transactionId]);
+ if (!$transaction || $transaction->getUserId() !== $this->getUser()->getId()) {
+ return new JsonResponse(['error' => 'Transaction not found'], 404);
+ }
+
+ // Verify transaction is in pending state
+ if ($transaction->getStatus() !== 'pending') {
+ return new JsonResponse(['error' => 'Transaction already processed'], 400);
+ }
+
+ // Create payment extension with SERVER-VALIDATED data
+ $amount = PaymentCurrencyAmount::create(
+ $transaction->getCurrency(),
+ $transaction->getAmount()
+ );
+
+ $instrument = PaymentCredentialInstrument::create(
+ $transaction->getPaymentMethod()->getDisplayName(),
+ $transaction->getPaymentMethod()->getIconUrl()
+ );
+
+ $paymentExtension = PaymentExtension::register(
+ rpId: 'example.com',
+ topOrigin: $transaction->getMerchantOrigin(),
+ payeeName: $transaction->getPayeeName(),
+ payeeOrigin: $transaction->getPayeeOrigin(),
+ amount: $amount,
+ instrument: $instrument
+ );
+
+ // Create authentication options
+ $options = PublicKeyCredentialRequestOptions::create(
+ challenge: random_bytes(32),
+ rpId: 'example.com',
+ allowCredentials: $this->getUserCredentials(),
+ extensions: new AuthenticationExtensions([$paymentExtension])
+ );
+
+ // Store challenge and transaction ID in session for verification
+ $_SESSION['payment_challenge'] = base64_encode($options->challenge);
+ $_SESSION['payment_transaction_id'] = $transactionId;
+
+ return new JsonResponse($options);
+ }
+
+ public function verify(Request $request): JsonResponse
+ {
+ $data = json_decode($request->getContent(), true);
+
+ // Deserialize and verify the credential
+ $publicKeyCredential = $this->serializer->deserialize(
+ json_encode($data),
+ PublicKeyCredential::class,
+ 'json'
+ );
+
+ // Verify payment extension output
+ $paymentChecker = new PaymentExtensionOutputChecker(
+ expectedPayeeName: 'Merchant Store',
+ expectedPayeeOrigin: 'https://merchant.example.com',
+ expectedRpId: 'example.com'
+ );
+
+ $extensionHandler = ExtensionOutputCheckerHandler::create();
+ $extensionHandler->add($paymentChecker);
+
+ // Verify the assertion
+ $publicKeyCredentialSource = $this->authenticatorAssertionResponseValidator->check(
+ publicKeyCredentialSource: $this->findCredentialSource($publicKeyCredential->id),
+ authenticatorAssertionResponse: $publicKeyCredential->response,
+ publicKeyCredentialRequestOptions: $this->getStoredOptions(),
+ host: 'example.com',
+ userHandle: $this->getCurrentUserId(),
+ extensionOutputCheckerHandler: $extensionHandler
+ );
+
+ // Process payment
+ $this->processPayment($publicKeyCredentialSource);
+
+ return new JsonResponse(['verified' => true]);
+ }
+}
+```
+
+### Frontend (HTML + Stimulus)
+
+```html
+
+
+
+ Secure Payment
+
+
+
+
Checkout
+
+ find($transactionId);
+ ?>
+
+
+
+
+
+
+
+```
+
+## W3C Specification
+
+This implementation follows the [W3C Secure Payment Confirmation specification](https://www.w3.org/TR/secure-payment-confirmation/).
+
+Key features implemented:
+
+- โ
Payment extension for WebAuthn
+- โ
PaymentCredentialInstrument data structure
+- โ
PaymentCurrencyAmount data structure
+- โ
CollectedClientPaymentData verification
+- โ
Extension output validation
+- โ
Required field validation (rpId, topOrigin, total, instrument)
+- โ
Payee information validation (payeeName/payeeOrigin)
+
+## Browser Support
+
+SPC is supported in:
+
+- Chrome 105+ (Desktop and Android)
+- Edge 105+
+- Opera 91+
+
+Check browser support at runtime:
+
+```javascript
+import { browserSupportsWebAuthn } from '@simplewebauthn/browser';
+
+if (browserSupportsWebAuthn()) {
+ // SPC is supported
+}
+```
+
+## Security Considerations
+
+### Critical Security Measures
+
+1. **NEVER trust client-side payment data**
+ - Payment amounts, payee names, and merchant details must NEVER come from HTML attributes or JavaScript
+ - Always fetch these from your secure server-side database using a transaction ID
+ - The Stimulus controller is designed to only send a `transactionId` - do not modify it to accept payment details
+
+2. **Server-side validation is mandatory**
+ - Always validate payment data using `PaymentExtensionOutputChecker`
+ - Verify the transaction belongs to the authenticated user
+ - Check the transaction status (must be "pending")
+ - Validate the amount hasn't been modified
+
+3. **Transaction ID security**
+ - Generate cryptographically secure transaction IDs (e.g., `bin2hex(random_bytes(16))`)
+ - Store transaction state in your database with user association
+ - Implement transaction expiry (e.g., 15 minutes)
+ - Mark transactions as "completed" or "cancelled" after processing
+
+4. **Standard WebAuthn security**
+ - Use HTTPS - SPC requires a secure context
+ - Verify the challenge matches what was sent to the client
+ - Check the RP ID matches your domain
+ - Validate the payee origin matches the expected merchant
+ - Store credentials securely using proper key management
+
+### Example: Secure Transaction Flow
+
+```php
+// 1. Create transaction in database (server-side only)
+$transaction = new Transaction();
+$transaction->setId(bin2hex(random_bytes(16))); // Secure ID
+$transaction->setUserId($currentUser->getId());
+$transaction->setAmount('99.99');
+$transaction->setCurrency('USD');
+$transaction->setPayeeName('Merchant Store');
+$transaction->setStatus('pending');
+$transaction->setExpiresAt(new DateTime('+15 minutes'));
+$entityManager->persist($transaction);
+$entityManager->flush();
+
+// 2. Render page with transaction ID only
+echo '
+```
+
+## Configuration
+
+### Values
+
+| Value | Type | Default | Description |
+|----------------------|---------|-------------------|------------------------------------------|
+| `optionsUrl` | String | `/payment/options` | URL to fetch payment options from server |
+| `resultUrl` | String | `/payment/verify` | URL to verify payment credential |
+| `transactionId` | String | - | **Secure transaction ID** (server fetches payment details) |
+| `submitViaForm` | Boolean | `false` | Submit via form instead of API |
+| `successRedirectUri` | String | - | Redirect URL on success |
+
+**๐ Security Note:** Payment details (amount, payee, merchant) are **intentionally NOT configurable** via HTML attributes. The server must fetch these from its database using the `transactionId` to prevent client-side tampering.
+
+### Targets
+
+| Target | Required | Description |
+|----------|-----------|------------------------------------------------------|
+| `result` | No | Hidden input to store credential for form submission |
+
+### Actions
+
+| Action | Description |
+|------------------|-------------------------------------|
+| `confirmPayment` | Initiates payment confirmation flow |
+
+## Events
+
+The controller dispatches custom events for integration:
+
+### Connection Events
+
+```javascript
+// Fired when controller connects
+document.addEventListener('webauthn:payment:connect', (event) => {
+ console.log('Payment data:', event.detail);
+ // event.detail contains: { optionsUrl, resultUrl, payeeName, payeeOrigin, amount, currency }
+});
+```
+
+### Options Events
+
+```javascript
+// Before fetching options
+document.addEventListener('webauthn:payment:options:request', (event) => {
+ console.log('Requesting options with:', event.detail.data);
+});
+
+// Options successfully received
+document.addEventListener('webauthn:payment:options:success', (event) => {
+ console.log('Options:', event.detail.options);
+});
+
+// Options request failed
+document.addEventListener('webauthn:payment:options:error', (event) => {
+ console.error('Options error:', event.detail.error || event.detail.response);
+});
+```
+
+### Credential Events
+
+```javascript
+// Credential created successfully
+document.addEventListener('webauthn:payment:credential', (event) => {
+ console.log('Credential:', event.detail.credential);
+ // Access payment extension results:
+ // event.detail.credential.clientExtensionResults.payment
+});
+```
+
+### Verification Events
+
+```javascript
+// Before verifying credential
+document.addEventListener('webauthn:payment:verify:request', (event) => {
+ console.log('Verifying credential:', event.detail.credential);
+});
+
+// Verification successful
+document.addEventListener('webauthn:payment:verify:success', (event) => {
+ console.log('Verification result:', event.detail.result);
+});
+
+// Verification failed
+document.addEventListener('webauthn:payment:verify:error', (event) => {
+ console.error('Verification error:', event.detail.error || event.detail.response);
+});
+```
+
+### Error Events
+
+```javascript
+// General payment errors
+document.addEventListener('webauthn:payment:error', (event) => {
+ console.error('Payment error:', event.detail.error);
+ if (event.detail.code) {
+ console.error('Error code:', event.detail.code);
+ console.error('Error name:', event.detail.name);
+ }
+});
+
+// Browser doesn't support WebAuthn
+document.addEventListener('webauthn:unsupported', () => {
+ alert('Your browser does not support WebAuthn');
+});
+```
+
+## Advanced Usage
+
+### Custom Payment Instrument
+
+```html
+
+```
+
+### Form Submission Mode
+
+Instead of API calls, submit the credential via traditional form submission:
+
+```html
+
+```
+
+### Success Redirect
+
+Automatically redirect on successful payment:
+
+```html
+
+```
+
+### Event Handling
+
+```html
+
+
+
+```
+
+## Server-Side Integration
+
+### Options Endpoint
+
+Your `/payment/options` endpoint should return a `PublicKeyCredentialRequestOptions` with the payment extension:
+
+```php
+getContent(), true);
+
+// Create payment data
+$amount = PaymentCurrencyAmount::create(
+ $data['payment']['total']['currency'],
+ $data['payment']['total']['value']
+);
+
+$instrument = PaymentCredentialInstrument::create(
+ $data['payment']['instrument']['displayName'] ?? 'Card',
+ $data['payment']['instrument']['icon'] ?? 'https://example.com/icon.png'
+);
+
+$paymentExtension = PaymentExtension::register(
+ rpId: 'example.com',
+ topOrigin: 'https://merchant.example.com',
+ payeeName: $data['payment']['payeeName'],
+ payeeOrigin: $data['payment']['payeeOrigin'],
+ amount: $amount,
+ instrument: $instrument
+);
+
+// Create options
+$options = PublicKeyCredentialRequestOptions::create(
+ challenge: random_bytes(32),
+ rpId: 'example.com',
+ allowCredentials: $this->getUserCredentials(),
+ extensions: new AuthenticationExtensions([$paymentExtension])
+);
+
+return new JsonResponse($options);
+```
+
+### Verification Endpoint
+
+Your `/payment/verify` endpoint should validate the payment data:
+
+```php
+add($paymentChecker);
+
+// Verify credential and process payment
+$publicKeyCredentialSource = $this->authenticatorAssertionResponseValidator->check(
+ publicKeyCredentialSource: $credentialSource,
+ authenticatorAssertionResponse: $credential->response,
+ publicKeyCredentialRequestOptions: $storedOptions,
+ host: 'example.com',
+ userHandle: $userId,
+ extensionOutputCheckerHandler: $extensionHandler
+);
+
+return new JsonResponse(['verified' => true]);
+```
+
+## Browser Support
+
+- Chrome 105+ (Desktop and Android)
+- Edge 105+
+- Opera 91+
+
+Check support at runtime:
+
+```javascript
+import { browserSupportsWebAuthn } from '@simplewebauthn/browser';
+
+if (!browserSupportsWebAuthn()) {
+ // Show fallback payment method
+}
+```
+
+## Security Notes
+
+1. Always use HTTPS
+2. Validate payment data server-side
+3. Verify the challenge matches
+4. Check payment extension is present in response
+5. Store credentials securely
+
+## Testing
+
+Tests are located in `test/payment-controller.test.js`:
+
+```bash
+npm test
+```
+
+## License
+
+MIT License - see LICENSE file for details
diff --git a/src/stimulus/assets/package.json b/src/stimulus/assets/package.json
index f394c99a..51fcf574 100644
--- a/src/stimulus/assets/package.json
+++ b/src/stimulus/assets/package.json
@@ -69,7 +69,10 @@
"symfony-ux",
"authentication",
"passkey",
- "passwordless"
+ "passwordless",
+ "secure-payment-confirmation",
+ "payment",
+ "spc"
],
"author": {
"name": "Florent Morselli",
diff --git a/src/symfony/src/Controller/AssertionRequestController.php b/src/symfony/src/Controller/AssertionRequestController.php
index a1ff4e95..ebcd3fb7 100644
--- a/src/symfony/src/Controller/AssertionRequestController.php
+++ b/src/symfony/src/Controller/AssertionRequestController.php
@@ -32,7 +32,11 @@ public function __invoke(Request $request): Response
try {
$userEntity = null;
$publicKeyCredentialRequestOptions = $this->optionsBuilder->getFromRequest($request, $userEntity);
- $response = $this->optionsHandler->onRequestOptions($publicKeyCredentialRequestOptions, $userEntity);
+ $response = $this->optionsHandler->onRequestOptions(
+ $publicKeyCredentialRequestOptions,
+ $userEntity,
+ $request
+ );
$this->optionsStorage->store(Item::create($publicKeyCredentialRequestOptions, $userEntity));
return $response;
diff --git a/src/symfony/src/Controller/AssertionResponseController.php b/src/symfony/src/Controller/AssertionResponseController.php
index ae3c6eb6..7afafe9d 100644
--- a/src/symfony/src/Controller/AssertionResponseController.php
+++ b/src/symfony/src/Controller/AssertionResponseController.php
@@ -71,7 +71,12 @@ public function __invoke(Request $request): Response
$request->getHost(),
$userEntity?->id,
);
- return $this->successHandler->onSuccess($request);
+ return $this->successHandler->onSuccess(
+ $request,
+ $publicKeyCredential,
+ $publicKeyCredentialRequestOptions,
+ $userEntity
+ );
} catch (Throwable $throwable) {
$this->logger->error('An error occurred during the assertion ceremony', [
'exception' => $throwable,
diff --git a/src/symfony/src/Controller/AttestationRequestController.php b/src/symfony/src/Controller/AttestationRequestController.php
index d766ade5..15590f7e 100644
--- a/src/symfony/src/Controller/AttestationRequestController.php
+++ b/src/symfony/src/Controller/AttestationRequestController.php
@@ -40,7 +40,8 @@ public function __invoke(Request $request): Response
$response = $this->creationOptionsHandler->onCreationOptions(
$publicKeyCredentialCreationOptions,
- $userEntity
+ $userEntity,
+ $request,
);
$this->optionsStorage->store(Item::create($publicKeyCredentialCreationOptions, $userEntity));
diff --git a/src/symfony/src/Controller/AttestationResponseController.php b/src/symfony/src/Controller/AttestationResponseController.php
index 0373e623..0a9d28ad 100644
--- a/src/symfony/src/Controller/AttestationResponseController.php
+++ b/src/symfony/src/Controller/AttestationResponseController.php
@@ -74,7 +74,12 @@ public function __invoke(Request $request): Response
throw new BadRequestHttpException('The credentials already exists');
}
$this->credentialSourceRepository->saveCredentialSource($credentialSource);
- return $this->successHandler->onSuccess($request);
+ return $this->successHandler->onSuccess(
+ $request,
+ $publicKeyCredential,
+ $publicKeyCredentialCreationOptions,
+ $userEntity
+ );
} catch (Throwable $throwable) {
if ($throwable instanceof MissingFeatureException) {
throw new HttpNotImplementedException($throwable->getMessage(), $throwable);
diff --git a/src/symfony/src/Resources/config/services.php b/src/symfony/src/Resources/config/services.php
index 780e29ab..ea72e8fd 100644
--- a/src/symfony/src/Resources/config/services.php
+++ b/src/symfony/src/Resources/config/services.php
@@ -34,8 +34,12 @@
use Webauthn\Denormalizer\AuthenticatorAttestationResponseDenormalizer;
use Webauthn\Denormalizer\AuthenticatorDataDenormalizer;
use Webauthn\Denormalizer\AuthenticatorResponseDenormalizer;
+use Webauthn\Denormalizer\CollectedClientAdditionalPaymentDataDenormalizer;
use Webauthn\Denormalizer\CollectedClientDataDenormalizer;
+use Webauthn\Denormalizer\CollectedClientPaymentDataDenormalizer;
use Webauthn\Denormalizer\ExtensionDescriptorDenormalizer;
+use Webauthn\Denormalizer\PaymentCredentialInstrumentDenormalizer;
+use Webauthn\Denormalizer\PaymentCurrencyAmountDenormalizer;
use Webauthn\Denormalizer\PublicKeyCredentialDenormalizer;
use Webauthn\Denormalizer\PublicKeyCredentialDescriptorNormalizer;
use Webauthn\Denormalizer\PublicKeyCredentialOptionsDenormalizer;
@@ -257,6 +261,26 @@
->tag('serializer.normalizer', [
'priority' => 1024,
]);
+ $service
+ ->set(CollectedClientPaymentDataDenormalizer::class)
+ ->tag('serializer.normalizer', [
+ 'priority' => 1024,
+ ]);
+ $service
+ ->set(CollectedClientAdditionalPaymentDataDenormalizer::class)
+ ->tag('serializer.normalizer', [
+ 'priority' => 1024,
+ ]);
+ $service
+ ->set(PaymentCurrencyAmountDenormalizer::class)
+ ->tag('serializer.normalizer', [
+ 'priority' => 1024,
+ ]);
+ $service
+ ->set(PaymentCredentialInstrumentDenormalizer::class)
+ ->tag('serializer.normalizer', [
+ 'priority' => 1024,
+ ]);
$service->set(WebauthnSerializerFactory::class);
$service->set(DefaultFailureHandler::class);
$service->set(DefaultSuccessHandler::class);
diff --git a/src/webauthn/src/AuthenticationExtensions/PaymentExtension.php b/src/webauthn/src/AuthenticationExtensions/PaymentExtension.php
new file mode 100644
index 00000000..57b1edd2
--- /dev/null
+++ b/src/webauthn/src/AuthenticationExtensions/PaymentExtension.php
@@ -0,0 +1,37 @@
+ true,
+ 'rpId' => $rpId,
+ 'topOrigin' => $topOrigin,
+ 'payeeName' => $payeeName,
+ 'payeeOrigin' => $payeeOrigin,
+ 'currencyAmount' => $amount,
+ 'credentialInstrument' => $instrument,
+ ]);
+ }
+
+ public static function authenticate(): AuthenticationExtension
+ {
+ return self::create('payment', [
+ 'isPayment' => true,
+ ]);
+ }
+}
diff --git a/src/webauthn/src/AuthenticationExtensions/PaymentExtensionOutputChecker.php b/src/webauthn/src/AuthenticationExtensions/PaymentExtensionOutputChecker.php
new file mode 100644
index 00000000..59882bd9
--- /dev/null
+++ b/src/webauthn/src/AuthenticationExtensions/PaymentExtensionOutputChecker.php
@@ -0,0 +1,138 @@
+has('payment')) {
+ return;
+ }
+
+ // Verify payment extension is present in outputs
+ if (! $outputs->has('payment')) {
+ throw AuthenticatorResponseVerificationException::create(
+ 'The payment extension was requested but not returned in the response.'
+ );
+ }
+
+ $outputExtension = $outputs->get('payment');
+ $paymentData = $outputExtension->value;
+
+ // Validate payment data structure
+ if (! is_array($paymentData)) {
+ throw AuthenticatorResponseVerificationException::create('Invalid payment extension output format.');
+ }
+
+ // Validate required fields
+ $this->validateRequiredFields($paymentData);
+
+ // Validate against expected values if provided
+ if ($this->expectedPayeeName !== null) {
+ $this->validatePayeeName($paymentData);
+ }
+
+ if ($this->expectedPayeeOrigin !== null) {
+ $this->validatePayeeOrigin($paymentData);
+ }
+
+ if ($this->expectedRpId !== null) {
+ $this->validateRpId($paymentData);
+ }
+ }
+
+ private function validateRequiredFields(array $paymentData): void
+ {
+ if (! isset($paymentData['payment'])) {
+ throw AuthenticatorResponseVerificationException::create(
+ 'Missing payment data in payment extension output.'
+ );
+ }
+
+ $payment = $paymentData['payment'];
+
+ if (! is_array($payment)) {
+ throw AuthenticatorResponseVerificationException::create(
+ 'Invalid payment data structure in payment extension output.'
+ );
+ }
+
+ // Validate required fields in CollectedClientAdditionalPaymentData
+ $requiredFields = ['rpId', 'topOrigin', 'total', 'instrument'];
+ foreach ($requiredFields as $field) {
+ if (! isset($payment[$field])) {
+ throw AuthenticatorResponseVerificationException::create(
+ "Missing required field '{$field}' in payment data."
+ );
+ }
+ }
+
+ // Validate at least one of payeeName or payeeOrigin is present
+ if (empty($payment['payeeName']) && empty($payment['payeeOrigin'])) {
+ throw AuthenticatorResponseVerificationException::create(
+ 'At least one of payeeName or payeeOrigin must be present in payment data.'
+ );
+ }
+
+ // Validate total structure
+ if (! is_array($payment['total']) || ! isset($payment['total']['currency'], $payment['total']['value'])) {
+ throw AuthenticatorResponseVerificationException::create('Invalid total structure in payment data.');
+ }
+
+ // Validate instrument structure
+ if (! is_array(
+ $payment['instrument']
+ ) || ! isset($payment['instrument']['displayName'], $payment['instrument']['icon'])) {
+ throw AuthenticatorResponseVerificationException::create(
+ 'Invalid instrument structure in payment data.'
+ );
+ }
+ }
+
+ private function validatePayeeName(array $paymentData): void
+ {
+ $actualPayeeName = $paymentData['payment']['payeeName'] ?? '';
+
+ if ($actualPayeeName !== $this->expectedPayeeName) {
+ throw AuthenticatorResponseVerificationException::create(
+ "Payment payee name mismatch. Expected '{$this->expectedPayeeName}', got '{$actualPayeeName}'."
+ );
+ }
+ }
+
+ private function validatePayeeOrigin(array $paymentData): void
+ {
+ $actualPayeeOrigin = $paymentData['payment']['payeeOrigin'] ?? '';
+
+ if ($actualPayeeOrigin !== $this->expectedPayeeOrigin) {
+ throw AuthenticatorResponseVerificationException::create(
+ "Payment payee origin mismatch. Expected '{$this->expectedPayeeOrigin}', got '{$actualPayeeOrigin}'."
+ );
+ }
+ }
+
+ private function validateRpId(array $paymentData): void
+ {
+ $actualRpId = $paymentData['payment']['rpId'];
+
+ if ($actualRpId !== $this->expectedRpId) {
+ throw AuthenticatorResponseVerificationException::create(
+ "Payment RP ID mismatch. Expected '{$this->expectedRpId}', got '{$actualRpId}'."
+ );
+ }
+ }
+}
diff --git a/src/webauthn/src/Denormalizer/CollectedClientAdditionalPaymentDataDenormalizer.php b/src/webauthn/src/Denormalizer/CollectedClientAdditionalPaymentDataDenormalizer.php
new file mode 100644
index 00000000..0a63cc2d
--- /dev/null
+++ b/src/webauthn/src/Denormalizer/CollectedClientAdditionalPaymentDataDenormalizer.php
@@ -0,0 +1,92 @@
+denormalizer->denormalize($data['total'], PaymentCurrencyAmount::class, $format, $context);
+ assert($total instanceof PaymentCurrencyAmount);
+
+ $instrument = $this->denormalizer->denormalize(
+ $data['instrument'],
+ PaymentCredentialInstrument::class,
+ $format,
+ $context
+ );
+ assert($instrument instanceof PaymentCredentialInstrument);
+
+ return new CollectedClientAdditionalPaymentData(
+ rpId: $data['rpId'],
+ topOrigin: $data['topOrigin'],
+ payeeName: $data['payeeName'] ?? '',
+ payeeOrigin: $data['payeeOrigin'] ?? '',
+ total: $total,
+ instrument: $instrument
+ );
+ }
+
+ public function supportsDenormalization(
+ mixed $data,
+ string $type,
+ ?string $format = null,
+ array $context = []
+ ): bool {
+ return $type === CollectedClientAdditionalPaymentData::class;
+ }
+
+ /**
+ * @return array
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ CollectedClientAdditionalPaymentData::class => true,
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public function normalize(mixed $object, ?string $format = null, array $context = []): array
+ {
+ assert($object instanceof CollectedClientAdditionalPaymentData);
+ return [
+ 'rpId' => $object->rpId,
+ 'topOrigin' => $object->topOrigin,
+ 'payeeName' => $object->payeeName,
+ 'payeeOrigin' => $object->payeeOrigin,
+ 'total' => [
+ 'currency' => $object->total->currency,
+ 'value' => $object->total->value,
+ ],
+ 'instrument' => [
+ 'displayName' => $object->instrument->displayName,
+ 'icon' => $object->instrument->icon,
+ 'iconMustBeShown' => $object->instrument->iconMustBeShown,
+ ],
+ ];
+ }
+
+ public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
+ {
+ return $data instanceof CollectedClientAdditionalPaymentData;
+ }
+}
diff --git a/src/webauthn/src/Denormalizer/CollectedClientPaymentDataDenormalizer.php b/src/webauthn/src/Denormalizer/CollectedClientPaymentDataDenormalizer.php
new file mode 100644
index 00000000..d6c33e58
--- /dev/null
+++ b/src/webauthn/src/Denormalizer/CollectedClientPaymentDataDenormalizer.php
@@ -0,0 +1,83 @@
+denormalizer->denormalize(
+ $data['payment'],
+ CollectedClientAdditionalPaymentData::class,
+ $format,
+ $context
+ );
+ assert($payment instanceof CollectedClientAdditionalPaymentData);
+
+ return new CollectedClientPaymentData(payment: $payment);
+ }
+
+ public function supportsDenormalization(
+ mixed $data,
+ string $type,
+ ?string $format = null,
+ array $context = []
+ ): bool {
+ return $type === CollectedClientPaymentData::class;
+ }
+
+ /**
+ * @return array
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ CollectedClientPaymentData::class => true,
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public function normalize(mixed $object, ?string $format = null, array $context = []): array
+ {
+ assert($object instanceof CollectedClientPaymentData);
+ return [
+ 'payment' => [
+ 'rpId' => $object->payment->rpId,
+ 'topOrigin' => $object->payment->topOrigin,
+ 'payeeName' => $object->payment->payeeName,
+ 'payeeOrigin' => $object->payment->payeeOrigin,
+ 'total' => [
+ 'currency' => $object->payment->total->currency,
+ 'value' => $object->payment->total->value,
+ ],
+ 'instrument' => [
+ 'displayName' => $object->payment->instrument->displayName,
+ 'icon' => $object->payment->instrument->icon,
+ 'iconMustBeShown' => $object->payment->instrument->iconMustBeShown,
+ ],
+ ],
+ ];
+ }
+
+ public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
+ {
+ return $data instanceof CollectedClientPaymentData;
+ }
+}
diff --git a/src/webauthn/src/Denormalizer/PaymentCredentialInstrumentDenormalizer.php b/src/webauthn/src/Denormalizer/PaymentCredentialInstrumentDenormalizer.php
new file mode 100644
index 00000000..5ca592e2
--- /dev/null
+++ b/src/webauthn/src/Denormalizer/PaymentCredentialInstrumentDenormalizer.php
@@ -0,0 +1,62 @@
+
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ PaymentCredentialInstrument::class => true,
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public function normalize(mixed $object, ?string $format = null, array $context = []): array
+ {
+ assert($object instanceof PaymentCredentialInstrument);
+ return [
+ 'displayName' => $object->displayName,
+ 'icon' => $object->icon,
+ 'iconMustBeShown' => $object->iconMustBeShown,
+ ];
+ }
+
+ public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
+ {
+ return $data instanceof PaymentCredentialInstrument;
+ }
+}
diff --git a/src/webauthn/src/Denormalizer/PaymentCurrencyAmountDenormalizer.php b/src/webauthn/src/Denormalizer/PaymentCurrencyAmountDenormalizer.php
new file mode 100644
index 00000000..ea0a4e48
--- /dev/null
+++ b/src/webauthn/src/Denormalizer/PaymentCurrencyAmountDenormalizer.php
@@ -0,0 +1,57 @@
+
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ PaymentCurrencyAmount::class => true,
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public function normalize(mixed $object, ?string $format = null, array $context = []): array
+ {
+ assert($object instanceof PaymentCurrencyAmount);
+ return [
+ 'currency' => $object->currency,
+ 'value' => $object->value,
+ ];
+ }
+
+ public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
+ {
+ return $data instanceof PaymentCurrencyAmount;
+ }
+}
diff --git a/src/webauthn/src/Denormalizer/WebauthnSerializerFactory.php b/src/webauthn/src/Denormalizer/WebauthnSerializerFactory.php
index e737e217..4ea71630 100644
--- a/src/webauthn/src/Denormalizer/WebauthnSerializerFactory.php
+++ b/src/webauthn/src/Denormalizer/WebauthnSerializerFactory.php
@@ -67,6 +67,10 @@ public function create(): SerializerInterface
new SignalCurrentUserDetailsDenormalizer(),
new SignalUnknownCredentialDenormalizer(),
new TrustPathDenormalizer(),
+ new PaymentCurrencyAmountDenormalizer(),
+ new PaymentCredentialInstrumentDenormalizer(),
+ new CollectedClientAdditionalPaymentDataDenormalizer(),
+ new CollectedClientPaymentDataDenormalizer(),
new UidNormalizer(),
new ArrayDenormalizer(),
new ObjectNormalizer(
diff --git a/src/webauthn/src/SecurePaymentConfirmation/CollectedClientAdditionalPaymentData.php b/src/webauthn/src/SecurePaymentConfirmation/CollectedClientAdditionalPaymentData.php
new file mode 100644
index 00000000..645ef470
--- /dev/null
+++ b/src/webauthn/src/SecurePaymentConfirmation/CollectedClientAdditionalPaymentData.php
@@ -0,0 +1,29 @@
+ true,
+ ]),
+ ]);
+
+ $outputs = new AuthenticationExtensions([
+ AuthenticationExtension::create('payment', [
+ 'payment' => [
+ 'rpId' => 'example.com',
+ 'topOrigin' => 'https://merchant.example.com',
+ 'payeeName' => 'Merchant Store',
+ 'payeeOrigin' => 'https://merchant.example.com',
+ 'total' => [
+ 'currency' => 'USD',
+ 'value' => '99.99',
+ ],
+ 'instrument' => [
+ 'displayName' => 'Visa โขโขโขโข 1234',
+ 'icon' => 'https://example.com/visa-icon.png',
+ 'iconMustBeShown' => true,
+ ],
+ ],
+ ]),
+ ]);
+
+ // When & Then - should not throw
+ $checker->check($inputs, $outputs);
+ $this->expectNotToPerformAssertions();
+ }
+
+ #[Test]
+ public function paymentExtensionOutputCheckerFailsOnMismatch(): void
+ {
+ // Given
+ $checker = new PaymentExtensionOutputChecker(expectedPayeeName: 'Expected Store');
+
+ $inputs = new AuthenticationExtensions([
+ AuthenticationExtension::create('payment', [
+ 'isPayment' => true,
+ ]),
+ ]);
+
+ $outputs = new AuthenticationExtensions([
+ AuthenticationExtension::create('payment', [
+ 'payment' => [
+ 'rpId' => 'example.com',
+ 'topOrigin' => 'https://merchant.example.com',
+ 'payeeName' => 'Different Store',
+ 'payeeOrigin' => 'https://merchant.example.com',
+ 'total' => [
+ 'currency' => 'USD',
+ 'value' => '99.99',
+ ],
+ 'instrument' => [
+ 'displayName' => 'Card',
+ 'icon' => 'https://example.com/icon.png',
+ ],
+ ],
+ ]),
+ ]);
+
+ // Then
+ $this->expectException(AuthenticatorResponseVerificationException::class);
+ $this->expectExceptionMessage('Payment payee name mismatch');
+
+ // When
+ $checker->check($inputs, $outputs);
+ }
+
+ #[Test]
+ public function paymentDataStructuresCanBeCreatedAndSerialized(): void
+ {
+ // Given
+ $amount = PaymentCurrencyAmount::create('EUR', '150.00');
+ $instrument = PaymentCredentialInstrument::create(
+ 'MasterCard โขโขโขโข 5678',
+ 'https://example.com/mc-icon.png',
+ false
+ );
+ $additionalData = CollectedClientAdditionalPaymentData::create(
+ 'example.com',
+ 'https://merchant.example.com',
+ 'Merchant Store',
+ 'https://merchant.example.com',
+ $amount,
+ $instrument
+ );
+ $paymentData = CollectedClientPaymentData::create($additionalData);
+
+ // When
+ $serialized = $this->getSerializer()
+ ->serialize($paymentData, 'json');
+ $deserialized = $this->getSerializer()
+ ->deserialize($serialized, CollectedClientPaymentData::class, 'json');
+
+ // Then
+ static::assertEquals($paymentData, $deserialized);
+ static::assertSame('EUR', $deserialized->payment->total->currency);
+ static::assertSame('150.00', $deserialized->payment->total->value);
+ static::assertSame('MasterCard โขโขโขโข 5678', $deserialized->payment->instrument->displayName);
+ static::assertFalse($deserialized->payment->instrument->iconMustBeShown);
+ }
+
+ #[Test]
+ public function paymentExtensionCanBeUsedInAuthenticationFlow(): void
+ {
+ // Given
+ $amount = PaymentCurrencyAmount::create('USD', '99.99');
+ $instrument = PaymentCredentialInstrument::create(
+ 'Visa โขโขโขโข 1234',
+ 'https://example.com/visa-icon.png'
+ );
+
+ // When - Create authentication extensions with payment
+ $extensions = new AuthenticationExtensions([
+ AuthenticationExtension::create('payment', [
+ 'isPayment' => true,
+ 'rpId' => 'example.com',
+ 'topOrigin' => 'https://merchant.example.com',
+ 'payeeName' => 'Merchant Store',
+ 'payeeOrigin' => 'https://merchant.example.com',
+ 'total' => [
+ 'currency' => $amount->currency,
+ 'value' => $amount->value,
+ ],
+ 'instrument' => [
+ 'displayName' => $instrument->displayName,
+ 'icon' => $instrument->icon,
+ 'iconMustBeShown' => $instrument->iconMustBeShown,
+ ],
+ ]),
+ ]);
+
+ // Then
+ static::assertTrue($extensions->has('payment'));
+ $paymentExtension = $extensions->get('payment');
+ static::assertSame('payment', $paymentExtension->name);
+ static::assertIsArray($paymentExtension->value);
+ static::assertTrue($paymentExtension->value['isPayment']);
+ }
+
+ #[Test]
+ public function multipleExtensionsCanCoexistWithPayment(): void
+ {
+ // Given
+ $extensions = new AuthenticationExtensions([
+ AuthenticationExtension::create('appid', 'https://example.com'),
+ AuthenticationExtension::create('payment', [
+ 'isPayment' => true,
+ 'rpId' => 'example.com',
+ 'topOrigin' => 'https://merchant.example.com',
+ 'payeeName' => 'Store',
+ 'payeeOrigin' => 'https://store.com',
+ 'total' => [
+ 'currency' => 'USD',
+ 'value' => '50.00',
+ ],
+ 'instrument' => [
+ 'displayName' => 'Card',
+ 'icon' => 'https://example.com/icon.png',
+ ],
+ ]),
+ AuthenticationExtension::create('credProps', true),
+ ]);
+
+ // Then
+ static::assertCount(3, $extensions);
+ static::assertTrue($extensions->has('appid'));
+ static::assertTrue($extensions->has('payment'));
+ static::assertTrue($extensions->has('credProps'));
+ }
+}
diff --git a/tests/library/Unit/SecurePaymentConfirmation/CollectedClientAdditionalPaymentDataTest.php b/tests/library/Unit/SecurePaymentConfirmation/CollectedClientAdditionalPaymentDataTest.php
new file mode 100644
index 00000000..dbe33060
--- /dev/null
+++ b/tests/library/Unit/SecurePaymentConfirmation/CollectedClientAdditionalPaymentDataTest.php
@@ -0,0 +1,101 @@
+rpId);
+ static::assertSame('https://merchant.example.com', $paymentData->topOrigin);
+ static::assertSame('Merchant Store', $paymentData->payeeName);
+ static::assertSame('https://merchant.example.com', $paymentData->payeeOrigin);
+ static::assertSame($total, $paymentData->total);
+ static::assertSame($instrument, $paymentData->instrument);
+ }
+
+ #[Test]
+ public function collectedClientAdditionalPaymentDataCanBeCreatedWithConstructor(): void
+ {
+ // Given
+ $total = new PaymentCurrencyAmount('EUR', '50.00');
+ $instrument = new PaymentCredentialInstrument(
+ 'MasterCard โขโขโขโข 5678',
+ 'https://example.com/mc-icon.png'
+ );
+
+ // When
+ $paymentData = new CollectedClientAdditionalPaymentData(
+ 'store.com',
+ 'https://top.example.com',
+ 'Online Store',
+ 'https://store.example.com',
+ $total,
+ $instrument
+ );
+
+ // Then
+ static::assertSame('store.com', $paymentData->rpId);
+ static::assertSame('https://top.example.com', $paymentData->topOrigin);
+ static::assertSame('Online Store', $paymentData->payeeName);
+ static::assertSame('https://store.example.com', $paymentData->payeeOrigin);
+ static::assertSame($total, $paymentData->total);
+ static::assertSame($instrument, $paymentData->instrument);
+ }
+
+ #[Test]
+ public function collectedClientAdditionalPaymentDataPropertiesAreReadonly(): void
+ {
+ // Given
+ $total = PaymentCurrencyAmount::create('GBP', '100.00');
+ $instrument = PaymentCredentialInstrument::create('Card', 'https://example.com/icon.png');
+ $paymentData = CollectedClientAdditionalPaymentData::create(
+ 'example.com',
+ 'https://top.example.com',
+ 'Payee',
+ 'https://payee.example.com',
+ $total,
+ $instrument
+ );
+
+ // Then
+ $reflection = new ReflectionClass($paymentData);
+ static::assertTrue($reflection->getProperty('rpId')->isReadOnly());
+ static::assertTrue($reflection->getProperty('topOrigin')->isReadOnly());
+ static::assertTrue($reflection->getProperty('payeeName')->isReadOnly());
+ static::assertTrue($reflection->getProperty('payeeOrigin')->isReadOnly());
+ static::assertTrue($reflection->getProperty('total')->isReadOnly());
+ static::assertTrue($reflection->getProperty('instrument')->isReadOnly());
+ }
+}
diff --git a/tests/library/Unit/SecurePaymentConfirmation/CollectedClientPaymentDataTest.php b/tests/library/Unit/SecurePaymentConfirmation/CollectedClientPaymentDataTest.php
new file mode 100644
index 00000000..8bb15cf1
--- /dev/null
+++ b/tests/library/Unit/SecurePaymentConfirmation/CollectedClientPaymentDataTest.php
@@ -0,0 +1,90 @@
+payment);
+ }
+
+ #[Test]
+ public function collectedClientPaymentDataCanBeCreatedWithConstructor(): void
+ {
+ // Given
+ $total = new PaymentCurrencyAmount('EUR', '75.00');
+ $instrument = new PaymentCredentialInstrument(
+ 'MasterCard โขโขโขโข 5678',
+ 'https://example.com/mc-icon.png'
+ );
+ $additionalData = new CollectedClientAdditionalPaymentData(
+ 'store.com',
+ 'https://top.example.com',
+ 'Online Store',
+ 'https://store.example.com',
+ $total,
+ $instrument
+ );
+
+ // When
+ $paymentData = new CollectedClientPaymentData($additionalData);
+
+ // Then
+ static::assertSame($additionalData, $paymentData->payment);
+ }
+
+ #[Test]
+ public function collectedClientPaymentDataPaymentPropertyIsReadonly(): void
+ {
+ // Given
+ $total = PaymentCurrencyAmount::create('GBP', '200.00');
+ $instrument = PaymentCredentialInstrument::create('Card', 'https://example.com/icon.png');
+ $additionalData = CollectedClientAdditionalPaymentData::create(
+ 'example.com',
+ 'https://top.example.com',
+ 'Payee',
+ 'https://payee.example.com',
+ $total,
+ $instrument
+ );
+ $paymentData = CollectedClientPaymentData::create($additionalData);
+
+ // Then
+ $reflection = new ReflectionClass($paymentData);
+ static::assertTrue($reflection->getProperty('payment')->isReadOnly());
+ }
+}
diff --git a/tests/library/Unit/SecurePaymentConfirmation/PaymentCredentialInstrumentTest.php b/tests/library/Unit/SecurePaymentConfirmation/PaymentCredentialInstrumentTest.php
new file mode 100644
index 00000000..a3ca3585
--- /dev/null
+++ b/tests/library/Unit/SecurePaymentConfirmation/PaymentCredentialInstrumentTest.php
@@ -0,0 +1,75 @@
+displayName);
+ static::assertSame('https://example.com/visa-icon.png', $instrument->icon);
+ static::assertTrue($instrument->iconMustBeShown);
+ }
+
+ #[Test]
+ public function paymentCredentialInstrumentCanBeCreatedWithCustomIconMustBeShown(): void
+ {
+ // Given & When
+ $instrument = PaymentCredentialInstrument::create(
+ 'MasterCard โขโขโขโข 5678',
+ 'https://example.com/mc-icon.png',
+ false
+ );
+
+ // Then
+ static::assertSame('MasterCard โขโขโขโข 5678', $instrument->displayName);
+ static::assertSame('https://example.com/mc-icon.png', $instrument->icon);
+ static::assertFalse($instrument->iconMustBeShown);
+ }
+
+ #[Test]
+ public function paymentCredentialInstrumentCanBeCreatedWithConstructor(): void
+ {
+ // Given & When
+ $instrument = new PaymentCredentialInstrument(
+ 'Amex โขโขโขโข 9012',
+ 'https://example.com/amex-icon.png'
+ );
+
+ // Then
+ static::assertSame('Amex โขโขโขโข 9012', $instrument->displayName);
+ static::assertSame('https://example.com/amex-icon.png', $instrument->icon);
+ static::assertTrue($instrument->iconMustBeShown);
+ }
+
+ #[Test]
+ public function paymentCredentialInstrumentPropertiesAreReadonly(): void
+ {
+ // Given
+ $instrument = PaymentCredentialInstrument::create('Card', 'https://example.com/icon.png');
+
+ // Then
+ $reflection = new ReflectionClass($instrument);
+ static::assertTrue($reflection->getProperty('displayName')->isReadOnly());
+ static::assertTrue($reflection->getProperty('icon')->isReadOnly());
+ static::assertTrue($reflection->getProperty('iconMustBeShown')->isReadOnly());
+ }
+}
diff --git a/tests/library/Unit/SecurePaymentConfirmation/PaymentCurrencyAmountTest.php b/tests/library/Unit/SecurePaymentConfirmation/PaymentCurrencyAmountTest.php
new file mode 100644
index 00000000..dee1e36a
--- /dev/null
+++ b/tests/library/Unit/SecurePaymentConfirmation/PaymentCurrencyAmountTest.php
@@ -0,0 +1,50 @@
+currency);
+ static::assertSame('10.00', $amount->value);
+ }
+
+ #[Test]
+ public function paymentCurrencyAmountCanBeCreatedWithConstructor(): void
+ {
+ // Given & When
+ $amount = new PaymentCurrencyAmount('EUR', '25.50');
+
+ // Then
+ static::assertSame('EUR', $amount->currency);
+ static::assertSame('25.50', $amount->value);
+ }
+
+ #[Test]
+ public function paymentCurrencyAmountPropertiesAreReadonly(): void
+ {
+ // Given
+ $amount = PaymentCurrencyAmount::create('GBP', '100.00');
+
+ // Then
+ $reflection = new ReflectionClass($amount);
+ static::assertTrue($reflection->getProperty('currency')->isReadOnly());
+ static::assertTrue($reflection->getProperty('value')->isReadOnly());
+ }
+}
diff --git a/tests/library/Unit/SecurePaymentConfirmation/PaymentDataSerializationTest.php b/tests/library/Unit/SecurePaymentConfirmation/PaymentDataSerializationTest.php
new file mode 100644
index 00000000..11b07d2f
--- /dev/null
+++ b/tests/library/Unit/SecurePaymentConfirmation/PaymentDataSerializationTest.php
@@ -0,0 +1,226 @@
+getSerializer()
+ ->serialize(
+ $amount,
+ 'json',
+ [
+ JsonEncode::OPTIONS => JSON_THROW_ON_ERROR,
+ AbstractObjectNormalizer::SKIP_NULL_VALUES => true,
+ AbstractObjectNormalizer::SKIP_UNINITIALIZED_VALUES => true,
+ ]
+ );
+
+ $deserialized = $this->getSerializer()
+ ->deserialize(
+ $json,
+ PaymentCurrencyAmount::class,
+ 'json',
+ [
+ AbstractObjectNormalizer::SKIP_NULL_VALUES => true,
+ AbstractObjectNormalizer::SKIP_UNINITIALIZED_VALUES => true,
+ ]
+ );
+
+ // Then
+ static::assertJsonStringEqualsJsonString('{"currency":"USD","value":"99.99"}', $json);
+ static::assertEquals($amount, $deserialized);
+ }
+
+ #[Test]
+ public function paymentCredentialInstrumentCanBeSerializedAndDeserialized(): void
+ {
+ // Given
+ $instrument = PaymentCredentialInstrument::create(
+ 'Visa โขโขโขโข 1234',
+ 'https://example.com/visa-icon.png',
+ true
+ );
+
+ // When
+ $json = $this->getSerializer()
+ ->serialize(
+ $instrument,
+ 'json',
+ [
+ JsonEncode::OPTIONS => JSON_THROW_ON_ERROR,
+ AbstractObjectNormalizer::SKIP_NULL_VALUES => true,
+ AbstractObjectNormalizer::SKIP_UNINITIALIZED_VALUES => true,
+ ]
+ );
+
+ $deserialized = $this->getSerializer()
+ ->deserialize(
+ $json,
+ PaymentCredentialInstrument::class,
+ 'json',
+ [
+ AbstractObjectNormalizer::SKIP_NULL_VALUES => true,
+ AbstractObjectNormalizer::SKIP_UNINITIALIZED_VALUES => true,
+ ]
+ );
+
+ // Then
+ static::assertJsonStringEqualsJsonString(
+ '{"displayName":"Visa โขโขโขโข 1234","icon":"https://example.com/visa-icon.png","iconMustBeShown":true}',
+ $json
+ );
+ static::assertEquals($instrument, $deserialized);
+ }
+
+ #[Test]
+ public function collectedClientAdditionalPaymentDataCanBeSerializedAndDeserialized(): void
+ {
+ // Given
+ $total = PaymentCurrencyAmount::create('USD', '150.00');
+ $instrument = PaymentCredentialInstrument::create(
+ 'MasterCard โขโขโขโข 5678',
+ 'https://example.com/mc-icon.png',
+ false
+ );
+ $paymentData = CollectedClientAdditionalPaymentData::create(
+ 'example.com',
+ 'https://merchant.example.com',
+ 'Merchant Store',
+ 'https://merchant.example.com',
+ $total,
+ $instrument
+ );
+
+ // When
+ $json = $this->getSerializer()
+ ->serialize(
+ $paymentData,
+ 'json',
+ [
+ JsonEncode::OPTIONS => JSON_THROW_ON_ERROR,
+ AbstractObjectNormalizer::SKIP_NULL_VALUES => true,
+ AbstractObjectNormalizer::SKIP_UNINITIALIZED_VALUES => true,
+ ]
+ );
+
+ $deserialized = $this->getSerializer()
+ ->deserialize(
+ $json,
+ CollectedClientAdditionalPaymentData::class,
+ 'json',
+ [
+ AbstractObjectNormalizer::SKIP_NULL_VALUES => true,
+ AbstractObjectNormalizer::SKIP_UNINITIALIZED_VALUES => true,
+ ]
+ );
+
+ // Then
+ static::assertJsonStringEqualsJsonString(
+ '{
+ "rpId": "example.com",
+ "topOrigin": "https://merchant.example.com",
+ "payeeName": "Merchant Store",
+ "payeeOrigin": "https://merchant.example.com",
+ "total": {
+ "currency": "USD",
+ "value": "150.00"
+ },
+ "instrument": {
+ "displayName": "MasterCard โขโขโขโข 5678",
+ "icon": "https://example.com/mc-icon.png",
+ "iconMustBeShown": false
+ }
+ }',
+ $json
+ );
+ static::assertEquals($paymentData, $deserialized);
+ }
+
+ #[Test]
+ public function collectedClientPaymentDataCanBeSerializedAndDeserialized(): void
+ {
+ // Given
+ $total = PaymentCurrencyAmount::create('EUR', '75.50');
+ $instrument = PaymentCredentialInstrument::create(
+ 'Amex โขโขโขโข 9012',
+ 'https://example.com/amex-icon.png'
+ );
+ $additionalData = CollectedClientAdditionalPaymentData::create(
+ 'store.com',
+ 'https://top.example.com',
+ 'Online Store',
+ 'https://store.example.com',
+ $total,
+ $instrument
+ );
+ $paymentData = CollectedClientPaymentData::create($additionalData);
+
+ // When
+ $json = $this->getSerializer()
+ ->serialize(
+ $paymentData,
+ 'json',
+ [
+ JsonEncode::OPTIONS => JSON_THROW_ON_ERROR,
+ AbstractObjectNormalizer::SKIP_NULL_VALUES => true,
+ AbstractObjectNormalizer::SKIP_UNINITIALIZED_VALUES => true,
+ ]
+ );
+
+ $deserialized = $this->getSerializer()
+ ->deserialize(
+ $json,
+ CollectedClientPaymentData::class,
+ 'json',
+ [
+ AbstractObjectNormalizer::SKIP_NULL_VALUES => true,
+ AbstractObjectNormalizer::SKIP_UNINITIALIZED_VALUES => true,
+ ]
+ );
+
+ // Then
+ static::assertJsonStringEqualsJsonString(
+ '{
+ "payment": {
+ "rpId": "store.com",
+ "topOrigin": "https://top.example.com",
+ "payeeName": "Online Store",
+ "payeeOrigin": "https://store.example.com",
+ "total": {
+ "currency": "EUR",
+ "value": "75.50"
+ },
+ "instrument": {
+ "displayName": "Amex โขโขโขโข 9012",
+ "icon": "https://example.com/amex-icon.png",
+ "iconMustBeShown": true
+ }
+ }
+ }',
+ $json
+ );
+ static::assertEquals($paymentData, $deserialized);
+ }
+}
diff --git a/tests/library/Unit/SecurePaymentConfirmation/PaymentExtensionOutputCheckerTest.php b/tests/library/Unit/SecurePaymentConfirmation/PaymentExtensionOutputCheckerTest.php
new file mode 100644
index 00000000..16ae4bac
--- /dev/null
+++ b/tests/library/Unit/SecurePaymentConfirmation/PaymentExtensionOutputCheckerTest.php
@@ -0,0 +1,261 @@
+check($inputs, $outputs);
+ $this->expectNotToPerformAssertions();
+ }
+
+ #[Test]
+ public function paymentExtensionRequestedButNotReturnedShouldFail(): void
+ {
+ // Given
+ $checker = new PaymentExtensionOutputChecker();
+ $inputs = new AuthenticationExtensions([
+ AuthenticationExtension::create('payment', [
+ 'isPayment' => true,
+ ]),
+ ]);
+ $outputs = new AuthenticationExtensions([]);
+
+ // Then
+ $this->expectException(AuthenticatorResponseVerificationException::class);
+ $this->expectExceptionMessage('The payment extension was requested but not returned in the response.');
+
+ // When
+ $checker->check($inputs, $outputs);
+ }
+
+ #[Test]
+ public function validPaymentExtensionOutputShouldPass(): void
+ {
+ // Given
+ $checker = new PaymentExtensionOutputChecker();
+ $inputs = new AuthenticationExtensions([
+ AuthenticationExtension::create('payment', [
+ 'isPayment' => true,
+ ]),
+ ]);
+ $outputs = new AuthenticationExtensions([
+ AuthenticationExtension::create('payment', [
+ 'payment' => [
+ 'rpId' => 'example.com',
+ 'topOrigin' => 'https://merchant.example.com',
+ 'payeeName' => 'Merchant Store',
+ 'payeeOrigin' => 'https://merchant.example.com',
+ 'total' => [
+ 'currency' => 'USD',
+ 'value' => '99.99',
+ ],
+ 'instrument' => [
+ 'displayName' => 'Visa โขโขโขโข 1234',
+ 'icon' => 'https://example.com/visa-icon.png',
+ 'iconMustBeShown' => true,
+ ],
+ ],
+ ]),
+ ]);
+
+ // When & Then
+ $checker->check($inputs, $outputs);
+ $this->expectNotToPerformAssertions();
+ }
+
+ #[Test]
+ public function missingRequiredFieldsShouldFail(): void
+ {
+ // Given
+ $checker = new PaymentExtensionOutputChecker();
+ $inputs = new AuthenticationExtensions([
+ AuthenticationExtension::create('payment', [
+ 'isPayment' => true,
+ ]),
+ ]);
+ $outputs = new AuthenticationExtensions([
+ AuthenticationExtension::create('payment', [
+ 'payment' => [
+ 'rpId' => 'example.com',
+ 'topOrigin' => 'https://merchant.example.com',
+ // Missing total and instrument
+ ],
+ ]),
+ ]);
+
+ // Then
+ $this->expectException(AuthenticatorResponseVerificationException::class);
+
+ // When
+ $checker->check($inputs, $outputs);
+ }
+
+ #[Test]
+ public function payeeNameMismatchShouldFail(): void
+ {
+ // Given
+ $checker = new PaymentExtensionOutputChecker(expectedPayeeName: 'Expected Store');
+ $inputs = new AuthenticationExtensions([
+ AuthenticationExtension::create('payment', [
+ 'isPayment' => true,
+ ]),
+ ]);
+ $outputs = new AuthenticationExtensions([
+ AuthenticationExtension::create('payment', [
+ 'payment' => [
+ 'rpId' => 'example.com',
+ 'topOrigin' => 'https://merchant.example.com',
+ 'payeeName' => 'Different Store',
+ 'payeeOrigin' => 'https://merchant.example.com',
+ 'total' => [
+ 'currency' => 'USD',
+ 'value' => '99.99',
+ ],
+ 'instrument' => [
+ 'displayName' => 'Visa โขโขโขโข 1234',
+ 'icon' => 'https://example.com/visa-icon.png',
+ ],
+ ],
+ ]),
+ ]);
+
+ // Then
+ $this->expectException(AuthenticatorResponseVerificationException::class);
+ $this->expectExceptionMessage('Payment payee name mismatch');
+
+ // When
+ $checker->check($inputs, $outputs);
+ }
+
+ #[Test]
+ public function payeeOriginMismatchShouldFail(): void
+ {
+ // Given
+ $checker = new PaymentExtensionOutputChecker(expectedPayeeOrigin: 'https://expected.com');
+ $inputs = new AuthenticationExtensions([
+ AuthenticationExtension::create('payment', [
+ 'isPayment' => true,
+ ]),
+ ]);
+ $outputs = new AuthenticationExtensions([
+ AuthenticationExtension::create('payment', [
+ 'payment' => [
+ 'rpId' => 'example.com',
+ 'topOrigin' => 'https://merchant.example.com',
+ 'payeeName' => 'Store',
+ 'payeeOrigin' => 'https://different.com',
+ 'total' => [
+ 'currency' => 'USD',
+ 'value' => '99.99',
+ ],
+ 'instrument' => [
+ 'displayName' => 'Visa โขโขโขโข 1234',
+ 'icon' => 'https://example.com/visa-icon.png',
+ ],
+ ],
+ ]),
+ ]);
+
+ // Then
+ $this->expectException(AuthenticatorResponseVerificationException::class);
+ $this->expectExceptionMessage('Payment payee origin mismatch');
+
+ // When
+ $checker->check($inputs, $outputs);
+ }
+
+ #[Test]
+ public function rpIdMismatchShouldFail(): void
+ {
+ // Given
+ $checker = new PaymentExtensionOutputChecker(expectedRpId: 'expected.com');
+ $inputs = new AuthenticationExtensions([
+ AuthenticationExtension::create('payment', [
+ 'isPayment' => true,
+ ]),
+ ]);
+ $outputs = new AuthenticationExtensions([
+ AuthenticationExtension::create('payment', [
+ 'payment' => [
+ 'rpId' => 'different.com',
+ 'topOrigin' => 'https://merchant.example.com',
+ 'payeeName' => 'Store',
+ 'payeeOrigin' => 'https://merchant.example.com',
+ 'total' => [
+ 'currency' => 'USD',
+ 'value' => '99.99',
+ ],
+ 'instrument' => [
+ 'displayName' => 'Visa โขโขโขโข 1234',
+ 'icon' => 'https://example.com/visa-icon.png',
+ ],
+ ],
+ ]),
+ ]);
+
+ // Then
+ $this->expectException(AuthenticatorResponseVerificationException::class);
+ $this->expectExceptionMessage('Payment RP ID mismatch');
+
+ // When
+ $checker->check($inputs, $outputs);
+ }
+
+ #[Test]
+ public function atLeastPayeeNameOrOriginRequired(): void
+ {
+ // Given
+ $checker = new PaymentExtensionOutputChecker();
+ $inputs = new AuthenticationExtensions([
+ AuthenticationExtension::create('payment', [
+ 'isPayment' => true,
+ ]),
+ ]);
+ $outputs = new AuthenticationExtensions([
+ AuthenticationExtension::create('payment', [
+ 'payment' => [
+ 'rpId' => 'example.com',
+ 'topOrigin' => 'https://merchant.example.com',
+ 'payeeName' => '',
+ 'payeeOrigin' => '',
+ 'total' => [
+ 'currency' => 'USD',
+ 'value' => '99.99',
+ ],
+ 'instrument' => [
+ 'displayName' => 'Visa โขโขโขโข 1234',
+ 'icon' => 'https://example.com/visa-icon.png',
+ ],
+ ],
+ ]),
+ ]);
+
+ // Then
+ $this->expectException(AuthenticatorResponseVerificationException::class);
+ $this->expectExceptionMessage('At least one of payeeName or payeeOrigin must be present');
+
+ // When
+ $checker->check($inputs, $outputs);
+ }
+}