feat: configurable Remote-User header (design consideration 1.2)
Sync GitHub / sync (push) Successful in 7s
Tests / test (pull_request) Successful in 58s

Add REMOTE_USER env var with four modes:
- session (default): sends session id, backward-compatible
- static: sends a fixed string (REMOTE_USER_STATIC)
- mapped: looks up session id in REMOTE_USER_MAP
- none: omits the header entirely

New RemoteUserMode enum, ConfigBag parsing/validation, and
StringTrait::authSuccessResponse resolves the header value based
on the configured mode. AcceptListener now receives ConfigBag as
a constructor dependency.

Addresses design consideration 1.2 (Remote-User header value is
user-controlled) from DESIGN_CONSIDERATIONS.md.

241 tests pass, 0 cs-fixer violations.
This commit is contained in:
lyra committed 2026-08-11 22:48:52 -04:00
1 parent 2258839bd6
commit 9ad54f8e2a
17 files changed
+460 -23

No files matched your search

+4
View File
@@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Security
- Made `Remote-User` header value configurable via `REMOTE_USER` environment
variable with four modes: `session` (default), `static`, `mapped`, and `none`.
This allows deployments to prevent user-controlled header values from reaching
backend services.
- Added `SecurityHeadersListener` to set `X-Content-Type-Options`, `X-Frame-Options`,
`Content-Security-Policy`, `Referrer-Policy`, and `Strict-Transport-Security`
headers on all responses.
+7 -4
View File
@@ -24,13 +24,16 @@ Strict-Transport-Security: max-age=31536000
The inline `<script>` and `<style>` in the templates mean a CSP with `'unsafe-inline'` for `script-src` and `style-src` is the strictest practical policy today. Moving scripts/styles to external files would allow a stricter CSP in the future.
### 1.2 Remote-User Header Value is User-Controlled [HIGH PRIORITY] ⬜ Open
### 1.2 Remote-User Header Value is User-Controlled [HIGH PRIORITY] ✅ Addressed
**Current state:** The `Remote-User` header sent back to Caddy (and forwarded to the protected backend) is set to the `id` field from the user's login payload. This value is sanitized via `makeCacheKey()` (which restricts to `[A-Za-z0-9_.]` and truncates to 128 chars), but it is otherwise arbitrary — a user who passes TOTP authentication can set their `Remote-User` to `admin`, `root`, or any other value.
**Current state:** Fixed. The `Remote-User` header value is now configurable via the `REMOTE_USER` environment variable, which supports four modes:
**Recommendation:** If this is a single-user gate (which it currently is), document this explicitly: "The Remote-User header identifies the session, not a system user. Backend services must not use it for authorization decisions." If multi-user support is added (as planned in the ROADMAP), the `id` field should be validated against a registered user list before being echoed as `Remote-User`.
- **`session`** (default, backward-compatible): Sends the session id, as before. The value is still sanitized via `makeCacheKey()`.
- **`static`**: Sends a fixed string (configurable via `REMOTE_USER_STATIC`, default `authenticated`) for all authenticated requests. This eliminates the user-controlled header issue entirely.
- **`mapped`**: Looks up the session id in a configured map (`REMOTE_USER_MAP`, format: `id1:user1,id2:user2`) and sends the mapped value. Falls back to the session id if not found in the map. This is the path to multi-user support.
- **`none`**: Omits the `Remote-User` header entirely. Caddy's `forward_auth` still accepts the request based on the 200 status code.
**Why:** A backend service that trusts `Remote-User` for access control (e.g., granting admin privileges to `Remote-User: admin`) would be trivially exploitable by any authenticated preauth user. This is the most important architectural caveat to document, even if it's intentional for the current single-user model. The README's Security Model section now mentions that `Remote-User` identifies the session, but a dedicated `docs/SECURITY.md` would be valuable.
The `RemoteUserMode` enum (`src/Enum/RemoteUserMode.php`) encapsulates the modes. `StringTrait::authSuccessResponse()` resolves the header value based on the configured mode, and `ConfigBag` handles parsing the map string and validating the mode (invalid values fall back to `session`). `AcceptListener` now receives `ConfigBag` as a constructor dependency to support this.
### 1.3 No CSRF Protection on POST Form Login [MEDIUM PRIORITY] ✅ Addressed
+14
View File
@@ -27,6 +27,16 @@ parameters:
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
env(TEAPOT): '1' # boolean
# --- remote-user header ---
# Controls the value sent in the Remote-User header on successful auth.
# session: the session id (default, backward-compatible)
# static: a fixed string (set via REMOTE_USER_STATIC)
# mapped: look up session id in REMOTE_USER_MAP (format: id1:user1,id2:user2)
# none: do not send the Remote-User header at all
env(REMOTE_USER): 'session'
env(REMOTE_USER_STATIC): 'authenticated'
env(REMOTE_USER_MAP): ''
# --- rate limiting ---
# Note: rate limiting can *NOT* be disabled, but you could allow hundreds of logins a second
# rate limiting, default is the lower of 2 per 30 seconds or 10 per hour
@@ -63,6 +73,10 @@ parameters:
app.ip_ttl: '%env(int:IP_TTL)%'
app.teapot: '%env(bool:TEAPOT)%'
app.remote_user: '%env(REMOTE_USER)%'
app.remote_user_static: '%env(REMOTE_USER_STATIC)%'
app.remote_user_map: '%env(REMOTE_USER_MAP)%'
app.error_message: '%env(ERROR_MESSAGE)%'
app.teapot_title: '%env(TEAPOT_TITLE)%'
app.too_many_title: '%env(TOO_MANY_TITLE)%'
+10
View File
@@ -24,6 +24,16 @@
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
#TEAPOT=true # default enabled, boolean
# --- remote-user header ---
# Controls the value sent in the Remote-User header on successful auth.
# session: the session id (default, backward-compatible)
# static: a fixed string (set via REMOTE_USER_STATIC)
# mapped: look up session id in REMOTE_USER_MAP (format: id1:user1,id2:user2)
# none: do not send the Remote-User header at all
#REMOTE_USER=session
#REMOTE_USER_STATIC=authenticated
#REMOTE_USER_MAP=''
# --- rate limiting ---
# Note: rate limiting can *NOT* be disabled, but you could allow hundreds of logins a second
+15
View File
@@ -112,6 +112,21 @@ for the complete reference.
| `IP_TTL` | `0` | Seconds to allow all traffic from an IP after login (0 = disabled). |
| `TEAPOT` | `1` | Respond with 418 instead of 429 when rate-limited (boolean). |
### Remote-User Header
The `Remote-User` header sent to backends on successful auth is configurable:
| Variable | Default | Description |
|----------|---------|-------------|
| `REMOTE_USER` | `session` | Mode: `session`, `static`, `mapped`, or `none`. |
| `REMOTE_USER_STATIC` | `authenticated` | Value sent when mode is `static`. |
| `REMOTE_USER_MAP` | _(empty)_ | Comma-separated map for `mapped` mode (e.g. `alice:admin,bob:user`). |
- **`session`** (default): Sends the session id. Backward-compatible.
- **`static`**: Sends a fixed string for all authenticated requests.
- **`mapped`**: Looks up the session id in the map; falls back to session id if not found.
- **`none`**: Omits the header entirely (Caddy still accepts based on status code).
### Rate Limiting
Rate limiting **cannot be disabled**. It uses a compound sliding window:
+51
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App;
use App\Enum\RemoteUserMode;
use Psr\Cache\InvalidArgumentException;
use Psr\Clock\ClockInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -18,6 +19,10 @@ final readonly class ConfigBag
private string $errorMessage;
private string $teapotTitle;
private string $tooManyTitle;
private RemoteUserMode $remoteUserMode;
private string $remoteUserStatic;
/** @var array<string,string> */
private array $remoteUserMap;
/** @throws InvalidArgumentException */
public function __construct(
@@ -30,6 +35,9 @@ final readonly class ConfigBag
#[Autowire('%app.error_message%')] string $errorMessage,
#[Autowire('%app.teapot_title%')] string $teapotTitle,
#[Autowire('%app.too_many_title%')] string $tooManyTitle,
#[Autowire('%app.remote_user%')] string $remoteUserMode,
#[Autowire('%app.remote_user_static%')] string $remoteUserStatic,
#[Autowire('%app.remote_user_map%')] string $remoteUserMap,
) {
$this->clock = $clock;
$this->cookieTtl = $cookieTtl;
@@ -39,6 +47,31 @@ final readonly class ConfigBag
$this->errorMessage = $errorMessage;
$this->teapotTitle = $teapotTitle;
$this->tooManyTitle = $tooManyTitle;
$this->remoteUserMode = RemoteUserMode::tryFrom($remoteUserMode) ?? RemoteUserMode::Session;
$this->remoteUserStatic = $remoteUserStatic;
$this->remoteUserMap = $this->parseUserMap($remoteUserMap);
}
/**
* Parse a comma-separated map string ("id1:user1,id2:user2") into an array.
*
* @return array<string,string>
*/
private function parseUserMap(string $map): array
{
if ($map === '') {
return [];
}
$result = [];
foreach (explode(',', $map) as $pair) {
$parts = explode(':', trim($pair), 2);
if (count($parts) === 2) {
$result[trim($parts[0])] = trim($parts[1]);
}
}
return $result;
}
public function clock(): ClockInterface
@@ -80,4 +113,22 @@ final readonly class ConfigBag
{
return $this->tooManyTitle;
}
public function remoteUserMode(): RemoteUserMode
{
return $this->remoteUserMode;
}
public function remoteUserStatic(): string
{
return $this->remoteUserStatic;
}
/**
* @return array<string,string>
*/
public function remoteUserMap(): array
{
return $this->remoteUserMap;
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Enum;
/**
* Controls what value is sent in the Remote-User header on auth success.
*/
enum RemoteUserMode: string
{
/** Send the session id (current/default behaviour). */
case Session = 'session';
/** Send a fixed static string for all authenticated requests. */
case Static = 'static';
/** Look up the session id in a configured map and send the mapped value. */
case Mapped = 'mapped';
/** Do not send the Remote-User header at all. */
case None = 'none';
}
+3 -1
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Listener;
use App\ConfigBag;
use App\Service\DomainInterface;
use App\Trait\CookieNameTrait;
use App\Trait\HasLoggerTrait;
@@ -22,6 +23,7 @@ final readonly class AcceptListener
public function __construct(
private CacheItemPoolInterface $sessionCache,
private DomainInterface $domainManager,
private ConfigBag $config,
) {
}
@@ -51,7 +53,7 @@ final readonly class AcceptListener
$id = $item->get();
$this->logger->debug("has valid cookie-session: $id");
$event->setResponse($this->authSuccessResponse($id));
$event->setResponse($this->authSuccessResponse($id, $this->config));
} catch (InvalidArgumentException $e) {
/* cache failure — fail closed (don't authenticate) */
$this->logger->error("cache error in AcceptListener: {$e->getMessage()}");
+1 -1
View File
@@ -46,7 +46,7 @@ final readonly class AllowListener
$id = $item->get();
$this->logger->debug("has valid ip-session: $id");
$event->setResponse($this->authSuccessResponse($id));
$event->setResponse($this->authSuccessResponse($id, $this->config));
} catch (InvalidArgumentException $e) {
/* cache failure — fail closed (don't authenticate) */
$this->logger->error("cache error in AllowListener: {$e->getMessage()}");
+1 -1
View File
@@ -63,7 +63,7 @@ final readonly class LoginManager implements LoginInterface
$cleanId = $this->makeCacheKey($payload->id);
/* if they just want this one page, return ok, to grant them access */
$response = $this->authSuccessResponse($cleanId);
$response = $this->authSuccessResponse($cleanId, $this->config);
if ($payload->scope !== Scope::None) {
/* grant access based on the requested scope */
+29 -7
View File
@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Trait;
use App\AppConstants;
use App\ConfigBag;
use App\Enum\RemoteUserMode;
use Symfony\Component\HttpFoundation\Response;
trait StringTrait
@@ -18,14 +20,34 @@ trait StringTrait
}
/**
* Build the plain-text success response body and headers for an authenticated request.
* The body is a simple greeting that includes the session id.
* Build the plain-text success response body and headers for an
* authenticated request. The body is a simple greeting that includes
* the session id. The Remote-User header is set (or omitted) based on
* the configured remote-user mode.
*/
public function authSuccessResponse(string $id): Response
public function authSuccessResponse(string $id, ConfigBag $config): Response
{
return new Response("hi $id", headers: [
'Content-Type' => 'text/plain',
'Remote-User' => $id,
]);
$headers = ['Content-Type' => 'text/plain'];
$headerValue = $this->resolveRemoteUser($id, $config);
if ($headerValue !== null) {
$headers['Remote-User'] = $headerValue;
}
return new Response("hi $id", headers: $headers);
}
/**
* Resolve the Remote-User header value based on the configured mode.
* Returns null when the header should not be sent.
*/
private function resolveRemoteUser(string $id, ConfigBag $config): ?string
{
return match ($config->remoteUserMode()) {
RemoteUserMode::Session => $id,
RemoteUserMode::Static => $config->remoteUserStatic(),
RemoteUserMode::Mapped => $config->remoteUserMap()[$id] ?? $id,
RemoteUserMode::None => null,
};
}
}
+7
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Tests\Support;
use App\ConfigBag;
use App\Enum\RemoteUserMode;
use App\Utilities;
use DateTimeImmutable;
use OTPHP\TOTP;
@@ -72,6 +73,9 @@ trait TotpTestHelper
string $errorMessage = 'Error',
string $teapotTitle = 'Teapot',
string $tooManyTitle = 'Too Many',
string $remoteUserMode = 'session',
string $remoteUserStatic = 'authenticated',
string $remoteUserMap = '',
): ConfigBag {
$clock = $this->frozenClock();
$utilities = $this->createUtilities($clock);
@@ -85,6 +89,9 @@ trait TotpTestHelper
$errorMessage,
$teapotTitle,
$tooManyTitle,
$remoteUserMode,
$remoteUserStatic,
$remoteUserMap,
);
}
+89
View File
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
use App\ConfigBag;
use App\Enum\RemoteUserMode;
use App\Tests\Support\TotpTestHelper;
use PHPUnit\Framework\TestCase;
final class ConfigBagRemoteUserTest extends TestCase
{
use TotpTestHelper;
public function testDefaultRemoteUserModeIsSession(): void
{
$config = $this->makeConfig();
self::assertSame(RemoteUserMode::Session, $config->remoteUserMode());
}
public function testStaticMode(): void
{
$config = $this->makeConfig(remoteUserMode: 'static', remoteUserStatic: 'authenticated');
self::assertSame(RemoteUserMode::Static, $config->remoteUserMode());
self::assertSame('authenticated', $config->remoteUserStatic());
}
public function testMappedMode(): void
{
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin,bob:user');
self::assertSame(RemoteUserMode::Mapped, $config->remoteUserMode());
self::assertSame(['alice' => 'admin', 'bob' => 'user'], $config->remoteUserMap());
}
public function testNoneMode(): void
{
$config = $this->makeConfig(remoteUserMode: 'none');
self::assertSame(RemoteUserMode::None, $config->remoteUserMode());
}
public function testInvalidModeFallsBackToSession(): void
{
$config = $this->makeConfig(remoteUserMode: 'invalid-mode');
self::assertSame(RemoteUserMode::Session, $config->remoteUserMode());
}
public function testEmptyMapReturnsEmptyArray(): void
{
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: '');
self::assertSame([], $config->remoteUserMap());
}
public function testMapParsesWithWhitespace(): void
{
$config = $this->makeConfig(
remoteUserMode: 'mapped',
remoteUserMap: ' alice : admin , bob : user ',
);
self::assertSame(['alice' => 'admin', 'bob' => 'user'], $config->remoteUserMap());
}
public function testMapIgnoresInvalidEntries(): void
{
$config = $this->makeConfig(
remoteUserMode: 'mapped',
remoteUserMap: 'alice:admin,noColon,bob:user',
);
self::assertSame(['alice' => 'admin', 'bob' => 'user'], $config->remoteUserMap());
}
public function testMapPreservesColonsInValue(): void
{
$config = $this->makeConfig(
remoteUserMode: 'mapped',
remoteUserMap: 'alice:admin:extra',
);
self::assertSame(['alice' => 'admin:extra'], $config->remoteUserMap());
}
}
+16 -4
View File
@@ -45,7 +45,10 @@ final class ConfigBagTest extends TestCase
true,
'Error!',
'Teapot!',
'Too Many!'
'Too Many!',
'session',
'authenticated',
'',
);
self::assertSame($clock, $config->clock());
@@ -72,7 +75,10 @@ final class ConfigBagTest extends TestCase
false,
'Error',
'Teapot',
'Too Many'
'Too Many',
'session',
'authenticated',
'',
);
self::assertSame('fallback-totp', $config->totpUri());
@@ -92,7 +98,10 @@ final class ConfigBagTest extends TestCase
false,
'Error',
'Teapot',
'Too Many'
'Too Many',
'session',
'authenticated',
'',
);
self::assertNull($config->ipTtl());
@@ -112,7 +121,10 @@ final class ConfigBagTest extends TestCase
false,
'Error',
'Teapot',
'Too Many'
'Too Many',
'session',
'authenticated',
'',
);
self::assertNull($config->ipTtl());
+134 -3
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Tests\Unit\Listener;
use App\ConfigBag;
use App\Listener\AcceptListener;
use App\Service\DomainManager;
use App\Tests\Support\TotpTestHelper;
@@ -22,9 +23,12 @@ final class AcceptListenerTest extends TestCase
private const string COOKIE_NAME = '__Host-Http-Preauth';
private const string AUTH_COOKIE_NAME = '__Http-Domain-Preauth';
private function makeListener(ArrayAdapter $pool, DomainManager $domainManager): AcceptListener
{
$listener = new AcceptListener($pool, $domainManager);
private function makeListener(
ArrayAdapter $pool,
DomainManager $domainManager,
?ConfigBag $config = null,
): AcceptListener {
$listener = new AcceptListener($pool, $domainManager, $config ?? $this->makeConfig());
$listener->setLogger(new NullLogger());
return $listener;
}
@@ -130,4 +134,131 @@ final class AcceptListenerTest extends TestCase
// empty cookie value should not be treated as a valid session
self::assertFalse($event->hasResponse());
}
/* ── Remote-User header modes ─────────────────────────────────────── */
public function testRemoteUserSessionModeSendsSessionId(): void
{
$pool = new ArrayAdapter();
$ulid = '01HXY1234567890ABCDEFGHIJK';
$item = $pool->getItem('cookie_' . $ulid);
$item->set('alice');
$pool->save($item);
$domainManager = new DomainManager(false, '');
$listener = $this->makeListener(
$pool,
$domainManager,
$this->makeConfig(remoteUserMode: 'session'),
);
$request = Request::create('/', 'GET');
$request->cookies->set(self::COOKIE_NAME, $ulid);
$event = $this->makeEvent($request);
$listener->onKernelRequest($event);
self::assertTrue($event->hasResponse());
self::assertSame('alice', $event->getResponse()->headers->get('Remote-User'));
}
public function testRemoteUserStaticModeSendsFixedValue(): void
{
$pool = new ArrayAdapter();
$ulid = '01HXY1234567890ABCDEFGHIJK';
$item = $pool->getItem('cookie_' . $ulid);
$item->set('alice');
$pool->save($item);
$domainManager = new DomainManager(false, '');
$listener = $this->makeListener(
$pool,
$domainManager,
$this->makeConfig(remoteUserMode: 'static', remoteUserStatic: 'authenticated'),
);
$request = Request::create('/', 'GET');
$request->cookies->set(self::COOKIE_NAME, $ulid);
$event = $this->makeEvent($request);
$listener->onKernelRequest($event);
self::assertTrue($event->hasResponse());
self::assertSame('authenticated', $event->getResponse()->headers->get('Remote-User'));
}
public function testRemoteUserMappedModeSendsMappedValue(): void
{
$pool = new ArrayAdapter();
$ulid = '01HXY1234567890ABCDEFGHIJK';
$item = $pool->getItem('cookie_' . $ulid);
$item->set('alice');
$pool->save($item);
$domainManager = new DomainManager(false, '');
$listener = $this->makeListener(
$pool,
$domainManager,
$this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin,bob:user'),
);
$request = Request::create('/', 'GET');
$request->cookies->set(self::COOKIE_NAME, $ulid);
$event = $this->makeEvent($request);
$listener->onKernelRequest($event);
self::assertTrue($event->hasResponse());
self::assertSame('admin', $event->getResponse()->headers->get('Remote-User'));
}
public function testRemoteUserMappedModeFallsBackToSessionIdWhenNotInMap(): void
{
$pool = new ArrayAdapter();
$ulid = '01HXY1234567890ABCDEFGHIJK';
$item = $pool->getItem('cookie_' . $ulid);
$item->set('unknown_user');
$pool->save($item);
$domainManager = new DomainManager(false, '');
$listener = $this->makeListener(
$pool,
$domainManager,
$this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin'),
);
$request = Request::create('/', 'GET');
$request->cookies->set(self::COOKIE_NAME, $ulid);
$event = $this->makeEvent($request);
$listener->onKernelRequest($event);
self::assertTrue($event->hasResponse());
self::assertSame('unknown_user', $event->getResponse()->headers->get('Remote-User'));
}
public function testRemoteUserNoneModeOmitsHeader(): void
{
$pool = new ArrayAdapter();
$ulid = '01HXY1234567890ABCDEFGHIJK';
$item = $pool->getItem('cookie_' . $ulid);
$item->set('alice');
$pool->save($item);
$domainManager = new DomainManager(false, '');
$listener = $this->makeListener(
$pool,
$domainManager,
$this->makeConfig(remoteUserMode: 'none'),
);
$request = Request::create('/', 'GET');
$request->cookies->set(self::COOKIE_NAME, $ulid);
$event = $this->makeEvent($request);
$listener->onKernelRequest($event);
self::assertTrue($event->hasResponse());
self::assertFalse($event->getResponse()->headers->has('Remote-User'));
}
}
+8 -2
View File
@@ -73,7 +73,10 @@ final class GetTotpTraitTest extends TestCase
false,
'Error',
'Teapot',
'Too Many'
'Too Many',
'session',
'authenticated',
'',
);
$obj->setConfig($config);
@@ -100,7 +103,10 @@ final class GetTotpTraitTest extends TestCase
false,
'Error',
'Teapot',
'Too Many'
'Too Many',
'session',
'authenticated',
'',
);
$obj->setConfig($config);
+48
View File
@@ -4,12 +4,14 @@ declare(strict_types=1);
namespace App\Tests\Unit\Trait;
use App\Tests\Support\TotpTestHelper;
use App\Trait\StringTrait;
use PHPUnit\Framework\TestCase;
final class StringTraitTest extends TestCase
{
use StringTrait;
use TotpTestHelper;
public function testMakeCacheKeySanitizesInvalidChars(): void
{
@@ -67,4 +69,50 @@ final class StringTraitTest extends TestCase
$result = $this->makeCacheKey('a🎉b');
self::assertSame('a_b', $result);
}
/* ── authSuccessResponse ──────────────────────────────────────────── */
public function testAuthSuccessResponseSessionMode(): void
{
$config = $this->makeConfig(remoteUserMode: 'session');
$response = $this->authSuccessResponse('alice', $config);
self::assertSame('hi alice', $response->getContent());
self::assertSame('text/plain', $response->headers->get('Content-Type'));
self::assertSame('alice', $response->headers->get('Remote-User'));
}
public function testAuthSuccessResponseStaticMode(): void
{
$config = $this->makeConfig(remoteUserMode: 'static', remoteUserStatic: 'authenticated');
$response = $this->authSuccessResponse('alice', $config);
self::assertSame('hi alice', $response->getContent());
self::assertSame('authenticated', $response->headers->get('Remote-User'));
}
public function testAuthSuccessResponseMappedMode(): void
{
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin');
$response = $this->authSuccessResponse('alice', $config);
self::assertSame('admin', $response->headers->get('Remote-User'));
}
public function testAuthSuccessResponseMappedModeFallback(): void
{
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin');
$response = $this->authSuccessResponse('unknown', $config);
self::assertSame('unknown', $response->headers->get('Remote-User'));
}
public function testAuthSuccessResponseNoneModeOmitsHeader(): void
{
$config = $this->makeConfig(remoteUserMode: 'none');
$response = $this->authSuccessResponse('alice', $config);
self::assertSame('hi alice', $response->getContent());
self::assertFalse($response->headers->has('Remote-User'));
}
}