feat: add oauth2 login against myep

This commit is contained in:
fromme committed 2026-09-13 17:58:11 +02:00
1 parent d433d271d0
commit d63df2cc1b
18 files changed
+1599 -33

No files matched your search

+251
View File
@@ -0,0 +1,251 @@
<?php
declare(strict_types=1);
namespace App\Tests\Security;
use App\Entity\User;
use App\Repository\UserRepository;
use App\Security\EpAuthenticator;
use App\Security\OAuth2\AuthorizationRequestException;
use App\Security\OAuth2\EpClient;
use App\Security\OAuth2\UserinfoUnavailableException;
use App\Security\Role;
use Doctrine\ORM\EntityManagerInterface;
use League\OAuth2\Client\Token\AccessToken;
use Psr\Log\NullLogger;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
/**
* The authenticator against a stubbed EpClient.
*
* Everything that speaks HTTP lives in EpClient, so this exercises the part that actually
* carries policy: which roles let somebody in, what identifies them, and what is written back.
*/
class EpAuthenticatorTest extends KernelTestCase
{
private EntityManagerInterface $em;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
$this->em->getConnection()->beginTransaction();
}
protected function tearDown(): void
{
$this->em->getConnection()->rollBack();
parent::tearDown();
}
public function testAnEmployeeIsLoggedInAndCreatedLocally(): void
{
$user = $this->authenticate([
'email' => '[email protected]',
'roles' => ['ROLE_EMPLOYEE'],
'profile' => ['first_name' => 'Anna', 'last_name' => 'Berger'],
]);
self::assertSame('[email protected]', $user->getEmail());
self::assertSame('Anna Berger', $user->getName());
self::assertContains(Role::EMPLOYEE, $user->getRoles());
}
public function testAnAdministratorIsLoggedInToo(): void
{
$user = $this->authenticate(['email' => '[email protected]', 'roles' => ['ROLE_ADMIN']]);
self::assertContains(Role::ADMIN, $user->getRoles());
}
public function testSomebodyWithNeitherRoleIsRefused(): void
{
$this->expectException(CustomUserMessageAuthenticationException::class);
$this->expectExceptionMessage('Mitarbeiter- oder Administrator-Rolle');
$this->authenticate(['email' => '[email protected]', 'roles' => ['ROLE_CUSTOMER', 'ROLE_TEAMER']]);
}
public function testARefusedLoginCreatesNoLocalAccount(): void
{
try {
$this->authenticate(['email' => '[email protected]', 'roles' => ['ROLE_CUSTOMER']]);
} catch (CustomUserMessageAuthenticationException) {
}
self::assertNull($this->em->getRepository(User::class)->findOneBy(['email' => '[email protected]']));
}
/**
* The roles claim is the identity provider's word, and myep is not the only thing that could
* ever put a value in it. Only what this application recognises is stored.
*/
public function testOnlyWhitelistedRolesAreStored(): void
{
$user = $this->authenticate([
'email' => '[email protected]',
'roles' => ['ROLE_EMPLOYEE', 'ROLE_SUPER_ADMIN', 'ROLE_HOUSE_MANAGER'],
]);
self::assertSame([Role::EMPLOYEE, 'ROLE_USER'], $user->getRoles());
}
public function testALoginWithoutAnEmailClaimIsRefused(): void
{
$this->expectException(CustomUserMessageAuthenticationException::class);
$this->authenticate(['roles' => ['ROLE_EMPLOYEE']]);
}
/**
* The consumer guide is explicit: profile.communication.email comes from the CRM address
* record and can belong to a different contact entirely. Keying on it would log somebody in
* as their colleague.
*/
public function testIdentityComesFromTheTopLevelEmailNotTheProfileOne(): void
{
$user = $this->authenticate([
'email' => '[email protected]',
'roles' => ['ROLE_EMPLOYEE'],
'profile' => [
'first_name' => 'Anna',
'last_name' => 'Berger',
'communication' => ['email' => '[email protected]'],
],
]);
self::assertSame('[email protected]', $user->getEmail());
}
public function testAnExistingAccountIsUpdatedRatherThanDuplicated(): void
{
$existing = new User('[email protected]', 'Anna Alt');
$existing->setRoles([Role::EMPLOYEE]);
$this->em->persist($existing);
$this->em->flush();
$user = $this->authenticate([
'email' => '[email protected]',
'roles' => ['ROLE_EMPLOYEE'],
'profile' => ['first_name' => 'Anna', 'last_name' => 'Neu'],
]);
self::assertSame($existing->getId(), $user->getId());
self::assertSame('Anna Neu', $user->getName());
self::assertCount(1, $this->em->getRepository(User::class)->findBy(['email' => '[email protected]']));
}
/**
* myep leads: a role it stops reporting is withdrawn here, not kept because it was granted
* once.
*/
public function testARoleMyepNoLongerReportsIsWithdrawn(): void
{
$existing = new User('[email protected]', 'Chef');
$existing->setRoles([Role::EMPLOYEE, Role::ADMIN]);
$this->em->persist($existing);
$this->em->flush();
$user = $this->authenticate(['email' => '[email protected]', 'roles' => ['ROLE_EMPLOYEE']]);
self::assertNotContains(Role::ADMIN, $user->getRoles());
}
public function testTheEmailStandsInForAMissingProfileName(): void
{
// The profile scope may simply not have been granted.
$user = $this->authenticate(['email' => '[email protected]', 'roles' => ['ROLE_EMPLOYEE']]);
self::assertSame('[email protected]', $user->getName());
}
public function testAMalformedRolesClaimIsTreatedAsNoRoles(): void
{
$this->expectException(CustomUserMessageAuthenticationException::class);
$this->authenticate(['email' => '[email protected]', 'roles' => 'ROLE_EMPLOYEE']);
}
public function testAFailedTokenExchangeBecomesAFriendlyFailure(): void
{
$this->expectException(CustomUserMessageAuthenticationException::class);
$this->expectExceptionMessage('Anmeldung über myep fehlgeschlagen.');
$this->authenticator(throwOnToken: new AuthorizationRequestException('state mismatch'))
->authenticate(new Request());
}
public function testAnUnreachableUserinfoBecomesAFriendlyFailure(): void
{
$this->expectException(CustomUserMessageAuthenticationException::class);
$this->expectExceptionMessage('Anmeldung über myep fehlgeschlagen.');
$this->authenticator(throwOnUserinfo: new UserinfoUnavailableException('boom'))
->authenticate(new Request());
}
public function testItOnlyFiresOnTheCallbackRoute(): void
{
$authenticator = $this->authenticator();
$callback = new Request();
$callback->attributes->set('_route', 'app_auth_check');
self::assertTrue($authenticator->supports($callback));
$dashboard = new Request();
$dashboard->attributes->set('_route', 'app_dashboard');
self::assertFalse($authenticator->supports($dashboard));
}
/**
* @param array<string, mixed> $userinfo
*/
private function authenticate(array $userinfo): User
{
$passport = $this->authenticator($userinfo)->authenticate(new Request());
$badge = $passport->getBadge(UserBadge::class);
self::assertInstanceOf(UserBadge::class, $badge);
$user = $badge->getUser();
self::assertInstanceOf(User::class, $user);
return $user;
}
/**
* @param array<string, mixed> $userinfo
*/
private function authenticator(
array $userinfo = [],
?\Throwable $throwOnToken = null,
?\Throwable $throwOnUserinfo = null,
): EpAuthenticator {
$client = $this->createStub(EpClient::class);
if (null !== $throwOnToken) {
$client->method('fetchAccessToken')->willThrowException($throwOnToken);
} else {
$client->method('fetchAccessToken')->willReturn(new AccessToken(['access_token' => 'token']));
}
if (null !== $throwOnUserinfo) {
$client->method('fetchUserinfo')->willThrowException($throwOnUserinfo);
} else {
$client->method('fetchUserinfo')->willReturn($userinfo);
}
return new EpAuthenticator(
$client,
self::getContainer()->get(UserRepository::class),
$this->em,
self::getContainer()->get(UrlGeneratorInterface::class),
new NullLogger(),
);
}
}
+130
View File
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Tests\Security;
use App\Security\OAuth2\AuthorizationRequestException;
use App\Security\OAuth2\EpClient;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class EpClientTest extends KernelTestCase
{
public function testAnUnregisteredClientIsReportedAsSuch(): void
{
// The state the project is in until somebody runs create-client on myep. The login page
// checks this so it can explain itself instead of redirecting into a broken flow.
self::assertFalse($this->client(clientId: '', clientSecret: '')->isConfigured());
self::assertFalse($this->client(clientId: 'ephub', clientSecret: '')->isConfigured());
self::assertTrue($this->client()->isConfigured());
}
public function testTheAuthorizationUrlCarriesTheClientAndScopes(): void
{
$request = $this->request();
$url = $this->client()->authorizationUrl($request);
self::assertStringStartsWith('https://myep.test/authorize?', $url);
parse_str((string) parse_url($url, \PHP_URL_QUERY), $query);
self::assertSame('ephub', $query['client_id']);
self::assertSame('code', $query['response_type']);
// myep's server wants space-separated scopes; the library's default is a comma.
self::assertSame('email profile roles', $query['scope']);
self::assertStringEndsWith('/auth/check', $query['redirect_uri']);
}
public function testTheStateIsRememberedOnTheSession(): void
{
$request = $this->request();
$url = $this->client()->authorizationUrl($request);
parse_str((string) parse_url($url, \PHP_URL_QUERY), $query);
self::assertNotEmpty($query['state']);
self::assertSame($query['state'], $request->getSession()->get('oauth2state'));
}
public function testACallbackWithoutACodeIsRefused(): void
{
$request = $this->request(['state' => 'abc']);
$request->getSession()->set('oauth2state', 'abc');
$this->expectException(AuthorizationRequestException::class);
$this->expectExceptionMessage('keinen Code');
$this->client()->fetchAccessToken($request);
}
/**
* Without this check an attacker can hand a victim a callback URL carrying the attacker's
* code and bind the victim's session to the attacker's identity.
*/
public function testACallbackWhoseStateDoesNotMatchIsRefused(): void
{
$request = $this->request(['code' => 'xyz', 'state' => 'forged']);
$request->getSession()->set('oauth2state', 'expected');
$this->expectException(AuthorizationRequestException::class);
$this->expectExceptionMessage('state-Parameter');
$this->client()->fetchAccessToken($request);
}
public function testACallbackWithNoStateOnTheSessionIsRefused(): void
{
$request = $this->request(['code' => 'xyz', 'state' => 'anything']);
$this->expectException(AuthorizationRequestException::class);
$this->client()->fetchAccessToken($request);
}
/**
* The stored state is consumed whether or not the check passed, so the same callback URL
* cannot be replayed.
*/
public function testTheStoredStateIsConsumedEvenOnFailure(): void
{
$request = $this->request(['code' => 'xyz', 'state' => 'forged']);
$session = $request->getSession();
$session->set('oauth2state', 'expected');
try {
$this->client()->fetchAccessToken($request);
} catch (AuthorizationRequestException) {
}
self::assertNull($session->get('oauth2state'));
}
/**
* @param array<string, string> $query
*/
private function request(array $query = []): Request
{
$request = new Request($query);
$request->setSession(new Session(new MockArraySessionStorage()));
return $request;
}
private function client(string $clientId = 'ephub', string $clientSecret = 'secret'): EpClient
{
self::bootKernel();
return new EpClient(self::getContainer()->get(UrlGeneratorInterface::class), [
'client_id' => $clientId,
'client_secret' => $clientSecret,
'url_authorize' => 'https://myep.test/authorize',
'url_access_token' => 'https://myep.test/token',
'url_userinfo' => 'https://myep.test/api/userinfo',
'scopes' => ['email', 'profile', 'roles'],
]);
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Tests\Security;
use App\Security\Role;
use PHPUnit\Framework\TestCase;
class RoleTest extends TestCase
{
public function testBothEligibleRolesAreRecognised(): void
{
self::assertSame([Role::EMPLOYEE, Role::ADMIN], Role::eligibleOnly(['ROLE_ADMIN', 'ROLE_EMPLOYEE']));
}
public function testEitherRoleOnItsOwnIsEnough(): void
{
self::assertSame([Role::EMPLOYEE], Role::eligibleOnly(['ROLE_EMPLOYEE']));
self::assertSame([Role::ADMIN], Role::eligibleOnly(['ROLE_ADMIN']));
}
/**
* The whole point of the whitelist: myep knows roles this application assigns no meaning to,
* and an injected one must not become storable just because it was in the response.
*/
public function testRolesThisApplicationDoesNotKnowAreDropped(): void
{
$claimed = ['ROLE_TEAMER', 'ROLE_CUSTOMER', 'ROLE_HOUSE_MANAGER', 'ROLE_SUPER_ADMIN', 'ROLE_EMPLOYEE'];
self::assertSame([Role::EMPLOYEE], Role::eligibleOnly($claimed));
}
public function testAPendingMarkerDoesNotCountAsTheRole(): void
{
// myep strips these before exporting, but that is its policy and not our guarantee.
self::assertSame([], Role::eligibleOnly(['ROLE_ADMIN_PENDING']));
}
public function testTheOrderIsStableRegardlessOfWhatMyepSends(): void
{
self::assertSame(
Role::eligibleOnly(['ROLE_ADMIN', 'ROLE_EMPLOYEE']),
Role::eligibleOnly(['ROLE_EMPLOYEE', 'ROLE_ADMIN']),
);
}
public function testAnEmptyClaimYieldsNothing(): void
{
self::assertSame([], Role::eligibleOnly([]));
self::assertSame([], Role::eligibleOnly(['ROLE_USER']));
}
}