Skip to content

Commit 28a3806

Browse files
committed
Introduce multi valued user ID config option
1 parent 45d7bd0 commit 28a3806

18 files changed

Lines changed: 286 additions & 58 deletions

‎config/module_oidc.php.dist‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,25 @@ $config = [
232232
* claim in the attribute-to-claim translation table (you will probably want
233233
* to use this attribute as the 'sub' claim since it designates a unique
234234
* identifier for the user).
235+
*
236+
* This option can be a single attribute name (string), or an array of
237+
* prioritized attribute names. The array form is useful in scenarios with
238+
* multiple heterogeneous IdPs (e.g., eduGAIN inter-federation), where not
239+
* every IdP is able (or willing) to release the same identifier attribute.
240+
* When an array is given, the attributes are consulted in order. The
241+
* first one that is actually present in the released attributes is used,
242+
* both as the internal user identifier and as the default source for the
243+
* 'sub' claim.
244+
*
245+
* Single value example:
246+
* ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE => 'uid',
247+
*
248+
* Prioritized list example:
249+
* ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE => [
250+
* 'eduPersonPrincipalName',
251+
* 'eduPersonUniqueId',
252+
* 'uid',
253+
* ],
235254
*/
236255
ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE => 'uid',
237256

‎docs/6-oidc-upgrade.md‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ Federation) and applies the matching signing rules; when present, the `aud` and
4747
- Require Pushed Authorization Requests (`require_pushed_authorization_requests`)
4848
- Require Signed Request Object (`require_signed_request_object`)
4949
- Registered Request URIs (`request_uris`)
50+
See the [configuration guide](3-oidc-configuration.md#pushed-authorization-requests-par-and-request-objects)
51+
for details.
5052
- Support for the OAuth 2.0 Form Post Response Mode (`response_mode=form_post`).
5153
The OP now supports three response modes - `query`, `fragment`, and
5254
`form_post`. With `form_post`, the authorization response parameters are
@@ -87,9 +89,16 @@ performance. Note that changing the encryption key (including switching from the
8789
secret salt to a Key, or rotating a Key) invalidates all outstanding encrypted
8890
artifacts (existing authorization codes, refresh tokens, and PAR request URIs
8991
will be rejected), so only set or change it during a planned maintenance window.
90-
91-
See the [configuration guide](3-oidc-configuration.md#pushed-authorization-requests-par-and-request-objects)
92-
for details.
92+
- The user identifier attribute option
93+
(`ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE`, `useridattr`) can now be
94+
configured either as a single attribute name (string, as before) or as an array
95+
of prioritized attribute names. This is useful in scenarios with multiple
96+
heterogeneous IdPs (for example, eduGAIN inter-federation), where not every IdP
97+
is able (or willing) to release the same identifier attribute. When an array is
98+
given, the attributes are consulted in priority order and the first one actually
99+
present in the released attributes is used, both as the internal user identifier
100+
and as the default source for the `sub` claim. The single-string form continues
101+
to work unchanged, so existing configurations are unaffected.
93102

94103
New configuration options:
95104

‎routing/services/services.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ services:
103103
SimpleSAML\Module\oidc\Utils\FederationParticipationValidator: ~
104104
SimpleSAML\Module\oidc\Utils\Routes: ~
105105
SimpleSAML\Module\oidc\Utils\RequestParamsResolver: ~
106+
SimpleSAML\Module\oidc\Utils\UserIdentifierResolver: ~
106107
SimpleSAML\Module\oidc\Utils\ClassInstanceBuilder: ~
107108
SimpleSAML\Module\oidc\Utils\JwksResolver: ~
108109
SimpleSAML\Module\oidc\Utils\AuthenticatedOAuth2ClientResolver: ~

‎src/Factories/ClaimTranslatorExtractorFactory.php‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,10 @@ public function build(): ClaimTranslatorExtractor
6767
}
6868
}
6969

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

