Skip to content

Commit 06b4715

Browse files
committed
fix: handle dot-containing claim names in nested claim resolution
Replace explode('.') with greedy longest-prefix matching that tries the full remaining path as a literal key first, then progressively shorter dot-prefixed segments. This correctly handles URL-based claim names (e.g. "https://idp.example.com/claims/groups") and object keys with literal dots (e.g. "user.role") as permitted by OIDC Core §5.1.2. Backward compatible: existing dot-separated nested paths resolve identically since the algorithm falls through to the same splits. Fixes #1373 Related: #1100 Signed-off-by: Strobel Pierre <strobelpierre@gmail.com>
1 parent e33fbdb commit 06b4715

2 files changed

Lines changed: 153 additions & 13 deletions

File tree

lib/Service/ProvisioningService.php

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -87,20 +87,10 @@ public function getClaimValues(object|array $tokenPayload, string $claimPath, in
8787
$alternatives = explode('|', $claimPath);
8888

8989
foreach ($alternatives as $altPath) {
90-
$parts = explode('.', trim($altPath));
91-
$value = $tokenPayload;
92-
93-
foreach ($parts as $part) {
94-
if (is_object($value) && property_exists($value, $part)) {
95-
$value = $value->{$part};
96-
} elseif (is_array($value) && array_key_exists($part, $value)) {
97-
$value = $value[$part];
98-
} else {
99-
continue 2;
100-
}
90+
$result = $this->resolveNestedClaim($tokenPayload, trim($altPath));
91+
if ($result !== null) {
92+
return $result;
10193
}
102-
103-
return $value;
10494
}
10595

10696
return null;
@@ -115,6 +105,50 @@ public function getClaimValue(object|array $tokenPayload, string $claimPath, int
115105
return is_string($value) ? $value : null;
116106
}
117107

108+
/**
109+
* Resolves a claim path against a token payload using greedy longest-prefix matching.
110+
*
111+
* Instead of splitting on every dot (which breaks URL-based claim names like
112+
* "https://idp.example.com/claims/groups" or literal dot keys like "user.role"),
113+
* this method first tries the full path as a literal key, then progressively
114+
* shorter dot-delimited prefixes (longest first), recursing into the remainder.
115+
*/
116+
private function resolveNestedClaim(object|array $data, string $path): mixed {
117+
if ($path === '') {
118+
return null;
119+
}
120+
121+
// Try full path as literal key
122+
if (is_object($data) && property_exists($data, $path)) {
123+
return $data->{$path};
124+
} elseif (is_array($data) && array_key_exists($path, $data)) {
125+
return $data[$path];
126+
}
127+
128+
// Try progressively shorter dot-prefixes (longest first)
129+
$lastDot = strlen($path);
130+
while (($lastDot = strrpos($path, '.', -(strlen($path) - $lastDot + 1))) !== false) {
131+
$prefix = substr($path, 0, $lastDot);
132+
$remainder = substr($path, $lastDot + 1);
133+
134+
$prefixValue = null;
135+
if (is_object($data) && property_exists($data, $prefix)) {
136+
$prefixValue = $data->{$prefix};
137+
} elseif (is_array($data) && array_key_exists($prefix, $data)) {
138+
$prefixValue = $data[$prefix];
139+
}
140+
141+
if ($prefixValue !== null && (is_object($prefixValue) || is_array($prefixValue))) {
142+
$result = $this->resolveNestedClaim($prefixValue, $remainder);
143+
if ($result !== null) {
144+
return $result;
145+
}
146+
}
147+
}
148+
149+
return null;
150+
}
151+
118152
/**
119153
* @param string $tokenUserId
120154
* @param int $providerId

tests/unit/Service/ProvisioningServiceTest.php

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,112 @@ public function testProvisionUserInvalidProperties(): void {
293293
);
294294
}
295295

296+
public static function dataGetClaimValues(): array {
297+
return [
298+
'flat simple key' => [
299+
'email',
300+
(object)['email' => 'alice@example.com'],
301+
'alice@example.com',
302+
],
303+
'nested via dot' => [
304+
'custom.nickname',
305+
(object)['custom' => (object)['nickname' => 'alice']],
306+
'alice',
307+
],
308+
'URL-based flat key' => [
309+
'https://idp.example.com/claims/groups',
310+
(object)['https://idp.example.com/claims/groups' => ['admin', 'users']],
311+
['admin', 'users'],
312+
],
313+
'URL key with nested navigation' => [
314+
'https://idp.example.com/attrs.role',
315+
(object)['https://idp.example.com/attrs' => (object)['role' => 'admin']],
316+
'admin',
317+
],
318+
'URL key with dotted sub-key' => [
319+
'https://idp.example.com/attrs.user.role',
320+
(object)['https://idp.example.com/attrs' => (object)['user.role' => 'admin']],
321+
'admin',
322+
],
323+
'deep nesting three levels' => [
324+
'a.b.c',
325+
(object)['a' => (object)['b' => (object)['c' => 'deep']]],
326+
'deep',
327+
],
328+
'pipe fallback first match' => [
329+
'missing | email',
330+
(object)['email' => 'bob@example.com'],
331+
'bob@example.com',
332+
],
333+
'pipe fallback second match' => [
334+
'primary_email | email',
335+
(object)['primary_email' => 'first@example.com', 'email' => 'second@example.com'],
336+
'first@example.com',
337+
],
338+
'non-existent path returns null' => [
339+
'does.not.exist',
340+
(object)['other' => 'value'],
341+
null,
342+
],
343+
'empty path returns null' => [
344+
'',
345+
(object)['key' => 'value'],
346+
null,
347+
],
348+
'literal dot key takes precedence over nested' => [
349+
'a.b',
350+
(object)['a.b' => 'flat', 'a' => (object)['b' => 'nested']],
351+
'flat',
352+
],
353+
'array payload' => [
354+
'user.name',
355+
['user' => ['name' => 'alice']],
356+
'alice',
357+
],
358+
'URL key as array payload' => [
359+
'https://idp.example.com/claims/roles',
360+
['https://idp.example.com/claims/roles' => ['editor']],
361+
['editor'],
362+
],
363+
];
364+
}
365+
366+
/**
367+
* @dataProvider dataGetClaimValues
368+
*/
369+
public function testGetClaimValues(string $claimPath, object|array $tokenPayload, mixed $expected): void {
370+
$providerId = 1;
371+
372+
$this->providerService
373+
->method('getSetting')
374+
->will($this->returnValueMap([
375+
[$providerId, ProviderService::SETTING_RESOLVE_NESTED_AND_FALLBACK_CLAIMS_MAPPING, '0', '1'],
376+
]));
377+
378+
$result = $this->provisioningService->getClaimValues($tokenPayload, $claimPath, $providerId);
379+
$this->assertEquals($expected, $result);
380+
}
381+
382+
public function testGetClaimValuesWithoutNestedResolution(): void {
383+
$providerId = 1;
384+
385+
$this->providerService
386+
->method('getSetting')
387+
->will($this->returnValueMap([
388+
[$providerId, ProviderService::SETTING_RESOLVE_NESTED_AND_FALLBACK_CLAIMS_MAPPING, '0', '0'],
389+
]));
390+
391+
// With nested resolution disabled, dot-containing keys should still work as literal keys
392+
$payload = (object)['https://idp.example.com/claims/groups' => ['admin']];
393+
$result = $this->provisioningService->getClaimValues($payload, 'https://idp.example.com/claims/groups', $providerId);
394+
$this->assertEquals(['admin'], $result);
395+
396+
// But nested navigation should NOT work
397+
$payload = (object)['custom' => (object)['nickname' => 'alice']];
398+
$result = $this->provisioningService->getClaimValues($payload, 'custom.nickname', $providerId);
399+
$this->assertNull($result);
400+
}
401+
296402
public static function dataProvisionUserGroups() {
297403
return [
298404
[

0 commit comments

Comments
 (0)