feat: identify the authenticated account in the userinfo claims

This commit is contained in:
2026-09-19 10:54:06 +02:00
parent 2cb4871268
commit ec83ad598f
5 changed files with 241 additions and 5 deletions
+23 -2
View File
@@ -379,7 +379,7 @@ Responses:
OIDC-style claims for the authenticated user. The response contains only the claims covered by the granted scopes:
- always: `email`
- always: `sub` (string), `email`
- scope `id`: `person_id`, `address_id`
- scope `roles`: `roles` (array; only the roles that actually grant something are exported — the implicit baseline role is stripped, and so are the `*_PENDING` markers of roles the BusPro CRM claims but nobody has approved yet, see `docs/buspronet-schema/crm-selection-queries.md#from-claim-to-role`)
- scope `profile`: `profile` object:
@@ -394,7 +394,28 @@ OIDC-style claims for the authenticated user. The response contains only the cla
}
```
Note: `profile.communication.email` comes from the CRM address record and is **not necessarily** the login email — a single address can hold several contacts. Use the top-level `email` claim for identity.
##### Identifying the account: match on `sub`
A BusPro address record holds several email addresses (private and business, say), and BusPro
accepts **any** of them as a login. Each one is a **separate MyE&P account with its own roles**
that is intended, not a duplicate: administrative roles are reserved for the company address, so
the same person signing in privately is a Teamer:in or Kund:in and signing in with their
`@ep-reisen.de` address is an administrator.
All of those accounts report the **same** `person_id` and `address_id`, and the same
`profile.communication.email` — that value is the first contact address on the BusPro record and
is **not necessarily** the one signed in with. So:
- **`sub`** — opaque, stable, unique per MyE&P account. The only claim that identifies an account.
Match your local user on it and store it.
- **`email`** — the address this session actually authenticated with. Distinct per account, but
treat it as a display and contact value; it is not the account key.
- **`person_id` / `address_id`** — the BusPro person behind the account. Shared between that
person's accounts, so they identify a *human*, never an account. Do not match on them.
Do not derive staff status from an email domain. MyE&P decides who is a member of staff and
exports the result as `ROLE_EMPLOYEE` in the `roles` claim; re-deriving it from `email` or from
`profile.communication.email` will disagree with MyE&P in both directions.
`400 {"message": "...", "code": "..."}` when the upstream lookup fails or rejects the credentials.
+15 -1
View File
@@ -61,6 +61,13 @@ class PersonalData
/** @var list<string> */
public array $hotelCodes = [];
// The authenticated account, not BusPro's idea of this person. One BusPro person may sign in
// under any of the addresses on its record, and each of those is a separate local account with
// its own roles, so neither the BusPro ids nor the communication email identifies one. Patched
// on by the caller in the same way as the roles and hotel codes above.
public ?string $subject = null;
public ?string $loginEmail = null;
public function __construct()
{
$this->address = new Address();
@@ -132,14 +139,21 @@ class PersonalData
* Creates a structured array containing user profile information
* suitable for JWT claims or user session data.
*
* `sub` and `email` describe the account that authenticated and are read from the patched-on
* fields, never from BusPro: `person_id`/`address_id` are shared by every account of the same
* person, and `profile.communication.email` is the first contact address on the BusPro record,
* which is not necessarily the one signed in with. Deliberately without a fallback to that
* address — a caller that forgets to patch them gets null rather than a wrong identity.
*
* @return array<string, mixed> The claims array with user profile data
*/
public function getClaims(): array
{
return [
'sub' => $this->subject,
'person_id' => $this->personId,
'address_id' => $this->addressId,
'email' => $this->communication->email,
'email' => $this->loginEmail,
'roles' => $this->roles,
'profile' => [
'first_name' => $this->firstName,
+10 -2
View File
@@ -26,8 +26,9 @@ class UserinfoController extends AbstractController
#[Route('/userinfo', name: 'api_userinfo', methods: ['GET'])]
public function index(): JsonResponse
{
// basic scopes applicable to all authenticated users
$scopes = ['email'];
// basic scopes applicable to all authenticated users. `sub` is not gated on a scope of its
// own: it identifies the account every other claim describes, so it is always exported.
$scopes = ['sub', 'email'];
// extend scopes depending on granted permissions
if ($this->isGranted('ROLE_OAUTH2_ID')) {
@@ -53,6 +54,13 @@ class UserinfoController extends AbstractController
return new JsonResponse(['message' => $data->message, 'code' => $data->code], Response::HTTP_BAD_REQUEST);
}
// Patch the identity of the account that authenticated. BusPro accepts any of the
// addresses on a person's record as a login and answers all of them with the same
// ids and the same first contact address, so only the local account tells the staff
// account and the private one apart — and they hold different roles.
$data->subject = (string) $user->getId();
$data->loginEmail = $user->getEmail();
// Patch current user's roles. The implicit ROLE_USER says nothing about the
// account — every authenticated user holds it — and is not exported.
$data->roles = Role::effectiveOnly($user->getRoles());
@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Api;
use App\BusProNet\ApiClient;
use App\BusProNet\Model\PersonalData;
use App\Controller\Api\UserinfoController;
use App\Entity\User;
use App\Security\Crypt;
use App\Security\Role;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* The claims are a published contract (docs/api-consumer-guide.md), and the identity claims in
* particular: a BusPro person may sign in under any address on its record, every one of those is
* a separate account with its own roles, and BusPro answers all of them identically. So what
* separates the accounts is asserted here rather than left to the consumers to discover.
*/
class UserinfoControllerTest extends TestCase
{
private const LOGIN_EMAIL = '[email protected]';
private const CONTACT_EMAIL = '[email protected]';
public function testSubAndEmailAreExportedWithoutAnyOptionalScope(): void
{
$claims = $this->claims();
self::assertSame(['sub', 'email'], array_keys($claims));
self::assertSame('42', $claims['sub']);
self::assertSame(self::LOGIN_EMAIL, $claims['email']);
}
public function testSubIsTheLocalAccountAndNotTheBusProPerson(): void
{
$claims = $this->claims(['ROLE_OAUTH2_ID']);
// The ids identify the human, the subject identifies the account. A second account of the
// same person reports these same two ids and must still be told apart.
self::assertSame('42', $claims['sub']);
self::assertSame(7, $claims['person_id']);
self::assertSame(9, $claims['address_id']);
}
public function testEmailIsTheAuthenticatedAddressAndNotTheFirstContactOnTheBusProRecord(): void
{
$claims = $this->claims(['ROLE_OAUTH2_PROFILE']);
self::assertSame(self::LOGIN_EMAIL, $claims['email']);
self::assertSame(self::CONTACT_EMAIL, $claims['profile']['communication']['email']);
}
public function testRolesExportOnlyWhatIsEffective(): void
{
$claims = $this->claims(['ROLE_OAUTH2_ROLES'], [
Role::USER,
Role::EMPLOYEE,
Role::TEAMER,
Role::pending(Role::MANAGER),
]);
// ROLE_EMPLOYEE is exported because consumers must read staff status from here rather than
// re-deriving it from an email domain — which, given the two addresses, would disagree.
self::assertSame([Role::EMPLOYEE, Role::TEAMER], $claims['roles']);
}
public function testUpstreamFailureIsReportedRatherThanAnswered(): void
{
$apiClient = $this->createStub(ApiClient::class);
$apiClient->method('getPersonalData')->willThrowException(new \App\BusProNet\Exception\ApiClientException('nope'));
$response = $this->call($apiClient, []);
self::assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode());
}
/**
* @param string[] $grantedScopeRoles
* @param string[] $roles
*
* @return array<string, mixed>
*/
private function claims(array $grantedScopeRoles = [], array $roles = [Role::TEAMER]): array
{
$apiClient = $this->createStub(ApiClient::class);
$apiClient->method('getPersonalData')->willReturn($this->personalData());
$response = $this->call($apiClient, $grantedScopeRoles, $roles);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
return json_decode((string) $response->getContent(), true);
}
/**
* @param string[] $grantedScopeRoles
* @param string[] $roles
*/
private function call(ApiClient $apiClient, array $grantedScopeRoles, array $roles = [Role::TEAMER]): Response
{
$crypt = $this->createStub(Crypt::class);
$crypt->method('decrypt')->willReturn('md5-of-the-password');
$controller = new UserinfoController($apiClient, $crypt);
$controller->setContainer($this->container($this->user($roles), $grantedScopeRoles));
return $controller->index();
}
/**
* @param string[] $grantedScopeRoles
*/
private function container(User $user, array $grantedScopeRoles): Container
{
$tokenStorage = new TokenStorage();
$tokenStorage->setToken(new UsernamePasswordToken($user, 'api', $user->getRoles()));
$authorizationChecker = $this->createStub(AuthorizationCheckerInterface::class);
$authorizationChecker->method('isGranted')->willReturnCallback(
static fn (mixed $attribute): bool => \in_array($attribute, $grantedScopeRoles, true),
);
// No 'serializer' service registered, so AbstractController::json() falls back to
// JsonResponse — which is what this endpoint produces in production anyway.
$container = new Container();
$container->set('security.token_storage', $tokenStorage);
$container->set('security.authorization_checker', $authorizationChecker);
return $container;
}
/**
* @param string[] $roles
*/
private function user(array $roles): User
{
$user = (new User(self::LOGIN_EMAIL))
->setPassword('encrypted')
->setRoles($roles)
;
// The id is generated by Doctrine and has no setter, but it is what `sub` exports.
$property = new \ReflectionProperty(User::class, 'id');
$property->setValue($user, 42);
return $user;
}
private function personalData(): PersonalData
{
$personalData = new PersonalData();
$personalData->personId = 7;
$personalData->addressId = 9;
$personalData->firstName = 'Alex';
$personalData->name = 'Beispiel';
// Deliberately not the login address: BusPro returns the first contact row on the record,
// which is whichever of the person's addresses happens to come first.
$personalData->communication->email = self::CONTACT_EMAIL;
return $personalData;
}
}
+24
View File
@@ -240,6 +240,28 @@ class BpnAuthenticatorTest extends TestCase
self::assertSame(['ROLE_USER', Role::CUSTOMER], $user->getRoles());
}
public function testAStaffContactAddressOnTheBusProRecordDoesNotMakeTheLoginStaff(): void
{
$persisted = null;
$persistedPassword = null;
$authenticator = $this->authenticator(
$this->crmAttributes([Role::ADMIN, Role::TEAMER], []),
null,
$persisted,
$persistedPassword,
'[email protected]',
);
$user = $this->loadUser($authenticator, '[email protected]');
// BusPro accepts any address on the record as a login and answers with the first contact
// address regardless of which one was used, so only the typed address may decide. Reading
// the response instead would hand ROLE_EMPLOYEE — and every EMPLOYEE_ONLY role with it —
// to anyone who can add a staff address to their own BusPro record.
self::assertSame(['ROLE_USER', Role::TEAMER], $user->getRoles());
self::assertSame([], $this->dispatched);
}
public function testEmployeeOnlyClaimFromAnotherDomainIsIgnored(): void
{
$persisted = null;
@@ -321,10 +343,12 @@ class BpnAuthenticatorTest extends TestCase
?User $existing,
?User &$persisted,
?string &$persistedPassword = null,
?string $contactEmail = null,
): BpnAuthenticator {
$personalData = new PersonalData();
$personalData->personId = 42;
$personalData->addressId = 4711;
$personalData->communication->email = $contactEmail;
$apiClient = $this->createStub(ApiClient::class);
$apiClient->method('getPersonalData')->willReturn($personalData);