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
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,40 @@ This app can stop matching users (when a user search is performed in Nextcloud)
],
```

### Optional: Enable support for nested and fallback claim mappings

By default, claim mapping in this app uses **flat attribute keys** like `email`, `name`, `custom.nickname`, etc.
However, some Identity Providers return **structured tokens** (nested JSON), and mapping such claims requires dot-notation (e.g. `custom.nickname` → `{ "custom": { "nickname": "value" } }`).

Additionally, you may want to define **fallbacks**, in case a preferred claim is missing, using the `|` separator.

#### Example

```
custom.nickname | profile.name | name
```

This will return the first non-empty string from the token in the order defined.


#### Enabling this behavior (optional)

To enable support for dot-notation and fallback claims for a specific provider, set the following configuration flag via the Nextcloud command line:

```bash
php occ user_oidc:provider <your-provider-identifier> --resolve-nested-claims=1
```

To disable again:

```bash
php occ user_oidc:provider <your-provider-identifier> --resolve-nested-claims=0
```

This setting is also available in the web interface when configuring a provider.
This setting is **disabled by default** to ensure full backward compatibility with existing configurations and flat token structures.


## Building the app

Requirements for building:
Expand Down
6 changes: 6 additions & 0 deletions lib/Command/UpsertProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,12 @@ class UpsertProvider extends Base {
'shortcut' => null, 'mode' => InputOption::VALUE_REQUIRED, 'setting_key' => ProviderService::SETTING_MAPPING_GROUPS,
'description' => 'Attribute mapping of the groups',
],
'resolve-nested-claims' => [
'shortcut' => null,
'mode' => InputOption::VALUE_REQUIRED,
'setting_key' => ProviderService::SETTING_RESOLVE_NESTED_AND_FALLBACK_CLAIMS_MAPPING,
'description' => 'Enable support for dot-separated and fallback claim mappings (e.g. "a.b | c.d | e"). 1 to enable, 0 to disable (default)',
],
];

public function __construct(
Expand Down
27 changes: 23 additions & 4 deletions lib/Controller/LoginController.php
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ public function login(int $providerId, ?string $redirectUrl = null) {
'userinfo' => [],
];

$resolveNestedClaims = $this->providerService->getSetting($providerId, ProviderService::SETTING_RESOLVE_NESTED_AND_FALLBACK_CLAIMS_MAPPING, '0') === '1';
// by default: default claims are ENABLED
// default claims are historically for quota, email, displayName and groups
$isDefaultClaimsEnabled = !isset($oidcSystemConfig['enable_default_claims']) || $oidcSystemConfig['enable_default_claims'] !== false;
Expand All @@ -218,7 +219,20 @@ public function login(int $providerId, ?string $redirectUrl = null) {
$displaynameAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_DISPLAYNAME);
$quotaAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_QUOTA);
$groupsAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_GROUPS);
foreach ([$emailAttribute, $displaynameAttribute, $quotaAttribute, $groupsAttribute] as $claim) {
$rawClaims = [$emailAttribute, $displaynameAttribute, $quotaAttribute, $groupsAttribute];

if ($resolveNestedClaims) {
$claimSet = [];
foreach ($rawClaims as $claim) {
if ($claim !== '') {
$first = trim(explode('|', $claim)[0]);
$claimSet[$first] = true;
}
}
$rawClaims = array_keys($claimSet);
}

foreach ($rawClaims as $claim) {
if ($claim !== '') {
$claims['id_token'][$claim] = null;
$claims['userinfo'][$claim] = null;
Expand All @@ -227,8 +241,12 @@ public function login(int $providerId, ?string $redirectUrl = null) {
}

if ($uidAttribute !== 'sub') {
$claims['id_token'][$uidAttribute] = ['essential' => true];
$claims['userinfo'][$uidAttribute] = ['essential' => true];
$uidAttributeToRequest = $uidAttribute;
if ($resolveNestedClaims) {
$uidAttributeToRequest = trim(explode('|', $uidAttribute)[0]);
}
$claims['id_token'][$uidAttributeToRequest] = ['essential' => true];
$claims['userinfo'][$uidAttributeToRequest] = ['essential' => true];
}

$extraClaimsString = $this->providerService->getSetting($providerId, ProviderService::SETTING_EXTRA_CLAIMS, '');
Expand Down Expand Up @@ -459,7 +477,8 @@ public function code(string $state = '', string $code = '', string $scope = '',

// get user ID attribute
$uidAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_UID, 'sub');
$userId = $idTokenPayload->{$uidAttribute} ?? null;
$userId = $this->provisioningService->getClaimValue($idTokenPayload, $uidAttribute, $providerId);

if ($userId === null) {
$message = $this->l10n->t('Failed to provision the user');
return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => 'failed to provision user']);
Expand Down
3 changes: 3 additions & 0 deletions lib/Service/ProviderService.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ class ProviderService {
public const SETTING_GROUP_PROVISIONING = 'groupProvisioning';
public const SETTING_GROUP_WHITELIST_REGEX = 'groupWhitelistRegex';
public const SETTING_RESTRICT_LOGIN_TO_GROUPS = 'restrictLoginToGroups';
public const SETTING_RESOLVE_NESTED_AND_FALLBACK_CLAIMS_MAPPING = 'nestedAndFallbackClaims';

public const BOOLEAN_SETTINGS_DEFAULT_VALUES = [
self::SETTING_GROUP_PROVISIONING => false,
Expand All @@ -60,6 +61,7 @@ class ProviderService {
self::SETTING_CHECK_BEARER => false,
self::SETTING_SEND_ID_TOKEN_HINT => false,
self::SETTING_RESTRICT_LOGIN_TO_GROUPS => false,
self::SETTING_RESOLVE_NESTED_AND_FALLBACK_CLAIMS_MAPPING => false,
];

public function __construct(
Expand Down Expand Up @@ -168,6 +170,7 @@ private function getSupportedSettings(): array {
self::SETTING_GROUP_PROVISIONING,
self::SETTING_GROUP_WHITELIST_REGEX,
self::SETTING_RESTRICT_LOGIN_TO_GROUPS,
self::SETTING_RESOLVE_NESTED_AND_FALLBACK_CLAIMS_MAPPING,
];
}

Expand Down
86 changes: 66 additions & 20 deletions lib/Service/ProvisioningService.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,52 @@ public function hasOidcUserProvisitioned(string $userId): bool {
return false;
}

/**
* Resolves a claim path like "custom.nickname" or multiple alternatives separated by "|".
* Returns the first found string value, or null if none could be resolved.
*/
public function getClaimValue(object|array $tokenPayload, string $claimPath, int $providerId): mixed {
if ($claimPath === '') {
return null;
}

// Check config if dot-notation resolution is enabled
$resolveDot = $this->providerService->getSetting($providerId, ProviderService::SETTING_RESOLVE_NESTED_AND_FALLBACK_CLAIMS_MAPPING, '0') === '1';

if (!$resolveDot) {
// fallback to simple access
if (is_object($tokenPayload) && property_exists($tokenPayload, $claimPath)) {
return $tokenPayload->{$claimPath};
} elseif (is_array($tokenPayload) && array_key_exists($claimPath, $tokenPayload)) {
return $tokenPayload[$claimPath];
}
return null;
}

// Support alternatives separated by "|"
$alternatives = explode('|', $claimPath);

foreach ($alternatives as $altPath) {
$parts = explode('.', trim($altPath));
$value = $tokenPayload;

foreach ($parts as $part) {
if (is_object($value) && property_exists($value, $part)) {
$value = $value->{$part};
} elseif (is_array($value) && array_key_exists($part, $value)) {
$value = $value[$part];
} else {
continue 2;
}
}

if (is_string($value)) {
return $value;
}
}

return null;
}
/**
* @param string $tokenUserId
* @param int $providerId
Expand All @@ -72,64 +118,64 @@ public function provisionUser(string $tokenUserId, int $providerId, object $idTo

// get name/email/quota information from the token itself
$emailAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_EMAIL, 'email');
$email = $idTokenPayload->{$emailAttribute} ?? null;
$email = $this->getClaimValue($idTokenPayload, $emailAttribute, $providerId);//$idTokenPayload->{$emailAttribute} ?? null;

$displaynameAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_DISPLAYNAME, 'name');
$userName = $idTokenPayload->{$displaynameAttribute} ?? null;
$userName = $this->getClaimValue($idTokenPayload, $displaynameAttribute, $providerId);//$idTokenPayload->{$displaynameAttribute} ?? null;

$quotaAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_QUOTA, 'quota');
$quota = $idTokenPayload->{$quotaAttribute} ?? null;
$quota = $this->getClaimValue($idTokenPayload, $quotaAttribute, $providerId);//$idTokenPayload->{$quotaAttribute} ?? null;

$languageAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_LANGUAGE, 'language');
$language = $idTokenPayload->{$languageAttribute} ?? null;
$language = $this->getClaimValue($idTokenPayload, $languageAttribute, $providerId);//$idTokenPayload->{$languageAttribute} ?? null;

$genderAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_GENDER, 'gender');
$gender = $idTokenPayload->{$genderAttribute} ?? null;
$gender = $this->getClaimValue($idTokenPayload, $genderAttribute, $providerId);//$idTokenPayload->{$genderAttribute} ?? null;

$addressAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_ADDRESS, 'address');
$address = $idTokenPayload->{$addressAttribute} ?? null;
$address = $this->getClaimValue($idTokenPayload, $addressAttribute, $providerId);//$idTokenPayload->{$addressAttribute} ?? null;

$postalcodeAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_POSTALCODE, 'postal_code');
$postalcode = $idTokenPayload->{$postalcodeAttribute} ?? null;
$postalcode = $this->getClaimValue($idTokenPayload, $postalcodeAttribute, $providerId);//$idTokenPayload->{$postalcodeAttribute} ?? null;

$streetAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_STREETADDRESS, 'street_address');
$street = $idTokenPayload->{$streetAttribute} ?? null;
$street = $this->getClaimValue($idTokenPayload, $streetAttribute, $providerId);//$idTokenPayload->{$streetAttribute} ?? null;

$localityAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_LOCALITY, 'locality');
$locality = $idTokenPayload->{$localityAttribute} ?? null;
$locality = $this->getClaimValue($idTokenPayload, $localityAttribute, $providerId);//$idTokenPayload->{$localityAttribute} ?? null;

$regionAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_REGION, 'region');
$region = $idTokenPayload->{$regionAttribute} ?? null;
$region = $this->getClaimValue($idTokenPayload, $regionAttribute, $providerId);//$idTokenPayload->{$regionAttribute} ?? null;

$countryAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_COUNTRY, 'country');
$country = $idTokenPayload->{$countryAttribute} ?? null;
$country = $this->getClaimValue($idTokenPayload, $countryAttribute, $providerId);//$idTokenPayload->{$countryAttribute} ?? null;

$websiteAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_WEBSITE, 'website');
$website = $idTokenPayload->{$websiteAttribute} ?? null;
$website = $this->getClaimValue($idTokenPayload, $websiteAttribute, $providerId);//$idTokenPayload->{$websiteAttribute} ?? null;

$avatarAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_AVATAR, 'avatar');
$avatar = $idTokenPayload->{$avatarAttribute} ?? null;
$avatar = $this->getClaimValue($idTokenPayload, $avatarAttribute, $providerId);//$idTokenPayload->{$avatarAttribute} ?? null;

$phoneAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_PHONE, 'phone_number');
$phone = $idTokenPayload->{$phoneAttribute} ?? null;
$phone = $this->getClaimValue($idTokenPayload, $phoneAttribute, $providerId);//$idTokenPayload->{$phoneAttribute} ?? null;

$twitterAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_TWITTER, 'twitter');
$twitter = $idTokenPayload->{$twitterAttribute} ?? null;
$twitter = $this->getClaimValue($idTokenPayload, $twitterAttribute, $providerId);//$idTokenPayload->{$twitterAttribute} ?? null;

$fediverseAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_FEDIVERSE, 'fediverse');
$fediverse = $idTokenPayload->{$fediverseAttribute} ?? null;
$fediverse = $this->getClaimValue($idTokenPayload, $fediverseAttribute, $providerId);//$idTokenPayload->{$fediverseAttribute} ?? null;

$organisationAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_ORGANISATION, 'organisation');
$organisation = $idTokenPayload->{$organisationAttribute} ?? null;
$organisation = $this->getClaimValue($idTokenPayload, $organisationAttribute, $providerId);//$idTokenPayload->{$organisationAttribute} ?? null;

$roleAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_ROLE, 'role');
$role = $idTokenPayload->{$roleAttribute} ?? null;
$role = $this->getClaimValue($idTokenPayload, $roleAttribute, $providerId);//$idTokenPayload->{$roleAttribute} ?? null;

$headlineAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_HEADLINE, 'headline');
$headline = $idTokenPayload->{$headlineAttribute} ?? null;
$headline = $this->getClaimValue($idTokenPayload, $headlineAttribute, $providerId);//$idTokenPayload->{$headlineAttribute} ?? null;

$biographyAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_BIOGRAPHY, 'biography');
$biography = $idTokenPayload->{$biographyAttribute} ?? null;
$biography = $this->getClaimValue($idTokenPayload, $biographyAttribute, $providerId);//$idTokenPayload->{$biographyAttribute} ?? null;

$event = new AttributeMappedEvent(ProviderService::SETTING_MAPPING_UID, $idTokenPayload, $tokenUserId);
$this->eventDispatcher->dispatchTyped($event);
Expand Down
13 changes: 7 additions & 6 deletions lib/User/Backend.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use OCA\UserOIDC\Service\DiscoveryService;
use OCA\UserOIDC\Service\LdapService;
use OCA\UserOIDC\Service\ProviderService;
use OCA\UserOIDC\Service\ProvisioningService;
use OCA\UserOIDC\User\Validator\SelfEncodedValidator;
use OCA\UserOIDC\User\Validator\UserInfoValidator;
use OCP\AppFramework\Db\DoesNotExistException;
Expand Down Expand Up @@ -56,6 +57,7 @@ public function __construct(
private DiscoveryService $discoveryService,
private ProviderMapper $providerMapper,
private ProviderService $providerService,
private ProvisioningService $provisioningService,
private LdapService $ldapService,
private IUserManager $userManager,
) {
Expand Down Expand Up @@ -175,22 +177,21 @@ private function formatUserData(int $providerId, array $attributes): array {
$result = ['formatted' => [], 'raw' => $attributes];

$emailAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_EMAIL, 'email');
$result['formatted']['email'] = $attributes[$emailAttribute] ?? null;
$result['formatted']['email'] = $this->provisioningService->getClaimValue($attributes, $emailAttribute, $providerId);

$displaynameAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_DISPLAYNAME, 'name');
$result['formatted']['displayName'] = $attributes[$displaynameAttribute] ?? null;

$result['formatted']['displayName'] = $this->provisioningService->getClaimValue($attributes, $displaynameAttribute, $providerId);
$quotaAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_QUOTA, 'quota');
$result['formatted']['quota'] = $attributes[$quotaAttribute] ?? null;
$result['formatted']['quota'] = $this->provisioningService->getClaimValue($attributes, $quotaAttribute, $providerId);
if ($result['formatted']['quota'] === '') {
$result['formatted']['quota'] = 'default';
}

$groupsAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_GROUPS, 'groups');
$result['formatted']['groups'] = $attributes[$groupsAttribute] ?? null;
$result['formatted']['groups'] = $this->provisioningService->getClaimValue($attributes, $groupsAttribute, $providerId);

$uidAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_UID, 'sub');
$result['formatted']['uid'] = $attributes[$uidAttribute] ?? null;
$result['formatted']['uid'] = $this->provisioningService->getClaimValue($attributes, $uidAttribute, $providerId);

return $result;
}
Expand Down
12 changes: 6 additions & 6 deletions lib/User/Validator/SelfEncodedValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use OCA\UserOIDC\Db\Provider;
use OCA\UserOIDC\Service\DiscoveryService;
use OCA\UserOIDC\Service\ProviderService;
use OCA\UserOIDC\Service\ProvisioningService;
use OCA\UserOIDC\User\Provisioning\SelfEncodedTokenProvisioning;
use OCA\UserOIDC\Vendor\Firebase\JWT\JWT;
use OCP\AppFramework\Utility\ITimeFactory;
Expand All @@ -23,6 +24,7 @@ class SelfEncodedValidator implements IBearerTokenValidator {

public function __construct(
private DiscoveryService $discoveryService,
private ProvisioningService $provisioningService,
private LoggerInterface $logger,
private ITimeFactory $timeFactory,
private IConfig $config,
Expand All @@ -32,7 +34,8 @@ public function __construct(
public function isValidBearerToken(Provider $provider, string $bearerToken): ?string {
/** @var ProviderService $providerService */
$providerService = \OC::$server->get(ProviderService::class);
$uidAttribute = $providerService->getSetting($provider->getId(), ProviderService::SETTING_MAPPING_UID, ProviderService::SETTING_MAPPING_UID_DEFAULT);
$providerId = $provider->getId();
$uidAttribute = $providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_UID, ProviderService::SETTING_MAPPING_UID_DEFAULT);

// try to decode the bearer token
JWT::$leeway = 60;
Expand Down Expand Up @@ -73,11 +76,8 @@ public function isValidBearerToken(Provider $provider, string $bearerToken): ?st
}

// find the user ID
if (!isset($payload->{$uidAttribute})) {
return null;
}

return $payload->{$uidAttribute};
$uid = $this->provisioningService->getClaimValue($payload, $uidAttribute, $providerId);
return $uid ?: null;
}

public function getProvisioningStrategy(): string {
Expand Down
13 changes: 7 additions & 6 deletions lib/User/Validator/UserInfoValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,24 @@
use OCA\UserOIDC\Db\Provider;
use OCA\UserOIDC\Service\OIDCService;
use OCA\UserOIDC\Service\ProviderService;
use OCA\UserOIDC\Service\ProvisioningService;

class UserInfoValidator implements IBearerTokenValidator {

public function __construct(
private OIDCService $userInfoService,
private ProviderService $providerService,
private ProvisioningService $provisioningService,
) {
}

public function isValidBearerToken(Provider $provider, string $bearerToken): ?string {
$userInfo = $this->userInfoService->userinfo($provider, $bearerToken);
$uidAttribute = $this->providerService->getSetting($provider->getId(), ProviderService::SETTING_MAPPING_UID, ProviderService::SETTING_MAPPING_UID_DEFAULT);
if (!isset($userInfo[$uidAttribute])) {
return null;
}

return $userInfo[$uidAttribute];
$providerId = $provider->getId();
$uidAttribute = $this->providerService->getSetting($providerId, ProviderService::SETTING_MAPPING_UID, ProviderService::SETTING_MAPPING_UID_DEFAULT);
// find the user ID
$uid = $this->provisioningService->getClaimValue($userInfo, $uidAttribute, $providerId);
return $uid ?: null;
}

public function getProvisioningStrategy(): string {
Expand Down
3 changes: 3 additions & 0 deletions src/components/SettingsForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@
placeholder="claim1 claim2 claim3">
</p>
<h3><b>{{ t('user_oidc', 'Attribute mapping') }}</b></h3>
<NcCheckboxRadioSwitch :checked.sync="localProvider.settings.nestedAndFallbackClaims" wrapper-element="div">
{{ t('user_oidc', 'Enable nested and fallback claim mappings (like "{example}")', { example: 'custom.nickname | profile.name | name' }) }}
</NcCheckboxRadioSwitch>
<p>
<label for="mapping-uid">{{ t('user_oidc', 'User ID mapping') }}</label>
<input id="mapping-uid"
Expand Down
Loading
Loading