diff --git a/Classes/Controller/LoginController.php b/Classes/Controller/LoginController.php index 001b154..320be73 100644 --- a/Classes/Controller/LoginController.php +++ b/Classes/Controller/LoginController.php @@ -27,8 +27,6 @@ use Sandstorm\NeosTwoFactorAuthentication\Service\SecondFactorSessionStorageService; use Sandstorm\NeosTwoFactorAuthentication\Service\TOTPService; use Sandstorm\NeosTwoFactorAuthentication\Service\WebAuthnService; -use Webauthn\PublicKeyCredentialCreationOptions; -use Webauthn\PublicKeyCredentialRequestOptions; class LoginController extends ActionController { @@ -324,12 +322,13 @@ public function webAuthnRegisterOptionsAction(bool $discoverable = false): strin } $hostname = $this->request->getHttpRequest()->getUri()->getHost(); $options = $this->webAuthnService->createRegistrationOptions($account, $hostname, $discoverable); + $optionsJson = $this->webAuthnService->optionsToJson($options); $this->secondFactorSessionStorageService->putValue( SecondFactorSessionStorageService::SESSION_OBJECT_WEBAUTHN_REGISTRATION_OPTIONS, - json_encode($options, JSON_THROW_ON_ERROR) + $optionsJson ); $this->response->setContentType('application/json'); - return json_encode($options, JSON_THROW_ON_ERROR); + return $optionsJson; } /** @@ -345,7 +344,7 @@ public function webAuthnRegisterVerifyAction(string $attestation, string $name = if (!is_string($serialized)) { return $this->jsonError('No registration in progress', 400); } - $options = PublicKeyCredentialCreationOptions::createFromString($serialized); + $options = $this->webAuthnService->creationOptionsFromJson($serialized); $account = $this->securityContext->getAccount(); if ($account === null) { return $this->jsonError('No authentication in progress', 401); @@ -385,12 +384,13 @@ public function webAuthnAuthenticateOptionsAction(): string return $this->jsonError('No authentication in progress', 401); } $options = $this->webAuthnService->createAuthenticationOptions($account); + $optionsJson = $this->webAuthnService->optionsToJson($options); $this->secondFactorSessionStorageService->putValue( SecondFactorSessionStorageService::SESSION_OBJECT_WEBAUTHN_AUTHENTICATION_OPTIONS, - json_encode($options, JSON_THROW_ON_ERROR) + $optionsJson ); $this->response->setContentType('application/json'); - return json_encode($options, JSON_THROW_ON_ERROR); + return $optionsJson; } /** @@ -404,7 +404,7 @@ public function webAuthnAuthenticateVerifyAction(string $assertion): string if (!is_string($serialized)) { return $this->jsonError('No authentication in progress', 400); } - $options = PublicKeyCredentialRequestOptions::createFromString($serialized); + $options = $this->webAuthnService->requestOptionsFromJson($serialized); $account = $this->securityContext->getAccount(); if ($account === null) { return $this->jsonError('No authentication in progress', 401); diff --git a/Classes/Controller/PasswordlessLoginController.php b/Classes/Controller/PasswordlessLoginController.php index c4bdc5e..9119d49 100644 --- a/Classes/Controller/PasswordlessLoginController.php +++ b/Classes/Controller/PasswordlessLoginController.php @@ -15,7 +15,6 @@ use Sandstorm\NeosTwoFactorAuthentication\Security\Token\WebAuthnPasswordlessToken; use Sandstorm\NeosTwoFactorAuthentication\Service\SecondFactorSessionStorageService; use Sandstorm\NeosTwoFactorAuthentication\Service\WebAuthnService; -use Webauthn\PublicKeyCredentialRequestOptions; /** * XHR endpoints for usernameless, passwordless passkey login from the Neos login screen. @@ -71,12 +70,13 @@ public function optionsAction(): string $this->secondFactorSessionStorageService->startSessionIfNotStarted(); $hostname = $this->request->getHttpRequest()->getUri()->getHost(); $options = $this->webAuthnService->createPasswordlessAuthenticationOptions($hostname); + $optionsJson = $this->webAuthnService->optionsToJson($options); $this->secondFactorSessionStorageService->putValue( SecondFactorSessionStorageService::SESSION_OBJECT_WEBAUTHN_PASSWORDLESS_OPTIONS, - json_encode($options, JSON_THROW_ON_ERROR) + $optionsJson ); $this->response->setContentType('application/json'); - return json_encode($options, JSON_THROW_ON_ERROR); + return $optionsJson; } /** @@ -98,7 +98,7 @@ public function verifyAction(string $assertion): string if (!is_string($serialized)) { return $this->jsonError('No passwordless login in progress', 400); } - $options = PublicKeyCredentialRequestOptions::createFromString($serialized); + $options = $this->webAuthnService->requestOptionsFromJson($serialized); try { $account = $this->webAuthnService->verifyPasswordlessAssertion( diff --git a/Classes/Service/PublicKeyCredentialSourceRepositoryAdapter.php b/Classes/Service/PublicKeyCredentialSourceRepositoryAdapter.php index 6eede86..2dcb8ac 100644 --- a/Classes/Service/PublicKeyCredentialSourceRepositoryAdapter.php +++ b/Classes/Service/PublicKeyCredentialSourceRepositoryAdapter.php @@ -7,21 +7,31 @@ use Psr\Log\LoggerInterface; use Sandstorm\NeosTwoFactorAuthentication\Domain\Model\SecondFactor; use Sandstorm\NeosTwoFactorAuthentication\Domain\Repository\SecondFactorRepository; -use Webauthn\PublicKeyCredentialSource; -use Webauthn\PublicKeyCredentialSourceRepository; +use Webauthn\CredentialRecord; use Webauthn\PublicKeyCredentialUserEntity; /** - * Adapter implementing the web-auth library's credential repository on top of - * our generic {@see SecondFactorRepository}. + * Repository for stored WebAuthn credentials, backed by our generic {@see SecondFactorRepository}. * - * Each row of TYPE_PUBLIC_KEY stores a JSON-serialized PublicKeyCredentialSource - * in the `secret` column. + * Each row of TYPE_PUBLIC_KEY stores a JSON-serialized credential (written by + * {@see WebAuthnService}) in the `secret` column. Under web-auth/webauthn-lib v5 these are read + * back via the Symfony serializer as {@see CredentialRecord} objects. + * + * In v4 this class implemented the library's `PublicKeyCredentialSourceRepository` interface and the + * ceremony validators called it back to look up and save credentials. v5 removed that interface — + * the validators no longer touch a repository — so this is now plain application code that + * {@see WebAuthnService} drives directly (look up before `check()`, save the counter bump after). * * @Flow\Scope("singleton") */ -class PublicKeyCredentialSourceRepositoryAdapter implements PublicKeyCredentialSourceRepository +class PublicKeyCredentialSourceRepositoryAdapter { + /** + * @Flow\Inject + * @var WebAuthnSerializerProvider + */ + protected $serializerProvider; + /** * @Flow\Inject * @var SecondFactorRepository @@ -40,10 +50,10 @@ class PublicKeyCredentialSourceRepositoryAdapter implements PublicKeyCredentialS */ protected $persistenceManager; - public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKeyCredentialSource + public function findOneByCredentialId(string $publicKeyCredentialId): ?CredentialRecord { foreach ($this->iterateAllWebAuthnFactors() as [$factor, $source]) { - if ($source->getPublicKeyCredentialId() === $publicKeyCredentialId) { + if ($source->publicKeyCredentialId === $publicKeyCredentialId) { return $source; } } @@ -51,42 +61,47 @@ public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKey } /** - * @return PublicKeyCredentialSource[] + * @return CredentialRecord[] */ public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity): array { - $userHandle = $publicKeyCredentialUserEntity->getId(); + $userHandle = $publicKeyCredentialUserEntity->id; $sources = []; foreach ($this->iterateAllWebAuthnFactors() as [$factor, $source]) { - if ($source->getUserHandle() === $userHandle) { + if ($source->userHandle === $userHandle) { $sources[] = $source; } } return $sources; } - public function saveCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource): void + /** + * Persist an updated credential (e.g. the counter bump returned by the assertion ceremony). + * In v5 the library no longer saves credentials itself, so {@see WebAuthnService} calls this + * after a successful `check()`. + */ + public function saveCredential(CredentialRecord $credentialRecord): void { - // Update path: find the existing factor for this credential and bump the counter. foreach ($this->iterateAllWebAuthnFactors() as [$factor, $source]) { - if ($source->getPublicKeyCredentialId() === $publicKeyCredentialSource->getPublicKeyCredentialId()) { - $factor->setCredentialData($publicKeyCredentialSource->jsonSerialize()); + if ($source->publicKeyCredentialId === $credentialRecord->publicKeyCredentialId) { + $factor->setSecret($this->serializerProvider->getSerializer()->serialize($credentialRecord, 'json')); $this->secondFactorRepository->update($factor); return; } } // No existing factor — initial registration is handled explicitly by - // WebAuthnService::persistNewCredential() so we ignore this branch. + // WebAuthnService::verifyAndPersistRegistration() so we ignore this branch. } /** - * @return \Generator + * @return \Generator */ private function iterateAllWebAuthnFactors(): \Generator { + $serializer = $this->serializerProvider->getSerializer(); foreach ($this->secondFactorRepository->findAllByType(SecondFactor::TYPE_PUBLIC_KEY) as $factor) { try { - $source = PublicKeyCredentialSource::createFromArray($factor->getCredentialData()); + $source = $serializer->deserialize($factor->getSecret(), CredentialRecord::class, 'json'); } catch (\Throwable $exception) { // A single corrupt/truncated credential row must not break the lookup for all // other users. Skip it and log so the broken factor can be investigated. diff --git a/Classes/Service/WebAuthnSerializerProvider.php b/Classes/Service/WebAuthnSerializerProvider.php new file mode 100644 index 0000000..fac3736 --- /dev/null +++ b/Classes/Service/WebAuthnSerializerProvider.php @@ -0,0 +1,44 @@ +serializer === null) { + $attestationStatementSupportManager = AttestationStatementSupportManager::create(); + $attestationStatementSupportManager->add(NoneAttestationStatementSupport::create()); + // FidoU2F is needed for U2F-only authenticators registered via the browser's + // U2F-compat fallback (see WebAuthnService); their attestation must round-trip too. + $attestationStatementSupportManager->add(FidoU2FAttestationStatementSupport::create()); + + $this->serializer = (new WebauthnSerializerFactory($attestationStatementSupportManager))->create(); + } + return $this->serializer; + } +} diff --git a/Classes/Service/WebAuthnService.php b/Classes/Service/WebAuthnService.php index 913a6ee..f0d960d 100644 --- a/Classes/Service/WebAuthnService.php +++ b/Classes/Service/WebAuthnService.php @@ -12,28 +12,27 @@ use Psr\Http\Message\ServerRequestInterface; use Sandstorm\NeosTwoFactorAuthentication\Domain\Model\SecondFactor; use Sandstorm\NeosTwoFactorAuthentication\Domain\Repository\SecondFactorRepository; -use Webauthn\AttestationStatement\AttestationObjectLoader; use Webauthn\AttestationStatement\AttestationStatementSupportManager; use Webauthn\AttestationStatement\FidoU2FAttestationStatementSupport; use Webauthn\AttestationStatement\NoneAttestationStatementSupport; -use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler; use Webauthn\AuthenticatorAssertionResponse; use Webauthn\AuthenticatorAssertionResponseValidator; use Webauthn\AuthenticatorAttestationResponse; use Webauthn\AuthenticatorAttestationResponseValidator; use Webauthn\AuthenticatorSelectionCriteria; +use Webauthn\CeremonyStep\CeremonyStepManagerFactory; +use Webauthn\CredentialRecord; +use Webauthn\PublicKeyCredential; use Webauthn\PublicKeyCredentialCreationOptions; use Webauthn\PublicKeyCredentialDescriptor; -use Webauthn\PublicKeyCredentialLoader; use Webauthn\PublicKeyCredentialParameters; use Webauthn\PublicKeyCredentialRequestOptions; use Webauthn\PublicKeyCredentialRpEntity; -use Webauthn\PublicKeyCredentialSource; use Webauthn\PublicKeyCredentialUserEntity; /** * Implements the WebAuthn registration and authentication ceremonies on top of - * the web-auth/webauthn-lib library. + * the web-auth/webauthn-lib library (v5). * * @Flow\Scope("singleton") */ @@ -76,10 +75,16 @@ class WebAuthnService protected $passwordlessLoginEnabled = false; /** - * `lazy=false` so the real adapter (not a DependencyProxy) is passed into the - * web-auth validator constructors, which strict-type-hint the interface. + * @Flow\Inject + * @var WebAuthnSerializerProvider + */ + protected $serializerProvider; + + /** + * Our own credential repository. In v5 the ceremony validators no longer call back into a + * repository, so we look credentials up and persist counter bumps through this ourselves. * - * @Flow\Inject(lazy=false) + * @Flow\Inject * @var PublicKeyCredentialSourceRepositoryAdapter */ protected $credentialSourceRepository; @@ -117,46 +122,49 @@ public function createRegistrationOptions(Account $account, string $hostname, bo // Exclude already-registered credentials so the browser refuses to register the same key twice. $excludeCredentials = array_map( - fn(PublicKeyCredentialSource $src): PublicKeyCredentialDescriptor => $src->getPublicKeyCredentialDescriptor(), + fn(CredentialRecord $src): PublicKeyCredentialDescriptor => $src->getPublicKeyCredentialDescriptor(), $this->credentialSourceRepository->findAllForUserEntity($userEntity) ); $challenge = random_bytes(32); $publicKeyCredentialParametersList = [ - new PublicKeyCredentialParameters('public-key', ECDSA\ES256::ID), - new PublicKeyCredentialParameters('public-key', ECDSA\ES384::ID), - new PublicKeyCredentialParameters('public-key', ECDSA\ES512::ID), - new PublicKeyCredentialParameters('public-key', RSA\RS256::ID), - new PublicKeyCredentialParameters('public-key', EdDSA\Ed25519::ID), + PublicKeyCredentialParameters::create('public-key', ECDSA\ES256::ID), + PublicKeyCredentialParameters::create('public-key', ECDSA\ES384::ID), + PublicKeyCredentialParameters::create('public-key', ECDSA\ES512::ID), + PublicKeyCredentialParameters::create('public-key', RSA\RS256::ID), + PublicKeyCredentialParameters::create('public-key', EdDSA\Ed25519::ID), ]; - $authenticatorSelection = AuthenticatorSelectionCriteria::create() - ->setUserVerification($this->userVerification); - // Register a discoverable (resident), user-verified passkey only when the user opted into // it AND passwordless login is enabled — such a credential works both for one-tap // usernameless login AND as a strong second factor. The guard means a discoverable // credential can never be minted while passwordless login is off. Any other registration // keeps the configured user-verification level and no resident-key requirement, so // touch-only / U2F-only keys keep working as a plain 2nd factor. - $registerAsPasskey = $discoverable && $this->passwordlessLoginEnabled; - if ($registerAsPasskey) { - $authenticatorSelection - ->setResidentKey(AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_REQUIRED) - ->setUserVerification(AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_REQUIRED); + $residentKey = AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_NO_PREFERENCE; + $userVerification = $this->userVerification; + if ($discoverable && $this->passwordlessLoginEnabled) { + $residentKey = AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_REQUIRED; + $userVerification = AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_REQUIRED; } + // v5 options objects are immutable — configured via named constructor arguments, not setters. + $authenticatorSelection = AuthenticatorSelectionCriteria::create( + userVerification: $userVerification, + residentKey: $residentKey, + ); + return PublicKeyCredentialCreationOptions::create( $rp, $userEntity, $challenge, - $publicKeyCredentialParametersList - ) - ->setTimeout($this->timeoutMs) - ->setAuthenticatorSelection($authenticatorSelection) - ->setAttestation(PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE) - ->excludeCredentials(...$excludeCredentials); + pubKeyCredParams: $publicKeyCredentialParametersList, + authenticatorSelection: $authenticatorSelection, + attestation: PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE, + excludeCredentials: $excludeCredentials, + timeout: $this->timeoutMs, + ); } /** @@ -183,22 +191,24 @@ public function verifyAndPersistRegistration( ServerRequestInterface $request, string $name = '' ): SecondFactor { - $publicKeyCredentialLoader = $this->buildCredentialLoader(); - $publicKeyCredential = $publicKeyCredentialLoader->load($attestationResponseJson); - $authenticatorResponse = $publicKeyCredential->getResponse(); + $publicKeyCredential = $this->serializerProvider->getSerializer() + ->deserialize($attestationResponseJson, PublicKeyCredential::class, 'json'); + $authenticatorResponse = $publicKeyCredential->response; if (!$authenticatorResponse instanceof AuthenticatorAttestationResponse) { throw new \RuntimeException('Response is not an AuthenticatorAttestationResponse', 1747750000); } - $validator = $this->buildAttestationValidator(); - $credentialSource = $validator->check($authenticatorResponse, $options, $request, $this->securedRelyingPartyIds); + $validator = AuthenticatorAttestationResponseValidator::create( + $this->buildCeremonyStepManagerFactory()->creationCeremony() + ); + $credentialRecord = $validator->check($authenticatorResponse, $options, $request->getUri()->getHost()); // Whether this credential is a discoverable "Passkey" is derived from the options it was // registered with (see isDiscoverableRegistration / createRegistrationOptions): a passkey // registration requested a resident key, a 2nd-factor registration did not. This keeps the // stored flag faithful to the per-registration choice rather than the global setting. return $this->secondFactorRepository->createSecondFactorForAccount( - json_encode($credentialSource->jsonSerialize(), JSON_THROW_ON_ERROR), + $this->serializerProvider->getSerializer()->serialize($credentialRecord, 'json'), $account, SecondFactor::TYPE_PUBLIC_KEY, $name, @@ -214,20 +224,22 @@ public function createAuthenticationOptions(Account $account): PublicKeyCredenti { $userEntity = $this->buildUserEntity($account); $allowedCredentials = array_map( - fn(PublicKeyCredentialSource $src): PublicKeyCredentialDescriptor => $src->getPublicKeyCredentialDescriptor(), + fn(CredentialRecord $src): PublicKeyCredentialDescriptor => $src->getPublicKeyCredentialDescriptor(), $this->credentialSourceRepository->findAllForUserEntity($userEntity) ); - return PublicKeyCredentialRequestOptions::create(random_bytes(32)) - ->setTimeout($this->timeoutMs) - ->setRpId($this->relyingPartyId) - ->setUserVerification($this->userVerification) - ->allowCredentials(...$allowedCredentials); + return PublicKeyCredentialRequestOptions::create( + random_bytes(32), + rpId: $this->relyingPartyId, + allowCredentials: $allowedCredentials, + userVerification: $this->userVerification, + timeout: $this->timeoutMs, + ); } /** * Verify the assertion response returned by the browser. On success returns - * the updated credential source (counter bumped) and the matching SecondFactor. + * the updated credential (counter bumped) which is also persisted. * * @throws \Throwable when validation fails */ @@ -236,25 +248,37 @@ public function verifyAuthenticationResponse( PublicKeyCredentialRequestOptions $options, Account $account, ServerRequestInterface $request - ): PublicKeyCredentialSource { - $publicKeyCredentialLoader = $this->buildCredentialLoader(); - $publicKeyCredential = $publicKeyCredentialLoader->load($assertionResponseJson); - $authenticatorResponse = $publicKeyCredential->getResponse(); + ): CredentialRecord { + $publicKeyCredential = $this->serializerProvider->getSerializer() + ->deserialize($assertionResponseJson, PublicKeyCredential::class, 'json'); + $authenticatorResponse = $publicKeyCredential->response; if (!$authenticatorResponse instanceof AuthenticatorAssertionResponse) { throw new \RuntimeException('Response is not an AuthenticatorAssertionResponse', 1747750001); } + // In v5 check() takes the stored credential itself, so we look it up first. + $credentialSource = $this->credentialSourceRepository->findOneByCredentialId($publicKeyCredential->rawId); + if ($credentialSource === null) { + throw new \RuntimeException('Unknown credential', 1747750002); + } + $userHandle = $this->buildUserHandle($account); - $validator = $this->buildAssertionValidator(); + $validator = AuthenticatorAssertionResponseValidator::create( + $this->buildCeremonyStepManagerFactory()->requestCeremony() + ); - return $validator->check( - $publicKeyCredential->getRawId(), + $updatedCredential = $validator->check( + $credentialSource, $authenticatorResponse, $options, - $request, - $userHandle, - $this->securedRelyingPartyIds + $request->getUri()->getHost(), + $userHandle ); + + // v5 no longer persists the credential itself; save the bumped counter ourselves. + $this->credentialSourceRepository->saveCredential($updatedCredential); + + return $updatedCredential; } /** @@ -265,10 +289,12 @@ public function verifyAuthenticationResponse( */ public function createPasswordlessAuthenticationOptions(string $hostname): PublicKeyCredentialRequestOptions { - return PublicKeyCredentialRequestOptions::create(random_bytes(32)) - ->setTimeout($this->timeoutMs) - ->setRpId($this->relyingPartyId ?: $hostname) - ->setUserVerification(PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_REQUIRED); + return PublicKeyCredentialRequestOptions::create( + random_bytes(32), + rpId: $this->relyingPartyId ?: $hostname, + userVerification: PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_REQUIRED, + timeout: $this->timeoutMs, + ); } /** @@ -287,30 +313,34 @@ public function verifyPasswordlessAssertion( PublicKeyCredentialRequestOptions $options, ServerRequestInterface $request ): Account { - $publicKeyCredentialLoader = $this->buildCredentialLoader(); - $publicKeyCredential = $publicKeyCredentialLoader->load($assertionResponseJson); - $authenticatorResponse = $publicKeyCredential->getResponse(); + $publicKeyCredential = $this->serializerProvider->getSerializer() + ->deserialize($assertionResponseJson, PublicKeyCredential::class, 'json'); + $authenticatorResponse = $publicKeyCredential->response; if (!$authenticatorResponse instanceof AuthenticatorAssertionResponse) { throw new \RuntimeException('Response is not an AuthenticatorAssertionResponse', 1751200000); } - $rawId = $publicKeyCredential->getRawId(); + $rawId = $publicKeyCredential->rawId; $credentialSource = $this->credentialSourceRepository->findOneByCredentialId($rawId); if ($credentialSource === null) { throw new \RuntimeException('Unknown passkey credential', 1751200001); } - $userHandle = $credentialSource->getUserHandle(); - $validator = $this->buildAssertionValidator(); - $validator->check( - $rawId, + $userHandle = $credentialSource->userHandle; + $validator = AuthenticatorAssertionResponseValidator::create( + $this->buildCeremonyStepManagerFactory()->requestCeremony() + ); + $updatedCredential = $validator->check( + $credentialSource, $authenticatorResponse, $options, - $request, - $userHandle, - $this->securedRelyingPartyIds + $request->getUri()->getHost(), + $userHandle ); + // Persist the bumped counter so replay protection stays effective across logins. + $this->credentialSourceRepository->saveCredential($updatedCredential); + return $this->resolveBackendAccountByUserHandle($userHandle); } @@ -335,9 +365,31 @@ public function resolveBackendAccountByUserHandle(string $userHandle): Account return $account; } + /** + * Serialize registration/authentication options for transport to the browser and for storage + * in the session across the options -> verify round trip. Centralised here so the controllers + * do not touch webauthn-lib serialization directly. + */ + public function optionsToJson(PublicKeyCredentialCreationOptions|PublicKeyCredentialRequestOptions $options): string + { + return $this->serializerProvider->getSerializer()->serialize($options, 'json'); + } + + public function creationOptionsFromJson(string $json): PublicKeyCredentialCreationOptions + { + return $this->serializerProvider->getSerializer() + ->deserialize($json, PublicKeyCredentialCreationOptions::class, 'json'); + } + + public function requestOptionsFromJson(string $json): PublicKeyCredentialRequestOptions + { + return $this->serializerProvider->getSerializer() + ->deserialize($json, PublicKeyCredentialRequestOptions::class, 'json'); + } + private function buildUserEntity(Account $account): PublicKeyCredentialUserEntity { - return new PublicKeyCredentialUserEntity( + return PublicKeyCredentialUserEntity::create( $account->getAccountIdentifier(), $this->buildUserHandle($account), $account->getAccountIdentifier() @@ -355,36 +407,25 @@ private function buildUserHandle(Account $account): string return $id; } - private function buildCredentialLoader(): PublicKeyCredentialLoader - { - $attestationManager = $this->buildAttestationStatementSupportManager(); - $attestationObjectLoader = new AttestationObjectLoader($attestationManager); - return new PublicKeyCredentialLoader($attestationObjectLoader); - } - - private function buildAttestationValidator(): AuthenticatorAttestationResponseValidator - { - return new AuthenticatorAttestationResponseValidator( - $this->buildAttestationStatementSupportManager(), - $this->credentialSourceRepository, - null, - new ExtensionOutputCheckerHandler() - ); - } - - private function buildAttestationStatementSupportManager(): AttestationStatementSupportManager + /** + * Build the ceremony-step configuration shared by the attestation and assertion validators. + * + * In v4 the algorithm manager, attestation support, extension handler and secured relying party + * ids were passed piecemeal to the validator constructors (and the origin/host came from the + * PSR request). v5 collects all of this into a CeremonyStepManager. We deliberately leave + * allowed-origins unset and configure `securedRelyingPartyId` instead, so origin validation + * stays host-based (dynamic multi-domain) exactly as before; the request host is passed to + * check() per call. + */ + private function buildCeremonyStepManagerFactory(): CeremonyStepManagerFactory { - $manager = new AttestationStatementSupportManager(); - $manager->add(new NoneAttestationStatementSupport()); + $attestationStatementSupportManager = AttestationStatementSupportManager::create(); + $attestationStatementSupportManager->add(NoneAttestationStatementSupport::create()); // FidoU2F is needed for U2F-only authenticators (e.g. YubiKey 4) registered via // the browser's U2F-compat fallback — they return `fido-u2f` attestation regardless // of the requested `attestation: none` conveyance preference. - $manager->add(new FidoU2FAttestationStatementSupport()); - return $manager; - } + $attestationStatementSupportManager->add(FidoU2FAttestationStatementSupport::create()); - private function buildAssertionValidator(): AuthenticatorAssertionResponseValidator - { $algorithmManager = CoseAlgorithmManager::create() ->add(new ECDSA\ES256()) ->add(new ECDSA\ES384()) @@ -392,11 +433,11 @@ private function buildAssertionValidator(): AuthenticatorAssertionResponseValida ->add(new RSA\RS256()) ->add(new EdDSA\Ed25519()); - return new AuthenticatorAssertionResponseValidator( - $this->credentialSourceRepository, - null, - new ExtensionOutputCheckerHandler(), - $algorithmManager - ); + $factory = new CeremonyStepManagerFactory(); + $factory->setAttestationStatementSupportManager($attestationStatementSupportManager); + $factory->setAlgorithmManager($algorithmManager); + $factory->setSecuredRelyingPartyId($this->securedRelyingPartyIds); + + return $factory; } } diff --git a/Tests/E2E/package-lock.json b/Tests/E2E/package-lock.json index 35deede..2fa0207 100644 --- a/Tests/E2E/package-lock.json +++ b/Tests/E2E/package-lock.json @@ -7,11 +7,11 @@ "name": "sandstorm-neostwofactorauthentication-e2e", "dependencies": { "@playwright/test": "^1.58.2", - "otplib": "^13.4.0", - "playwright-bdd": "^8.0.0" + "otplib": "^13.4.0" }, "devDependencies": { "@types/node": "^25.5.0", + "playwright-bdd": "^9.2.0", "prettier": "^3.8.4", "typescript": "^6.0.2" } @@ -20,40 +20,51 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, "license": "MIT", "optional": true, "engines": { "node": ">=0.1.90" } }, + "node_modules/@cucumber/ci-environment": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/ci-environment/-/ci-environment-13.0.0.tgz", + "integrity": "sha512-cs+3NzfNkGbcmHPddjEv4TKFiBpZRQ6WJEEufB9mw+ExS22V/4R/zpDSEG+fsJ/iSNCd6A2sATdY8PFOyY3YnA==", + "dev": true, + "license": "MIT" + }, "node_modules/@cucumber/cucumber-expressions": { - "version": "18.0.1", - "resolved": "https://registry.npmjs.org/@cucumber/cucumber-expressions/-/cucumber-expressions-18.0.1.tgz", - "integrity": "sha512-NSid6bI+7UlgMywl5octojY5NXnxR9uq+JisjOrO52VbFsQM6gTWuQFE8syI10KnIBEdPzuEUSVEeZ0VFzRnZA==", + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/cucumber-expressions/-/cucumber-expressions-19.0.0.tgz", + "integrity": "sha512-4FKoOQh2Uf6F6/Ln+1OxuK8LkTg6PyAqekhf2Ix8zqV2M54sH+m7XNJNLhOFOAW/t9nxzRbw2CcvXbCLjcvHZg==", + "dev": true, "license": "MIT", "dependencies": { "regexp-match-indices": "1.0.2" } }, "node_modules/@cucumber/gherkin": { - "version": "32.2.0", - "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-32.2.0.tgz", - "integrity": "sha512-X8xuVhSIqlUjxSRifRJ7t0TycVWyX58fygJH3wDNmHINLg9sYEkvQT0SO2G5YlRZnYc11TIFr4YPenscvdlBIw==", + "version": "39.1.0", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-39.1.0.tgz", + "integrity": "sha512-pqmSO2bUWxJm3TbNrKXlDaHjL6c77+ez9kWmfCd9oRPeTRPEVH3spZvpAqdXYWOZYSNYwWFCAAeZ4RGpkauNoQ==", + "dev": true, "license": "MIT", "dependencies": { - "@cucumber/messages": ">=19.1.4 <28" + "@cucumber/messages": ">=31.0.0 <33" } }, "node_modules/@cucumber/gherkin-utils": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/@cucumber/gherkin-utils/-/gherkin-utils-9.2.0.tgz", - "integrity": "sha512-3nmRbG1bUAZP3fAaUBNmqWO0z0OSkykZZotfLjyhc8KWwDSOrOmMJlBTd474lpA8EWh4JFLAX3iXgynBqBvKzw==", + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin-utils/-/gherkin-utils-11.0.0.tgz", + "integrity": "sha512-LJ+s4+TepHTgdKWDR4zbPyT7rQjmYIcukTwNbwNwgqr6i8Gjcmzf6NmtbYDA19m1ZFg6kWbFsmHnj37ZuX+kZA==", + "dev": true, "license": "MIT", "dependencies": { - "@cucumber/gherkin": "^31.0.0", - "@cucumber/messages": "^27.0.0", + "@cucumber/gherkin": "^38.0.0", + "@cucumber/messages": "^32.0.0", "@teppeis/multimaps": "3.0.0", - "commander": "13.1.0", + "commander": "14.0.2", "source-map-support": "^0.5.21" }, "bin": { @@ -61,55 +72,43 @@ } }, "node_modules/@cucumber/gherkin-utils/node_modules/@cucumber/gherkin": { - "version": "31.0.0", - "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-31.0.0.tgz", - "integrity": "sha512-wlZfdPif7JpBWJdqvHk1Mkr21L5vl4EfxVUOS4JinWGf3FLRV6IKUekBv5bb5VX79fkDcfDvESzcQ8WQc07Wgw==", - "license": "MIT", - "dependencies": { - "@cucumber/messages": ">=19.1.4 <=26" - } - }, - "node_modules/@cucumber/gherkin-utils/node_modules/@cucumber/gherkin/node_modules/@cucumber/messages": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-26.0.1.tgz", - "integrity": "sha512-DIxSg+ZGariumO+Lq6bn4kOUIUET83A4umrnWmidjGFl8XxkBieUZtsmNbLYgH/gnsmP07EfxxdTr0hOchV1Sg==", + "version": "38.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-38.0.0.tgz", + "integrity": "sha512-duEXK+KDfQUzu3vsSzXjkxQ2tirF5PRsc1Xrts6THKHJO6mjw4RjM8RV+vliuDasmhhrmdLcOcM7d9nurNTJKw==", + "dev": true, "license": "MIT", "dependencies": { - "@types/uuid": "10.0.0", - "class-transformer": "0.5.1", - "reflect-metadata": "0.2.2", - "uuid": "10.0.0" + "@cucumber/messages": ">=31.0.0 <33" } }, - "node_modules/@cucumber/gherkin-utils/node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], + "node_modules/@cucumber/gherkin-utils/node_modules/commander": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", + "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "dev": true, "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" + "engines": { + "node": ">=20" } }, "node_modules/@cucumber/html-formatter": { - "version": "21.15.1", - "resolved": "https://registry.npmjs.org/@cucumber/html-formatter/-/html-formatter-21.15.1.tgz", - "integrity": "sha512-tjxEpP161sQ7xc3VREc94v1ymwIckR3ySViy7lTvfi1jUpyqy2Hd/p4oE3YT1kQ9fFDvUflPwu5ugK5mA7BQLA==", + "version": "23.1.0", + "resolved": "https://registry.npmjs.org/@cucumber/html-formatter/-/html-formatter-23.1.0.tgz", + "integrity": "sha512-DcCSFoGs6jbwzXPgX1CwgJKEE+ZMcIEzq/0Memg0o24maNn9NJizBFHmoFWG4iv/OxHza+mvc+56cTHetfHndw==", + "dev": true, "license": "MIT", "peerDependencies": { "@cucumber/messages": ">=18" } }, "node_modules/@cucumber/junit-xml-formatter": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@cucumber/junit-xml-formatter/-/junit-xml-formatter-0.7.1.tgz", - "integrity": "sha512-AzhX+xFE/3zfoYeqkT7DNq68wAQfBcx4Dk9qS/ocXM2v5tBv6eFQ+w8zaSfsktCjYzu4oYRH/jh4USD1CYHfaQ==", + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/@cucumber/junit-xml-formatter/-/junit-xml-formatter-0.13.3.tgz", + "integrity": "sha512-w9ujOxiuKDtU6fLzJz+wp4Sgp5Xu6ba7ls00LHJccVmQU0Ba7zs+AHnv3iIgPjKZAQe1w8x93dr8Gaubh7Vqkg==", + "dev": true, "license": "MIT", "dependencies": { - "@cucumber/query": "^13.0.2", + "@cucumber/query": "^15.0.1", "@teppeis/multimaps": "^3.0.0", "luxon": "^3.5.0", "xmlbuilder": "^15.1.1" @@ -119,35 +118,21 @@ } }, "node_modules/@cucumber/messages": { - "version": "27.2.0", - "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-27.2.0.tgz", - "integrity": "sha512-f2o/HqKHgsqzFLdq6fAhfG1FNOQPdBdyMGpKwhb7hZqg0yZtx9BVqkTyuoNk83Fcvk3wjMVfouFXXHNEk4nddA==", + "version": "32.3.1", + "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-32.3.1.tgz", + "integrity": "sha512-yNQq1KoXRYaEKrWMFmpUQX7TdeQuU9jeGgJAZ3dArTsC/T4NpJ6DnqaJIIgwPnz/wtQIQTNX7/h0rOuF5xY4qQ==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@types/uuid": "10.0.0", "class-transformer": "0.5.1", - "reflect-metadata": "0.2.2", - "uuid": "11.0.5" - } - }, - "node_modules/@cucumber/messages/node_modules/uuid": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz", - "integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" + "reflect-metadata": "0.2.2" } }, "node_modules/@cucumber/query": { - "version": "13.6.0", - "resolved": "https://registry.npmjs.org/@cucumber/query/-/query-13.6.0.tgz", - "integrity": "sha512-tiDneuD5MoWsJ9VKPBmQok31mSX9Ybl+U4wqDoXeZgsXHDURqzM3rnpWVV3bC34y9W6vuFxrlwF/m7HdOxwqRw==", + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/@cucumber/query/-/query-15.0.1.tgz", + "integrity": "sha512-FMfT3orJblRsOxvU2doECBvQmauizYlj+5JsM8atAKKPbnQTj7v2/OrnuykvQpfZNBf19DYbRq1e832vllRP/g==", + "dev": true, "license": "MIT", "dependencies": { "@teppeis/multimaps": "3.0.0", @@ -158,9 +143,10 @@ } }, "node_modules/@cucumber/tag-expressions": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/@cucumber/tag-expressions/-/tag-expressions-6.2.0.tgz", - "integrity": "sha512-KIF0eLcafHbWOuSDWFw0lMmgJOLdDRWjEL1kfXEWrqHmx2119HxVAr35WuEd9z542d3Yyg+XNqSr+81rIKqEdg==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@cucumber/tag-expressions/-/tag-expressions-9.1.0.tgz", + "integrity": "sha512-bvHjcRFZ+J1TqIa9eFNO1wGHqwx4V9ZKV3hYgkuK/VahHx73uiP4rKV3JVrvWSMrwrFvJG6C8aEwnCWSvbyFdQ==", + "dev": true, "license": "MIT" }, "node_modules/@noble/hashes": { @@ -175,41 +161,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@otplib/core": { "version": "13.4.0", "resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.4.0.tgz", @@ -271,7 +222,6 @@ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright": "1.58.2" }, @@ -295,6 +245,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/@teppeis/multimaps/-/multimaps-3.0.0.tgz", "integrity": "sha512-ID7fosbc50TbT0MK0EG12O+gAP3W3Aa/Pz4DaTtQtEvlc9Odaqi0de+xuZ7Li2GtK4HzEX7IuRWS/JmZLksR3Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -310,49 +261,35 @@ "undici-types": "~7.18.0" } }, - "node_modules/@types/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", - "license": "MIT" - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, "license": "MIT" }, "node_modules/class-transformer": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "dev": true, "license": "MIT" }, "node_modules/cli-table3": { "version": "0.6.5", "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "dev": true, "license": "MIT", "dependencies": { "string-width": "^4.2.0" @@ -368,6 +305,7 @@ "version": "13.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -377,43 +315,25 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" + "node": ">=12.0.0" }, - "engines": { - "node": ">=8" + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, "node_modules/fsevents": { @@ -430,98 +350,38 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "dev": true, "license": "MIT" }, "node_modules/luxon": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -531,6 +391,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -558,12 +419,13 @@ } }, "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -588,29 +450,31 @@ } }, "node_modules/playwright-bdd": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/playwright-bdd/-/playwright-bdd-8.5.0.tgz", - "integrity": "sha512-w/Bd5C1d6Xe5e1oREsbt2rDN0/Mcp+J2OjQwSl49/mroa2K6UZU33P7v91pLQPl1otDitMwJOaOeJsqaj7WU7w==", + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/playwright-bdd/-/playwright-bdd-9.2.0.tgz", + "integrity": "sha512-1tBTmo4DpOhLsc+A6PB4isWO3DHKb4BQ3Tzw5+ze/PmgBW9W2m9c+nc0TPy2nByWJtw0gKSfMoexyBR+y82+pg==", + "dev": true, "license": "MIT", "dependencies": { - "@cucumber/cucumber-expressions": "18.0.1", - "@cucumber/gherkin": "^32.1.2", - "@cucumber/gherkin-utils": "^9.2.0", - "@cucumber/html-formatter": "^21.11.0", - "@cucumber/junit-xml-formatter": "^0.7.1", - "@cucumber/messages": "^27.2.0", - "@cucumber/tag-expressions": "^6.2.0", + "@cucumber/ci-environment": "^13.0.0", + "@cucumber/cucumber-expressions": "19.0.0", + "@cucumber/gherkin": "^39.1.0", + "@cucumber/gherkin-utils": "^11.0.0", + "@cucumber/html-formatter": "^23.1.0", + "@cucumber/junit-xml-formatter": "^0.13.3", + "@cucumber/messages": "^32.3.1", + "@cucumber/query": "^15.0.1", + "@cucumber/tag-expressions": "^9.1.0", "cli-table3": "0.6.5", "commander": "^13.1.0", - "fast-glob": "^3.3.3", "mime-types": "^3.0.2", - "xmlbuilder": "15.1.1" + "tinyglobby": "0.2.17" }, "bin": { "bddgen": "dist/cli/index.js" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/vitalets" @@ -647,36 +511,18 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true, "license": "Apache-2.0" }, "node_modules/regexp-match-indices": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/regexp-match-indices/-/regexp-match-indices-1.0.2.tgz", "integrity": "sha512-DwZuAkt8NF5mKwGGER1EGh2PRqyvhRhhLviH+R8y8dIuaQROlUfXjt4s9ZTXstIsSkptf06BSvwcEmmfheJJWQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "regexp-tree": "^0.1.11" @@ -686,48 +532,17 @@ "version": "0.1.27", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "dev": true, "license": "MIT", "bin": { "regexp-tree": "bin/regexp-tree" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -737,6 +552,7 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", @@ -747,6 +563,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -761,6 +578,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -769,16 +587,21 @@ "node": ">=8" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, "license": "MIT", "dependencies": { - "is-number": "^7.0.0" + "fdir": "^6.5.0", + "picomatch": "^4.0.4" }, "engines": { - "node": ">=8.0" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, "node_modules/typescript": { @@ -806,6 +629,7 @@ "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.0" diff --git a/Tests/E2E/package.json b/Tests/E2E/package.json index a7ae3e7..888404c 100644 --- a/Tests/E2E/package.json +++ b/Tests/E2E/package.json @@ -19,12 +19,12 @@ }, "devDependencies": { "@types/node": "^25.5.0", + "playwright-bdd": "^9.2.0", "prettier": "^3.8.4", "typescript": "^6.0.2" }, "dependencies": { "@playwright/test": "^1.58.2", - "otplib": "^13.4.0", - "playwright-bdd": "^8.0.0" + "otplib": "^13.4.0" } } diff --git a/Tests/Unit/Service/CredentialSerializationTest.php b/Tests/Unit/Service/CredentialSerializationTest.php new file mode 100644 index 0000000..b3f6183 --- /dev/null +++ b/Tests/Unit/Service/CredentialSerializationTest.php @@ -0,0 +1,92 @@ + v5 upgrade. + * + * Existing WebAuthn second factors were serialized to the `secret` column by v4's + * PublicKeyCredentialSource::jsonSerialize(). Under v5 they are read back through the Symfony + * serializer (a different code path). These tests feed real, captured v4-serialized credential + * sources through the v5 serializer and assert that every field round-trips, so no already + * registered key is silently locked out after the upgrade. + * + * The fixtures are real captures (with the account-identifying `userHandle` replaced by a dummy + * UUID) from docs/20260709_publickey_fixtures_neostwofactorauth2-csv.csv. + */ +class CredentialSerializationTest extends UnitTestCase +{ + private const DUMMY_USER_HANDLE = '40b985a5-da1f-45b0-8864-321bdd63a918'; + + /** + * @return array + */ + public static function v4CredentialSourceProvider(): array + { + return [ + // Real YubiKey registered as a plain 2nd factor: none attestation, empty trust path, + // zero aaguid, no uvInitialized field, and a non-zero counter from repeated use. + 'used security key (2nd factor, counter 227)' => [ + 'secret' => '{"publicKeyCredentialId":"UfGOMXF0z46jrELBGylyN9aXUgs2OgkvY8WsLKefnvufoyb7fjw_2DpS81SiK8FT-F6X_y_9xC8WeyKOGrSnMw","type":"public-key","transports":[],"attestationType":"none","trustPath":{"type":"Webauthn\\\\TrustPath\\\\EmptyTrustPath"},"aaguid":"00000000-0000-0000-0000-000000000000","credentialPublicKey":"pQECAyYgASFYIE6HyqPfnnEnSfmdyNugRBUSyA1J30UFz5IaxLE6z7zHIlggYO5AtmknrOWx6bCwnjTQERc6NJm09LjrIbQrX-z0PBg","userHandle":"NDBiOTg1YTUtZGExZi00NWIwLTg4NjQtMzIxYmRkNjNhOTE4","counter":227,"backupEligible":false,"backupStatus":false}', + 'credentialId' => 'UfGOMXF0z46jrELBGylyN9aXUgs2OgkvY8WsLKefnvufoyb7fjw_2DpS81SiK8FT-F6X_y_9xC8WeyKOGrSnMw', + 'counter' => 227, + ], + // Fresh 2nd-factor registration: counter 0 and an explicit uvInitialized:false. + 'fresh security key (2nd factor, counter 0)' => [ + 'secret' => '{"publicKeyCredentialId":"s_DI7_9m4sC5E1sVGCTC0fibI_qdPNTW1DeHA_uT_WQ6ywKuZ5H4rLiILvOEhQjoMLu_H9PIfLaDvnYycEdIhg","type":"public-key","transports":[],"attestationType":"none","trustPath":{"type":"Webauthn\\\\TrustPath\\\\EmptyTrustPath"},"aaguid":"00000000-0000-0000-0000-000000000000","credentialPublicKey":"pQECAyYgASFYIEEcAEv5PVrOo83R2FxnEloPMp8VUQ_CP4WUMALr8T32IlggitTdQTvOegyer6z3tf35_sgGvNcmzFePoM7a-xj2kWo","userHandle":"NDBiOTg1YTUtZGExZi00NWIwLTg4NjQtMzIxYmRkNjNhOTE4","counter":0,"backupEligible":false,"backupStatus":false,"uvInitialized":false}', + 'credentialId' => 's_DI7_9m4sC5E1sVGCTC0fibI_qdPNTW1DeHA_uT_WQ6ywKuZ5H4rLiILvOEhQjoMLu_H9PIfLaDvnYycEdIhg', + 'counter' => 0, + ], + // Resident, user-verified passkey (discoverable) from a platform authenticator: + // real aaguid and uvInitialized:true — the passwordless-login shape. + 'resident passkey (discoverable, uvInitialized)' => [ + 'secret' => '{"publicKeyCredentialId":"oI0Z1UcrXWmByWp-5ZQCrr1ETW81YDU6ptr26TqIAYU","type":"public-key","transports":[],"attestationType":"none","trustPath":{"type":"Webauthn\\\\TrustPath\\\\EmptyTrustPath"},"aaguid":"adce0002-35bc-c60a-648b-0b25f1f05503","credentialPublicKey":"pQECAyYgASFYIJeEuCpbvN5moHx9FI5r5msfOxxS54iXIerHSK4m073yIlggRhXJRyGLKYKya6Ba-aG-JvtFYrKKkVT5zekHf3YxlZQ","userHandle":"NDBiOTg1YTUtZGExZi00NWIwLTg4NjQtMzIxYmRkNjNhOTE4","counter":0,"backupEligible":false,"backupStatus":false,"uvInitialized":true}', + 'credentialId' => 'oI0Z1UcrXWmByWp-5ZQCrr1ETW81YDU6ptr26TqIAYU', + 'counter' => 0, + ], + ]; + } + + /** + * @test + * @dataProvider v4CredentialSourceProvider + */ + public function readsV4SerializedCredentialSourceUnderV5(string $secret, string $credentialId, int $counter): void + { + $serializer = (new WebAuthnSerializerProvider())->getSerializer(); + + $source = $serializer->deserialize($secret, CredentialRecord::class, 'json'); + + self::assertInstanceOf(CredentialRecord::class, $source); + self::assertSame($credentialId, Base64UrlSafe::encodeUnpadded($source->publicKeyCredentialId)); + self::assertSame(self::DUMMY_USER_HANDLE, $source->userHandle); + self::assertSame($counter, $source->counter); + } + + /** + * The write path (used when persisting the counter bump after an assertion, and when storing a + * freshly registered credential) must produce JSON that the same serializer can read back + * unchanged, so a saved credential keeps working on the next login. + * + * @test + * @dataProvider v4CredentialSourceProvider + */ + public function serializedCredentialRecordRoundTrips(string $secret, string $credentialId, int $counter): void + { + $serializer = (new WebAuthnSerializerProvider())->getSerializer(); + + $source = $serializer->deserialize($secret, CredentialRecord::class, 'json'); + $reSerialized = $serializer->serialize($source, 'json'); + $roundTripped = $serializer->deserialize($reSerialized, CredentialRecord::class, 'json'); + + self::assertSame($credentialId, Base64UrlSafe::encodeUnpadded($roundTripped->publicKeyCredentialId)); + self::assertSame(self::DUMMY_USER_HANDLE, $roundTripped->userHandle); + self::assertSame($counter, $roundTripped->counter); + self::assertSame($source->credentialPublicKey, $roundTripped->credentialPublicKey); + } +} diff --git a/Tests/Unit/Service/PublicKeyCredentialSourceRepositoryAdapterTest.php b/Tests/Unit/Service/PublicKeyCredentialSourceRepositoryAdapterTest.php new file mode 100644 index 0000000..4ae5ba4 --- /dev/null +++ b/Tests/Unit/Service/PublicKeyCredentialSourceRepositoryAdapterTest.php @@ -0,0 +1,101 @@ +adapter = new PublicKeyCredentialSourceRepositoryAdapter(); + $this->secondFactorRepository = $this->createMock(SecondFactorRepository::class); + $this->inject($this->adapter, 'serializerProvider', new WebAuthnSerializerProvider()); + $this->inject($this->adapter, 'secondFactorRepository', $this->secondFactorRepository); + $this->inject($this->adapter, 'securityLogger', $this->createMock(LoggerInterface::class)); + $this->inject($this->adapter, 'persistenceManager', $this->createMock(PersistenceManagerInterface::class)); + } + + private function factorWithSecret(string $secret): SecondFactor + { + $factor = new SecondFactor(); + $factor->setSecret($secret); + $factor->setType(SecondFactor::TYPE_PUBLIC_KEY); + return $factor; + } + + /** + * @test + */ + public function findOneByCredentialIdReturnsTheStoredCredential(): void + { + $this->secondFactorRepository->method('findAllByType') + ->with(SecondFactor::TYPE_PUBLIC_KEY) + ->willReturn([$this->factorWithSecret(self::YUBIKEY_SECRET)]); + + $rawId = Base64UrlSafe::decode(self::YUBIKEY_CRED_ID); + $record = $this->adapter->findOneByCredentialId($rawId); + + self::assertInstanceOf(CredentialRecord::class, $record); + self::assertSame($rawId, $record->publicKeyCredentialId); + } + + /** + * @test + */ + public function findOneByCredentialIdReturnsNullForUnknownId(): void + { + $this->secondFactorRepository->method('findAllByType') + ->willReturn([$this->factorWithSecret(self::YUBIKEY_SECRET)]); + + self::assertNull($this->adapter->findOneByCredentialId('no-such-credential-id')); + } + + /** + * @test + */ + public function findAllForUserEntityReturnsOnlyCredentialsWithMatchingUserHandle(): void + { + $this->secondFactorRepository->method('findAllByType')->willReturn([ + $this->factorWithSecret(self::YUBIKEY_SECRET), + $this->factorWithSecret(self::PASSKEY_SECRET), + ]); + + $matching = $this->adapter->findAllForUserEntity( + PublicKeyCredentialUserEntity::create('admin', self::USER_HANDLE, 'admin') + ); + self::assertCount(2, $matching); + + $none = $this->adapter->findAllForUserEntity( + PublicKeyCredentialUserEntity::create('other', 'a-different-user-handle', 'other') + ); + self::assertCount(0, $none); + } +} diff --git a/Tests/Unit/Service/WebAuthnServiceTest.php b/Tests/Unit/Service/WebAuthnServiceTest.php index 38cb0b5..7306a38 100644 --- a/Tests/Unit/Service/WebAuthnServiceTest.php +++ b/Tests/Unit/Service/WebAuthnServiceTest.php @@ -7,7 +7,9 @@ use Neos\Flow\Tests\UnitTestCase; use Sandstorm\NeosTwoFactorAuthentication\Service\PublicKeyCredentialSourceRepositoryAdapter; use Sandstorm\NeosTwoFactorAuthentication\Service\WebAuthnService; +use Sandstorm\NeosTwoFactorAuthentication\Service\WebAuthnSerializerProvider; use Webauthn\AuthenticatorSelectionCriteria; +use Webauthn\PublicKeyCredentialRequestOptions; /** * Unit tests for the security-critical mapping from a passkey user handle to a Neos backend @@ -179,4 +181,30 @@ public function discoverabilityIsDerivedFromTheRegistrationOptions(): void self::assertTrue($this->service->isDiscoverableRegistration($passkeyOptions)); self::assertFalse($this->service->isDiscoverableRegistration($secondFactorOptions)); } + + /** + * The controllers stash options in the session as JSON and hand them back to verify against. + * Under v5 that (de)serialization goes through the Symfony serializer (createFromString is + * gone), so a request-options object must survive a serialize -> deserialize round trip + * unchanged. + * + * @test + */ + public function requestOptionsRoundTripThroughTheJsonHelpers(): void + { + $service = new WebAuthnService(); + $this->inject($service, 'serializerProvider', new WebAuthnSerializerProvider()); + + $options = PublicKeyCredentialRequestOptions::create( + random_bytes(32), + rpId: 'neos.example.com', + userVerification: PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_REQUIRED, + ); + + $restored = $service->requestOptionsFromJson($service->optionsToJson($options)); + + self::assertSame($options->rpId, $restored->rpId); + self::assertSame($options->userVerification, $restored->userVerification); + self::assertSame($options->challenge, $restored->challenge); + } } diff --git a/composer.json b/composer.json index 1ba3045..71742c7 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,8 @@ "neos/fusion-form": "*", "spomky-labs/otphp": "^11.5", "chillerlan/php-qrcode": "^5.0", - "web-auth/webauthn-lib": "^4.9.3" + "web-auth/webauthn-lib": "^5.3.5", + "symfony/serializer": "^6.4 | ^7.0 | ^8.0" }, "autoload": { "psr-4": {