Skip to content

Commit 0d4cddc

Browse files
authored
Merge pull request #1379 from small1/entraid
Allow usage of Microsoft graph to lookup guid to group names on EntraID
2 parents 725520c + cae438b commit 0d4cddc

6 files changed

Lines changed: 157 additions & 2 deletions

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,16 @@ The following formats are supported for the groups claim:
431431
* Array of group name strings: `"groups": ["group1", "group2", "group3"]`
432432
* Object with name and id: `"groups": [{ "gid": "id1", "displayName": "group1" }, ...]`
433433

434+
### EntraID and Microsoft graph
435+
436+
If using EntraID an option to turn on group name lookups via Microsoft Graph. It will loop through all guid a user has and store the names of the groups in Nextcloud.
437+
438+
This can be done in the graphical settings for the provider by with the occ command to create/update providers:
439+
440+
```
441+
sudo -u www-data php occ user_oidc:provider demoprovider --entraid-group-names=1
442+
```
443+
434444
### Disable audience and azp checks
435445

436446
The `audience` and `azp` token claims will be checked when validating a login ID token.

lib/Command/UpsertProvider.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,10 @@ class UpsertProvider extends Base {
155155
'shortcut' => null, 'mode' => InputOption::VALUE_REQUIRED, 'setting_key' => ProviderService::SETTING_MAPPING_GROUPS,
156156
'description' => 'Attribute mapping of the groups',
157157
],
158+
'entraid-group-names' => [
159+
'shortcut' => null, 'mode' => InputOption::VALUE_REQUIRED, 'setting_key' => ProviderService::SETTING_AZURE_GROUP_NAMES,
160+
'description' => 'Turn on usage of mapping guid to names with Microsoft Graph. 1 to enable, 0 to disable (default)',
161+
],
158162
'resolve-nested-claims' => [
159163
'shortcut' => null,
160164
'mode' => InputOption::VALUE_REQUIRED,

lib/Service/ProviderService.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ class ProviderService {
5959
public const SETTING_GROUP_PROVISIONING = 'groupProvisioning';
6060
public const SETTING_GROUP_WHITELIST_REGEX = 'groupWhitelistRegex';
6161
public const SETTING_RESTRICT_LOGIN_TO_GROUPS = 'restrictLoginToGroups';
62+
public const SETTING_AZURE_GROUP_NAMES = 'azureGroupNames';
6263
public const SETTING_RESOLVE_NESTED_AND_FALLBACK_CLAIMS_MAPPING = 'nestedAndFallbackClaims';
6364

6465
public const BOOLEAN_SETTINGS_DEFAULT_VALUES = [
@@ -69,6 +70,7 @@ class ProviderService {
6970
self::SETTING_CHECK_BEARER => false,
7071
self::SETTING_SEND_ID_TOKEN_HINT => false,
7172
self::SETTING_RESTRICT_LOGIN_TO_GROUPS => false,
73+
self::SETTING_AZURE_GROUP_NAMES => false,
7274
self::SETTING_RESOLVE_NESTED_AND_FALLBACK_CLAIMS_MAPPING => false,
7375
];
7476

@@ -191,6 +193,7 @@ public function getSupportedSettings(): array {
191193
self::SETTING_GROUP_PROVISIONING,
192194
self::SETTING_GROUP_WHITELIST_REGEX,
193195
self::SETTING_RESTRICT_LOGIN_TO_GROUPS,
196+
self::SETTING_AZURE_GROUP_NAMES,
194197
self::SETTING_RESOLVE_NESTED_AND_FALLBACK_CLAIMS_MAPPING,
195198
];
196199
}

lib/Service/ProvisioningService.php

Lines changed: 123 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use Locale;
1212
use OC\Accounts\AccountManager;
1313
use OCA\UserOIDC\AppInfo\Application;
14+
use OCA\UserOIDC\Db\ProviderMapper;
1415
use OCA\UserOIDC\Db\UserMapper;
1516
use OCA\UserOIDC\Event\AttributeMappedEvent;
1617
use OCP\Accounts\IAccountManager;
@@ -29,6 +30,7 @@
2930
use OCP\IUserManager;
3031
use OCP\L10N\IFactory;
3132
use OCP\PreConditionNotMetException;
33+
use OCP\Security\ICrypto;
3234
use OCP\User\Events\UserChangedEvent;
3335
use Psr\Log\LoggerInterface;
3436
use Throwable;
@@ -49,6 +51,8 @@ public function __construct(
4951
private IConfig $config,
5052
private ISession $session,
5153
private IFactory $l10nFactory,
54+
private ProviderMapper $providerMapper,
55+
private ICrypto $crypto,
5256
) {
5357
}
5458

@@ -588,6 +592,18 @@ public function getSyncGroupsOfToken(int $providerId, object $idTokenPayload): ?
588592
}
589593
$syncGroups = [];
590594

595+
$token = null;
596+
$tenant = null;
597+
if ($this->providerService->getSetting($providerId, ProviderService::SETTING_AZURE_GROUP_NAMES, '0') === '1') {
598+
$azureGroupSyncContext = $this->getAzureGroupSyncContext($providerId);
599+
if ($azureGroupSyncContext === null) {
600+
return null;
601+
}
602+
603+
$tenant = $azureGroupSyncContext['tenant'];
604+
$token = $azureGroupSyncContext['token'];
605+
}
606+
591607
foreach ($groups as $k => $v) {
592608
if (is_object($v)) {
593609
// Handle array of objects, e.g. [{gid: "1", displayName: "group1"}, ...]
@@ -614,8 +630,14 @@ public function getSyncGroupsOfToken(int $providerId, object $idTokenPayload): ?
614630
}
615631
}
616632

617-
$group->gid = $this->idService->getId($providerId, $group->gid);
618-
633+
if ($this->providerService->getSetting($providerId, ProviderService::SETTING_AZURE_GROUP_NAMES, '0') === '1' && is_string($v)) {
634+
$group = $this->getAzureGroupWithResolvedName($providerId, $tenant, $token, $v);
635+
if ($group === null) {
636+
continue;
637+
}
638+
} else {
639+
$group->gid = $this->idService->getId($providerId, $group->gid);
640+
}
619641
$syncGroups[] = $group;
620642
}
621643

