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
4 changes: 4 additions & 0 deletions config/packages/ci/parameters.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 4 additions & 0 deletions config/packages/dev/parameters.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 2 additions & 0 deletions config/packages/engineblock_features.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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%"
Expand Down
4 changes: 4 additions & 0 deletions config/packages/parameters.yml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions config/packages/security.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions config/packages/test/parameters.yml
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions config/services/controllers/api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions config/services/services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
57 changes: 57 additions & 0 deletions migrations/DoctrineMigrations/Version20260331000000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

/**
* Copyright 2026 SURFnet B.V.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

declare(strict_types=1);

namespace OpenConext\EngineBlock\Doctrine\Migrations;

use Doctrine\DBAL\Schema\Schema;

/**
* Corrects the column comment on saml_persistent_id.persistent_id.
*
* The original comment read "SHA1 of service_provider_uuid + user_uuid", which was
* inaccurate in two ways: the operand order was wrong, and the COIN: salt was omitted.
* The actual value stored is sha1('COIN:' + user_uuid + service_provider_uuid), as
* defined in EngineBlock_Saml2_NameIdResolver::PERSISTENT_NAMEID_SALT.
*
* NOTE: This migration is NOT mandatory. It only updates a database-level column comment
* and has no effect on data integrity or application behaviour. It is safe to skip on
* existing installations where updating the comment is not considered necessary.
*/
final class Version20260331000000 extends AbstractEngineBlockMigration
{
public function getDescription(): string
{
return 'Corrects the column comment on saml_persistent_id.persistent_id to accurately reflect the SHA1 formula.';
}

public function up(Schema $schema): void
{
$this->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'"
);
}
}
109 changes: 109 additions & 0 deletions src/OpenConext/EngineBlock/Service/NameIdLookupService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?php

/**
* Copyright 2026 SURFnet B.V.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

namespace OpenConext\EngineBlock\Service;

use OpenConext\EngineBlock\Authentication\Value\CollabPersonId;
use OpenConext\EngineBlock\Authentication\Value\CollabPersonUuid;
use OpenConext\EngineBlockBundle\Authentication\Repository\SamlPersistentIdRepository;
use OpenConext\EngineBlockBundle\Authentication\Repository\ServiceProviderUuidRepository;
use OpenConext\EngineBlockBundle\Authentication\Repository\UserRepository;
use Psr\Log\LoggerInterface;

final class NameIdLookupService
{
private const PERSISTENT_NAMEID_SALT = 'COIN:';

public function __construct(
private readonly UserRepository $userRepository,
private readonly ServiceProviderUuidRepository $spUuidRepository,
private readonly SamlPersistentIdRepository $persistentIdRepository,
private readonly LoggerInterface $logger
) {
}

public function resolveNameId(string $schacHomeOrganization, string $uid, string $spEntityId): ?array
{
$collabPersonId = new CollabPersonId(sprintf(
'%s:%s:%s',
CollabPersonId::URN_NAMESPACE,
$schacHomeOrganization,
str_replace('@', '_', $uid)
));

$user = $this->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,
];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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();
}
}
Loading