Skip to content
Merged
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
19 changes: 19 additions & 0 deletions config/module_oidc.php.dist
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,25 @@ $config = [
* claim in the attribute-to-claim translation table (you will probably want
* to use this attribute as the 'sub' claim since it designates a unique
* identifier for the user).
*
* This option can be a single attribute name (string), or an array of
* prioritized attribute names. The array form is useful in scenarios with
* multiple heterogeneous IdPs (e.g., eduGAIN inter-federation), where not
* every IdP is able (or willing) to release the same identifier attribute.
* When an array is given, the attributes are consulted in order. The
* first one that is actually present in the released attributes is used,
* both as the internal user identifier and as the default source for the
* 'sub' claim.
*
* Single value example:
* ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE => 'uid',
*
* Prioritized list example:
* ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE => [
* 'eduPersonPrincipalName',
* 'eduPersonUniqueId',
* 'uid',
* ],
*/
ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE => 'uid',

Expand Down
15 changes: 12 additions & 3 deletions docs/6-oidc-upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ Federation) and applies the matching signing rules; when present, the `aud` and
- Require Pushed Authorization Requests (`require_pushed_authorization_requests`)
- Require Signed Request Object (`require_signed_request_object`)
- Registered Request URIs (`request_uris`)
See the [configuration guide](3-oidc-configuration.md#pushed-authorization-requests-par-and-request-objects)
for details.
- Support for the OAuth 2.0 Form Post Response Mode (`response_mode=form_post`).
The OP now supports three response modes - `query`, `fragment`, and
`form_post`. With `form_post`, the authorization response parameters are
Expand Down Expand Up @@ -87,9 +89,16 @@ performance. Note that changing the encryption key (including switching from the
secret salt to a Key, or rotating a Key) invalidates all outstanding encrypted
artifacts (existing authorization codes, refresh tokens, and PAR request URIs
will be rejected), so only set or change it during a planned maintenance window.

See the [configuration guide](3-oidc-configuration.md#pushed-authorization-requests-par-and-request-objects)
for details.
- The user identifier attribute option
(`ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE`, `useridattr`) can now be
configured either as a single attribute name (string, as before) or as an array
of prioritized attribute names. This is useful in scenarios with multiple
heterogeneous IdPs (for example, eduGAIN inter-federation), where not every IdP
is able (or willing) to release the same identifier attribute. When an array is
given, the attributes are consulted in priority order and the first one actually
present in the released attributes is used, both as the internal user identifier
and as the default source for the `sub` claim. The single-string form continues
to work unchanged, so existing configurations are unaffected.

New configuration options:

Expand Down
1 change: 1 addition & 0 deletions routing/services/services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ services:
SimpleSAML\Module\oidc\Utils\FederationParticipationValidator: ~
SimpleSAML\Module\oidc\Utils\Routes: ~
SimpleSAML\Module\oidc\Utils\RequestParamsResolver: ~
SimpleSAML\Module\oidc\Utils\UserIdentifierResolver: ~
SimpleSAML\Module\oidc\Utils\ClassInstanceBuilder: ~
SimpleSAML\Module\oidc\Utils\JwksResolver: ~
SimpleSAML\Module\oidc\Utils\AuthenticatedOAuth2ClientResolver: ~
Expand Down
4 changes: 2 additions & 2 deletions src/Factories/ClaimTranslatorExtractorFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,10 @@ public function build(): ClaimTranslatorExtractor
}
}

$userIdAttr = $this->moduleConfig->getUserIdentifierAttribute();
$userIdAttrs = $this->moduleConfig->getUserIdentifierAttributes();

