About the Code<\/strong><\/p>\n\n\n\nThis 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.<\/p>\n\n\n\n
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.<\/p>\n\n\n\n
\t\t\/\/\/ <summary>\n\t\t\/\/\/ Looks for a groups overage claim in an access token and returns the value if found.\n\t\t\/\/\/ <\/summary>\n\t\t\/\/\/ <param name=\"accessToken\"><\/param>\n\t\t\/\/\/ <returns><\/returns>\n\t\tprivate static string Get_GroupsOverageClaimURL(string accessToken)\n {\n\t\t\tJwtSecurityToken token = new JwtSecurityTokenHandler().ReadJwtToken(accessToken);\n\t\t\tstring claim = string.Empty;\n\t\t\tstring sources = string.Empty;\n\t\t\tstring originalUrl = string.Empty;\n\t\t\tstring newUrl = string.Empty;\n\n try\n {\n\t\t\t\t\/\/ use the user id in the new graph url since the old overage link is for aad graph which is being deprecated.\n\t\t\t\tuserId = token.Claims.First(c => c.Type == \"oid\").Value;\n\n\t\t\t\t\/\/ getting the claim name to properly parse from the claim sources but the next 3 lines of code are not needed,\n\t\t\t\t\/\/ just for demonstration purposes only so you can see the original value that was used in the token.\n\t\t\t\tclaim = token.Claims.First(c => c.Type == \"_claim_names\").Value;\n\t\t\t\tsources = token.Claims.First(c => c.Type == \"_claim_sources\").Value;\n\t\t\t\toriginalUrl = sources.Split(\"{\\\"\" + claim.Split(\"{\\\"groups\\\":\\\"\")[1].Replace(\"}\",\"\").Replace(\"\\\"\",\"\") + \"\\\":{\\\"endpoint\\\":\\\"\")[1].Replace(\"}\",\"\").Replace(\"\\\"\", \"\");\n\t\t\t\t\n\t\t\t\t\/\/ make sure the endpoint is specific for your tenant -- .gov for example for gov tenants, etc.\n\t\t\t\tnewUrl = $\"https:\/\/graph.microsoft.com\/v1.0\/users\/{userId}\/memberOf?$orderby=displayName&$count=true\";\n\n\t\t\t\tConsole.WriteLine($\"Original Overage URL: {originalUrl}\");\n\t\t\t\t\/\/Console.WriteLine($\"New URL: {newUrl}\");\n\n\n\t\t\t} catch {\n\t\t\t\t\/\/ no need to do anything because the claim does not exist\n\t\t\t} \n\n\t\t\treturn newUrl;\n }<\/pre>\n\n\n\nThere 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.<\/p>\n\n\n\n
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.<\/p>\n\n\n\n
\t\tstatic AzureConfig _config = null;\n\t\tpublic static AzureConfig AzureSettings\n\t\t{\n\t\t\tget\n\t\t\t{\n\t\t\t\t\/\/ only load this once when the app starts.\n\t\t\t\t\/\/ To reload, you will have to set the variable _config to null again before calling this property\n\t\t\t\tif (_config == null)\n\t\t\t\t{\n\t\t\t\t\t_config = new AzureConfig();\n\t\t\t\t\tIConfiguration builder = new ConfigurationBuilder()\n\t\t\t\t\t\t.SetBasePath(System.IO.Directory.GetCurrentDirectory())\n\t\t\t\t\t\t.AddJsonFile(\"appsettings.json\", optional: true, reloadOnChange: true)\n\t\t\t\t\t\t.Build();\n\n\t\t\t\t\tConfigurationBinder.Bind(builder.GetSection(\"Azure\"), _config);\n\t\t\t\t}\n\n\t\t\t\treturn _config;\n\t\t\t}\n\t\t}<\/pre>\n\n\n\nFor 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.<\/p>\n\n\n\n
using Microsoft.Graph;\n\nusing System;\nusing System.Collections.Generic;\nusing System.Net.Http;\nusing System.Net.Http.Headers;\nusing System.Text;\nusing System.Threading.Tasks;\n\nnamespace MSAL.Net_GroupOveragesClaim.Authentication\n{\n class ManualTokenProvider : IAuthenticationProvider\n {\n string _accessToken;\n\n public ManualTokenProvider ( string accessToken)\n {\n _accessToken = accessToken;\n }\n\n async Task IAuthenticationProvider.AuthenticateRequestAsync(HttpRequestMessage request)\n {\n request.Headers.Authorization = new AuthenticationHeaderValue(\"Bearer\", _accessToken);\n request.Headers.Add(\"ConsistencyLevel\", \"eventual\");\n }\n }\n}<\/pre>\n\n\n\nThe 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.<\/p>\n\n\n\n
\t\t\/\/\/ <summary>\n\t\t\/\/\/ Entry point to make the request to Microsoft graph using the .Net HTTP Client\n\t\t\/\/\/ <\/summary>\n\t\t\/\/\/ <param name=\"graphToken\"><\/param>\n\t\t\/\/\/ <returns><\/returns>\n\t\tprivate static async Task Get_Groups_HTTP_Method(string graphToken, string url)\n {\n\t\t\tList<Group> groupList = new List<Group>();\n\t\t\t\t\t\t\n\t\t\tgroupList = await Graph_Request_viaHTTP(graphToken, url);\n\t\t\tforeach (Group g in groupList)\n\t\t\t{\n\t\t\t\tConsole.WriteLine($\"Group Id: {g.Id} : Display Name: {g.DisplayName}\");\n\t\t\t}\n\t\t}<\/pre>\n\n\n\n\t\t\/\/\/ <summary>\n\t\t\/\/\/ Calls Microsoft Graph via a HTTP request. Handles paging in the request\n\t\t\/\/\/ <\/summary>\n\t\t\/\/\/ <param name=\"user_access_token\"><\/param>\n\t\t\/\/\/ <returns>List of Microsoft Graph Groups<\/returns>\n\t\tprivate static async Task<List<Group>> Graph_Request_viaHTTP(string user_access_token, string url)\n {\n\t\t\tstring json = string.Empty;\n\t\t\t\/\/string url = \"https:\/\/graph.microsoft.com\/v1.0\/me\/memberOf?$orderby=displayName&$count=true\";\n\t\t\tList<Group> groups = new List<Group>();\n\n\t\t\t\/\/ todo: check for the count parameter in the request and add if missing\n\n\t\t\t\/*\n\t\t\t * refer to this documentation for usage of the http client in .net\n\t\t\t * https:\/\/docs.microsoft.com\/en-us\/dotnet\/api\/system.net.http.httpclient?view=net-5.0\n\t\t\t * \n\t\t\t *\/\n\n\t\t\t\/\/ add the bearer token to the authorization header for this request\n\t\t\t_httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue( \"Bearer\", user_access_token);\n\t\t\t\n\t\t\t\/\/ adding the consistencylevel header value if there is a $count parameter in the request as this is needed to get a count\n\t\t\t\/\/ this only needs to be done one time so only add it if it does not exist already. It is case sensitive as well.\n\t\t\t\/\/ if this value is not added to the header, the results will not sort properly -- if that even matters for your scenario\n\t\t\tif(url.Contains(\"&$count\", StringComparison.OrdinalIgnoreCase))\n {\n if (!_httpClient.DefaultRequestHeaders.Contains(\"ConsistencyLevel\"))\n {\n\t\t\t\t\t_httpClient.DefaultRequestHeaders.Add(\"ConsistencyLevel\", \"eventual\");\n }\n }\n\t\t\t\n\t\t\t\/\/ while loop to handle paging\n\t\t\twhile(url != string.Empty)\n {\n\t\t\t\tHttpResponseMessage response = await _httpClient.GetAsync(new Uri(url));\n\t\t\t\turl = string.Empty; \/\/ clear now -- repopulate if there is a nextlink value.\n\n\t\t\t\tif (response.IsSuccessStatusCode)\n\t\t\t\t{\n\t\t\t\t\tjson = await response.Content.ReadAsStringAsync();\n\n\t\t\t\t\t\/\/ Console.WriteLine(json);\n\n\t\t\t\t\tusing (JsonDocument document = JsonDocument.Parse(json))\n\t\t\t\t\t{\n\t\t\t\t\t\tJsonElement root = document.RootElement;\n\t\t\t\t\t\t\/\/ check for the nextLink property to see if there is paging that is occuring for our while loop\n\t\t\t\t\t\tif (root.TryGetProperty(\"@odata.nextLink\", out JsonElement nextPage))\n {\n\t\t\t\t\t\t\turl = nextPage.GetString();\n }\n\t\t\t\t\t\tJsonElement valueElement = root.GetProperty(\"value\"); \/\/ the values\n\n\t\t\t\t\t\t\/\/ loop through each value in the value array\n\t\t\t\t\t\tforeach (JsonElement value in valueElement.EnumerateArray())\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tif (value.TryGetProperty(\"@odata.type\", out JsonElement objtype))\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\/\/ only getting groups -- roles will show up in this graph query as well.\n\t\t\t\t\t\t\t\t\/\/ If you want those too, then remove this if filter check\n\t\t\t\t\t\t\t\tif (objtype.GetString() == \"#microsoft.graph.group\")\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tGroup g = new Group();\n\n\t\t\t\t\t\t\t\t\t\/\/ specifically get each property you want here and populate it in our new group object\n\t\t\t\t\t\t\t\t\tif (value.TryGetProperty(\"id\", out JsonElement id)) { g.Id = id.GetString(); }\n\t\t\t\t\t\t\t\t\tif (value.TryGetProperty(\"displayName\", out JsonElement displayName)) { g.DisplayName = displayName.GetString(); }\n\n\t\t\t\t\t\t\t\t\tgroups.Add(g);\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else\n {\n\t\t\t\t\tConsole.WriteLine($\"Error making graph request:\\n{response.ToString()}\");\n }\n\t\t\t} \/\/ end while loop\n\t\n\t\t\treturn groups;\n }<\/pre>\n\n\n\nIn 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.<\/p>\n\n\n\n
\t\t\/\/\/ <summary>\n\t\t\/\/\/ Entry point to make the request to Microsoft Graph using the Graph sdk and outputs the list to the console.\n\t\t\/\/\/ <\/summary>\n\t\t\/\/\/ <param name=\"graphToken\"><\/param>\n\t\t\/\/\/ <returns><\/returns>\n\t\tprivate static async Task Get_Groups_GraphSDK_Method(string graphToken, bool me_endpoint)\n {\n\t\t\tList<Group> groupList = new List<Group>();\n\n\t\t\tgroupList = await Get_GroupList_GraphSDK(graphToken, me_endpoint);\n\t\t\tforeach (Group g in groupList)\n\t\t\t{\n\t\t\t\tConsole.WriteLine($\"Group Id: {g.Id} : Display Name: {g.DisplayName}\");\n\t\t\t}\n\t\t}<\/pre>\n\n\n\nTo 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.<\/p>\n\n\n\n
\t\t\/\/\/ <summary>\n\t\t\/\/\/ Calls the Me.MemberOf endpoint in Microsoft Graph and handles paging\n\t\t\/\/\/ <\/summary>\n\t\t\/\/\/ <param name=\"graphToken\"><\/param>\n\t\t\/\/\/ <returns>List of Microsoft Graph Groups<\/returns>\n\t\tprivate static async Task<List<Group>> Get_GroupList_GraphSDK(string graphToken, bool use_me_endpoint)\n {\n\n\t\t\tGraphServiceClient client;\n\n\t\t\tAuthentication.ManualTokenProvider authProvider = new Authentication.ManualTokenProvider(graphToken);\n\n\t\t\tclient = new GraphServiceClient(authProvider);\n\t\t\tIUserMemberOfCollectionWithReferencesPage membershipPage = null;\n\n\t\t\tHeaderOption option = new HeaderOption(\"ConsistencyLevel\",\"eventual\");\n\n\t\t\tif (use_me_endpoint)\n {\n if (!client.Me.MemberOf.Request().Headers.Contains(option))\n {\n\t\t\t\t\tclient.Me.MemberOf.Request().Headers.Add(option);\n }\n\n\t\t\t\tmembershipPage = await client.Me.MemberOf\n\t\t\t\t\t.Request()\n\t\t\t\t\t.OrderBy(\"displayName&$count=true\") \/\/ todo: find the right way to add the generic query string value for count\n\t\t\t\t\t.GetAsync();\n } else\n {\n if (!client.Users[userId].MemberOf.Request().Headers.Contains(option))\n {\n\t\t\t\t\tclient.Users[userId].MemberOf.Request().Headers.Add(option);\n }\n\n\t\t\t\tmembershipPage = await client.Users[userId].MemberOf\n\t\t\t\t\t.Request()\n\t\t\t\t\t.OrderBy(\"displayName&$count=true\")\n\t\t\t\t\t.GetAsync();\n }\n\n\t\t\tList<Group> allItems = new List<Group>();\t\t\t\n\t\t\t\n\t\t\tif(membershipPage != null)\n {\n\t\t\t\tforeach(DirectoryObject o in membershipPage)\n {\n\t\t\t\t\tif(o is Group)\n {\n\t\t\t\t\t\tallItems.Add((Group)o);\n }\n }\n\n\t\t\t\twhile (membershipPage.AdditionalData.ContainsKey(\"@odata.nextLink\") && membershipPage.AdditionalData[\"@odata.nextLink\"].ToString() != string.Empty)\n {\n\t\t\t\t\tmembershipPage = await membershipPage.NextPageRequest.GetAsync();\n\t\t\t\t\tforeach (DirectoryObject o in membershipPage)\n\t\t\t\t\t{\n\t\t\t\t\t\tif (o is Group)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tallItems.Add(o as Group);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n }\n\n return allItems;\n\n\t\t}<\/pre>\n\n\n\nRegardless 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<\/a> for more information about paging.<\/p>\n\n\n\nSummary<\/strong><\/p>\n\n\n\nAccess 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.<\/p>\n","protected":false},"excerpt":{"rendered":"
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…<\/p>\n","protected":false},"author":6,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[4,10,11,179,247],"tags":[24,36,45,248,101],"class_list":["post-8278","post","type-post","status-publish","format-standard","hentry","category-authentication-flows","category-microsoftgraph","category-ms-graph-client","category-msal","category-token-claims","tag-access-token","tag-authentication","tag-client-credential-grant","tag-groups-overage-claim","tag-microsoft-graph-api"],"_links":{"self":[{"href":"https:\/\/blogs.aaddevsup.xyz\/wp-json\/wp\/v2\/posts\/8278"}],"collection":[{"href":"https:\/\/blogs.aaddevsup.xyz\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/blogs.aaddevsup.xyz\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/blogs.aaddevsup.xyz\/wp-json\/wp\/v2\/users\/6"}],"replies":[{"embeddable":true,"href":"https:\/\/blogs.aaddevsup.xyz\/wp-json\/wp\/v2\/comments?post=8278"}],"version-history":[{"count":13,"href":"https:\/\/blogs.aaddevsup.xyz\/wp-json\/wp\/v2\/posts\/8278\/revisions"}],"predecessor-version":[{"id":8898,"href":"https:\/\/blogs.aaddevsup.xyz\/wp-json\/wp\/v2\/posts\/8278\/revisions\/8898"}],"wp:attachment":[{"href":"https:\/\/blogs.aaddevsup.xyz\/wp-json\/wp\/v2\/media?parent=8278"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/blogs.aaddevsup.xyz\/wp-json\/wp\/v2\/categories?post=8278"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/blogs.aaddevsup.xyz\/wp-json\/wp\/v2\/tags?post=8278"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}