From d597eed3ae8d940203b7ee12a7b3aaf8f60e1a16 Mon Sep 17 00:00:00 2001 From: Benjamin Franzke Date: Mon, 4 May 2026 08:49:37 +0200 Subject: [PATCH 1/9] Update to web-auth/webauthn-lib v5.3 Allows to install in TYPO3 v14, since this version also supports phpdocumentor/reflection-docblock v6. --- Resources/Private/Libraries/composer.json | 2 +- Resources/Private/Libraries/composer.lock | 26 +++++++++++------------ composer.json | 2 +- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Resources/Private/Libraries/composer.json b/Resources/Private/Libraries/composer.json index 175bfa4..fa5fefb 100644 --- a/Resources/Private/Libraries/composer.json +++ b/Resources/Private/Libraries/composer.json @@ -1,7 +1,7 @@ { "name": "bnf/mfa-webauthn-libraries-for-classic-mode", "require": { - "web-auth/webauthn-lib": "^5.2" + "web-auth/webauthn-lib": "^5.3" }, "replace": { "psr/http-client": "*", diff --git a/Resources/Private/Libraries/composer.lock b/Resources/Private/Libraries/composer.lock index 8c63320..e1e6116 100644 --- a/Resources/Private/Libraries/composer.lock +++ b/Resources/Private/Libraries/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ec80cd0982e961daa0e183a087576310", + "content-hash": "0bc8d1743bff1b79a315258b5ef56912", "packages": [ { "name": "brick/math", @@ -888,16 +888,16 @@ }, { "name": "web-auth/webauthn-lib", - "version": "5.2.5", + "version": "5.3.1", "source": { "type": "git", "url": "https://github.com/web-auth/webauthn-lib.git", - "reference": "c28f27cb8f968d2b84db48587563f03bb451b60a" + "reference": "a272f254c056fb3d6c80a4801d3c7c5fedc6a08d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/c28f27cb8f968d2b84db48587563f03bb451b60a", - "reference": "c28f27cb8f968d2b84db48587563f03bb451b60a", + "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/a272f254c056fb3d6c80a4801d3c7c5fedc6a08d", + "reference": "a272f254c056fb3d6c80a4801d3c7c5fedc6a08d", "shasum": "" }, "require": { @@ -905,18 +905,18 @@ "ext-openssl": "*", "paragonie/constant_time_encoding": "^2.6|^3.0", "php": ">=8.2", - "phpdocumentor/reflection-docblock": "^5.3", + "phpdocumentor/reflection-docblock": "^5.3|^6.0", "psr/clock": "^1.0", "psr/event-dispatcher": "^1.0", "psr/log": "^1.0|^2.0|^3.0", "spomky-labs/cbor-php": "^3.0", "spomky-labs/pki-framework": "^1.0", - "symfony/clock": "^6.4|^7.0", + "symfony/clock": "^6.4|^7.0|^8.0", "symfony/deprecation-contracts": "^3.2", - "symfony/property-access": "^6.4|^7.0", - "symfony/property-info": "^6.4|^7.0", - "symfony/serializer": "^6.4|^7.0", - "symfony/uid": "^6.4|^7.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", "web-auth/cose-lib": "^4.2.3" }, "suggest": { @@ -958,7 +958,7 @@ "webauthn" ], "support": { - "source": "https://github.com/web-auth/webauthn-lib/tree/5.2.5" + "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.1" }, "funding": [ { @@ -970,7 +970,7 @@ "type": "patreon" } ], - "time": "2026-03-23T21:43:02+00:00" + "time": "2026-05-01T12:14:37+00:00" }, { "name": "webmozart/assert", diff --git a/composer.json b/composer.json index 42bb466..f4ecafb 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ "require": { "php": "^8.2", "typo3/cms-core": "^12.0 || ^13.0 || ^14.0", - "web-auth/webauthn-lib": "^5.2.4" + "web-auth/webauthn-lib": "^5.3" }, "suggest": { "ext-bcmath": "bcmath or gmp are needed for webauthn", From 80b54c3f09f6616c7593d8999df9cf1764d89a39 Mon Sep 17 00:00:00 2001 From: Benjamin Franzke Date: Mon, 4 May 2026 09:11:24 +0200 Subject: [PATCH 2/9] Drop TYPO3 v11 related JavaScript --- Classes/Provider/WebAuthnProvider.php | 13 ++----------- Resources/Public/JavaScript/MfaWebAuthn.js | 1 - build/rollup.config.js | 6 ------ 3 files changed, 2 insertions(+), 18 deletions(-) delete mode 100644 Resources/Public/JavaScript/MfaWebAuthn.js diff --git a/Classes/Provider/WebAuthnProvider.php b/Classes/Provider/WebAuthnProvider.php index 7096db4..13e83d7 100644 --- a/Classes/Provider/WebAuthnProvider.php +++ b/Classes/Provider/WebAuthnProvider.php @@ -29,7 +29,6 @@ use TYPO3\CMS\Core\Authentication\Mfa\MfaViewType; use TYPO3\CMS\Core\Context\Context; use TYPO3\CMS\Core\Core\Environment; -use TYPO3\CMS\Core\Information\Typo3Version; use TYPO3\CMS\Core\Http\NormalizedParams; use TYPO3\CMS\Core\Page\PageRenderer; use TYPO3\CMS\Core\Utility\GeneralUtility; @@ -312,11 +311,7 @@ protected function prepareSetup( $keys = $propertyManager->getProperty(PublicKeyCredentialSourceRepository::PROPERTY) ?? []; $pageRenderer = GeneralUtility::makeInstance(PageRenderer::class); - if ((new Typo3Version())->getMajorVersion() >= 12) { - $pageRenderer->loadJavaScriptModule('@bnf/mfa-webauthn/mfa-web-authn.js'); - } else { - $pageRenderer->loadRequireJsModule('TYPO3/CMS/MfaWebauthn/MfaWebAuthn'); - } + $pageRenderer->loadJavaScriptModule('@bnf/mfa-webauthn/mfa-web-authn.js'); $labels = [ 'singular' => 'security key', @@ -374,11 +369,7 @@ private function prepareAuth(ServerRequestInterface $request, MfaProviderPropert // @todo: Detect FE $pageRenderer = GeneralUtility::makeInstance(PageRenderer::class); - if ((new Typo3Version())->getMajorVersion() >= 12) { - $pageRenderer->loadJavaScriptModule('@bnf/mfa-webauthn/mfa-web-authn.js'); - } else { - $pageRenderer->loadRequireJsModule('TYPO3/CMS/MfaWebauthn/MfaWebAuthn'); - } + $pageRenderer->loadJavaScriptModule('@bnf/mfa-webauthn/mfa-web-authn.js'); return $this->renderHtmlTag('mfa-webauthn-authenticator', [ 'credential-request-options' => $this->createSerializer()->normalize($publicKeyCredentialRequestOptions), diff --git a/Resources/Public/JavaScript/MfaWebAuthn.js b/Resources/Public/JavaScript/MfaWebAuthn.js deleted file mode 100644 index 8a10684..0000000 --- a/Resources/Public/JavaScript/MfaWebAuthn.js +++ /dev/null @@ -1 +0,0 @@ -define(["lit"],(function(e){"use strict";function t(e){const t=new Uint8Array(e);let n="";for(const e of t)n+=String.fromCharCode(e);return btoa(n).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}function n(e){const t=e.replace(/-/g,"+").replace(/_/g,"/"),n=(4-t.length%4)%4,i=t.padEnd(t.length+n,"="),r=atob(i),a=new ArrayBuffer(r.length),o=new Uint8Array(a);for(let e=0;e"public-key"===e.type)).length?new o({message:'No entry in pubKeyCredParams was of type "public-key"',code:"ERROR_MALFORMED_PUBKEYCREDPARAMS",cause:e}):new o({message:"No available authenticator supported any of the specified pubKeyCredParams algorithms",code:"ERROR_AUTHENTICATOR_NO_SUPPORTED_PUBKEYCREDPARAMS_ALG",cause:e});if("SecurityError"===e.name){const t=window.location.hostname;if(!a(t))return new o({message:`${window.location.hostname} is an invalid domain`,code:"ERROR_INVALID_DOMAIN",cause:e});if(n.rp.id!==t)return new o({message:`The RP ID "${n.rp.id}" is invalid for this domain`,code:"ERROR_INVALID_RP_ID",cause:e})}else if("TypeError"===e.name){if(n.user.id.byteLength<1||n.user.id.byteLength>64)return new o({message:"User ID was not between 1 and 64 characters",code:"ERROR_INVALID_USER_ID_LENGTH",cause:e})}else if("UnknownError"===e.name)return new o({message:"The authenticator was unable to process the specified options, or could not create a new credential",code:"ERROR_AUTHENTICATOR_GENERAL_ERROR",cause:e})}return e}({error:e,options:u})}if(!d)throw new Error("Registration was not completed");const{id:h,rawId:p,response:m,type:b}=d;let w;return"function"==typeof m.getTransports&&(w=m.getTransports()),{id:h,rawId:t(p),response:{attestationObject:t(m.attestationObject),clientDataJSON:t(m.clientDataJSON),transports:w},type:b,clientExtensionResults:d.getClientExtensionResults(),authenticatorAttachment:c(d.authenticatorAttachment)}}async function d(e,l=!1){if(!i())throw new Error("WebAuthn is not supported in this browser");let u;0!==e.allowCredentials?.length&&(u=e.allowCredentials?.map(r));const d={...e,challenge:n(e.challenge),allowCredentials:u},h={};if(l){if(!await async function(){const e=window.PublicKeyCredential;return void 0!==e.isConditionalMediationAvailable&&e.isConditionalMediationAvailable()}())throw Error("Browser does not support WebAuthn autofill");if(document.querySelectorAll("input[autocomplete*='webauthn']").length<1)throw Error('No with `"webauthn"` in its `autocomplete` attribute was detected');h.mediation="conditional",d.allowCredentials=[]}let p;h.publicKey=d,h.signal=s.createNewAbortSignal();try{p=await navigator.credentials.get(h)}catch(e){throw function({error:e,options:t}){const{publicKey:n}=t;if(!n)throw Error("options was missing required publicKey property");if("AbortError"===e.name){if(t.signal instanceof AbortSignal)return new o({message:"Authentication ceremony was sent an abort signal",code:"ERROR_CEREMONY_ABORTED",cause:e})}else{if("NotAllowedError"===e.name)return new o({message:e.message,code:"ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY",cause:e});if("SecurityError"===e.name){const t=window.location.hostname;if(!a(t))return new o({message:`${window.location.hostname} is an invalid domain`,code:"ERROR_INVALID_DOMAIN",cause:e});if(n.rpId!==t)return new o({message:`The RP ID "${n.rpId}" is invalid for this domain`,code:"ERROR_INVALID_RP_ID",cause:e})}else if("UnknownError"===e.name)return new o({message:"The authenticator was unable to process the specified options, or could not create a new assertion signature",code:"ERROR_AUTHENTICATOR_GENERAL_ERROR",cause:e})}return e}({error:e,options:h})}if(!p)throw new Error("Authentication was not completed");const{id:m,rawId:b,response:w,type:f}=p;let g;var R;return w.userHandle&&(R=w.userHandle,g=new TextDecoder("utf-8").decode(R)),{id:m,rawId:t(b),response:{authenticatorData:t(w.authenticatorData),clientDataJSON:t(w.clientDataJSON),signature:t(w.signature),userHandle:g},type:f,clientExtensionResults:p.getClientExtensionResults(),authenticatorAttachment:c(p.authenticatorAttachment)}}class h extends HTMLElement{connectedCallback(){const e=document.createElement("input");e.setAttribute("type","hidden"),e.setAttribute("name","webauthn_publicKeyCredential"),this.appendChild(e);let t=this.parentElement;for(;"form"!==t.tagName.toLowerCase();)t=t.parentElement;t.addEventListener("submit",(n=>{if(e.value)return;n.preventDefault();d(JSON.parse(this.getAttribute("credential-request-options"))).then((n=>{e.value=JSON.stringify(n),t.requestSubmit()}),(e=>{console.log(e)}))})),t.requestSubmit()}}window.customElements.define("mfa-webauthn-authenticator",h);class p extends e.LitElement{static get properties(){return{mode:{type:String},credentials:{type:Object},credentialCreationOptions:{type:Object,attribute:"credential-creation-options"},labels:{type:Object},publicKeyCredential:{type:String,attribute:!1},publicKeyDescription:{type:String,attribute:!1},publicKeyIcon:{type:String,attribute:!1},action:{type:String,attribute:!1},loading:{type:Boolean,attribute:!1}}}render(){const t="add"===this.action?"fa-check":this.loading?"fa-circle-o-notch fa-spin":"fa-plus";return e.html` ${0===Object.keys(this.credentials).length?e.html`

No ${this.labels.plural} added

Configure ${this.labels.plural} below
`:e.html`${Object.keys(this.credentials).map((t=>e.html``))}
Registered ${this.labels.plural}
${this._getIcon(this.credentials[t].icon||"key")}${this.credentials[t].description||"(unnamed)"}
Last used: ${this._formatDate(this.credentials[t].updated)}
`} `}createRenderRoot(){return this}connectedCallback(){for(this.publicKeyCrendetial="",this.publicKeyDescription="",this.action="",this.form=this.parentElement;"form"!==this.form.tagName.toLowerCase();)this.form=this.form.parentElement;super.connectedCallback(),"setup"===this.mode&&this._createCredentials()}_createCredentials(e){e&&e.preventDefault();const t=JSON.parse(JSON.stringify(this.credentialCreationOptions));this.loading=!0,u(t).then((e=>{this.action="add",this.publicKeyCredential=JSON.stringify(e),this.publicKeyIcon="key";let t=this.labels.defaultName;if("platform"===this.credentialCreationOptions.authenticatorSelection.authenticatorAttachment){const e=/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);t="My "+(e?"Phone":"Computer"),this.publicKeyIcon=e?"mobile":"computer"}const n=window.prompt("Please provide a name for this "+this.labels.signular+".",t);this.publicKeyDescription=n,this.loading=!1,this.updateComplete.then((()=>this.form.requestSubmit()))}),(e=>{this.loading=!1,console.log(e)}))}_removeCredentials(e,t){e.preventDefault(),this.action="remove";const n=this.credentials[t].description||"(unnamed)";window.confirm("Do you really want to delete the "+this.labels.singular+' "'+n+'"?')&&(this.publicKeyCredential=JSON.stringify(this.credentials[t].publickey),this.updateComplete.then((()=>this.form.requestSubmit())))}_formatDate(e){if(!e)return"never";const t=new Date(1e3*e);return t.toLocaleDateString("en-US",{weekday:"long",year:"numeric",month:"long",day:"numeric"})+" "+t.toLocaleTimeString("en-US")}_getIcon(t){return"mobile"===t?e.html``:"computer"===t?e.html``:"trash"===t?e.html``:e.html``}}window.customElements.define("mfa-webauthn-setup",p)})); diff --git a/build/rollup.config.js b/build/rollup.config.js index 5f99915..7f9fa2b 100644 --- a/build/rollup.config.js +++ b/build/rollup.config.js @@ -12,12 +12,6 @@ export default { name: 'webauthn', plugins: [terser()] }, - { - file: '../Resources/Public/JavaScript/MfaWebAuthn.js', - format: 'amd', - name: 'webauthn', - plugins: [terser()] - }, ], plugins: [ resolve({ From dc657de729a53751f07db3c15ad9da9cff56cee6 Mon Sep 17 00:00:00 2001 From: Benjamin Franzke Date: Mon, 4 May 2026 10:08:35 +0200 Subject: [PATCH 3/9] Adapt createSerializer() return type to correcty annouce (de)normalizing capabilities --- Classes/Provider/WebAuthnProvider.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/Classes/Provider/WebAuthnProvider.php b/Classes/Provider/WebAuthnProvider.php index 13e83d7..60e7c38 100644 --- a/Classes/Provider/WebAuthnProvider.php +++ b/Classes/Provider/WebAuthnProvider.php @@ -24,6 +24,9 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ServerRequestInterface; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderInterface; use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderPropertyManager; use TYPO3\CMS\Core\Authentication\Mfa\MfaViewType; @@ -410,9 +413,16 @@ private function createUserEntity(MfaProviderPropertyManager $propertyManager): return new PublicKeyCredentialUserEntity($userName, $uniqueid, $displayName); } - private function createSerializer(): \Symfony\Component\Serializer\SerializerInterface + private function createSerializer(): SerializerInterface&NormalizerInterface&DenormalizerInterface { - return (new WebauthnSerializerFactory(new AttestationStatementSupportManager()))->create(); + $serializer = (new WebauthnSerializerFactory(new AttestationStatementSupportManager()))->create(); + if (!$serializer instanceof NormalizerInterface || + !$serializer instanceof DenormalizerInterface + ) { + throw new \RuntimeException('Expected WebauthnSerializerFactory to create a (de)normalizing serializer', 1777882044); + } + + return $serializer; } private function createWebauthnServer( From 84d2e2b0f25251aecff7c19c6ac5f71060218088 Mon Sep 17 00:00:00 2001 From: Benjamin Franzke Date: Mon, 4 May 2026 09:11:51 +0200 Subject: [PATCH 4/9] Migrate from deprecated PublicKeyCredentialSource to CredentialRecord --- Classes/Provider/WebAuthnProvider.php | 15 ++++--- .../PublicKeyCredentialSourceRepository.php | 45 +++++++++++++------ Classes/Server.php | 15 ++++--- 3 files changed, 49 insertions(+), 26 deletions(-) diff --git a/Classes/Provider/WebAuthnProvider.php b/Classes/Provider/WebAuthnProvider.php index 60e7c38..118bad0 100644 --- a/Classes/Provider/WebAuthnProvider.php +++ b/Classes/Provider/WebAuthnProvider.php @@ -38,6 +38,7 @@ use TYPO3\CMS\Extbase\Utility\LocalizationUtility; use Webauthn\AttestationStatement\AttestationStatementSupportManager; use Webauthn\AuthenticatorSelectionCriteria; +use Webauthn\CredentialRecord; use Webauthn\Denormalizer\WebauthnSerializerFactory; use Webauthn\PublicKeyCredentialCreationOptions; use Webauthn\PublicKeyCredentialRequestOptions; @@ -291,9 +292,10 @@ protected function prepareSetup( $publicKeyCredentialSourceRepository = new PublicKeyCredentialSourceRepository($propertyManager); $credentialSources = $publicKeyCredentialSourceRepository->findAllForUserEntity($userEntity); // Convert the Credential Sources into Public Key Credential Descriptors - $excludeCredentials = array_map(function (PublicKeyCredentialSource $credential) { - return $credential->getPublicKeyCredentialDescriptor(); - }, $credentialSources); + $excludeCredentials = array_map( + static fn (CredentialRecord $credential) => $credential->getPublicKeyCredentialDescriptor(), + $credentialSources + ); $creationOptions = $webauthn->generatePublicKeyCredentialCreationOptions( $userEntity, @@ -354,9 +356,10 @@ private function prepareAuth(ServerRequestInterface $request, MfaProviderPropert $credentialSources = $publicKeyCredentialSourceRepository->findAllForUserEntity($userEntity); // Convert the Credential Sources into Public Key Credential Descriptors - $allowedCredentials = array_map(function (PublicKeyCredentialSource $credential) { - return $credential->getPublicKeyCredentialDescriptor(); - }, $credentialSources); + $allowedCredentials = array_map( + static fn(CredentialRecord $credential) => $credential->getPublicKeyCredentialDescriptor(), + $credentialSources + ); $webauthn = $this->createWebauthnServer($request, $propertyManager); diff --git a/Classes/Repository/PublicKeyCredentialSourceRepository.php b/Classes/Repository/PublicKeyCredentialSourceRepository.php index 20cd7f2..01173f6 100644 --- a/Classes/Repository/PublicKeyCredentialSourceRepository.php +++ b/Classes/Repository/PublicKeyCredentialSourceRepository.php @@ -23,7 +23,8 @@ use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderPropertyManager; use TYPO3\CMS\Core\Context\Context; use TYPO3\CMS\Core\Utility\GeneralUtility; -use Webauthn\Denormalizer\PublicKeyCredentialSourceDenormalizer; +use Webauthn\CredentialRecord; +use Webauthn\Denormalizer\CredentialRecordDenormalizer; use Webauthn\Denormalizer\TrustPathDenormalizer; use Webauthn\PublicKeyCredentialSource; use Webauthn\PublicKeyCredentialUserEntity; @@ -42,19 +43,22 @@ public function __construct(MfaProviderPropertyManager $mfaProviderPropertyManag private static function createSerializer(): Serializer { return new Serializer([ - new PublicKeyCredentialSourceDenormalizer(), + new CredentialRecordDenormalizer(), new TrustPathDenormalizer(), new UidNormalizer(), new ArrayDenormalizer(), ]); } - private function createPublicKeyCredentialSource(array $source): PublicKeyCredentialSource + /** + * @param array $source + */ + private function createPublicKeyCredentialSource(array $source): CredentialRecord { - return self::createSerializer()->denormalize($source, PublicKeyCredentialSource::class); + return self::createSerializer()->denormalize($source, CredentialRecord::class); } - public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKeyCredentialSource + public function findOneByCredentialId(string $publicKeyCredentialId): ?CredentialRecord { $data = $this->load(); $identifier = base64_encode($publicKeyCredentialId); @@ -67,7 +71,7 @@ public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKey } /** - * @return PublicKeyCredentialSource[] + * @return CredentialRecord[] */ public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity): array { @@ -81,33 +85,40 @@ public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCre return $sources; } - public function saveCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource): void + public function saveCredentialSource(CredentialRecord $publicKeyCredentialSource): void { $identifier = base64_encode($publicKeyCredentialSource->publicKeyCredentialId); + /** @var array $source */ $source = self::createSerializer()->normalize($publicKeyCredentialSource); $data = $this->load(); $data[$identifier]['publickey'] = $source; - $data[$identifier]['updated'] = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('date', 'timestamp'); + /** @var int $timestamp */ + $timestamp = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('date', 'timestamp'); + $data[$identifier]['updated'] = $timestamp; $this->save($data); } - public function addCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource, string $description, string $icon): void + public function addCredentialSource(CredentialRecord $publicKeyCredentialSource, string $description, string $icon): void { $identifier = base64_encode($publicKeyCredentialSource->publicKeyCredentialId); $source = []; - $source['publickey'] = self::createSerializer()->normalize($publicKeyCredentialSource); + /** @var array $publickey */ + $publickey = self::createSerializer()->normalize($publicKeyCredentialSource); + $source['publickey'] = $publickey; $source['description'] = $description; $source['icon'] = $icon; - $source['created'] = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('date', 'timestamp'); + /** @var int $timestamp */ + $timestamp = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('date', 'timestamp'); + $source['created'] = $timestamp; $data = $this->load(); $data[$identifier] = $source; $this->save($data); } - public function removeCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource): void + public function removeCredentialSource(CredentialRecord $publicKeyCredentialSource): void { $identifier = base64_encode($publicKeyCredentialSource->publicKeyCredentialId); @@ -119,11 +130,19 @@ public function removeCredentialSource(PublicKeyCredentialSource $publicKeyCrede $this->save($data); } + /** + * @return array, description?: string, icon?: string, created?: int, updated?: int}> + */ private function load(): array { - return $this->propertyManager->getProperty(self::PROPERTY) ?? []; + /** @var array, description?: string, icon?: string, created?: int, updated?: int}> $data */ + $data = $this->propertyManager->getProperty(self::PROPERTY) ?? []; + return $data; } + /** + * @param array, description?: string, icon?: string, created?: int, updated?: int}> $data + */ private function save(array $data): void { $properties = [self::PROPERTY => $data]; diff --git a/Classes/Server.php b/Classes/Server.php index 7ad3f80..119baa8 100644 --- a/Classes/Server.php +++ b/Classes/Server.php @@ -34,6 +34,7 @@ use Webauthn\AuthenticatorAttestationResponseValidator; use Webauthn\AuthenticatorSelectionCriteria; use Webauthn\CeremonyStep\CeremonyStepManagerFactory; +use Webauthn\CredentialRecord; use Webauthn\Denormalizer\WebauthnSerializerFactory; use Webauthn\PublicKeyCredential; use Webauthn\PublicKeyCredentialCreationOptions; @@ -41,21 +42,21 @@ use Webauthn\PublicKeyCredentialParameters; use Webauthn\PublicKeyCredentialRequestOptions; use Webauthn\PublicKeyCredentialRpEntity; -use Webauthn\PublicKeyCredentialSource; +//use Webauthn\PublicKeyCredentialSource; use Webauthn\PublicKeyCredentialUserEntity; class Server { /** - * @var int + * @var positive-int */ - public $timeout = 60000; + public int $timeout = 60000; /** - * @var int<1, max> + * @var positive-int */ - public $challengeSize = 32; + public int $challengeSize = 32; /** * @var PublicKeyCredentialRpEntity @@ -197,7 +198,7 @@ public function generatePublicKeyCredentialRequestOptions(?string $userVerificat ); } - public function loadAndCheckAttestationResponse(string $data, PublicKeyCredentialCreationOptions $publicKeyCredentialCreationOptions, string $hostname): PublicKeyCredentialSource + public function loadAndCheckAttestationResponse(string $data, PublicKeyCredentialCreationOptions $publicKeyCredentialCreationOptions, string $hostname): CredentialRecord { $attestationStatementSupportManager = $this->getAttestationStatementSupportManager(); $serializer = (new WebauthnSerializerFactory($attestationStatementSupportManager))->create(); @@ -217,7 +218,7 @@ public function loadAndCheckAttestationResponse(string $data, PublicKeyCredentia return $authenticatorAttestationResponseValidator->check($authenticatorResponse, $publicKeyCredentialCreationOptions, $hostname); } - public function loadAndCheckAssertionResponse(string $data, PublicKeyCredentialRequestOptions $publicKeyCredentialRequestOptions, ?PublicKeyCredentialUserEntity $userEntity, string $hostname): PublicKeyCredentialSource + public function loadAndCheckAssertionResponse(string $data, PublicKeyCredentialRequestOptions $publicKeyCredentialRequestOptions, ?PublicKeyCredentialUserEntity $userEntity, string $hostname): CredentialRecord { $attestationStatementSupportManager = $this->getAttestationStatementSupportManager(); $serializer = (new WebauthnSerializerFactory($attestationStatementSupportManager))->create(); From 90678f939d3cff4eaa0518816a23484792f41bfe Mon Sep 17 00:00:00 2001 From: Benjamin Franzke Date: Mon, 4 May 2026 12:20:10 +0200 Subject: [PATCH 5/9] Rename PublicKeyCredentialSourceRepository to CredentialRecordRepository git grep -l PublicKeyCredentialSource | xargs sed -i 's/PublicKeyCredentialSource/CredentialRecord/g' git grep -l publicKeyCredentialSource | xargs sed -i 's/publicKeyCredentialSource/credentialRecord/g' git grep -l CredentialSource | xargs sed -i 's/CredentialSource/CredentialRecord/g' # exception, PROPERTY needs to keep the old name, since this is a # pointer to the data array sed -i "s/public const PROPERTY = 'credentialRecords'/public const PROPERTY = 'publicKeyCredentialSources'/" Classes/Repository/CredentialRecordRepository.php git mv Classes/Repository/PublicKeyCredentialSourceRepository.php Classes/Repository/CredentialRecordRepository.php --- Classes/Provider/WebAuthnProvider.php | 33 +++++++++---------- ...ory.php => CredentialRecordRepository.php} | 25 +++++++------- Classes/Server.php | 20 +++++------ 3 files changed, 38 insertions(+), 40 deletions(-) rename Classes/Repository/{PublicKeyCredentialSourceRepository.php => CredentialRecordRepository.php} (80%) diff --git a/Classes/Provider/WebAuthnProvider.php b/Classes/Provider/WebAuthnProvider.php index 118bad0..01b6cbc 100644 --- a/Classes/Provider/WebAuthnProvider.php +++ b/Classes/Provider/WebAuthnProvider.php @@ -17,7 +17,7 @@ namespace Bnf\MfaWebauthn\Provider; -use Bnf\MfaWebauthn\Repository\PublicKeyCredentialSourceRepository; +use Bnf\MfaWebauthn\Repository\CredentialRecordRepository; use Bnf\MfaWebauthn\Server; use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerAwareTrait; @@ -43,7 +43,6 @@ use Webauthn\PublicKeyCredentialCreationOptions; use Webauthn\PublicKeyCredentialRequestOptions; use Webauthn\PublicKeyCredentialRpEntity; -use Webauthn\PublicKeyCredentialSource; use Webauthn\PublicKeyCredentialUserEntity; class WebAuthnProvider implements MfaProviderInterface, LoggerAwareInterface @@ -93,7 +92,7 @@ public function canProcess(ServerRequestInterface $request): bool public function isActive(MfaProviderPropertyManager $propertyManager): bool { return (bool)$propertyManager->getProperty('active') && - count($propertyManager->getProperty(PublicKeyCredentialSourceRepository::PROPERTY) ?? []) > 0; + count($propertyManager->getProperty(CredentialRecordRepository::PROPERTY) ?? []) > 0; } public function isLocked(MfaProviderPropertyManager $propertyManager): bool @@ -183,7 +182,7 @@ public function update(ServerRequestInterface $request, MfaProviderPropertyManag private function addCredentials(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool { $data = $this->getPublicKey($request); - $publicKeyCredentialSourceRepository = new PublicKeyCredentialSourceRepository($propertyManager); + $credentialRecordRepository = new CredentialRecordRepository($propertyManager); $keyDescription = $this->getDescription($request); $keyIcon = $this->getIcon($request); @@ -197,12 +196,12 @@ private function addCredentials(ServerRequestInterface $request, MfaProviderProp $webauthn = $this->createWebauthnServer($request, $propertyManager); try { - $publicKeyCredentialSource = $webauthn->loadAndCheckAttestationResponse( + $credentialRecord = $webauthn->loadAndCheckAttestationResponse( $data, $creationOptions, // This one contains the challenge we stored during the previous step $hostname ); - $publicKeyCredentialSourceRepository->addCredentialSource($publicKeyCredentialSource, $keyDescription, $keyIcon); + $credentialRecordRepository->addCredentialRecord($credentialRecord, $keyDescription, $keyIcon); } catch (\Throwable $exception) { return false; @@ -223,11 +222,11 @@ private function addCredentials(ServerRequestInterface $request, MfaProviderProp private function removeCredentials(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool { $data = $this->getPublicKey($request); - $publicKeyCredentialSourceRepository = new PublicKeyCredentialSourceRepository($propertyManager); + $credentialRecordRepository = new CredentialRecordRepository($propertyManager); try { $sourceData = json_decode($data, true); - $credentialSource = $this->createSerializer()->denormalize($sourceData, PublicKeyCredentialSource::class); - $publicKeyCredentialSourceRepository->removeCredentialSource($credentialSource); + $credentialSource = $this->createSerializer()->denormalize($sourceData, CredentialRecord::class); + $credentialRecordRepository->removeCredentialRecord($credentialSource); } catch (\Throwable $e) { return false; } @@ -253,7 +252,7 @@ public function verify(ServerRequestInterface $request, MfaProviderPropertyManag $hostname = $this->getNormalizedParams($request)->getRequestHostOnly(); try { - $publicKeyCredentialSource = $webauthn->loadAndCheckAssertionResponse( + $credentialRecord = $webauthn->loadAndCheckAssertionResponse( $publicKey, $publicKeyCredentialRequestOptions, // The options stored during the previous (prepareAuth) step $userEntity, @@ -289,8 +288,8 @@ protected function prepareSetup( $userEntity = $this->createUserEntity($propertyManager); - $publicKeyCredentialSourceRepository = new PublicKeyCredentialSourceRepository($propertyManager); - $credentialSources = $publicKeyCredentialSourceRepository->findAllForUserEntity($userEntity); + $credentialRecordRepository = new CredentialRecordRepository($propertyManager); + $credentialSources = $credentialRecordRepository->findAllForUserEntity($userEntity); // Convert the Credential Sources into Public Key Credential Descriptors $excludeCredentials = array_map( static fn (CredentialRecord $credential) => $credential->getPublicKeyCredentialDescriptor(), @@ -313,7 +312,7 @@ protected function prepareSetup( ? $propertyManager->updateProperties($properties) : $propertyManager->createProviderEntry($properties); - $keys = $propertyManager->getProperty(PublicKeyCredentialSourceRepository::PROPERTY) ?? []; + $keys = $propertyManager->getProperty(CredentialRecordRepository::PROPERTY) ?? []; $pageRenderer = GeneralUtility::makeInstance(PageRenderer::class); $pageRenderer->loadJavaScriptModule('@bnf/mfa-webauthn/mfa-web-authn.js'); @@ -350,10 +349,10 @@ private function prepareAuth(ServerRequestInterface $request, MfaProviderPropert $propertyManager->getProperty('userEntity'), PublicKeyCredentialUserEntity::class ); - $keys = $propertyManager->getProperty(PublicKeyCredentialSourceRepository::PROPERTY); + $keys = $propertyManager->getProperty(CredentialRecordRepository::PROPERTY); - $publicKeyCredentialSourceRepository = new PublicKeyCredentialSourceRepository($propertyManager); - $credentialSources = $publicKeyCredentialSourceRepository->findAllForUserEntity($userEntity); + $credentialRecordRepository = new CredentialRecordRepository($propertyManager); + $credentialSources = $credentialRecordRepository->findAllForUserEntity($userEntity); // Convert the Credential Sources into Public Key Credential Descriptors $allowedCredentials = array_map( @@ -437,7 +436,7 @@ private function createWebauthnServer( $server = new Server( new PublicKeyCredentialRpEntity($name, $id), - new PublicKeyCredentialSourceRepository($propertyManager) + new CredentialRecordRepository($propertyManager) ); if ($this->logger !== null) { $server->setLogger($this->logger); diff --git a/Classes/Repository/PublicKeyCredentialSourceRepository.php b/Classes/Repository/CredentialRecordRepository.php similarity index 80% rename from Classes/Repository/PublicKeyCredentialSourceRepository.php rename to Classes/Repository/CredentialRecordRepository.php index 01173f6..86fafea 100644 --- a/Classes/Repository/PublicKeyCredentialSourceRepository.php +++ b/Classes/Repository/CredentialRecordRepository.php @@ -26,10 +26,9 @@ use Webauthn\CredentialRecord; use Webauthn\Denormalizer\CredentialRecordDenormalizer; use Webauthn\Denormalizer\TrustPathDenormalizer; -use Webauthn\PublicKeyCredentialSource; use Webauthn\PublicKeyCredentialUserEntity; -class PublicKeyCredentialSourceRepository +class CredentialRecordRepository { public const PROPERTY = 'publicKeyCredentialSources'; @@ -53,7 +52,7 @@ private static function createSerializer(): Serializer /** * @param array $source */ - private function createPublicKeyCredentialSource(array $source): CredentialRecord + private function createCredentialRecord(array $source): CredentialRecord { return self::createSerializer()->denormalize($source, CredentialRecord::class); } @@ -67,7 +66,7 @@ public function findOneByCredentialId(string $publicKeyCredentialId): ?Credentia return null; } - return $this->createPublicKeyCredentialSource($source); + return $this->createCredentialRecord($source); } /** @@ -77,7 +76,7 @@ public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCre { $sources = []; foreach ($this->load() as $data) { - $source = $this->createPublicKeyCredentialSource($data['publickey']); + $source = $this->createCredentialRecord($data['publickey']); if ($source->userHandle === $publicKeyCredentialUserEntity->id) { $sources[] = $source; } @@ -85,11 +84,11 @@ public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCre return $sources; } - public function saveCredentialSource(CredentialRecord $publicKeyCredentialSource): void + public function saveCredentialRecord(CredentialRecord $credentialRecord): void { - $identifier = base64_encode($publicKeyCredentialSource->publicKeyCredentialId); + $identifier = base64_encode($credentialRecord->publicKeyCredentialId); /** @var array $source */ - $source = self::createSerializer()->normalize($publicKeyCredentialSource); + $source = self::createSerializer()->normalize($credentialRecord); $data = $this->load(); $data[$identifier]['publickey'] = $source; @@ -99,13 +98,13 @@ public function saveCredentialSource(CredentialRecord $publicKeyCredentialSource $this->save($data); } - public function addCredentialSource(CredentialRecord $publicKeyCredentialSource, string $description, string $icon): void + public function addCredentialRecord(CredentialRecord $credentialRecord, string $description, string $icon): void { - $identifier = base64_encode($publicKeyCredentialSource->publicKeyCredentialId); + $identifier = base64_encode($credentialRecord->publicKeyCredentialId); $source = []; /** @var array $publickey */ - $publickey = self::createSerializer()->normalize($publicKeyCredentialSource); + $publickey = self::createSerializer()->normalize($credentialRecord); $source['publickey'] = $publickey; $source['description'] = $description; $source['icon'] = $icon; @@ -118,9 +117,9 @@ public function addCredentialSource(CredentialRecord $publicKeyCredentialSource, $this->save($data); } - public function removeCredentialSource(CredentialRecord $publicKeyCredentialSource): void + public function removeCredentialRecord(CredentialRecord $credentialRecord): void { - $identifier = base64_encode($publicKeyCredentialSource->publicKeyCredentialId); + $identifier = base64_encode($credentialRecord->publicKeyCredentialId); $data = $this->load(); if (!isset($data[$identifier])) { diff --git a/Classes/Server.php b/Classes/Server.php index 119baa8..82994b2 100644 --- a/Classes/Server.php +++ b/Classes/Server.php @@ -13,7 +13,7 @@ namespace Bnf\MfaWebauthn; -use Bnf\MfaWebauthn\Repository\PublicKeyCredentialSourceRepository; +use Bnf\MfaWebauthn\Repository\CredentialRecordRepository; use Cose\Algorithm\Algorithm; use Cose\Algorithm\ManagerFactory; use Cose\Algorithm\Signature\ECDSA; @@ -42,7 +42,7 @@ use Webauthn\PublicKeyCredentialParameters; use Webauthn\PublicKeyCredentialRequestOptions; use Webauthn\PublicKeyCredentialRpEntity; -//use Webauthn\PublicKeyCredentialSource; +//use Webauthn\CredentialRecord; use Webauthn\PublicKeyCredentialUserEntity; @@ -69,9 +69,9 @@ class Server private $coseAlgorithmManagerFactory; /** - * @var PublicKeyCredentialSourceRepository + * @var CredentialRecordRepository */ - private $publicKeyCredentialSourceRepository; + private $credentialRecordRepository; /** * @var ExtensionOutputCheckerHandler @@ -93,7 +93,7 @@ class Server */ private $securedRelyingPartyId = []; - public function __construct(PublicKeyCredentialRpEntity $relyingParty, PublicKeyCredentialSourceRepository $publicKeyCredentialSourceRepository) + public function __construct(PublicKeyCredentialRpEntity $relyingParty, CredentialRecordRepository $credentialRecordRepository) { $this->rpEntity = $relyingParty; @@ -112,7 +112,7 @@ public function __construct(PublicKeyCredentialRpEntity $relyingParty, PublicKey $this->coseAlgorithmManagerFactory->add('Ed25519', new EdDSA\Ed25519()); $this->selectedAlgorithms = ['RS256', 'RS512', 'PS256', 'PS512', 'ES256', 'ES512', 'Ed25519']; - $this->publicKeyCredentialSourceRepository = $publicKeyCredentialSourceRepository; + $this->credentialRecordRepository = $credentialRecordRepository; $this->extensionOutputCheckerHandler = new ExtensionOutputCheckerHandler(); } @@ -227,7 +227,7 @@ public function loadAndCheckAssertionResponse(string $data, PublicKeyCredentialR $authenticatorResponse = $publicKeyCredential->response; $authenticatorResponse instanceof AuthenticatorAssertionResponse || throw new InvalidArgumentException('Not an authenticator assertion response'); - $credentialSource = $this->publicKeyCredentialSourceRepository->findOneByCredentialId($publicKeyCredential->rawId); + $credentialSource = $this->credentialRecordRepository->findOneByCredentialId($publicKeyCredential->rawId); $credentialSource !== null || throw new InvalidArgumentException('Credential source not found'); $ceremonyStepManagerFactory = $this->createCeremonyStepManagerFactory($attestationStatementSupportManager); @@ -238,16 +238,16 @@ public function loadAndCheckAssertionResponse(string $data, PublicKeyCredentialR $authenticatorAssertionResponseValidator->setLogger($this->logger); } - $updatedCredentialSource = $authenticatorAssertionResponseValidator->check( + $updatedCredentialRecord = $authenticatorAssertionResponseValidator->check( $credentialSource, $authenticatorResponse, $publicKeyCredentialRequestOptions, $hostname, $userEntity?->id, ); - $this->publicKeyCredentialSourceRepository->saveCredentialSource($updatedCredentialSource); + $this->credentialRecordRepository->saveCredentialRecord($updatedCredentialRecord); - return $updatedCredentialSource; + return $updatedCredentialRecord; } public function setLogger(LoggerInterface $logger): void From 899417144f8f626ee82a0647a927a065e89df0b3 Mon Sep 17 00:00:00 2001 From: Benjamin Franzke Date: Mon, 4 May 2026 13:13:17 +0200 Subject: [PATCH 6/9] Streamline serializer initialization --- Classes/Provider/WebAuthnProvider.php | 55 ++++++++----------- .../Repository/CredentialRecordRepository.php | 33 +++-------- Classes/Server.php | 37 +++++++++++-- 3 files changed, 63 insertions(+), 62 deletions(-) diff --git a/Classes/Provider/WebAuthnProvider.php b/Classes/Provider/WebAuthnProvider.php index 01b6cbc..c11aaee 100644 --- a/Classes/Provider/WebAuthnProvider.php +++ b/Classes/Provider/WebAuthnProvider.php @@ -24,9 +24,6 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ServerRequestInterface; -use Symfony\Component\Serializer\SerializerInterface; -use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; -use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderInterface; use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderPropertyManager; use TYPO3\CMS\Core\Authentication\Mfa\MfaViewType; @@ -39,7 +36,6 @@ use Webauthn\AttestationStatement\AttestationStatementSupportManager; use Webauthn\AuthenticatorSelectionCriteria; use Webauthn\CredentialRecord; -use Webauthn\Denormalizer\WebauthnSerializerFactory; use Webauthn\PublicKeyCredentialCreationOptions; use Webauthn\PublicKeyCredentialRequestOptions; use Webauthn\PublicKeyCredentialRpEntity; @@ -182,18 +178,18 @@ public function update(ServerRequestInterface $request, MfaProviderPropertyManag private function addCredentials(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool { $data = $this->getPublicKey($request); - $credentialRecordRepository = new CredentialRecordRepository($propertyManager); + $webauthn = $this->createWebauthnServer($request, $propertyManager); + $serializer = $webauthn->getSerializer(); + $credentialRecordRepository = new CredentialRecordRepository($propertyManager, $serializer); $keyDescription = $this->getDescription($request); $keyIcon = $this->getIcon($request); - $serializer = $this->createSerializer(); $creationOptions = $serializer->denormalize( $propertyManager->getProperty('creationOptions'), PublicKeyCredentialCreationOptions::class ); $hostname = $this->getNormalizedParams($request)->getRequestHostOnly(); - $webauthn = $this->createWebauthnServer($request, $propertyManager); try { $credentialRecord = $webauthn->loadAndCheckAttestationResponse( @@ -222,10 +218,12 @@ private function addCredentials(ServerRequestInterface $request, MfaProviderProp private function removeCredentials(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool { $data = $this->getPublicKey($request); - $credentialRecordRepository = new CredentialRecordRepository($propertyManager); + $webauthn = $this->createWebauthnServer($request, $propertyManager); + $serializer = $webauthn->getSerializer(); + $credentialRecordRepository = new CredentialRecordRepository($propertyManager, $serializer); try { $sourceData = json_decode($data, true); - $credentialSource = $this->createSerializer()->denormalize($sourceData, CredentialRecord::class); + $credentialSource = $serializer->denormalize($sourceData, CredentialRecord::class); $credentialRecordRepository->removeCredentialRecord($credentialSource); } catch (\Throwable $e) { return false; @@ -237,7 +235,9 @@ public function verify(ServerRequestInterface $request, MfaProviderPropertyManag { $publicKey = $this->getPublicKey($request); - $serializer = $this->createSerializer(); + $webauthn = $this->createWebauthnServer($request, $propertyManager); + $serializer = $webauthn->getSerializer(); + $userEntity = $serializer->denormalize( $propertyManager->getProperty('userEntity'), PublicKeyCredentialUserEntity::class @@ -248,7 +248,6 @@ public function verify(ServerRequestInterface $request, MfaProviderPropertyManag PublicKeyCredentialRequestOptions::class ); - $webauthn = $this->createWebauthnServer($request, $propertyManager); $hostname = $this->getNormalizedParams($request)->getRequestHostOnly(); try { @@ -280,6 +279,7 @@ protected function prepareSetup( MfaViewType|string $type ): string { $webauthn = $this->createWebauthnServer($request, $propertyManager); + $serializer = $webauthn->getSerializer(); $authenticatorSelectionCriteria = new AuthenticatorSelectionCriteria( $this->authenticatorAttachment, @@ -288,7 +288,7 @@ protected function prepareSetup( $userEntity = $this->createUserEntity($propertyManager); - $credentialRecordRepository = new CredentialRecordRepository($propertyManager); + $credentialRecordRepository = new CredentialRecordRepository($propertyManager, $serializer); $credentialSources = $credentialRecordRepository->findAllForUserEntity($userEntity); // Convert the Credential Sources into Public Key Credential Descriptors $excludeCredentials = array_map( @@ -303,7 +303,6 @@ protected function prepareSetup( $authenticatorSelectionCriteria ); - $serializer = $this->createSerializer(); $properties = [ 'creationOptions' => $serializer->normalize($creationOptions), 'userEntity' => $serializer->normalize($userEntity), @@ -345,13 +344,16 @@ protected function prepareSetup( private function prepareAuth(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): string { - $userEntity = $this->createSerializer()->denormalize( + $webauthn = $this->createWebauthnServer($request, $propertyManager); + $serializer = $webauthn->getSerializer(); + + $userEntity = $serializer->denormalize( $propertyManager->getProperty('userEntity'), PublicKeyCredentialUserEntity::class ); $keys = $propertyManager->getProperty(CredentialRecordRepository::PROPERTY); - $credentialRecordRepository = new CredentialRecordRepository($propertyManager); + $credentialRecordRepository = new CredentialRecordRepository($propertyManager, $serializer); $credentialSources = $credentialRecordRepository->findAllForUserEntity($userEntity); // Convert the Credential Sources into Public Key Credential Descriptors @@ -360,8 +362,6 @@ private function prepareAuth(ServerRequestInterface $request, MfaProviderPropert $credentialSources ); - $webauthn = $this->createWebauthnServer($request, $propertyManager); - // We generate the set of options. $publicKeyCredentialRequestOptions = $webauthn->generatePublicKeyCredentialRequestOptions( $this->userVerification, @@ -369,7 +369,7 @@ private function prepareAuth(ServerRequestInterface $request, MfaProviderPropert ); $propertyManager->updateProperties([ - 'lastRequest' => $this->createSerializer()->normalize($publicKeyCredentialRequestOptions), + 'lastRequest' => $serializer->normalize($publicKeyCredentialRequestOptions), ]); // @todo: Detect FE @@ -377,7 +377,7 @@ private function prepareAuth(ServerRequestInterface $request, MfaProviderPropert $pageRenderer->loadJavaScriptModule('@bnf/mfa-webauthn/mfa-web-authn.js'); return $this->renderHtmlTag('mfa-webauthn-authenticator', [ - 'credential-request-options' => $this->createSerializer()->normalize($publicKeyCredentialRequestOptions), + 'credential-request-options' => $serializer->normalize($publicKeyCredentialRequestOptions), 'locked' => $this->isLocked($propertyManager), ]); } @@ -415,18 +415,6 @@ private function createUserEntity(MfaProviderPropertyManager $propertyManager): return new PublicKeyCredentialUserEntity($userName, $uniqueid, $displayName); } - private function createSerializer(): SerializerInterface&NormalizerInterface&DenormalizerInterface - { - $serializer = (new WebauthnSerializerFactory(new AttestationStatementSupportManager()))->create(); - if (!$serializer instanceof NormalizerInterface || - !$serializer instanceof DenormalizerInterface - ) { - throw new \RuntimeException('Expected WebauthnSerializerFactory to create a (de)normalizing serializer', 1777882044); - } - - return $serializer; - } - private function createWebauthnServer( ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager @@ -436,8 +424,11 @@ private function createWebauthnServer( $server = new Server( new PublicKeyCredentialRpEntity($name, $id), - new CredentialRecordRepository($propertyManager) ); + $serializer = $server->getSerializer(); + $repository = new CredentialRecordRepository($propertyManager, $serializer); + $server->setCredentialRecordRepository($repository); + if ($this->logger !== null) { $server->setLogger($this->logger); } diff --git a/Classes/Repository/CredentialRecordRepository.php b/Classes/Repository/CredentialRecordRepository.php index 86fafea..fe26cb0 100644 --- a/Classes/Repository/CredentialRecordRepository.php +++ b/Classes/Repository/CredentialRecordRepository.php @@ -17,44 +17,29 @@ namespace Bnf\MfaWebauthn\Repository; -use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; -use Symfony\Component\Serializer\Normalizer\UidNormalizer; -use Symfony\Component\Serializer\Serializer; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderPropertyManager; use TYPO3\CMS\Core\Context\Context; use TYPO3\CMS\Core\Utility\GeneralUtility; use Webauthn\CredentialRecord; -use Webauthn\Denormalizer\CredentialRecordDenormalizer; -use Webauthn\Denormalizer\TrustPathDenormalizer; use Webauthn\PublicKeyCredentialUserEntity; class CredentialRecordRepository { public const PROPERTY = 'publicKeyCredentialSources'; - private MfaProviderPropertyManager $propertyManager; - - public function __construct(MfaProviderPropertyManager $mfaProviderPropertyManager) - { - $this->propertyManager = $mfaProviderPropertyManager; - } - - private static function createSerializer(): Serializer - { - return new Serializer([ - new CredentialRecordDenormalizer(), - new TrustPathDenormalizer(), - new UidNormalizer(), - new ArrayDenormalizer(), - ]); - } + public function __construct( + private readonly MfaProviderPropertyManager $propertyManager, + private readonly DenormalizerInterface&NormalizerInterface $normalizer, + ) {} /** * @param array $source */ private function createCredentialRecord(array $source): CredentialRecord { - return self::createSerializer()->denormalize($source, CredentialRecord::class); + return $this->normalizer->denormalize($source, CredentialRecord::class); } public function findOneByCredentialId(string $publicKeyCredentialId): ?CredentialRecord @@ -88,7 +73,7 @@ public function saveCredentialRecord(CredentialRecord $credentialRecord): void { $identifier = base64_encode($credentialRecord->publicKeyCredentialId); /** @var array $source */ - $source = self::createSerializer()->normalize($credentialRecord); + $source = $this->normalizer->normalize($credentialRecord); $data = $this->load(); $data[$identifier]['publickey'] = $source; @@ -104,7 +89,7 @@ public function addCredentialRecord(CredentialRecord $credentialRecord, string $ $source = []; /** @var array $publickey */ - $publickey = self::createSerializer()->normalize($credentialRecord); + $publickey = $this->normalizer->normalize($credentialRecord); $source['publickey'] = $publickey; $source['description'] = $description; $source['icon'] = $icon; diff --git a/Classes/Server.php b/Classes/Server.php index 82994b2..f12a40d 100644 --- a/Classes/Server.php +++ b/Classes/Server.php @@ -21,6 +21,9 @@ use Cose\Algorithm\Signature\RSA; use InvalidArgumentException; use Psr\Log\LoggerInterface; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Webauthn\AttestationStatement\AndroidKeyAttestationStatementSupport; use Webauthn\AttestationStatement\AttestationStatementSupportManager; use Webauthn\AttestationStatement\FidoU2FAttestationStatementSupport; @@ -42,7 +45,6 @@ use Webauthn\PublicKeyCredentialParameters; use Webauthn\PublicKeyCredentialRequestOptions; use Webauthn\PublicKeyCredentialRpEntity; -//use Webauthn\CredentialRecord; use Webauthn\PublicKeyCredentialUserEntity; @@ -93,8 +95,9 @@ class Server */ private $securedRelyingPartyId = []; - public function __construct(PublicKeyCredentialRpEntity $relyingParty, CredentialRecordRepository $credentialRecordRepository) - { + public function __construct( + PublicKeyCredentialRpEntity $relyingParty, + ) { $this->rpEntity = $relyingParty; $this->coseAlgorithmManagerFactory = new ManagerFactory(); @@ -112,10 +115,19 @@ public function __construct(PublicKeyCredentialRpEntity $relyingParty, Credentia $this->coseAlgorithmManagerFactory->add('Ed25519', new EdDSA\Ed25519()); $this->selectedAlgorithms = ['RS256', 'RS512', 'PS256', 'PS512', 'ES256', 'ES512', 'Ed25519']; - $this->credentialRecordRepository = $credentialRecordRepository; $this->extensionOutputCheckerHandler = new ExtensionOutputCheckerHandler(); } + public function getCredentialRecordRepository(): CredentialRecordRepository + { + return $this->credentialRecordRepository; + } + + public function setCredentialRecordRepository(CredentialRecordRepository $credentialRecordRepository): void + { + $this->credentialRecordRepository = $credentialRecordRepository; + } + /** * @param string[] $selectedAlgorithms */ @@ -201,8 +213,8 @@ public function generatePublicKeyCredentialRequestOptions(?string $userVerificat public function loadAndCheckAttestationResponse(string $data, PublicKeyCredentialCreationOptions $publicKeyCredentialCreationOptions, string $hostname): CredentialRecord { $attestationStatementSupportManager = $this->getAttestationStatementSupportManager(); - $serializer = (new WebauthnSerializerFactory($attestationStatementSupportManager))->create(); + $serializer = $this->getSerializer($attestationStatementSupportManager); $publicKeyCredential = $serializer->deserialize($data, PublicKeyCredential::class, 'json'); $authenticatorResponse = $publicKeyCredential->response; $authenticatorResponse instanceof AuthenticatorAttestationResponse || throw new \InvalidArgumentException('Not an authenticator attestation response'); @@ -221,7 +233,7 @@ public function loadAndCheckAttestationResponse(string $data, PublicKeyCredentia public function loadAndCheckAssertionResponse(string $data, PublicKeyCredentialRequestOptions $publicKeyCredentialRequestOptions, ?PublicKeyCredentialUserEntity $userEntity, string $hostname): CredentialRecord { $attestationStatementSupportManager = $this->getAttestationStatementSupportManager(); - $serializer = (new WebauthnSerializerFactory($attestationStatementSupportManager))->create(); + $serializer = $this->getSerializer($attestationStatementSupportManager); $publicKeyCredential = $serializer->deserialize($data, PublicKeyCredential::class, 'json'); $authenticatorResponse = $publicKeyCredential->response; @@ -255,6 +267,19 @@ public function setLogger(LoggerInterface $logger): void $this->logger = $logger; } + public function getSerializer(?AttestationStatementSupportManager $attestationStatementSupportManager = null): SerializerInterface&NormalizerInterface&DenormalizerInterface + { + $attestationStatementSupportManager ??= $this->getAttestationStatementSupportManager(); + $serializer = (new WebauthnSerializerFactory($attestationStatementSupportManager))->create(); + if (!$serializer instanceof NormalizerInterface || + !$serializer instanceof DenormalizerInterface + ) { + throw new \RuntimeException('Expected WebauthnSerializerFactory to create a (de)normalizing serializer', 1777882044); + } + + return $serializer; + } + private function createCeremonyStepManagerFactory(AttestationStatementSupportManager $attestationStatementSupportManager): CeremonyStepManagerFactory { $factory = new CeremonyStepManagerFactory(); From 4d066217149b56d0974c5e62574fd6005b490b5d Mon Sep 17 00:00:00 2001 From: Benjamin Franzke Date: Mon, 4 May 2026 13:29:09 +0200 Subject: [PATCH 7/9] Detect deprecations in phpstan --- composer.json | 3 ++- phpstan.neon.dist | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index f4ecafb..6ff1b45 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,8 @@ } }, "require-dev": { - "phpstan/phpstan": "^1.8" + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-deprecation-rules": "*" }, "config": { "allow-plugins": { diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 36c8060..7366218 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,3 +1,6 @@ +includes: + - vendor/phpstan/phpstan-deprecation-rules/rules.neon + parameters: level: max From 7836d1067ca263506d2e305c4d5293ce82da371d Mon Sep 17 00:00:00 2001 From: Benjamin Franzke Date: Mon, 4 May 2026 13:48:17 +0200 Subject: [PATCH 8/9] Resolve phpstan annoation errors --- Classes/Provider/WebAuthnProvider.php | 45 +++++++++++++++++++++------ 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/Classes/Provider/WebAuthnProvider.php b/Classes/Provider/WebAuthnProvider.php index c11aaee..fe1aea9 100644 --- a/Classes/Provider/WebAuthnProvider.php +++ b/Classes/Provider/WebAuthnProvider.php @@ -87,8 +87,12 @@ public function canProcess(ServerRequestInterface $request): bool public function isActive(MfaProviderPropertyManager $propertyManager): bool { - return (bool)$propertyManager->getProperty('active') && - count($propertyManager->getProperty(CredentialRecordRepository::PROPERTY) ?? []) > 0; + if (!(bool)$propertyManager->getProperty('active')) { + return false; + } + /** @var array */ + $credentialRecords = $propertyManager->getProperty(CredentialRecordRepository::PROPERTY) ?? []; + return count($credentialRecords) > 0; } public function isLocked(MfaProviderPropertyManager $propertyManager): bool @@ -311,6 +315,7 @@ protected function prepareSetup( ? $propertyManager->updateProperties($properties) : $propertyManager->createProviderEntry($properties); + /** @var array $keys */ $keys = $propertyManager->getProperty(CredentialRecordRepository::PROPERTY) ?? []; $pageRenderer = GeneralUtility::makeInstance(PageRenderer::class); @@ -330,14 +335,15 @@ protected function prepareSetup( ]; } + /** @var array $credentialCreationOptions */ + $credentialCreationOptions = $serializer->normalize($creationOptions); return $this->renderHtmlTag( 'mfa-webauthn-setup', [ - 'credential-creation-options' => $serializer->normalize($creationOptions), + 'credential-creation-options' => $credentialCreationOptions, 'credentials' => $keys, 'mode' => $type, 'labels' => $labels, - 'locked' => $this->isLocked($propertyManager), ] ); } @@ -376,30 +382,44 @@ private function prepareAuth(ServerRequestInterface $request, MfaProviderPropert $pageRenderer = GeneralUtility::makeInstance(PageRenderer::class); $pageRenderer->loadJavaScriptModule('@bnf/mfa-webauthn/mfa-web-authn.js'); + /** @var array $credentialRequestOptions */ + $credentialRequestOptions = $serializer->normalize($publicKeyCredentialRequestOptions); return $this->renderHtmlTag('mfa-webauthn-authenticator', [ - 'credential-request-options' => $serializer->normalize($publicKeyCredentialRequestOptions), - 'locked' => $this->isLocked($propertyManager), + 'credential-request-options' => $credentialRequestOptions, ]); } private function getAction(ServerRequestInterface $request): string { - return trim((string)($request->getQueryParams()['webauthn_action'] ?? $request->getParsedBody()['webauthn_action'] ?? '')); + return $this->getRequestData($request, 'action'); } private function getPublicKey(ServerRequestInterface $request): string { - return trim((string)($request->getQueryParams()['webauthn_publicKeyCredential'] ?? $request->getParsedBody()['webauthn_publicKeyCredential'] ?? '')); + return $this->getRequestData($request, 'publicKeyCredential'); } private function getDescription(ServerRequestInterface $request): string { - return trim((string)($request->getQueryParams()['webauthn_publicKeyDescription'] ?? $request->getParsedBody()['webauthn_publicKeyDescription'] ?? '')); + return $this->getRequestData($request, 'publicKeyDescription'); } private function getIcon(ServerRequestInterface $request): string { - return trim((string)($request->getQueryParams()['webauthn_publicKeyIcon'] ?? $request->getParsedBody()['webauthn_publicKeyIcon'] ?? '')); + return $this->getRequestData($request, 'publicKeyIcon'); + } + + private function getRequestData(ServerRequestInterface $request, string $identifier): string + { + $index = 'webauthn_' . $identifier; + if (isset($request->getQueryParams()[$index])) { + return trim((string)$request->getQueryParams()[$index]); + } + $body = $request->getParsedBody(); + if (!is_array($body) || !isset($body[$index])) { + return ''; + } + return trim((string)$body[$index]); } private function createUserEntity(MfaProviderPropertyManager $propertyManager): PublicKeyCredentialUserEntity @@ -442,12 +462,17 @@ private function createWebauthnServer( return $server; } + /** + * @param array|ArrayObject|object> $attributes + */ private function renderHtmlTag(string $tagName, array $attributes = [], string $content = ''): string { $unescaped = []; foreach ($attributes as $name => $value) { if (is_object($value) || is_array($value)) { $value = GeneralUtility::jsonEncodeForHtmlAttribute($value, false); + } else { + $value = (string)$value; } $unescaped[$name] = $value; } From 5e55c5210505d1388c4043c01bd0586dfc7fe138 Mon Sep 17 00:00:00 2001 From: Benjamin Franzke Date: Mon, 4 May 2026 13:54:21 +0200 Subject: [PATCH 9/9] Replace deprecated setSecuredRelyingPartyId Use setAllowedOrigins instead. --- Classes/Provider/WebAuthnProvider.php | 18 +++++------ Classes/Server.php | 45 ++++----------------------- 2 files changed, 14 insertions(+), 49 deletions(-) diff --git a/Classes/Provider/WebAuthnProvider.php b/Classes/Provider/WebAuthnProvider.php index fe1aea9..4dea399 100644 --- a/Classes/Provider/WebAuthnProvider.php +++ b/Classes/Provider/WebAuthnProvider.php @@ -442,23 +442,21 @@ private function createWebauthnServer( $name = 'TYPO3 Backend'; $id = $this->getNormalizedParams($request)->getRequestHostOnly(); + $allowedOrigins = []; + if (preg_match('/^(.+\.)?localhost$/', $id)) { + // Marks 'localhost' and *.localhost as secure + $allowedOrigins = ['localhost']; + } + $server = new Server( new PublicKeyCredentialRpEntity($name, $id), + $allowedOrigins, + $this->logger, ); $serializer = $server->getSerializer(); $repository = new CredentialRecordRepository($propertyManager, $serializer); $server->setCredentialRecordRepository($repository); - if ($this->logger !== null) { - $server->setLogger($this->logger); - } - - if (preg_match('/^(.+\.)?localhost$/', $id)) { - // Marks 'localhost' and *.localhost as secure - // relying party ID (helps for local testing - $server->setSecuredRelyingPartyId([$id]); - } - return $server; } diff --git a/Classes/Server.php b/Classes/Server.php index f12a40d..ec817aa 100644 --- a/Classes/Server.php +++ b/Classes/Server.php @@ -60,11 +60,6 @@ class Server */ public int $challengeSize = 32; - /** - * @var PublicKeyCredentialRpEntity - */ - private $rpEntity; - /** * @var ManagerFactory */ @@ -85,21 +80,12 @@ class Server */ private $selectedAlgorithms; - /** - * @var LoggerInterface|null - */ - private $logger; - - /** - * @var string[] - */ - private $securedRelyingPartyId = []; - public function __construct( - PublicKeyCredentialRpEntity $relyingParty, + private readonly PublicKeyCredentialRpEntity $rpEntity, + /** @var list */ + private readonly array $allowedOrigins, + private ?LoggerInterface $logger, ) { - $this->rpEntity = $relyingParty; - $this->coseAlgorithmManagerFactory = new ManagerFactory(); $this->coseAlgorithmManagerFactory->add('RS1', new RSA\RS1()); $this->coseAlgorithmManagerFactory->add('RS256', new RSA\RS256()); @@ -154,20 +140,6 @@ public function setExtensionOutputCheckerHandler(ExtensionOutputCheckerHandler $ return $this; } - /** - * @param string[] $securedRelyingPartyId - */ - public function setSecuredRelyingPartyId(array $securedRelyingPartyId): void - { - $count = count($securedRelyingPartyId); - if ($count === 0 || count($securedRelyingPartyId) !== count(array_filter($securedRelyingPartyId, fn ($value): bool => is_string($value)))) { - throw new InvalidArgumentException( - 'Invalid list. Shall be a list of strings' - ); - } - $this->securedRelyingPartyId = $securedRelyingPartyId; - } - /** * @param PublicKeyCredentialDescriptor[] $excludedPublicKeyDescriptors */ @@ -262,11 +234,6 @@ public function loadAndCheckAssertionResponse(string $data, PublicKeyCredentialR return $updatedCredentialRecord; } - public function setLogger(LoggerInterface $logger): void - { - $this->logger = $logger; - } - public function getSerializer(?AttestationStatementSupportManager $attestationStatementSupportManager = null): SerializerInterface&NormalizerInterface&DenormalizerInterface { $attestationStatementSupportManager ??= $this->getAttestationStatementSupportManager(); @@ -286,8 +253,8 @@ private function createCeremonyStepManagerFactory(AttestationStatementSupportMan $factory->setAlgorithmManager($this->coseAlgorithmManagerFactory->generate(...$this->selectedAlgorithms)); $factory->setAttestationStatementSupportManager($attestationStatementSupportManager); $factory->setExtensionOutputCheckerHandler($this->extensionOutputCheckerHandler); - if ($this->securedRelyingPartyId !== []) { - $factory->setSecuredRelyingPartyId($this->securedRelyingPartyId); + if ($this->allowedOrigins !== []) { + $factory->setAllowedOrigins($this->allowedOrigins, true); } return $factory; }