diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1a433c2..885423e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -130,6 +130,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **`locale.unlocalized: serve`:** `UnlocalizedDefaultLocaleSubscriber` forces `locale.default` at request time on AuthKit `*_unlocalized` routes (after Symfony `LocaleListener`), so bare `/login` (etc.) stays correct when the compiled route `_locale` default was warmed with a different value (Docker image build vs runtime env). + ## [1.22.0] - 2026-09-28 ### Security diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 46c5e8e..4fa818d 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -465,7 +465,7 @@ nowo_auth_kit: When `in_path: both`: - `unlocalized: redirect` — bare URL redirects to `/{default}/…` (or current request locale via `auth_kit_route_params()`). -- `unlocalized: serve` — bare URL renders with `_locale = locale.default`. +- `unlocalized: serve` — bare URL renders with `_locale = locale.default`. `UnlocalizedDefaultLocaleSubscriber` (priority `14`) re-applies `locale.default` on each request so a stale warmed route cache cannot stick a wrong locale on `/login` (image build vs runtime `DEFAULT_LOCALE`). Legacy keys `default_locale`, `enabled_locales`, and `locale_in_path` (bool) still work and map into `locale.*`. diff --git a/specs/001-baseline/code-inventory.md b/specs/001-baseline/code-inventory.md index 24a205f..99fd27d 100644 --- a/specs/001-baseline/code-inventory.md +++ b/specs/001-baseline/code-inventory.md @@ -34,6 +34,7 @@ Every production file under `src/` is listed exactly once. Status **Mapped** mea | `DeviceIntelligence/NewDeviceLoginNotifierInterface.php` | production | FR-DIINTEL-001 | Mapped | | `DeviceIntelligence/NullNewDeviceLoginNotifier.php` | production | FR-DIINTEL-001 | Mapped | | `EventSubscriber/NewDeviceLoginSubscriber.php` | production | FR-DIINTEL-001 | Mapped | +| `EventSubscriber/UnlocalizedDefaultLocaleSubscriber.php` | production | FR-LOCALE-001 | Mapped | ## HTTP controllers diff --git a/specs/001-baseline/spec.md b/specs/001-baseline/spec.md index ef059da..d74c8e7 100644 --- a/specs/001-baseline/spec.md +++ b/specs/001-baseline/spec.md @@ -107,7 +107,7 @@ Drop-in Symfony **authentication kit**: login/logout, gated registration, rememb ### Profiles & locale - **FR-PROFILE-001**: `ProfileSettings`, `ProfileRegistry`, `RequestProfileResolver`, `UnknownProfileException`. -- **FR-LOCALE-001**: Locale-in-path / unlocalized redirect modes and `UnlocalizedLocaleRedirectController`. +- **FR-LOCALE-001**: Locale-in-path / unlocalized redirect modes and `UnlocalizedLocaleRedirectController`. When `unlocalized: serve`, `UnlocalizedDefaultLocaleSubscriber` forces `locale.default` at runtime on `*_unlocalized` AuthKit routes (covers warmed route-cache vs runtime env drift). ### Security integration diff --git a/src/EventSubscriber/UnlocalizedDefaultLocaleSubscriber.php b/src/EventSubscriber/UnlocalizedDefaultLocaleSubscriber.php new file mode 100644 index 0000000..a09c91a --- /dev/null +++ b/src/EventSubscriber/UnlocalizedDefaultLocaleSubscriber.php @@ -0,0 +1,72 @@ +unlocalizedMode = UnlocalizedLocaleMode::from($unlocalizedMode); + } + + /** + * @return array> + */ + public static function getSubscribedEvents(): array + { + return [ + // After LocaleListener (16) / LocaleAwareListener (15); before typical host sticky-session listeners. + KernelEvents::REQUEST => [['onKernelRequest', 14]], + ]; + } + + public function onKernelRequest(RequestEvent $event): void + { + if (!$event->isMainRequest() || $this->unlocalizedMode !== UnlocalizedLocaleMode::Serve) { + return; + } + + $request = $event->getRequest(); + if (!$request->attributes->has(RequestProfileResolver::REQUEST_ATTRIBUTE)) { + return; + } + + $route = $request->attributes->get('_route'); + if (!is_string($route) || !str_ends_with($route, AuthKitRouteLoader::UNLOCALIZED_ROUTE_SUFFIX)) { + return; + } + + $locale = strtolower($this->defaultLocale); + $request->attributes->set('_locale', $locale); + $request->setLocale($locale); + if ($this->translator instanceof LocaleAwareInterface) { + $this->translator->setLocale($locale); + } + } +} diff --git a/src/Resources/config/services.yaml b/src/Resources/config/services.yaml index 21ad3b8..b4ac282 100644 --- a/src/Resources/config/services.yaml +++ b/src/Resources/config/services.yaml @@ -59,6 +59,11 @@ services: $defaultLocale: '%nowo_auth_kit.locale.default%' $enabledLocales: '%nowo_auth_kit.locale.enabled%' + Nowo\AuthKitBundle\EventSubscriber\UnlocalizedDefaultLocaleSubscriber: + arguments: + $defaultLocale: '%nowo_auth_kit.locale.default%' + $unlocalizedMode: '%nowo_auth_kit.locale.unlocalized%' + Nowo\AuthKitBundle\Routing\AuthKitRouteLoader: arguments: $profiles: '%nowo_auth_kit.profiles%' diff --git a/tests/Unit/EventSubscriber/UnlocalizedDefaultLocaleSubscriberTest.php b/tests/Unit/EventSubscriber/UnlocalizedDefaultLocaleSubscriberTest.php new file mode 100644 index 0000000..a85963a --- /dev/null +++ b/tests/Unit/EventSubscriber/UnlocalizedDefaultLocaleSubscriberTest.php @@ -0,0 +1,129 @@ + [['onKernelRequest', 14]]], + UnlocalizedDefaultLocaleSubscriber::getSubscribedEvents(), + ); + } + + public function testForcesDefaultLocaleOnUnlocalizedAuthRouteWhenServe(): void + { + $translator = new RecordingTranslator(); + $request = Request::create('/login'); + $request->attributes->set('_route', 'nowo_auth_kit_login_unlocalized'); + $request->attributes->set(RequestProfileResolver::REQUEST_ATTRIBUTE, 'default'); + $request->attributes->set('_locale', 'en'); + $request->setLocale('en'); + $event = $this->mainEvent($request); + + new UnlocalizedDefaultLocaleSubscriber($translator, 'es', 'serve')->onKernelRequest($event); + + self::assertSame('es', $request->getLocale()); + self::assertSame('es', $request->attributes->get('_locale')); + self::assertSame('es', $translator->locale); + } + + public function testIgnoresWhenUnlocalizedModeIsRedirect(): void + { + $translator = new RecordingTranslator(); + $request = Request::create('/login'); + $request->attributes->set('_route', 'nowo_auth_kit_login_unlocalized'); + $request->attributes->set(RequestProfileResolver::REQUEST_ATTRIBUTE, 'default'); + $request->attributes->set('_locale', 'en'); + $request->setLocale('en'); + $event = $this->mainEvent($request); + + new UnlocalizedDefaultLocaleSubscriber($translator, 'es', 'redirect')->onKernelRequest($event); + + self::assertSame('en', $request->getLocale()); + self::assertSame('en', $request->attributes->get('_locale')); + self::assertSame('en', $translator->locale); + } + + public function testIgnoresLocalizedAuthRoutes(): void + { + $translator = new RecordingTranslator(); + $request = Request::create('/en/login'); + $request->attributes->set('_route', 'nowo_auth_kit_login'); + $request->attributes->set(RequestProfileResolver::REQUEST_ATTRIBUTE, 'default'); + $request->attributes->set('_locale', 'en'); + $request->setLocale('en'); + $event = $this->mainEvent($request); + + new UnlocalizedDefaultLocaleSubscriber($translator, 'es', 'serve')->onKernelRequest($event); + + self::assertSame('en', $request->getLocale()); + self::assertSame('en', $request->attributes->get('_locale')); + self::assertSame('en', $translator->locale); + } + + public function testIgnoresNonAuthKitUnlocalizedRoutes(): void + { + $translator = new RecordingTranslator(); + $request = Request::create('/setup'); + $request->attributes->set('_route', 'nowo_site_backup_setup_unlocalized'); + $request->attributes->set('_locale', 'en'); + $request->setLocale('en'); + $event = $this->mainEvent($request); + + new UnlocalizedDefaultLocaleSubscriber($translator, 'es', 'serve')->onKernelRequest($event); + + self::assertSame('en', $request->getLocale()); + self::assertSame('en', $request->attributes->get('_locale')); + self::assertSame('en', $translator->locale); + } + + private function mainEvent(Request $request): RequestEvent + { + return new RequestEvent( + $this->createStub(KernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + ); + } +} + +/** + * @internal + */ +final class RecordingTranslator implements TranslatorInterface, LocaleAwareInterface +{ + public string $locale = 'en'; + + /** + * @param array $parameters + */ + public function trans(string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string + { + return $id; + } + + public function getLocale(): string + { + return $this->locale; + } + + public function setLocale(string $locale): void + { + $this->locale = $locale; + } +}