diff --git a/.idea/php.xml b/.idea/php.xml index a61bbefce8..f2d3b4cf44 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -1,8 +1,5 @@ - - @@ -35,7 +32,6 @@ - @@ -47,19 +43,16 @@ - - - @@ -68,9 +61,6 @@ - - - @@ -113,7 +103,6 @@ - @@ -135,7 +124,6 @@ - @@ -183,7 +171,6 @@ - @@ -199,11 +186,6 @@ - - - - - @@ -215,7 +197,6 @@ - @@ -565,14 +546,14 @@ - - /usr/local/etc/php/conf.d/app-php.ini, /usr/local/etc/php/conf.d/docker-fpm.ini, /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini, /usr/local/etc/php/conf.d/docker-php-ext-sodium.ini + + /usr/local/etc/php/conf.d/app-php.ini, /usr/local/etc/php/conf.d/docker-fpm.ini, /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini, /usr/local/etc/php/conf.d/docker-php-ext-gmp.ini, /usr/local/etc/php/conf.d/docker-php-ext-sodium.ini - + @@ -591,6 +572,7 @@ + @@ -616,14 +598,14 @@ - - /usr/local/etc/php/conf.d/app-php.ini, /usr/local/etc/php/conf.d/docker-fpm.ini, /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini, /usr/local/etc/php/conf.d/docker-php-ext-sodium.ini + + /usr/local/etc/php/conf.d/app-php.ini, /usr/local/etc/php/conf.d/docker-fpm.ini, /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini, /usr/local/etc/php/conf.d/docker-php-ext-gmp.ini, /usr/local/etc/php/conf.d/docker-php-ext-sodium.ini - + @@ -642,6 +624,7 @@ + @@ -667,14 +650,14 @@ - + /usr/local/etc/php/conf.d/app-php.ini, /usr/local/etc/php/conf.d/docker-fpm.ini, /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini, /usr/local/etc/php/conf.d/docker-php-ext-gettext.ini, /usr/local/etc/php/conf.d/docker-php-ext-intl.ini, /usr/local/etc/php/conf.d/docker-php-ext-redis.ini, /usr/local/etc/php/conf.d/docker-php-ext-sodium.ini - + @@ -721,14 +704,14 @@ - + /usr/local/etc/php/conf.d/docker-php-ext-sodium.ini - + diff --git a/Makefile b/Makefile index af97ce5726..59c7f12cce 100644 --- a/Makefile +++ b/Makefile @@ -121,59 +121,59 @@ unit_test_all: | unit_test_viewer_app unit_test_actor_app unit_test_javascript u .PHONY: unit_test_all unit_test_viewer_app: - $(COMPOSE) run viewer-app /app/vendor/bin/phpunit + $(COMPOSE) run --rm viewer-app /app/vendor/bin/phpunit .PHONY: unit_test_viewer_app unit_test_actor_app: - $(COMPOSE) run actor-app /app/vendor/bin/phpunit + $(COMPOSE) run --rm actor-app /app/vendor/bin/phpunit .PHONY: unit_test_actor_app unit_test_javascript: - $(COMPOSE) run --entrypoint="/bin/sh -c" webpack "npm run test" + $(COMPOSE) run --rm --entrypoint="/bin/sh -c" webpack "npm run test" .PHONY: unit_test_actor_app unit_test_api_app: - $(COMPOSE) run api-app /app/vendor/bin/phpunit + $(COMPOSE) run --rm api-app /app/vendor/bin/phpunit .PHONY: unit_test_api_app enable_development_mode: - $(COMPOSE) run front-composer development-enable - $(COMPOSE) run api-composer development-enable + $(COMPOSE) run --rm front-composer development-enable + $(COMPOSE) run --rm api-composer development-enable .PHONY: enable_development_mode development_mode: | enable_development_mode clear_config_cache .PHONY: development_mode run_front_composer: - $(COMPOSE) run front-composer $(filter-out $@,$(MAKECMDGOALS)) + $(COMPOSE) run --rm front-composer $(filter-out $@,$(MAKECMDGOALS)) .PHONY: run_front_composer run_api_composer: - $(COMPOSE) run api-composer $(filter-out $@,$(MAKECMDGOALS)) + $(COMPOSE) run --rm api-composer $(filter-out $@,$(MAKECMDGOALS)) .PHONY: run_api_composer run_front_composer_install: - $(COMPOSE) run front-composer install --prefer-dist --no-suggest --no-interaction --no-scripts --optimize-autoloader + $(COMPOSE) run --rm front-composer install --prefer-dist --no-suggest --no-interaction --no-scripts --optimize-autoloader .PHONY: run_front_composer_install run_api_composer_install: - $(COMPOSE) run api-composer install --prefer-dist --no-suggest --no-interaction --no-scripts --optimize-autoloader + $(COMPOSE) run --rm api-composer install --prefer-dist --no-suggest --no-interaction --no-scripts --optimize-autoloader .PHONY: run_api_composer_install run_front_composer_update: - $(COMPOSE) run front-composer update + $(COMPOSE) run --rm front-composer update .PHONY: run_front_composer_update run_front_npm_update: - $(COMPOSE) run --entrypoint="/bin/sh -c" webpack "npm update" + $(COMPOSE) run --rm --entrypoint="/bin/sh -c" webpack "npm update" .PHONY: run_front_npm_update run_api_composer_update: - $(COMPOSE) run api-composer update + $(COMPOSE) run --rm api-composer update .PHONY: run_api_composer_update run_smoke_composer_update: - $(TEST_COMPOSE) run smoke-tests composer update + $(TEST_COMPOSE) run --rm smoke-tests composer update .PHONY: run_smoke_composer_update run_update: run_front_composer_update run_front_npm_update run_api_composer_update run_smoke_composer_update @@ -186,7 +186,7 @@ clear_config_cache: .PHONY: clear_config_cache smoke_tests: - $(TEST_COMPOSE) run smoke-tests vendor/bin/behat $(filter-out $@,$(MAKECMDGOALS)) + $(TEST_COMPOSE) run --rm smoke-tests vendor/bin/behat $(filter-out $@,$(MAKECMDGOALS)) .PHONY: smoke_tests run-structurizr: diff --git a/docker-compose.dependencies.yml b/docker-compose.dependencies.yml index 43d6e97849..5006ea0e91 100644 --- a/docker-compose.dependencies.yml +++ b/docker-compose.dependencies.yml @@ -103,7 +103,7 @@ services: mock-one-login: container_name: mock-one-login - image: 311462405659.dkr.ecr.eu-west-1.amazonaws.com/use_an_lpa/mock_onelogin_app:v0.60.0 + image: 311462405659.dkr.ecr.eu-west-1.amazonaws.com/mock-onelogin:v0.68.0 ports: - "4013:8080" environment: diff --git a/service-api/app/composer.lock b/service-api/app/composer.lock index 8ba41689d5..ceadf3cbbe 100644 --- a/service-api/app/composer.lock +++ b/service-api/app/composer.lock @@ -115,16 +115,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.304.2", + "version": "3.304.4", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "2435079c3e1a08148d955de15ec090018114f35a" + "reference": "20be41a5f1eef4c8a53a6ae7c0fc8b7346c0c386" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/2435079c3e1a08148d955de15ec090018114f35a", - "reference": "2435079c3e1a08148d955de15ec090018114f35a", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/20be41a5f1eef4c8a53a6ae7c0fc8b7346c0c386", + "reference": "20be41a5f1eef4c8a53a6ae7c0fc8b7346c0c386", "shasum": "" }, "require": { @@ -204,9 +204,9 @@ "support": { "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.304.2" + "source": "https://github.com/aws/aws-sdk-php/tree/3.304.4" }, - "time": "2024-04-10T18:05:32+00:00" + "time": "2024-04-12T18:06:45+00:00" }, { "name": "blazon/psr11-monolog", @@ -2290,16 +2290,16 @@ }, { "name": "monolog/monolog", - "version": "2.9.2", + "version": "2.9.3", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "437cb3628f4cf6042cc10ae97fc2b8472e48ca1f" + "reference": "a30bfe2e142720dfa990d0a7e573997f5d884215" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/437cb3628f4cf6042cc10ae97fc2b8472e48ca1f", - "reference": "437cb3628f4cf6042cc10ae97fc2b8472e48ca1f", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/a30bfe2e142720dfa990d0a7e573997f5d884215", + "reference": "a30bfe2e142720dfa990d0a7e573997f5d884215", "shasum": "" }, "require": { @@ -2320,8 +2320,8 @@ "mongodb/mongodb": "^1.8", "php-amqplib/php-amqplib": "~2.4 || ^3", "phpspec/prophecy": "^1.15", - "phpstan/phpstan": "^0.12.91", - "phpunit/phpunit": "^8.5.14", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^8.5.38 || ^9.6.19", "predis/predis": "^1.1 || ^2.0", "rollbar/rollbar": "^1.3 || ^2 || ^3", "ruflin/elastica": "^7", @@ -2376,7 +2376,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/2.9.2" + "source": "https://github.com/Seldaek/monolog/tree/2.9.3" }, "funding": [ { @@ -2388,7 +2388,7 @@ "type": "tidelift" } ], - "time": "2023-10-27T15:25:26+00:00" + "time": "2024-04-12T20:52:51+00:00" }, { "name": "mtdowling/jmespath.php", @@ -6055,16 +6055,16 @@ }, { "name": "amphp/byte-stream", - "version": "v1.8.1", + "version": "v1.8.2", "source": { "type": "git", "url": "https://github.com/amphp/byte-stream.git", - "reference": "acbd8002b3536485c997c4e019206b3f10ca15bd" + "reference": "4f0e968ba3798a423730f567b1b50d3441c16ddc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/byte-stream/zipball/acbd8002b3536485c997c4e019206b3f10ca15bd", - "reference": "acbd8002b3536485c997c4e019206b3f10ca15bd", + "url": "https://api.github.com/repos/amphp/byte-stream/zipball/4f0e968ba3798a423730f567b1b50d3441c16ddc", + "reference": "4f0e968ba3798a423730f567b1b50d3441c16ddc", "shasum": "" }, "require": { @@ -6080,11 +6080,6 @@ "psalm/phar": "^3.11.4" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.x-dev" - } - }, "autoload": { "files": [ "lib/functions.php" @@ -6108,7 +6103,7 @@ } ], "description": "A stream abstraction to make working with non-blocking I/O simple.", - "homepage": "http://amphp.org/byte-stream", + "homepage": "https://amphp.org/byte-stream", "keywords": [ "amp", "amphp", @@ -6118,9 +6113,8 @@ "stream" ], "support": { - "irc": "irc://irc.freenode.org/amphp", "issues": "https://github.com/amphp/byte-stream/issues", - "source": "https://github.com/amphp/byte-stream/tree/v1.8.1" + "source": "https://github.com/amphp/byte-stream/tree/v1.8.2" }, "funding": [ { @@ -6128,7 +6122,7 @@ "type": "github" } ], - "time": "2021-03-30T17:13:30+00:00" + "time": "2024-04-13T18:00:56+00:00" }, { "name": "amphp/cache", @@ -7628,21 +7622,21 @@ }, { "name": "daverandom/libdns", - "version": "v2.0.3", + "version": "v2.1.0", "source": { "type": "git", "url": "https://github.com/DaveRandom/LibDNS.git", - "reference": "42c2d700d1178c9f9e78664793463f7f1aea248c" + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/DaveRandom/LibDNS/zipball/42c2d700d1178c9f9e78664793463f7f1aea248c", - "reference": "42c2d700d1178c9f9e78664793463f7f1aea248c", + "url": "https://api.github.com/repos/DaveRandom/LibDNS/zipball/b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", "shasum": "" }, "require": { "ext-ctype": "*", - "php": ">=7.0" + "php": ">=7.1" }, "suggest": { "ext-intl": "Required for IDN support" @@ -7666,9 +7660,9 @@ ], "support": { "issues": "https://github.com/DaveRandom/LibDNS/issues", - "source": "https://github.com/DaveRandom/LibDNS/tree/v2.0.3" + "source": "https://github.com/DaveRandom/LibDNS/tree/v2.1.0" }, - "time": "2022-09-20T18:15:38+00:00" + "time": "2024-04-12T12:12:48+00:00" }, { "name": "dealerdirect/phpcodesniffer-composer-installer", @@ -9935,16 +9929,16 @@ }, { "name": "phpunit/phpunit", - "version": "10.5.17", + "version": "10.5.18", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "c1f736a473d21957ead7e94fcc029f571895abf5" + "reference": "835df1709ac6c968ba34bf23f3c30e5d5a266de8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c1f736a473d21957ead7e94fcc029f571895abf5", - "reference": "c1f736a473d21957ead7e94fcc029f571895abf5", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/835df1709ac6c968ba34bf23f3c30e5d5a266de8", + "reference": "835df1709ac6c968ba34bf23f3c30e5d5a266de8", "shasum": "" }, "require": { @@ -10016,7 +10010,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.17" + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.18" }, "funding": [ { @@ -10032,7 +10026,7 @@ "type": "tidelift" } ], - "time": "2024-04-05T04:39:01+00:00" + "time": "2024-04-14T07:05:31+00:00" }, { "name": "psalm/plugin-phpunit", @@ -10150,12 +10144,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "31f373849a62ccfe23cba594e91b488e3ec2270b" + "reference": "1b43da6199cab7867f06b52454a256efa743f170" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/31f373849a62ccfe23cba594e91b488e3ec2270b", - "reference": "31f373849a62ccfe23cba594e91b488e3ec2270b", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/1b43da6199cab7867f06b52454a256efa743f170", + "reference": "1b43da6199cab7867f06b52454a256efa743f170", "shasum": "" }, "conflict": { @@ -10461,7 +10455,7 @@ "mantisbt/mantisbt": "<2.26.1", "marcwillmann/turn": "<0.3.3", "matyhtf/framework": "<3.0.6", - "mautic/core": "<4.3", + "mautic/core": "<4.4.12|>=5.0.0.0-alpha,<5.0.4", "mediawiki/core": "<1.36.2", "mediawiki/matomo": "<2.4.3", "mediawiki/semantic-media-wiki": "<4.0.2", @@ -10605,7 +10599,7 @@ "really-simple-plugins/complianz-gdpr": "<6.4.2", "redaxo/source": "<=5.15.1", "remdex/livehelperchat": "<4.29", - "reportico-web/reportico": "<=7.1.21", + "reportico-web/reportico": "<=8.1", "rhukster/dom-sanitizer": "<1.0.7", "rmccue/requests": ">=1.6,<1.8", "robrichards/xmlseclibs": ">=1,<3.0.4", @@ -10734,6 +10728,7 @@ "thinkcmf/thinkcmf": "<=5.1.7", "thorsten/phpmyfaq": "<3.2.2", "tikiwiki/tiki-manager": "<=17.1", + "timber/timber": "<=2", "tinymce/tinymce": "<7", "tinymighty/wiki-seo": "<1.2.2", "titon/framework": "<9.9.99", @@ -10787,6 +10782,7 @@ "wikimedia/parsoid": "<0.12.2", "willdurand/js-translation-bundle": "<2.1.1", "winter/wn-backend-module": "<1.2.4", + "winter/wn-dusk-plugin": "<2.1", "winter/wn-system-module": "<1.2.4", "wintercms/winter": "<=1.2.3", "woocommerce/woocommerce": "<6.6", @@ -10887,7 +10883,7 @@ "type": "tidelift" } ], - "time": "2024-04-09T19:04:27+00:00" + "time": "2024-04-12T22:04:12+00:00" }, { "name": "sebastian/cli-parser", diff --git a/service-api/app/config/routes.php b/service-api/app/config/routes.php index 3f8599e84f..075e31715d 100644 --- a/service-api/app/config/routes.php +++ b/service-api/app/config/routes.php @@ -20,6 +20,7 @@ use App\Handler\LpasResourceImagesCollectionHandler; use App\Handler\NotifyHandler; use App\Handler\OneLoginAuthenticationCallbackHandler; +use App\Handler\OneLoginAuthenticationLogoutHandler; use App\Handler\OneLoginAuthenticationRequestHandler; use App\Handler\RequestChangeEmailHandler; use App\Handler\RequestCleanseHandler; @@ -138,6 +139,7 @@ $app->get('/v1/auth/start', OneLoginAuthenticationRequestHandler::class, 'user.auth-start'); $app->post('/v1/auth/callback', OneLoginAuthenticationCallbackHandler::class, 'user.auth-callback'); + $app->put('/v1/auth/logout', OneLoginAuthenticationLogoutHandler::class, 'user.auth-logout'); $app->post('/v1/email-user/{emailTemplate}', NotifyHandler::class, 'lpa.user.notify'); }; diff --git a/service-api/app/features/actor-logout.feature b/service-api/app/features/actor-logout.feature new file mode 100644 index 0000000000..76b0e00f99 --- /dev/null +++ b/service-api/app/features/actor-logout.feature @@ -0,0 +1,15 @@ +@actor @logout +Feature: A user of the system is able to logout + As a user of the lpa application + I can logout when logged in + So that I am sure my account is secure + + Background: + Given I am a user of the lpa application + And I have been given access to use an LPA via credentials + + @acceptance @ff:allow_gov_one_login:true + Scenario: A user can logout + Given I am currently signed in + When I logout of the application + Then I am taken to complete a satisfaction survey \ No newline at end of file diff --git a/service-api/app/features/one-login.feature b/service-api/app/features/actor-one-login.feature similarity index 66% rename from service-api/app/features/one-login.feature rename to service-api/app/features/actor-one-login.feature index cd8b315dbe..edca6638ec 100644 --- a/service-api/app/features/one-login.feature +++ b/service-api/app/features/actor-one-login.feature @@ -13,3 +13,10 @@ Feature: Authorise One Login And I have an existing local account When I am returned to the use an lpa service Then I am taken to my dashboard + + @acceptance + Scenario: I can successfully log out via one login + Given I have completed a successful one login sign-in process + And I have an existing local account + When I am returned to the use an lpa service + Then I am taken to my dashboard \ No newline at end of file diff --git a/service-api/app/features/context/Acceptance/OidcContext.php b/service-api/app/features/context/Acceptance/OidcContext.php index be59dc0ccb..c0802f475d 100644 --- a/service-api/app/features/context/Acceptance/OidcContext.php +++ b/service-api/app/features/context/Acceptance/OidcContext.php @@ -111,6 +111,7 @@ function (RequestInterface $request): ResponseInterface { 'token_endpoint' => 'https://one-login-mock/token', 'userinfo_endpoint' => 'https://one-login-mock/userinfo', 'jwks_uri' => 'https://one-login-mock/.well-known/jwks', + 'end_session_endpoint' => 'https://one-login-mock/logout', ], ), ); @@ -302,6 +303,18 @@ function (RequestInterface $request): ResponseInterface { ); } + /** + * @Then /^I am taken to complete a satisfaction survey$/ + */ + public function iAmTakenToCompleteASatisfactionSurvey(): void + { + $this->ui->assertSession()->statusCodeEquals(StatusCodeInterface::STATUS_OK); + + $response = $this->getResponseAsJson(); + + Assert::assertArrayHasKey('redirect_uri', $response); + } + /** * @Then /^I am taken to my dashboard$/ */ @@ -311,15 +324,19 @@ public function iAmTakenToMyDashboard(): void $response = $this->getResponseAsJson(); - Assert::assertArrayHasKey('Id', $response); - Assert::assertArrayHasKey('Identity', $response); - Assert::assertArrayHasKey('Email', $response); - Assert::assertArrayHasKey('LastLogin', $response); + Assert::assertArrayHasKey('user', $response); + Assert::assertArrayHasKey('token', $response); - Assert::assertArrayNotHasKey('Password', $response); + $user = $response['user']; + Assert::assertArrayHasKey('Id', $user); + Assert::assertArrayHasKey('Identity', $user); + Assert::assertArrayHasKey('Email', $user); + Assert::assertArrayHasKey('LastLogin', $user); - Assert::assertSame($response['Identity'], $this->sub); - Assert::assertSame($response['Email'], $this->email); + Assert::assertArrayNotHasKey('Password', $user); + + Assert::assertSame($user['Identity'], $this->sub); + Assert::assertSame($user['Email'], $this->email); } /** @@ -338,6 +355,27 @@ public function iHaveCompletedASuccessfulOneLoginSignInProcess(): void // Not needed in this context } + /** + * @When /^I logout of the application$/ + */ + public function iLogoutOfTheApplication(): void + { + $this->oidcFixtureSetup(); + + $this->apiPut( + '/v1/auth/logout', + [ + 'user' => [ + 'Id' => '0000-00-00-00-000', + 'Identity' => $this->sub, + 'Email' => $this->email, + 'LastLogin' => (new DateTimeImmutable('-1 day'))->format('c'), + 'IdToken' => $this->identityTokenSetup(), + ], + ], + ); + } + /** * @When /^I start the login process$/ */ diff --git a/service-api/app/features/context/BaseAcceptanceContextTrait.php b/service-api/app/features/context/BaseAcceptanceContextTrait.php index 2e435725e5..1a03d058d1 100644 --- a/service-api/app/features/context/BaseAcceptanceContextTrait.php +++ b/service-api/app/features/context/BaseAcceptanceContextTrait.php @@ -108,7 +108,7 @@ protected function apiPost(string $url, array $data, ?array $headers = null): vo protected function apiPut(string $url, array $data, ?array $headers = null): void { - $this->getSession()->getDriver()->getClient()->jsonRequest( + $this->ui->getSession()->getDriver()->getClient()->jsonRequest( 'PUT', $url, $data, diff --git a/service-api/app/src/App/src/Handler/OneLoginAuthenticationLogoutHandler.php b/service-api/app/src/App/src/Handler/OneLoginAuthenticationLogoutHandler.php new file mode 100644 index 0000000000..aa45e3a949 --- /dev/null +++ b/service-api/app/src/App/src/Handler/OneLoginAuthenticationLogoutHandler.php @@ -0,0 +1,48 @@ +getParsedBody(); + + if (empty($requestData['user'])) { + throw new BadRequestException('User must be provided'); + } + + if (empty($requestData['user']['IdToken'])) { + throw new BadRequestException('User does not contain an OIDC token value'); + } + + return new JsonResponse( + [ + 'redirect_uri' => $this->logoutHandlingService->createLogoutUrl($requestData['user']['IdToken']), + ], + ); + } +} diff --git a/service-api/app/src/App/src/Service/Authentication/AuthorisationClientManager.php b/service-api/app/src/App/src/Service/Authentication/AuthorisationClientManager.php index 567363a114..6f5eff120c 100644 --- a/service-api/app/src/App/src/Service/Authentication/AuthorisationClientManager.php +++ b/service-api/app/src/App/src/Service/Authentication/AuthorisationClientManager.php @@ -11,10 +11,16 @@ use Facile\OpenIDClient\Client\ClientInterface; use Facile\OpenIDClient\Client\Metadata\ClientMetadata; use Facile\OpenIDClient\Issuer\Metadata\Provider\MetadataProviderBuilder; +use Laminas\Cache\Psr\SimpleCache\SimpleCacheException; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\NotFoundExceptionInterface; use Psr\Http\Client\ClientInterface as HttpClientInterface; +use RuntimeException; class AuthorisationClientManager { + public const CACHE_TTL = 3600; + public function __construct( private string $clientId, private string $clientDiscoveryEndpoint, @@ -26,19 +32,25 @@ public function __construct( ) { } + /** + * @throws NotFoundExceptionInterface + * @throws SimpleCacheException + * @throws ContainerExceptionInterface + * @throws RuntimeException + */ public function get(): ClientInterface { $cachedBuilder = new MetadataProviderBuilder(); $cachedBuilder ->setHttpClient($this->httpClient) ->setCache(($this->cacheFactory)('one-login')) - ->setCacheTtl(3600); + ->setCacheTtl(self::CACHE_TTL); $cachedProvider = new JwksProviderBuilder(); $cachedProvider ->setHttpClient($this->httpClient) ->setCache(($this->cacheFactory)('one-login')) - ->setCacheTtl(3600); + ->setCacheTtl(self::CACHE_TTL); $issuer = $this->issuerBuilder ->setMetadataProviderBuilder($cachedBuilder) diff --git a/service-api/app/src/App/src/Service/Authentication/AuthorisationService.php b/service-api/app/src/App/src/Service/Authentication/AuthorisationService.php index 812d62cd72..f286d11004 100644 --- a/service-api/app/src/App/src/Service/Authentication/AuthorisationService.php +++ b/service-api/app/src/App/src/Service/Authentication/AuthorisationService.php @@ -9,10 +9,14 @@ use Facile\OpenIDClient\Service\AuthorizationService; use Facile\OpenIDClient\Session\AuthSession; use Facile\OpenIDClient\Token\TokenSetInterface; +use Laminas\Cache\Psr\SimpleCache\SimpleCacheException; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\NotFoundExceptionInterface; +use RuntimeException; use Throwable; /** - * Facade class for Facile AuthorizationService + * Facade class for Facile AuthorizationService and components * * @link https://en.wikipedia.org/wiki/Facade_pattern * @@ -85,9 +89,40 @@ public function callback(string $code, string $state, array $session): TokenSetI } } + /** + * Interrogates the client metadata to find the OIDC end_session_endpoint URI + * + * @throws AuthorisationServiceException + */ + public function getLogoutUri(): string + { + try { + $endpoint = $this->getClient()->getIssuer()->getMetadata()->get('end_session_endpoint'); + + if ($endpoint === null) { + throw new AuthorisationServiceException( + '"end_session_endpoint" not defined in Issuers OIDC configuration' + ); + } + + return $endpoint; + } catch (Throwable $e) { + throw new AuthorisationServiceException( + 'JSON error encountered when fetching logout uri', + 500, + $e + ); + } + } + /** * Ensures each instance of this class only builds a single client instance. In practice this should amount to * once per request. + * + * @throws SimpleCacheException + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + * @throws RuntimeException */ private function getClient(): OpenIDClient { diff --git a/service-api/app/src/App/src/Service/Authentication/OneLoginService.php b/service-api/app/src/App/src/Service/Authentication/OneLoginService.php index e81c88d607..b24eb625fc 100644 --- a/service-api/app/src/App/src/Service/Authentication/OneLoginService.php +++ b/service-api/app/src/App/src/Service/Authentication/OneLoginService.php @@ -21,7 +21,8 @@ */ class OneLoginService { - public const CORE_IDENTITY_JWT = 'https://vocab.account.gov.uk/v1/coreIdentityJWT'; + public const CORE_IDENTITY_JWT = 'https://vocab.account.gov.uk/v1/coreIdentityJWT'; + public const LOGOUT_REDIRECT_URL = 'https://www.gov.uk/done/use-lasting-power-of-attorney'; public function __construct( private AuthorisationServiceBuilder $authorisationServiceBuilder, @@ -68,8 +69,14 @@ public function createAuthenticationRequest(string $uiLocale, string $redirectUR * redirect_uri: string * } * } $authSession A pair of values needed generated at the start of the process - * @return array The User retrieved from our records - * @psalm-return ActorUser + * @return array{ + * user: array, + * token: string, + * } The User retrieved from our records + * @psalm-return array{ + * user: ActorUser, + * token: string, + * } The User retrieved from our records * @throws AuthorisationServiceException Throw whilst failing to talk to the OIDC service * @throws CreationException It was not possible to create a new user * @throws ConflictException The email exists as a "NewEmail" against an existing account @@ -88,6 +95,27 @@ public function handleCallback( $info = $this->userInfoService->getUserInfo($tokens); - return ($this->resolveOAuthUser)($info['sub'], $info['email']); + return [ + 'user' => ($this->resolveOAuthUser)($info['sub'], $info['email']), + 'token' => $tokens->getIdToken(), + ]; + } + + /** + * @param string $idToken The ID token retrieved as a part of the original authentication flow + * @return string A URL with populated parameters + * @throws AuthorisationServiceException + */ + public function createLogoutUrl(string $idToken): string + { + $authorisationService = $this->authorisationServiceBuilder->build(); + $logoutUri = $authorisationService->getLogoutUri(); + + $params = [ + 'id_token_hint' => $idToken, + 'post_logout_redirect_uri' => self::LOGOUT_REDIRECT_URL, + ]; + + return $logoutUri . '?' . http_build_query($params); } } diff --git a/service-api/app/src/App/src/Service/Cache/CacheFactory.php b/service-api/app/src/App/src/Service/Cache/CacheFactory.php index e674741c95..559855ca58 100644 --- a/service-api/app/src/App/src/Service/Cache/CacheFactory.php +++ b/service-api/app/src/App/src/Service/Cache/CacheFactory.php @@ -5,9 +5,11 @@ namespace App\Service\Cache; use Laminas\Cache\Psr\SimpleCache\SimpleCacheDecorator; +use Laminas\Cache\Psr\SimpleCache\SimpleCacheException; use Laminas\Cache\Service\StorageAdapterFactoryInterface; +use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; -use Laminas\Cache\Storage\Adapter\Apcu; +use Psr\Container\NotFoundExceptionInterface; use Psr\SimpleCache\CacheInterface; use RuntimeException; @@ -17,6 +19,14 @@ public function __construct(private ContainerInterface $container) { } + /** + * @param string $cacheName + * @return CacheInterface + * @throws RuntimeException + * @throws SimpleCacheException + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ public function __invoke(string $cacheName): CacheInterface { $config = $this->container->get('config'); @@ -27,9 +37,12 @@ public function __invoke(string $cacheName): CacheInterface if (!isset($config['cache'][$cacheName])) { throw new RuntimeException('Missing cache configuration for ' . $cacheName); } + + /** @var StorageAdapterFactoryInterface $factory */ $factory = $this->container->get(StorageAdapterFactoryInterface::class); - /** @var Apcu $cacheAdaptor */ + $cacheAdaptor = $factory->createFromArrayConfiguration($config['cache'][$cacheName]); + return new SimpleCacheDecorator($cacheAdaptor); } } diff --git a/service-api/app/test/AppTest/Service/Authentication/OneLoginServiceTest.php b/service-api/app/test/AppTest/Service/Authentication/OneLoginServiceTest.php index bd8c96036a..e70238ad33 100644 --- a/service-api/app/test/AppTest/Service/Authentication/OneLoginServiceTest.php +++ b/service-api/app/test/AppTest/Service/Authentication/OneLoginServiceTest.php @@ -126,12 +126,14 @@ public function handle_callback(): void $fakeSession, ); - $this->assertArrayHasKey('Id', $user); - $this->assertArrayHasKey('Identity', $user); - $this->assertArrayHasKey('Email', $user); + $this->assertArrayHasKey('user', $user); - $this->assertSame('fakeSub', $user['Identity']); - $this->assertSame('fakeEmail', $user['Email']); + $this->assertArrayHasKey('Id', $user['user']); + $this->assertArrayHasKey('Identity', $user['user']); + $this->assertArrayHasKey('Email', $user['user']); + + $this->assertSame('fakeSub', $user['user']['Identity']); + $this->assertSame('fakeEmail', $user['user']['Email']); } #[Test] @@ -171,4 +173,35 @@ public function handle_callback_missing_token(): void $fakeSession, ); } + + #[Test] + public function creates_logout_url(): void + { + $fakeRedirect = 'http://fakehost/logout'; + + $service = $this->prophesize(AuthorisationService::class); + $service->getLogoutUri()->willReturn($fakeRedirect); + + $serviceBuilder = $this->prophesize(AuthorisationServiceBuilder::class); + $serviceBuilder->build() + ->willReturn($service->reveal()); + + $randomByteGenerator = $this->prophesize(RandomByteGenerator::class); + + $authorisationRequestService = new OneLoginService( + $serviceBuilder->reveal(), + $this->prophesize(UserInfoService::class)->reveal(), + $this->prophesize(ResolveOAuthUser::class)->reveal(), + $randomByteGenerator->reveal(), + ); + + $logoutUrl = $authorisationRequestService->createLogoutUrl('token'); + + $this->assertStringContainsString($fakeRedirect, $logoutUrl); + $this->assertStringContainsString('id_token_hint=token', $logoutUrl); + $this->assertStringContainsString( + 'post_logout_redirect_uri=' . urlencode(OneLoginService::LOGOUT_REDIRECT_URL), + $logoutUrl, + ); + } }