From 8ec4b54f294e61d6b23ea233408945403ec6b9ba Mon Sep 17 00:00:00 2001 From: Kay Joosten Date: Tue, 31 Mar 2026 07:39:17 +0200 Subject: [PATCH 1/2] feat: add NameID lookup API (#1931) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new endpoints on the internal API: - POST /info/users/nameid — forward lookup (sho + uid + sp_entityid → nameid) - POST /info/users/id — reverse lookup (nameid → sho + uid + sp_entityid) Both require ROLE_API_USER_NAMEID_LOOKUP and are feature-flag gated. --- config/packages/ci/parameters.yml | 4 + config/packages/dev/parameters.yml | 4 + config/packages/engineblock_features.yaml | 2 + config/packages/parameters.yml.dist | 4 + config/packages/security.yaml | 3 + config/packages/test/parameters.yml | 5 + config/services/controllers/api.yml | 8 + config/services/services.yml | 7 + .../Service/NameIdLookupService.php | 109 ++++++++++++ .../Entity/SamlPersistentId.php | 2 +- .../Repository/SamlPersistentIdRepository.php | 13 ++ .../ServiceProviderUuidRepository.php | 25 ++- .../Repository/UserRepository.php | 15 ++ .../Controller/Api/UserController.php | 165 ++++++++++++++++++ 14 files changed, 360 insertions(+), 6 deletions(-) create mode 100644 config/packages/test/parameters.yml create mode 100644 src/OpenConext/EngineBlock/Service/NameIdLookupService.php create mode 100644 src/OpenConext/EngineBlockBundle/Controller/Api/UserController.php diff --git a/config/packages/ci/parameters.yml b/config/packages/ci/parameters.yml index a874aea6d2..cb927e63d5 100644 --- a/config/packages/ci/parameters.yml +++ b/config/packages/ci/parameters.yml @@ -1,4 +1,8 @@ parameters: + api.users.nameIdLookup.username: nameid + api.users.nameIdLookup.password: secret + feature_api_users_nameid: true + feature_api_users_id: true encryption_keys: default: publicFile: /config/engine/engineblock.crt diff --git a/config/packages/dev/parameters.yml b/config/packages/dev/parameters.yml index a874aea6d2..cb927e63d5 100644 --- a/config/packages/dev/parameters.yml +++ b/config/packages/dev/parameters.yml @@ -1,4 +1,8 @@ parameters: + api.users.nameIdLookup.username: nameid + api.users.nameIdLookup.password: secret + feature_api_users_nameid: true + feature_api_users_id: true encryption_keys: default: publicFile: /config/engine/engineblock.crt diff --git a/config/packages/engineblock_features.yaml b/config/packages/engineblock_features.yaml index 8e7ed423c1..38c9f7c4e9 100644 --- a/config/packages/engineblock_features.yaml +++ b/config/packages/engineblock_features.yaml @@ -5,6 +5,8 @@ parameters: api.consent_remove: "%feature_api_consent_remove%" api.metadata_api: "%feature_api_metadata_api%" api.deprovision: "%feature_api_deprovision%" + api.users_nameid: "%feature_api_users_nameid%" + api.users_id: "%feature_api_users_id%" eb.encrypted_assertions: "%feature_eb_encrypted_assertions%" eb.encrypted_assertions_require_outer_signature: "%feature_eb_encrypted_assertions_require_outer_signature%" eb.run_all_manipulations_prior_to_consent: "%feature_run_all_manipulations_prior_to_consent%" diff --git a/config/packages/parameters.yml.dist b/config/packages/parameters.yml.dist index 0dc1693cf8..e2c3e59378 100644 --- a/config/packages/parameters.yml.dist +++ b/config/packages/parameters.yml.dist @@ -77,6 +77,8 @@ parameters: api.users.profile.password: secret api.users.deprovision.username: lifecycle api.users.deprovision.password: secret + api.users.nameIdLookup.username: nameid + api.users.nameIdLookup.password: secret ########################################################################################## ## CLIENT SETTINGS @@ -220,6 +222,8 @@ parameters: feature_api_consent_remove: true feature_api_metadata_api: true feature_api_deprovision: true + feature_api_users_nameid: true + feature_api_users_id: true feature_run_all_manipulations_prior_to_consent: false feature_block_user_on_violation: false feature_enable_consent: true diff --git a/config/packages/security.yaml b/config/packages/security.yaml index cc57b3a1f5..3a095541d4 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -12,6 +12,9 @@ security: "%api.users.deprovision.username%": password: "%api.users.deprovision.password%" roles: 'ROLE_API_USER_DEPROVISION' + "%api.users.nameIdLookup.username%": + password: "%api.users.nameIdLookup.password%" + roles: 'ROLE_API_USER_NAMEID_LOOKUP' password_hashers: Symfony\Component\Security\Core\User\InMemoryUser: plaintext diff --git a/config/packages/test/parameters.yml b/config/packages/test/parameters.yml new file mode 100644 index 0000000000..5fb8fa9275 --- /dev/null +++ b/config/packages/test/parameters.yml @@ -0,0 +1,5 @@ +parameters: + api.users.nameIdLookup.username: nameid + api.users.nameIdLookup.password: secret + feature_api_users_nameid: true + feature_api_users_id: true diff --git a/config/services/controllers/api.yml b/config/services/controllers/api.yml index 90686d60ff..43c0fb69ad 100644 --- a/config/services/controllers/api.yml +++ b/config/services/controllers/api.yml @@ -26,6 +26,14 @@ services: - '@OpenConext\EngineBlock\Service\DeprovisionService' - 'EngineBlock' + OpenConext\EngineBlockBundle\Controller\Api\UserController: + arguments: + - '@security.token_storage' + - '@security.access.decision_manager' + - '@OpenConext\EngineBlockBundle\Configuration\FeatureConfiguration' + - '@OpenConext\EngineBlock\Service\NameIdLookupService' + - '@logger' + OpenConext\EngineBlockBundle\Controller\Api\HeartbeatController: OpenConext\EngineBlockBundle\Controller\Api\MetadataController: diff --git a/config/services/services.yml b/config/services/services.yml index a3f81ab354..0e41ac636e 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -87,6 +87,13 @@ services: - '@OpenConext\EngineBlockBundle\Authentication\Repository\SamlPersistentIdRepository' - '@OpenConext\EngineBlockBundle\Authentication\Repository\ServiceProviderUuidRepository' + OpenConext\EngineBlock\Service\NameIdLookupService: + arguments: + - '@OpenConext\EngineBlockBundle\Authentication\Repository\UserRepository' + - '@OpenConext\EngineBlockBundle\Authentication\Repository\ServiceProviderUuidRepository' + - '@OpenConext\EngineBlockBundle\Authentication\Repository\SamlPersistentIdRepository' + - '@logger' + OpenConext\EngineBlock\Service\MetadataService: arguments: - '@engineblock.compat.repository.metadata' diff --git a/src/OpenConext/EngineBlock/Service/NameIdLookupService.php b/src/OpenConext/EngineBlock/Service/NameIdLookupService.php new file mode 100644 index 0000000000..77e05b1d3a --- /dev/null +++ b/src/OpenConext/EngineBlock/Service/NameIdLookupService.php @@ -0,0 +1,109 @@ +userRepository->findByCollabPersonId($collabPersonId); + if ($user === null) { + $this->logger->debug('NameIdLookupService: user not found', [ + 'collabPersonId' => $collabPersonId->getCollabPersonId(), + ]); + return null; + } + + $spUuid = $this->spUuidRepository->findUuidByEntityId($spEntityId); + if ($spUuid === null) { + $this->logger->debug('NameIdLookupService: SP not found', ['spEntityId' => $spEntityId]); + return null; + } + + $userUuid = $user->collabPersonUuid->getUuid(); + $stored = $this->persistentIdRepository->findByUserAndSpUuid($userUuid, $spUuid); + + if ($stored !== null) { + return ['nameid' => $stored->persistentId, 'stored' => true]; + } + + $calculatedNameId = sha1(self::PERSISTENT_NAMEID_SALT . $userUuid . $spUuid); + return ['nameid' => $calculatedNameId, 'stored' => false]; + } + + public function resolveUserIdentity(string $persistentId): ?array + { + $entry = $this->persistentIdRepository->find($persistentId); + if ($entry === null) { + return null; + } + + $user = $this->userRepository->findByCollabPersonUuid(new CollabPersonUuid($entry->userUuid)); + if ($user === null) { + $this->logger->warning( + 'NameIdLookupService: saml_persistent_id entry exists but user record is missing', + ['userUuid' => $entry->userUuid] + ); + return null; + } + + $spEntityId = $this->spUuidRepository->findEntityIdByUuid($entry->serviceProviderUuid); + if ($spEntityId === null) { + $this->logger->warning( + 'NameIdLookupService: saml_persistent_id entry exists but SP UUID record is missing', + ['serviceProviderUuid' => $entry->serviceProviderUuid] + ); + return null; + } + + $parts = explode(':', $user->collabPersonId->getCollabPersonId(), 5); + $schacHomeOrganization = $parts[3] ?? ''; + $uid = $parts[4] ?? ''; + + return [ + 'schacHomeOrganization' => $schacHomeOrganization, + 'uid' => $uid, + 'sp_entityid' => $spEntityId, + ]; + } +} diff --git a/src/OpenConext/EngineBlockBundle/Authentication/Entity/SamlPersistentId.php b/src/OpenConext/EngineBlockBundle/Authentication/Entity/SamlPersistentId.php index d16b387a60..4267d3428a 100644 --- a/src/OpenConext/EngineBlockBundle/Authentication/Entity/SamlPersistentId.php +++ b/src/OpenConext/EngineBlockBundle/Authentication/Entity/SamlPersistentId.php @@ -30,7 +30,7 @@ class SamlPersistentId * @var string */ #[ORM\Id] - #[ORM\Column(type: Types::STRING, length: 40, options: ['fixed' => true, 'comment' => 'SHA1 of service_provider_uuid + user_uuid'])] + #[ORM\Column(type: Types::STRING, length: 40, options: ['fixed' => true, 'comment' => 'SHA1 of COIN: + user_uuid + service_provider_uuid'])] public ?string $persistentId = null; /** diff --git a/src/OpenConext/EngineBlockBundle/Authentication/Repository/SamlPersistentIdRepository.php b/src/OpenConext/EngineBlockBundle/Authentication/Repository/SamlPersistentIdRepository.php index a54faec28f..59d968c79f 100644 --- a/src/OpenConext/EngineBlockBundle/Authentication/Repository/SamlPersistentIdRepository.php +++ b/src/OpenConext/EngineBlockBundle/Authentication/Repository/SamlPersistentIdRepository.php @@ -57,4 +57,17 @@ public function deleteByUuid(CollabPersonUuid $uuid) ->getQuery() ->execute(); } + + /** + * @param string $userUuid + * @param string $serviceProviderUuid + * @return SamlPersistentId|null + */ + public function findByUserAndSpUuid(string $userUuid, string $serviceProviderUuid): ?SamlPersistentId + { + return $this->findOneBy([ + 'userUuid' => $userUuid, + 'serviceProviderUuid' => $serviceProviderUuid, + ]); + } } diff --git a/src/OpenConext/EngineBlockBundle/Authentication/Repository/ServiceProviderUuidRepository.php b/src/OpenConext/EngineBlockBundle/Authentication/Repository/ServiceProviderUuidRepository.php index 4826232ee9..d053e5749c 100644 --- a/src/OpenConext/EngineBlockBundle/Authentication/Repository/ServiceProviderUuidRepository.php +++ b/src/OpenConext/EngineBlockBundle/Authentication/Repository/ServiceProviderUuidRepository.php @@ -32,11 +32,7 @@ public function __construct(ManagerRegistry $registry) parent::__construct($registry, ServiceProviderUuid::class); } - /** - * @param string $uuid - * @return string|null - */ - public function findEntityIdByUuid($uuid) + public function findEntityIdByUuid(string $uuid): ?string { $entry = $this->findOneBy([ 'uuid' => $uuid, @@ -45,5 +41,24 @@ public function findEntityIdByUuid($uuid) if ($entry instanceof ServiceProviderUuid) { return $entry->serviceProviderEntityId; } + + return null; + } + + /** + * @param string $entityId + * @return string|null UUID string, or null if SP is not yet known + */ + public function findUuidByEntityId(string $entityId): ?string + { + $entry = $this->findOneBy([ + 'serviceProviderEntityId' => $entityId, + ]); + + if ($entry instanceof ServiceProviderUuid) { + return $entry->uuid; + } + + return null; } } diff --git a/src/OpenConext/EngineBlockBundle/Authentication/Repository/UserRepository.php b/src/OpenConext/EngineBlockBundle/Authentication/Repository/UserRepository.php index 900ba93ae5..ef3e4abe5c 100644 --- a/src/OpenConext/EngineBlockBundle/Authentication/Repository/UserRepository.php +++ b/src/OpenConext/EngineBlockBundle/Authentication/Repository/UserRepository.php @@ -21,6 +21,7 @@ use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; use OpenConext\EngineBlock\Authentication\Value\CollabPersonId; +use OpenConext\EngineBlock\Authentication\Value\CollabPersonUuid; use OpenConext\EngineBlockBundle\Authentication\Entity\User; /** @@ -74,4 +75,18 @@ public function deleteUserWithCollabPersonId(CollabPersonId $collabPersonId) $this->getEntityManager()->flush(); } + + /** + * @param CollabPersonUuid $uuid + * @return null|User + */ + public function findByCollabPersonUuid(CollabPersonUuid $uuid): ?User + { + return $this + ->createQueryBuilder('u') + ->where('u.collabPersonUuid = :uuid') + ->setParameter('uuid', $uuid->getUuid()) + ->getQuery() + ->getOneOrNullResult(); + } } diff --git a/src/OpenConext/EngineBlockBundle/Controller/Api/UserController.php b/src/OpenConext/EngineBlockBundle/Controller/Api/UserController.php new file mode 100644 index 0000000000..fd12744b1b --- /dev/null +++ b/src/OpenConext/EngineBlockBundle/Controller/Api/UserController.php @@ -0,0 +1,165 @@ +tokenStorage->getToken()?->getUserIdentifier() ?? 'unknown'; + } + + #[Route(path: '/info/users/nameid', name: 'api_users_nameid', methods: ['POST'], defaults: ['_format' => 'json'])] + public function nameIdAction(Request $request): JsonResponse + { + if (!$this->featureConfiguration->isEnabled('api.users_nameid')) { + throw new ApiNotFoundHttpException('NameID lookup API is disabled'); + } + + $this->assertAuthorized(); + + $entries = $this->decodeJsonArray($request); + + $this->logger->info('NameID lookup requested', [ + 'caller' => $this->getCallerUsername(), + 'count' => count($entries), + ]); + + $results = []; + foreach ($entries as $entry) { + $this->assertEntryHasRequiredFields($entry, ['schacHomeOrganization', 'uid', 'sp_entityid']); + $results[] = $this->nameIdLookupService->resolveNameId( + $entry['schacHomeOrganization'], + $entry['uid'], + $entry['sp_entityid'] + ); + } + + return new JsonResponse($results, Response::HTTP_OK); + } + + #[Route(path: '/info/users/id', name: 'api_users_id', methods: ['POST'], defaults: ['_format' => 'json'])] + public function userIdentityAction(Request $request): JsonResponse + { + if (!$this->featureConfiguration->isEnabled('api.users_id')) { + throw new ApiNotFoundHttpException('User identity lookup API is disabled'); + } + + $this->assertAuthorized(); + + $nameIds = $this->decodeJsonArray($request); + + $this->logger->info('User identity lookup requested', [ + 'caller' => $this->getCallerUsername(), + 'count' => count($nameIds), + ]); + + $results = []; + foreach ($nameIds as $nameId) { + if (!is_string($nameId)) { + throw new BadApiRequestHttpException('Each entry in the request must be a string NameID'); + } + if (!preg_match('/^[0-9a-f]{40}$/i', $nameId)) { + throw new BadApiRequestHttpException( + sprintf('Invalid NameID format "%s": must be a 40-character hexadecimal SHA1 string', $nameId) + ); + } + $results[] = $this->nameIdLookupService->resolveUserIdentity($nameId); + } + + return new JsonResponse($results, Response::HTTP_OK); + } + + private function assertAuthorized(): void + { + $token = $this->tokenStorage->getToken(); + if (!$token || !$token->getUser()) { + throw new AuthenticationCredentialsNotFoundException( + 'The token storage contains no authentication token.' + ); + } + + if (!$this->accessDecisionManager->decide($token, ['ROLE_API_USER_NAMEID_LOOKUP'], null)) { + throw new ApiAccessDeniedHttpException( + 'Access to the NameID lookup API requires the role ROLE_API_USER_NAMEID_LOOKUP' + ); + } + } + + private function decodeJsonArray(Request $request): array + { + $content = $request->getContent(); + $data = json_decode($content, true); + + if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) { + throw new BadApiRequestHttpException( + sprintf('Request body must be a valid JSON array. JSON error: %s', json_last_error_msg()) + ); + } + + return $data; + } + + private function assertEntryHasRequiredFields(mixed $entry, array $requiredFields): void + { + if (!is_array($entry)) { + throw new BadApiRequestHttpException('Each entry in the request must be a JSON object'); + } + + foreach ($requiredFields as $field) { + if (!array_key_exists($field, $entry)) { + throw new BadApiRequestHttpException( + sprintf('Missing required field "%s" in request entry', $field) + ); + } + + if (!is_string($entry[$field]) || $entry[$field] === '') { + throw new BadApiRequestHttpException( + sprintf('Field "%s" must be a non-empty string', $field) + ); + } + } + } +} From 6367c740fbaef172a733a45c0d668eac209058c7 Mon Sep 17 00:00:00 2001 From: Kay Joosten Date: Tue, 31 Mar 2026 07:39:26 +0200 Subject: [PATCH 2/2] test: add unit, functional and integration tests for NameID lookup API --- .../Version20260331000000.php | 57 +++ .../Service/NameIdLookupService.php | 2 +- .../Controller/Api/UserController.php | 8 +- .../Repository/NameIdLookupRepositoryTest.php | 178 +++++++++ .../Controller/Api/UserControllerTest.php | 347 ++++++++++++++++++ .../Service/NameIdLookupServiceTest.php | 294 +++++++++++++++ 6 files changed, 882 insertions(+), 4 deletions(-) create mode 100644 migrations/DoctrineMigrations/Version20260331000000.php create mode 100644 tests/functional/OpenConext/EngineBlockBundle/Authentication/Repository/NameIdLookupRepositoryTest.php create mode 100644 tests/functional/OpenConext/EngineBlockBundle/Controller/Api/UserControllerTest.php create mode 100644 tests/unit/OpenConext/EngineBlock/Service/NameIdLookupServiceTest.php diff --git a/migrations/DoctrineMigrations/Version20260331000000.php b/migrations/DoctrineMigrations/Version20260331000000.php new file mode 100644 index 0000000000..b27cf35e0e --- /dev/null +++ b/migrations/DoctrineMigrations/Version20260331000000.php @@ -0,0 +1,57 @@ +addSql( + "ALTER TABLE `saml_persistent_id` MODIFY COLUMN `persistent_id` CHAR(40) NOT NULL COMMENT 'SHA1 of COIN: + user_uuid + service_provider_uuid'" + ); + } + + public function down(Schema $schema): void + { + $this->addSql( + "ALTER TABLE `saml_persistent_id` MODIFY COLUMN `persistent_id` CHAR(40) NOT NULL COMMENT 'SHA1 of service_provider_uuid + user_uuid'" + ); + } +} diff --git a/src/OpenConext/EngineBlock/Service/NameIdLookupService.php b/src/OpenConext/EngineBlock/Service/NameIdLookupService.php index 77e05b1d3a..7b7a0cc0a2 100644 --- a/src/OpenConext/EngineBlock/Service/NameIdLookupService.php +++ b/src/OpenConext/EngineBlock/Service/NameIdLookupService.php @@ -1,7 +1,7 @@ tokenStorage->getToken()?->getUserIdentifier() ?? 'unknown'; } - #[Route(path: '/info/users/nameid', name: 'api_users_nameid', methods: ['POST'], defaults: ['_format' => 'json'])] + #[Route(path: '/info/users/nameid', name: 'api_users_nameid', defaults: ['_format' => 'json'], methods: ['POST'])] public function nameIdAction(Request $request): JsonResponse { if (!$this->featureConfiguration->isEnabled('api.users_nameid')) { @@ -80,7 +80,7 @@ public function nameIdAction(Request $request): JsonResponse return new JsonResponse($results, Response::HTTP_OK); } - #[Route(path: '/info/users/id', name: 'api_users_id', methods: ['POST'], defaults: ['_format' => 'json'])] + #[Route(path: '/info/users/id', name: 'api_users_id', defaults: ['_format' => 'json'], methods: ['POST'])] public function userIdentityAction(Request $request): JsonResponse { if (!$this->featureConfiguration->isEnabled('api.users_id')) { @@ -101,11 +101,13 @@ public function userIdentityAction(Request $request): JsonResponse if (!is_string($nameId)) { throw new BadApiRequestHttpException('Each entry in the request must be a string NameID'); } + if (!preg_match('/^[0-9a-f]{40}$/i', $nameId)) { throw new BadApiRequestHttpException( sprintf('Invalid NameID format "%s": must be a 40-character hexadecimal SHA1 string', $nameId) ); } + $results[] = $this->nameIdLookupService->resolveUserIdentity($nameId); } diff --git a/tests/functional/OpenConext/EngineBlockBundle/Authentication/Repository/NameIdLookupRepositoryTest.php b/tests/functional/OpenConext/EngineBlockBundle/Authentication/Repository/NameIdLookupRepositoryTest.php new file mode 100644 index 0000000000..62eac47973 --- /dev/null +++ b/tests/functional/OpenConext/EngineBlockBundle/Authentication/Repository/NameIdLookupRepositoryTest.php @@ -0,0 +1,178 @@ +clearFixtures(); + parent::tearDown(); + restore_exception_handler(); + } + + #[Group('Repository')] + #[Group('NameIdLookup')] + #[Test] + public function find_by_collab_person_uuid_returns_the_correct_user(): void + { + $userUuid = Uuid::uuid4()->toString(); + $collabPersonId = 'urn:collab:person:example.edu:' . uniqid(); + + $this->insertUser($collabPersonId, $userUuid); + + $repo = self::getContainer()->get(UserRepository::class); + $user = $repo->findByCollabPersonUuid(new CollabPersonUuid($userUuid)); + + $this->assertNotNull($user); + $this->assertSame($collabPersonId, $user->collabPersonId->getCollabPersonId()); + $this->assertSame($userUuid, $user->collabPersonUuid->getUuid()); + } + + #[Group('Repository')] + #[Group('NameIdLookup')] + #[Test] + public function find_by_collab_person_uuid_returns_null_when_uuid_is_unknown(): void + { + $repo = self::getContainer()->get(UserRepository::class); + $user = $repo->findByCollabPersonUuid(new CollabPersonUuid('00000000-0000-0000-0000-000000000000')); + + $this->assertNull($user); + } + + #[Group('Repository')] + #[Group('NameIdLookup')] + #[Test] + public function find_uuid_by_entity_id_returns_the_uuid_for_a_known_sp(): void + { + $spUuid = Uuid::uuid4()->toString(); + $spEntityId = 'https://sp-' . uniqid() . '.example.com/'; + + $this->insertServiceProvider($spUuid, $spEntityId); + + $repo = self::getContainer()->get(ServiceProviderUuidRepository::class); + $uuid = $repo->findUuidByEntityId($spEntityId); + + $this->assertSame($spUuid, $uuid); + } + + #[Group('Repository')] + #[Group('NameIdLookup')] + #[Test] + public function find_uuid_by_entity_id_returns_null_when_sp_is_unknown(): void + { + $repo = self::getContainer()->get(ServiceProviderUuidRepository::class); + $uuid = $repo->findUuidByEntityId('https://unknown-sp.example.com/'); + + $this->assertNull($uuid); + } + + #[Group('Repository')] + #[Group('NameIdLookup')] + #[Test] + public function find_by_user_and_sp_uuid_returns_entry_when_persistent_id_exists(): void + { + $userUuid = Uuid::uuid4()->toString(); + $spUuid = Uuid::uuid4()->toString(); + $persistentId = sha1('COIN:' . $userUuid . $spUuid); + + $this->insertPersistentId($persistentId, $userUuid, $spUuid); + + $repo = self::getContainer()->get(SamlPersistentIdRepository::class); + $entry = $repo->findByUserAndSpUuid($userUuid, $spUuid); + + $this->assertNotNull($entry); + $this->assertSame($persistentId, $entry->persistentId); + $this->assertSame($userUuid, $entry->userUuid); + $this->assertSame($spUuid, $entry->serviceProviderUuid); + } + + #[Group('Repository')] + #[Group('NameIdLookup')] + #[Test] + public function find_by_user_and_sp_uuid_returns_null_when_no_persistent_id_stored(): void + { + $repo = self::getContainer()->get(SamlPersistentIdRepository::class); + $entry = $repo->findByUserAndSpUuid(Uuid::uuid4()->toString(), Uuid::uuid4()->toString()); + + $this->assertNull($entry); + } + + private function insertUser(string $collabPersonId, string $uuid): void + { + $qb = $this->connection()->createQueryBuilder(); + assert($qb instanceof QueryBuilder); + $qb->insert('user') + ->values(['collab_person_id' => ':collab_person_id', 'uuid' => ':uuid']) + ->setParameters(['collab_person_id' => $collabPersonId, 'uuid' => $uuid]) + ->executeStatement(); + } + + private function insertServiceProvider(string $uuid, string $entityId): void + { + $qb = $this->connection()->createQueryBuilder(); + assert($qb instanceof QueryBuilder); + $qb->insert('service_provider_uuid') + ->values(['uuid' => ':uuid', 'service_provider_entity_id' => ':entity_id']) + ->setParameters(['uuid' => $uuid, 'entity_id' => $entityId]) + ->executeStatement(); + } + + private function insertPersistentId(string $persistentId, string $userUuid, string $spUuid): void + { + $qb = $this->connection()->createQueryBuilder(); + assert($qb instanceof QueryBuilder); + $qb->insert('saml_persistent_id') + ->values([ + 'persistent_id' => ':persistent_id', + 'user_uuid' => ':user_uuid', + 'service_provider_uuid' => ':sp_uuid', + ]) + ->setParameters([ + 'persistent_id' => $persistentId, + 'user_uuid' => $userUuid, + 'sp_uuid' => $spUuid, + ]) + ->executeStatement(); + } + + private function clearFixtures(): void + { + $conn = $this->connection(); + $conn->executeStatement('DELETE FROM saml_persistent_id'); + $conn->executeStatement('DELETE FROM service_provider_uuid'); + $conn->executeStatement('DELETE FROM user'); + } + + private function connection(): Connection + { + return self::getContainer()->get('doctrine')->getConnection(); + } +} diff --git a/tests/functional/OpenConext/EngineBlockBundle/Controller/Api/UserControllerTest.php b/tests/functional/OpenConext/EngineBlockBundle/Controller/Api/UserControllerTest.php new file mode 100644 index 0000000000..94c398dbe9 --- /dev/null +++ b/tests/functional/OpenConext/EngineBlockBundle/Controller/Api/UserControllerTest.php @@ -0,0 +1,347 @@ +clearFixtures(); + parent::tearDown(); + } + + private function clearFixtures(): void + { + $conn = $this->connection(); + $conn->executeStatement('DELETE FROM saml_persistent_id'); + $conn->executeStatement('DELETE FROM service_provider_uuid'); + $conn->executeStatement('DELETE FROM user'); + } + + private function insertUserFixture(string $userUuid): void + { + $collabId = 'urn:collab:person:' . self::SHO . ':' . self::UID; + $this->connection()->executeStatement( + 'INSERT IGNORE INTO user (collab_person_id, uuid) VALUES (?, ?)', + [$collabId, $userUuid] + ); + } + + private function insertSpFixture(string $spUuid): void + { + $this->connection()->executeStatement( + 'INSERT IGNORE INTO service_provider_uuid (uuid, service_provider_entity_id) VALUES (?, ?)', + [$spUuid, self::SP_ENTITY_ID] + ); + } + + private function insertPersistentIdFixture(string $persistentId, string $userUuid, string $spUuid): void + { + $this->connection()->executeStatement( + 'INSERT IGNORE INTO saml_persistent_id (persistent_id, user_uuid, service_provider_uuid) VALUES (?, ?, ?)', + [$persistentId, $userUuid, $spUuid] + ); + } + + private function connection(): Connection + { + return self::getContainer()->get('doctrine')->getConnection(); + } + + private function createNameIdLookupClient(): KernelBrowser + { + return static::createClient([], [ + 'PHP_AUTH_USER' => 'nameid', + 'PHP_AUTH_PW' => 'secret', + ]); + } + + #[Group('Api')] + #[Group('NameIdLookup')] + #[Test] + public function nameid_endpoint_requires_authentication(): void + { + $client = static::createClient(); + $client->request('POST', 'https://engine-api.dev.openconext.local/info/users/nameid', [], [], ['CONTENT_TYPE' => 'application/json'], '[]'); + $this->assertStatusCode(Response::HTTP_UNAUTHORIZED, $client); + } + + #[Group('Api')] + #[Group('NameIdLookup')] + #[Test] + public function id_endpoint_requires_authentication(): void + { + $client = static::createClient(); + $client->request('POST', 'https://engine-api.dev.openconext.local/info/users/id', [], [], ['CONTENT_TYPE' => 'application/json'], '[]'); + $this->assertStatusCode(Response::HTTP_UNAUTHORIZED, $client); + } + + #[Group('Api')] + #[Group('NameIdLookup')] + #[Test] + public function nameid_endpoint_denies_wrong_role(): void + { + $client = static::createClient([], ['PHP_AUTH_USER' => 'profile', 'PHP_AUTH_PW' => 'secret']); + $client->request('POST', 'https://engine-api.dev.openconext.local/info/users/nameid', [], [], ['CONTENT_TYPE' => 'application/json'], '[]'); + $this->assertStatusCode(Response::HTTP_FORBIDDEN, $client); + } + + // ─── HTTP method validation ─────────────────────────────────────────────── + + #[Group('Api')] + #[Group('NameIdLookup')] + #[Test] + public function nameid_endpoint_rejects_get_requests(): void + { + $client = $this->createNameIdLookupClient(); + $client->request('GET', 'https://engine-api.dev.openconext.local/info/users/nameid'); + $this->assertStatusCode(Response::HTTP_METHOD_NOT_ALLOWED, $client); + } + + #[Group('Api')] + #[Group('NameIdLookup')] + #[Test] + public function id_endpoint_rejects_get_requests(): void + { + $client = $this->createNameIdLookupClient(); + $client->request('GET', 'https://engine-api.dev.openconext.local/info/users/id'); + $this->assertStatusCode(Response::HTTP_METHOD_NOT_ALLOWED, $client); + } + + #[Group('Api')] + #[Group('NameIdLookup')] + #[Test] + public function nameid_endpoint_returns_400_for_invalid_json(): void + { + $client = $this->createNameIdLookupClient(); + $client->request( + 'POST', + 'https://engine-api.dev.openconext.local/info/users/nameid', + [], + [], + ['CONTENT_TYPE' => 'application/json'], + 'not-json' + ); + $this->assertStatusCode(Response::HTTP_BAD_REQUEST, $client); + } + + #[Group('Api')] + #[Group('NameIdLookup')] + #[Test] + public function nameid_endpoint_returns_400_when_entry_missing_required_fields(): void + { + $client = $this->createNameIdLookupClient(); + $body = json_encode([['schacHomeOrganization' => 'example.edu']]); + $client->request( + 'POST', + 'https://engine-api.dev.openconext.local/info/users/nameid', + [], + [], + ['CONTENT_TYPE' => 'application/json'], + $body + ); + $this->assertStatusCode(Response::HTTP_BAD_REQUEST, $client); + } + + #[Group('Api')] + #[Group('NameIdLookup')] + #[Test] + public function nameid_endpoint_returns_null_for_unknown_user(): void + { + $client = $this->createNameIdLookupClient(); + $body = json_encode([[ + 'schacHomeOrganization' => 'unknown.edu', + 'uid' => 'nobody', + 'sp_entityid' => self::SP_ENTITY_ID, + ]]); + $client->request( + 'POST', + 'https://engine-api.dev.openconext.local/info/users/nameid', + [], + [], + ['CONTENT_TYPE' => 'application/json'], + $body + ); + $this->assertStatusCode(Response::HTTP_OK, $client); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertCount(1, $response); + $this->assertNull($response[0]); + } + + #[Group('Api')] + #[Group('NameIdLookup')] + #[Test] + public function nameid_endpoint_returns_calculated_nameid_when_not_yet_stored(): void + { + $userUuid = Uuid::uuid4()->toString(); + $spUuid = Uuid::uuid4()->toString(); + $persistentId = sha1('COIN:' . $userUuid . $spUuid); + + $client = $this->createNameIdLookupClient(); + $this->insertUserFixture($userUuid); + $this->insertSpFixture($spUuid); + + $body = json_encode([[ + 'schacHomeOrganization' => self::SHO, + 'uid' => self::UID, + 'sp_entityid' => self::SP_ENTITY_ID, + ]]); + $client->request( + 'POST', + 'https://engine-api.dev.openconext.local/info/users/nameid', + [], + [], + ['CONTENT_TYPE' => 'application/json'], + $body + ); + $this->assertStatusCode(Response::HTTP_OK, $client); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertCount(1, $response); + $this->assertSame($persistentId, $response[0]['nameid']); + $this->assertFalse($response[0]['stored']); + } + + #[Group('Api')] + #[Group('NameIdLookup')] + #[Test] + public function nameid_endpoint_returns_stored_nameid(): void + { + $userUuid = Uuid::uuid4()->toString(); + $spUuid = Uuid::uuid4()->toString(); + $persistentId = sha1('COIN:' . $userUuid . $spUuid); + + $client = $this->createNameIdLookupClient(); + $this->insertUserFixture($userUuid); + $this->insertSpFixture($spUuid); + $this->insertPersistentIdFixture($persistentId, $userUuid, $spUuid); + + $body = json_encode([[ + 'schacHomeOrganization' => self::SHO, + 'uid' => self::UID, + 'sp_entityid' => self::SP_ENTITY_ID, + ]]); + $client->request( + 'POST', + 'https://engine-api.dev.openconext.local/info/users/nameid', + [], + [], + ['CONTENT_TYPE' => 'application/json'], + $body + ); + $this->assertStatusCode(Response::HTTP_OK, $client); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertSame($persistentId, $response[0]['nameid']); + $this->assertTrue($response[0]['stored']); + } + + #[Group('Api')] + #[Group('NameIdLookup')] + #[Test] + public function id_endpoint_returns_null_for_unknown_nameid(): void + { + $client = $this->createNameIdLookupClient(); + $body = json_encode(['0000000000000000000000000000000000000000']); + $client->request( + 'POST', + 'https://engine-api.dev.openconext.local/info/users/id', + [], + [], + ['CONTENT_TYPE' => 'application/json'], + $body + ); + $this->assertStatusCode(Response::HTTP_OK, $client); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertCount(1, $response); + $this->assertNull($response[0]); + } + + #[Group('Api')] + #[Group('NameIdLookup')] + #[Test] + public function id_endpoint_returns_user_data_for_known_nameid(): void + { + $userUuid = Uuid::uuid4()->toString(); + $spUuid = Uuid::uuid4()->toString(); + $persistentId = sha1('COIN:' . $userUuid . $spUuid); + + $client = $this->createNameIdLookupClient(); + $this->insertUserFixture($userUuid); + $this->insertSpFixture($spUuid); + $this->insertPersistentIdFixture($persistentId, $userUuid, $spUuid); + + $body = json_encode([$persistentId]); + $client->request( + 'POST', + 'https://engine-api.dev.openconext.local/info/users/id', + [], + [], + ['CONTENT_TYPE' => 'application/json'], + $body + ); + $this->assertStatusCode(Response::HTTP_OK, $client); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertCount(1, $response); + $this->assertSame(self::SHO, $response[0]['schacHomeOrganization']); + $this->assertSame(self::UID, $response[0]['uid']); + $this->assertSame(self::SP_ENTITY_ID, $response[0]['sp_entityid']); + } + + #[Group('Api')] + #[Group('NameIdLookup')] + #[Test] + public function id_endpoint_returns_400_for_invalid_nameid_format(): void + { + $client = $this->createNameIdLookupClient(); + $body = json_encode(['not-a-valid-sha1']); + $client->request( + 'POST', + 'https://engine-api.dev.openconext.local/info/users/id', + [], + [], + ['CONTENT_TYPE' => 'application/json'], + $body + ); + $this->assertStatusCode(Response::HTTP_BAD_REQUEST, $client); + } + + private function assertStatusCode(int $expected, KernelBrowser $client): void + { + $this->assertSame( + $expected, + $client->getResponse()->getStatusCode(), + sprintf( + 'Expected HTTP %d but got %d. Response: %s', + $expected, + $client->getResponse()->getStatusCode(), + $client->getResponse()->getContent() + ) + ); + } +} diff --git a/tests/unit/OpenConext/EngineBlock/Service/NameIdLookupServiceTest.php b/tests/unit/OpenConext/EngineBlock/Service/NameIdLookupServiceTest.php new file mode 100644 index 0000000000..a9a4e1891e --- /dev/null +++ b/tests/unit/OpenConext/EngineBlock/Service/NameIdLookupServiceTest.php @@ -0,0 +1,294 @@ +userRepository = m::mock(UserRepository::class); + $this->spUuidRepository = m::mock(ServiceProviderUuidRepository::class); + $this->persistentIdRepository = m::mock(SamlPersistentIdRepository::class); + $this->logger = m::mock(LoggerInterface::class); + $this->logger->shouldIgnoreMissing(); + + $this->service = new NameIdLookupService( + $this->userRepository, + $this->spUuidRepository, + $this->persistentIdRepository, + $this->logger + ); + } + + #[Group('NameIdLookup')] + #[Test] + public function resolve_name_id_returns_null_when_user_is_not_found(): void + { + $this->userRepository->shouldReceive('findByCollabPersonId') + ->once() + ->andReturn(null); + + $result = $this->service->resolveNameId('example.edu', 'student001', 'https://sp.example.com/'); + + $this->assertNull($result); + } + + #[Group('NameIdLookup')] + #[Test] + public function resolve_name_id_returns_null_when_sp_is_not_found(): void + { + $userUuid = Uuid::uuid4()->toString(); + + $user = new User(); + $user->collabPersonId = new CollabPersonId('urn:collab:person:example.edu:student001'); + $user->collabPersonUuid = new CollabPersonUuid($userUuid); + + $this->userRepository->shouldReceive('findByCollabPersonId') + ->once() + ->andReturn($user); + + $this->spUuidRepository->shouldReceive('findUuidByEntityId') + ->once() + ->andReturn(null); + + $result = $this->service->resolveNameId('example.edu', 'student001', 'https://sp.example.com/'); + + $this->assertNull($result); + } + + #[Group('NameIdLookup')] + #[Test] + public function resolve_name_id_returns_stored_nameid_when_persistent_id_exists(): void + { + $userUuid = Uuid::uuid4()->toString(); + $spUuid = Uuid::uuid4()->toString(); + $persistentId = sha1('COIN:' . $userUuid . $spUuid); + + $user = new User(); + $user->collabPersonId = new CollabPersonId('urn:collab:person:example.edu:student001'); + $user->collabPersonUuid = new CollabPersonUuid($userUuid); + + $storedEntry = new SamlPersistentId(); + $storedEntry->persistentId = $persistentId; + $storedEntry->userUuid = $userUuid; + $storedEntry->serviceProviderUuid = $spUuid; + + $this->userRepository->shouldReceive('findByCollabPersonId') + ->once() + ->andReturn($user); + + $this->spUuidRepository->shouldReceive('findUuidByEntityId') + ->once() + ->andReturn($spUuid); + + $this->persistentIdRepository->shouldReceive('findByUserAndSpUuid') + ->once() + ->with($userUuid, $spUuid) + ->andReturn($storedEntry); + + $result = $this->service->resolveNameId('example.edu', 'student001', 'https://sp.example.com/'); + + $this->assertNotNull($result); + $this->assertSame($persistentId, $result['nameid']); + $this->assertTrue($result['stored']); + } + + #[Group('NameIdLookup')] + #[Test] + public function resolve_name_id_calculates_nameid_when_not_yet_stored(): void + { + $userUuid = Uuid::uuid4()->toString(); + $spUuid = Uuid::uuid4()->toString(); + + $user = new User(); + $user->collabPersonId = new CollabPersonId('urn:collab:person:example.edu:student001'); + $user->collabPersonUuid = new CollabPersonUuid($userUuid); + + $this->userRepository->shouldReceive('findByCollabPersonId') + ->once() + ->andReturn($user); + + $this->spUuidRepository->shouldReceive('findUuidByEntityId') + ->once() + ->andReturn($spUuid); + + $this->persistentIdRepository->shouldReceive('findByUserAndSpUuid') + ->once() + ->andReturn(null); + + $result = $this->service->resolveNameId('example.edu', 'student001', 'https://sp.example.com/'); + + $this->assertNotNull($result); + $this->assertSame(sha1('COIN:' . $userUuid . $spUuid), $result['nameid']); + $this->assertFalse($result['stored']); + } + + #[Group('NameIdLookup')] + #[Test] + public function resolve_name_id_replaces_at_sign_in_uid_when_building_collab_person_id(): void + { + $this->userRepository->shouldReceive('findByCollabPersonId') + ->once() + ->with(m::on(function (CollabPersonId $id): bool { + return $id->getCollabPersonId() === 'urn:collab:person:example.edu:user_example.edu'; + })) + ->andReturn(null); + + $this->service->resolveNameId('example.edu', 'user@example.edu', 'https://sp.example.com/'); + } + + #[Group('NameIdLookup')] + #[Test] + public function resolve_user_identity_returns_null_when_persistent_id_not_found(): void + { + $this->persistentIdRepository->shouldReceive('find') + ->once() + ->with('abc123') + ->andReturn(null); + + $result = $this->service->resolveUserIdentity('abc123'); + + $this->assertNull($result); + } + + #[Group('NameIdLookup')] + #[Test] + public function resolve_user_identity_returns_user_data_when_persistent_id_found(): void + { + $userUuid = Uuid::uuid4()->toString(); + $spUuid = Uuid::uuid4()->toString(); + $persistentId = sha1('COIN:' . $userUuid . $spUuid); + + $storedEntry = new SamlPersistentId(); + $storedEntry->persistentId = $persistentId; + $storedEntry->userUuid = $userUuid; + $storedEntry->serviceProviderUuid = $spUuid; + + $user = new User(); + $user->collabPersonId = new CollabPersonId('urn:collab:person:example.edu:student001'); + $user->collabPersonUuid = new CollabPersonUuid($userUuid); + + $this->persistentIdRepository->shouldReceive('find') + ->once() + ->with($persistentId) + ->andReturn($storedEntry); + + $this->userRepository->shouldReceive('findByCollabPersonUuid') + ->once() + ->with(m::on(fn(CollabPersonUuid $u) => $u->getUuid() === $userUuid)) + ->andReturn($user); + + $this->spUuidRepository->shouldReceive('findEntityIdByUuid') + ->once() + ->with($spUuid) + ->andReturn('https://sp.example.com/'); + + $result = $this->service->resolveUserIdentity($persistentId); + + $this->assertNotNull($result); + $this->assertSame('example.edu', $result['schacHomeOrganization']); + $this->assertSame('student001', $result['uid']); + $this->assertSame('https://sp.example.com/', $result['sp_entityid']); + } + + #[Group('NameIdLookup')] + #[Test] + public function resolve_user_identity_returns_null_when_user_record_missing(): void + { + $userUuid = Uuid::uuid4()->toString(); + $spUuid = Uuid::uuid4()->toString(); + + $storedEntry = new SamlPersistentId(); + $storedEntry->persistentId = 'abc123'; + $storedEntry->userUuid = $userUuid; + $storedEntry->serviceProviderUuid = $spUuid; + + $this->persistentIdRepository->shouldReceive('find') + ->once() + ->andReturn($storedEntry); + + $this->userRepository->shouldReceive('findByCollabPersonUuid') + ->once() + ->andReturn(null); + + $result = $this->service->resolveUserIdentity('abc123'); + + $this->assertNull($result); + } + + #[Group('NameIdLookup')] + #[Test] + public function resolve_user_identity_returns_null_when_sp_uuid_record_missing(): void + { + $userUuid = Uuid::uuid4()->toString(); + $spUuid = Uuid::uuid4()->toString(); + + $storedEntry = new SamlPersistentId(); + $storedEntry->persistentId = 'abc123'; + $storedEntry->userUuid = $userUuid; + $storedEntry->serviceProviderUuid = $spUuid; + + $user = new User(); + $user->collabPersonId = new CollabPersonId('urn:collab:person:example.edu:student001'); + $user->collabPersonUuid = new CollabPersonUuid($userUuid); + + $this->persistentIdRepository->shouldReceive('find') + ->once() + ->andReturn($storedEntry); + + $this->userRepository->shouldReceive('findByCollabPersonUuid') + ->once() + ->andReturn($user); + + $this->spUuidRepository->shouldReceive('findEntityIdByUuid') + ->once() + ->with($spUuid) + ->andReturn(null); + + $this->logger->shouldReceive('warning')->once(); + + $result = $this->service->resolveUserIdentity('abc123'); + + $this->assertNull($result); + } +}