From b6f970ed69e1372a67f438dd97ff425d2c6f51c9 Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Tue, 18 Nov 2025 15:19:39 +0100 Subject: [PATCH] feat: implement secure payment confirmation data structures and denormalizers --- .ci-tools/phpstan-baseline.neon | 504 ++++++++++++++++ .gitattributes | 1 + docs/examples/QUICKSTART.md | 306 ++++++++++ docs/examples/README.md | 329 ++++++++++ .../payment-bundle-configuration.yaml | 69 +++ .../payment-controller-standalone.php | 289 +++++++++ docs/examples/payment-frontend.html | 211 +++++++ docs/examples/payment-handlers.php | 280 +++++++++ docs/examples/payment-service-example.php | 337 +++++++++++ docs/secure-payment-confirmation.md | 568 ++++++++++++++++++ src/stimulus/PAYMENT_CONTROLLER.md | 337 +++++++++++ src/stimulus/assets/package.json | 5 +- .../Controller/AssertionRequestController.php | 6 +- .../AssertionResponseController.php | 7 +- .../AttestationRequestController.php | 3 +- .../AttestationResponseController.php | 7 +- src/symfony/src/Resources/config/services.php | 24 + .../PaymentExtension.php | 37 ++ .../PaymentExtensionOutputChecker.php | 138 +++++ ...lientAdditionalPaymentDataDenormalizer.php | 92 +++ ...CollectedClientPaymentDataDenormalizer.php | 83 +++ ...aymentCredentialInstrumentDenormalizer.php | 62 ++ .../PaymentCurrencyAmountDenormalizer.php | 57 ++ .../WebauthnSerializerFactory.php | 4 + .../CollectedClientAdditionalPaymentData.php | 29 + .../CollectedClientPaymentData.php | 18 + .../PaymentCredentialInstrument.php | 20 + .../PaymentCurrencyAmount.php | 19 + .../SecurePaymentConfirmationTest.php | 205 +++++++ ...llectedClientAdditionalPaymentDataTest.php | 101 ++++ .../CollectedClientPaymentDataTest.php | 90 +++ .../PaymentCredentialInstrumentTest.php | 75 +++ .../PaymentCurrencyAmountTest.php | 50 ++ .../PaymentDataSerializationTest.php | 226 +++++++ .../PaymentExtensionOutputCheckerTest.php | 261 ++++++++ 35 files changed, 4845 insertions(+), 5 deletions(-) create mode 100644 docs/examples/QUICKSTART.md create mode 100644 docs/examples/README.md create mode 100644 docs/examples/payment-bundle-configuration.yaml create mode 100644 docs/examples/payment-controller-standalone.php create mode 100644 docs/examples/payment-frontend.html create mode 100644 docs/examples/payment-handlers.php create mode 100644 docs/examples/payment-service-example.php create mode 100644 docs/secure-payment-confirmation.md create mode 100644 src/stimulus/PAYMENT_CONTROLLER.md create mode 100644 src/webauthn/src/AuthenticationExtensions/PaymentExtension.php create mode 100644 src/webauthn/src/AuthenticationExtensions/PaymentExtensionOutputChecker.php create mode 100644 src/webauthn/src/Denormalizer/CollectedClientAdditionalPaymentDataDenormalizer.php create mode 100644 src/webauthn/src/Denormalizer/CollectedClientPaymentDataDenormalizer.php create mode 100644 src/webauthn/src/Denormalizer/PaymentCredentialInstrumentDenormalizer.php create mode 100644 src/webauthn/src/Denormalizer/PaymentCurrencyAmountDenormalizer.php create mode 100644 src/webauthn/src/SecurePaymentConfirmation/CollectedClientAdditionalPaymentData.php create mode 100644 src/webauthn/src/SecurePaymentConfirmation/CollectedClientPaymentData.php create mode 100644 src/webauthn/src/SecurePaymentConfirmation/PaymentCredentialInstrument.php create mode 100644 src/webauthn/src/SecurePaymentConfirmation/PaymentCurrencyAmount.php create mode 100644 tests/library/Functional/SecurePaymentConfirmationTest.php create mode 100644 tests/library/Unit/SecurePaymentConfirmation/CollectedClientAdditionalPaymentDataTest.php create mode 100644 tests/library/Unit/SecurePaymentConfirmation/CollectedClientPaymentDataTest.php create mode 100644 tests/library/Unit/SecurePaymentConfirmation/PaymentCredentialInstrumentTest.php create mode 100644 tests/library/Unit/SecurePaymentConfirmation/PaymentCurrencyAmountTest.php create mode 100644 tests/library/Unit/SecurePaymentConfirmation/PaymentDataSerializationTest.php create mode 100644 tests/library/Unit/SecurePaymentConfirmation/PaymentExtensionOutputCheckerTest.php 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

