From b291e959e07dbe5dcfc2524d0c85bd2c6e0b9310 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Tue, 24 Feb 2026 09:27:51 +0100 Subject: [PATCH] Add WebAuthn integration, reCAPTCHA support, and new tests - Integrate WebAuthn library for passkey authentication workflows, including assertion verification and improved error handling. - Add support for reCAPTCHA token validation across multiple endpoints for enhanced security. - Extend OpenAPI schema to document new fields and restructured payloads. - Add unit tests for WebAuthn flows, permission initialization, and route validation to ensure robustness and accuracy. --- openapi.yaml | 91 +- services/nginx/app/classes/webauthn.php | 120 + services/nginx/app/composer.json | 3 +- services/nginx/app/composer.lock | 1968 ++++++++++++++++- services/nginx/app/index.php | 1 + services/nginx/app/objects/passkeys_o.php | 22 + services/nginx/app/routes/authRoute.php | 150 +- .../app/tests/auth/WebAuthnInstallTest.php | 28 + .../selfserve/SelfServeRelayGatingTest.php | 93 + .../subusers/SelfservePermissionInitTest.php | 95 + .../SubusersRoutePermissionLinkTest.php | 31 + 11 files changed, 2487 insertions(+), 115 deletions(-) create mode 100644 services/nginx/app/classes/webauthn.php create mode 100644 services/nginx/app/tests/auth/WebAuthnInstallTest.php create mode 100644 services/nginx/app/tests/selfserve/SelfServeRelayGatingTest.php create mode 100644 services/nginx/app/tests/subusers/SelfservePermissionInitTest.php create mode 100644 services/nginx/app/tests/subusers/SubusersRoutePermissionLinkTest.php diff --git a/openapi.yaml b/openapi.yaml index c934284d..f16a8849 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1041,6 +1041,7 @@ paths: required: - customer_number - password + - g_recaptcha_response properties: customer_number: type: integer @@ -1051,6 +1052,9 @@ paths: format: password description: Customer password minLength: 1 + g_recaptcha_response: + type: string + description: reCAPTCHA verification token responses: '200': description: Login successful @@ -1097,6 +1101,7 @@ paths: required: - user_id - password + - g_recaptcha_response properties: user_id: type: integer @@ -1106,6 +1111,9 @@ paths: type: string format: password description: Employee password + g_recaptcha_response: + type: string + description: reCAPTCHA verification token responses: '200': description: Login successful @@ -1147,11 +1155,16 @@ paths: application/json: schema: type: object + required: + - g_recaptcha_response properties: customer_number: type: integer description: Optional customer's e-conomic customer number example: 12345 + g_recaptcha_response: + type: string + description: reCAPTCHA verification token responses: '200': description: Challenge generated @@ -1213,35 +1226,55 @@ paths: type: object required: - challenge_token - - id - - response + - credential + - g_recaptcha_response properties: challenge_token: type: string description: The token returned by the challenge endpoint - id: + g_recaptcha_response: type: string - description: The credential ID (base64url) - response: + description: reCAPTCHA verification token + credential: type: object + description: The WebAuthn PublicKeyCredential object (assertion) required: - - clientDataJSON - - authenticatorData - - signature + - id + - rawId + - type + - response properties: - clientDataJSON: + id: type: string - description: Base64URL-encoded client data - authenticatorData: + description: The credential ID (base64url) + rawId: type: string - description: Base64URL-encoded authenticator data - signature: + description: The raw credential ID (base64url) + type: type: string - description: Base64URL-encoded signature - userHandle: - type: string - nullable: true - description: Base64URL-encoded user handle + example: public-key + clientExtensionResults: + type: object + response: + type: object + required: + - clientDataJSON + - authenticatorData + - signature + properties: + clientDataJSON: + type: string + description: Base64URL-encoded client data + authenticatorData: + type: string + description: Base64URL-encoded authenticator data + signature: + type: string + description: Base64URL-encoded signature + userHandle: + type: string + nullable: true + description: Base64URL-encoded user handle responses: '200': description: Verification successful, session started @@ -1487,6 +1520,7 @@ paths: - contactEmail - contactPhone - contactName + - g_recaptcha_response properties: cvr: type: string @@ -1524,6 +1558,9 @@ paths: type: string description: Contact person name example: "Mikkel" + g_recaptcha_response: + type: string + description: reCAPTCHA verification token responses: '201': description: Customer registered successfully @@ -1546,11 +1583,15 @@ paths: type: object required: - customer_number + - g_recaptcha_response properties: customer_number: type: integer description: The customer number example: 123456 + g_recaptcha_response: + type: string + description: reCAPTCHA verification token responses: '200': description: Request processed @@ -1611,6 +1652,7 @@ paths: required: - token - password + - g_recaptcha_response properties: token: type: string @@ -1618,6 +1660,9 @@ paths: password: type: string description: The new password + g_recaptcha_response: + type: string + description: reCAPTCHA verification token responses: '200': description: Password updated successfully @@ -6211,11 +6256,17 @@ paths: application/json: schema: type: object + required: [id, data] properties: - form_id: - type: integer + id: + type: string + description: Form identifier data: type: object + description: Form submission data + g_recaptcha_response: + type: string + description: reCAPTCHA verification token (required if not authenticated) responses: '201': description: Form submitted successfully diff --git a/services/nginx/app/classes/webauthn.php b/services/nginx/app/classes/webauthn.php new file mode 100644 index 00000000..cbb1eb13 --- /dev/null +++ b/services/nginx/app/classes/webauthn.php @@ -0,0 +1,120 @@ +setAllowedOrigins(['https://' . $rpId, 'http://' . $rpId]); + + $this->validator = new AuthenticatorAssertionResponseValidator( + $factory->requestCeremony() + ); + + $attestationStatementSupportManager = new AttestationStatementSupportManager(); + + $serializerFactory = new WebauthnSerializerFactory($attestationStatementSupportManager); + $this->serializer = $serializerFactory->create(); + } + + /** + * Verify a WebAuthn assertion + * + * @param string $assertionJson The JSON string from the client + * @param string $challengeToken The hex challenge token stored in our DB + * @param passkeys_o $passkey The passkey object from our DB + * @param string $host The host (rpId) to verify against + * @return bool + */ + public function verifyAssertion( + string $assertionJson, + string $challengeToken, + passkeys_o $passkey, + string $host + ): bool { + try { + $publicKeyCredential = $this->serializer->deserialize($assertionJson, PublicKeyCredential::class, 'json'); + $response = $publicKeyCredential->response; + + if (!$response instanceof AuthenticatorAssertionResponse) { + throw new Exception('Not an assertion response'); + } + + // Create PublicKeyCredentialSource from passkey object + $source = $this->createSourceFromObject($passkey); + + // Prepare challenge (Base64URL without padding) + $rawChallenge = hex2bin($challengeToken); + $challenge = rtrim(strtr(base64_encode($rawChallenge), '+/', '-_'), '='); + + // Create PublicKeyCredentialRequestOptions + $options = PublicKeyCredentialRequestOptions::create( + $challenge, + $host + ); + + // Check + $this->validator->check( + $source, + $response, + $options, + $host, + $source->userHandle + ); + + // If it didn't throw, it's valid. Update sign count. + // Note: validator updates source counter in v5 + $passkey->sign_count->set($source->counter); + + return true; + } catch (Exception $e) { + // Log error + error_log('WebAuthn verification failed: ' . $e->getMessage()); + return false; + } + } + + private function createSourceFromObject(passkeys_o $passkey): PublicKeyCredentialSource + { + $transports = $passkey->transports->value() ?: []; + + // Decode Base64URL credential ID and public key to binary as expected by the WebAuthn lib + $credential_id_bin = base64_decode(strtr($passkey->credential_id->value(), '-_', '+/')); + $public_key_bin = base64_decode(strtr($passkey->public_key->value(), '-_', '+/')); + + return new PublicKeyCredentialSource( + $credential_id_bin, // publicKeyCredentialId (binary) + 'public-key', + $transports, + 'none', // attestationType + new EmptyTrustPath(), + Uuid::fromString('00000000-0000-0000-0000-000000000000'), // aaguid + $public_key_bin, // credentialPublicKey (binary) + (string)$passkey->user_id->value(), // userHandle + (int)$passkey->sign_count->value() + ); + } +} diff --git a/services/nginx/app/composer.json b/services/nginx/app/composer.json index 04a1e3dc..bc02abb3 100644 --- a/services/nginx/app/composer.json +++ b/services/nginx/app/composer.json @@ -14,7 +14,8 @@ "php-http/guzzle7-adapter": "^1.1", "nyholm/psr7": "^1.8", "mailersend/mailersend": "^0.28.0", - "spipu/html2pdf": "^5.3" + "spipu/html2pdf": "^5.3", + "web-auth/webauthn-lib": "^5.2" }, "config": { "allow-plugins": { diff --git a/services/nginx/app/composer.lock b/services/nginx/app/composer.lock index b804fb41..8175b22e 100644 --- a/services/nginx/app/composer.lock +++ b/services/nginx/app/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": "f2957953bf4f6529867c3a9786cec549", + "content-hash": "bf12357a788f855350980902c3c24362", "packages": [ { "name": "aws/aws-crt-php", @@ -224,6 +224,66 @@ }, "time": "2024-07-15T13:18:35+00:00" }, + { + "name": "brick/math", + "version": "0.14.8", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629", + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.2", + "phpstan/phpstan": "2.1.22", + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\Math\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Arbitrary-precision arithmetic library", + "keywords": [ + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" + ], + "support": { + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/0.14.8" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2026-02-10T14:33:43+00:00" + }, { "name": "clue/stream-filter", "version": "v1.7.0", @@ -290,6 +350,54 @@ ], "time": "2023-12-20T15:40:13+00:00" }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, { "name": "guzzlehttp/guzzle", "version": "7.10.0", @@ -1022,6 +1130,75 @@ ], "time": "2024-09-09T07:06:30+00:00" }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, { "name": "php-http/client-common", "version": "2.7.3", @@ -1405,6 +1582,181 @@ }, "time": "2024-03-15T13:55:21+00:00" }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "5.6.6", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/5cee1d3dfc2d2aa6599834520911d246f656bcb8", + "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^1.7", + "phpstan/phpdoc-parser": "^1.7|^2.0", + "webmozart/assert": "^1.9.1 || ^2" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.6" + }, + "time": "2025-12-22T21:13:58+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "1.12.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.3 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^1.18|^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-phpunit": "^1.1", + "phpunit/phpunit": "^9.5", + "rector/rector": "^0.13.9", + "vimeo/psalm": "^4.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" + }, + "time": "2025-11-21T15:09:14+00:00" + }, { "name": "phpmailer/phpmailer", "version": "v6.12.0", @@ -1486,6 +1838,53 @@ ], "time": "2025-10-15T16:49:08+00:00" }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.2", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" + }, + "time": "2026-01-25T14:56:51+00:00" + }, { "name": "predis/predis", "version": "v3.4.0", @@ -1549,6 +1948,54 @@ ], "time": "2026-02-11T17:30:28+00:00" }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, { "name": "psr/container", "version": "2.0.2", @@ -1602,6 +2049,56 @@ }, "time": "2021-11-05T16:47:00+00:00" }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, { "name": "psr/http-client", "version": "1.0.3", @@ -1762,6 +2259,56 @@ }, "time": "2023-04-04T09:54:51+00:00" }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, { "name": "psr/simple-cache", "version": "3.0.0", @@ -1914,6 +2461,186 @@ }, "time": "2025-06-08T21:47:59+00:00" }, + { + "name": "spomky-labs/cbor-php", + "version": "3.2.2", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/cbor-php.git", + "reference": "2a5fb86aacfe1004611370ead6caa2bfc88435d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/2a5fb86aacfe1004611370ead6caa2bfc88435d0", + "reference": "2a5fb86aacfe1004611370ead6caa2bfc88435d0", + "shasum": "" + }, + "require": { + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14", + "ext-mbstring": "*", + "php": ">=8.0" + }, + "require-dev": { + "ext-json": "*", + "roave/security-advisories": "dev-latest", + "symfony/error-handler": "^6.4|^7.1|^8.0", + "symfony/var-dumper": "^6.4|^7.1|^8.0" + }, + "suggest": { + "ext-bcmath": "GMP or BCMath extensions will drastically improve the library performance. BCMath extension needed to handle the Big Float and Decimal Fraction Tags", + "ext-gmp": "GMP or BCMath extensions will drastically improve the library performance" + }, + "type": "library", + "autoload": { + "psr-4": { + "CBOR\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/Spomky-Labs/cbor-php/contributors" + } + ], + "description": "CBOR Encoder/Decoder for PHP", + "keywords": [ + "Concise Binary Object Representation", + "RFC7049", + "cbor" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/cbor-php/issues", + "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.2.2" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2025-11-13T13:00:34+00:00" + }, + { + "name": "spomky-labs/pki-framework", + "version": "1.4.1", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/pki-framework.git", + "reference": "f0e9a548df4e3942886adc9b7830581a46334631" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/f0e9a548df4e3942886adc9b7830581a46334631", + "reference": "f0e9a548df4e3942886adc9b7830581a46334631", + "shasum": "" + }, + "require": { + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14", + "ext-mbstring": "*", + "php": ">=8.1" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", + "ext-gmp": "*", + "ext-openssl": "*", + "infection/infection": "^0.28|^0.29|^0.31", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.3|^2.0", + "phpstan/phpstan": "^1.8|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.1|^2.0", + "phpstan/phpstan-strict-rules": "^1.3|^2.0", + "phpunit/phpunit": "^10.1|^11.0|^12.0", + "rector/rector": "^1.0|^2.0", + "roave/security-advisories": "dev-latest", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symplify/easy-coding-standard": "^12.0" + }, + "suggest": { + "ext-bcmath": "For better performance (or GMP)", + "ext-gmp": "For better performance (or BCMath)", + "ext-openssl": "For OpenSSL based cyphering" + }, + "type": "library", + "autoload": { + "psr-4": { + "SpomkyLabs\\Pki\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joni Eskelinen", + "email": "jonieske@gmail.com", + "role": "Original developer" + }, + { + "name": "Florent Morselli", + "email": "florent.morselli@spomky-labs.com", + "role": "Spomky-Labs PKI Framework developer" + } + ], + "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.", + "homepage": "https://github.com/spomky-labs/pki-framework", + "keywords": [ + "DER", + "Private Key", + "ac", + "algorithm identifier", + "asn.1", + "asn1", + "attribute certificate", + "certificate", + "certification request", + "cryptography", + "csr", + "decrypt", + "ec", + "encrypt", + "pem", + "pkcs", + "public key", + "rsa", + "sign", + "signature", + "verify", + "x.509", + "x.690", + "x509", + "x690" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/pki-framework/issues", + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.1" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2025-12-20T12:57:40+00:00" + }, { "name": "stripe/stripe-php", "version": "v16.6.0", @@ -1973,6 +2700,84 @@ }, "time": "2025-02-24T22:35:29+00:00" }, + { + "name": "symfony/clock", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "9169f24776edde469914c1e7a1442a50f7a4e110" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/9169f24776edde469914c1e7a1442a50f7a4e110", + "reference": "9169f24776edde469914c1e7a1442a50f7a4e110", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v7.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-12T15:39:26+00:00" + }, { "name": "symfony/deprecation-contracts", "version": "v3.6.0", @@ -2264,6 +3069,173 @@ ], "time": "2024-09-09T11:45:10+00:00" }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-27T09:58:17+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, { "name": "symfony/polyfill-mbstring", "version": "v1.33.0", @@ -2433,6 +3405,775 @@ ], "time": "2025-01-02T08:10:11+00:00" }, + { + "name": "symfony/polyfill-php83", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-08T02:45:35+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-24T13:30:11+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/property-access", + "version": "v7.4.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-access.git", + "reference": "fa49bf1ca8fce1ba0e2dba4e4658554cfb9364b1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-access/zipball/fa49bf1ca8fce1ba0e2dba4e4658554cfb9364b1", + "reference": "fa49bf1ca8fce1ba0e2dba4e4658554cfb9364b1", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/property-info": "^6.4.32|~7.3.10|^7.4.4|^8.0.4" + }, + "require-dev": { + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4.1|^7.0.1|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyAccess\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides functions to read and write from/to an object or array using a simple string notation", + "homepage": "https://symfony.com", + "keywords": [ + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property-path", + "reflection" + ], + "support": { + "source": "https://github.com/symfony/property-access/tree/v7.4.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T08:47:25+00:00" + }, + { + "name": "symfony/property-info", + "version": "v7.4.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-info.git", + "reference": "1c9d326bd69602561e2ea467a16c09b5972eee21" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-info/zipball/1c9d326bd69602561e2ea467a16c09b5972eee21", + "reference": "1c9d326bd69602561e2ea467a16c09b5972eee21", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/type-info": "~7.3.10|^7.4.4|^8.0.4" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/cache": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/serializer": "<6.4" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts information about PHP class' properties using metadata of popular sources", + "homepage": "https://symfony.com", + "keywords": [ + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" + ], + "support": { + "source": "https://github.com/symfony/property-info/tree/v7.4.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-27T16:16:02+00:00" + }, + { + "name": "symfony/serializer", + "version": "v7.4.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/serializer.git", + "reference": "480cd1237c98ab1219c20945b92c9d4480a44f47" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/serializer/zipball/480cd1237c98ab1219c20945b92c9d4480a44f47", + "reference": "480cd1237c98ab1219c20945b92c9d4480a44f47", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-php84": "^1.30" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/dependency-injection": "<6.4", + "symfony/property-access": "<6.4", + "symfony/property-info": "<6.4", + "symfony/uid": "<6.4", + "symfony/validator": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "seld/jsonlint": "^1.10", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^7.2|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/form": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/type-info": "^7.1.8|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Serializer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/serializer/tree/v7.4.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-27T08:59:58+00:00" + }, + { + "name": "symfony/string", + "version": "v7.4.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "1c4b10461bf2ec27537b5f36105337262f5f5d6f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/1c4b10461bf2ec27537b5f36105337262f5f5d6f", + "reference": "1c4b10461bf2ec27537b5f36105337262f5f5d6f", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.4.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-12T10:54:30+00:00" + }, + { + "name": "symfony/type-info", + "version": "v7.4.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/type-info.git", + "reference": "f83c725e72b39b2704b9d6fc85070ad6ac7a5889" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/type-info/zipball/f83c725e72b39b2704b9d6fc85070ad6ac7a5889", + "reference": "f83c725e72b39b2704b9d6fc85070ad6ac7a5889", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "phpstan/phpdoc-parser": "<1.30" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^1.30|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\TypeInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts PHP types information.", + "homepage": "https://symfony.com", + "keywords": [ + "PHPStan", + "phpdoc", + "symfony", + "type" + ], + "support": { + "source": "https://github.com/symfony/type-info/tree/v7.4.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-09T12:14:21+00:00" + }, + { + "name": "symfony/uid", + "version": "v7.4.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/7719ce8aba76be93dfe249192f1fbfa52c588e36", + "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.4.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-03T23:30:35+00:00" + }, { "name": "tecnickcom/tcpdf", "version": "6.10.1", @@ -2503,6 +4244,225 @@ } ], "time": "2025-11-21T10:58:21+00:00" + }, + { + "name": "web-auth/cose-lib", + "version": "4.5.0", + "source": { + "type": "git", + "url": "https://github.com/web-auth/cose-lib.git", + "reference": "5adac6fe126994a3ee17ed9950efb4947ab132a9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/5adac6fe126994a3ee17ed9950efb4947ab132a9", + "reference": "5adac6fe126994a3ee17ed9950efb4947ab132a9", + "shasum": "" + }, + "require": { + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14", + "ext-json": "*", + "ext-openssl": "*", + "php": ">=8.1", + "spomky-labs/pki-framework": "^1.0" + }, + "require-dev": { + "spomky-labs/cbor-php": "^3.2.2" + }, + "suggest": { + "ext-bcmath": "For better performance, please install either GMP (recommended) or BCMath extension", + "ext-gmp": "For better performance, please install either GMP (recommended) or BCMath extension", + "spomky-labs/cbor-php": "For COSE Signature support" + }, + "type": "library", + "autoload": { + "psr-4": { + "Cose\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-auth/cose/contributors" + } + ], + "description": "CBOR Object Signing and Encryption (COSE) For PHP", + "homepage": "https://github.com/web-auth", + "keywords": [ + "COSE", + "RFC8152" + ], + "support": { + "issues": "https://github.com/web-auth/cose-lib/issues", + "source": "https://github.com/web-auth/cose-lib/tree/4.5.0" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-01-03T14:43:18+00:00" + }, + { + "name": "web-auth/webauthn-lib", + "version": "5.2.3", + "source": { + "type": "git", + "url": "https://github.com/web-auth/webauthn-lib.git", + "reference": "8782f575032fedc36e2eb27c39c736054e2b6867" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/8782f575032fedc36e2eb27c39c736054e2b6867", + "reference": "8782f575032fedc36e2eb27c39c736054e2b6867", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "paragonie/constant_time_encoding": "^2.6|^3.0", + "php": ">=8.2", + "phpdocumentor/reflection-docblock": "^5.3", + "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/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", + "web-auth/cose-lib": "^4.2.3" + }, + "suggest": { + "psr/log-implementation": "Recommended to receive logs from the library", + "symfony/event-dispatcher": "Recommended to use dispatched events", + "web-token/jwt-library": "Mandatory for fetching Metadata Statement from distant sources" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/web-auth/webauthn-framework", + "name": "web-auth/webauthn-framework" + } + }, + "autoload": { + "psr-4": { + "Webauthn\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-auth/webauthn-library/contributors" + } + ], + "description": "FIDO2/Webauthn Support For PHP", + "homepage": "https://github.com/web-auth", + "keywords": [ + "FIDO2", + "fido", + "webauthn" + ], + "support": { + "source": "https://github.com/web-auth/webauthn-lib/tree/5.2.3" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2025-12-20T10:54:02+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.1.5", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "79155f94852fa27e2f73b459f6503f5e87e2c188" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/79155f94852fa27e2f73b459f6503f5e87e2c188", + "reference": "79155f94852fa27e2f73b459f6503f5e87e2c188", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-feature/2-0": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.1.5" + }, + "time": "2026-02-18T14:09:36+00:00" } ], "packages-dev": [ @@ -2621,7 +4581,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": false, "prefer-lowest": false, "platform": { @@ -2630,6 +4590,6 @@ "ext-curl": "*", "ext-json": "*" }, - "platform-dev": {}, - "plugin-api-version": "2.9.0" + "platform-dev": [], + "plugin-api-version": "2.6.0" } diff --git a/services/nginx/app/index.php b/services/nginx/app/index.php index c1c73778..464d6368 100644 --- a/services/nginx/app/index.php +++ b/services/nginx/app/index.php @@ -80,6 +80,7 @@ require_once 'classes/attachment_store.php'; require_once 'classes/virkdata.php'; require_once 'classes/shelly.php'; require_once 'classes/selfserve.php'; +require_once 'classes/webauthn.php'; /** * Modules diff --git a/services/nginx/app/objects/passkeys_o.php b/services/nginx/app/objects/passkeys_o.php index 82bd9dca..87f0324e 100644 --- a/services/nginx/app/objects/passkeys_o.php +++ b/services/nginx/app/objects/passkeys_o.php @@ -29,6 +29,28 @@ class passkeys_o extends db $this->setTable('passkeys'); } + /** + * Finds a passkey by its credential ID, optionally constrained to a given user ID. + * Returns the selected object instance on success or null if not found. + */ + public function findByCredentialId(string $credentialId, ?int $userId = null): ?self + { + global $db; + $credentialId = $db->escape_string($credentialId); + $where = "credential_id = '" . $credentialId . "'"; + if ($userId !== null) { + $where .= ' AND user_id = ' . (int)$userId; + } + $sql = "SELECT id FROM $this->table WHERE $where LIMIT 1"; + $result = $db->query($sql); + $row = $db->fetch_assoc($result); + if (!$row || !isset($row['id'])) { + return null; + } + $this->select((int)$row['id']); + return $this; + } + /** * Add a new passkey for a user * @param int $user_id The customer number or subuser id, depending on the value of is_subuser diff --git a/services/nginx/app/routes/authRoute.php b/services/nginx/app/routes/authRoute.php index aa53a405..2bdee352 100644 --- a/services/nginx/app/routes/authRoute.php +++ b/services/nginx/app/routes/authRoute.php @@ -8,6 +8,7 @@ use classes\email; use classes\recaptcha; use classes\totp; use classes\virkdata; +use classes\webauthn; use Exception; use objects\customer_password_reset_keys_o; use objects\logs_o; @@ -549,11 +550,7 @@ class authRoute $challenge = rtrim(strtr(base64_encode($rawChallenge), '+/', '-_'), '='); // rpId for passkeys - $rpId = 'truckwash.io'; - $host = $_SERVER['HTTP_HOST'] ?? ''; - if (str_starts_with($host, 'localhost')) { - $rpId = 'localhost'; - } + $rpId = parse_url((string)$_SERVER['HTTP_ORIGIN'], PHP_URL_HOST) ?: $_SERVER['SERVER_NAME']; // Create an ephemeral token to bind the challenge to the (potential) user (new tokens_o())->create($user_id, $challenge_token, 'PASSKEY_CHALLENGE'); @@ -574,115 +571,88 @@ class authRoute }); $this->post('/auth/passkey/verify', function () { - global $response, $db; + global $response; $this->requireRecaptcha(); - self::requireParameters(['challenge_token', 'id', 'response']); - $challenge_token_str = (string)self::getParameter('challenge_token'); - $credential_id = (string)self::getParameter('id'); - $webauthn_response = self::getParameter('response'); - - if (!is_array($webauthn_response)) { - $response->error('Invalid response format', 400); + // Parse JSON body + $data = json_decode(file_get_contents('php://input'), true); + if (!is_array($data)) { + $data = []; } + if (!isset($data['challenge_token']) || !is_string($data['challenge_token']) || strlen($data['challenge_token']) < 10) { + $response->error('Invalid or missing challenge_token', 400); + } + if (!isset($data['credential'])) { + $response->error('Missing credential', 400); + } + + $challenge_token = (string)$data['challenge_token']; + $credentialPayload = $data['credential']; + $credentialJson = is_string($credentialPayload) ? $credentialPayload : json_encode($credentialPayload, JSON_UNESCAPED_SLASHES); + if (!is_string($credentialJson) || $credentialJson === false) { + $response->error('Invalid credential payload', 400); + } + + // Load and validate the ephemeral challenge token $token_o = new tokens_o(); try { - $token = $token_o->getToken($challenge_token_str); + $token = $token_o->getToken($challenge_token); } catch (Exception $e) { - $response->error('Invalid or expired challenge token', 401); + $response->error('Invalid or expired challenge', 401); } - - if ($token->type->value() !== 'PASSKEY_CHALLENGE') { + $token_type = $token->type->value(); + if ($token_type !== 'PASSKEY_CHALLENGE') { $response->error('Invalid token type', 401); } - // Find the passkey - $passkeys = new passkeys_o(); - $fields = ['id', 'user_id', 'is_subuser', 'public_key', 'sign_count', 'algorithm']; - $found = $passkeys->getFieldsWhere(['credential_id' => $credential_id], $fields); + // Determine rpId/host + $host = parse_url((string)($_SERVER['HTTP_ORIGIN'] ?? ''), PHP_URL_HOST) ?: ($_SERVER['SERVER_NAME'] ?? 'localhost'); - if (empty($found)) { - $response->error('Passkey not found', 401); + // Extract credential id + $credentialArr = is_array($credentialPayload) ? $credentialPayload : json_decode($credentialJson, true); + $credentialId = $credentialArr['id'] ?? $credentialArr['rawId'] ?? null; + if (!is_string($credentialId) || $credentialId === '') { + $response->error('Invalid credential id', 400); } - $passkey_data = $found[0]; - $user_id = (int)$passkey_data['user_id']; - $is_subuser = (bool)$passkey_data['is_subuser']; - - // If the challenge was bound to a specific user, verify it matches - if ((int)$token->user_id->value() > 0 && (int)$token->user_id->value() !== $user_id) { - $response->error('Passkey does not match requested user', 401); + // Find corresponding stored passkey + $user_id_hint = (int)$token->user_id->value(); + $passkeyRepo = new passkeys_o(); + $passkey = $passkeyRepo->findByCredentialId($credentialId, $user_id_hint > 0 ? $user_id_hint : null); + if ($passkey === null) { + // As a fallback for discoverable credentials, try without user hint + $passkey = (new passkeys_o())->findByCredentialId($credentialId, null); + } + if ($passkey === null) { + (new logs_o())->add('auth', 'global', 1, $user_id_hint, 'AUTH_PASSKEY_VERIFY_FAILURE', 'Unknown credential'); + $response->error('Invalid credential', 404); } - // WebAuthn signature verification - $clientDataJSON = $webauthn_response['clientDataJSON'] ?? null; - $authenticatorData = $webauthn_response['authenticatorData'] ?? null; - $signature = $webauthn_response['signature'] ?? null; - - if (!$clientDataJSON || !$authenticatorData || !$signature) { - $response->error('Missing WebAuthn response fields', 400); + // Verify assertion using the WebAuthn library + $wa = new webauthn(); + $ok = $wa->verifyAssertion($credentialJson, $challenge_token, $passkey, $host); + if (!$ok) { + (new logs_o())->add('auth', 'global', 1, (int)$passkey->user_id->value(), 'AUTH_PASSKEY_VERIFY_FAILURE', 'Assertion verification failed'); + $response->error('Invalid passkey assertion', 401); } - $decode = function ($data) { - return base64_decode(strtr($data, '-_', '+/')); - }; + // Success → issue session token accordingly and delete the challenge token + $issued_to_user_id = (int)$passkey->user_id->value(); + $is_subuser = (bool)$passkey->is_subuser->value(); + $token_o->delete($challenge_token); - $rawClientDataJSON = $decode($clientDataJSON); - $rawAuthenticatorData = $decode($authenticatorData); - $rawSignature = $decode($signature); - - $clientData = json_decode($rawClientDataJSON, true); - if (!$clientData || !isset($clientData['challenge'])) { - $response->error('Invalid clientDataJSON', 400); - } - - // Reconstruct and verify challenge - $expectedChallenge = rtrim(strtr(base64_encode(hex2bin($challenge_token_str)), '+/', '-_'), '='); - if ($clientData['challenge'] !== $expectedChallenge) { - $response->error('Challenge mismatch', 401); - } - - // Verify signature: S = ES256(authenticatorData || hash(clientDataJSON)) - $clientDataHash = hash('sha256', $rawClientDataJSON, true); - $dataToVerify = $rawAuthenticatorData . $clientDataHash; - $publicKey = $passkey_data['public_key']; - - // If the public key is not in PEM format, assume it's base64url encoded DER - if (strpos($publicKey, '-----BEGIN PUBLIC KEY-----') === false) { - $rawPublicKey = $decode($publicKey); - $publicKey = "-----BEGIN PUBLIC KEY-----\n" . - chunk_split(base64_encode($rawPublicKey), 64, "\n") . - "-----END PUBLIC KEY-----"; - } - - // WebAuthn signatures are DER encoded, which openssl_verify accepts. - $algo = OPENSSL_ALGO_SHA256; - $verifyResult = openssl_verify($dataToVerify, $rawSignature, $publicKey, $algo); - - if ($verifyResult !== 1) { - (new logs_o())->add('auth', 'global', 0, $user_id, 'AUTH_PASSKEY_VERIFY_FAILURE', 'Signature verification failed for credential ' . $credential_id); - $response->error('Invalid signature', 401); - } - - // Success! Update sign_count - $p_obj = (new passkeys_o())->select((int)$passkey_data['id']); - $p_obj->update(['sign_count' => (int)$passkey_data['sign_count'] + 1]); - - $token_o->delete($challenge_token_str); - $auth = new authentication(); + (new logs_o())->add('auth', 'global', 1, $issued_to_user_id, 'AUTH_PASSKEY_VERIFY_SUCCESS', 'Passkey assertion accepted'); if ($is_subuser) { - $subuser = (new subusers_o())->select($user_id); + $subuser = (new subusers_o())->select($issued_to_user_id); $session = $subuser->generateSession(); - (new logs_o())->add('auth', 'global', 1, $user_id, 'AUTH_SUCCESS', 'Subuser login via passkey'); $response->success(['session' => $session]); } else { - $user = (new users_o())->getUserById($user_id); - $customer_number = (int)$user->customer_number->value(); - $new_token = $auth->create_token($customer_number); - (new logs_o())->add('auth', 'global', 1, $user_id, 'AUTH_SUCCESS', 'Customer login via passkey'); - $response->success(['token' => $new_token]); + // For customers, user_id stores the customer number + $auth = new authentication(); + $jwt = $auth->create_token($issued_to_user_id); + $response->success(['token' => $jwt]); } }); diff --git a/services/nginx/app/tests/auth/WebAuthnInstallTest.php b/services/nginx/app/tests/auth/WebAuthnInstallTest.php new file mode 100644 index 00000000..66a01f47 --- /dev/null +++ b/services/nginx/app/tests/auth/WebAuthnInstallTest.php @@ -0,0 +1,28 @@ +lane($laneId); + +// Clear allowed services first +try { + $lane->setLaneCache($laneId, $lane::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES, []); + ok('Cleared allowed services for lane ' . $laneId); +} catch (Exception $e) { + fail('Failed to clear allowed services: ' . $e->getMessage()); +} + +// 1) When MACHINE is not allowed, turning on relay must be blocked by gating +$thrown = false; +try { + // This should throw due to gating (NOT ALLOWED) + $lane->turnOnRelay(selfserve_lane_relay::MACHINE, 1); +} catch (Exception $e) { + $thrown = true; + if (stripos($e->getMessage(), 'not allowed') !== false) { + ok('Gating prevented relay enable without allowed services (as expected)'); + } else { + fail('Unexpected exception message when gating: ' . $e->getMessage()); + } +} +if (!$thrown) { + fail('Expected gating exception when MACHINE is not allowed'); +} + +// 2) Set allowed services to include MACHINE +try { + $lane->setLaneCache($laneId, $lane::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES, ['MACHINE']); + $allowed = $lane->getLaneCache($laneId, $lane::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES); + if (is_array($allowed) && in_array('MACHINE', $allowed, true)) { + ok('Allowed services updated to include MACHINE'); + } else { + fail('Allowed services not updated as expected'); + } +} catch (Exception $e) { + fail('Failed to set allowed services: ' . $e->getMessage()); +} + +// 3) We do NOT actually enable the relay in tests to avoid hitting hardware. +// Instead, we assert that the gating check would pass by attempting the call +// and immediately catching any non-gating error (e.g., hardware/network), +// considering that a pass of the gating layer. + +$passedGating = false; +try { + $lane->turnOnRelay(selfserve_lane_relay::MACHINE, 1); + // If no exception at all, then gating passed and hardware also succeeded (in test env). Count as pass. + $passedGating = true; + warn('Relay enable returned without exception. Assuming test environment allowed a real toggle.'); +} catch (Exception $e) { + if (stripos($e->getMessage(), 'not allowed') !== false) { + fail('Gating still blocked enable even though MACHINE is allowed'); + } else { + // Non-gating error indicates we passed gating and then failed on hardware/network as expected in tests + ok('Gating layer passed when MACHINE allowed (hardware/network error after gating is acceptable in tests)'); + $passedGating = true; + } +} + +if ($passedGating) { + ok('SelfServeRelayGatingTest completed successfully.'); +} else { + fail('SelfServeRelayGatingTest did not pass gating as expected.'); +} + +echo "\nSelfServeRelayGatingTest finished.\n"; diff --git a/services/nginx/app/tests/subusers/SelfservePermissionInitTest.php b/services/nginx/app/tests/subusers/SelfservePermissionInitTest.php new file mode 100644 index 00000000..3a96d2b7 --- /dev/null +++ b/services/nginx/app/tests/subusers/SelfservePermissionInitTest.php @@ -0,0 +1,95 @@ +injectedPermissions = $injectedPermissions; + parent::__construct($subuser_id, $customer_number); + } + + // Override to inject permissions instead of querying DB + public function loadGrants(): void + { + // Manually enable nodes on each permission container without hitting the database + $reflection = new \ReflectionClass($this); + $properties = $reflection->getProperties(); + foreach ($properties as $property) { + $type = $property->getType(); + if ($type && is_a($type->getName(), \modules\subusers\classes\subusers_permission_nodes::class, true)) { + if (method_exists($property, 'isInitialized') && !$property->isInitialized($this)) { + continue; + } + $container = $property->getValue($this); + if ($container instanceof \modules\subusers\classes\subusers_permission_nodes) { + foreach ($this->injectedPermissions as $perm) { + $key = is_string($perm) ? $perm : $perm->name; + $node = $container->getNodeByKey($key); + if ($node) { + $node->value = true; + } + } + } + } + } + } +} + +// Scenario: subuser has only SELFSERVE_ADD granted => ADD = true; LIST/EDIT/DELETE = false +$grant = new SelfserveTestSubuserGrant(123, 456, [subusers_permission_node_key::SELFSERVE_ADD]); + +if ($grant->hasNode(subusers_permission_node_key::SELFSERVE_ADD)) { + ok('SELFSERVE_ADD is granted as expected'); +} else { + fail('SELFSERVE_ADD should be granted but was not'); +} + +if (!$grant->hasNode(subusers_permission_node_key::SELFSERVE_LIST)) { + ok('SELFSERVE_LIST is not granted as expected'); +} else { + fail('SELFSERVE_LIST should not be granted'); +} + +if (!$grant->hasNode(subusers_permission_node_key::SELFSERVE_EDIT)) { + ok('SELFSERVE_EDIT is not granted as expected'); +} else { + fail('SELFSERVE_EDIT should not be granted'); +} + +if (!$grant->hasNode(subusers_permission_node_key::SELFSERVE_DELETE)) { + ok('SELFSERVE_DELETE is not granted as expected'); +} else { + fail('SELFSERVE_DELETE should not be granted'); +} + +echo "\nSelfservePermissionInitTest completed.\n"; diff --git a/services/nginx/app/tests/subusers/SubusersRoutePermissionLinkTest.php b/services/nginx/app/tests/subusers/SubusersRoutePermissionLinkTest.php new file mode 100644 index 00000000..1a873a8c --- /dev/null +++ b/services/nginx/app/tests/subusers/SubusersRoutePermissionLinkTest.php @@ -0,0 +1,31 @@ +