@@ -625,6 +647,105 @@ public function getSyncGroupsOfToken(int $providerId, object $idTokenPayload): ?
625647
return null;
626648
}
627649

650+
/**
651+
* @return array{tenant: string, token: string}|null
652+
*/
653+
private function getAzureGroupSyncContext(int $providerId): ?array {
654+
$provider = $this->providerMapper->getProvider($providerId);
655+
$url = $provider->getDiscoveryEndpoint();
656+
$tenant = explode('//', $url);
657+
$tenant = count($tenant) === 1 ? $tenant[0] : $tenant[1];
658+
$tenant = explode('/', $tenant);
659+
if (count($tenant) === 1) {
660+
$this->logger->error('Could not figure out the tenant id. (Is the discovery endpoint formatted properly?) Will not sync groups');
661+
return null;
662+
}
663+
$tenant = $tenant[1];
664+
665+
$client = $this->clientService->newClient();
666+
try {
667+
$response = $client->post("https://login.microsoftonline.com/$tenant/oauth2/v2.0/token", [
668+
'headers' => [ 'Accept' => 'application/json' ],
669+
'form_params' => [
670+
'client_id' => $provider->getClientId(),
671+
'scope' => 'https://graph.microsoft.com/.default',
672+
'client_secret' => $this->crypto->decrypt($provider->getClientSecret()),
673+
'grant_type' => 'client_credentials'
674+
],
675+
'http_errors' => false
676+
]);
677+
} catch (\Exception $e) {
678+
$this->logger->error($e->getMessage());
679+
return null;
680+
}
681+
682+
$res = $response->getBody();
683+
if (!is_string($res)) {
684+
$this->logger->error('Could not fetch Bearer token for Microsoft Graph. Will not sync groups');
685+
return null;
686+
}
687+
688+
$res = json_decode($res, true);
689+
if (empty($res) || empty($res['access_token']) || !is_string($res['access_token'])) {
690+
$this->logger->error('Could not fetch Bearer token for Microsoft Graph. Will not sync groups');
691+
return null;
692+
}
693+
694+
return [
695+
'tenant' => $tenant,
696+
'token' => $res['access_token'],
697+
];
698+
}
699+
700+
private function getAzureGroupWithResolvedName(int $providerId, string $tenant, string $token, string $groupId): ?object {
701+
$client = $this->clientService->newClient();
702+
try {
703+
$response = $client->get(
704+
"https://graph.microsoft.com/v1.0/$tenant/groups/" . $groupId,
705+
[ 'headers' => [ 'Accept' => 'application/json', 'Authorization' => "Bearer $token" ], 'http_errors' => false ]
706+
);
707+
} catch (\Exception $e) {
708+
$this->logger->error($e->getMessage());
709+
return null;
710+
}
711+
712+
$res = $response->getBody();
713+
if (!is_string($res)) {
714+
$this->logger->error('No response from Microsoft Graph while fetching group name. Will not sync the group ' . $groupId);
715+
return null;
716+
}
717+
718+
$res = json_decode($res, true); // https://learn.microsoft.com/en-us/graph/api/group-get?view=graph-rest-1.0&tabs=http#response-1
719+
if (isset($res['error'])) {
720+
$errorMessage = !empty($res['error']['message']) && is_string($res['error']['message']) ? $res['error']['message'] : '';
721+
$this->logger->error('Error response from Microsoft Graph while fetching group name. Will not sync the group ' . $groupId . '. Graph said: ' . $errorMessage);
722+
return null;
723+
}
724+
725+
if (empty($res['displayName'])) {
726+
$this->logger->error('Empty response from Microsoft Graph while fetching group name. Will not sync the group ' . $groupId);
727+
return null;
728+
}
729+
730+
$group = (object)['gid' => $res['displayName']];
731+
if ($this->providerService->getSetting($providerId, ProviderService::SETTING_PROVIDER_BASED_ID, '0') === '1') {
732+
$providerName = $this->providerMapper->getProvider($providerId)->getIdentifier();
733+
$group->gid = $providerName . '-' . $group->gid;
734+
}
735+
736+
if (strlen($group->gid) > 64) {
737+
$this->logger->warning('Group id ' . $group->gid . ' longer than supported. Group id truncated.');
738+
$group->displayName = $group->gid;
739+
$group->gid = substr($group->gid, 0, 64);
740+
if (strlen($group->displayName) > 255) {
741+
$this->logger->warning('Group name ' . $group->displayName . ' longer than supported. Group name truncated.');
742+
$group->displayName = substr($group->displayName, 0, 255);
743+
}
744+
}
745+
746+
return $group;
747+
}
748+
628749
public function provisionUserGroups(IUser $user, int $providerId, object $idTokenPayload): ?array {
629750
$groupsWhitelistRegex = $this->getGroupWhitelistRegex($providerId);
630751

tests/unit/Service/ProviderServiceTest.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ public function testGetProvidersWithSettings() {
9797
'groupProvisioning' => true,
9898
'groupWhitelistRegex' => '1',
9999
'restrictLoginToGroups' => true,
100+
'azureGroupNames' => true,
100101
'nestedAndFallbackClaims' => true,
101102
],
102103
],
@@ -143,6 +144,7 @@ public function testGetProvidersWithSettings() {
143144
'groupProvisioning' => true,
144145
'groupWhitelistRegex' => '1',
145146
'restrictLoginToGroups' => true,
147+
'azureGroupNames' => true,
146148
'nestedAndFallbackClaims' => true,
147149
],
148150
],
@@ -185,6 +187,7 @@ public function testSetSettings() {
185187
'mappingBirthdate' => 'birthdate',
186188
'groupWhitelistRegex' => '',
187189
'restrictLoginToGroups' => false,
190+
'azureGroupNames' => false,
188191
'nestedAndFallbackClaims' => false,
189192
];
190193
$this->appConfig->expects(self::any())
@@ -224,6 +227,7 @@ public function testSetSettings() {
224227
[Application::APP_ID, 'provider-1-' . ProviderService::SETTING_GROUP_PROVISIONING, '', true, '1'],
225228
[Application::APP_ID, 'provider-1-' . ProviderService::SETTING_GROUP_WHITELIST_REGEX, '', true, ''],
226229
[Application::APP_ID, 'provider-1-' . ProviderService::SETTING_RESTRICT_LOGIN_TO_GROUPS, '', true, '0'],
230+
[Application::APP_ID, 'provider-1-' . ProviderService::SETTING_AZURE_GROUP_NAMES, '', true, '0'],
227231
[Application::APP_ID, 'provider-1-' . ProviderService::SETTING_RESOLVE_NESTED_AND_FALLBACK_CLAIMS_MAPPING, '', true, '0'],
228232
]);
229233

