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
3 changes: 3 additions & 0 deletions config/packages/ci/parameters.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
parameters:
api.users.nameIdLookup.username: nameid
api.users.nameIdLookup.password: secret
feature_api_users_nameid_lookup: true
encryption_keys:
default:
publicFile: /config/engine/engineblock.crt
Expand Down
3 changes: 3 additions & 0 deletions config/packages/dev/parameters.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
parameters:
api.users.nameIdLookup.username: nameid
api.users.nameIdLookup.password: secret
feature_api_users_nameid_lookup: true
encryption_keys:
default:
publicFile: /config/engine/engineblock.crt
Expand Down
1 change: 1 addition & 0 deletions config/packages/engineblock_features.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ parameters:
api.consent_remove: "%feature_api_consent_remove%"
api.metadata_api: "%feature_api_metadata_api%"
api.deprovision: "%feature_api_deprovision%"
api.users_nameid_lookup: "%feature_api_users_nameid_lookup%"
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
3 changes: 3 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,7 @@ parameters:
feature_api_consent_remove: true
feature_api_metadata_api: true
feature_api_deprovision: true
feature_api_users_nameid_lookup: 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
4 changes: 4 additions & 0 deletions config/packages/test/parameters.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
parameters:
api.users.nameIdLookup.username: nameid
api.users.nameIdLookup.password: secret
feature_api_users_nameid_lookup: 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'"
);
}
}
29 changes: 29 additions & 0 deletions src/OpenConext/EngineBlock/Authentication/Value/CollabPersonId.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
namespace OpenConext\EngineBlock\Authentication\Value;

use OpenConext\EngineBlock\Assert\Assertion;
use RuntimeException;

final class CollabPersonId
{
Expand Down Expand Up @@ -84,6 +85,34 @@ public function getCollabPersonId(): string
return $this->collabPersonId;
}

public function getSchacHomeOrganization(): string
{
$parts = explode(':', $this->collabPersonId, 5);
if (!isset($parts[3])) {
throw new RuntimeException(sprintf(
'Cannot extract schacHomeOrganization from CollabPersonId "%s"',
$this->collabPersonId
));
}
return $parts[3];
}

/**
* Returns the uid as stored in the CollabPersonId. Note: if the original uid contained an
* @-sign, it will have been replaced by an underscore (legacy LDAP module behaviour).
*/
public function getStoredUid(): string
{
$parts = explode(':', $this->collabPersonId, 5);
if (!isset($parts[4])) {
throw new RuntimeException(sprintf(
'Cannot extract uid from CollabPersonId "%s"',
$this->collabPersonId
));
}
return $parts[4];
}

/**
* @param CollabPersonId $other
* @return bool
Expand Down
103 changes: 103 additions & 0 deletions src/OpenConext/EngineBlock/Service/NameIdLookupService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?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\EngineBlock\Authentication\Value\SchacHomeOrganization;
use OpenConext\EngineBlock\Authentication\Value\Uid;
use OpenConext\EngineBlockBundle\Authentication\Entity\SamlPersistentId;
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
{
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): ?NameIdResult
{
$collabPersonId = CollabPersonId::generateWithReplacedAtSignFrom(
new Uid($uid),
new SchacHomeOrganization($schacHomeOrganization)
);

$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 new NameIdResult($stored->persistentId, true);
}

return new NameIdResult(SamlPersistentId::generate($userUuid, $spUuid)->persistentId, false);
}

public function resolveUserIdentity(string $persistentId): ?UserIdentityResult
{
$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;
}

return new UserIdentityResult(
$user->collabPersonId->getSchacHomeOrganization(),
$user->collabPersonId->getStoredUid(),
$spEntityId,
);
}
}
38 changes: 38 additions & 0 deletions src/OpenConext/EngineBlock/Service/NameIdResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?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 JsonSerializable;

final class NameIdResult implements JsonSerializable
{
public function __construct(
public readonly string $nameId,
public readonly bool $stored,
) {
}

public function jsonSerialize(): array
{
return [
'nameid' => $this->nameId,
'stored' => $this->stored,
];
}
}
40 changes: 40 additions & 0 deletions src/OpenConext/EngineBlock/Service/UserIdentityResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?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 JsonSerializable;

final class UserIdentityResult implements JsonSerializable
{
public function __construct(
public readonly string $schacHomeOrganization,
public readonly string $uid,
public readonly string $spEntityId,
) {
}

public function jsonSerialize(): array
{
return [
'schacHomeOrganization' => $this->schacHomeOrganization,
'uid' => $this->uid,
'sp_entityid' => $this->spEntityId,
];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,21 @@
#[ORM\Index(columns: ['user_uuid', 'service_provider_uuid'], name: 'user_uuid')]
class SamlPersistentId
{
private const PERSISTENT_ID_SALT = 'COIN:';

public static function generate(string $userUuid, string $spUuid): self
{
$entity = new self();
$entity->userUuid = $userUuid;
$entity->serviceProviderUuid = $spUuid;
$entity->persistentId = sha1(self::PERSISTENT_ID_SALT . $userUuid . $spUuid);
return $entity;
}
/**
* @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
Loading