diff --git a/api/.env b/api/.env
index 4091bba46..5512e6033 100644
--- a/api/.env
+++ b/api/.env
@@ -20,6 +20,9 @@ TRUSTED_HOSTS=^(localhost|php)$
OIDC_SERVER_URL=https://localhost/oidc/realms/demo
OIDC_SERVER_URL_INTERNAL=http://keycloak:8080/oidc/realms/demo
OIDC_SWAGGER_CLIENT_ID=api-platform-swagger
+OIDC_API_CLIENT_ID=api-platform-api
+OIDC_API_CLIENT_SECRET=sEocbxCy7iFS8NzYzWyQ71QgxTDZ9fnU
+OIDC_AUD=api-platform
###> symfony/framework-bundle ###
APP_ENV=dev
diff --git a/api/.env.test b/api/.env.test
index e3d5d8bdb..d568771b8 100644
--- a/api/.env.test
+++ b/api/.env.test
@@ -4,6 +4,7 @@ APP_SECRET='$ecretf0rt3st'
SYMFONY_DEPRECATIONS_HELPER=999999
PANTHER_APP_ENV=panther
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
+OIDC_JWK='{"kty": "EC","d": "cT3_vKHaGOAhhmzR0Jbi1ko40dNtpjtaiWzm_7VNwLA","use": "sig","crv": "P-256","x": "n6PnJPqNK5nP-ymwwsOIqZvjiCKFNzRyqWA8KNyBsDo","y": "bQSmMlDXOmtgyS1rhsKUmqlxq-8Kw0Iw9t50cSloTMM","alg": "ES256"}'
# API Platform distribution
TRUSTED_HOSTS=^example\.com|localhost$
diff --git a/api/Dockerfile b/api/Dockerfile
index 5df79082f..a0498e6a3 100644
--- a/api/Dockerfile
+++ b/api/Dockerfile
@@ -34,6 +34,7 @@ RUN apt-get update; \
RUN set -eux; \
install-php-extensions \
apcu \
+ bcmath \
intl \
opcache \
zip \
diff --git a/api/composer.json b/api/composer.json
index 4862a32f8..4f905eaec 100644
--- a/api/composer.json
+++ b/api/composer.json
@@ -34,7 +34,7 @@
"symfony/uid": "7.0.*",
"symfony/validator": "7.0.*",
"symfony/yaml": "7.0.*",
- "web-token/jwt-library": "^3.3",
+ "web-token/jwt-bundle": "^3.3",
"webonyx/graphql-php": "^15.8",
"zenstruck/foundry": "^1.36"
},
@@ -109,7 +109,11 @@
"symfony": {
"allow-contrib": false,
"require": "7.0.*",
- "docker": false
+ "docker": false,
+ "endpoint": [
+ "https://api.github.com/repos/Spomky-Labs/recipes/contents/index.json?ref=main",
+ "flex://defaults"
+ ]
}
}
}
diff --git a/api/composer.lock b/api/composer.lock
index fb46232ed..9350a0ac5 100644
--- a/api/composer.lock
+++ b/api/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": "30bf2abc05f735781179f265db9f9426",
+ "content-hash": "b6e35817aa0d4602d22e2457ed1f9ab6",
"packages": [
{
"name": "api-platform/core",
@@ -7086,18 +7086,99 @@
],
"time": "2023-11-21T18:54:41+00:00"
},
+ {
+ "name": "web-token/jwt-bundle",
+ "version": "3.3.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/web-token/jwt-bundle.git",
+ "reference": "fce2876e1f2b611d8cce0a637d0eb2585ad94d8e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/web-token/jwt-bundle/zipball/fce2876e1f2b611d8cce0a637d0eb2585ad94d8e",
+ "reference": "fce2876e1f2b611d8cce0a637d0eb2585ad94d8e",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "psr/event-dispatcher": "^1.0",
+ "symfony/config": "^5.4|^6.0|^7.0",
+ "symfony/dependency-injection": "^5.4|^6.0|^7.0",
+ "symfony/event-dispatcher": "^5.4|^6.0|^7.0",
+ "symfony/http-kernel": "^5.4|^6.0|^7.0",
+ "web-token/jwt-library": "^3.3"
+ },
+ "suggest": {
+ "symfony/serializer": "Use the Symfony serializer to serialize/unserialize JWS and JWE tokens."
+ },
+ "type": "symfony-bundle",
+ "autoload": {
+ "psr-4": {
+ "Jose\\Bundle\\JoseFramework\\": ""
+ }
+ },
+ "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-token/jwt-bundle/contributors"
+ }
+ ],
+ "description": "JWT Bundle of the JWT Framework.",
+ "homepage": "https://github.com/web-token",
+ "keywords": [
+ "JOSE",
+ "JWE",
+ "JWK",
+ "JWKSet",
+ "JWS",
+ "Jot",
+ "RFC7515",
+ "RFC7516",
+ "RFC7517",
+ "RFC7518",
+ "RFC7519",
+ "RFC7520",
+ "bundle",
+ "jwa",
+ "jwt",
+ "symfony"
+ ],
+ "support": {
+ "source": "https://github.com/web-token/jwt-bundle/tree/3.3.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Spomky",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/FlorentMorselli",
+ "type": "patreon"
+ }
+ ],
+ "time": "2024-03-24T09:57:06+00:00"
+ },
{
"name": "web-token/jwt-library",
- "version": "3.3.1",
+ "version": "3.3.4",
"source": {
"type": "git",
"url": "https://github.com/web-token/jwt-library.git",
- "reference": "37a0665d89865d09579e484e5d7d70950d079198"
+ "reference": "a9fde8057aa978a4b97436d8875f5bb45a30fb2e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/web-token/jwt-library/zipball/37a0665d89865d09579e484e5d7d70950d079198",
- "reference": "37a0665d89865d09579e484e5d7d70950d079198",
+ "url": "https://api.github.com/repos/web-token/jwt-library/zipball/a9fde8057aa978a4b97436d8875f5bb45a30fb2e",
+ "reference": "a9fde8057aa978a4b97436d8875f5bb45a30fb2e",
"shasum": ""
},
"require": {
@@ -7169,7 +7250,7 @@
],
"support": {
"issues": "https://github.com/web-token/jwt-library/issues",
- "source": "https://github.com/web-token/jwt-library/tree/3.3.1"
+ "source": "https://github.com/web-token/jwt-library/tree/3.3.4"
},
"funding": [
{
@@ -7181,7 +7262,7 @@
"type": "patreon"
}
],
- "time": "2024-02-28T09:04:35+00:00"
+ "time": "2024-03-24T09:57:06+00:00"
},
{
"name": "webonyx/graphql-php",
@@ -8008,16 +8089,16 @@
},
{
"name": "phpstan/phpdoc-parser",
- "version": "1.26.0",
+ "version": "1.27.0",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpdoc-parser.git",
- "reference": "231e3186624c03d7e7c890ec662b81e6b0405227"
+ "reference": "86e4d5a4b036f8f0be1464522f4c6b584c452757"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/231e3186624c03d7e7c890ec662b81e6b0405227",
- "reference": "231e3186624c03d7e7c890ec662b81e6b0405227",
+ "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/86e4d5a4b036f8f0be1464522f4c6b584c452757",
+ "reference": "86e4d5a4b036f8f0be1464522f4c6b584c452757",
"shasum": ""
},
"require": {
@@ -8049,22 +8130,22 @@
"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/1.26.0"
+ "source": "https://github.com/phpstan/phpdoc-parser/tree/1.27.0"
},
- "time": "2024-02-23T16:05:55+00:00"
+ "time": "2024-03-21T13:14:53+00:00"
},
{
"name": "phpstan/phpstan",
- "version": "1.10.62",
+ "version": "1.10.65",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan.git",
- "reference": "cd5c8a1660ed3540b211407c77abf4af193a6af9"
+ "reference": "3c657d057a0b7ecae19cb12db446bbc99d8839c6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpstan/zipball/cd5c8a1660ed3540b211407c77abf4af193a6af9",
- "reference": "cd5c8a1660ed3540b211407c77abf4af193a6af9",
+ "url": "https://api.github.com/repos/phpstan/phpstan/zipball/3c657d057a0b7ecae19cb12db446bbc99d8839c6",
+ "reference": "3c657d057a0b7ecae19cb12db446bbc99d8839c6",
"shasum": ""
},
"require": {
@@ -8113,25 +8194,25 @@
"type": "tidelift"
}
],
- "time": "2024-03-13T12:27:20+00:00"
+ "time": "2024-03-23T10:30:26+00:00"
},
{
"name": "phpstan/phpstan-doctrine",
- "version": "1.3.62",
+ "version": "1.3.64",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-doctrine.git",
- "reference": "f3abbd8e93e12fed8091be3aeec216b06bed0950"
+ "reference": "f42828ad684e026054c3d94161d89870150bc3da"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/f3abbd8e93e12fed8091be3aeec216b06bed0950",
- "reference": "f3abbd8e93e12fed8091be3aeec216b06bed0950",
+ "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/f42828ad684e026054c3d94161d89870150bc3da",
+ "reference": "f42828ad684e026054c3d94161d89870150bc3da",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0",
- "phpstan/phpstan": "^1.10.48"
+ "phpstan/phpstan": "^1.10.64"
},
"conflict": {
"doctrine/collections": "<1.0",
@@ -8183,9 +8264,9 @@
"description": "Doctrine extensions for PHPStan",
"support": {
"issues": "https://github.com/phpstan/phpstan-doctrine/issues",
- "source": "https://github.com/phpstan/phpstan-doctrine/tree/1.3.62"
+ "source": "https://github.com/phpstan/phpstan-doctrine/tree/1.3.64"
},
- "time": "2024-02-12T11:52:17+00:00"
+ "time": "2024-03-21T10:19:51+00:00"
},
{
"name": "phpstan/phpstan-phpunit",
@@ -8241,22 +8322,22 @@
},
{
"name": "phpstan/phpstan-symfony",
- "version": "1.3.8",
+ "version": "1.3.9",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-symfony.git",
- "reference": "d8a0bc03a68d95288b6471c37d435647fbdaff1a"
+ "reference": "a32bc86da24495025d7aafd1ba62444d4a364a98"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/d8a0bc03a68d95288b6471c37d435647fbdaff1a",
- "reference": "d8a0bc03a68d95288b6471c37d435647fbdaff1a",
+ "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/a32bc86da24495025d7aafd1ba62444d4a364a98",
+ "reference": "a32bc86da24495025d7aafd1ba62444d4a364a98",
"shasum": ""
},
"require": {
"ext-simplexml": "*",
"php": "^7.2 || ^8.0",
- "phpstan/phpstan": "^1.10.36"
+ "phpstan/phpstan": "^1.10.62"
},
"conflict": {
"symfony/framework-bundle": "<3.0"
@@ -8307,9 +8388,9 @@
"description": "Symfony Framework extensions and rules for PHPStan",
"support": {
"issues": "https://github.com/phpstan/phpstan-symfony/issues",
- "source": "https://github.com/phpstan/phpstan-symfony/tree/1.3.8"
+ "source": "https://github.com/phpstan/phpstan-symfony/tree/1.3.9"
},
- "time": "2024-03-05T16:33:08+00:00"
+ "time": "2024-03-16T16:50:20+00:00"
},
{
"name": "phpunit/php-code-coverage",
@@ -8636,16 +8717,16 @@
},
{
"name": "phpunit/phpunit",
- "version": "11.0.6",
+ "version": "11.0.8",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
- "reference": "6af32d7938fc366f86e49a5f5ebb314018d1b1fb"
+ "reference": "48ea58408879a9aad630022186398364051482fc"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/6af32d7938fc366f86e49a5f5ebb314018d1b1fb",
- "reference": "6af32d7938fc366f86e49a5f5ebb314018d1b1fb",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/48ea58408879a9aad630022186398364051482fc",
+ "reference": "48ea58408879a9aad630022186398364051482fc",
"shasum": ""
},
"require": {
@@ -8716,7 +8797,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
- "source": "https://github.com/sebastianbergmann/phpunit/tree/11.0.6"
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/11.0.8"
},
"funding": [
{
@@ -8732,7 +8813,7 @@
"type": "tidelift"
}
],
- "time": "2024-03-12T15:40:01+00:00"
+ "time": "2024-03-22T04:21:01+00:00"
},
{
"name": "sebastian/cli-parser",
@@ -9108,16 +9189,16 @@
},
{
"name": "sebastian/environment",
- "version": "7.0.0",
+ "version": "7.1.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/environment.git",
- "reference": "100d8b855d7180f79f9a9a5c483f2d960581c3ea"
+ "reference": "4eb3a442574d0e9d141aab209cd4aaf25701b09a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/100d8b855d7180f79f9a9a5c483f2d960581c3ea",
- "reference": "100d8b855d7180f79f9a9a5c483f2d960581c3ea",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/4eb3a442574d0e9d141aab209cd4aaf25701b09a",
+ "reference": "4eb3a442574d0e9d141aab209cd4aaf25701b09a",
"shasum": ""
},
"require": {
@@ -9132,7 +9213,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "7.0-dev"
+ "dev-main": "7.1-dev"
}
},
"autoload": {
@@ -9160,7 +9241,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/environment/issues",
"security": "https://github.com/sebastianbergmann/environment/security/policy",
- "source": "https://github.com/sebastianbergmann/environment/tree/7.0.0"
+ "source": "https://github.com/sebastianbergmann/environment/tree/7.1.0"
},
"funding": [
{
@@ -9168,7 +9249,7 @@
"type": "github"
}
],
- "time": "2024-02-02T05:57:54+00:00"
+ "time": "2024-03-23T08:56:34+00:00"
},
{
"name": "sebastian/exporter",
@@ -9933,16 +10014,16 @@
},
{
"name": "symfony/maker-bundle",
- "version": "v1.56.0",
+ "version": "v1.57.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/maker-bundle.git",
- "reference": "bbb7949ae048363df7c8439abeddef8befd155ce"
+ "reference": "2c90181911241648356b828b86b04fe3571aca0b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/bbb7949ae048363df7c8439abeddef8befd155ce",
- "reference": "bbb7949ae048363df7c8439abeddef8befd155ce",
+ "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/2c90181911241648356b828b86b04fe3571aca0b",
+ "reference": "2c90181911241648356b828b86b04fe3571aca0b",
"shasum": ""
},
"require": {
@@ -10005,7 +10086,7 @@
],
"support": {
"issues": "https://github.com/symfony/maker-bundle/issues",
- "source": "https://github.com/symfony/maker-bundle/tree/v1.56.0"
+ "source": "https://github.com/symfony/maker-bundle/tree/v1.57.0"
},
"funding": [
{
@@ -10021,7 +10102,7 @@
"type": "tidelift"
}
],
- "time": "2024-03-04T13:36:45+00:00"
+ "time": "2024-03-22T12:00:21+00:00"
},
{
"name": "symfony/process",
diff --git a/api/config/bundles.php b/api/config/bundles.php
index 24080a7f5..aafe128b0 100644
--- a/api/config/bundles.php
+++ b/api/config/bundles.php
@@ -18,4 +18,5 @@
DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['all' => true],
Zenstruck\Foundry\ZenstruckFoundryBundle::class => ['all' => true],
+ Jose\Bundle\JoseFramework\JoseFrameworkBundle::class => ['all' => true],
];
diff --git a/api/config/packages/framework.yaml b/api/config/packages/framework.yaml
index f7ddf88a7..ec3dcaa22 100644
--- a/api/config/packages/framework.yaml
+++ b/api/config/packages/framework.yaml
@@ -24,6 +24,12 @@ framework:
php_errors:
log: true
+ http_client:
+ scoped_clients:
+ # use scoped client to ease mock on functional tests
+ security.authorization.client:
+ base_uri: '%env(OIDC_SERVER_URL_INTERNAL)%/'
+
when@test:
framework:
test: true
diff --git a/api/config/packages/jose.yaml b/api/config/packages/jose.yaml
new file mode 100644
index 000000000..1f1b7ab35
--- /dev/null
+++ b/api/config/packages/jose.yaml
@@ -0,0 +1,44 @@
+jose:
+ jws:
+ serializers:
+ oidc:
+ serializers: ['jws_compact']
+ is_public: true
+ loaders:
+ oidc:
+ serializers: ['jws_compact']
+ signature_algorithms: ['HS256', 'RS256', 'ES256']
+ header_checkers: ['alg', 'iat', 'nbf', 'exp', 'aud', 'iss']
+ is_public: true
+
+services:
+ _defaults:
+ autowire: true
+ autoconfigure: true
+
+ Jose\Component\Checker\AlgorithmChecker:
+ arguments:
+ $supportedAlgorithms: ['HS256', 'RS256', 'ES256']
+ tags:
+ - name: 'jose.checker.header'
+ alias: 'alg'
+ Jose\Component\Checker\AudienceChecker:
+ arguments:
+ $audience: '%env(OIDC_AUD)%'
+ tags:
+ - name: 'jose.checker.header'
+ alias: 'aud'
+ Jose\Component\Checker\IssuerChecker:
+ arguments:
+ $issuers: ['%env(OIDC_SERVER_URL)%']
+ tags:
+ - name: 'jose.checker.header'
+ alias: 'iss'
+
+when@test:
+ jose:
+ jws:
+ builders:
+ oidc:
+ signature_algorithms: ['HS256', 'RS256', 'ES256']
+ is_public: true
diff --git a/api/config/packages/security.yaml b/api/config/packages/security.yaml
index 55fb5f7b3..701d910cd 100644
--- a/api/config/packages/security.yaml
+++ b/api/config/packages/security.yaml
@@ -1,14 +1,8 @@
-parameters:
- app.oidc.jwk: '{"kty": "EC","d": "cT3_vKHaGOAhhmzR0Jbi1ko40dNtpjtaiWzm_7VNwLA","use": "sig","crv": "P-256","x": "n6PnJPqNK5nP-ymwwsOIqZvjiCKFNzRyqWA8KNyBsDo","y": "bQSmMlDXOmtgyS1rhsKUmqlxq-8Kw0Iw9t50cSloTMM","alg": "ES256"}'
- app.oidc.aud: 'api-platform'
-
security:
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
app_user_provider:
id: 'App\Security\Core\UserProvider'
- role_hierarchy:
- ROLE_ADMIN: ROLE_USER
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
@@ -34,10 +28,14 @@ when@prod: &prod
firewalls:
main:
access_token:
- token_handler:
- oidc_user_info:
- claim: email
- base_uri: '%env(OIDC_SERVER_URL_INTERNAL)%/protocol/openid-connect/userinfo'
+ token_handler: App\Security\Http\AccessToken\Oidc\OidcDiscoveryTokenHandler
+ # todo support Discovery in Symfony
+# oidc:
+# claim: 'email'
+# base_uri: '%env(OIDC_SERVER_URL)%'
+# audience: '%env(OIDC_AUD)%'
+# cache: '@cache.app' # default
+# cache_ttl: 600 # default
when@dev: *prod
@@ -48,17 +46,8 @@ when@test:
access_token:
token_handler:
oidc:
- claim: email
- audience: '%app.oidc.aud%'
+ claim: 'email'
+ audience: '%env(OIDC_AUD)%'
issuers: [ '%env(OIDC_SERVER_URL)%' ]
algorithm: 'ES256'
- key: '%app.oidc.jwk%'
- # required by App\Tests\Api\Trait\SecurityTrait
- parameters:
- app.oidc.issuer: '%env(OIDC_SERVER_URL)%'
- services:
- app.security.jwk:
- parent: 'security.access_token_handler.oidc.jwk'
- public: true
- arguments:
- $json: '%app.oidc.jwk%'
+ key: '%env(OIDC_JWK)%'
diff --git a/api/config/services.yaml b/api/config/services.yaml
index 2d6a76f94..f5e1a973d 100644
--- a/api/config/services.yaml
+++ b/api/config/services.yaml
@@ -22,3 +22,11 @@ services:
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
+
+when@test:
+ services:
+ App\Tests\Api\Security\:
+ resource: '../tests/Api/Security/'
+ autowire: true
+ autoconfigure: true
+ public: true
diff --git a/api/frankenphp/conf.d/app.ini b/api/frankenphp/conf.d/app.ini
index 79a17dd81..ebaf594fc 100644
--- a/api/frankenphp/conf.d/app.ini
+++ b/api/frankenphp/conf.d/app.ini
@@ -3,6 +3,7 @@ date.timezone = UTC
apc.enable_cli = 1
session.use_strict_mode = 1
zend.detect_unicode = 0
+memory_limit = 256M
; https://symfony.com/doc/current/performance.html
realpath_cache_size = 4096K
diff --git a/api/migrations/Version20230906094949.php b/api/migrations/Version20230906094949.php
index 4c37dbb6d..376622441 100644
--- a/api/migrations/Version20230906094949.php
+++ b/api/migrations/Version20230906094949.php
@@ -41,11 +41,9 @@ public function up(Schema $schema): void
$this->addSql('COMMENT ON COLUMN review.user_id IS \'(DC2Type:uuid)\'');
$this->addSql('COMMENT ON COLUMN review.book_id IS \'(DC2Type:uuid)\'');
$this->addSql('COMMENT ON COLUMN review.published_at IS \'(DC2Type:datetime_immutable)\'');
- $this->addSql('CREATE TABLE "user" (id UUID NOT NULL, sub UUID NOT NULL, email VARCHAR(255) NOT NULL, first_name VARCHAR(255) NOT NULL, last_name VARCHAR(255) NOT NULL, roles JSON NOT NULL, PRIMARY KEY(id))');
- $this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649580282DC ON "user" (sub)');
+ $this->addSql('CREATE TABLE "user" (id UUID NOT NULL, email VARCHAR(255) NOT NULL, first_name VARCHAR(255) NOT NULL, last_name VARCHAR(255) NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649E7927C74 ON "user" (email)');
$this->addSql('COMMENT ON COLUMN "user".id IS \'(DC2Type:uuid)\'');
- $this->addSql('COMMENT ON COLUMN "user".sub IS \'(DC2Type:uuid)\'');
$this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921DA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921D16A2B381 FOREIGN KEY (book_id) REFERENCES book (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE review ADD CONSTRAINT FK_794381C6A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
diff --git a/api/src/DataFixtures/Factory/ReviewFactory.php b/api/src/DataFixtures/Factory/ReviewFactory.php
index d22a35749..4ee375c5b 100644
--- a/api/src/DataFixtures/Factory/ReviewFactory.php
+++ b/api/src/DataFixtures/Factory/ReviewFactory.php
@@ -5,6 +5,7 @@
namespace App\DataFixtures\Factory;
use App\Entity\Review;
+use App\Security\Http\Protection\ResourceHandlerInterface;
use Doctrine\ORM\EntityRepository;
use Zenstruck\Foundry\ModelFactory;
use Zenstruck\Foundry\Proxy;
@@ -52,8 +53,9 @@ final class ReviewFactory extends ModelFactory
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services
*/
- public function __construct()
- {
+ public function __construct(
+ private readonly ?ResourceHandlerInterface $resourceHandler = null,
+ ) {
parent::__construct();
}
@@ -76,7 +78,21 @@ protected function getDefaults(): array
*/
protected function initialize(): self
{
- return $this;
+ return $this
+ // create the resource on the OIDC server
+ ->afterPersist(function (Review $object): void {
+ if (!$this->resourceHandler) {
+ return;
+ }
+
+ // project specification: only create resource on OIDC server for known users (john.doe and chuck.norris)
+ if (\in_array($object->user?->email, ['john.doe@example.com', 'chuck.norris@example.com'], true)) {
+ $this->resourceHandler->create($object, $object->user, [
+ 'operation_name' => '/books/{bookId}/reviews/{id}{._format}',
+ ]);
+ }
+ })
+ ;
// ->afterInstantiate(function(Review $review): void {})
}
diff --git a/api/src/DataFixtures/Factory/UserFactory.php b/api/src/DataFixtures/Factory/UserFactory.php
index 479bdb995..641114a55 100644
--- a/api/src/DataFixtures/Factory/UserFactory.php
+++ b/api/src/DataFixtures/Factory/UserFactory.php
@@ -6,7 +6,6 @@
use App\Entity\User;
use Doctrine\ORM\EntityRepository;
-use Symfony\Component\Uid\Uuid;
use Zenstruck\Foundry\ModelFactory;
use Zenstruck\Foundry\Proxy;
use Zenstruck\Foundry\RepositoryProxy;
@@ -58,7 +57,16 @@ public function __construct()
public static function createOneAdmin(array $attributes = []): Proxy|User
{
- return self::createOne(['roles' => ['ROLE_ADMIN']] + $attributes);
+ return self::new($attributes)->withAdmin()->create();
+ }
+
+ public function withAdmin(): self
+ {
+ return $this->addState([
+ 'email' => 'chuck.norris@example.com',
+ 'firstName' => 'Chuck',
+ 'lastName' => 'Norris',
+ ]);
}
/**
@@ -67,11 +75,9 @@ public static function createOneAdmin(array $attributes = []): Proxy|User
protected function getDefaults(): array
{
return [
- 'sub' => Uuid::v7(),
'email' => self::faker()->unique()->email(),
'firstName' => self::faker()->firstName(),
'lastName' => self::faker()->lastName(),
- 'roles' => ['ROLE_USER'],
];
}
diff --git a/api/src/DataFixtures/Story/DefaultStory.php b/api/src/DataFixtures/Story/DefaultStory.php
index 5c6227ce5..c87bee9f7 100644
--- a/api/src/DataFixtures/Story/DefaultStory.php
+++ b/api/src/DataFixtures/Story/DefaultStory.php
@@ -58,7 +58,6 @@ public function build(): void
'email' => 'john.doe@example.com',
'firstName' => 'John',
'lastName' => 'Doe',
- 'roles' => ['ROLE_USER'],
]);
// Default user has a review on the default book
@@ -87,11 +86,6 @@ public function build(): void
}
// Create admin user
- UserFactory::createOne([
- 'email' => 'chuck.norris@example.com',
- 'firstName' => 'Chuck',
- 'lastName' => 'Norris',
- 'roles' => ['ROLE_ADMIN'],
- ]);
+ UserFactory::createOneAdmin();
}
}
diff --git a/api/src/Entity/Book.php b/api/src/Entity/Book.php
index 74309f116..d1977e719 100644
--- a/api/src/Entity/Book.php
+++ b/api/src/Entity/Book.php
@@ -71,7 +71,7 @@
AbstractNormalizer::GROUPS => ['Book:write'],
],
collectDenormalizationErrors: true,
- security: 'is_granted("ROLE_ADMIN")'
+ security: 'is_granted("OIDC_ADMIN")'
)]
#[ApiResource(
types: ['https://schema.org/Book', 'https://schema.org/Offer'],
diff --git a/api/src/Entity/Bookmark.php b/api/src/Entity/Bookmark.php
index 4002398fe..1e315e849 100644
--- a/api/src/Entity/Bookmark.php
+++ b/api/src/Entity/Bookmark.php
@@ -36,7 +36,7 @@
operations: [
new GetCollection(),
new Delete(
- security: 'is_granted("ROLE_USER") and object.user === user'
+ security: 'object.user === user'
),
new Post(
processor: BookmarkPersistProcessor::class
@@ -54,7 +54,7 @@
],
collectDenormalizationErrors: true,
mercure: true,
- security: 'is_granted("ROLE_USER")'
+ security: 'is_granted("OIDC_USER")'
)]
#[ORM\Entity(repositoryClass: BookmarkRepository::class)]
#[ORM\UniqueConstraint(fields: ['user', 'book'])]
diff --git a/api/src/Entity/Review.php b/api/src/Entity/Review.php
index 74cdf6546..9c53b0b7b 100644
--- a/api/src/Entity/Review.php
+++ b/api/src/Entity/Review.php
@@ -16,6 +16,7 @@
use ApiPlatform\Metadata\Put;
use ApiPlatform\State\CreateProvider;
use App\Repository\ReviewRepository;
+use App\Security\Voter\OidcTokenPermissionVoter;
use App\Serializer\IriTransformerNormalizer;
use App\State\Processor\ReviewPersistProcessor;
use App\State\Processor\ReviewRemoveProcessor;
@@ -76,7 +77,7 @@
AbstractNormalizer::GROUPS => ['Review:write', 'Review:write:admin'],
],
collectDenormalizationErrors: true,
- security: 'is_granted("ROLE_ADMIN")'
+ security: 'is_granted("OIDC_ADMIN")'
)]
#[ApiResource(
types: ['https://schema.org/Review'],
@@ -98,7 +99,7 @@
]
),
new Post(
- security: 'is_granted("ROLE_USER")',
+ security: 'is_granted("OIDC_USER")',
// Mercure publish is done manually in MercureProcessor through ReviewPersistProcessor
processor: ReviewPersistProcessor::class,
provider: CreateProvider::class,
@@ -111,7 +112,8 @@
'bookId' => new Link(toProperty: 'book', fromClass: Book::class),
'id' => new Link(fromClass: Review::class),
],
- security: 'is_granted("ROLE_USER") and user == object.user',
+ /** @see OidcTokenPermissionVoter */
+ security: 'is_granted("OIDC_USER", request.getRequestUri())',
// Mercure publish is done manually in MercureProcessor through ReviewPersistProcessor
processor: ReviewPersistProcessor::class
),
@@ -121,7 +123,8 @@
'bookId' => new Link(toProperty: 'book', fromClass: Book::class),
'id' => new Link(fromClass: Review::class),
],
- security: 'is_granted("ROLE_USER") and user == object.user',
+ /** @see OidcTokenPermissionVoter */
+ security: 'is_granted("OIDC_USER", request.getRequestUri())',
// Mercure publish is done manually in MercureProcessor through ReviewRemoveProcessor
processor: ReviewRemoveProcessor::class
),
diff --git a/api/src/Entity/User.php b/api/src/Entity/User.php
index f9e2e6fb1..2832c1623 100644
--- a/api/src/Entity/User.php
+++ b/api/src/Entity/User.php
@@ -30,17 +30,17 @@
new GetCollection(
uriTemplate: '/admin/users{._format}',
itemUriTemplate: '/admin/users/{id}{._format}',
- security: 'is_granted("ROLE_ADMIN")',
+ security: 'is_granted("OIDC_ADMIN")',
filters: ['app.filter.user.admin.name'],
paginationClientItemsPerPage: true
),
new Get(
uriTemplate: '/admin/users/{id}{._format}',
- security: 'is_granted("ROLE_ADMIN")'
+ security: 'is_granted("OIDC_ADMIN")'
),
new Get(
uriTemplate: '/users/{id}{._format}',
- security: 'is_granted("ROLE_USER") and object.getUserIdentifier() === user.getUserIdentifier()'
+ security: 'object === user'
),
],
normalizationContext: [
@@ -63,14 +63,6 @@ class User implements UserInterface
#[ORM\Id]
private ?Uuid $id = null;
- /**
- * @see https://schema.org/identifier
- */
- #[ApiProperty(types: ['https://schema.org/identifier'])]
- #[Groups(groups: ['User:read', 'Review:read'])]
- #[ORM\Column(type: UuidType::NAME, unique: true)]
- public ?Uuid $sub = null;
-
/**
* @see https://schema.org/email
*/
@@ -93,9 +85,6 @@ class User implements UserInterface
#[ORM\Column]
public ?string $lastName = null;
- #[ORM\Column(type: 'json')]
- public array $roles = [];
-
public function getId(): ?Uuid
{
return $this->id;
@@ -110,7 +99,7 @@ public function eraseCredentials(): void
*/
public function getRoles(): array
{
- return $this->roles;
+ return ['ROLE_USER'];
}
public function getUserIdentifier(): string
diff --git a/api/src/Security/Core/UserProvider.php b/api/src/Security/Core/UserProvider.php
index 55b27e36e..c0c641649 100644
--- a/api/src/Security/Core/UserProvider.php
+++ b/api/src/Security/Core/UserProvider.php
@@ -10,7 +10,6 @@
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\User\AttributesBasedUserProviderInterface;
use Symfony\Component\Security\Core\User\UserInterface;
-use Symfony\Component\Uid\Uuid;
/**
* @implements AttributesBasedUserProviderInterface
@@ -46,15 +45,6 @@ public function loadUserByIdentifier(string $identifier, array $attributes = [])
$user = $this->repository->findOneBy(['email' => $identifier]) ?: new User();
$user->email = $identifier;
- if (!isset($attributes['sub'])) {
- throw new UnsupportedUserException('Property "sub" is missing in token attributes.');
- }
- try {
- $user->sub = Uuid::fromString($attributes['sub']);
- } catch (\Throwable $e) {
- throw new UnsupportedUserException($e->getMessage(), $e->getCode(), $e);
- }
-
if (!isset($attributes['given_name'])) {
throw new UnsupportedUserException('Property "given_name" is missing in token attributes.');
}
diff --git a/api/src/Security/Http/AccessToken/Oidc/OidcDiscoveryTokenHandler.php b/api/src/Security/Http/AccessToken/Oidc/OidcDiscoveryTokenHandler.php
new file mode 100644
index 000000000..6bf7dbe3d
--- /dev/null
+++ b/api/src/Security/Http/AccessToken/Oidc/OidcDiscoveryTokenHandler.php
@@ -0,0 +1,104 @@
+cache->get('oidc.configuration', function (ItemInterface $item): string {
+ $item->expiresAfter($this->ttl);
+ $response = $this->securityAuthorizationClient->request('GET', '.well-known/openid-configuration');
+
+ return $response->getContent();
+ }), true, 512, \JSON_THROW_ON_ERROR);
+ } catch (\Throwable $e) {
+ $this->logger?->error('An error occurred while requesting OIDC configuration.', [
+ 'error' => $e->getMessage(),
+ 'trace' => $e->getTraceAsString(),
+ ]);
+
+ throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e);
+ }
+
+ try {
+ $keyset = JWKSet::createFromJson(
+ $this->cache->get('oidc.jwkSet', function (ItemInterface $item) use ($oidcConfiguration): string {
+ $item->expiresAfter($this->ttl);
+ $response = $this->securityAuthorizationClient->request('GET', $oidcConfiguration['jwks_uri']);
+ // we only need signature key
+ $keys = array_filter($response->toArray()['keys'], static fn (array $key) => 'sig' === $key['use']);
+
+ return json_encode(['keys' => $keys]);
+ })
+ );
+ } catch (\Throwable $e) {
+ $this->logger?->error('An error occurred while requesting OIDC certs.', [
+ 'error' => $e->getMessage(),
+ 'trace' => $e->getTraceAsString(),
+ ]);
+
+ throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e);
+ }
+
+ try {
+ // Decode the token
+ $signature = null;
+ $jws = $this->jwsLoader->loadAndVerifyWithKeySet(
+ token: $accessToken,
+ keyset: $keyset,
+ signature: $signature,
+ );
+
+ $claims = json_decode($jws->getPayload(), true);
+ if (empty($claims[$this->claim])) {
+ throw new MissingClaimException(sprintf('"%s" claim not found.', $this->claim));
+ }
+
+ // UserLoader argument can be overridden by a UserProvider on AccessTokenAuthenticator::authenticate
+ return new UserBadge($claims[$this->claim], new FallbackUserLoader(fn () => $this->createUser($claims)), $claims);
+ } catch (\Throwable $e) {
+ $this->logger?->error('An error occurred while decoding and validating the token.', [
+ 'error' => $e->getMessage(),
+ 'trace' => $e->getTraceAsString(),
+ ]);
+
+ throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e);
+ }
+ }
+}
diff --git a/api/src/Security/Http/Protection/ResourceHandlerInterface.php b/api/src/Security/Http/Protection/ResourceHandlerInterface.php
new file mode 100644
index 000000000..b9776bb3e
--- /dev/null
+++ b/api/src/Security/Http/Protection/ResourceHandlerInterface.php
@@ -0,0 +1,31 @@
+resourceMetadataCollectionFactory->create(resourceClass: $resource::class)->getOperation(
+ operationName: $context['operation_name'] ?? null,
+ httpOperation: true,
+ );
+ $shortName = strtolower(preg_replace('~(?<=\\w)([A-Z])~', '-$1', $operation->getShortName()));
+ $resourceIri = $this->iriConverter->getIriFromResource(
+ resource: $resource,
+ referenceType: UrlGeneratorInterface::ABS_PATH,
+ operation: $operation,
+ );
+
+ // create resource_set on OIDC server
+ $this->securityAuthorizationClient->request('POST', $this->getResourceRegistrationEndpoint(), [
+ 'auth_bearer' => $this->getPAT(),
+ 'json' => [
+ 'name' => sprintf('%s_%s', $shortName, $resource->getId()->__toString()),
+ 'displayName' => sprintf('%s #%s', $operation->getShortName(), $resource->getId()->__toString()),
+ 'uris' => [$resourceIri],
+ 'type' => sprintf('urn:%s:resources:%s', $this->oidcClientId, $shortName),
+ 'owner' => $owner->getUserIdentifier(),
+ ],
+ ]);
+ }
+
+ public function delete(object $resource, UserInterface $owner, array $context = []): void
+ {
+ $operation = $this->resourceMetadataCollectionFactory->create(resourceClass: $resource::class)->getOperation(
+ operationName: $context['operation_name'] ?? null,
+ httpOperation: true,
+ );
+ $shortName = strtolower(preg_replace('~(?<=\\w)([A-Z])~', '-$1', $operation->getShortName()));
+ $resourceIri = $this->iriConverter->getIriFromResource(
+ resource: $resource,
+ referenceType: UrlGeneratorInterface::ABS_PATH,
+ operation: $operation,
+ );
+
+ // retrieve corresponding resource_set from OIDC server
+ $response = $this->securityAuthorizationClient->request(
+ 'GET',
+ $this->getResourceRegistrationEndpoint(),
+ [
+ 'auth_bearer' => $this->getPAT(),
+ 'query' => [
+ 'deep' => 'true',
+ 'first' => 0,
+ 'max' => 1,
+ 'uri' => $resourceIri,
+ 'owner' => $owner->getUserIdentifier(),
+ 'type' => sprintf('urn:%s:resources:%s', $this->oidcClientId, $shortName),
+ ],
+ ]
+ );
+ $content = $response->toArray();
+ $resourceSet = $content[0];
+
+ // delete corresponding resource_set on OIDC server
+ $this->securityAuthorizationClient->request(
+ 'DELETE',
+ sprintf('%s/%s', $this->getResourceRegistrationEndpoint(), $resourceSet['_id']),
+ [
+ 'auth_bearer' => $this->getPAT(),
+ ]
+ );
+ }
+
+ /**
+ * @see https://www.keycloak.org/docs/latest/authorization_services/index.html#_service_protection_whatis_obtain_pat
+ */
+ private function getPAT(): string
+ {
+ $response = $this->securityAuthorizationClient->request('POST', $this->getTokenEndpoint(), [
+ 'body' => [
+ 'grant_type' => 'client_credentials',
+ 'client_id' => $this->oidcClientId,
+ 'client_secret' => $this->oidcClientSecret,
+ ],
+ ]);
+ $content = $response->toArray();
+
+ return $content['access_token'];
+ }
+
+ private function getTokenEndpoint(): string
+ {
+ $response = $this->securityAuthorizationClient->request('GET', '.well-known/openid-configuration');
+ $content = $response->toArray();
+
+ return $content['token_endpoint'];
+ }
+
+ private function getResourceRegistrationEndpoint(): string
+ {
+ $response = $this->securityAuthorizationClient->request('GET', '.well-known/uma2-configuration');
+ $content = $response->toArray();
+
+ return $content['resource_registration_endpoint'];
+ }
+}
diff --git a/api/src/Security/Voter/OidcRoleVoter.php b/api/src/Security/Voter/OidcRoleVoter.php
new file mode 100644
index 000000000..a09948769
--- /dev/null
+++ b/api/src/Security/Voter/OidcRoleVoter.php
@@ -0,0 +1,59 @@
+getUser() instanceof UserInterface) {
+ return false;
+ }
+
+ $accessToken = $this->getToken();
+ if (!$accessToken) {
+ return false;
+ }
+
+ // OIDC server doesn't seem to answer: check roles in token (if present)
+ $jws = $this->jwsSerializerManager->unserialize($accessToken);
+ $claims = json_decode($jws->getPayload(), true);
+ $roles = array_map(static fn (string $role): string => strtolower($role), $claims['realm_access']['roles'] ?? []);
+
+ return \in_array(strtolower(substr($attribute, 5)), $roles, true);
+ }
+}
diff --git a/api/src/Security/Voter/OidcTokenIntrospectRoleVoter.php b/api/src/Security/Voter/OidcTokenIntrospectRoleVoter.php
new file mode 100644
index 000000000..f81df1619
--- /dev/null
+++ b/api/src/Security/Voter/OidcTokenIntrospectRoleVoter.php
@@ -0,0 +1,82 @@
+getUser() instanceof UserInterface) {
+ return false;
+ }
+
+ $accessToken = $this->getToken();
+ if (!$accessToken) {
+ return false;
+ }
+
+ try {
+ $response = $this->securityAuthorizationClient->request('POST', 'protocol/openid-connect/token/introspect', [
+ 'body' => [
+ 'client_id' => $this->oidcClientId,
+ 'client_secret' => $this->oidcClientSecret,
+ 'token' => $accessToken,
+ ],
+ ]);
+
+ $roles = array_map(static fn (string $role): string => strtolower($role), $response->toArray()['realm_access']['roles'] ?? []);
+
+ return \in_array(strtolower(substr($attribute, 5)), $roles, true);
+ } catch (HttpExceptionInterface) {
+ // OIDC server said no!
+ } catch (ExceptionInterface $e) {
+ $this->logger?->error('An error occurred while checking the roles on OIDC server.', [
+ 'error' => $e->getMessage(),
+ 'trace' => $e->getTraceAsString(),
+ ]);
+ }
+
+ return false;
+ }
+}
diff --git a/api/src/Security/Voter/OidcTokenPermissionVoter.php b/api/src/Security/Voter/OidcTokenPermissionVoter.php
new file mode 100644
index 000000000..e97b8748d
--- /dev/null
+++ b/api/src/Security/Voter/OidcTokenPermissionVoter.php
@@ -0,0 +1,88 @@
+iriConverter->getIriFromResource($subject);
+ }
+
+ if (!\is_string($subject)) {
+ throw new \InvalidArgumentException(sprintf('Invalid subject type, expected "string" or "object", got "%s".', get_debug_type($subject)));
+ }
+
+ // ensure user is authenticated
+ if (!$token->getUser() instanceof UserInterface) {
+ return false;
+ }
+
+ $accessToken = $this->getToken();
+ if (!$accessToken) {
+ return false;
+ }
+
+ try {
+ $response = $this->securityAuthorizationClient->request('POST', 'protocol/openid-connect/token', [
+ 'auth_bearer' => $accessToken,
+ 'body' => [
+ 'grant_type' => 'urn:ietf:params:oauth:grant-type:uma-ticket',
+ 'audience' => $this->oidcClientId,
+ 'response_mode' => 'decision',
+ 'permission_resource_format' => 'uri',
+ 'permission_resource_matching_uri' => true,
+ 'permission' => sprintf('%s', $subject),
+ ],
+ ]);
+
+ return $response->toArray()['result'] ?? false;
+ } catch (HttpExceptionInterface) {
+ // OIDC server said no!
+ } catch (ExceptionInterface $e) {
+ $this->logger?->error('An error occurred while checking the permissions on OIDC server.', [
+ 'error' => $e->getMessage(),
+ 'trace' => $e->getTraceAsString(),
+ ]);
+ }
+
+ return false;
+ }
+}
diff --git a/api/src/Security/Voter/OidcVoter.php b/api/src/Security/Voter/OidcVoter.php
new file mode 100644
index 000000000..c435b5de5
--- /dev/null
+++ b/api/src/Security/Voter/OidcVoter.php
@@ -0,0 +1,37 @@
+requestStack->getCurrentRequest();
+
+ // user is authenticated, its token should be valid (validated through AccessTokenAuthenticator)
+ $accessToken = $this->accessTokenExtractor->extractAccessToken($request);
+ if (!$accessToken) {
+ throw new TokenNotFoundException();
+ }
+
+ return $accessToken;
+ }
+}
diff --git a/api/src/State/Processor/ReviewPersistProcessor.php b/api/src/State/Processor/ReviewPersistProcessor.php
index 867433f70..5ffe57613 100644
--- a/api/src/State/Processor/ReviewPersistProcessor.php
+++ b/api/src/State/Processor/ReviewPersistProcessor.php
@@ -9,6 +9,7 @@
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Review;
+use App\Security\Http\Protection\ResourceHandlerInterface;
use Psr\Clock\ClockInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -28,7 +29,8 @@ public function __construct(
#[Autowire(service: MercureProcessor::class)]
private ProcessorInterface $mercureProcessor,
private Security $security,
- private ClockInterface $clock
+ private ClockInterface $clock,
+ private ResourceHandlerInterface $resourceHandler,
) {
}
@@ -53,6 +55,16 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
// save entity
$data = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
+ // create resource on OIDC server
+ if ($operation instanceof Post) {
+ // project specification: only create resource on OIDC server for known users (john.doe and chuck.norris)
+ if (\in_array($data->user->email, ['john.doe@example.com', 'chuck.norris@example.com'], true)) {
+ $this->resourceHandler->create($data, $data->user, [
+ 'operation_name' => '/books/{bookId}/reviews/{id}{._format}',
+ ]);
+ }
+ }
+
// publish on Mercure
foreach (['/admin/reviews/{id}{._format}', '/books/{bookId}/reviews/{id}{._format}'] as $uriTemplate) {
$this->mercureProcessor->process(
diff --git a/api/src/State/Processor/ReviewRemoveProcessor.php b/api/src/State/Processor/ReviewRemoveProcessor.php
index bf91259e8..a0dccbd3b 100644
--- a/api/src/State/Processor/ReviewRemoveProcessor.php
+++ b/api/src/State/Processor/ReviewRemoveProcessor.php
@@ -11,6 +11,7 @@
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Review;
+use App\Security\Http\Protection\ResourceHandlerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
@@ -28,7 +29,8 @@ public function __construct(
#[Autowire(service: MercureProcessor::class)]
private ProcessorInterface $mercureProcessor,
private ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory,
- private IriConverterInterface $iriConverter
+ private IriConverterInterface $iriConverter,
+ private ResourceHandlerInterface $resourceHandler,
) {
}
@@ -42,6 +44,13 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
// remove entity
$this->removeProcessor->process($data, $operation, $uriVariables, $context);
+ // project specification: only delete resource on OIDC server for known users (john.doe and chuck.norris)
+ if (\in_array($object->user->email, ['john.doe@example.com', 'chuck.norris@example.com'], true)) {
+ $this->resourceHandler->delete($object, $object->user, [
+ 'operation_name' => '/books/{bookId}/reviews/{id}{._format}',
+ ]);
+ }
+
// publish on Mercure
foreach (['/admin/reviews/{id}{._format}', '/books/{bookId}/reviews/{id}{._format}'] as $uriTemplate) {
$iri = $this->iriConverter->getIriFromResource(
diff --git a/api/symfony.lock b/api/symfony.lock
index 95fab5157..dcb642d36 100644
--- a/api/symfony.lock
+++ b/api/symfony.lock
@@ -522,6 +522,15 @@
"twig/twig": {
"version": "v3.3.7"
},
+ "web-token/jwt-bundle": {
+ "version": "3.3",
+ "recipe": {
+ "repo": "github.com/Spomky-Labs/recipes",
+ "branch": "tree",
+ "version": "3.0",
+ "ref": "e9872ca728053c5a09ef09ec4712d430f30895d6"
+ }
+ },
"willdurand/negotiation": {
"version": "3.0.0"
},
diff --git a/api/tests/Api/Admin/BookTest.php b/api/tests/Api/Admin/BookTest.php
index 1c1307113..e2b0f6785 100644
--- a/api/tests/Api/Admin/BookTest.php
+++ b/api/tests/Api/Admin/BookTest.php
@@ -12,7 +12,7 @@
use App\Enum\BookCondition;
use App\Repository\BookRepository;
use App\Tests\Api\Admin\Trait\UsersDataProviderTrait;
-use App\Tests\Api\Trait\SecurityTrait;
+use App\Tests\Api\Security\TokenGenerator;
use App\Tests\Api\Trait\SerializerTrait;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
@@ -26,7 +26,6 @@ final class BookTest extends ApiTestCase
{
use Factories;
use ResetDatabase;
- use SecurityTrait;
use SerializerTrait;
use UsersDataProviderTrait;
@@ -43,7 +42,7 @@ public function asNonAdminUserICannotGetACollectionOfBooks(int $expectedCode, st
{
$options = [];
if ($userFactory) {
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => $userFactory->create()->email,
]);
$options['auth_bearer'] = $token;
@@ -68,7 +67,7 @@ public function asAdminUserICanGetACollectionOfBooks(FactoryCollection $factory,
// Cannot use Factory as data provider because BookFactory has a service dependency
$factory->create();
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -135,7 +134,7 @@ public function asAdminUserICanGetACollectionOfBooksOrderedByTitle(): void
BookFactory::createOne(['title' => 'The Wandering Earth']);
BookFactory::createOne(['title' => 'Ball Lightning']);
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -157,7 +156,7 @@ public function asAnyUserICannotGetAnInvalidBook(?UserFactory $userFactory): voi
$options = [];
if ($userFactory) {
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => $userFactory->create()->email,
]);
$options['auth_bearer'] = $token;
@@ -172,7 +171,7 @@ public static function getAllUsers(): iterable
{
yield [null];
yield [UserFactory::new()];
- yield [UserFactory::new(['roles' => ['ROLE_ADMIN']])];
+ yield [UserFactory::new()->withAdmin()];
}
#[Test]
@@ -183,7 +182,7 @@ public function asNonAdminUserICannotGetABook(int $expectedCode, string $hydraDe
$options = [];
if ($userFactory) {
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => $userFactory->create()->email,
]);
$options['auth_bearer'] = $token;
@@ -207,7 +206,7 @@ public function asAdminUserICanGetABook(): void
{
$book = BookFactory::createOne();
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -231,7 +230,7 @@ public function asNonAdminUserICannotCreateABook(int $expectedCode, string $hydr
{
$options = [];
if ($userFactory) {
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => $userFactory->create()->email,
]);
$options['auth_bearer'] = $token;
@@ -262,7 +261,7 @@ public function asNonAdminUserICannotCreateABook(int $expectedCode, string $hydr
#[DataProvider(methodName: 'getInvalidDataOnCreate')]
public function asAdminUserICannotCreateABookWithInvalidData(array $data, int $statusCode, array $expected): void
{
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -368,7 +367,7 @@ public static function getInvalidData(): iterable
#[Test]
public function asAdminUserICanCreateABook(): void
{
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -429,7 +428,7 @@ public function asNonAdminUserICannotUpdateBook(int $expectedCode, string $hydra
$options = [];
if ($userFactory) {
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => $userFactory->create()->email,
]);
$options['auth_bearer'] = $token;
@@ -461,7 +460,7 @@ public function asAdminUserICannotUpdateAnInvalidBook(): void
{
BookFactory::createOne();
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -485,7 +484,7 @@ public function asAdminUserICannotUpdateABookWithInvalidData(array $data, int $s
{
$book = BookFactory::createOne();
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -516,7 +515,7 @@ public function asAdminUserICanUpdateABook(): void
]);
self::getMercureHub()->reset();
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -576,7 +575,7 @@ public function asNonAdminUserICannotDeleteABook(int $expectedCode, string $hydr
$options = [];
if ($userFactory) {
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => $userFactory->create()->email,
]);
$options['auth_bearer'] = $token;
@@ -599,7 +598,7 @@ public function asAdminUserICannotDeleteAnInvalidBook(): void
{
BookFactory::createOne();
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -618,7 +617,7 @@ public function asAdminUserICanDeleteABook(): void
self::getMercureHub()->reset();
$id = $book->getId();
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
diff --git a/api/tests/Api/Admin/ReviewTest.php b/api/tests/Api/Admin/ReviewTest.php
index d9e24c92b..b761be05f 100644
--- a/api/tests/Api/Admin/ReviewTest.php
+++ b/api/tests/Api/Admin/ReviewTest.php
@@ -13,7 +13,7 @@
use App\Entity\Review;
use App\Entity\User;
use App\Tests\Api\Admin\Trait\UsersDataProviderTrait;
-use App\Tests\Api\Trait\SecurityTrait;
+use App\Tests\Api\Security\TokenGenerator;
use App\Tests\Api\Trait\SerializerTrait;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
@@ -27,7 +27,6 @@ final class ReviewTest extends ApiTestCase
{
use Factories;
use ResetDatabase;
- use SecurityTrait;
use SerializerTrait;
use UsersDataProviderTrait;
@@ -44,7 +43,7 @@ public function asNonAdminUserICannotGetACollectionOfReviews(int $expectedCode,
{
$options = [];
if ($userFactory) {
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => $userFactory->create()->email,
]);
$options['auth_bearer'] = $token;
@@ -68,7 +67,7 @@ public function asAdminUserICanGetACollectionOfReviews(FactoryCollection $factor
{
$factory->create();
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -151,7 +150,7 @@ public function asNonAdminUserICannotGetAReview(int $expectedCode, string $hydra
$options = [];
if ($userFactory) {
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => $userFactory->create()->email,
]);
$options['auth_bearer'] = $token;
@@ -172,7 +171,7 @@ public function asNonAdminUserICannotGetAReview(int $expectedCode, string $hydra
#[Test]
public function asAdminUserICannotGetAnInvalidReview(): void
{
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -186,7 +185,7 @@ public function asAdminUserICanGetAReview(): void
{
$review = ReviewFactory::createOne();
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -205,7 +204,7 @@ public function asNonAdminUserICannotUpdateAReview(int $expectedCode, string $hy
$options = [];
if ($userFactory) {
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => $userFactory->create()->email,
]);
$options['auth_bearer'] = $token;
@@ -235,7 +234,7 @@ public function asNonAdminUserICannotUpdateAReview(int $expectedCode, string $hy
#[Test]
public function asAdminUserICannotUpdateAnInvalidReview(): void
{
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -264,7 +263,7 @@ public function asAdminUserICanUpdateAReview(): void
$review = ReviewFactory::createOne(['book' => $book]);
$user = UserFactory::createOneAdmin();
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => $user->email,
]);
@@ -325,7 +324,7 @@ public function asNonAdminUserICannotDeleteAReview(int $expectedCode, string $hy
$options = [];
if ($userFactory) {
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => $userFactory->create()->email,
]);
$options['auth_bearer'] = $token;
@@ -346,7 +345,7 @@ public function asNonAdminUserICannotDeleteAReview(int $expectedCode, string $hy
#[Test]
public function asAdminUserICannotDeleteAnInvalidReview(): void
{
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -365,7 +364,7 @@ public function asAdminUserICanDeleteAReview(): void
$id = $review->getId();
$bookId = $review->book->getId();
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
diff --git a/api/tests/Api/Admin/UserTest.php b/api/tests/Api/Admin/UserTest.php
index d493df09e..4aaf80eb6 100644
--- a/api/tests/Api/Admin/UserTest.php
+++ b/api/tests/Api/Admin/UserTest.php
@@ -9,10 +9,9 @@
use App\DataFixtures\Factory\UserFactory;
use App\Repository\UserRepository;
use App\Tests\Api\Admin\Trait\UsersDataProviderTrait;
-use App\Tests\Api\Trait\SecurityTrait;
+use App\Tests\Api\Security\TokenGenerator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
-use Symfony\Component\Uid\Uuid;
use Zenstruck\Foundry\FactoryCollection;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
@@ -21,14 +20,13 @@ final class UserTest extends ApiTestCase
{
use Factories;
use ResetDatabase;
- use SecurityTrait;
use UsersDataProviderTrait;
private Client $client;
protected function setup(): void
{
- $this->client = self::createClient();
+ $this->client = self::createClient(['debug' => true]);
}
#[Test]
@@ -37,7 +35,7 @@ public function asNonAdminUserICannotGetACollectionOfUsers(int $expectedCode, st
{
$options = [];
if ($userFactory) {
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => $userFactory->create()->email,
]);
$options['auth_bearer'] = $token;
@@ -61,7 +59,7 @@ public function asAdminUserICanGetACollectionOfUsers(FactoryCollection $factory,
{
$factory->create();
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -113,7 +111,7 @@ public function asNonAdminUserICannotGetAUser(int $expectedCode, string $hydraDe
$options = [];
if ($userFactory) {
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => $userFactory->create()->email,
]);
$options['auth_bearer'] = $token;
@@ -136,7 +134,7 @@ public function asAdminUserICanGetAUser(): void
{
$user = UserFactory::createOne();
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => UserFactory::createOneAdmin()->email,
]);
@@ -157,12 +155,9 @@ public function asAUserIAmUpdatedOnLogin(): void
$user = UserFactory::createOne([
'firstName' => 'John',
'lastName' => 'DOE',
- 'sub' => Uuid::fromString('b5c5bff1-5b5f-4a73-8fc8-4ea8f18586a9'),
])->disableAutoRefresh();
- $sub = Uuid::v7()->__toString();
- $token = $this->generateToken([
- 'sub' => $sub,
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => $user->email,
'given_name' => 'Chuck',
'family_name' => 'NORRIS',
@@ -177,6 +172,5 @@ public function asAUserIAmUpdatedOnLogin(): void
self::assertEquals('Chuck', $user->firstName);
self::assertEquals('NORRIS', $user->lastName);
self::assertEquals('Chuck NORRIS', $user->getName());
- self::assertEquals($sub, $user->sub);
}
}
diff --git a/api/tests/Api/Admin/schemas/Review/collection.json b/api/tests/Api/Admin/schemas/Review/collection.json
index 181231e03..6a1f327d4 100644
--- a/api/tests/Api/Admin/schemas/Review/collection.json
+++ b/api/tests/Api/Admin/schemas/Review/collection.json
@@ -131,12 +131,6 @@
"type": "string",
"pattern": "^https://schema.org/Person$"
},
- "sub": {
- "externalDocs": {
- "url": "https:\/\/schema.org\/identifier"
- },
- "type": "string"
- },
"firstName": {
"description": "The givenName of the person",
"externalDocs": {
@@ -162,7 +156,6 @@
"required": [
"@id",
"@type",
- "sub",
"firstName",
"lastName",
"name"
diff --git a/api/tests/Api/Admin/schemas/Review/item.json b/api/tests/Api/Admin/schemas/Review/item.json
index f2e6902e6..3f81816f5 100644
--- a/api/tests/Api/Admin/schemas/Review/item.json
+++ b/api/tests/Api/Admin/schemas/Review/item.json
@@ -38,12 +38,6 @@
"type": "string",
"pattern": "^https://schema.org/Person$"
},
- "sub": {
- "externalDocs": {
- "url": "https:\/\/schema.org\/identifier"
- },
- "type": "string"
- },
"firstName": {
"description": "The givenName of the person",
"externalDocs": {
@@ -69,7 +63,6 @@
"required": [
"@id",
"@type",
- "sub",
"firstName",
"lastName",
"name"
diff --git a/api/tests/Api/Admin/schemas/User/collection.json b/api/tests/Api/Admin/schemas/User/collection.json
index 915885e01..f94e49ad0 100644
--- a/api/tests/Api/Admin/schemas/User/collection.json
+++ b/api/tests/Api/Admin/schemas/User/collection.json
@@ -17,12 +17,6 @@
"type": "string",
"pattern": "^https://schema.org/Person$"
},
- "sub": {
- "externalDocs": {
- "url": "https:\/\/schema.org\/identifier"
- },
- "type": "string"
- },
"firstName": {
"description": "The givenName of the person",
"externalDocs": {
@@ -48,7 +42,6 @@
"required": [
"@id",
"@type",
- "sub",
"firstName",
"lastName",
"name"
diff --git a/api/tests/Api/Admin/schemas/User/item.json b/api/tests/Api/Admin/schemas/User/item.json
index 0c24d77f0..945d5b051 100644
--- a/api/tests/Api/Admin/schemas/User/item.json
+++ b/api/tests/Api/Admin/schemas/User/item.json
@@ -18,12 +18,6 @@
"type": "string",
"pattern": "^https://schema.org/Person$"
},
- "sub": {
- "externalDocs": {
- "url": "https:\/\/schema.org\/identifier"
- },
- "type": "string"
- },
"firstName": {
"description": "The givenName of the person",
"externalDocs": {
@@ -50,7 +44,6 @@
"@context",
"@id",
"@type",
- "sub",
"firstName",
"lastName",
"name"
diff --git a/api/tests/Api/BookmarkTest.php b/api/tests/Api/BookmarkTest.php
index 796b5842f..679a12937 100644
--- a/api/tests/Api/BookmarkTest.php
+++ b/api/tests/Api/BookmarkTest.php
@@ -12,7 +12,7 @@
use App\Entity\Book;
use App\Entity\Bookmark;
use App\Repository\BookmarkRepository;
-use App\Tests\Api\Trait\SecurityTrait;
+use App\Tests\Api\Security\TokenGenerator;
use App\Tests\Api\Trait\SerializerTrait;
use PHPUnit\Framework\Attributes\Test;
use Symfony\Component\HttpFoundation\Response;
@@ -25,7 +25,6 @@ final class BookmarkTest extends ApiTestCase
{
use Factories;
use ResetDatabase;
- use SecurityTrait;
use SerializerTrait;
private Client $client;
@@ -62,8 +61,9 @@ public function asAUserICanGetACollectionOfMyBookmarksWithoutFilters(): void
$user = UserFactory::createOne();
BookmarkFactory::createMany(35, ['user' => $user]);
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => $user->email,
+ 'authorize' => true,
]);
$response = $this->client->request('GET', '/bookmarks', ['auth_bearer' => $token]);
@@ -105,8 +105,9 @@ public function asAnonymousICannotCreateABookmark(): void
#[Test]
public function asAUserICannotCreateABookmarkWithInvalidData(): void
{
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => UserFactory::createOne()->email,
+ 'authorize' => true,
]);
$uuid = Uuid::v7()->__toString();
@@ -148,8 +149,9 @@ public function asAUserICanCreateABookmark(): void
$user = UserFactory::createOne();
self::getMercureHub()->reset();
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => $user->email,
+ 'authorize' => true,
]);
$response = $this->client->request('POST', '/bookmarks', [
@@ -194,8 +196,9 @@ public function asAUserICannotCreateADuplicateBookmark(): void
$user = UserFactory::createOne();
BookmarkFactory::createOne(['book' => $book, 'user' => $user]);
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => $user->email,
+ 'authorize' => true,
]);
$this->client->request('POST', '/bookmarks', [
@@ -241,8 +244,9 @@ public function asAUserICannotDeleteABookmarkOfAnotherUser(): void
{
$bookmark = BookmarkFactory::createOne(['user' => UserFactory::createOne()]);
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => UserFactory::createOne()->email,
+ 'authorize' => false,
]);
$this->client->request('DELETE', '/bookmarks/' . $bookmark->getId(), [
@@ -262,7 +266,7 @@ public function asAUserICannotDeleteABookmarkOfAnotherUser(): void
#[Test]
public function asAUserICannotDeleteAnInvalidBookmark(): void
{
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => UserFactory::createOne()->email,
]);
@@ -285,8 +289,9 @@ public function asAUserICanDeleteMyBookmark(): void
$id = $bookmark->getId();
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => $bookmark->user->email,
+ 'authorize' => true,
]);
$response = $this->client->request('DELETE', '/bookmarks/' . $bookmark->getId(), [
diff --git a/api/tests/Api/ReviewTest.php b/api/tests/Api/ReviewTest.php
index 686b607b9..1a6ee8468 100644
--- a/api/tests/Api/ReviewTest.php
+++ b/api/tests/Api/ReviewTest.php
@@ -13,7 +13,7 @@
use App\Entity\Review;
use App\Entity\User;
use App\Repository\ReviewRepository;
-use App\Tests\Api\Trait\SecurityTrait;
+use App\Tests\Api\Security\TokenGenerator;
use App\Tests\Api\Trait\SerializerTrait;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
@@ -27,7 +27,6 @@ final class ReviewTest extends ApiTestCase
{
use Factories;
use ResetDatabase;
- use SecurityTrait;
use SerializerTrait;
private Client $client;
@@ -162,8 +161,9 @@ public function asAUserICannotAddAReviewOnABookWithInvalidData(array $data, int
{
$book = BookFactory::createOne();
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => UserFactory::createOne()->email,
+ 'authorize' => true,
]);
$this->client->request('POST', '/books/' . $book->getId() . '/reviews', [
@@ -210,7 +210,7 @@ public function asAUserICannotAddAReviewWithValidDataOnAnInvalidBook(): void
ReviewFactory::createMany(5, ['book' => $book]);
$user = UserFactory::createOne();
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => $user->email,
]);
@@ -247,8 +247,9 @@ public function asAUserICanAddAReviewOnABook(): void
$user = UserFactory::createOne();
self::getMercureHub()->reset();
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => $user->email,
+ 'authorize' => true,
]);
$response = $this->client->request('POST', '/books/' . $book->getId() . '/reviews', [
@@ -301,8 +302,9 @@ public function asAUserICannotAddADuplicateReviewOnABook(): void
$user = UserFactory::createOne();
ReviewFactory::createOne(['book' => $book, 'user' => $user]);
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => $user->email,
+ 'authorize' => true,
]);
$this->client->request('POST', '/books/' . $book->getId() . '/reviews', [
@@ -391,8 +393,9 @@ public function asAUserICannotUpdateABookReviewOfAnotherUser(): void
{
$review = ReviewFactory::createOne(['user' => UserFactory::createOne()]);
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => UserFactory::createOne()->email,
+ 'authorize' => false,
]);
$this->client->request('PATCH', '/books/' . $review->book->getId() . '/reviews/' . $review->getId(), [
@@ -421,7 +424,7 @@ public function asAUserICannotUpdateAnInvalidBookReview(): void
{
$book = BookFactory::createOne();
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => UserFactory::createOne()->email,
]);
@@ -448,8 +451,9 @@ public function asAUserICanUpdateMyBookReview(): void
$review = ReviewFactory::createOne();
self::getMercureHub()->reset();
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => $review->user->email,
+ 'authorize' => true,
]);
$this->client->request('PATCH', '/books/' . $review->book->getId() . '/reviews/' . $review->getId(), [
@@ -505,8 +509,9 @@ public function asAUserICannotDeleteABookReviewOfAnotherUser(): void
{
$review = ReviewFactory::createOne(['user' => UserFactory::createOne()]);
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => UserFactory::createOne()->email,
+ 'authorize' => false,
]);
$this->client->request('DELETE', '/books/' . $review->book->getId() . '/reviews/' . $review->getId(), [
@@ -528,7 +533,7 @@ public function asAUserICannotDeleteAnInvalidBookReview(): void
{
$book = BookFactory::createOne();
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => UserFactory::createOne()->email,
]);
@@ -550,8 +555,9 @@ public function asAUserICanDeleteMyBookReview(): void
$id = $review->getId();
$bookId = $review->book->getId();
- $token = $this->generateToken([
+ $token = self::getContainer()->get(TokenGenerator::class)->generateToken([
'email' => $review->user->email,
+ 'authorize' => true,
]);
$response = $this->client->request('DELETE', '/books/' . $bookId . '/reviews/' . $id, [
diff --git a/api/tests/Api/Security/TokenGenerator.php b/api/tests/Api/Security/TokenGenerator.php
new file mode 100644
index 000000000..4c28a5669
--- /dev/null
+++ b/api/tests/Api/Security/TokenGenerator.php
@@ -0,0 +1,71 @@
+jwk = JWK::createFromJson(json: $jwk);
+ }
+
+ public function generateToken(array $claims): string
+ {
+ // Defaults
+ $time = time();
+ $sub = Uuid::v7()->__toString();
+ $claims += [
+ 'sub' => $sub,
+ 'iat' => $time,
+ 'nbf' => $time,
+ 'exp' => $time + 3600,
+ 'iss' => $this->issuer,
+ 'aud' => $this->audience,
+ 'given_name' => 'John',
+ 'family_name' => 'DOE',
+ ];
+ if (empty($claims['sub'])) {
+ $claims['sub'] = $sub;
+ }
+ if (empty($claims['iat'])) {
+ $claims['iat'] = $time;
+ }
+ if (empty($claims['nbf'])) {
+ $claims['nbf'] = $time;
+ }
+ if (empty($claims['exp'])) {
+ $claims['exp'] = $time + 3600;
+ }
+ if (empty($claims['realm_access']) || empty($claims['realm_access']['roles'])) {
+ $claims['realm_access']['roles'] = ['chuck.norris@example.com' === ($claims['email'] ?? null) ? 'admin' : 'user'];
+ }
+
+ return $this->jwsSerializerManager->serialize(
+ name: 'jws_compact',
+ jws: $this->jwsBuilder
+ ->withPayload(json_encode($claims))
+ ->addSignature($this->jwk, ['alg' => $this->jwk->get('alg')])
+ ->build(),
+ );
+ }
+}
diff --git a/api/tests/Api/Security/Voter/Mock/KeycloakProtocolOpenIdConnectTokenIntrospectMock.php b/api/tests/Api/Security/Voter/Mock/KeycloakProtocolOpenIdConnectTokenIntrospectMock.php
new file mode 100644
index 000000000..2accd186f
--- /dev/null
+++ b/api/tests/Api/Security/Voter/Mock/KeycloakProtocolOpenIdConnectTokenIntrospectMock.php
@@ -0,0 +1,75 @@
+handleRequest(...), $this->baseUri);
+ }
+
+ private function handleRequest(string $method, string $url, array $options): ResponseInterface
+ {
+ if (!('POST' === $method && $this->baseUri . 'protocol/openid-connect/token/introspect' === $url)) {
+ return $this->decorated->request($method, $url, $options);
+ }
+
+ if (!isset($options['body'])) {
+ return $this->getInvalidMock();
+ }
+
+ // retrieve token from body
+ parse_str($options['body'], $body);
+ if (!isset($body['token'])) {
+ return $this->getInvalidMock();
+ }
+
+ $serializerManager = new JWSSerializerManager([new CompactSerializer()]);
+ $jws = $serializerManager->unserialize($body['token']);
+ $claims = json_decode($jws->getPayload(), true);
+
+ // "authorize" custom claim set in the test
+ if (\array_key_exists('authorize', $claims)) {
+ return $claims['authorize'] ? $this->getValidMock($claims) : $this->getInvalidMock();
+ }
+
+ // no "authorize" custom claim: build roles from user email
+ return 'chuck.norris@example.com' === ($claims['email'] ?? null) ? $this->getValidMock($claims) : $this->getInvalidMock();
+ }
+
+ private function getValidMock(array $claims): MockResponse
+ {
+ $roles = ['offline_access', 'uma_authorization', 'user'];
+ if ('chuck.norris@example.com' === ($claims['email'] ?? null)) {
+ $roles[] = 'admin';
+ }
+
+ return new MockResponse(json_encode($claims + [
+ 'realm_access' => [
+ 'roles' => $roles,
+ ],
+ ]), ['http_code' => Response::HTTP_OK]);
+ }
+
+ private function getInvalidMock(): MockResponse
+ {
+ return new MockResponse('', ['http_code' => Response::HTTP_UNAUTHORIZED]);
+ }
+}
diff --git a/api/tests/Api/Security/Voter/Mock/KeycloakProtocolOpenIdConnectTokenMock.php b/api/tests/Api/Security/Voter/Mock/KeycloakProtocolOpenIdConnectTokenMock.php
new file mode 100644
index 000000000..bc4e151b5
--- /dev/null
+++ b/api/tests/Api/Security/Voter/Mock/KeycloakProtocolOpenIdConnectTokenMock.php
@@ -0,0 +1,72 @@
+handleRequest(...), $this->baseUri);
+ }
+
+ private function handleRequest(string $method, string $url, array $options): ResponseInterface
+ {
+ if (!('POST' === $method && $this->baseUri . 'protocol/openid-connect/token' === $url)) {
+ return $this->decorated->request($method, $url, $options);
+ }
+
+ if (!isset($options['normalized_headers']['authorization'][0])) {
+ return $this->getInvalidMock();
+ }
+
+ $accessToken = preg_replace('/^Authorization: Bearer (.*)$/', '$1', $options['normalized_headers']['authorization'][0]);
+ $serializerManager = new JWSSerializerManager([new CompactSerializer()]);
+ $jws = $serializerManager->unserialize($accessToken);
+ $claims = json_decode($jws->getPayload(), true);
+
+ // "authorize" custom claim set in the test
+ if (\array_key_exists('authorize', $claims)) {
+ return $claims['authorize'] ? $this->getValidMock() : $this->getInvalidMock();
+ }
+
+ // no "authorize" custom claim set, try to detect permission from body
+ parse_str($options['body'], $body);
+ if (!isset($body['permission'])) {
+ return $this->getInvalidMock();
+ }
+
+ // if permission starts with "/admin", check for user email in token
+ if (preg_match('/^\/admin\//', $body['permission'])) {
+ return 'chuck.norris@example.com' === ($claims['email'] ?? null) ? $this->getValidMock() : $this->getInvalidMock();
+ }
+
+ // no "authorize" custom claim, permission is not "/admin": consider permission valid
+ return $this->getValidMock();
+ }
+
+ private function getValidMock(): MockResponse
+ {
+ return new MockResponse(json_encode(['result' => true]), ['http_code' => Response::HTTP_OK]);
+ }
+
+ private function getInvalidMock(): MockResponse
+ {
+ return new MockResponse(json_encode(['result' => false]), ['http_code' => Response::HTTP_UNAUTHORIZED]);
+ }
+}
diff --git a/api/tests/Api/Security/Voter/Mock/NotImplementedMock.php b/api/tests/Api/Security/Voter/Mock/NotImplementedMock.php
new file mode 100644
index 000000000..b9f44e913
--- /dev/null
+++ b/api/tests/Api/Security/Voter/Mock/NotImplementedMock.php
@@ -0,0 +1,25 @@
+handleRequest(...), $baseUri);
+ }
+
+ public function handleRequest(string $method, string $url): void
+ {
+ throw new \UnexpectedValueException("Mock not implemented: {$method}/{$url}");
+ }
+}
diff --git a/api/tests/Api/Trait/SecurityTrait.php b/api/tests/Api/Trait/SecurityTrait.php
deleted file mode 100644
index c05b3a719..000000000
--- a/api/tests/Api/Trait/SecurityTrait.php
+++ /dev/null
@@ -1,56 +0,0 @@
-get('security.access_token_handler.oidc.signature.ES256');
- $jwk = $container->get('app.security.jwk');
- $audience = $container->getParameter('app.oidc.aud');
- $issuer = $container->getParameter('app.oidc.issuer');
-
- // Defaults
- $time = time();
- $sub = Uuid::v7()->__toString();
- $claims += [
- 'sub' => $sub,
- 'iat' => $time,
- 'nbf' => $time,
- 'exp' => $time + 3600,
- 'iss' => $issuer,
- 'aud' => $audience,
- 'given_name' => 'John',
- 'family_name' => 'DOE',
- ];
- if (empty($claims['sub'])) {
- $claims['sub'] = $sub;
- }
- if (empty($claims['iat'])) {
- $claims['iat'] = $time;
- }
- if (empty($claims['nbf'])) {
- $claims['nbf'] = $time;
- }
- if (empty($claims['exp'])) {
- $claims['exp'] = $time + 3600;
- }
-
- return (new CompactSerializer())->serialize((new JWSBuilder(new AlgorithmManager([
- $signatureAlgorithm,
- ])))->create()
- ->withPayload(json_encode($claims))
- ->addSignature($jwk, ['alg' => $signatureAlgorithm->name()])
- ->build()
- );
- }
-}
diff --git a/api/tests/Api/schemas/Review/collection.json b/api/tests/Api/schemas/Review/collection.json
index 1779749f2..cfe83e86c 100644
--- a/api/tests/Api/schemas/Review/collection.json
+++ b/api/tests/Api/schemas/Review/collection.json
@@ -88,12 +88,6 @@
"type": "string",
"pattern": "^https://schema.org/Person$"
},
- "sub": {
- "externalDocs": {
- "url": "https:\/\/schema.org\/identifier"
- },
- "type": "string"
- },
"firstName": {
"description": "The givenName of the person",
"externalDocs": {
@@ -119,7 +113,6 @@
"required": [
"@id",
"@type",
- "sub",
"firstName",
"lastName",
"name"
diff --git a/api/tests/Api/schemas/Review/item.json b/api/tests/Api/schemas/Review/item.json
index 4578faf03..fb4c5716f 100644
--- a/api/tests/Api/schemas/Review/item.json
+++ b/api/tests/Api/schemas/Review/item.json
@@ -38,12 +38,6 @@
"type": "string",
"pattern": "^https://schema.org/Person$"
},
- "sub": {
- "externalDocs": {
- "url": "https:\/\/schema.org\/identifier"
- },
- "type": "string"
- },
"firstName": {
"description": "The givenName of the person",
"externalDocs": {
@@ -69,7 +63,6 @@
"required": [
"@id",
"@type",
- "sub",
"firstName",
"lastName",
"name"
diff --git a/api/tests/Security/Core/UserProviderTest.php b/api/tests/Security/Core/UserProviderTest.php
index e4eb1a69b..6e26887ab 100644
--- a/api/tests/Security/Core/UserProviderTest.php
+++ b/api/tests/Security/Core/UserProviderTest.php
@@ -15,7 +15,6 @@
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\User\UserInterface;
-use Symfony\Component\Uid\Uuid;
final class UserProviderTest extends TestCase
{
@@ -102,12 +101,8 @@ public function itCannotLoadUserIfAttributeIsMissing(array $attributes): void
public static function getInvalidAttributes(): iterable
{
- yield 'missing sub' => [[]];
- yield 'missing given_name' => [[
- 'sub' => 'ba86c94b-efeb-4452-a0b4-93ed3c889156',
- ]];
+ yield 'missing given_name' => [[]];
yield 'missing family_name' => [[
- 'sub' => 'ba86c94b-efeb-4452-a0b4-93ed3c889156',
'given_name' => 'John',
]];
}
@@ -128,7 +123,6 @@ public function itLoadsUserFromAttributes(): void
;
$this->assertSame($this->userMock, $this->provider->loadUserByIdentifier('john.doe@example.com', [
- 'sub' => 'ba86c94b-efeb-4452-a0b4-93ed3c889156',
'given_name' => 'John',
'family_name' => 'DOE',
]));
@@ -140,7 +134,6 @@ public function itCreatesAUserFromAttributes(): void
$expectedUser = new User();
$expectedUser->firstName = 'John';
$expectedUser->lastName = 'DOE';
- $expectedUser->sub = Uuid::fromString('ba86c94b-efeb-4452-a0b4-93ed3c889156');
$expectedUser->email = 'john.doe@example.com';
$this->repositoryMock
@@ -156,7 +149,6 @@ public function itCreatesAUserFromAttributes(): void
;
$this->assertEquals($expectedUser, $this->provider->loadUserByIdentifier('john.doe@example.com', [
- 'sub' => 'ba86c94b-efeb-4452-a0b4-93ed3c889156',
'given_name' => 'John',
'family_name' => 'DOE',
]));
diff --git a/api/tests/Serializer/IriTransformerNormalizerTest.php b/api/tests/Serializer/IriTransformerNormalizerTest.php
index f2978cce1..aef28b883 100644
--- a/api/tests/Serializer/IriTransformerNormalizerTest.php
+++ b/api/tests/Serializer/IriTransformerNormalizerTest.php
@@ -5,7 +5,6 @@
namespace App\Tests\Serializer;
use ApiPlatform\Api\IriConverterInterface;
-use ApiPlatform\Api\UrlGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface;
use App\Serializer\IriTransformerNormalizer;
diff --git a/api/tests/State/Processor/BookPersistProcessorTest.php b/api/tests/State/Processor/BookPersistProcessorTest.php
index c548d99b9..69b31bb9e 100644
--- a/api/tests/State/Processor/BookPersistProcessorTest.php
+++ b/api/tests/State/Processor/BookPersistProcessorTest.php
@@ -11,7 +11,6 @@
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
-use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Serializer\Encoder\DecoderInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
diff --git a/api/tests/State/Processor/BookRemoveProcessorTest.php b/api/tests/State/Processor/BookRemoveProcessorTest.php
index 997caecb2..02628ee4f 100644
--- a/api/tests/State/Processor/BookRemoveProcessorTest.php
+++ b/api/tests/State/Processor/BookRemoveProcessorTest.php
@@ -5,7 +5,6 @@
namespace App\Tests\State\Processor;
use ApiPlatform\Api\IriConverterInterface;
-use ApiPlatform\Api\UrlGeneratorInterface;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Operation;
@@ -14,7 +13,6 @@
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Book;
use App\State\Processor\BookRemoveProcessor;
-use App\State\Processor\MercureProcessor;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
diff --git a/api/tests/State/Processor/ReviewPersistProcessorTest.php b/api/tests/State/Processor/ReviewPersistProcessorTest.php
index 56d71c892..05dbfb46c 100644
--- a/api/tests/State/Processor/ReviewPersistProcessorTest.php
+++ b/api/tests/State/Processor/ReviewPersistProcessorTest.php
@@ -9,6 +9,7 @@
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Review;
use App\Entity\User;
+use App\Security\Http\Protection\ResourceHandlerInterface;
use App\State\Processor\ReviewPersistProcessor;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
@@ -25,6 +26,7 @@ final class ReviewPersistProcessorTest extends TestCase
private MockObject|User $userMock;
private MockObject|Review $objectMock;
private ClockInterface|MockObject $clockMock;
+ private ResourceHandlerInterface|MockObject $resourceHandlerMock;
private ReviewPersistProcessor $processor;
protected function setUp(): void
@@ -35,12 +37,14 @@ protected function setUp(): void
$this->userMock = $this->createMock(User::class);
$this->objectMock = $this->createMock(Review::class);
$this->clockMock = new MockClock();
+ $this->resourceHandlerMock = $this->createMock(ResourceHandlerInterface::class);
$this->processor = new ReviewPersistProcessor(
$this->persistProcessorMock,
$this->mercureProcessorMock,
$this->securityMock,
- $this->clockMock
+ $this->clockMock,
+ $this->resourceHandlerMock
);
}
@@ -53,6 +57,7 @@ public function itUpdatesReviewDataFromOperationBeforeSaveAndSendMercureUpdates(
$expectedData->user = $this->userMock;
$expectedData->publishedAt = $this->clockMock->now();
+ $this->userMock->email = 'john.doe@example.com';
$this->securityMock
->expects($this->once())
->method('getUser')
@@ -64,6 +69,13 @@ public function itUpdatesReviewDataFromOperationBeforeSaveAndSendMercureUpdates(
->with($expectedData, $operation, [], [])
->willReturn($expectedData)
;
+ $this->resourceHandlerMock
+ ->expects($this->once())
+ ->method('create')
+ ->with($expectedData, $this->userMock, [
+ 'operation_name' => '/books/{bookId}/reviews/{id}{._format}',
+ ])
+ ;
$this->mercureProcessorMock
->expects($this->exactly(2))
->method('process')
@@ -105,6 +117,10 @@ public function itUpdatesReviewDataFromContextBeforeSaveAndSendMercureUpdates():
->with($expectedData, $operation, [], $context)
->willReturn($expectedData)
;
+ $this->resourceHandlerMock
+ ->expects($this->never())
+ ->method('create')
+ ;
$this->mercureProcessorMock
->expects($this->exactly(2))
->method('process')
diff --git a/api/tests/State/Processor/ReviewRemoveProcessorTest.php b/api/tests/State/Processor/ReviewRemoveProcessorTest.php
index 7b3fe9b14..acedcd2dd 100644
--- a/api/tests/State/Processor/ReviewRemoveProcessorTest.php
+++ b/api/tests/State/Processor/ReviewRemoveProcessorTest.php
@@ -5,7 +5,6 @@
namespace App\Tests\State\Processor;
use ApiPlatform\Api\IriConverterInterface;
-use ApiPlatform\Api\UrlGeneratorInterface;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Operation;
@@ -13,7 +12,8 @@
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Review;
-use App\State\Processor\MercureProcessor;
+use App\Entity\User;
+use App\Security\Http\Protection\ResourceHandlerInterface;
use App\State\Processor\ReviewRemoveProcessor;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
@@ -28,6 +28,7 @@ final class ReviewRemoveProcessorTest extends TestCase
private IriConverterInterface|MockObject $iriConverterMock;
private MockObject|Review $objectMock;
private MockObject|Operation $operationMock;
+ private ResourceHandlerInterface|MockObject $resourceHandlerMock;
private ReviewRemoveProcessor $processor;
protected function setUp(): void
@@ -35,6 +36,7 @@ protected function setUp(): void
$this->removeProcessorMock = $this->createMock(ProcessorInterface::class);
$this->mercureProcessorMock = $this->createMock(ProcessorInterface::class);
$this->resourceMetadataCollectionFactoryMock = $this->createMock(ResourceMetadataCollectionFactoryInterface::class);
+ $this->resourceHandlerMock = $this->createMock(ResourceHandlerInterface::class);
$this->resourceMetadataCollection = new ResourceMetadataCollection(Review::class, [
new ApiResource(operations: [new Get('/admin/reviews/{id}{._format}')]),
new ApiResource(operations: [new Get('/books/{bookId}/reviews/{id}{._format}')]),
@@ -47,7 +49,8 @@ protected function setUp(): void
$this->removeProcessorMock,
$this->mercureProcessorMock,
$this->resourceMetadataCollectionFactoryMock,
- $this->iriConverterMock
+ $this->iriConverterMock,
+ $this->resourceHandlerMock
);
}
@@ -83,6 +86,15 @@ public function itRemovesBookAndSendMercureUpdates(): void
'/books/8ad70d36-abaf-4c9b-aeaa-7ec63e6ca6f3/reviews/9aff4b91-31cf-4e91-94b0-1d52bbe23fe6',
)
;
+ $this->objectMock->user = $this->createMock(User::class);
+ $this->objectMock->user->email = 'john.doe@example.com';
+ $this->resourceHandlerMock
+ ->expects($this->once())
+ ->method('delete')
+ ->with($this->objectMock, $this->objectMock->user, [
+ 'operation_name' => '/books/{bookId}/reviews/{id}{._format}',
+ ])
+ ;
$this->mercureProcessorMock
->expects($this->exactly(2))
->method('process')
diff --git a/compose.yaml b/compose.yaml
index 05b24cf2b..4dad87078 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -38,10 +38,10 @@ services:
NEXT_PUBLIC_ENTRYPOINT: http://php
AUTH_SECRET: ${AUTH_SECRET:-!ChangeThisNextAuthSecret!}
AUTH_URL: ${AUTH_URL:-https://localhost/api/auth}
- AUTH_URL_INTERNAL: http://127.0.0.1:3000/api/auth
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-api-platform-pwa}
OIDC_SERVER_URL: ${OIDC_SERVER_URL:-https://localhost/oidc/realms/demo}
OIDC_SERVER_URL_INTERNAL: ${OIDC_SERVER_URL_INTERNAL:-http://keycloak:8080/oidc/realms/demo}
+ OIDC_AUTHORIZATION_CLIENT_ID: ${OIDC_AUTHORIZATION_CLIENT_ID:-api-platform-api}
NEXT_SHARP_PATH: /srv/app/node_modules/sharp
###> doctrine/doctrine-bundle ###
@@ -86,6 +86,8 @@ services:
# https://www.keycloak.org/server/hostname
KC_HOSTNAME_URL: https://${SERVER_NAME:-localhost}/oidc/
KC_HOSTNAME_ADMIN_URL: https://${SERVER_NAME:-localhost}/oidc/
+ # https://www.keycloak.org/server/features
+ KC_FEATURES: "scripts"
depends_on:
- keycloak-database
ports:
diff --git a/helm/api-platform/keycloak/Dockerfile b/helm/api-platform/keycloak/Dockerfile
index 75033b3ec..cd85b3465 100644
--- a/helm/api-platform/keycloak/Dockerfile
+++ b/helm/api-platform/keycloak/Dockerfile
@@ -16,3 +16,4 @@ FROM bitnami/keycloak:23-debian-11 AS keycloak_upstream
FROM keycloak_upstream AS keycloak
COPY --link themes/api-platform-demo /opt/bitnami/keycloak/themes/api-platform-demo
+COPY --link providers/owner-policy.jar /opt/bitnami/keycloak/providers/owner-policy.jar
diff --git a/helm/api-platform/keycloak/config/realm-demo.json b/helm/api-platform/keycloak/config/realm-demo.json
index e6e99092a..58a9c1172 100755
--- a/helm/api-platform/keycloak/config/realm-demo.json
+++ b/helm/api-platform/keycloak/config/realm-demo.json
@@ -1,43 +1,148 @@
{
"realm": "demo",
"displayName": "API Platform - Demo",
+ "accessCodeLifespan": 1,
"enabled": true,
"registrationAllowed": false,
- "accessCodeLifespan": 1,
+ "loginWithEmailAllowed": true,
"loginTheme": "api-platform-demo",
+ "roles": {
+ "realm": [
+ {
+ "name": "admin",
+ "description": "Admin role (includes 'user' role)",
+ "composite": true,
+ "composites": {
+ "realm": [
+ "user"
+ ]
+ },
+ "clientRole": false
+ },
+ {
+ "id": "0a34f93f-a81a-4edd-b6a2-7ed3d06a392c",
+ "name": "user",
+ "description": "User role (default role on an authenticated user)",
+ "composite": false,
+ "clientRole": false
+ }
+ ],
+ "client": {
+ "api-platform-api": [
+ {
+ "name": "uma_protection",
+ "composite": false,
+ "clientRole": true,
+ "containerId": "6832d039-5543-4e66-afc5-bc5057e8234d"
+ }
+ ],
+ "api-platform-pwa": [],
+ "api-platform-swagger": []
+ }
+ },
+ "defaultRole": {
+ "id": "0a34f93f-a81a-4edd-b6a2-7ed3d06a392c",
+ "name": "user",
+ "description": "User role (default role on an authenticated user)",
+ "composite": false,
+ "clientRole": false
+ },
"users": [
{
- "username": "chuck.norris",
+ "username": "chuck.norris@example.com",
+ "email": "chuck.norris@example.com",
"enabled": true,
"emailVerified": true,
"firstName": "Chuck",
"lastName": "Norris",
- "email": "chuck.norris@example.com",
"credentials": [
{
"type": "password",
"value": "Pa55w0rd"
}
+ ],
+ "realmRoles": [
+ "admin"
]
},
{
- "username": "john.doe",
+ "username": "john.doe@example.com",
+ "email": "john.doe@example.com",
"enabled": true,
"emailVerified": true,
"firstName": "John",
"lastName": "Doe",
- "email": "john.doe@example.com",
"credentials": [
{
"type": "password",
"value": "Pa55w0rd"
}
+ ],
+ "realmRoles": [
+ "user"
]
}
],
+ "clientScopes": [
+ {
+ "name": "roles",
+ "attributes": {
+ "include.in.token.scope": "true"
+ }
+ }
+ ],
"clients": [
+ {
+ "id": "6832d039-5543-4e66-afc5-bc5057e8234d",
+ "clientId": "api-platform-api",
+ "description": "Confidential client to check authorizations (RBAC)",
+ "name": "API Platform API",
+ "enabled": true,
+ "clientAuthenticatorType": "client-secret",
+ "secret": "sEocbxCy7iFS8NzYzWyQ71QgxTDZ9fnU",
+ "redirectUris": [
+ "/*"
+ ],
+ "webOrigins": [
+ "/*"
+ ],
+ "standardFlowEnabled": true,
+ "directAccessGrantsEnabled": true,
+ "serviceAccountsEnabled": true,
+ "authorizationServicesEnabled": true,
+ "publicClient": false,
+ "protocol": "openid-connect",
+ "fullScopeAllowed": true,
+ "authorizationSettings": {
+ "allowRemoteResourceManagement": true,
+ "policyEnforcementMode": "ENFORCING",
+ "decisionStrategy": "UNANIMOUS",
+ "policies": [
+ {
+ "name": "Owner Policy",
+ "description": "Grants access only for users that own a resource (based on \"owner\" resource attribute)",
+ "type": "script-owner-policy.js",
+ "logic": "POSITIVE",
+ "decisionStrategy": "AFFIRMATIVE"
+ },
+ {
+ "name": "Review Owner Permission",
+ "description": "Ensure only users that own a review resource have access",
+ "type": "resource",
+ "logic": "POSITIVE",
+ "decisionStrategy": "UNANIMOUS",
+ "config": {
+ "defaultResourceType": "urn:api-platform-api:resources:review",
+ "applyPolicies": "[\"Owner Policy\"]"
+ }
+ }
+ ]
+ }
+ },
{
"clientId": "api-platform-swagger",
+ "name": "API Platform Swagger UI",
+ "description": "Public client for Swagger UI",
"enabled": true,
"redirectUris": ["*"],
"webOrigins": ["*"],
@@ -45,6 +150,8 @@
},
{
"clientId": "api-platform-pwa",
+ "name": "API Platform PWA",
+ "description": "Public client for the PWA",
"enabled": true,
"redirectUris": ["*"],
"webOrigins": ["*"],
diff --git a/helm/api-platform/keycloak/providers/README.md b/helm/api-platform/keycloak/providers/README.md
new file mode 100644
index 000000000..76a607fec
--- /dev/null
+++ b/helm/api-platform/keycloak/providers/README.md
@@ -0,0 +1,12 @@
+# Building Providers
+
+Keycloak comes with a bunch of providers.
+
+To create a custom JavaScript Policy (https://www.keycloak.org/docs/24.0.1/server_development/#_script_providers),
+it must be packed in a JAR file.
+
+Build the provider as following:
+
+```shell
+zip -r owner-policy.jar owner/*
+```
diff --git a/helm/api-platform/keycloak/providers/owner-policy.jar b/helm/api-platform/keycloak/providers/owner-policy.jar
new file mode 100644
index 000000000..6343c2ace
Binary files /dev/null and b/helm/api-platform/keycloak/providers/owner-policy.jar differ
diff --git a/helm/api-platform/keycloak/providers/owner/META-INF/keycloak-scripts.json b/helm/api-platform/keycloak/providers/owner/META-INF/keycloak-scripts.json
new file mode 100644
index 000000000..ff3ebd3d5
--- /dev/null
+++ b/helm/api-platform/keycloak/providers/owner/META-INF/keycloak-scripts.json
@@ -0,0 +1,9 @@
+{
+ "policies": [
+ {
+ "name": "Owner",
+ "fileName": "owner-policy.js",
+ "description": "Checks that a resource is owned by the current user"
+ }
+ ]
+}
diff --git a/helm/api-platform/keycloak/providers/owner/owner-policy.js b/helm/api-platform/keycloak/providers/owner/owner-policy.js
new file mode 100644
index 000000000..29ad72b1a
--- /dev/null
+++ b/helm/api-platform/keycloak/providers/owner/owner-policy.js
@@ -0,0 +1,8 @@
+var context = $evaluation.context;
+var identity = context.identity;
+var permission = $evaluation.permission;
+var resource = permission.resource;
+
+if (resource.owner == identity.id) {
+ $evaluation.grant();
+}
diff --git a/helm/api-platform/templates/configmap.yaml b/helm/api-platform/templates/configmap.yaml
index 86047e125..2247360a6 100644
--- a/helm/api-platform/templates/configmap.yaml
+++ b/helm/api-platform/templates/configmap.yaml
@@ -18,6 +18,7 @@ data:
oidc-server-url-internal: "http://{{ template "common.names.fullname" .Subcharts.keycloak }}/oidc/realms/demo"
next-auth-url: "https://{{ (first .Values.ingress.hosts).host }}/api/auth"
pwa-client-id: {{ .Values.pwa.oidcClientId | quote }}
+ pwa-authorization-client-id: {{ .Values.php.oidcClientId | quote }}
---
diff --git a/helm/api-platform/templates/pwa-deployment.yaml b/helm/api-platform/templates/pwa-deployment.yaml
index c46755b5a..59c5846a5 100644
--- a/helm/api-platform/templates/pwa-deployment.yaml
+++ b/helm/api-platform/templates/pwa-deployment.yaml
@@ -61,6 +61,11 @@ spec:
configMapKeyRef:
name: {{ include "api-platform.fullname" . }}
key: pwa-client-id
+ - name: OIDC_AUTHORIZATION_CLIENT_ID
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "api-platform.fullname" . }}
+ key: pwa-authorization-client-id
ports:
- name: http
containerPort: 3000
diff --git a/helm/api-platform/values.yaml b/helm/api-platform/values.yaml
index 02c1d5e20..2e0287e00 100644
--- a/helm/api-platform/values.yaml
+++ b/helm/api-platform/values.yaml
@@ -12,6 +12,7 @@ php:
appDebug: "0"
appSecret: ""
corsAllowOrigin: "^https?://.*?\\.chart-example\\.local$"
+ oidcClientId: api-platform-api
trustedHosts: "^127\\.0\\.0\\.1|localhost|.*\\.chart-example\\.local$"
trustedProxies:
- "127.0.0.1"
diff --git a/pwa/app/auth.tsx b/pwa/app/auth.tsx
index f6c5dc947..2492438ec 100644
--- a/pwa/app/auth.tsx
+++ b/pwa/app/auth.tsx
@@ -1,14 +1,10 @@
import { type TokenSet } from "@auth/core/types";
import { signOut as logout, type SignOutParams } from "next-auth/react";
-import NextAuth, { type Session as DefaultSession, type User as DefaultUser } from "next-auth";
+import NextAuth, { type Session as DefaultSession, type User } from "next-auth";
import KeycloakProvider from "next-auth/providers/keycloak";
import { OIDC_CLIENT_ID, OIDC_SERVER_URL, OIDC_SERVER_URL_INTERNAL } from "../config/keycloak";
-export interface User extends DefaultUser {
- sub?: string | null
-}
-
export interface Session extends DefaultSession {
error?: "RefreshAccessTokenError"
accessToken: string
@@ -22,7 +18,6 @@ interface JWT {
expiresAt: number
refreshToken: string
error?: "RefreshAccessTokenError"
- sub?: any
}
interface Account {
@@ -32,10 +27,6 @@ interface Account {
refresh_token: string
}
-interface Profile {
- sub: string
-}
-
interface SignOutResponse {
url: string
}
@@ -53,7 +44,7 @@ export async function signOut(
export const { handlers: { GET, POST }, auth } = NextAuth({
callbacks: {
// @ts-ignore
- async jwt({ token, account, profile }: { token: JWT, account: Account, profile: Profile }): Promise {
+ async jwt({ token, account }: { token: JWT, account: Account }): Promise {
if (account) {
// Save the access token and refresh token in the JWT on the initial login
return {
@@ -62,7 +53,6 @@ export const { handlers: { GET, POST }, auth } = NextAuth({
idToken: account.id_token,
expiresAt: Math.floor(Date.now() / 1000 + account.expires_in),
refreshToken: account.refresh_token,
- sub: profile.sub,
};
} else if (Date.now() < token.expiresAt * 1000) {
// If the access token has not expired yet, return it
@@ -70,7 +60,7 @@ export const { handlers: { GET, POST }, auth } = NextAuth({
} else {
// If the access token has expired, try to refresh it
try {
- const response = await fetch(`${OIDC_SERVER_URL}/protocol/openid-connect/token`, {
+ const response = await fetch(`${OIDC_SERVER_URL_INTERNAL}/protocol/openid-connect/token`, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: OIDC_CLIENT_ID,
@@ -113,9 +103,6 @@ export const { handlers: { GET, POST }, auth } = NextAuth({
session.accessToken = token.accessToken;
session.idToken = token.idToken;
session.error = token.error;
- if (session?.user && token?.sub) {
- session.user.sub = token.sub;
- }
}
return session;
diff --git a/pwa/app/bookmarks/page.tsx b/pwa/app/bookmarks/page.tsx
index bc5fc9f44..925790b5d 100644
--- a/pwa/app/bookmarks/page.tsx
+++ b/pwa/app/bookmarks/page.tsx
@@ -17,7 +17,8 @@ export const metadata: Metadata = {
async function getServerSideProps({ page = 1 }: Query, session: Session): Promise {
try {
const response: FetchResponse> | undefined = await fetchApi(`/bookmarks?page=${Number(page)}`, {
- next: { revalidate: 3600 },
+ // next: { revalidate: 3600 },
+ cache: "no-cache",
}, session);
if (!response?.data) {
throw new Error('Unable to retrieve data from /bookmarks.');
diff --git a/pwa/app/books/[id]/[slug]/page.tsx b/pwa/app/books/[id]/[slug]/page.tsx
index 455bcb442..b854363eb 100644
--- a/pwa/app/books/[id]/[slug]/page.tsx
+++ b/pwa/app/books/[id]/[slug]/page.tsx
@@ -12,12 +12,11 @@ interface Props {
export async function generateMetadata({ params }: Props): Promise {
const id = params.id;
- // @ts-ignore
- const session: Session|null = await auth();
try {
const response: FetchResponse | undefined = await fetchApi(`/books/${id}`, {
- next: { revalidate: 3600 },
- }, session);
+ // next: { revalidate: 3600 },
+ cache: "no-cache",
+ });
if (!response?.data) {
throw new Error(`Unable to retrieve data from /books/${id}.`);
}
@@ -39,7 +38,8 @@ async function getServerSideProps(id: string, session: Session|null): Promise> | undefined = await fetchApi(buildUriFromFilters("/books", filters), {
- cache: "force-cache",
+ // next: { revalidate: 3600 },
+ cache: "no-cache",
}, session);
if (!response?.data) {
throw new Error('Unable to retrieve data from /books.');
diff --git a/pwa/components/admin/book/BookInput.tsx b/pwa/components/admin/book/BookInput.tsx
index b0905edb5..117415485 100644
--- a/pwa/components/admin/book/BookInput.tsx
+++ b/pwa/components/admin/book/BookInput.tsx
@@ -24,7 +24,7 @@ const fetchOpenLibrarySearch = async (query: string, signal?: AbortSignal | unde
const response = await fetch(`https://openlibrary.org/search.json?q=${query.replace(/ - /, ' ')}&limit=10`, {
signal,
method: "GET",
- cache: "force-cache",
+ next: { revalidate: 3600 },
});
const results: Search = await response.json();
diff --git a/pwa/components/review/Item.tsx b/pwa/components/review/Item.tsx
index 19ce78534..b4086a093 100644
--- a/pwa/components/review/Item.tsx
+++ b/pwa/components/review/Item.tsx
@@ -7,6 +7,8 @@ import { Error } from "../common/Error";
import { type Review } from "../../types/Review";
import { fetchApi } from "../../utils/dataAccess";
import { Form } from "./Form";
+import {usePermission} from "../../utils/review";
+import {useOpenLibraryBook} from "../../utils/book";
interface Props {
review: Review;
@@ -22,10 +24,12 @@ interface DeleteParams {
const deleteReview = async (id: string, session) => await fetchApi(id, { method: "DELETE" }, session);
export const Item: FunctionComponent = ({ review, onDelete, onEdit }) => {
- const { data: session } = useSession();
+ const { data: session, status } = useSession();
const [data, setData] = useState(review);
const [error, setError] = useState();
const [edit, setEdit] = useState(false);
+ // @ts-ignore
+ const isGranted = usePermission(data, session);
const deleteMutation = useMutation({
mutationFn: async ({ id }: DeleteParams) => deleteReview(id, session),
@@ -79,7 +83,7 @@ export const Item: FunctionComponent = ({ review, onDelete, onEdit }) =>
{data["body"]}
{/* @ts-ignore */}
- {!!session && !!session?.user?.sub && !!data["user"] && data["user"]["sub"] === session.user.sub && (
+ {isGranted && (
{
e.preventDefault();
diff --git a/pwa/config/keycloak.ts b/pwa/config/keycloak.ts
index ac26026bd..185a144a9 100644
--- a/pwa/config/keycloak.ts
+++ b/pwa/config/keycloak.ts
@@ -1,3 +1,4 @@
export const OIDC_CLIENT_ID: string = process.env.OIDC_CLIENT_ID || 'api-platform-pwa';
export const OIDC_SERVER_URL: string = process.env.OIDC_SERVER_URL || 'https://localhost/oidc/realms/demo';
export const OIDC_SERVER_URL_INTERNAL: string = process.env.OIDC_SERVER_URL_INTERNAL || 'http://keycloak:8080/oidc/realms/demo';
+export const OIDC_AUTHORIZATION_CLIENT_ID: string = process.env.OIDC_AUTHORIZATION_CLIENT_ID || 'api-platform-api';
diff --git a/pwa/tests/admin/pages/AbstractPage.ts b/pwa/tests/admin/pages/AbstractPage.ts
index a3122b882..8554fcf18 100644
--- a/pwa/tests/admin/pages/AbstractPage.ts
+++ b/pwa/tests/admin/pages/AbstractPage.ts
@@ -5,7 +5,7 @@ export abstract class AbstractPage {
}
public async login() {
- await this.page.getByLabel("Username or email").fill("chuck.norris@example.com");
+ await this.page.getByLabel("Email").fill("chuck.norris@example.com");
await this.page.getByLabel("Password").fill("Pa55w0rd");
await this.page.getByRole("button", { name: "Sign In" }).click();
if (await this.page.getByRole("button", { name: "Sign in with Keycloak" }).count()) {
diff --git a/pwa/tests/pages/AbstractPage.ts b/pwa/tests/pages/AbstractPage.ts
index 635e027c3..04350e205 100644
--- a/pwa/tests/pages/AbstractPage.ts
+++ b/pwa/tests/pages/AbstractPage.ts
@@ -5,7 +5,7 @@ export abstract class AbstractPage {
}
public async login() {
- await this.page.getByLabel("Username or email").fill("john.doe@example.com");
+ await this.page.getByLabel("Email").fill("john.doe@example.com");
await this.page.getByLabel("Password").fill("Pa55w0rd");
await this.page.getByRole("button", { name: "Sign In" }).click();
if (await this.page.getByRole("button", { name: "Sign in with Keycloak" }).count()) {
diff --git a/pwa/utils/book.ts b/pwa/utils/book.ts
index 17ac6a147..0ee535258 100644
--- a/pwa/utils/book.ts
+++ b/pwa/utils/book.ts
@@ -51,7 +51,7 @@ export const useOpenLibraryBook = (data: TData) => {
// retrieve data from work if necessary
if ((!data["description"] || !data["images"]) && typeof book["works"] !== "undefined" && book["works"].length > 0) {
const response = await fetch(`https://openlibrary.org${book["works"][0]["key"]}.json`, {
- cache: "force-cache",
+ next: { revalidate: 3600 },
});
const work: Work = await response.json();
diff --git a/pwa/utils/review.ts b/pwa/utils/review.ts
new file mode 100644
index 000000000..b6cdc5808
--- /dev/null
+++ b/pwa/utils/review.ts
@@ -0,0 +1,46 @@
+import { useEffect, useState } from "react";
+
+import { type Session } from "../app/auth";
+import { type Review } from "../types/Review";
+import { OIDC_AUTHORIZATION_CLIENT_ID, OIDC_SERVER_URL } from "../config/keycloak";
+
+interface Permission {
+ result: boolean;
+}
+
+export const usePermission = (review: Review, session: Session|null): boolean => {
+ const [isGranted, grant] = useState(false);
+
+ useEffect(() => {
+ if (!session) {
+ return;
+ }
+
+ (async () => {
+ const response = await fetch(`${OIDC_SERVER_URL}/protocol/openid-connect/token`, {
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ Authorization: `Bearer ${session?.accessToken}`,
+ },
+ body: new URLSearchParams({
+ grant_type: "urn:ietf:params:oauth:grant-type:uma-ticket",
+ audience: OIDC_AUTHORIZATION_CLIENT_ID,
+ response_mode: "decision",
+ permission_resource_format: "uri",
+ permission_resource_matching_uri: "true",
+ // @ts-ignore
+ permission: review["@id"].toString(),
+ }),
+ method: "POST",
+ });
+ const permission: Permission = await response.json();
+ console.log(permission);
+
+ if (permission.result) {
+ grant(true);
+ }
+ })();
+ }, [review]);
+
+ return isGranted;
+};