Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.*`.

Expand Down
1 change: 1 addition & 0 deletions specs/001-baseline/code-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion specs/001-baseline/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
72 changes: 72 additions & 0 deletions src/EventSubscriber/UnlocalizedDefaultLocaleSubscriber.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

namespace Nowo\AuthKitBundle\EventSubscriber;

use Nowo\AuthKitBundle\Enum\UnlocalizedLocaleMode;
use Nowo\AuthKitBundle\Profile\RequestProfileResolver;
use Nowo\AuthKitBundle\Routing\AuthKitRouteLoader;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Contracts\Translation\LocaleAwareInterface;
use Symfony\Contracts\Translation\TranslatorInterface;

use function is_string;
use function str_ends_with;
use function strtolower;

/**
* Forces {@code locale.default} on AuthKit {@code *_unlocalized} twins when {@code unlocalized: serve}.
*
* Bare {@code /login} (etc.) must render the configured default even when the compiled route
* {@code _locale} default was warmed with a different value (image build vs runtime env).
*/
final readonly class UnlocalizedDefaultLocaleSubscriber implements EventSubscriberInterface
{
private UnlocalizedLocaleMode $unlocalizedMode;

public function __construct(
private TranslatorInterface $translator,
private string $defaultLocale,
string $unlocalizedMode = 'redirect',
) {
$this->unlocalizedMode = UnlocalizedLocaleMode::from($unlocalizedMode);
}

/**
* @return array<string, list<array{0: string, 1: int}>>
*/
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);
}
}
}
5 changes: 5 additions & 0 deletions src/Resources/config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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%'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php

declare(strict_types=1);

namespace Nowo\AuthKitBundle\Tests\Unit\EventSubscriber;

use Nowo\AuthKitBundle\EventSubscriber\UnlocalizedDefaultLocaleSubscriber;
use Nowo\AuthKitBundle\Profile\RequestProfileResolver;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Contracts\Translation\LocaleAwareInterface;
use Symfony\Contracts\Translation\TranslatorInterface;

final class UnlocalizedDefaultLocaleSubscriberTest extends TestCase
{
public function testSubscribesAfterLocaleAwareListener(): void
{
self::assertSame(
[KernelEvents::REQUEST => [['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<string, mixed> $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;
}
}
Loading