7272
return new ClaimTranslatorExtractor(
73-
$userIdAttr,
73+
$userIdAttrs,
7474
$this->claimSetEntityFactory,
7575
$claimSet,
7676
$translatorTable,

‎src/Factories/CredentialOfferUriFactory.php‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use SimpleSAML\Module\oidc\Repositories\IssuerStateRepository;
2222
use SimpleSAML\Module\oidc\Repositories\UserRepository;
2323
use SimpleSAML\Module\oidc\Services\LoggerService;
24+
use SimpleSAML\Module\oidc\Utils\UserIdentifierResolver;
2425
use SimpleSAML\OpenID\Codebooks\ClaimsEnum;
2526
use SimpleSAML\OpenID\Codebooks\GrantTypesEnum;
2627
use SimpleSAML\OpenID\Exceptions\OpenIdException;
@@ -42,6 +43,7 @@ public function __construct(
4243
protected readonly EmailFactory $emailFactory,
4344
protected readonly IssuerStateEntityFactory $issuerStateEntityFactory,
4445
protected readonly IssuerStateRepository $issuerStateRepository,
46+
protected readonly UserIdentifierResolver $userIdentifierResolver,
4547
) {
4648
}
4749

@@ -136,16 +138,14 @@ public function buildPreAuthorized(
136138

137139
$userId = null;
138140
try {
139-
/** @psalm-suppress MixedAssignment */
140-
$userId = $this->sspBridge->utils()->attributes()->getExpectedAttribute(
141+
$userId = $this->userIdentifierResolver->resolve(
142+
$this->moduleConfig->getUserIdentifierAttributes(),
141143
$userAttributes,
142-
$this->moduleConfig->getUserIdentifierAttribute(),
143144
);
144145

145-
if (!is_scalar($userId)) {
146-
throw new RuntimeException('User identifier attribute value is not a string.');
146+
if ($userId === null) {
147+
throw new RuntimeException('User identifier attribute value is not available.');
147148
}
148-
$userId = strval($userId);
149149
} catch (\Throwable $e) {
150150
$this->loggerService->warning(
151151
'Could not extract user identifier from user attributes: ' . $e->getMessage(),

‎src/ModuleConfig.php‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,11 +409,34 @@ public function getDefaultAuthSourceId(): string
409409
}
410410

411411
/**
412+
* Get the ordered list of candidate user identifier attributes.
413+
*
414+
* The option may be configured either as a single string (legacy) or as an
415+
* array of prioritized attribute names. In heterogeneous IdP scenarios (e.g.
416+
* eduGAIN inter-federation) not every IdP releases the same identifier, so
417+
* the list is consulted in order and the first attribute that is actually
418+
* present in the released attributes is used.
419+
*
420+
* @return string[]
412421
* @throws \Exception
413422
*/
423+
public function getUserIdentifierAttributes(): array
424+
{
425+
return $this->config()->getOptionalArrayizeString(
426+
ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE,
427+
['uid'],
428+
);
429+
}
430+
431+
/**
432+
* Returns the primary (first) configured user ID candidate.
433+
* @throws \SimpleSAML\Error\ConfigurationError
434+
* @deprecated Use getUserIdentifierAttributes().
435+
*/
414436
public function getUserIdentifierAttribute(): string
415437
{
416-
return $this->config()->getString(ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE);
438+
return $this->getUserIdentifierAttributes()[0]
439+
?? throw new ConfigurationError('No user identifier attribute configured.');
417440
}
418441

419442
public function getSupportedAlgorithms(): SupportedAlgorithms

‎src/Services/AuthContextService.php‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use SimpleSAML\Module\oidc\Bridges\SspBridge;
1010
use SimpleSAML\Module\oidc\Factories\AuthSimpleFactory;
1111
use SimpleSAML\Module\oidc\ModuleConfig;
12+
use SimpleSAML\Module\oidc\Utils\UserIdentifierResolver;
1213

1314
/**
1415
* Provide contextual authentication information for administration interface.
@@ -29,6 +30,7 @@ public function __construct(
2930
private readonly ModuleConfig $moduleConfig,
3031
private readonly AuthSimpleFactory $authSimpleFactory,
3132
private readonly SspBridge $sspBridge,
33+
private readonly UserIdentifierResolver $userIdentifierResolver,
3234
) {
3335
}
3436

@@ -39,11 +41,17 @@ public function __construct(
3941
public function getAuthUserId(): string
4042
{
4143
$simple = $this->authenticate();
42-
$userIdAttr = $this->moduleConfig->getUserIdentifierAttribute();
43-
return (string)$this->sspBridge->utils()->attributes()->getExpectedAttribute(
44-
$simple->getAttributes(),
45-
$userIdAttr,
46-
);
44+
$userIdAttrs = $this->moduleConfig->getUserIdentifierAttributes();
45+
$userId = $this->userIdentifierResolver->resolve($userIdAttrs, $simple->getAttributes());
46+
47+
if ($userId === null) {
48+
throw new RuntimeException(sprintf(
49+
'None of the configured user identifier attributes (%s) are available.',
50+
implode(', ', $userIdAttrs),
51+
));
52+
}
53+
54+
return $userId;
4755
}
4856

4957
/**

‎src/Services/AuthenticationService.php‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor;
4343
use SimpleSAML\Module\oidc\Utils\RequestParamsResolver;
4444
use SimpleSAML\Module\oidc\Utils\Routes;
45+
use SimpleSAML\Module\oidc\Utils\UserIdentifierResolver;
4546

4647
class AuthenticationService
4748
{
@@ -51,9 +52,10 @@ class AuthenticationService
5152
private ?string $authSourceId = null;
5253

5354
/**
54-
* @var string
55+
* Ordered list of candidate user identifier attributes.
56+
* @var string[]
5557
*/
56-
private string $userIdAttr;
58+
private array $userIdAttrs;
5759

5860
/**
5961
* @throws \Exception
@@ -71,8 +73,9 @@ public function __construct(
7173
private readonly RequestParamsResolver $requestParamsResolver,
7274
private readonly UserEntityFactory $userEntityFactory,
7375
private readonly Routes $routes,
76+
private readonly UserIdentifierResolver $userIdentifierResolver,
7477
) {
75-
$this->userIdAttr = $this->moduleConfig->getUserIdentifierAttribute();
78+
$this->userIdAttrs = $this->moduleConfig->getUserIdentifierAttributes();
7679
}
7780

7881
/**
@@ -136,20 +139,19 @@ public function getAuthenticateUser(
136139

137140
$claims = $state['Attributes'];
138141

139-
if (!array_key_exists($this->userIdAttr, $claims) || !is_array($claims[$this->userIdAttr])) {
140-
$attr = implode(', ', array_keys($claims));
142+
$userId = $this->userIdentifierResolver->resolve($this->userIdAttrs, $claims);
143+
144+
if ($userId === null) {
141145
throw new Error\Exception(
142146
sprintf(
143-
'User identifier attribute `%s` does not exist in the user attribute state.' .
147+
'None of the configured user identifier attributes (%s) exist in the user attribute state.' .
144148
' Available attributes are: %s.',
145-
$this->userIdAttr,
146-
$attr,
149+
implode(', ', $this->userIdAttrs),
150+
implode(', ', array_keys($claims)),
147151
),
148152
);
149153
}
150154

151-
$userId = (string)$claims[$this->userIdAttr][0];
152-
153155
$user = $this->userRepository->getUserEntityByIdentifier($userId);
154156

155157
if ($user) {

‎src/Utils/ClaimTranslatorExtractor.php‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -156,19 +156,25 @@ class ClaimTranslatorExtractor
156156
/**
157157
* ClaimTranslatorExtractor constructor.
158158
*
159+
* @param string[] $userIdAttrs Ordered list of candidate user identifier attributes.
159160
* @param \SimpleSAML\Module\oidc\Entities\Interfaces\ClaimSetEntityInterface[] $claimSets
160161
* @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException
161162
*/
162163
public function __construct(
163-
string $userIdAttr,
164+
array $userIdAttrs,
164165
protected readonly ClaimSetEntityFactory $claimSetEntityFactory,
165166
array $claimSets = [],
166167
array $translationTable = [],
167168
protected array $allowedMultiValueClaims = [],
168169
) {
169-
// By default, add the userIdAttribute as one of the attribute for 'sub' claim.
170-
/** @psalm-suppress MixedArgument */
171-
array_unshift($this->translationTable['sub'], $userIdAttr);
170+
// By default, add the user identifier attribute(s) as attributes for the
171+
// 'sub' claim, preserving the configured priority order (the translation
172+
// resolves to the first present attribute). array_reverse keeps the
173+
// configured order after successive array_unshift() prepends.
174+
foreach (array_reverse($userIdAttrs) as $userIdAttr) {
175+
/** @psalm-suppress MixedArgument */
176+
array_unshift($this->translationTable['sub'], $userIdAttr);
177+
}
172178

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the simplesamlphp-module-oidc.
7+
*
8+
* Copyright (C) 2018 by the Spanish Research and Academic Network.
9+
*
10+
* This code was developed by Universidad de Córdoba (UCO https://www.uco.es)
11+
* for the RedIRIS SIR service (SIR: http://www.rediris.es/sir)
12+
*
13+
* For the full copyright and license information, please view the LICENSE
14+
* file that was distributed with this source code.
15+
*/
16+
17+
namespace SimpleSAML\Module\oidc\Utils;
18+
19+
/**
20+
* Resolves a single user identifier value from a set of released attributes,
21+
* given an ordered list of candidate attribute names.
22+
*
23+
* In heterogeneous IdP scenarios (e.g. eduGAIN inter-federation) not every IdP
24+
* releases the same identifier attribute. The candidate list is therefore
25+
* consulted in priority order and the first candidate that is present and holds
26+
* a non-empty value is used.
27+
*/
28+
class UserIdentifierResolver
29+
{
30+
/**
31+
* @param string[] $candidates Ordered list of candidate attribute names.
32+
* @param array<string,mixed> $attributes Released attributes (each value an array of values).
33+
* @return string|null The first resolved identifier value, or null if none of the candidates match.
34+
*/
35+
public function resolve(array $candidates, array $attributes): ?string
36+
{
37+
foreach ($candidates as $candidate) {
38+
if (
39+
!array_key_exists($candidate, $attributes) ||
40+
!is_array($attributes[$candidate]) ||
41+
$attributes[$candidate] === []
42+
) {
43+
continue;
44+
}
45+
46+
/** @psalm-suppress MixedAssignment */
47+
$value = reset($attributes[$candidate]);
48+
49+
if (is_scalar($value) && (string)$value !== '') {
50+
return (string)$value;
51+
}
52+
}
53+
54+
return null;
55+
}
56+
}

0 commit comments

Comments
 (0)