return new ClaimTranslatorExtractor(
$userIdAttr,
$userIdAttrs,
$this->claimSetEntityFactory,
$claimSet,
$translatorTable,
Expand Down
12 changes: 6 additions & 6 deletions src/Factories/CredentialOfferUriFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use SimpleSAML\Module\oidc\Repositories\IssuerStateRepository;
use SimpleSAML\Module\oidc\Repositories\UserRepository;
use SimpleSAML\Module\oidc\Services\LoggerService;
use SimpleSAML\Module\oidc\Utils\UserIdentifierResolver;
use SimpleSAML\OpenID\Codebooks\ClaimsEnum;
use SimpleSAML\OpenID\Codebooks\GrantTypesEnum;
use SimpleSAML\OpenID\Exceptions\OpenIdException;
Expand All @@ -42,6 +43,7 @@ public function __construct(
protected readonly EmailFactory $emailFactory,
protected readonly IssuerStateEntityFactory $issuerStateEntityFactory,
protected readonly IssuerStateRepository $issuerStateRepository,
protected readonly UserIdentifierResolver $userIdentifierResolver,
) {
}

Expand Down Expand Up @@ -136,16 +138,14 @@ public function buildPreAuthorized(

$userId = null;
try {
/** @psalm-suppress MixedAssignment */
$userId = $this->sspBridge->utils()->attributes()->getExpectedAttribute(
$userId = $this->userIdentifierResolver->resolve(
$this->moduleConfig->getUserIdentifierAttributes(),
$userAttributes,
$this->moduleConfig->getUserIdentifierAttribute(),
);

if (!is_scalar($userId)) {
throw new RuntimeException('User identifier attribute value is not a string.');
if ($userId === null) {
throw new RuntimeException('User identifier attribute value is not available.');
}
$userId = strval($userId);
} catch (\Throwable $e) {
$this->loggerService->warning(
'Could not extract user identifier from user attributes: ' . $e->getMessage(),
Expand Down
27 changes: 26 additions & 1 deletion src/ModuleConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -409,11 +409,36 @@ public function getDefaultAuthSourceId(): string
}

/**
* Get the ordered list of candidate user identifier attributes.
*
* The option may be configured either as a single string (legacy) or as an
* array of prioritized attribute names. In heterogeneous IdP scenarios (e.g.
* eduGAIN inter-federation) not every IdP releases the same identifier, so
* the list is consulted in order and the first attribute that is actually
* present in the released attributes is used.
*
* @return string[]
* @throws \Exception
*/
public function getUserIdentifierAttributes(): array
{
$value = $this->config()->getOptionalArrayizeString(
ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE,
['uid'],
);

return array_values(array_filter($value, 'is_string'));
}

/**
* Returns the primary (first) configured user ID candidate.
* @throws \SimpleSAML\Error\ConfigurationError
* @deprecated Use getUserIdentifierAttributes().
*/
public function getUserIdentifierAttribute(): string
{
return $this->config()->getString(ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE);
return $this->getUserIdentifierAttributes()[0]
?? throw new ConfigurationError('No user identifier attribute configured.');
}

public function getSupportedAlgorithms(): SupportedAlgorithms
Expand Down
20 changes: 13 additions & 7 deletions src/Services/AuthContextService.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@

use RuntimeException;
use SimpleSAML\Auth\Simple;
use SimpleSAML\Module\oidc\Bridges\SspBridge;
use SimpleSAML\Module\oidc\Factories\AuthSimpleFactory;
use SimpleSAML\Module\oidc\ModuleConfig;
use SimpleSAML\Module\oidc\Utils\UserIdentifierResolver;

/**
* Provide contextual authentication information for administration interface.
Expand All @@ -28,7 +28,7 @@ class AuthContextService
public function __construct(
private readonly ModuleConfig $moduleConfig,
private readonly AuthSimpleFactory $authSimpleFactory,
private readonly SspBridge $sspBridge,
private readonly UserIdentifierResolver $userIdentifierResolver,
) {
}

Expand All @@ -39,11 +39,17 @@ public function __construct(
public function getAuthUserId(): string
{
$simple = $this->authenticate();
$userIdAttr = $this->moduleConfig->getUserIdentifierAttribute();
return (string)$this->sspBridge->utils()->attributes()->getExpectedAttribute(
$simple->getAttributes(),
$userIdAttr,
);
$userIdAttrs = $this->moduleConfig->getUserIdentifierAttributes();
$userId = $this->userIdentifierResolver->resolve($userIdAttrs, $simple->getAttributes());

if ($userId === null) {
throw new RuntimeException(sprintf(
'None of the configured user identifier attributes (%s) are available.',
implode(', ', $userIdAttrs),
));
}

return $userId;
}

/**
Expand Down
22 changes: 12 additions & 10 deletions src/Services/AuthenticationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor;
use SimpleSAML\Module\oidc\Utils\RequestParamsResolver;
use SimpleSAML\Module\oidc\Utils\Routes;
use SimpleSAML\Module\oidc\Utils\UserIdentifierResolver;

class AuthenticationService
{
Expand All @@ -51,9 +52,10 @@ class AuthenticationService
private ?string $authSourceId = null;

/**
* @var string
* Ordered list of candidate user identifier attributes.
* @var string[]
*/
private string $userIdAttr;
private array $userIdAttrs;

/**
* @throws \Exception
Expand All @@ -71,8 +73,9 @@ public function __construct(
private readonly RequestParamsResolver $requestParamsResolver,
private readonly UserEntityFactory $userEntityFactory,
private readonly Routes $routes,
private readonly UserIdentifierResolver $userIdentifierResolver,
) {
$this->userIdAttr = $this->moduleConfig->getUserIdentifierAttribute();
$this->userIdAttrs = $this->moduleConfig->getUserIdentifierAttributes();
}

/**
Expand Down Expand Up @@ -136,20 +139,19 @@ public function getAuthenticateUser(

$claims = $state['Attributes'];

if (!array_key_exists($this->userIdAttr, $claims) || !is_array($claims[$this->userIdAttr])) {
$attr = implode(', ', array_keys($claims));
$userId = $this->userIdentifierResolver->resolve($this->userIdAttrs, $claims);

if ($userId === null) {
throw new Error\Exception(
sprintf(
'User identifier attribute `%s` does not exist in the user attribute state.' .
'None of the configured user identifier attributes (%s) exist in the user attribute state.' .
' Available attributes are: %s.',
$this->userIdAttr,
$attr,
implode(', ', $this->userIdAttrs),
implode(', ', array_keys($claims)),
),
);
}

$userId = (string)$claims[$this->userIdAttr][0];

$user = $this->userRepository->getUserEntityByIdentifier($userId);

if ($user) {
Expand Down
14 changes: 10 additions & 4 deletions src/Utils/ClaimTranslatorExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -156,19 +156,25 @@ class ClaimTranslatorExtractor
/**
* ClaimTranslatorExtractor constructor.
*
* @param string[] $userIdAttrs Ordered list of candidate user identifier attributes.
* @param \SimpleSAML\Module\oidc\Entities\Interfaces\ClaimSetEntityInterface[] $claimSets
* @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException
*/
public function __construct(
string $userIdAttr,
array $userIdAttrs,
protected readonly ClaimSetEntityFactory $claimSetEntityFactory,
array $claimSets = [],
array $translationTable = [],
protected array $allowedMultiValueClaims = [],
) {
// By default, add the userIdAttribute as one of the attribute for 'sub' claim.
/** @psalm-suppress MixedArgument */
array_unshift($this->translationTable['sub'], $userIdAttr);
// By default, add the user identifier attribute(s) as attributes for the
// 'sub' claim, preserving the configured priority order (the translation
// resolves to the first present attribute). array_reverse keeps the
// configured order after successive array_unshift() prepends.
foreach (array_reverse($userIdAttrs) as $userIdAttr) {
/** @psalm-suppress MixedArgument */
array_unshift($this->translationTable['sub'], $userIdAttr);
}

$this->translationTable = array_merge($this->translationTable, $translationTable);

Expand Down
56 changes: 56 additions & 0 deletions src/Utils/UserIdentifierResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

declare(strict_types=1);

/*
* This file is part of the simplesamlphp-module-oidc.
*
* Copyright (C) 2018 by the Spanish Research and Academic Network.
*
* This code was developed by Universidad de Córdoba (UCO https://www.uco.es)
* for the RedIRIS SIR service (SIR: http://www.rediris.es/sir)
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace SimpleSAML\Module\oidc\Utils;

/**
* Resolves a single user identifier value from a set of released attributes,
* given an ordered list of candidate attribute names.
*
* In heterogeneous IdP scenarios (e.g. eduGAIN inter-federation) not every IdP
* releases the same identifier attribute. The candidate list is therefore
* consulted in priority order and the first candidate that is present and holds
* a non-empty value is used.
*/
class UserIdentifierResolver
{
/**
* @param string[] $candidates Ordered list of candidate attribute names.
* @param array<array-key, mixed> $attributes Released attributes (each value an array of values).
* @return string|null The first resolved identifier value, or null if none of the candidates match.
*/
public function resolve(array $candidates, array $attributes): ?string
{
foreach ($candidates as $candidate) {
if (
!array_key_exists($candidate, $attributes) ||
!is_array($attributes[$candidate]) ||
$attributes[$candidate] === []
) {
continue;
}

/** @psalm-suppress MixedAssignment */
$value = reset($attributes[$candidate]);

if (is_scalar($value) && (string)$value !== '') {
return (string)$value;
}
}

return null;
}
}
2 changes: 1 addition & 1 deletion templates/config/protocol.twig
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
<p>
{{ 'Default Authentication Source'|trans }}: {{ moduleConfig.getDefaultAuthSourceId }}
<br>
{{ 'User Identifier Attribute'|trans }}: {{ moduleConfig.getUserIdentifierAttribute }}
{{ 'User Identifier Attribute'|trans }}: {{ moduleConfig.getUserIdentifierAttributes|join(', ') }}
</p>
<p>
{{ 'Authentication Processing Filters'|trans }}:
Expand Down
14 changes: 14 additions & 0 deletions tests/unit/src/ModuleConfigTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,20 @@ public function testCanGetUserIdentifierAttribute(): void
$this->assertEquals('sample', $this->sut()->getUserIdentifierAttribute());
}

public function testCanGetUserIdentifierAttributesFromString(): void
{
$this->overrides[ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE] = 'sample';
$this->assertEquals(['sample'], $this->sut()->getUserIdentifierAttributes());
}

public function testCanGetUserIdentifierAttributesFromArray(): void
{
$this->overrides[ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE] = ['ePPN', 'uid'];
$this->assertEquals(['ePPN', 'uid'], $this->sut()->getUserIdentifierAttributes());
// The deprecated single accessor returns the primary (first) candidate.
$this->assertEquals('ePPN', $this->sut()->getUserIdentifierAttribute());
}

public function testCanGetCommonFederationOptions(): void
{
$this->assertFalse($this->sut()->getFederationEnabled());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ class RequestedClaimsRuleTest extends TestCase
protected Stub $requestStub;
protected string $redirectUri = 'https://some-redirect-uri.org';
protected Stub $loggerServiceStub;
protected static string $userIdAttr = 'uid';
/** @var string[] */
protected static array $userIdAttrs = ['uid'];
protected Stub $requestParamsResolverStub;
protected Stub $claimSetEntityFactoryStub;
protected Helpers $helpers;
Expand Down Expand Up @@ -69,7 +70,10 @@ protected function sut(
): RequestedClaimsRule {
$requestParamsResolver ??= $this->requestParamsResolverStub;
$helpers ??= $this->helpers;
$claimTranslatorExtractor ??= new ClaimTranslatorExtractor(self::$userIdAttr, $this->claimSetEntityFactoryStub);
$claimTranslatorExtractor ??= new ClaimTranslatorExtractor(
self::$userIdAttrs,
$this->claimSetEntityFactoryStub,
);

return new RequestedClaimsRule(
$requestParamsResolver,
Expand Down
Loading
Loading