diff --git a/docs/6-oidc-upgrade.md b/docs/6-oidc-upgrade.md index 82a02e4f..f81a3f29 100644 --- a/docs/6-oidc-upgrade.md +++ b/docs/6-oidc-upgrade.md @@ -181,6 +181,20 @@ 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 applied using the +standard SimpleSAMLphp language cookie — the same mechanism as when the user +picks a language on any SimpleSAMLphp page — so subsequent screens shown during +the flow (login page, consent, logout page...) are rendered in the requested +language. 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). - 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 @@ +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,34 @@ 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. + */ + protected function setUiLanguage(OAuth2AuthorizationRequestInterface $authorizationRequest): void + { + if (!$authorizationRequest instanceof AuthorizationRequest) { + return; + } + + $language = $this->uiLocalesResolver->resolve($authorizationRequest->getUiLocales()); + + if ($language === null) { + 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..33c69785 100644 --- a/src/Controllers/EndSessionController.php +++ b/src/Controllers/EndSessionController.php @@ -7,6 +7,7 @@ use League\OAuth2\Server\Exception\OAuthServerException; use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; +use SimpleSAML\Module\oidc\Bridges\SspBridge; use SimpleSAML\Module\oidc\Factories\TemplateFactory; use SimpleSAML\Module\oidc\Server\AuthorizationServer; use SimpleSAML\Module\oidc\Server\LogoutHandlers\BackChannelLogoutHandler; @@ -15,6 +16,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 +34,8 @@ public function __construct( protected TemplateFactory $templateFactory, protected PsrHttpBridge $psrHttpBridge, protected ErrorResponder $errorResponder, + protected UiLocalesResolver $uiLocalesResolver, + protected SspBridge $sspBridge, ) { } @@ -51,6 +55,8 @@ public function __invoke(ServerRequestInterface $request): Response $logoutRequest = $this->authorizationServer->validateLogoutRequest($request); + $uiLanguage = $this->setUiLanguage($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 +140,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); + } + + /** + * Set the UI language for the current user agent based on the ui_locales logout 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). Per + * specification this is best-effort, so no error is raised if none of the requested languages are + * available. Returns the resolved language, so it can also be applied when rendering the logout + * page in the current request (the language cookie only affects subsequent requests). + */ + protected function setUiLanguage(LogoutRequest $logoutRequest): ?string + { + $language = $this->uiLocalesResolver->resolve($logoutRequest->getUiLocales()); + + if ($language === null) { + return null; + } + + $this->loggerService->debug( + 'EndSessionController: setting UI language based on ui_locales parameter.', + ['uiLocales' => $logoutRequest->getUiLocales(), 'language' => $language], + ); + + $this->sspBridge->locale()->language()->setLanguageCookie($language); + + return $language; } public function endSession(Request $request): Response @@ -211,8 +243,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 +283,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..52b7ed48 --- /dev/null +++ b/src/Utils/UiLocalesResolver.php @@ -0,0 +1,96 @@ +sspConfiguration->getOptionalArray( + 'language.available', + [Language::FALLBACKLANGUAGE], + ); + + $normalizedAvailableLanguages = []; + /** @psalm-suppress MixedAssignment */ + foreach ($availableLanguages as $availableLanguage) { + if (is_string($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 (per the language.available config option), 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 + { + $availableLanguages = $this->sspConfiguration->getOptionalArray( + 'language.available', + [Language::FALLBACKLANGUAGE], + ); + + $supportedUiLocales = []; + /** @psalm-suppress MixedAssignment */ + foreach ($availableLanguages as $availableLanguage) { + if (is_string($availableLanguage)) { + $supportedUiLocales[] = str_replace('_', '-', $availableLanguage); + } + } + + return $supportedUiLocales; + } + + 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..acfb19bc --- /dev/null +++ b/tests/unit/src/Bridges/SspBridge/Locale/LanguageTest.php @@ -0,0 +1,23 @@ +assertInstanceOf(Language::class, $this->sut()); + } +} 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..8410146e 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,63 @@ 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); + } } diff --git a/tests/unit/src/Controllers/EndSessionControllerTest.php b/tests/unit/src/Controllers/EndSessionControllerTest.php index 4cac5640..12d1333f 100644 --- a/tests/unit/src/Controllers/EndSessionControllerTest.php +++ b/tests/unit/src/Controllers/EndSessionControllerTest.php @@ -11,6 +11,7 @@ use PHPUnit\Framework\TestCase; use SimpleSAML\Error\BadRequest; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; +use SimpleSAML\Module\oidc\Bridges\SspBridge; use SimpleSAML\Module\oidc\Controllers\EndSessionController; use SimpleSAML\Module\oidc\Factories\TemplateFactory; use SimpleSAML\Module\oidc\Server\AuthorizationServer; @@ -20,6 +21,7 @@ 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; @@ -48,6 +50,10 @@ class EndSessionControllerTest extends TestCase protected Stub $templateFactoryStub; protected MockObject $psrHttpBridgeMock; protected MockObject $errorResponderMock; + protected Stub $uiLocalesResolverStub; + protected MockObject $sspBridgeMock; + protected MockObject $sspBridgeLocaleMock; + protected MockObject $sspBridgeLocaleLanguageMock; /** * @throws \PHPUnit\Framework\MockObject\Exception @@ -68,6 +74,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); } protected function mock(): EndSessionController @@ -80,6 +93,8 @@ protected function mock(): EndSessionController $this->templateFactoryStub, $this->psrHttpBridgeMock, $this->errorResponderMock, + $this->uiLocalesResolverStub, + $this->sspBridgeMock, ); } @@ -211,4 +226,43 @@ public function testLogoutHandler(): never { $this->markTestIncomplete(); } + + /** + * @throws \Throwable + * @throws \SimpleSAML\Error\BadRequest + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + public function testSetsUiLanguageBasedOnUiLocales(): 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->sspBridgeLocaleLanguageMock->expects($this->once()) + ->method('setLanguageCookie') + ->with('hr'); + + $this->mock()->__invoke($this->serverRequestStub); + } + + /** + * @throws \Throwable + * @throws \SimpleSAML\Error\BadRequest + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + public function testDoesNotSetUiLanguageWhenNoRequestedLanguageIsAvailable(): 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->sspBridgeLocaleLanguageMock->expects($this->never()) + ->method('setLanguageCookie'); + + $this->mock()->__invoke($this->serverRequestStub); + } } 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..63d9160e --- /dev/null +++ b/tests/unit/src/Utils/UiLocalesResolverTest.php @@ -0,0 +1,74 @@ + $availableLanguages], + ); + + return new UiLocalesResolver($sspConfiguration); + } + + 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'], + ]; + } + + #[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()); + } +}