Skip to content
Open
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
45 changes: 38 additions & 7 deletions packages/auth/src/Authentication/SessionAuthenticator.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,35 +7,49 @@
use Tempest\Http\Session\Session;
use Tempest\Http\Session\SessionManager;

final readonly class SessionAuthenticator implements Authenticator
final class SessionAuthenticator implements Authenticator
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you also implement Resettable? That way this one is already prepared for FrankenPHP support :)

{
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);
}
Expand All @@ -46,9 +60,26 @@ 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;
}

private function clearCurrent(): void
{
$this->currentId = null;
$this->currentClass = null;
$this->current = null;
}
}
189 changes: 189 additions & 0 deletions packages/auth/tests/SessionAuthenticatorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
<?php

declare(strict_types=1);

namespace Tempest\Auth\Tests;

use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Tempest\Auth\Authentication\Authenticatable;
use Tempest\Auth\Authentication\AuthenticatableResolver;
use Tempest\Auth\Authentication\SessionAuthenticator;
use Tempest\DateTime\DateTime;
use Tempest\Http\Session\Session;
use Tempest\Http\Session\SessionId;
use Tempest\Http\Session\SessionManager;

final class SessionAuthenticatorTest extends TestCase
{
#[Test]
public function current_memoizes_the_resolved_authenticatable_for_the_current_session_identity(): 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->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 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<int|string, MemoizedAuthenticatable> */
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 {}
}
Loading