tests/unit/Service/ProvisioningServiceTest.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* SPDX-License-Identifier: AGPL-3.0-or-later
66
*/
77

8+
use OCA\UserOIDC\Db\ProviderMapper;
89
use OCA\UserOIDC\Db\User;
910
use OCA\UserOIDC\Db\UserMapper;
1011
use OCA\UserOIDC\Service\LocalIdService;
@@ -23,6 +24,7 @@
2324
use OCP\IUser;
2425
use OCP\IUserManager;
2526
use OCP\L10N\IFactory;
27+
use OCP\Security\ICrypto;
2628
use PHPUnit\Framework\Attributes\DataProvider;
2729
use PHPUnit\Framework\MockObject\MockObject;
2830
use PHPUnit\Framework\TestCase;
@@ -88,6 +90,12 @@ class ProvisioningServiceTest extends TestCase {
8890
*/
8991
private $l10nFactory;
9092

93+
/** @var ProviderMapper | MockObject */
94+
private $providerMapper;
95+
96+
/** @var ICrypto | MockObject */
97+
private $crypto;
98+
9199
public function setUp(): void {
92100
parent::setUp();
93101
$this->idService = $this->createMock(LocalIdService::class);
@@ -103,6 +111,8 @@ public function setUp(): void {
103111
$this->avatarManager = $this->createMock(IAvatarManager::class);
104112
$this->session = $this->createMock(ISession::class);
105113
$this->l10nFactory = $this->createMock(IFactory::class);
114+
$this->providerMapper = $this->createMock(ProviderMapper::class);
115+
$this->crypto = $this->createMock(ICrypto::class);
106116

107117
$this->provisioningService = new ProvisioningService(
108118
$this->idService,
@@ -118,6 +128,8 @@ public function setUp(): void {
118128
$this->config,
119129
$this->session,
120130
$this->l10nFactory,
131+
$this->providerMapper,
132+
$this->crypto,
121133
);
122134
}
123135

@@ -481,6 +493,7 @@ public function testProvisionUserGroups(string $gid, string $displayName, object
481493
->willReturnMap([
482494
[$providerId, ProviderService::SETTING_GROUP_WHITELIST_REGEX, '', $group_whitelist],
483495
[$providerId, ProviderService::SETTING_MAPPING_GROUPS, 'groups', 'groups'],
496+
[$providerId, ProviderService::SETTING_AZURE_GROUP_NAMES, '0', '0'],
484497
[$providerId, ProviderService::SETTING_RESOLVE_NESTED_AND_FALLBACK_CLAIMS_MAPPING, '0', '0'],
485498
]);
486499

0 commit comments

Comments
 (0)