BookStack icon indicating copy to clipboard operation
BookStack copied to clipboard

OIDC: Distributed claims are not retrieved during group sync

Open andrelaszlo opened this issue 10 months ago • 6 comments

Describe the Bug

When OIDC is used as the primary authentication method and group sync is configured, some users are not getting the appropriate roles mapped despite being members of the corresponding groups.

Steps to Reproduce

  1. Set up OIDC with Azure (perhaps by following the excellent guide by @ssddanbrown https://foss.video/w/n67qNijhf8BdTRQys8SDYf)
  2. Be sure to set OIDC_GROUPS_CLAIM=groups in .env
  3. Add a valid group (G) to one of your BookStack roles (R), under "External Authentication IDs"
  4. Add an Azure user to G. Log in to your BookStack instance using this member and verify that they are now assigned to R as well.
  5. Remove the assigned role from the user in BookStack.
  6. Add at least 200 (sorry...) group memberships for the user in Azure.
  7. Log the user in again.
  8. Verify that the user is now not assigned the role R, despite still being a member of G.

Expected Behaviour

At step 7, the user is still a member of G so it should get assigned the role R.

Screenshots or Additional Context

Normally, a claim returned by Azure looks like this:

{
  "aud": "...",
  "iss": "https://login.microsoftonline.com/.../v2.0",
  "iat": 1741364356,
  "nbf": 1741364356,
  "exp": 1741368256,
  "aio": "...",
  "email": "[email protected]",
  "family_name": "John",
  "given_name": "Doe",
  "groups": [
    "<group uuid>",
    "...",
  ],
  "name": "John Doe",
  "oid": "...",
  "preferred_username": "[email protected]",
  "rh": "...",
  "sid": "...",
  "sub": "...",
  "tid": "...",
  "uti": "...",
  "ver": "2.0",
  "wids": [
    "..."
  ]
}

The list of group ids in groups can be used to map groups to BookStack roles. However, when this list grows too large, the corresponding JWT can become too large to fit in the HTTP headers, so Microsoft will instead return a distributed claim for the groups claim if the user has over 200 group memberships (for JWT, 100 for SAML), they refer to this as a groups overage claim.

Distributed claims are part of OIDC Core 1.0: [5.6.2. Aggregated and Distributed Claims](https://openid.net/specs/openid-connect-core-1_0.html#AggregatedDistributedClaims.

In this case, the claim will instead look like this:

{
  "aud": "...",
  "iss": "https://login.microsoftonline.com/.../v2.0",
  "iat": 1741350002,
  "nbf": 1741350002,
  "exp": 1741353902,
  "_claim_names": {
    "groups": "src1"
  },
  "_claim_sources": {
    "src1": {
      "endpoint": "https://graph.windows.net/.../users/.../getMemberObjects"
    }
  },
  "aio": "...",
  "email": "[email protected]",
  "family_name": "Doe",
  "given_name": "John",
  "name": "John Doe",
  "oid": "...",
  "preferred_username": "[email protected]",
  "rh": "...",
  "sid": "...",
  "sub": "...",
  "tid": "...",
  "uti": "...",
  "ver": "2.0",
  "wids": [
    "..."
  ]
}

In the above claim, the groups key is missing and is instead represented by _claim_names.groups: src1 and _claim_sources.src1: https://graph.windows.net/.../users/.../getMemberObjects. Supporting distributed claims is optional according to the standard, but since this breaks the OIDC_GROUPS_CLAIM feature in BookStack, I think it should be considered a bug.

Browser Details

This bug is not related to the browser being used, but was tested with Microsoft Edge.

Exact BookStack Version

v24.12.1

andrelaszlo avatar Mar 07 '25 16:03 andrelaszlo

This might be related to #5306, and now that I did some more digging I see that it's definitely a duplicate of #4885. I totally understand your stance, dealing with identity provider quirks is a never-ending nightmare. I had a quick look and didn't find a good OIDC library for PHP either.

Close?

andrelaszlo avatar Mar 07 '25 17:03 andrelaszlo

I might eventually have some time to hack on it, if you'd be interested in a PR.

andrelaszlo avatar Mar 07 '25 17:03 andrelaszlo

Thanks for raising @andrelaszlo, I've recategorized this as a auth feature request, since I don't see it as a bug in existing supported functionality.

I totally understand your stance, dealing with identity provider quirks is a never-ending nightmare. I had a quick look and didn't find a good OIDC library for PHP either. Close?

To be fair, In those issues I didn't realise that Azure/Entra was at least using (a lesser used) part of the OIDC spec, rather than something custom. That said, I'm hesitant to grow the scope/features of OIDC further without a decent amount of proven need (ideally provide need across multiple auth systems).

Do you know if Entra provides all groups via the userinfo endpoint? We've now added support for that so that could be an easier route to support if provided by Entra.

ssddanbrown avatar Mar 07 '25 17:03 ssddanbrown

Ok, thanks for the thoughtful review :)

Do you know if Entra provides all groups via the userinfo endpoint?

That would have been neat, but unfortunately it doesn't look like it's a supported claim according to the UserInfo endpoint (OIDC) docs:

{
    "sub": "OLu859SGc2Sr9ZsqbkG-QbeLgJlb41KcdiPoLYNpSFA",
    "name": "Mikah Ollenburg", // all names require the “profile” scope.
    "family_name": " Ollenburg",
    "given_name": "Mikah",
    "picture": "https://graph.microsoft.com/v1.0/me/photo/$value",
    "email": "[email protected]" // requires the “email” scope.
}

The claims shown in the response are all those that the UserInfo endpoint can return. [...] You can't add to or customize the information returned by the UserInfo endpoint.

andrelaszlo avatar Mar 07 '25 17:03 andrelaszlo

Darn! Microsoft always has to be awkward.

As a workaround, we expose an event via our logical theme system which allows customization of OIDC data, as shown here: https://www.bookstackapp.com/blog/bookstack-release-v23-05/#oidc-id-token-logical-theme-event

We provide the access token data as part of that. It should be possible to use that to perform the further fetch from Entra (assuming to the claim src endpoint) to gain full group data, then supplement the ID token data with fetched groups.

ssddanbrown avatar Mar 07 '25 22:03 ssddanbrown

For the record, I'm also experiencing this exact issue. Unfortunately, I'm already using this logical theme hack and would have to implement a workaround into my existing logical theme.

Given the existing issues, wouldn't it make sense to implement this directly in Bookstack?

To be honest, when initially setting up Bookstack and dealing with all sorts of different variables (groups, roles, id-connectors, creds, ..), I really wouldn't have thought, that my issue originates from a group limitation, called "Groups overage claim".. My assumption here is, that I am not the only one who didn't realize this and that the actual demand for this implementation is significantly higher.

Tomblarom avatar Dec 10 '25 12:12 Tomblarom

Here's a logical theme solution, which combines the existing avatar hack, with extra logic to fetch group data over the Graph API where not provided in the ID token:

Note: This does NOT follow the OIDC spec using distributed claims (see next section below).

functions.php code

<?php

use BookStack\Facades\Theme;
use BookStack\Http\HttpRequestService;
use BookStack\Theming\ThemeEvents;
use BookStack\Uploads\UserAvatars;
use BookStack\Users\Models\User;
use GuzzleHttp\Psr7\Request;

// Variable to track the access token for later use
$accessToken = '';

// Listen for the OIDC ID token validation events so we can capture the access token and check
// if we need to perform additional lookups for user groups.
Theme::listen(ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE, function (array $idTokenData, array $accessTokenData) use (&$accessToken) {
    $accessToken = $accessTokenData['access_token'] ?? '';

    // Check if there's group data in the response, or if we need to perform a lookup instead
    $needGroupLookup = !isset($idTokenData['groups']) && isset($idTokenData['_claim_names']['groups']);
    if ($needGroupLookup) {
        $idTokenData['groups'] = fetchUserGroupsFromGraphApi($accessToken);
    }

    return $idTokenData;
});

// Listen for the auth register event to download and assign the profile image to the user
Theme::listen(ThemeEvents::AUTH_REGISTER, function (string $authSystem, User $user) use (&$accessToken) {
    if ($authSystem === 'oidc' && $accessToken) {
        downloadAndAssignUserAvatar($user, $accessToken);
    }
});

// Function to fetch the user's groups from the Microsoft Graph API
function fetchUserGroupsFromGraphApi(string $accessToken): array
{
    // Create the HTTP client for fetching the group data
    /** @var HttpRequestService $http */
    $http = app()->make(HttpRequestService::class);
    $client = $http->buildClient(4);

    // Append on the API version since Microsoft seems to require this, yet not provide this in the endpoint.
    $sourceUrl = 'https://graph.microsoft.com/v1.0/me/memberOf';

    // Collect the groups from the Graph API, via a loop to page over the results.
    $groups = [];
    while ($sourceUrl) {
        // Fetch a page of groups via an authorized request
        $response = $client->sendRequest(new Request('GET', $sourceUrl, [
            'Authorization' => 'Bearer ' . $accessToken,
        ]));

        // Gather the response data and break if there's no data
        $responseData = json_decode($response->getBody()->getContents(), true);
        if (!$responseData || !isset($responseData['value'])) {
            break;
        }

        // Extract the IDs from the groups in the response data and add them to those collected.
        // If using groups display names (not recommended), you'd need to change 'id' to 'displayName' in the line below
        $groups = array_merge($groups, array_map(fn($group) => $group['id'], $responseData['value']));
        // Update the source URL to the next page if there is one
        $sourceUrl = $responseData['@odata.nextLink'] ?? '';
    }

    // Return back the user groups
    return $groups;
}

// Function to download and assign the profile image to the user
function downloadAndAssignUserAvatar(User $user, string $accessToken): void
{
    // Create the HTTP client for fetching the profile image
    /** @var HttpRequestService $http */
    $http = app()->make(HttpRequestService::class);
    $client = $http->buildClient(4);

    // Fetch the profile image via an authorized request
    $response = $client->sendRequest(new Request('GET', 'https://graph.microsoft.com/v1.0/me/photo/$value', [
        'Authorization' => 'Bearer ' . $accessToken,
    ]));

    // If the response is successful and the content type is an image, assign the image to the user
    $allowedContentTypes = ['image/jpeg', 'image/png'];
    if ($response->getStatusCode() === 200 && in_array($response->getHeader('Content-Type')[0], $allowedContentTypes)) {
        $avatars = app()->make(UserAvatars::class);
        $extension = explode('/', $response->getHeader('Content-Type')[0])[1];
        $avatars->assignToUserFromExistingData($user, $response->getBody()->getContents(), $extension);
    }
}

To use this I had to add 'Microsoft Graph > GroupMember.Read.All' to the app registration's API permissions, then grant admin consent for this permission. It may be possible to have a tighter-scoped permission, but I'm not familiar enough with Microsoft Graph to know for sure.

Entra with Distributed Claims

I tried to originally follow the OIDC spec, and use the Distributed claims via the source URL provided in the token from Entra. I did not have any luck though going down this road.

On the _claim_sources Entra provides back a graph.windows.net claim source URL. Using this fails, as the URL requires a version number so you'd have to append: ?api-version=1.6 to it manually.

Calling that URL gets further, but fails with: {"odata.error":{"code":"Authentication_MissingOrMalformed","codeForMetrics":"Authentication_MissingOrMalformed","message":{"lang":"en","value":"Access Token missing or malformed."}}}

Even when using the access token from the OIDC flow. It seems that the graph.windows.net API has different authentication requirements.

Modifying the core original access token flow, to request the access token with a 'resource' of 'https://graph.windows.net/', results in an error:

AADSTS9010010: The resource parameter provided in the request doesn't match with the requested scopes. Trace ID: 12c19830-1785-4601-9f16-334d4f581900 Correlation ID: b407a328-24c8-4dc2-a88e-270e3b0c3de2 Timestamp: 2025-12-11 17:02:12Z

At this point I gave up on this path, since it was getting deeper and deeper into edits to adapt existing OIDC flow logic.

The 'graph.windows.net' endpoint is for the Azure AD Graph API, which seems to be deprecated and was fully retired as of August 31, 2025 according to Microsoft's docs.

I tried creating a new application registration in Entra, in case this is something specific to older Entra applications, but the result is the same.

Overall this seems wrong and a potentially broken part of Entra's OIDC support, like this corner case has not been updated since the AzureAD Graph API deprecation. Maybe there is something that's causing this at an Entra application level, but I couldn't find any relevant controls/causes. It's also possible I'm just doing something wrong and haven't spotted my mistake, and this should be much simpler.

If someone has some level of real support communication with Microsoft, I'd welcome you to raise this with them and share back the response!

An alternative workaround for Entra

When exploring the above, I did come across this option when configuring the "Groups" claim in the "Token configuration" part of the app registration:

Image

If this does what I understand, this may be a more sensible solution for some which won't require customizations or non-standard functionality. I'm assuming you could assign groups to the application to filter the user groups to just those which you're actually matching up with BookStack roles.

Unfortunately, I could not test this myself since I get this when attempting to assigned groups to my application:

Groups are not available for assignment due to your Active Directory plan level. You can assign individual users to the application.

I can however assign the application via editing a group's members, but this does not seem to allow the reverse relation.

If you're on a paid Entra plan, you may have better luck with this.

ssddanbrown avatar Dec 11 '25 19:12 ssddanbrown