diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 1d1d062f..7f40dbdb 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -211,7 +211,11 @@ jobs: strategy: fail-fast: false matrix: - ssp-version: ["v2.5.0"] + # SimpleSAMLphp ref to build the OP base image from. Any Composer version works: a released tag + # (e.g. "v2.5.2") or an (unreleased) branch (e.g. "dev-simplesamlphp-2.5"). During v7 development + # the module depends on the unreleased simplesamlphp-2.5 branch, which exposes + # \SimpleSAML\Locale\Language::getAvailableLanguages() used by the ui_locales support. + ssp-composer-version: ["dev-simplesamlphp-2.5"] env: SUITE_BASE_URL: https://localhost.emobix.co.uk:8443 VERSION: release-v5.1.45 @@ -241,11 +245,22 @@ jobs: run: | docker compose -f docker-compose-dev.yml up -d while ! curl -skfail https://localhost.emobix.co.uk:8443/api/runner/available >/dev/null; do sleep 2; done + - name: Build SimpleSAMLphp base image + # SimpleSAMLphp is installed into the base image via Composer (cirrus's SSP_COMPOSER_VERSION path), + # so any tag or branch can be used - here the unreleased simplesamlphp-2.5 branch the module + # currently depends on. The image is built locally each run and not pushed to any registry. The + # cirrus image recipe is pinned to a commit for reproducibility; bump it when needed. + run: | + git clone https://github.com/cirrusidentity/docker-simplesamlphp.git ssp-base-image + git -C ssp-base-image checkout 0bf3ae9df5c0 + docker build ssp-base-image/docker \ + --build-arg SSP_COMPOSER_VERSION=${{ matrix.ssp-composer-version }} \ + -t ssp-base:${{ matrix.ssp-composer-version }} - name: Start SSP docker working-directory: ./main # Must run after conformance suite since they share a docker network. run: | - SSP_VERSION=${{ matrix.ssp-version }} OIDC_VERSION=@dev docker compose -f docker/docker-compose.yml --project-directory . up -d --build + SSP_IMAGE=ssp-base:${{ matrix.ssp-composer-version }} OIDC_VERSION=@dev docker compose -f docker/docker-compose.yml --project-directory . up -d --build sleep 30 # while ! curl -skfail https://op.local.stack-dev.cirrusidentity.com/.well-known/openid-configuration >/dev/null; do sleep 2; done - name: Run Basic conformance tests diff --git a/composer.json b/composer.json index abe3ddeb..e4500362 100644 --- a/composer.json +++ b/composer.json @@ -36,13 +36,14 @@ "spomky-labs/base64url": "^2.0", "symfony/cache": "^7.4", "symfony/expression-language": "^7.4", + "symfony/intl": "^7.4", "symfony/psr-http-message-bridge": "^7.4", "web-token/jwt-framework": "^3.4.10" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3", "rector/rector": "^1.2.10", - "simplesamlphp/simplesamlphp": "2.5.*", + "simplesamlphp/simplesamlphp": "dev-simplesamlphp-2.5", "simplesamlphp/simplesamlphp-test-framework": "^1.9.3", "vimeo/psalm": "^6.15.1", "testcontainers/testcontainers": "^0.2", diff --git a/docker/Dockerfile b/docker/Dockerfile index abf08d8b..c6f25c00 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,6 +1,8 @@ -ARG SSP_VERSION="v2.5.0" -FROM cirrusid/simplesamlphp:${SSP_VERSION} -#FROM cicnavi/simplesamlphp:${SSP_VERSION} +# Base SimpleSAMLphp image. Defaults to a published cirrusid/simplesamlphp release tag, but can be +# overridden to run against a different base image, for example one built locally from an unreleased +# SimpleSAMLphp branch (see "Build against an unreleased SimpleSAMLphp version" in docs/4-oidc-docker.md). +ARG SSP_IMAGE="cirrusid/simplesamlphp:v2.5.0" +FROM ${SSP_IMAGE} RUN apt-get update && apt-get --no-install-recommends install -y sqlite3 # Prepopulate the DB with items needed for testing diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 5a634700..cb6745e3 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -22,7 +22,7 @@ services: context: . dockerfile: docker/Dockerfile args: - SSP_VERSION: "${SSP_VERSION}" + SSP_IMAGE: "${SSP_IMAGE:-cirrusid/simplesamlphp:v2.5.0}" OIDC_VERSION: "${OIDC_VERSION}" environment: - STAGINGCOMPOSERREPOS=oidc diff --git a/docs/1-oidc.md b/docs/1-oidc.md index 2ad1feb4..3e80c584 100644 --- a/docs/1-oidc.md +++ b/docs/1-oidc.md @@ -180,8 +180,11 @@ Minor versions listed show which SimpleSAMLphp versions were used during module development. SimpleSAMLphp follows semantic versioning for its API since v2.0. PHP version requirements may differ. +TODO mivanci require simplesamlphp/simplesamlphp:^2.5.3 in composer.json + | OIDC module | Tested SimpleSAMLphp | PHP | |:------------|:---------------------|:------:| +| v7\* | dev-simplesamlphp-2.5| \>=8.3 | | v6.4.\* | v2.5.\* | \>=8.3 | | v6.3.\* | v2.3.\*, v2.4.\* | \>=8.2 | | v5.\* | v2.1.\* | \>=8.1 | diff --git a/docs/4-oidc-docker.md b/docs/4-oidc-docker.md index 6b0d13c3..e7591a8d 100644 --- a/docs/4-oidc-docker.md +++ b/docs/4-oidc-docker.md @@ -6,6 +6,7 @@ This document shows how to run and test the module with Docker. - Local testing with other DBs - Testing AuthProc filters - Build image for conformance tests +- Build against an unreleased SimpleSAMLphp version - Docker Compose ## Run with the current git branch (live mount) @@ -117,18 +118,59 @@ docker exec ssp-oidc-dev-image sqlite3 /var/simplesamlphp/data/mydb.sq3 '.dump' Conformance tests are easier to run locally. See [Conformance](5-oidc-conformance.md). +## Build against an unreleased SimpleSAMLphp version + +The OP image is built `FROM` a SimpleSAMLphp base image, selected with the +`SSP_IMAGE` build argument (default: a published `cirrusid/simplesamlphp` +release tag). Published tags only exist for SimpleSAMLphp *releases*, so to run +against an unreleased SimpleSAMLphp branch (or any specific ref) you first build +a base image from that ref and then point `SSP_IMAGE` at it. + +> During v7 development the module depends on the unreleased `simplesamlphp-2.5` +> branch (it uses `\SimpleSAML\Locale\Language::getAvailableLanguages()`, added +> there for the `ui_locales` support). Until a SimpleSAMLphp release contains it, +> the OP image must be built from that branch, otherwise the discovery endpoint +> fails. + +The [cirrusid/simplesamlphp image](https://github.com/cirrusidentity/docker-simplesamlphp) +installs SimpleSAMLphp with Composer when given an `SSP_COMPOSER_VERSION` build +argument, which accepts any Composer version — a branch (`dev-`) or a +tag (`v2.5.2`): + +```bash +# 1. Build a SimpleSAMLphp base image from the desired ref (branch or tag). +git clone https://github.com/cirrusidentity/docker-simplesamlphp.git +docker build docker-simplesamlphp/docker \ + --build-arg SSP_COMPOSER_VERSION=dev-simplesamlphp-2.5 \ + -t ssp-base:dev-simplesamlphp-2.5 + +# 2. Build and run the OP on top of it. For the docker/Dockerfile-based builds +# (Docker Compose below and the conformance image above) set SSP_IMAGE: +SSP_IMAGE=ssp-base:dev-simplesamlphp-2.5 OIDC_VERSION=@dev \ + docker compose -f docker/docker-compose.yml --project-directory . up --build +``` + +For the live-mount `docker run` example above, use the built base image name +(`ssp-base:dev-simplesamlphp-2.5`) directly in place of +`cirrusid/simplesamlphp:...`. This is exactly what the GitHub Actions conformance +job does: it builds the base image from `matrix.ssp-composer-version` and passes +it as `SSP_IMAGE`. + ## Docker Compose Docker Compose runs multiple containers to ease testing. It builds an image containing the OIDC module. You can remove `--build` to reuse an -existing container. +existing container. The SimpleSAMLphp base image defaults to a published +`cirrusid/simplesamlphp` release tag; override it with `SSP_IMAGE` to run +against a different base (see "Build against an unreleased SimpleSAMLphp +version" above). ```bash # Use current branch/git checkout. Composer installs local checkout -OIDC_VERSION=@dev docker-compose -f docker/docker-compose.yml --project-directory . up --build +OIDC_VERSION=@dev docker compose -f docker/docker-compose.yml --project-directory . up --build # Use a specific module version -OIDC_VERSION=dev-master docker-compose -f docker/docker-compose.yml --project-directory . up --build +OIDC_VERSION=dev-master docker compose -f docker/docker-compose.yml --project-directory . up --build ``` Visit the OP and verify a few clients exist: diff --git a/docs/5-oidc-conformance.md b/docs/5-oidc-conformance.md index 21f626a7..205191ac 100644 --- a/docs/5-oidc-conformance.md +++ b/docs/5-oidc-conformance.md @@ -18,8 +18,8 @@ Clone, build, and run the conformance test suite: git clone https://gitlab.com/openid/conformance-suite.git cd conformance-suite git checkout release-v5.1.45 -MAVEN_CACHE=./m2 docker-compose -f builder-compose.yml run builder -docker-compose up +MAVEN_CACHE=./m2 docker compose -f builder-compose.yml run builder +docker compose up ``` This starts the Java conformance app and a MongoDB server. Then: @@ -35,8 +35,16 @@ Next, run your SSP OIDC image. ### Run SSP Run SSP with OIDC on the same Docker network as the conformance tests so -containers can communicate. See the "Docker Compose" section in the -README for details. +containers can communicate. See the "Docker Compose" section in +[Using Docker](4-oidc-docker.md) for details. + +The OP image is built on a SimpleSAMLphp base image. During v7 development the +module depends on the unreleased `simplesamlphp-2.5` branch, so the base image +must be built from that branch (or discovery fails); see +"Build against an unreleased SimpleSAMLphp version" in +[Using Docker](4-oidc-docker.md). The GitHub Actions conformance job does this +automatically: it builds the base image from the SimpleSAMLphp ref in its +matrix (`ssp-composer-version`) and passes it to the OP build as `SSP_IMAGE`. ### Run conformance tests (interactive) @@ -149,7 +157,7 @@ Because the plan is deterministic, the GitHub Actions step is a blocking gate (n `continue-on-error`). Prerequisites: run the docker deploy image for conformance tests (see -README) and the conformance test image first. +[Using Docker](4-oidc-docker.md)) and the conformance test image first. ## Run hosted tests @@ -158,8 +166,8 @@ OP must be publicly accessible on the internet. ### Deploy SSP OIDC image -Use the docker image described in the README. It contains a SQLite DB -pre-populated with data for the tests. Build and run the image. +Use the docker image described in [Using Docker](4-oidc-docker.md). It contains +a SQLite DB pre-populated with data for the tests. Build and run the image. ### Register and create conformance tests diff --git a/docs/6-oidc-upgrade.md b/docs/6-oidc-upgrade.md index 82a02e4f..43ec6a76 100644 --- a/docs/6-oidc-upgrade.md +++ b/docs/6-oidc-upgrade.md @@ -181,6 +181,29 @@ per-field metadata policy (honored / validated / rejected) is documented in future major version may switch the getters to fall back to the spec defaults.) DCR is also opt-in (disabled by default), so unless you enable it, nothing changes for your deployment. +- Support for the `ui_locales` parameter on the authorization and end session +endpoints (previously ignored). The parameter carries the End-User's preferred +UI languages as a space-separated list of BCP47 language tags, ordered by +preference. The most preferred requested language which is also available in +SimpleSAMLphp (per the `language.available` config option) is used to render +the screens shown during the flow. On the authorization endpoint, where the +login page and consent are rendered by SimpleSAMLphp in subsequent requests, +this is done via the standard SimpleSAMLphp language cookie (the same mechanism +as when the user picks a language on any SimpleSAMLphp page); to avoid a +client-supplied parameter silently overwriting a persistent, instance-wide +preference, an already present language cookie (an explicit choice the user +made) is not overridden. On the end session endpoint, the logout page is +rendered directly in the requested language, without setting the language +cookie. Matching includes a fallback to the primary language subtag (for +example, requested `fr-CA` matches available `fr`). Per specification this is +best-effort: if none of the requested languages are available, the parameter is +ignored without raising an error. The available languages are also advertised +in the OP discovery metadata via the `ui_locales_supported` claim (as BCP47 +language tags). Note: use the canonical SimpleSAMLphp locale codes in +`language.available` (for example `pt_BR` and `zh_TW`); the deprecated codes +(`pt-br`, `zh-tw`), which SimpleSAMLphp itself warns against, are not fully +supported by `ui_locales` on the authorization endpoint, because the language +cookie is validated against the raw configured codes. - Logging has been improved for authentication flows. It should now be easier to find information about what went wrong by looking at the relevant log entries. diff --git a/routing/services/services.yml b/routing/services/services.yml index 31fdbdcb..09a61087 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -107,6 +107,7 @@ services: SimpleSAML\Module\oidc\Utils\Routes: ~ SimpleSAML\Module\oidc\Utils\RequestParamsResolver: ~ SimpleSAML\Module\oidc\Utils\UserIdentifierResolver: ~ + SimpleSAML\Module\oidc\Utils\UiLocalesResolver: ~ SimpleSAML\Module\oidc\Utils\ClassInstanceBuilder: ~ SimpleSAML\Module\oidc\Utils\JwksResolver: ~ SimpleSAML\Module\oidc\Utils\AuthenticatedOAuth2ClientResolver: ~ diff --git a/src/Bridges/SspBridge.php b/src/Bridges/SspBridge.php index 6b5b7ffc..1ff9e3cb 100644 --- a/src/Bridges/SspBridge.php +++ b/src/Bridges/SspBridge.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Module\oidc\Bridges; use SimpleSAML\Module\oidc\Bridges\SspBridge\Auth; +use SimpleSAML\Module\oidc\Bridges\SspBridge\Locale; use SimpleSAML\Module\oidc\Bridges\SspBridge\Module; use SimpleSAML\Module\oidc\Bridges\SspBridge\Utils; @@ -17,6 +18,7 @@ class SspBridge protected static ?Auth $auth = null; protected static ?Utils $utils = null; protected static ?Module $module = null; + protected static ?Locale $locale = null; public function utils(): Utils { @@ -32,4 +34,9 @@ public function auth(): Auth { return self::$auth ??= new Auth(); } + + public function locale(): Locale + { + return self::$locale ??= new Locale(); + } } diff --git a/src/Bridges/SspBridge/Locale.php b/src/Bridges/SspBridge/Locale.php new file mode 100644 index 00000000..808a564b --- /dev/null +++ b/src/Bridges/SspBridge/Locale.php @@ -0,0 +1,17 @@ +getAvailableLanguages(); + } +} diff --git a/src/Controllers/AuthorizationController.php b/src/Controllers/AuthorizationController.php index 00d13570..0b2a77ac 100644 --- a/src/Controllers/AuthorizationController.php +++ b/src/Controllers/AuthorizationController.php @@ -17,10 +17,12 @@ namespace SimpleSAML\Module\oidc\Controllers; use League\OAuth2\Server\Exception\OAuthServerException; +use League\OAuth2\Server\RequestTypes\AuthorizationRequestInterface as OAuth2AuthorizationRequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Auth\ProcessingChain; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; +use SimpleSAML\Module\oidc\Bridges\SspBridge; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\AuthorizationServer; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; @@ -28,6 +30,7 @@ use SimpleSAML\Module\oidc\Services\AuthenticationService; use SimpleSAML\Module\oidc\Services\ErrorResponder; use SimpleSAML\Module\oidc\Services\LoggerService; +use SimpleSAML\Module\oidc\Utils\UiLocalesResolver; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -40,6 +43,8 @@ public function __construct( private readonly LoggerService $loggerService, private readonly PsrHttpBridge $psrHttpBridge, private readonly ErrorResponder $errorResponder, + private readonly UiLocalesResolver $uiLocalesResolver, + private readonly SspBridge $sspBridge, ) { } @@ -64,6 +69,7 @@ public function __invoke(ServerRequestInterface $request): ResponseInterface if (!isset($queryParameters[ProcessingChain::AUTHPARAM])) { $this->loggerService->debug('AuthorizationController::invoke: No AuthProcId query param.'); $authorizationRequest = $this->authorizationServer->validateAuthorizationRequest($request); + $this->setUiLanguage($authorizationRequest); $state = $this->authenticationService->processRequest($request, $authorizationRequest); // processState will trigger a redirect } @@ -123,6 +129,46 @@ public function authorization(Request $request): Response } } + /** + * Set the UI language for the current user agent based on the ui_locales authorization request parameter, + * if any of the requested languages are available in SimpleSAMLphp. This is done using the standard + * SimpleSAMLphp language cookie (same mechanism as when the user picks a language on any SimpleSAMLphp + * page), so subsequent screens shown during the authentication flow (login page, consent...) are + * rendered in the requested language. Per specification this is best-effort, so no error is raised + * if none of the requested languages are available. + * + * The SimpleSAMLphp language cookie is a persistent, instance-wide preference, so an already present + * cookie (an explicit language choice the user, or a previous interaction, has made) is not + * overridden by the client-supplied ui_locales value. + */ + protected function setUiLanguage(OAuth2AuthorizationRequestInterface $authorizationRequest): void + { + if (!$authorizationRequest instanceof AuthorizationRequest) { + return; + } + + $language = $this->uiLocalesResolver->resolve($authorizationRequest->getUiLocales()); + + if ($language === null) { + return; + } + + if ($this->sspBridge->locale()->language()->getLanguageCookie() !== null) { + $this->loggerService->debug( + 'AuthorizationController: not overriding existing language cookie with ui_locales parameter.', + ['uiLocales' => $authorizationRequest->getUiLocales(), 'language' => $language], + ); + return; + } + + $this->loggerService->debug( + 'AuthorizationController: setting UI language based on ui_locales parameter.', + ['uiLocales' => $authorizationRequest->getUiLocales(), 'language' => $language], + ); + + $this->sspBridge->locale()->language()->setLanguageCookie($language); + } + /** * Validate authorization request after the authn has been performed. For example, check if the * ACR claim has been requested and that authn performed satisfies it. diff --git a/src/Controllers/EndSessionController.php b/src/Controllers/EndSessionController.php index 977ff9b2..24f50f9a 100644 --- a/src/Controllers/EndSessionController.php +++ b/src/Controllers/EndSessionController.php @@ -15,6 +15,7 @@ use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Services\SessionService; use SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreBuilder; +use SimpleSAML\Module\oidc\Utils\UiLocalesResolver; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\Session; use Symfony\Component\HttpFoundation\RedirectResponse; @@ -32,6 +33,7 @@ public function __construct( protected TemplateFactory $templateFactory, protected PsrHttpBridge $psrHttpBridge, protected ErrorResponder $errorResponder, + protected UiLocalesResolver $uiLocalesResolver, ) { } @@ -51,6 +53,8 @@ public function __invoke(ServerRequestInterface $request): Response $logoutRequest = $this->authorizationServer->validateLogoutRequest($request); + $uiLanguage = $this->resolveUiLanguage($logoutRequest); + // Set indication that the logout is initiated using OIDC protocol. This will be checked in the // logoutHandler() method. $this->sessionService->setIsOidcInitiatedLogout(true); @@ -134,7 +138,33 @@ public function __invoke(ServerRequestInterface $request): Response // run for other logout initiated actions, like (currently) re-authentication... $this->sessionService->setIsOidcInitiatedLogout(false); - return $this->resolveResponse($logoutRequest, $wasLogoutActionCalled); + return $this->resolveResponse($logoutRequest, $wasLogoutActionCalled, $uiLanguage); + } + + /** + * Resolve the UI language to use for the logout page based on the ui_locales logout request parameter, if + * any of the requested languages are available in SimpleSAMLphp. The resolved language is applied only when + * rendering the logout page in the current request (see resolveResponse()); unlike the authorization flow, + * the logout flow renders at most a single page and has no subsequent SimpleSAMLphp screens, so the + * persistent, instance-wide language cookie is intentionally not set here (it would be a side effect + * affecting later unrelated screens, including when a post_logout_redirect_uri is used and no page is + * rendered at all). Per specification this is best-effort, so no error is raised if none of the + * requested languages are available. + */ + protected function resolveUiLanguage(LogoutRequest $logoutRequest): ?string + { + $language = $this->uiLocalesResolver->resolve($logoutRequest->getUiLocales()); + + if ($language === null) { + return null; + } + + $this->loggerService->debug( + 'EndSessionController: resolved UI language based on ui_locales parameter.', + ['uiLocales' => $logoutRequest->getUiLocales(), 'language' => $language], + ); + + return $language; } public function endSession(Request $request): Response @@ -211,8 +241,11 @@ public static function logoutHandler(): void /** * @throws \SimpleSAML\Error\ConfigurationError */ - protected function resolveResponse(LogoutRequest $logoutRequest, bool $wasLogoutActionCalled): Response - { + protected function resolveResponse( + LogoutRequest $logoutRequest, + bool $wasLogoutActionCalled, + ?string $uiLanguage = null, + ): Response { if (($postLogoutRedirectUri = $logoutRequest->getPostLogoutRedirectUri()) !== null) { $this->loggerService->debug( 'Logout request includes post-logout redirect URI: ' . $postLogoutRedirectUri, @@ -248,6 +281,7 @@ protected function resolveResponse(LogoutRequest $logoutRequest, bool $wasLogout showMenu: false, showModuleName: false, showSubPageTitle: false, + language: $uiLanguage, ); } } diff --git a/src/Factories/TemplateFactory.php b/src/Factories/TemplateFactory.php index 75d50fe6..f6ce5bea 100644 --- a/src/Factories/TemplateFactory.php +++ b/src/Factories/TemplateFactory.php @@ -54,9 +54,16 @@ public function build( ?bool $showMenu = null, ?bool $showModuleName = null, ?bool $showSubPageTitle = null, + ?string $language = null, ): Template { $template = new Template($this->sspConfiguration, $templateName); + if ($language !== null) { + // Render this template in the given language. Note that the language cookie is intentionally not + // set here (callers can do that themselves, for example, using the SSP bridge). + $template->getTranslator()->getLanguage()->setLanguage($language, false); + } + $includeDefaultMenuItems ??= $this->includeDefaultMenuItems; $showMenu ??= $this->showMenu; $showModuleName ??= $this->showModuleName; diff --git a/src/Server/AuthorizationServer.php b/src/Server/AuthorizationServer.php index ddcdc74c..bbde4398 100644 --- a/src/Server/AuthorizationServer.php +++ b/src/Server/AuthorizationServer.php @@ -196,7 +196,6 @@ public function validateLogoutRequest(ServerRequestInterface $request): LogoutRe $idTokenHint = $resultBag->getOrFail(IdTokenHintRule::class)->getValue(); $postLogoutRedirectUri = $resultBag->getOrFail(PostLogoutRedirectUriRule::class)->getValue(); $state = $resultBag->getOrFail(StateRule::class)->getValue(); - /** @var string|null $uiLocales */ $uiLocales = $resultBag->getOrFail(UiLocalesRule::class)->getValue(); return new LogoutRequest($idTokenHint, $postLogoutRedirectUri, $state, $uiLocales); diff --git a/src/Server/Grants/AuthCodeGrant.php b/src/Server/Grants/AuthCodeGrant.php index f3830d34..e083ea8c 100644 --- a/src/Server/Grants/AuthCodeGrant.php +++ b/src/Server/Grants/AuthCodeGrant.php @@ -65,6 +65,7 @@ use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ScopeOfflineAccessRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ScopeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\StateRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\UiLocalesRule; use SimpleSAML\Module\oidc\Server\RequestTypes\AuthorizationRequest; use SimpleSAML\Module\oidc\Server\ResponseModes\QueryResponseMode; use SimpleSAML\Module\oidc\Server\ResponseTypes\Interfaces\AcrResponseTypeInterface; @@ -867,6 +868,7 @@ public function validateAuthorizationRequestWithRequestRules( CodeChallengeMethodRule::class, IssuerStateRule::class, AuthorizationDetailsRule::class, + UiLocalesRule::class, ]; // Since we have already validated redirect_uri, and we have state, make it available for other checkers. @@ -984,6 +986,10 @@ public function validateAuthorizationRequestWithRequestRules( $this->loggerService->debug('AuthCodeGrant: ACR values: ', ['acrValues' => $acrValues]); $authorizationRequest->setRequestedAcrValues($acrValues); + $uiLocales = $resultBag->getOrFail(UiLocalesRule::class)->getValue(); + $this->loggerService->debug('AuthCodeGrant: UI locales: ', ['uiLocales' => $uiLocales]); + $authorizationRequest->setUiLocales($uiLocales); + $authorizationRequest->setIsVciRequest($isVciAuthorizationCodeRequest); $flowType = $isVciAuthorizationCodeRequest ? diff --git a/src/Server/Grants/ImplicitGrant.php b/src/Server/Grants/ImplicitGrant.php index 533a8076..df837dc2 100644 --- a/src/Server/Grants/ImplicitGrant.php +++ b/src/Server/Grants/ImplicitGrant.php @@ -35,6 +35,7 @@ use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ResponseTypeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ScopeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\StateRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\UiLocalesRule; use SimpleSAML\Module\oidc\Server\RequestTypes\AuthorizationRequest; use SimpleSAML\Module\oidc\Server\ResponseModes\FragmentResponseMode; use SimpleSAML\Module\oidc\Services\IdTokenBuilder; @@ -133,6 +134,7 @@ public function validateAuthorizationRequestWithRequestRules( RequiredNonceRule::class, RequestedClaimsRule::class, AcrValuesRule::class, + UiLocalesRule::class, ]; $this->requestRulesManager->predefineResultBag($resultBag); @@ -189,6 +191,9 @@ public function validateAuthorizationRequestWithRequestRules( $acrValues = $resultBag->getOrFail(AcrValuesRule::class)->getValue(); $authorizationRequest->setRequestedAcrValues($acrValues); + $uiLocales = $resultBag->getOrFail(UiLocalesRule::class)->getValue(); + $authorizationRequest->setUiLocales($uiLocales); + $responseMode = $resultBag->getOrFail(ResponseModeRule::class)->getValue(); $authorizationRequest->setResponseMode($responseMode); diff --git a/src/Server/RequestRules/Rules/UiLocalesRule.php b/src/Server/RequestRules/Rules/UiLocalesRule.php index 47aff0f7..04d7261d 100644 --- a/src/Server/RequestRules/Rules/UiLocalesRule.php +++ b/src/Server/RequestRules/Rules/UiLocalesRule.php @@ -14,7 +14,7 @@ use SimpleSAML\OpenID\Codebooks\ParamsEnum; /** - * @extends AbstractRule + * @extends AbstractRule */ class UiLocalesRule extends AbstractRule { @@ -32,7 +32,7 @@ public function checkRule( ResponseModeInterface $responseMode = new QueryResponseMode(), array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?Result { - return new Result($this->getKey(), $this->requestParamsResolver->getBasedOnAllowedMethods( + return new Result($this->getKey(), $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( ParamsEnum::UiLocales->value, $request, $allowedServerRequestMethods, diff --git a/src/Server/RequestTypes/AuthorizationRequest.php b/src/Server/RequestTypes/AuthorizationRequest.php index 4ad40bec..6c2cb018 100644 --- a/src/Server/RequestTypes/AuthorizationRequest.php +++ b/src/Server/RequestTypes/AuthorizationRequest.php @@ -36,6 +36,12 @@ class AuthorizationRequest extends OAuth2AuthorizationRequest */ protected ?array $requestedAcrValues = null; + /** + * End-User's preferred UI languages, as requested using the ui_locales parameter (space-separated list of + * BCP47 language tags, ordered by preference). + */ + protected ?string $uiLocales = null; + /** * ACR used during authn. */ @@ -225,6 +231,16 @@ public function setRequestedAcrValues(?array $requestedAcrValues): void $this->requestedAcrValues = $requestedAcrValues; } + public function getUiLocales(): ?string + { + return $this->uiLocales; + } + + public function setUiLocales(?string $uiLocales): void + { + $this->uiLocales = $uiLocales; + } + public function getAcr(): ?string { return $this->acr; diff --git a/src/Services/OpMetadataService.php b/src/Services/OpMetadataService.php index 56953560..e3e102af 100644 --- a/src/Services/OpMetadataService.php +++ b/src/Services/OpMetadataService.php @@ -8,6 +8,7 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; use SimpleSAML\Module\oidc\Utils\Routes; +use SimpleSAML\Module\oidc\Utils\UiLocalesResolver; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\GrantTypesEnum; @@ -28,6 +29,7 @@ public function __construct( private readonly ModuleConfig $moduleConfig, private readonly ClaimTranslatorExtractor $claimTranslatorExtractor, private readonly Routes $routes, + private readonly UiLocalesResolver $uiLocalesResolver, ) { $this->initMetadata(); } @@ -111,6 +113,10 @@ private function initMetadata(): void $this->metadata[ClaimsEnum::ResponseModesSupported->value] = $this->moduleConfig->getSupportedResponseModes(); + if (!(empty($uiLocalesSupported = $this->uiLocalesResolver->getSupportedUiLocales()))) { + $this->metadata[ClaimsEnum::UiLocalesSupported->value] = $uiLocalesSupported; + } + // https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-oauth-20-authorization-serv // OPTIONAL // pre-authorized_grant_anonymous_access_supported // TODO mivanci Make configurable diff --git a/src/Utils/UiLocalesResolver.php b/src/Utils/UiLocalesResolver.php new file mode 100644 index 00000000..301fa428 --- /dev/null +++ b/src/Utils/UiLocalesResolver.php @@ -0,0 +1,92 @@ +getAvailableLanguages() as $availableLanguage) { + $normalizedAvailableLanguages[$this->normalize($availableLanguage)] = $availableLanguage; + } + + foreach (preg_split('/\s+/', trim($uiLocales)) ?: [] as $languageTag) { + $normalizedLanguageTag = $this->normalize($languageTag); + + if (isset($normalizedAvailableLanguages[$normalizedLanguageTag])) { + return $normalizedAvailableLanguages[$normalizedLanguageTag]; + } + + $primarySubtag = explode('_', $normalizedLanguageTag)[0]; + if (isset($normalizedAvailableLanguages[$primarySubtag])) { + return $normalizedAvailableLanguages[$primarySubtag]; + } + } + + return null; + } + + /** + * Get languages available in SimpleSAMLphp, represented as BCP47 language tags (SSP uses underscore as + * region separator in some codes, like pt_BR, while BCP47 uses hyphen). Can be used to advertise + * supported UI locales in OP discovery metadata (ui_locales_supported). + * + * @return string[] + */ + public function getSupportedUiLocales(): array + { + return array_map( + fn(string $availableLanguage): string => str_replace('_', '-', $availableLanguage), + $this->getAvailableLanguages(), + ); + } + + /** + * Get languages available in SimpleSAMLphp (configured in language.available and usable for UI + * rendering), as computed by SimpleSAMLphp itself + * (\SimpleSAML\Locale\Language::getAvailableLanguages()). This includes SimpleSAMLphp's own handling + * of deprecated locale codes and filtering of codes unknown to the translation system, so such codes + * are neither advertised nor matched here. + * + * @return string[] + */ + protected function getAvailableLanguages(): array + { + return $this->sspBridge->locale()->language()->getAvailableLanguages($this->sspConfiguration); + } + + protected function normalize(string $languageTag): string + { + return strtolower(str_replace('-', '_', $languageTag)); + } +} diff --git a/tests/unit/src/Bridges/SspBridge/Locale/LanguageTest.php b/tests/unit/src/Bridges/SspBridge/Locale/LanguageTest.php new file mode 100644 index 00000000..9dd04998 --- /dev/null +++ b/tests/unit/src/Bridges/SspBridge/Locale/LanguageTest.php @@ -0,0 +1,33 @@ +assertInstanceOf(Language::class, $this->sut()); + } + + public function testCanGetAvailableLanguages(): void + { + $configuration = Configuration::loadFromArray([ + 'language.available' => ['en', 'hr'], + ]); + + $this->assertSame(['en', 'hr'], $this->sut()->getAvailableLanguages($configuration)); + } +} diff --git a/tests/unit/src/Bridges/SspBridge/LocaleTest.php b/tests/unit/src/Bridges/SspBridge/LocaleTest.php new file mode 100644 index 00000000..8637123e --- /dev/null +++ b/tests/unit/src/Bridges/SspBridge/LocaleTest.php @@ -0,0 +1,28 @@ +assertInstanceOf(Locale::class, $this->sut()); + } + + public function testCanBuildLanguageInstance(): void + { + $this->assertInstanceOf(Locale\Language::class, $this->sut()->language()); + } +} diff --git a/tests/unit/src/Bridges/SspBridgeTest.php b/tests/unit/src/Bridges/SspBridgeTest.php index 12ab86a5..ae220ab4 100644 --- a/tests/unit/src/Bridges/SspBridgeTest.php +++ b/tests/unit/src/Bridges/SspBridgeTest.php @@ -35,4 +35,9 @@ public function testCanBuildAuthInstance(): void { $this->assertInstanceOf(SspBridge\Auth::class, $this->sut()->auth()); } + + public function testCanBuildLocaleInstance(): void + { + $this->assertInstanceOf(SspBridge\Locale::class, $this->sut()->locale()); + } } diff --git a/tests/unit/src/Controllers/AuthorizationControllerTest.php b/tests/unit/src/Controllers/AuthorizationControllerTest.php index bf52819e..3f352564 100644 --- a/tests/unit/src/Controllers/AuthorizationControllerTest.php +++ b/tests/unit/src/Controllers/AuthorizationControllerTest.php @@ -12,6 +12,7 @@ use Psr\Http\Message\ResponseInterface; use SimpleSAML\Auth\ProcessingChain; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; +use SimpleSAML\Module\oidc\Bridges\SspBridge; use SimpleSAML\Module\oidc\Controllers\AuthorizationController; use SimpleSAML\Module\oidc\Entities\UserEntity; use SimpleSAML\Module\oidc\ModuleConfig; @@ -21,6 +22,7 @@ use SimpleSAML\Module\oidc\Services\AuthenticationService; use SimpleSAML\Module\oidc\Services\ErrorResponder; use SimpleSAML\Module\oidc\Services\LoggerService; +use SimpleSAML\Module\oidc\Utils\UiLocalesResolver; use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\ResponseHeaderBag; @@ -52,6 +54,10 @@ class AuthorizationControllerTest extends TestCase protected Stub $responseStub; protected MockObject $psrHttpBridgeMock; protected MockObject $errorResponderMock; + protected Stub $uiLocalesResolverStub; + protected MockObject $sspBridgeMock; + protected MockObject $sspBridgeLocaleMock; + protected MockObject $sspBridgeLocaleLanguageMock; protected array $state; protected static string $sampleAuthSourceId = 'authSource123'; @@ -83,6 +89,13 @@ public function setUp(): void $this->psrHttpBridgeMock = $this->createMock(PsrHttpBridge::class); $this->errorResponderMock = $this->createMock(ErrorResponder::class); + $this->uiLocalesResolverStub = $this->createStub(UiLocalesResolver::class); + $this->sspBridgeMock = $this->createMock(SspBridge::class); + $this->sspBridgeLocaleMock = $this->createMock(SspBridge\Locale::class); + $this->sspBridgeLocaleLanguageMock = $this->createMock(SspBridge\Locale\Language::class); + $this->sspBridgeMock->method('locale')->willReturn($this->sspBridgeLocaleMock); + $this->sspBridgeLocaleMock->method('language')->willReturn($this->sspBridgeLocaleLanguageMock); + $this->state = [ 'Attributes' => self::AUTH_DATA['Attributes'], 'Oidc' => [ @@ -122,6 +135,8 @@ protected function mock( ?LoggerService $loggerService = null, ?PsrHttpBridge $psrHttpBridge = null, ?ErrorResponder $errorResponder = null, + ?UiLocalesResolver $uiLocalesResolver = null, + ?SspBridge $sspBridge = null, ): AuthorizationController { $authenticationService ??= $this->authenticationServiceStub; $authorizationServer ??= $this->authorizationServerStub; @@ -129,6 +144,8 @@ protected function mock( $loggerService ??= $this->loggerServiceMock; $psrHttpBridge ??= $this->psrHttpBridgeMock; $errorResponder ??= $this->errorResponderMock; + $uiLocalesResolver ??= $this->uiLocalesResolverStub; + $sspBridge ??= $this->sspBridgeMock; return new AuthorizationController( $authenticationService, @@ -137,6 +154,8 @@ protected function mock( $loggerService, $psrHttpBridge, $errorResponder, + $uiLocalesResolver, + $sspBridge, ); } @@ -170,14 +189,7 @@ public function testReturnsResponseWhenInvoked(array $queryParameters): void ->method('getAuthorizationRequestFromState') ->willReturn($this->authorizationRequestMock); - $controller = new AuthorizationController( - $this->authenticationServiceStub, - $this->authorizationServerStub, - $this->moduleConfigStub, - $this->loggerServiceMock, - $this->psrHttpBridgeMock, - $this->errorResponderMock, - ); + $controller = $this->mock(); if (empty($queryParameters)) { $this->authenticationServiceStub->expects($this->once()) @@ -221,14 +233,7 @@ public function testValidateAcrThrowsIfAuthSourceIdNotSetInAuthorizationRequest( $this->expectException(OidcServerException::class); - (new AuthorizationController( - $this->authenticationServiceStub, - $this->authorizationServerStub, - $this->moduleConfigStub, - $this->loggerServiceMock, - $this->psrHttpBridgeMock, - $this->errorResponderMock, - ))($this->serverRequestStub); + ($this->mock())($this->serverRequestStub); } /** @@ -263,14 +268,7 @@ public function testValidateAcrThrowsIfCookieBasedAuthnNotSetInAuthorizationRequ $this->expectException(OidcServerException::class); - (new AuthorizationController( - $this->authenticationServiceStub, - $this->authorizationServerStub, - $this->moduleConfigStub, - $this->loggerServiceMock, - $this->psrHttpBridgeMock, - $this->errorResponderMock, - ))($this->serverRequestStub); + ($this->mock())($this->serverRequestStub); } /** @@ -314,14 +312,7 @@ public function testValidateAcrSetsForcedAcrForCookieAuthentication(): void $this->authorizationRequestMock->expects($this->once())->method('setAcr')->with('0'); - (new AuthorizationController( - $this->authenticationServiceStub, - $this->authorizationServerStub, - $this->moduleConfigStub, - $this->loggerServiceMock, - $this->psrHttpBridgeMock, - $this->errorResponderMock, - ))($this->serverRequestStub); + ($this->mock())($this->serverRequestStub); } /** @@ -365,14 +356,7 @@ public function testValidateAcrThrowsIfNoMatchedAcrForEssentialAcrs(): void $this->expectException(OidcServerException::class); - (new AuthorizationController( - $this->authenticationServiceStub, - $this->authorizationServerStub, - $this->moduleConfigStub, - $this->loggerServiceMock, - $this->psrHttpBridgeMock, - $this->errorResponderMock, - ))($this->serverRequestStub); + ($this->mock())($this->serverRequestStub); } /** @@ -416,14 +400,7 @@ public function testValidateAcrSetsFirstMatchedAcr(): void $this->authorizationRequestMock->expects($this->once())->method('setAcr')->with('1'); - (new AuthorizationController( - $this->authenticationServiceStub, - $this->authorizationServerStub, - $this->moduleConfigStub, - $this->loggerServiceMock, - $this->psrHttpBridgeMock, - $this->errorResponderMock, - ))($this->serverRequestStub); + ($this->mock())($this->serverRequestStub); } /** @@ -467,14 +444,7 @@ public function testValidateAcrSetsCurrentSessionAcrIfNoMatchedAcr(): void $this->authorizationRequestMock->expects($this->once())->method('setAcr')->with('1'); - (new AuthorizationController( - $this->authenticationServiceStub, - $this->authorizationServerStub, - $this->moduleConfigStub, - $this->loggerServiceMock, - $this->psrHttpBridgeMock, - $this->errorResponderMock, - ))($this->serverRequestStub); + ($this->mock())($this->serverRequestStub); } /** @@ -519,14 +489,7 @@ public function testValidateAcrLogsWarningIfNoAcrsConfigured(): void $this->authorizationRequestMock->expects($this->once())->method('setAcr'); $this->loggerServiceMock->expects($this->once())->method('warning'); - (new AuthorizationController( - $this->authenticationServiceStub, - $this->authorizationServerStub, - $this->moduleConfigStub, - $this->loggerServiceMock, - $this->psrHttpBridgeMock, - $this->errorResponderMock, - ))($this->serverRequestStub); + ($this->mock())($this->serverRequestStub); } public function testItAlwaysReturnsAccessControlAllowOrigin(): void @@ -541,4 +504,94 @@ public function testItAlwaysReturnsAccessControlAllowOrigin(): void $this->mock()->authorization($this->symfonyRequestMock); } + + /** + * @throws \Throwable + */ + public function testSetsUiLanguageBasedOnUiLocalesOnInitialRequest(): void + { + $this->authorizationRequestMock->method('getUiLocales')->willReturn('hr en'); + $this->uiLocalesResolverStub->method('resolve')->willReturn('hr'); + + $this->authorizationServerStub + ->method('validateAuthorizationRequest') + ->willReturn($this->authorizationRequestMock); + $this->authorizationServerStub + ->method('completeAuthorizationRequest') + ->willReturn($this->responseStub); + + $this->serverRequestStub->method('getQueryParams')->willReturn([]); + + $this->authenticationServiceStub->method('manageState')->willReturn($this->state); + $this->authenticationServiceStub->method('getAuthenticateUser')->willReturn($this->userEntityStub); + $this->authenticationServiceStub + ->method('getAuthorizationRequestFromState') + ->willReturn($this->authorizationRequestMock); + + $this->sspBridgeLocaleLanguageMock->expects($this->once()) + ->method('setLanguageCookie') + ->with('hr'); + + ($this->mock())($this->serverRequestStub); + } + + /** + * @throws \Throwable + */ + public function testDoesNotSetUiLanguageWhenNoRequestedLanguageIsAvailable(): void + { + $this->authorizationRequestMock->method('getUiLocales')->willReturn('de'); + $this->uiLocalesResolverStub->method('resolve')->willReturn(null); + + $this->authorizationServerStub + ->method('validateAuthorizationRequest') + ->willReturn($this->authorizationRequestMock); + $this->authorizationServerStub + ->method('completeAuthorizationRequest') + ->willReturn($this->responseStub); + + $this->serverRequestStub->method('getQueryParams')->willReturn([]); + + $this->authenticationServiceStub->method('manageState')->willReturn($this->state); + $this->authenticationServiceStub->method('getAuthenticateUser')->willReturn($this->userEntityStub); + $this->authenticationServiceStub + ->method('getAuthorizationRequestFromState') + ->willReturn($this->authorizationRequestMock); + + $this->sspBridgeLocaleLanguageMock->expects($this->never()) + ->method('setLanguageCookie'); + + ($this->mock())($this->serverRequestStub); + } + + /** + * @throws \Throwable + */ + public function testDoesNotOverrideExistingLanguageCookieWithUiLocales(): void + { + $this->authorizationRequestMock->method('getUiLocales')->willReturn('hr en'); + $this->uiLocalesResolverStub->method('resolve')->willReturn('hr'); + // An explicit language choice is already stored in the language cookie. + $this->sspBridgeLocaleLanguageMock->method('getLanguageCookie')->willReturn('de'); + + $this->authorizationServerStub + ->method('validateAuthorizationRequest') + ->willReturn($this->authorizationRequestMock); + $this->authorizationServerStub + ->method('completeAuthorizationRequest') + ->willReturn($this->responseStub); + + $this->serverRequestStub->method('getQueryParams')->willReturn([]); + + $this->authenticationServiceStub->method('manageState')->willReturn($this->state); + $this->authenticationServiceStub->method('getAuthenticateUser')->willReturn($this->userEntityStub); + $this->authenticationServiceStub + ->method('getAuthorizationRequestFromState') + ->willReturn($this->authorizationRequestMock); + + $this->sspBridgeLocaleLanguageMock->expects($this->never()) + ->method('setLanguageCookie'); + + ($this->mock())($this->serverRequestStub); + } } diff --git a/tests/unit/src/Controllers/EndSessionControllerTest.php b/tests/unit/src/Controllers/EndSessionControllerTest.php index 4cac5640..7b69cf62 100644 --- a/tests/unit/src/Controllers/EndSessionControllerTest.php +++ b/tests/unit/src/Controllers/EndSessionControllerTest.php @@ -20,9 +20,11 @@ use SimpleSAML\Module\oidc\Services\SessionService; use SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreBuilder; use SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreDb; +use SimpleSAML\Module\oidc\Utils\UiLocalesResolver; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Core\IdToken; use SimpleSAML\Session; +use SimpleSAML\XHTML\Template; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Response; @@ -48,6 +50,7 @@ class EndSessionControllerTest extends TestCase protected Stub $templateFactoryStub; protected MockObject $psrHttpBridgeMock; protected MockObject $errorResponderMock; + protected Stub $uiLocalesResolverStub; /** * @throws \PHPUnit\Framework\MockObject\Exception @@ -68,18 +71,21 @@ public function setUp(): void $this->psrHttpBridgeMock = $this->createMock(PsrHttpBridge::class); $this->errorResponderMock = $this->createMock(ErrorResponder::class); + + $this->uiLocalesResolverStub = $this->createStub(UiLocalesResolver::class); } - protected function mock(): EndSessionController + protected function mock(?TemplateFactory $templateFactory = null): EndSessionController { return new EndSessionController( $this->authorizationServerStub, $this->sessionServiceStub, $this->sessionLogoutTicketStoreBuilderStub, $this->loggerServiceMock, - $this->templateFactoryStub, + $templateFactory ?? $this->templateFactoryStub, $this->psrHttpBridgeMock, $this->errorResponderMock, + $this->uiLocalesResolverStub, ); } @@ -211,4 +217,74 @@ public function testLogoutHandler(): never { $this->markTestIncomplete(); } + + /** + * @throws \Throwable + * @throws \SimpleSAML\Error\BadRequest + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + public function testRendersLogoutPageInResolvedUiLanguage(): void + { + $this->currentSessionMock->method('getAuthorities')->willReturn([]); + $this->sessionServiceStub->method('getCurrentSession')->willReturn($this->currentSessionMock); + $this->logoutRequestStub->method('getUiLocales')->willReturn('hr en'); + $this->authorizationServerStub->method('validateLogoutRequest')->willReturn($this->logoutRequestStub); + $this->uiLocalesResolverStub->method('resolve')->willReturn('hr'); + + $this->assertSame('hr', $this->captureRenderedTemplateLanguage()); + } + + /** + * @throws \Throwable + * @throws \SimpleSAML\Error\BadRequest + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + public function testRendersLogoutPageWithoutLanguageWhenNoneResolved(): void + { + $this->currentSessionMock->method('getAuthorities')->willReturn([]); + $this->sessionServiceStub->method('getCurrentSession')->willReturn($this->currentSessionMock); + $this->logoutRequestStub->method('getUiLocales')->willReturn('de'); + $this->authorizationServerStub->method('validateLogoutRequest')->willReturn($this->logoutRequestStub); + $this->uiLocalesResolverStub->method('resolve')->willReturn(null); + + $this->assertNull($this->captureRenderedTemplateLanguage()); + } + + /** + * Invoke the controller with a TemplateFactory mock and return the language passed to the rendered + * logout template. + * + * @throws \Throwable + * @throws \SimpleSAML\Error\BadRequest + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + private function captureRenderedTemplateLanguage(): ?string + { + $templateStub = $this->createStub(Template::class); + $capturedLanguage = null; + + $templateFactoryMock = $this->createMock(TemplateFactory::class); + $templateFactoryMock->method('build')->willReturnCallback( + function ( + string $templateName, + array $data = [], + ?string $activeHrefPath = null, + ?bool $includeDefaultMenuItems = null, + ?bool $showMenu = null, + ?bool $showModuleName = null, + ?bool $showSubPageTitle = null, + ?string $language = null, + ) use ( + &$capturedLanguage, + $templateStub, + ): Template { + $capturedLanguage = $language; + return $templateStub; + }, + ); + + $this->mock($templateFactoryMock)->__invoke($this->serverRequestStub); + + return $capturedLanguage; + } } diff --git a/tests/unit/src/Server/RequestRules/Rules/UiLocalesRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/UiLocalesRuleTest.php index 749525c3..5ad94f12 100644 --- a/tests/unit/src/Server/RequestRules/Rules/UiLocalesRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/UiLocalesRuleTest.php @@ -60,7 +60,7 @@ protected function sut( */ public function testCheckRuleReturnsResultWhenParamSet() { - $this->requestParamsResolverStub->method('getBasedOnAllowedMethods')->willReturn('en'); + $this->requestParamsResolverStub->method('getAsStringBasedOnAllowedMethods')->willReturn('en'); $result = $this->sut()->checkRule( $this->requestStub, diff --git a/tests/unit/src/Services/OpMetadataServiceTest.php b/tests/unit/src/Services/OpMetadataServiceTest.php index 5dabc919..d26b8b7d 100644 --- a/tests/unit/src/Services/OpMetadataServiceTest.php +++ b/tests/unit/src/Services/OpMetadataServiceTest.php @@ -11,6 +11,7 @@ use SimpleSAML\Module\oidc\Services\OpMetadataService; use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; use SimpleSAML\Module\oidc\Utils\Routes; +use SimpleSAML\Module\oidc\Utils\UiLocalesResolver; use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmBag; use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; @@ -30,6 +31,7 @@ class OpMetadataServiceTest extends TestCase protected MockObject $supportedAlgorithmsMock; protected MockObject $signatureKeyPairBagMock; protected MockObject $signatureKeyPairMock; + protected MockObject $uiLocalesResolverMock; /** * @throws \Exception @@ -93,6 +95,9 @@ public function setUp(): void ->willReturn($this->signatureKeyPairBagMock); $this->moduleConfigMock->method('getRequestUriParameterSupported')->willReturn(true); + + $this->uiLocalesResolverMock = $this->createMock(UiLocalesResolver::class); + $this->uiLocalesResolverMock->method('getSupportedUiLocales')->willReturn(['en', 'pt-BR']); } /** @@ -102,15 +107,18 @@ protected function sut( ?ModuleConfig $moduleConfig = null, ?ClaimTranslatorExtractor $claimTranslatorExtractor = null, ?Routes $routes = null, + ?UiLocalesResolver $uiLocalesResolver = null, ): OpMetadataService { $moduleConfig = $moduleConfig ?? $this->moduleConfigMock; $claimTranslatorExtractor = $claimTranslatorExtractor ?? $this->claimTranslatorExtractorMock; $routes = $routes ?? $this->routesMock; + $uiLocalesResolver = $uiLocalesResolver ?? $this->uiLocalesResolverMock; return new OpMetadataService( $moduleConfig, $claimTranslatorExtractor, $routes, + $uiLocalesResolver, ); } @@ -164,11 +172,26 @@ public function testItReturnsExpectedMetadata(): void 'backchannel_logout_supported' => true, 'backchannel_logout_session_supported' => true, 'response_modes_supported' => ['query', 'fragment', 'form_post'], + 'ui_locales_supported' => ['en', 'pt-BR'], ], $this->sut()->getMetadata(), ); } + /** + * @throws \Exception + */ + public function testDoesNotAdvertiseUiLocalesSupportedWhenNoneAvailable(): void + { + $uiLocalesResolverMock = $this->createMock(UiLocalesResolver::class); + $uiLocalesResolverMock->method('getSupportedUiLocales')->willReturn([]); + + $this->assertArrayNotHasKey( + ClaimsEnum::UiLocalesSupported->value, + $this->sut(uiLocalesResolver: $uiLocalesResolverMock)->getMetadata(), + ); + } + public function testAdvertisesRegistrationEndpointWhenDcrEnabled(): void { $this->moduleConfigMock->method('getDcrEnabled')->willReturn(true); diff --git a/tests/unit/src/Utils/UiLocalesResolverTest.php b/tests/unit/src/Utils/UiLocalesResolverTest.php new file mode 100644 index 00000000..dc660651 --- /dev/null +++ b/tests/unit/src/Utils/UiLocalesResolverTest.php @@ -0,0 +1,81 @@ + $availableLanguages], + ); + + return new UiLocalesResolver($sspConfiguration, new SspBridge()); + } + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(UiLocalesResolver::class, $this->sut()); + } + + public static function uiLocalesProvider(): array + { + return [ + 'null ui_locales' => [null, ['en', 'hr'], null], + 'empty ui_locales' => ['', ['en', 'hr'], null], + 'blank ui_locales' => [' ', ['en', 'hr'], null], + 'exact match' => ['hr', ['en', 'hr'], 'hr'], + 'preference order is honored' => ['hr en', ['en', 'hr'], 'hr'], + 'first unavailable, second available' => ['fr en', ['en', 'hr'], 'en'], + 'primary subtag fallback' => ['fr-CA', ['en', 'fr'], 'fr'], + 'case-insensitive match' => ['HR', ['en', 'hr'], 'hr'], + 'separator normalization' => ['pt-BR', ['en', 'pt_BR'], 'pt_BR'], + 'case and separator normalization' => ['PT-br', ['en', 'pt_BR'], 'pt_BR'], + 'no match' => ['de fr', ['en', 'hr'], null], + 'multiple whitespace between tags' => ['de en', ['en', 'hr'], 'en'], + 'returns configured code, not requested tag' => ['en-US', ['en'], 'en'], + 'configured code unknown to the translation system is not matched' => ['xx', ['en', 'xx'], null], + ]; + } + + #[DataProvider('uiLocalesProvider')] + public function testCanResolveUiLocales( + ?string $uiLocales, + array $availableLanguages, + ?string $expectedLanguage, + ): void { + $this->assertSame($expectedLanguage, $this->sut($availableLanguages)->resolve($uiLocales)); + } + + public function testFallsBackToDefaultAvailableLanguage(): void + { + // When language.available is not configured, the SSP fallback language (en) is used. + $this->assertSame('en', $this->sut()->resolve('en de')); + $this->assertNull($this->sut()->resolve('de')); + } + + public function testCanGetSupportedUiLocalesAsBcp47Tags(): void + { + $this->assertSame(['en', 'hr', 'pt-BR'], $this->sut(['en', 'hr', 'pt_BR'])->getSupportedUiLocales()); + } + + public function testSupportedUiLocalesFallBackToDefaultAvailableLanguage(): void + { + $this->assertSame(['en'], $this->sut()->getSupportedUiLocales()); + } + + public function testSupportedUiLocalesExcludeCodesUnknownToTranslationSystem(): void + { + $this->assertSame(['en'], $this->sut(['en', 'xx'])->getSupportedUiLocales()); + } +}