diff --git a/packages/auth/src/Authentication/SessionAuthenticator.php b/packages/auth/src/Authentication/SessionAuthenticator.php index 6d43b69c4..fc5d98d1d 100644 --- a/packages/auth/src/Authentication/SessionAuthenticator.php +++ b/packages/auth/src/Authentication/SessionAuthenticator.php @@ -4,38 +4,53 @@ namespace Tempest\Auth\Authentication; +use Tempest\Container\Resettable; use Tempest\Http\Session\Session; use Tempest\Http\Session\SessionManager; -final readonly class SessionAuthenticator implements Authenticator +final class SessionAuthenticator implements Authenticator, Resettable { public const string AUTHENTICATABLE_KEY = '#authenticatable:id'; public const string AUTHENTICATABLE_CLASS = '#authenticatable:class'; + private int|string|null $currentId = null; + + private ?string $currentClass = null; + + private ?Authenticatable $current = null; + public function __construct( - private SessionManager $sessionManager, - private Session $session, - private AuthenticatableResolver $authenticatableResolver, + private readonly SessionManager $sessionManager, + private readonly Session $session, + private readonly AuthenticatableResolver $authenticatableResolver, ) {} public function authenticate(Authenticatable $authenticatable): void { + $id = $this->authenticatableResolver->resolveId($authenticatable); + $class = $authenticatable::class; + $this->session->set( key: self::AUTHENTICATABLE_CLASS, - value: $authenticatable::class, + value: $class, ); $this->session->set( key: self::AUTHENTICATABLE_KEY, - value: $this->authenticatableResolver->resolveId($authenticatable), + value: $id, ); + + $this->currentId = $id; + $this->currentClass = $class; + $this->current = $authenticatable; } public function deauthenticate(): void { $this->session->remove(self::AUTHENTICATABLE_KEY); $this->session->remove(self::AUTHENTICATABLE_CLASS); + $this->clearCurrent(); $this->sessionManager->save($this->session); } @@ -46,9 +61,31 @@ public function current(): ?Authenticatable $class = $this->session->get(self::AUTHENTICATABLE_CLASS); if (! $id || ! $class) { + $this->clearCurrent(); + return null; } - return $this->authenticatableResolver->resolve($id, $class); + if ($this->currentId === $id && $this->currentClass === $class) { + return $this->current; + } + + $this->currentId = $id; + $this->currentClass = $class; + $this->current = $this->authenticatableResolver->resolve($id, $class); + + return $this->current; + } + + public function reset(): void + { + $this->clearCurrent(); + } + + private function clearCurrent(): void + { + $this->currentId = null; + $this->currentClass = null; + $this->current = null; } } diff --git a/packages/auth/tests/SessionAuthenticatorTest.php b/packages/auth/tests/SessionAuthenticatorTest.php new file mode 100644 index 000000000..c4aa78a3f --- /dev/null +++ b/packages/auth/tests/SessionAuthenticatorTest.php @@ -0,0 +1,214 @@ +createSession(); + $session->set(SessionAuthenticator::AUTHENTICATABLE_KEY, 1); + $session->set(SessionAuthenticator::AUTHENTICATABLE_CLASS, MemoizedAuthenticatable::class); + + $authenticator = new SessionAuthenticator( + sessionManager: new TestingSessionManager(), + session: $session, + authenticatableResolver: $resolver, + ); + + $this->assertSame($authenticatable, $authenticator->current()); + $this->assertSame($authenticatable, $authenticator->current()); + $this->assertSame(1, $resolver->resolveCalls); + } + + #[Test] + public function current_memoizes_a_missing_authenticatable_for_the_current_session_identity(): void + { + $resolver = new CountingAuthenticatableResolver(); + $session = $this->createSession(); + $session->set(SessionAuthenticator::AUTHENTICATABLE_KEY, 1); + $session->set(SessionAuthenticator::AUTHENTICATABLE_CLASS, MemoizedAuthenticatable::class); + + $authenticator = new SessionAuthenticator( + sessionManager: new TestingSessionManager(), + session: $session, + authenticatableResolver: $resolver, + ); + + $this->assertNull($authenticator->current()); + $this->assertNull($authenticator->current()); + $this->assertSame(1, $resolver->resolveCalls); + } + + #[Test] + public function current_re_resolves_when_the_session_identity_changes(): void + { + $resolver = new CountingAuthenticatableResolver( + new MemoizedAuthenticatable(id: 1), + new MemoizedAuthenticatable(id: 2), + ); + $session = $this->createSession(); + $session->set(SessionAuthenticator::AUTHENTICATABLE_KEY, 1); + $session->set(SessionAuthenticator::AUTHENTICATABLE_CLASS, MemoizedAuthenticatable::class); + + $authenticator = new SessionAuthenticator( + sessionManager: new TestingSessionManager(), + session: $session, + authenticatableResolver: $resolver, + ); + + $current = $authenticator->current(); + $this->assertInstanceOf(MemoizedAuthenticatable::class, $current); + $this->assertSame(1, $current->id); + + $session->set(SessionAuthenticator::AUTHENTICATABLE_KEY, 2); + + $current = $authenticator->current(); + $this->assertInstanceOf(MemoizedAuthenticatable::class, $current); + $this->assertSame(2, $current->id); + $this->assertSame(2, $resolver->resolveCalls); + } + + #[Test] + public function reset_clears_the_cached_current_authenticatable(): void + { + $authenticatable = new MemoizedAuthenticatable(id: 1); + $resolver = new CountingAuthenticatableResolver($authenticatable); + $session = $this->createSession(); + $session->set(SessionAuthenticator::AUTHENTICATABLE_KEY, 1); + $session->set(SessionAuthenticator::AUTHENTICATABLE_CLASS, MemoizedAuthenticatable::class); + + $authenticator = new SessionAuthenticator( + sessionManager: new TestingSessionManager(), + session: $session, + authenticatableResolver: $resolver, + ); + + $this->assertInstanceOf(Resettable::class, $authenticator); + $this->assertSame($authenticatable, $authenticator->current()); + + $authenticator->reset(); + + $this->assertSame($authenticatable, $authenticator->current()); + $this->assertSame(2, $resolver->resolveCalls); + } + + #[Test] + public function authenticate_replaces_a_cached_current_authenticatable(): void + { + $resolver = new CountingAuthenticatableResolver( + new MemoizedAuthenticatable(id: 1), + new MemoizedAuthenticatable(id: 2), + ); + $session = $this->createSession(); + $session->set(SessionAuthenticator::AUTHENTICATABLE_KEY, 1); + $session->set(SessionAuthenticator::AUTHENTICATABLE_CLASS, MemoizedAuthenticatable::class); + + $authenticator = new SessionAuthenticator( + sessionManager: new TestingSessionManager(), + session: $session, + authenticatableResolver: $resolver, + ); + + $current = $authenticator->current(); + $this->assertInstanceOf(MemoizedAuthenticatable::class, $current); + $this->assertSame(1, $current->id); + + $authenticator->authenticate(new MemoizedAuthenticatable(id: 2)); + + $current = $authenticator->current(); + $this->assertInstanceOf(MemoizedAuthenticatable::class, $current); + $this->assertSame(2, $current->id); + } + + private function createSession(): Session + { + $now = DateTime::now(); + + return new Session( + id: new SessionId('test-session'), + createdAt: $now, + lastActiveAt: $now, + ); + } +} + +final readonly class MemoizedAuthenticatable implements Authenticatable +{ + public function __construct( + public int $id, + ) {} +} + +final class CountingAuthenticatableResolver implements AuthenticatableResolver +{ + public int $resolveCalls = 0; + + /** @var array */ + private array $authenticatables = []; + + public function __construct(MemoizedAuthenticatable ...$authenticatables) + { + foreach ($authenticatables as $authenticatable) { + $this->authenticatables[$authenticatable->id] = $authenticatable; + } + } + + public function resolve(int|string $id, string $class): ?Authenticatable + { + $this->resolveCalls++; + + if ($class !== MemoizedAuthenticatable::class) { + return null; + } + + return $this->authenticatables[$id] ?? null; + } + + public function resolveId(Authenticatable $authenticatable): int + { + return $authenticatable instanceof MemoizedAuthenticatable ? $authenticatable->id : 0; + } +} + +final class TestingSessionManager implements SessionManager +{ + public int $savedSessions = 0; + + public function getOrCreate(SessionId $id): Session + { + $now = DateTime::now(); + + return new Session($id, $now, $now); + } + + public function save(Session $session): void + { + $this->savedSessions++; + } + + public function delete(Session $session): void {} + + public function isValid(Session $session): bool + { + return true; + } + + public function deleteExpiredSessions(): void {} +}