+ +
+

Payment Details

+
+

Merchant: Example Store

+

Amount: 99.99 EUR

+

Transaction ID: txn_abc123

+
+ + +
+ + + + + + + + +
+ + + + +
+
+ + + + + + + + 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 +
+ +

Confirm Payment

+ +

Amount: getFormattedAmount()) ?>

+

Merchant: getPayeeName()) ?>

+

Payment Method: getInstrumentDisplay()) ?>

+ + + +
+``` + +### 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); + ?> + +
+ +
+

Order Summary

+ +

Total: getFormattedAmount()) ?>

+

Merchant: getPayeeName()) ?>

+
+ +
+

Payment Method

+ <?= htmlspecialchars($transaction->getPaymentMethod()->getBrand()) ?> + getPaymentMethod()->getDisplayName()) ?> +
+ + + +
+

๐Ÿ”’ This payment will be authenticated using your device's biometric sensor

+
+
+
+ + + + +``` + +## 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 '
'; + +// 3. In options endpoint: Fetch from database +$transaction = $repository->findOneBy([ + 'id' => $transactionId, + 'userId' => $currentUser->getId(), + 'status' => 'pending' +]); + +if (!$transaction || $transaction->isExpired()) { + return new JsonResponse(['error' => 'Invalid transaction'], 400); +} + +// Use $transaction data for payment extension (NOT client data!) +``` + +### Why This Matters + +**Attack scenario without this protection:** +1. User initiates payment for $10.00 +2. Attacker modifies HTML: `data-amount-value="0.01"` +3. Without server validation, user pays only $0.01 + +**Protection with transaction ID:** +1. User initiates payment for $10.00 +2. Server creates transaction with ID `txn_abc123` storing amount $10.00 +3. Attacker can modify HTML attributes, but server ignores them +4. Server always uses database amount ($10.00) from transaction ID +5. Payment is processed for the correct amount โœ… + +## Troubleshooting + +### Payment extension not present in response + +Ensure the payment extension is properly configured in your `PublicKeyCredentialRequestOptions` and that the browser supports SPC. + +### Payee name/origin mismatch + +Check that the expected values in `PaymentExtensionOutputChecker` match the actual values sent to the client. + +### Browser doesn't show payment UI + +Verify: +- Browser supports SPC (Chrome 105+) +- Page is served over HTTPS +- Payment extension is correctly formatted +- User has registered a credential with the payment extension + +## Contributing + +Contributions are welcome! Please see [CONTRIBUTING.md](../CONTRIBUTING.md) for details. + +## License + +This library is released under the MIT License. See [LICENSE](../LICENSE) for details. diff --git a/src/stimulus/PAYMENT_CONTROLLER.md b/src/stimulus/PAYMENT_CONTROLLER.md new file mode 100644 index 00000000..ce522c73 --- /dev/null +++ b/src/stimulus/PAYMENT_CONTROLLER.md @@ -0,0 +1,337 @@ +# Payment Controller - Secure Payment Confirmation + +The Payment Controller provides Stimulus-based integration for Secure Payment Confirmation (SPC) using WebAuthn. + +## โš ๏ธ Security Notice + +**This controller is designed to be secure by default.** Payment details (amount, payee, merchant) are **intentionally NOT** accepted via HTML attributes to prevent client-side tampering. Instead, you provide a secure `transaction-id`, and the server fetches the actual payment details from its database. + +## Installation + +```bash +npm install @web-auth/webauthn-stimulus +``` + +## Basic Usage + +```html + + + +

Amount: getFormattedAmount()) ?>

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