From 118dc472c66542d226fef4ad46bc005d2e3ae972 Mon Sep 17 00:00:00 2001 From: mscherer Date: Tue, 28 Apr 2026 22:30:55 +0200 Subject: [PATCH 1/5] Add AuthenticationComponent::replaceIdentity() Swaps the in-request identity attribute without going through clearIdentity()/persistIdentity(). Useful for cache-warming the active identity (eager-loaded associations, computed flags) without ending impersonation or rotating the session. Adds AuthenticationServiceInterface::buildIdentity() so the component can build an identity object using the configured identityClass via the public service API. --- src/AuthenticationServiceInterface.php | 12 +++ .../Component/AuthenticationComponent.php | 32 +++++++ .../Component/AuthenticationComponentTest.php | 90 +++++++++++++++++++ 3 files changed, 134 insertions(+) diff --git a/src/AuthenticationServiceInterface.php b/src/AuthenticationServiceInterface.php index 570253ae..0b8d537f 100644 --- a/src/AuthenticationServiceInterface.php +++ b/src/AuthenticationServiceInterface.php @@ -16,6 +16,7 @@ */ namespace Authentication; +use ArrayAccess; use Authentication\Authenticator\AuthenticatorInterface; use Authentication\Authenticator\PersistenceInterface; use Authentication\Authenticator\ResultInterface; @@ -69,6 +70,17 @@ public function getResult(): ?ResultInterface; */ public function getIdentityAttribute(): string; + /** + * Build an identity object from raw identity data. + * + * If the supplied data is already an `IdentityInterface`, it is returned + * unchanged. + * + * @param \ArrayAccess|array $identityData Identity data. + * @return \Authentication\IdentityInterface + */ + public function buildIdentity(ArrayAccess|array $identityData): IdentityInterface; + /** * Return the URL to redirect unauthenticated users to. * diff --git a/src/Controller/Component/AuthenticationComponent.php b/src/Controller/Component/AuthenticationComponent.php index 5ce0d7d3..71aec025 100644 --- a/src/Controller/Component/AuthenticationComponent.php +++ b/src/Controller/Component/AuthenticationComponent.php @@ -322,6 +322,38 @@ public function setIdentity(ArrayAccess|array $identity) return $this; } + /** + * Replace the in-request identity object without persisting it. + * + * Use this when you only need to swap the identity attribute on the + * current request - for example, to attach eager-loaded associations + * or computed flags to the active user for the rest of the request - + * without going through `clearIdentity()` and `persistIdentity()`. + * + * Unlike `setIdentity()`, this does not touch the session and does not + * end an active impersonation, because no authenticator's + * `clearIdentity()` is invoked. + * + * @param \ArrayAccess|array $identity Identity data or an identity object. + * @return $this + */ + public function replaceIdentity(ArrayAccess|array $identity) + { + $controller = $this->getController(); + $service = $this->getAuthenticationService(); + + $identity = $service->buildIdentity($identity); + + $controller->setRequest( + $controller->getRequest()->withAttribute( + $service->getIdentityAttribute(), + $identity, + ), + ); + + return $this; + } + /** * Log a user out. * diff --git a/tests/TestCase/Controller/Component/AuthenticationComponentTest.php b/tests/TestCase/Controller/Component/AuthenticationComponentTest.php index 16b71a0a..2931a552 100644 --- a/tests/TestCase/Controller/Component/AuthenticationComponentTest.php +++ b/tests/TestCase/Controller/Component/AuthenticationComponentTest.php @@ -280,6 +280,96 @@ public function testSetIdentityOverwrite(): void ); } + /** + * Ensure replaceIdentity() swaps the request attribute without + * touching the session. + * + * @return void + */ + public function testReplaceIdentity(): void + { + $request = $this->request->withAttribute('authentication', $this->service); + + $controller = new Controller($request); + $registry = new ComponentRegistry($controller); + $component = new AuthenticationComponent($registry); + + $component->replaceIdentity($this->identityData); + + $result = $component->getIdentity(); + $this->assertInstanceOf(IdentityInterface::class, $result); + $this->assertSame($this->identityData, $result->getOriginalData()); + $this->assertNull( + $controller->getRequest()->getSession()->read('Auth'), + 'Session must not be written by replaceIdentity().', + ); + } + + /** + * Test that replaceIdentity() called with an identity instance keeps the + * exact instance as the request attribute. + * + * @return void + */ + public function testReplaceIdentityInstance(): void + { + $request = $this->request->withAttribute('authentication', $this->service); + + $controller = new Controller($request); + $registry = new ComponentRegistry($controller); + $component = new AuthenticationComponent($registry); + + $identity = new Identity($this->identityData); + $component->replaceIdentity($identity); + + $this->assertSame($identity, $component->getIdentity()); + } + + /** + * Ensure replaceIdentity() does not end an active impersonation, + * unlike setIdentity() which clears identity first. + * + * @return void + */ + public function testReplaceIdentityKeepsImpersonation(): void + { + $impersonator = new ArrayObject(['username' => 'mariano']); + $impersonated = new ArrayObject(['username' => 'larry']); + $this->request->getSession()->write('Auth', $impersonator); + $this->service->authenticate($this->request); + $identity = new Identity($impersonator); + $request = $this->request + ->withAttribute('identity', $identity) + ->withAttribute('authentication', $this->service); + $controller = new Controller($request); + $registry = new ComponentRegistry($controller); + $component = new AuthenticationComponent($registry); + + $component->impersonate($impersonated); + $this->assertEquals($impersonated, $controller->getRequest()->getSession()->read('Auth')); + $this->assertEquals($impersonator, $controller->getRequest()->getSession()->read('AuthImpersonate')); + + $reloaded = new ArrayObject(['username' => 'larry', 'profile' => 'loaded']); + $component->replaceIdentity($reloaded); + + $this->assertSame( + $reloaded, + $component->getIdentity()->getOriginalData(), + 'Request identity should reflect the reloaded user.', + ); + $this->assertEquals( + $impersonated, + $controller->getRequest()->getSession()->read('Auth'), + 'Session Auth slot must be untouched by replaceIdentity().', + ); + $this->assertEquals( + $impersonator, + $controller->getRequest()->getSession()->read('AuthImpersonate'), + 'Impersonation must survive replaceIdentity().', + ); + $this->assertTrue($component->isImpersonating()); + } + /** * testGetIdentity * From c6922eb9d25c4799f6bae22c8de685d9acdf9705 Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 29 Apr 2026 13:14:16 +0200 Subject: [PATCH 2/5] Address PR review: drop interface BC break, add preserveImpersonation option - AuthenticationServiceInterface: revert added buildIdentity() method declaration and replace with a @method docblock annotation. Adding a method to the interface is BC-breaking for any third-party implementer and cannot ship in 4.x or 4.next. - AuthenticationComponent::setIdentity() gains a $preserveImpersonation flag. When true, the new identity is persisted into the session as usual, but an active impersonation session is left intact (as is the successfully resolved authenticator). - AuthenticationService::clearIdentity() gains an optional third $stopImpersonation parameter that backs the new behavior. The interface signature is unchanged, so external implementers remain compatible. - Adds tests covering both the preserveImpersonation path and the default path that still ends impersonation. --- src/AuthenticationService.php | 22 +++++- src/AuthenticationServiceInterface.php | 15 +--- .../Component/AuthenticationComponent.php | 24 +++++- .../Component/AuthenticationComponentTest.php | 77 +++++++++++++++++++ 4 files changed, 120 insertions(+), 18 deletions(-) diff --git a/src/AuthenticationService.php b/src/AuthenticationService.php index 051a5b79..497e2ee7 100644 --- a/src/AuthenticationService.php +++ b/src/AuthenticationService.php @@ -184,14 +184,26 @@ public function authenticate(ServerRequestInterface $request): ResultInterface * * @param \Psr\Http\Message\ServerRequestInterface $request The request. * @param \Psr\Http\Message\ResponseInterface $response The response. + * @param bool $stopImpersonation Whether to stop an active impersonation + * before clearing each authenticator. Defaults to true (existing + * behavior). Pass false to keep the impersonation session intact - the + * authenticator's `clearIdentity()` is still called, but + * `stopImpersonating()` is not. * @return array Return an array containing the request and response objects. * @return array{request: \Psr\Http\Message\ServerRequestInterface, response: \Psr\Http\Message\ResponseInterface} */ - public function clearIdentity(ServerRequestInterface $request, ResponseInterface $response): array - { + public function clearIdentity( + ServerRequestInterface $request, + ResponseInterface $response, + bool $stopImpersonation = true, + ): array { foreach ($this->authenticators() as $authenticator) { if ($authenticator instanceof PersistenceInterface) { - if ($authenticator instanceof ImpersonationInterface && $authenticator->isImpersonating($request)) { + if ( + $stopImpersonation + && $authenticator instanceof ImpersonationInterface + && $authenticator->isImpersonating($request) + ) { $stopImpersonationResult = $authenticator->stopImpersonating($request, $response); ['request' => $request, 'response' => $response] = $stopImpersonationResult; } @@ -199,7 +211,9 @@ public function clearIdentity(ServerRequestInterface $request, ResponseInterface ['request' => $request, 'response' => $response] = $result; } } - $this->_successfulAuthenticator = null; + if ($stopImpersonation) { + $this->_successfulAuthenticator = null; + } return [ 'request' => $request->withoutAttribute($this->getConfig('identityAttribute')), diff --git a/src/AuthenticationServiceInterface.php b/src/AuthenticationServiceInterface.php index 0b8d537f..917ccb04 100644 --- a/src/AuthenticationServiceInterface.php +++ b/src/AuthenticationServiceInterface.php @@ -16,12 +16,14 @@ */ namespace Authentication; -use ArrayAccess; use Authentication\Authenticator\AuthenticatorInterface; use Authentication\Authenticator\PersistenceInterface; use Authentication\Authenticator\ResultInterface; use Psr\Http\Message\ServerRequestInterface; +/** + * @method \Authentication\IdentityInterface buildIdentity(\ArrayAccess|array $identityData) Build an identity object from raw identity data. + */ interface AuthenticationServiceInterface extends PersistenceInterface { /** @@ -70,17 +72,6 @@ public function getResult(): ?ResultInterface; */ public function getIdentityAttribute(): string; - /** - * Build an identity object from raw identity data. - * - * If the supplied data is already an `IdentityInterface`, it is returned - * unchanged. - * - * @param \ArrayAccess|array $identityData Identity data. - * @return \Authentication\IdentityInterface - */ - public function buildIdentity(ArrayAccess|array $identityData): IdentityInterface; - /** * Return the URL to redirect unauthenticated users to. * diff --git a/src/Controller/Component/AuthenticationComponent.php b/src/Controller/Component/AuthenticationComponent.php index 71aec025..cd2ede60 100644 --- a/src/Controller/Component/AuthenticationComponent.php +++ b/src/Controller/Component/AuthenticationComponent.php @@ -18,6 +18,7 @@ use ArrayAccess; use ArrayObject; +use Authentication\AuthenticationService; use Authentication\AuthenticationServiceInterface; use Authentication\Authenticator\AuthenticatorInterface; use Authentication\Authenticator\ImpersonationInterface; @@ -299,15 +300,34 @@ public function getIdentityData(string $path): mixed * is cleared and then set to ensure that privilege escalation * and de-escalation include side effects like session rotation. * + * Pass `$preserveImpersonation = true` to keep an active impersonation + * session alive while replacing the identity. The active authenticators' + * `clearIdentity()` is still called (so the new identity properly + * overwrites the existing one in storage), but the impersonation slot is + * left intact. Use this when refreshing the active impersonated user + * (for example, attaching eager-loaded associations) without ending the + * impersonation. + * * @param \ArrayAccess|array $identity Identity data to persist. + * @param bool $preserveImpersonation Whether to keep an active + * impersonation alive while replacing the identity. Defaults to false + * (existing behavior). * @return $this */ - public function setIdentity(ArrayAccess|array $identity) + public function setIdentity(ArrayAccess|array $identity, bool $preserveImpersonation = false) { $controller = $this->getController(); $service = $this->getAuthenticationService(); - $service->clearIdentity($controller->getRequest(), $controller->getResponse()); + if ($preserveImpersonation && $service instanceof AuthenticationService) { + $service->clearIdentity( + $controller->getRequest(), + $controller->getResponse(), + stopImpersonation: false, + ); + } else { + $service->clearIdentity($controller->getRequest(), $controller->getResponse()); + } /** @var array{request: \Cake\Http\ServerRequest, response: \Cake\Http\Response} $result */ $result = $service->persistIdentity( diff --git a/tests/TestCase/Controller/Component/AuthenticationComponentTest.php b/tests/TestCase/Controller/Component/AuthenticationComponentTest.php index 2931a552..dfdccacd 100644 --- a/tests/TestCase/Controller/Component/AuthenticationComponentTest.php +++ b/tests/TestCase/Controller/Component/AuthenticationComponentTest.php @@ -370,6 +370,83 @@ public function testReplaceIdentityKeepsImpersonation(): void $this->assertTrue($component->isImpersonating()); } + /** + * Ensure setIdentity($identity, preserveImpersonation: true) persists the + * new identity into the session but does not end an active impersonation, + * unlike the default flow. + * + * @return void + */ + public function testSetIdentityPreserveImpersonation(): void + { + $impersonator = new ArrayObject(['username' => 'mariano']); + $impersonated = new ArrayObject(['username' => 'larry']); + $this->request->getSession()->write('Auth', $impersonator); + $this->service->authenticate($this->request); + $identity = new Identity($impersonator); + $request = $this->request + ->withAttribute('identity', $identity) + ->withAttribute('authentication', $this->service); + $controller = new Controller($request); + $registry = new ComponentRegistry($controller); + $component = new AuthenticationComponent($registry); + + $component->impersonate($impersonated); + $this->assertEquals($impersonated, $controller->getRequest()->getSession()->read('Auth')); + $this->assertEquals($impersonator, $controller->getRequest()->getSession()->read('AuthImpersonate')); + + $reloaded = new ArrayObject(['username' => 'larry', 'profile' => 'loaded']); + $component->setIdentity($reloaded, preserveImpersonation: true); + + $this->assertSame( + $reloaded, + $component->getIdentity()->getOriginalData(), + 'Request identity should reflect the reloaded user.', + ); + $this->assertEquals( + $reloaded, + $controller->getRequest()->getSession()->read('Auth'), + 'Session Auth slot must be persisted with the reloaded user.', + ); + $this->assertEquals( + $impersonator, + $controller->getRequest()->getSession()->read('AuthImpersonate'), + 'Impersonation must survive setIdentity() when preserveImpersonation is set.', + ); + $this->assertTrue($component->isImpersonating()); + } + + /** + * Ensure that `setIdentity()` with the default behavior still ends an + * active impersonation - we do not want to silently change BC. + * + * @return void + */ + public function testSetIdentityDefaultEndsImpersonation(): void + { + $impersonator = new ArrayObject(['username' => 'mariano']); + $impersonated = new ArrayObject(['username' => 'larry']); + $this->request->getSession()->write('Auth', $impersonator); + $this->service->authenticate($this->request); + $identity = new Identity($impersonator); + $request = $this->request + ->withAttribute('identity', $identity) + ->withAttribute('authentication', $this->service); + $controller = new Controller($request); + $registry = new ComponentRegistry($controller); + $component = new AuthenticationComponent($registry); + + $component->impersonate($impersonated); + + $reloaded = new ArrayObject(['username' => 'larry', 'profile' => 'loaded']); + $component->setIdentity($reloaded); + + $this->assertNull( + $controller->getRequest()->getSession()->read('AuthImpersonate'), + 'Default setIdentity() must end an active impersonation.', + ); + } + /** * testGetIdentity * From a733306bc90db85291f7d22557de4f6194b3d56b Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 29 Apr 2026 13:40:30 +0200 Subject: [PATCH 3/5] Document replaceIdentity() and setIdentity() preserveImpersonation option - Adds a 'Replacing the current identity' section to authentication-component.md covering setIdentity(), replaceIdentity() and the preserveImpersonation flag with usage examples. - Adds a third 'Limitations' bullet to impersonation.md explaining that setIdentity()/clearIdentity() end impersonation by default and pointing at the two new APIs as the supported workarounds. --- docs/en/authentication-component.md | 54 +++++++++++++++++++++++++++++ docs/en/impersonation.md | 20 +++++++++++ 2 files changed, 74 insertions(+) diff --git a/docs/en/authentication-component.md b/docs/en/authentication-component.md index 909026ab..b298eef0 100644 --- a/docs/en/authentication-component.md +++ b/docs/en/authentication-component.md @@ -102,6 +102,60 @@ The result returned will contain an array like this: > context you're working in you'll have to use these instances from now on if you > want to continue to work with the modified response and request objects. +## Replacing the current identity + +Use `setIdentity()` to change which user is logged in (e.g. after registration +or social-login first-touch). It clears all persisted identity data and writes +the new identity through every persisting authenticator: + +```php +$this->Authentication->setIdentity($user); +``` + +> [!WARNING] +> `setIdentity()` ends an active impersonation session by default, because it +> goes through `clearIdentity()` first, which calls `stopImpersonating()` on +> impersonation-aware authenticators. See the two methods below for the +> non-default cases. + +### Refresh the active identity for the current request only + +When you only need to swap the in-request identity (for example to attach +eager-loaded associations or computed flags in `beforeFilter()`) without +touching the session or persistence, use `replaceIdentity()`: + +```php +// AppController::beforeFilter() +$identity = $this->Authentication->getIdentity(); +if ($identity && !$identity->some_association) { + $reloaded = $this->fetchTable('Users') + ->get($identity->getIdentifier(), finder: 'fullProfile'); + $this->Authentication->replaceIdentity($reloaded); +} +``` + +This rewrites only the request attribute. The session is not touched, so an +active impersonation is preserved and no privilege-escalation side effects +(like session rotation) occur. + +### Persist a refreshed identity while impersonating + +If the refresh has to survive into subsequent requests but you still want to +keep an active impersonation alive, pass `preserveImpersonation: true` to +`setIdentity()`: + +```php +$this->Authentication->setIdentity($reloaded, preserveImpersonation: true); +``` + +The new identity is persisted into the session as usual, but the +impersonation slot (`AuthImpersonate`) and the active authenticator are left +intact. Note that this also skips the session rotation that the default +`setIdentity()` flow performs - it is a refresh, not a privilege transition, +so do not use it for login or role changes. + +See [User Impersonation](impersonation.md) for the broader context. + ## Configure Automatic Identity Checks By default `AuthenticationComponent` will automatically enforce an identity to diff --git a/docs/en/impersonation.md b/docs/en/impersonation.md index 2ed83075..78fcbbb6 100644 --- a/docs/en/impersonation.md +++ b/docs/en/impersonation.md @@ -67,3 +67,23 @@ There are a few limitations to impersonation. 1. Your application must be using the `Session` authenticator. 2. You cannot impersonate another user while impersonation is active. Instead you must `stopImpersonating()` and then start it again. +3. Calling `setIdentity()` or `clearIdentity()` (and therefore `logout()`) + ends impersonation by default. The service's `clearIdentity()` actively + calls `stopImpersonating()` on impersonation-aware authenticators, so any + code path that swaps the persisted identity will revert to the original + user. + + To refresh the active identity without disturbing impersonation, use one + of the dedicated methods on `AuthenticationComponent`: + + - `replaceIdentity($identity)` updates the in-request identity attribute + only. The session is not touched. Use this for the common + `beforeFilter()` case of attaching eager-loaded associations to the + active user for the rest of the request. + - `setIdentity($identity, preserveImpersonation: true)` persists the new + identity into the session like the default flow, but keeps the + impersonation slot intact. Use this when the refresh has to survive + into subsequent requests. + + See [Replacing the current identity](authentication-component.md#replacing-the-current-identity) + for examples. From 2ea395438ceb224b05acd4665289c3c5d1a6291b Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Tue, 9 Jun 2026 05:13:29 +0200 Subject: [PATCH 4/5] Apply suggestions from code review Co-authored-by: Mark Story --- docs/en/authentication-component.md | 2 +- .../Controller/Component/AuthenticationComponentTest.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/en/authentication-component.md b/docs/en/authentication-component.md index b298eef0..993ad3df 100644 --- a/docs/en/authentication-component.md +++ b/docs/en/authentication-component.md @@ -134,7 +134,7 @@ if ($identity && !$identity->some_association) { } ``` -This rewrites only the request attribute. The session is not touched, so an +This rewrites only the request attribute. The session is not modified, so an active impersonation is preserved and no privilege-escalation side effects (like session rotation) occur. diff --git a/tests/TestCase/Controller/Component/AuthenticationComponentTest.php b/tests/TestCase/Controller/Component/AuthenticationComponentTest.php index dfdccacd..08fbda98 100644 --- a/tests/TestCase/Controller/Component/AuthenticationComponentTest.php +++ b/tests/TestCase/Controller/Component/AuthenticationComponentTest.php @@ -445,6 +445,7 @@ public function testSetIdentityDefaultEndsImpersonation(): void $controller->getRequest()->getSession()->read('AuthImpersonate'), 'Default setIdentity() must end an active impersonation.', ); + $this->assertFalse($component->isImpersonating()); } /** From b63dcaae2a8e50babe34cb7c60af812d724bb405 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Tue, 9 Jun 2026 05:24:22 +0200 Subject: [PATCH 5/5] Fix isImpersonating() throwing after default setIdentity() After a default setIdentity() the successful authenticator is reset to null (via clearIdentity with stopImpersonation). Calling isImpersonating() afterwards then threw InvalidArgumentException 'No AuthenticationProvider present.' instead of reporting that no impersonation is active. Treat a missing authentication provider as not impersonating, since an unauthenticated request cannot be impersonating anyone. The misconfig throw for providers that do not implement ImpersonationInterface is preserved. --- src/AuthenticationService.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/AuthenticationService.php b/src/AuthenticationService.php index 497e2ee7..65f7fe7f 100644 --- a/src/AuthenticationService.php +++ b/src/AuthenticationService.php @@ -515,6 +515,10 @@ public function stopImpersonating(ServerRequestInterface $request, ResponseInter */ public function isImpersonating(ServerRequestInterface $request): bool { + if (!$this->getAuthenticationProvider() instanceof AuthenticatorInterface) { + return false; + } + $provider = $this->getImpersonationProvider(); return $provider->isImpersonating($request);