Azure AD has a maximum number of groups that can be returned in an access token when you have selected to include the groups claim for your access token. This post will show you how to reproduce the scenario and then how to get the users groups using Microsoft Graph when a groups overage claim is present in the token instead of actual groups.
For a JWT token, Azure has a limit of 200 groups that can be present in the token. If the user is a member of more than 200 groups when requesting an access token for the resource that has the groups claim configured on it, instead of getting the groups you will get an overage claim, which is basically a URL that should be called to get the groups list instead. Please read about the “groups” claim in this document. The current groups claim is using the AAD Graph endpoint ( https://graph.windows.net…. ) and since this endpoint is being deprecated, we are transforming the claim to be the Microsoft Graph endpoint instead.
Configure access tokens for the groups claim
Optional claims can be configured from the Azure Portal to include Groups. Please see this document to enable the groups claim. For access tokens, you would need to configure the groups claim in the app registration for your API. If this is a 1st party app ( Microsoft App ) you will not be able to configure the groups claims — you will only be able to configure this with your own app registration. If you want the claim in the client application, you would have to configure it in the ID token instead.
Reproduce the scenario
Step 1: Configure an app registration for this sample app.
Since the application will be performing both a public client flow and a confidential client flow, you will need to configure a web redirect ( for the public client flow ) and a client secret ( for the confidential client flow ). As well, the confidential client version will need the Microsoft Graph application permission of Group.Read.All. The reason being, the confidential client must go to the users endpoint and look up the groups based on a user id, which we get from the initial sign-in token. The public client will just go to the ‘me’ endpoint, since there is a user context.
Step 2: Run the PowerShell script to recreate the scenario ( if needed )
The sample project has a text file called Create_TestGroup.ps1.txt. You can download it from the Git Repository here. To run the script, remove the .txt extension. Also, before running it, you will need an object id of a user to add to the test groups. You must be in a directory role that can create groups and add users to the groups. The sample will create 255 groups with a format of “TEST_0001”, “TEST_0002”, etc. and the object id will be added that you provide to each group.
At the bottom of the script, you will see that it will log you into Azure, then run the command to create the groups and then log you back out. There is a sample cleanup method that is commented out at line 53:
Sample project
The sample project can be located in this Git Repository.
The sample project must first be configured to work with your tenant. Once that is configured, you will need to update the “appsettings.json” file with the appropriate values:
For the AppScopes, the key is that you must have a scope for which the groups claim has been configured. Normally, this would be an api in your tenant but in this case, adding the Azure SQL database with the user_impersonation permission works for this scenario and the scope I have works with that API. This is because the groups claim has been configured on that API already.
For the Graph Scopes, add the application permissions “Group.Read.All” ( needed to get the group display name ) and “User.Read.All” ( needed to get the groups list using the client credentials flow ) You must provide admin consent for those permissions. The delegated permission “User.Read” should already be there but if not, please add that as well.
Once the app registration is configured, plug in the client id ( application id ), client secret, tenant id into the .net applications appsettings.json file.
Running the application
The application is a console application so authentication will happen in a browser window. Once you have signed in, you can close that browser window and you will then be brought back to the .Net console application.
The access token is presented so you can copy that to the clipboard and go to https://jwt.ms and paste it there to see the encoded token. It will just be a user token. However, if the groups overage claim is present in that token because the user is a member of too many groups, then the console app will display the original group overage url as well as the new group overage url that will be used in the http client request. That endpoint will not be used in the graph sdk request. The next step in the flow of the application is to get an access token for Microsoft Graph. You can get an access token using the currently signed in users refresh token ( a delegated token ) or get an access token using the client credentials grant flow ( an application token ). Make a selection for the type of token you want to get.
Next, you will then be given a chance as to choice which method to use to get the groups. This sample is going to demonstrate both using the .Net HTTP Client and also the Graph SDK client.
The groups will then be displayed in the console window:
About the Code
This application is using MSAL.Net ( Microsoft.Identity.Client ) for authenticating the user and acquiring access tokens. It is using System.Net.Http for the http client and Microsoft.Graph sdk for the graph client. To parse the JSON, System.Text.Json and for getting the claims from the token, it is using System.IdentityModel.Tokens.Jwt.
Getting the groups overage claim in the token is done with the JwtSecurityToken provider. The premise of the method I have to get the claim is simple, if there exists in the token the claims “_claim_names” and “_claim_sources” then there is a group overages claim in the token and I simply get the user id ( oid ) and build the url to call for the groups list and return that value. For educational purposes only, I am displaying the original value in the console. If either of those 2 values do not exist, then the try/catch block will handle the error and a string.empty value is returned instead to let the caller know that there is not a groups overage claim in the token.
/// <summary> /// Looks for a groups overage claim in an access token and returns the value if found. /// </summary> /// <param name="accessToken"></param> /// <returns></returns> private static string Get_GroupsOverageClaimURL(string accessToken) { JwtSecurityToken token = new JwtSecurityTokenHandler().ReadJwtToken(accessToken); string claim = string.Empty; string sources = string.Empty; string originalUrl = string.Empty; string newUrl = string.Empty; try { // use the user id in the new graph url since the old overage link is for aad graph which is being deprecated. userId = token.Claims.First(c => c.Type == "oid").Value; // getting the claim name to properly parse from the claim sources but the next 3 lines of code are not needed, // just for demonstration purposes only so you can see the original value that was used in the token. claim = token.Claims.First(c => c.Type == "_claim_names").Value; sources = token.Claims.First(c => c.Type == "_claim_sources").Value; originalUrl = sources.Split("{\"" + claim.Split("{\"groups\":\"")[1].Replace("}","").Replace("\"","") + "\":{\"endpoint\":\"")[1].Replace("}","").Replace("\"", ""); // make sure the endpoint is specific for your tenant -- .gov for example for gov tenants, etc. newUrl = $"https://graph.microsoft.com/v1.0/users/{userId}/memberOf?$orderby=displayName&$count=true"; Console.WriteLine($"Original Overage URL: {originalUrl}"); //Console.WriteLine($"New URL: {newUrl}"); } catch { // no need to do anything because the claim does not exist } return newUrl; }
There is a public client application configuration for signing in a user and getting access tokens and a Confidential Client Application for signing in as an application and getting access tokens ( the client credentials grant flow ). I am using just a simple ManualTokenProvider for the Graph Service Client to pass the service an access token verses having graph obtain an access token.
There is also an appsettings file and a class to store those settings ( AzureConfig.cs ) during runtime. The public static property AzureSettings will pull the settings from teh config file using a configuration builder ( similar to the asp.netcore applications ). I had to add this in since it isn’t native to a console application.
static AzureConfig _config = null; public static AzureConfig AzureSettings { get { // only load this once when the app starts. // To reload, you will have to set the variable _config to null again before calling this property if (_config == null) { _config = new AzureConfig(); IConfiguration builder = new ConfigurationBuilder() .SetBasePath(System.IO.Directory.GetCurrentDirectory()) .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) .Build(); ConfigurationBinder.Bind(builder.GetSection("Azure"), _config); } return _config; } }
For the Authentication provider for the Graph service client, I will just be using a custom manual token provider so that I can set the access token for the client since I am already obtaining access tokens using MSAL.
using Microsoft.Graph; using System; using System.Collections.Generic; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Threading.Tasks; namespace MSAL.Net_GroupOveragesClaim.Authentication { class ManualTokenProvider : IAuthenticationProvider { string _accessToken; public ManualTokenProvider ( string accessToken) { _accessToken = accessToken; } async Task IAuthenticationProvider.AuthenticateRequestAsync(HttpRequestMessage request) { request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken); request.Headers.Add("ConsistencyLevel", "eventual"); } } }
The HTTP method has 2 parts, the method “Get_Groups_Http_Method” will call “Graph_Request_viaHTTP” to get the list of groups and then display’s that list in the console window.
/// <summary> /// Entry point to make the request to Microsoft graph using the .Net HTTP Client /// </summary> /// <param name="graphToken"></param> /// <returns></returns> private static async Task Get_Groups_HTTP_Method(string graphToken, string url) { List<Group> groupList = new List<Group>(); groupList = await Graph_Request_viaHTTP(graphToken, url); foreach (Group g in groupList) { Console.WriteLine($"Group Id: {g.Id} : Display Name: {g.DisplayName}"); } }
/// <summary> /// Calls Microsoft Graph via a HTTP request. Handles paging in the request /// </summary> /// <param name="user_access_token"></param> /// <returns>List of Microsoft Graph Groups</returns> private static async Task<List<Group>> Graph_Request_viaHTTP(string user_access_token, string url) { string json = string.Empty; //string url = "https://graph.microsoft.com/v1.0/me/memberOf?$orderby=displayName&$count=true"; List<Group> groups = new List<Group>(); // todo: check for the count parameter in the request and add if missing /* * refer to this documentation for usage of the http client in .net * https://docs.microsoft.com/en-us/dotnet/api/system.net.http.httpclient?view=net-5.0 * */ // add the bearer token to the authorization header for this request _httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue( "Bearer", user_access_token); // adding the consistencylevel header value if there is a $count parameter in the request as this is needed to get a count // this only needs to be done one time so only add it if it does not exist already. It is case sensitive as well. // if this value is not added to the header, the results will not sort properly -- if that even matters for your scenario if(url.Contains("&$count", StringComparison.OrdinalIgnoreCase)) { if (!_httpClient.DefaultRequestHeaders.Contains("ConsistencyLevel")) { _httpClient.DefaultRequestHeaders.Add("ConsistencyLevel", "eventual"); } } // while loop to handle paging while(url != string.Empty) { HttpResponseMessage response = await _httpClient.GetAsync(new Uri(url)); url = string.Empty; // clear now -- repopulate if there is a nextlink value. if (response.IsSuccessStatusCode) { json = await response.Content.ReadAsStringAsync(); // Console.WriteLine(json); using (JsonDocument document = JsonDocument.Parse(json)) { JsonElement root = document.RootElement; // check for the nextLink property to see if there is paging that is occuring for our while loop if (root.TryGetProperty("@odata.nextLink", out JsonElement nextPage)) { url = nextPage.GetString(); } JsonElement valueElement = root.GetProperty("value"); // the values // loop through each value in the value array foreach (JsonElement value in valueElement.EnumerateArray()) { if (value.TryGetProperty("@odata.type", out JsonElement objtype)) { // only getting groups -- roles will show up in this graph query as well. // If you want those too, then remove this if filter check if (objtype.GetString() == "#microsoft.graph.group") { Group g = new Group(); // specifically get each property you want here and populate it in our new group object if (value.TryGetProperty("id", out JsonElement id)) { g.Id = id.GetString(); } if (value.TryGetProperty("displayName", out JsonElement displayName)) { g.DisplayName = displayName.GetString(); } groups.Add(g); } } } } } else { Console.WriteLine($"Error making graph request:\n{response.ToString()}"); } } // end while loop return groups; }
In a similar fashion, the Graph sdk has an entry method ( “Get_Groups_GraphSDK_Method” ) that will call “Get_GroupList_GraphSDK” to get the list of groups and then display them in the console window.
/// <summary> /// Entry point to make the request to Microsoft Graph using the Graph sdk and outputs the list to the console. /// </summary> /// <param name="graphToken"></param> /// <returns></returns> private static async Task Get_Groups_GraphSDK_Method(string graphToken, bool me_endpoint) { List<Group> groupList = new List<Group>(); groupList = await Get_GroupList_GraphSDK(graphToken, me_endpoint); foreach (Group g in groupList) { Console.WriteLine($"Group Id: {g.Id} : Display Name: {g.DisplayName}"); } }
To get the group list, there is logic to determine if we are going to use the “me” endpoint to get the group list or the “users” endpoint. If you used the client credentials grant flow to get the access token for Microsoft Graph, then it will be using the “users” endpoint. If not ( i.e. a delegated flow was used for the access token ) then it will be using the “users” endpoint.
/// <summary> /// Calls the Me.MemberOf endpoint in Microsoft Graph and handles paging /// </summary> /// <param name="graphToken"></param> /// <returns>List of Microsoft Graph Groups</returns> private static async Task<List<Group>> Get_GroupList_GraphSDK(string graphToken, bool use_me_endpoint) { GraphServiceClient client; Authentication.ManualTokenProvider authProvider = new Authentication.ManualTokenProvider(graphToken); client = new GraphServiceClient(authProvider); IUserMemberOfCollectionWithReferencesPage membershipPage = null; HeaderOption option = new HeaderOption("ConsistencyLevel","eventual"); if (use_me_endpoint) { if (!client.Me.MemberOf.Request().Headers.Contains(option)) { client.Me.MemberOf.Request().Headers.Add(option); } membershipPage = await client.Me.MemberOf .Request() .OrderBy("displayName&$count=true") // todo: find the right way to add the generic query string value for count .GetAsync(); } else { if (!client.Users[userId].MemberOf.Request().Headers.Contains(option)) { client.Users[userId].MemberOf.Request().Headers.Add(option); } membershipPage = await client.Users[userId].MemberOf .Request() .OrderBy("displayName&$count=true") .GetAsync(); } List<Group> allItems = new List<Group>(); if(membershipPage != null) { foreach(DirectoryObject o in membershipPage) { if(o is Group) { allItems.Add((Group)o); } } while (membershipPage.AdditionalData.ContainsKey("@odata.nextLink") && membershipPage.AdditionalData["@odata.nextLink"].ToString() != string.Empty) { membershipPage = await membershipPage.NextPageRequest.GetAsync(); foreach (DirectoryObject o in membershipPage) { if (o is Group) { allItems.Add(o as Group); } } } } return allItems; }
Regardless of the method used, the code will handle paging since, by default, only 100 records per page will be returned. Paging is determined via the “@odata.nextLink” value. If there is a value for that property, then that full url is called for the next page of data. See this document for more information about paging.
Summary
Access tokens have a limit to how many groups will appear in the token. When the limit is exceeded, Azure will provide the “groups overage claim” which is a URL to call in order to get the list of groups that normally would have been present in the token. This blog post gives 2 examples how you can obtain the group list for the currently signed in user.