diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f84a790 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +indent_style = space +indent_size = 2 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..ca98f73 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,126 @@ +on: pull_request +name: Review +jobs: + changelog: + runs-on: ubuntu-latest + name: Changelog should be updated + strategy: + fail-fast: false + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 2 + + - name: Git fetch + run: git fetch + + - name: Check that changelog has been updated. + run: git diff --exit-code origin/${{ github.base_ref }} -- CHANGELOG.md && exit 1 || exit 0 + + test-composer-files: + name: Validate composer + runs-on: ubuntu-latest + strategy: + matrix: + php-versions: [ '8.1' ] + dependency-version: [ prefer-lowest, prefer-stable ] + steps: + - uses: actions/checkout@master + - name: Setup PHP, with composer and extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: json + coverage: none + tools: composer:v2 + # https://github.com/shivammathur/setup-php#cache-composer-dependencies + - name: Get composer cache directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + - name: Validate composer files + run: | + composer validate --strict composer.json + # Check that dependencies resolve. + composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction + - name: Check that composer file is normalized + run: | + composer normalize --dry-run + + php-coding-standards: + name: PHP coding standards + runs-on: ubuntu-latest + strategy: + matrix: + php-versions: [ '8.1' ] + steps: + - uses: actions/checkout@master + - name: Setup PHP, with composer and extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: json + coverage: none + tools: composer:v2 + # https://github.com/shivammathur/setup-php#cache-composer-dependencies + - name: Get composer cache directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + - name: Install Dependencies + run: | + composer install --no-interaction --no-progress + - name: PHPCS + run: | + composer coding-standards-check/phpcs + + php-code-analysis: + name: PHP code analysis + runs-on: ubuntu-latest + strategy: + matrix: + php-versions: [ '8.1' ] + steps: + - uses: actions/checkout@master + - name: Setup PHP, with composer and extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: json + coverage: none + tools: composer:v2 + # https://github.com/shivammathur/setup-php#cache-composer-dependencies + - name: Get composer cache directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + - name: Code analysis + run: | + ./scripts/code-analysis + + coding-standards-markdown: + name: Markdown coding standards + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@master + + - name: Coding standards + run: | + docker run --rm --volume $PWD:/md peterdavehello/markdownlint markdownlint --ignore vendor --ignore LICENSE.md '**/*.md' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8a7996 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +composer.lock +vendor/ diff --git a/.markdownlint.jsonc b/.markdownlint.jsonc new file mode 100644 index 0000000..a28c580 --- /dev/null +++ b/.markdownlint.jsonc @@ -0,0 +1,13 @@ +{ + "default": true, + // https://github.com/DavidAnson/markdownlint/blob/main/doc/md013.md + "line-length": { + "line_length": 120, + "code_blocks": false, + "tables": false + }, + // https://github.com/DavidAnson/markdownlint/blob/main/doc/md024.md + "no-duplicate-heading": { + "siblings_only": true + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..261668c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +[Unreleased]: https://github.com/rimi-itk/os2web_key diff --git a/README.md b/README.md index d7aadb3..c48c830 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,134 @@ # OS2Web key -Key stuff for OS2Web. +Key types and providers for OS2Web built on the [Key module](https://www.drupal.org/project/key). + +## Installation + +``` shell +composer require os2web/os2web_key +drush pm:install os2web_key +``` + +## Key types + +### Certificate + +This key type handles [PKCS 12](https://en.wikipedia.org/wiki/PKCS_12) or [Privacy-Enhanced Mail +(PEM)](https://en.wikipedia.org/wiki/Privacy-Enhanced_Mail) certificate with an optional password (passphrase). + +Use in a form: + +``` php +$form['key'] => [ + '#type' => 'key_select', + '#key_filters' => [ + 'type' => 'os2web_key_certificate', + ], +]; +``` + +The [`KeyHelper`](https://github.com/OS2web/os2web_key/blob/main/src/KeyHelper.php) can be used to get +the actual certificates (parts): + +``` php +getKey('my_key'); +[ + // Passwordless certificate. + CertificateKeyType::CERT => $certificate, + CertificateKeyType::PKEY => $privateKey, +] = $helper->getCertificates($key); + +``` + +**Note**: The parsed certificate has no password. + +### OpenID Connect (OIDC) + +Example use in a form: + +``` php +$form['key'] => [ + '#type' => 'key_select', + '#key_filters' => [ + 'type' => 'os2web_key_oidc, + ], +]; +``` + +Get the OIDC config: + +``` php +getKey('openid_connect_ad'); +[ + OidcKeyType::DISCOVERY_URL => $discoveryUrl, + OidcKeyType::CLIENT_ID => $clientId, + OidcKeyType::CLIENT_SECRET => $clientSecret, +] = $helper->getOidcValues($key); +``` + +See [the Key Developer Guide](https://www.drupal.org/docs/contributed-modules/key/developer-guide) for details and more +examples. + +## Providers + +### `@todo` Azure Key Vault + + + +### `@todo` Infisical + + + +## Coding standards + +Our coding are checked by GitHub Actions (cf. [.github/workflows/pr.yml](.github/workflows/pr.yml)). Use the commands +below to run the checks locally. + +### PHP + +```shell +docker run --rm --volume ${PWD}:/app --workdir /app itkdev/php8.1-fpm composer install +# Fix (some) coding standards issues +docker run --rm --volume ${PWD}:/app --workdir /app itkdev/php8.1-fpm composer coding-standards-apply +# Check that code adheres to the coding standards +docker run --rm --volume ${PWD}:/app --workdir /app itkdev/php8.1-fpm composer coding-standards-check +``` + +### Markdown + +```shell +docker run --rm --volume $PWD:/md peterdavehello/markdownlint markdownlint --ignore vendor --ignore LICENSE.md '**/*.md' --fix +docker run --rm --volume $PWD:/md peterdavehello/markdownlint markdownlint --ignore vendor --ignore LICENSE.md '**/*.md' +``` + +## Code analysis + +We use [PHPStan](https://phpstan.org/) for static code analysis. + +Running statis code analysis on a standalone Drupal module is a bit tricky, so we use a helper script to run the +analysis: + +```shell +docker run --rm --volume ${PWD}:/app --workdir /app itkdev/php8.1-fpm ./scripts/code-analysis +``` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..cb11b37 --- /dev/null +++ b/composer.json @@ -0,0 +1,54 @@ +{ + "name": "os2web/os2web_key", + "description": "OS2Web key", + "license": "EUPL-1.2", + "type": "drupal-module", + "authors": [ + { + "name": "Mikkel Ricky", + "email": "rimi@aarhus.dk" + } + ], + "require": { + "php": "^8.1", + "ext-openssl": "*", + "drupal/core": "^9 || ^10", + "drupal/key": "^1.17" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "drupal/coder": "^8.3", + "ergebnis/composer-normalize": "^2.42", + "mglaman/phpstan-drupal": "^1.2", + "phpstan/extension-installer": "^1.3", + "phpstan/phpstan-deprecation-rules": "^1.1" + }, + "repositories": [ + { + "type": "composer", + "url": "https://packages.drupal.org/8" + } + ], + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true, + "ergebnis/composer-normalize": true, + "phpstan/extension-installer": true + }, + "sort-packages": true + }, + "scripts": { + "coding-standards-apply": [ + "@coding-standards-apply/phpcs" + ], + "coding-standards-apply/phpcs": [ + "phpcbf --standard=phpcs.xml.dist" + ], + "coding-standards-check": [ + "@coding-standards-check/phpcs" + ], + "coding-standards-check/phpcs": [ + "phpcs --standard=phpcs.xml.dist" + ] + } +} diff --git a/os2web_key.info.yml b/os2web_key.info.yml new file mode 100644 index 0000000..30ba830 --- /dev/null +++ b/os2web_key.info.yml @@ -0,0 +1,5 @@ +name: 'OS2Web key' +type: module +description: 'Key stuff for OS2Web' +package: 'OS2web' +core_version_requirement: ^9 || ^10 diff --git a/os2web_key.services.yml b/os2web_key.services.yml new file mode 100644 index 0000000..eca6b32 --- /dev/null +++ b/os2web_key.services.yml @@ -0,0 +1,8 @@ +services: + logger.channel.os2web_key: + parent: logger.channel_base + arguments: [ 'os2web_key' ] + + Drupal\os2web_key\KeyHelper: + arguments: + - '@logger.channel.os2web_key' diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..389b5d1 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,22 @@ + + + PHP Code Sniffer configuration + + . + vendor/ + + + + + + + + + + + + + + + + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..e51b69f --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,13 @@ +parameters: + level: 6 + paths: + - src + + ignoreErrors: + - "#Method [a-zA-Z0-9\\_\\\\:\\(\\)]+ has parameter \\$[a-zA-Z0-9_]+ with no value type specified in iterable type array#" + - "#Method [a-zA-Z0-9\\_\\\\:\\(\\)]+ return type has no value type specified in iterable type array#" + - '#Unsafe usage of new static\(\).#' + +# Local Variables: +# mode: yaml +# End: diff --git a/scripts/code-analysis b/scripts/code-analysis new file mode 100755 index 0000000..5a3c1e2 --- /dev/null +++ b/scripts/code-analysis @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +script_dir=$(pwd) +module_name=$(basename "$script_dir") +drupal_dir=vendor/drupal-module-code-analysis +# Relative to $drupal_dir +module_path=web/modules/contrib/$module_name + +cd "$script_dir" || exit + +drupal_composer() { + composer --working-dir="$drupal_dir" --no-interaction "$@" +} + +# Create new Drupal 9 project +if [ ! -f "$drupal_dir/composer.json" ]; then + composer --no-interaction create-project drupal/recommended-project:^9 "$drupal_dir" +fi +# Copy our code into the modules folder + +# Clean up +rm -fr "${drupal_dir:?}/$module_path" + +# https://stackoverflow.com/a/15373763 +# rsync --archive --compress . --filter=':- .gitignore' --exclude "$drupal_dir" --exclude .git "$drupal_dir/$module_path" + +# The rsync command in not available in itkdev/php8.1-fpm + +git config --global --add safe.directory /app +# Copy module files into module path +for f in $(git ls-files); do + mkdir -p "$drupal_dir/$module_path/$(dirname "$f")" + cp "$f" "$drupal_dir/$module_path/$f" +done + +drupal_composer config minimum-stability dev + +# Allow ALL plugins +# https://getcomposer.org/doc/06-config.md#allow-plugins +drupal_composer config --no-plugins allow-plugins true + +drupal_composer require wikimedia/composer-merge-plugin +drupal_composer config extra.merge-plugin.include "$module_path/composer.json" +# https://www.drupal.org/project/drupal/issues/3220043#comment-14845434 +drupal_composer require --dev symfony/phpunit-bridge + +# Run PHPStan +(cd "$drupal_dir/$module_path" && ../../../../vendor/bin/phpstan) diff --git a/src/Exception/RuntimeException.php b/src/Exception/RuntimeException.php new file mode 100644 index 0000000..298fb40 --- /dev/null +++ b/src/Exception/RuntimeException.php @@ -0,0 +1,10 @@ +setLogger($logger); + } + + /** + * Get certificates from a key. + * + * @param \Drupal\key\KeyInterface $key + * The key. + * + * @return array{cert: string, pkey: string} + * The certificates. + */ + public function getCertificates(KeyInterface $key): array { + $type = $key->getKeyType(); + if (!($type instanceof CertificateKeyType)) { + throw $this->createSslRuntimeException(sprintf('Invalid key type: %s', $type::class), $key); + } + $contents = $key->getKeyValue(); + + return $this->parseCertificates( + $contents, + $type->getInputFormat(), + $type->getPassphrase(), + $key + ); + } + + /** + * Get OIDC values from a key. + * + * @param \Drupal\key\KeyInterface $key + * The key. + * + * @return array{discovery_url: string, client_id: string, client_secret: string} + * The OIDC values. + */ + public function getOidcValues(KeyInterface $key): array { + $type = $key->getKeyType(); + if (!($type instanceof OidcKeyType)) { + throw $this->createSslRuntimeException(sprintf('Invalid key type: %s', $type::class), $key); + } + $contents = $key->getKeyValue(); + + try { + $values = json_decode($contents, TRUE, 512, JSON_THROW_ON_ERROR); + foreach ([ + OidcKeyType::DISCOVERY_URL, + OidcKeyType::CLIENT_ID, + OidcKeyType::CLIENT_SECRET, + ] as $name) { + if (!isset($values[$name])) { + throw $this->createRuntimeException(sprintf("Missing OIDC value: %s", $name), $key); + } + } + return $values; + } + catch (\JsonException $e) { + throw $this->createRuntimeException(sprintf("Cannot get OIDC values: %s", $e->getMessage()), $key); + } + } + + /** + * Parse certificates. + * + * @return array{cert: string, pkey: string} + * The certificates. + */ + public function parseCertificates( + string $contents, + string $format, + ?string $passphrase, + ?KeyInterface $key, + ): array { + $certificates = [ + CertificateKeyType::CERT => NULL, + CertificateKeyType::PKEY => NULL, + ]; + switch ($format) { + case CertificateKeyType::FORMAT_PFX: + if (!openssl_pkcs12_read($contents, $certificates, $passphrase)) { + throw $this->createSslRuntimeException('Error reading certificate', $key); + } + break; + + case CertificateKeyType::FORMAT_PEM: + $certificate = @openssl_x509_read($contents); + if (FALSE === $certificate) { + throw $this->createSslRuntimeException('Error reading certificate', $key); + } + if (!@openssl_x509_export($certificate, $certificates['cert'])) { + throw $this->createSslRuntimeException('Error exporting x509 certificate', $key); + } + $pkey = @openssl_pkey_get_private($contents, $passphrase); + if (FALSE === $pkey) { + throw $this->createSslRuntimeException('Error reading private key', $key); + } + if (!@openssl_pkey_export($pkey, $certificates['pkey'])) { + throw $this->createSslRuntimeException('Error exporting private key', $key); + } + break; + } + + if (!isset($certificates[CertificateKeyType::CERT], $certificates[CertificateKeyType::PKEY])) { + throw $this->createRuntimeException("Cannot read certificate parts 'cert' and 'pkey'", $key); + } + + return $certificates; + } + + /** + * Create a passwordless certificate. + */ + public function createPasswordlessCertificate(array $certificates, string $format, ?KeyInterface $key): string { + $cert = $certificates[CertificateKeyType::CERT] ?? NULL; + if (!isset($cert)) { + throw $this->createRuntimeException('Certificate part "cert" not found', $key); + } + + $pkey = $certificates[CertificateKeyType::PKEY] ?? NULL; + if (!isset($pkey)) { + throw $this->createRuntimeException('Certificate part "pkey" not found', $key); + } + + $output = ''; + switch ($format) { + case CertificateKeyType::FORMAT_PEM: + $parts = ['', '']; + if (!@openssl_x509_export($cert, $parts[0])) { + throw $this->createSslRuntimeException('Cannot export certificate', $key); + } + if (!@openssl_pkey_export($pkey, $parts[1])) { + throw $this->createSslRuntimeException('Cannot export private key', $key); + } + $output = implode('', $parts); + break; + + case CertificateKeyType::FORMAT_PFX: + if (!@openssl_pkcs12_export($cert, $output, $pkey, '')) { + throw $this->createSslRuntimeException('Cannot export certificate', $key); + } + break; + + default: + throw $this->createSslRuntimeException(sprintf('Invalid format: %s', $format), $key); + } + + return $output; + } + + /** + * Create a runtime exception. + */ + public function createRuntimeException(string $message, ?KeyInterface $key, ?string $sslError = NULL): RuntimeException { + if (NULL !== $sslError) { + $message .= ' (' . $sslError . ')'; + } + // @fixme Error: Typed property …::$logger must not be accessed before initialization. + if (isset($this->logger)) { + $this->logger->error('@key: @message', [ + '@key' => $key?->id(), + '@message' => $message, + ]); + } + + return new RuntimeException($message); + } + + /** + * Create an SSL runtime exception. + */ + public function createSslRuntimeException(string $message, ?KeyInterface $key): RuntimeException { + return $this->createRuntimeException($message, $key, openssl_error_string() ?: NULL); + } + +} diff --git a/src/Plugin/KeyInput/OidcKeyInput.php b/src/Plugin/KeyInput/OidcKeyInput.php new file mode 100644 index 0000000..9d48157 --- /dev/null +++ b/src/Plugin/KeyInput/OidcKeyInput.php @@ -0,0 +1,69 @@ + '', + OidcKeyType::CLIENT_ID => '', + OidcKeyType::CLIENT_SECRET => '', + ]; + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $form[OidcKeyType::DISCOVERY_URL] = [ + '#type' => 'url', + '#title' => $this->t('Discovery url'), + '#default_value' => $this->configuration[OidcKeyType::DISCOVERY_URL], + '#required' => TRUE, + ]; + + $form[OidcKeyType::CLIENT_ID] = [ + '#type' => 'textfield', + '#title' => $this->t('Client ID'), + '#default_value' => $this->configuration[OidcKeyType::CLIENT_ID], + '#required' => TRUE, + ]; + + $form[OidcKeyType::CLIENT_SECRET] = [ + '#type' => 'textfield', + '#title' => $this->t('Client Secret'), + '#default_value' => $this->configuration[OidcKeyType::CLIENT_SECRET], + '#required' => TRUE, + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function processSubmittedKeyValue(FormStateInterface $form_state) { + $values = $form_state->getValues(); + return [ + 'submitted' => $values, + 'processed_submitted' => $values, + ]; + } + +} diff --git a/src/Plugin/KeyType/CertificateKeyType.php b/src/Plugin/KeyType/CertificateKeyType.php new file mode 100644 index 0000000..4373fc4 --- /dev/null +++ b/src/Plugin/KeyType/CertificateKeyType.php @@ -0,0 +1,174 @@ +get(KeyHelper::class) + ); + } + + /** + * {@inheritdoc} + */ + public function defaultConfiguration() { + return [ + self::PASSPHRASE => '', + self::INPUT_FORMAT => self::FORMAT_PFX, + self::OUTPUT_FORMAT => self::FORMAT_PEM, + ]; + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $form[self::PASSPHRASE] = [ + '#type' => 'textfield', + '#title' => $this->t('Passphrase'), + '#default_value' => $this->configuration[self::PASSPHRASE] ?? NULL, + ]; + + $formatOptions = [ + self::FORMAT_PEM => $this->t('PEM'), + self::FORMAT_PFX => $this->t('pfx (p12)'), + ]; + $form[self::INPUT_FORMAT] = [ + '#title' => $this->t('Input format'), + '#type' => 'select', + '#options' => $formatOptions, + '#required' => TRUE, + '#default_value' => $this->configuration[self::INPUT_FORMAT] ?? NULL, + ]; + + $form[self::OUTPUT_FORMAT] = [ + '#title' => $this->t('Output format'), + '#type' => 'select', + '#options' => $formatOptions, + '#required' => TRUE, + '#default_value' => $this->configuration[self::OUTPUT_FORMAT] ?? NULL, + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function validateConfigurationForm(array &$form, FormStateInterface $form_state): void { + // This function is deliberately left empty. + // Actual validation is performed by self::validateKeyValue(), which see. + } + + /** + * {@inheritdoc} + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state): void { + $this->setConfiguration($form_state->getValues()); + } + + /** + * {@inheritdoc} + */ + public static function generateKeyValue(array $configuration): string { + return Json::encode($configuration); + } + + /** + * {@inheritdoc} + */ + public function validateKeyValue(array $form, FormStateInterface $form_state, $key_value): void { + $passphrase = $form_state->getValue(self::PASSPHRASE); + $inputFormat = $form_state->getValue(self::INPUT_FORMAT); + $outputFormat = $form_state->getValue(self::OUTPUT_FORMAT); + + try { + $certificates = $this->certificateHelper->parseCertificates($key_value, $inputFormat, $passphrase, NULL); + } + catch (RuntimeException $exception) { + $form_state->setError($form, $this->t('Error parsing certificates: @message', ['@message' => $exception->getMessage()])); + return; + } + + try { + $this->certificateHelper->createPasswordlessCertificate($certificates, $outputFormat, NULL); + } + catch (RuntimeException $exception) { + $form_state->setError($form, $this->t('Error creating passwordless certificate: @message', ['@message' => $exception->getMessage()])); + } + } + + /** + * Get passphrase. + */ + public function getPassphrase(): string { + return $this->configuration[self::PASSPHRASE]; + } + + /** + * Get input format. + */ + public function getInputFormat(): string { + return $this->configuration[self::INPUT_FORMAT]; + } + + /** + * Get output format. + */ + public function getOutputFormat(): string { + return $this->configuration[self::OUTPUT_FORMAT]; + } + +} diff --git a/src/Plugin/KeyType/OidcKeyType.php b/src/Plugin/KeyType/OidcKeyType.php new file mode 100644 index 0000000..2156793 --- /dev/null +++ b/src/Plugin/KeyType/OidcKeyType.php @@ -0,0 +1,97 @@ +setError($form, $this->t('The key value is empty.')); + return; + } + + $definition = $this->getPluginDefinition(); + $fields = $definition['multivalue']['fields']; + + foreach ($fields as $id => $field) { + if (!is_array($field)) { + $field = ['label' => $field]; + } + + if (isset($field['required']) && $field['required'] === FALSE) { + continue; + } + + if (!isset($key_value[$id])) { + $form_state->setError($form, $this->t('The key value is missing the field %field.', ['%field' => $id])); + } + elseif (empty($key_value[$id])) { + $form_state->setError($form, $this->t('The key value field %field is empty.', ['%field' => $id])); + } + } + } + + /** + * {@inheritdoc} + */ + public function serialize(array $array) { + return Json::encode($array); + } + + /** + * {@inheritdoc} + */ + public function unserialize($value) { + return Json::decode($value); + } + +}