|
| 1 | +--- |
| 2 | +title: Enable Bundled Consent for Microsoft Entra ID Applications. |
| 3 | +description: Describes how to bundle consent for Microsoft Entra ID applications. |
| 4 | +ms.reviewer: willfid |
| 5 | +ms.service: entra-id |
| 6 | +ms.date: 05/27/2025 |
| 7 | +ms.custom: sap:Developing or Registering apps with Microsoft identity platform |
| 8 | +--- |
| 9 | +# Bundled consent for Microsoft Entra ID applications |
| 10 | + |
| 11 | +This article discusses how to configure bundled consent for Microsoft Entra ID applications. |
| 12 | + |
| 13 | +## Symptoms |
| 14 | + |
| 15 | +You have a custom client app and a custom API app, and you create app registrations for both apps in Microsoft Entra ID. You configure bundled consent for both apps. In this scenario, you might receive one of the following error messages when you try to sign in to either app: |
| 16 | + |
| 17 | +- AADSTS70000: The request was denied because one or more scopes requested are unauthorized or expired. The user must first sign in and grant the client application access to the requested scope. |
| 18 | + |
| 19 | +- AADSTS650052: The app is trying to access a service\”{app_id}\”(\”app_name\”) that your organization %\”{organization}\” lacks a service principal for. Contact your IT Admin to review the configuration of your service subscriptions or consent to the application in order to create the required service principal. |
| 20 | + |
| 21 | +## Solution |
| 22 | + |
| 23 | +### Step 1: Configure knownClientApplications for the API app registration |
| 24 | + |
| 25 | +Add the custom client app ID to the custom API app registration's `knownClientApplications` property. For more information, see [knownClientApplications attribute](/entra/identity-platform/reference-app-manifest#knownclientapplications-attribute). |
| 26 | + |
| 27 | +### Step 2: Configure API permissions |
| 28 | + |
| 29 | +Make sure that: |
| 30 | + |
| 31 | +- All required API permissions are correctly configured on both the custom client and custom API app registrations. |
| 32 | +- The custom client app registration includes the API permissions that are defined in the custom API app registration. |
| 33 | + |
| 34 | +### Step 3: The sign-in request |
| 35 | + |
| 36 | +Your authentication request must use the `.default` scope for Microsoft Graph. For Microsoft accounts, the scope must be for the custom API. |
| 37 | + |
| 38 | +**Example Request for Microsoft accounts and work or school accounts** |
| 39 | + |
| 40 | +```HTTP |
| 41 | +https://login.microsoftonline.com/common/oauth2/v2.0/authorize |
| 42 | +?response_type=code |
| 43 | +&Client_id=72333f42-5078-4212-abb2-e4f9521ec76a |
| 44 | +&redirect_uri=https://localhost |
| 45 | +&scope=openid profile offline_access app_uri_id1/.default |
| 46 | +&prompt=consent |
| 47 | +``` |
| 48 | + |
| 49 | +> [!NOTE] |
| 50 | +> The client will seem to be lacking permission for the API. This condition is expected because the client is listed as `knownClientApplication`. |
| 51 | +
|
| 52 | +**Example request for work or school accounts only** |
| 53 | + |
| 54 | +```http |
| 55 | +GET https://login.microsoftonline.com/common/oauth2/v2.0/authorize |
| 56 | +?response_type=code |
| 57 | +&client_id=72333f42-5078-4212-abb2-e4f9521ec76a |
| 58 | +&redirect_uri=https://localhost |
| 59 | +&scope=openid profile offline_access User.Read https://graph.microsoft.com/.default |
| 60 | +&prompt=consent |
| 61 | +``` |
| 62 | + |
| 63 | +#### Implementation by using MSAL.NET |
| 64 | + |
| 65 | +```csharp |
| 66 | +String[] consentScope = { "api://ae5a0bbe-d6b3-4a20-867b-c8d9fd442160/.default" }; |
| 67 | +var loginResult = await clientApp.AcquireTokenInteractive(consentScope) |
| 68 | + .WithAccount(account) |
| 69 | + .WithPrompt(Prompt.Consent) |
| 70 | + .ExecuteAsync(); |
| 71 | +``` |
| 72 | + |
| 73 | +Consent propagation for new service principals and permissions can take some time to finish. Your application should successfully handle this delay. |
| 74 | + |
| 75 | +#### Acquire tokens for multiple resources |
| 76 | + |
| 77 | +If your client app has to acquire tokens for another resource, such as Microsoft Graph, you must implement logic to handle potential delays after users consent to the application. Here are some recommendations: |
| 78 | + |
| 79 | +- Use the `.default` scope when you request tokens. |
| 80 | +- Track acquired scopes until the required one is returned. |
| 81 | +- Add a delay if the result still does not have the required scope. |
| 82 | + |
| 83 | +Currently, if `AcquireTokenSilent` fails, MSAL requires a successful interactive authentication before it allows another silent token acquisition. This restriction applies even if a valid refresh token is available. |
| 84 | + |
| 85 | +Here's some sample code that uses retry logic: |
| 86 | + |
| 87 | +```csharp |
| 88 | + public static async Task<AuthenticationResult> GetTokenAfterConsentAsync(string[] resourceScopes) |
| 89 | + { |
| 90 | + AuthenticationResult result = null; |
| 91 | + int retryCount = 0; |
| 92 | + |
| 93 | + int index = resourceScopes[0].LastIndexOf("/"); |
| 94 | + |
| 95 | + string resource = String.Empty; |
| 96 | + |
| 97 | + // Determine resource of scope |
| 98 | + if (index < 0) |
| 99 | + { |
| 100 | + resource = "https://graph.microsoft.com"; |
| 101 | + } |
| 102 | + else |
| 103 | + { |
| 104 | + resource = resourceScopes[0].Substring(0, index); |
| 105 | + } |
| 106 | + |
| 107 | + string[] defaultScope = { $"{resource}/.default" }; |
| 108 | + |
| 109 | + string[] acquiredScopes = { "" }; |
| 110 | + string[] scopes = defaultScope; |
| 111 | + |
| 112 | + while (!acquiredScopes.Contains(resourceScopes[0]) && retryCount <= 15) |
| 113 | + { |
| 114 | + try |
| 115 | + { |
| 116 | + result = await clientApp.AcquireTokenSilent(scopes, CurrentAccount).WithForceRefresh(true).ExecuteAsync(); |
| 117 | + acquiredScopes = result.Scopes.ToArray(); |
| 118 | + if (acquiredScopes.Contains(resourceScopes[0])) continue; |
| 119 | + } |
| 120 | + catch (Exception e) |
| 121 | + { } |
| 122 | + |
| 123 | + // Switch scopes to pass to MSAL on next loop. This tricks MSAL to force AcquireTokenSilent after failure. This also resolves intermittent cachine issue in ESTS |
| 124 | + scopes = scopes == resourceScopes ? defaultScope : resourceScopes; |
| 125 | + retryCount++; |
| 126 | + |
| 127 | + // Obvisouly something went wrong |
| 128 | + if(retryCount==15) |
| 129 | + { |
| 130 | + throw new Exception(); |
| 131 | + } |
| 132 | + |
| 133 | + // MSA tokens do not return scope in expected format when .default is used |
| 134 | + int i = 0; |
| 135 | + foreach(var acquiredScope in acquiredScopes) |
| 136 | + { |
| 137 | + if(acquiredScope.IndexOf('/')==0) acquiredScopes[i].Replace("/", $"{resource}/"); |
| 138 | + i++; |
| 139 | + } |
| 140 | + |
| 141 | + Thread.Sleep(2000); |
| 142 | + } |
| 143 | + |
| 144 | + return result; |
| 145 | + } |
| 146 | +``` |
| 147 | + |
| 148 | +#### About the custom API that uses the On-behalf-of flow |
| 149 | + |
| 150 | +Similar to the client app, when your custom API tries to acquire tokens for another resource by using the On-Behalf-Of (OBO) flow, it might fail immediately after consent. To resolve this issue, you can implement retry logic and scope tracking, as in the following sample code: |
| 151 | + |
| 152 | +```csharp |
| 153 | +while (result == null && retryCount >= 6) |
| 154 | + { |
| 155 | + UserAssertion assertion = new UserAssertion(accessToken); |
| 156 | + try |
| 157 | + { |
| 158 | + result = await apiMsalClient.AcquireTokenOnBehalfOf(scopes, assertion).ExecuteAsync(); |
| 159 | + |
| 160 | + } |
| 161 | + catch { } |
| 162 | + |
| 163 | + retryCount++; |
| 164 | + |
| 165 | + if (result == null) |
| 166 | + { |
| 167 | + Thread.Sleep(1000 * retryCount * 2); |
| 168 | + } |
| 169 | + } |
| 170 | + |
| 171 | +If (result==null) return new HttpStatusCodeResult(HttpStatusCode.Forbidden, "Need Consent"); |
| 172 | +``` |
| 173 | + |
| 174 | +If all retries fail, return an error message, and then instruct the client to start a full consent process. |
| 175 | + |
| 176 | +**Example of client code that assumes your API throws a 403** |
| 177 | + |
| 178 | +```csharp |
| 179 | +HttpResponseMessage apiResult = null; |
| 180 | +apiResult = await MockApiCall(result.AccessToken); |
| 181 | + |
| 182 | +if(apiResult.StatusCode==HttpStatusCode.Forbidden) |
| 183 | +{ |
| 184 | + var authResult = await clientApp.AcquireTokenInteractive(apiDefaultScope) |
| 185 | + .WithAccount(account) |
| 186 | + .WithPrompt(Prompt.Consent) |
| 187 | + .ExecuteAsync(); |
| 188 | + CurrentAccount = authResult.Account; |
| 189 | + |
| 190 | + // Retry API call |
| 191 | + apiResult = await MockApiCall(result.AccessToken); |
| 192 | +} |
| 193 | +``` |
| 194 | + |
| 195 | +## Recommendations and expected behavior |
| 196 | + |
| 197 | +Ideally, you would create a separate flow that takes the following actions: |
| 198 | +- Guides users through the consent process |
| 199 | +- Provisions your app and API in their tenant or Microsoft account |
| 200 | +- Completes consent in a single step that's separate from signing in |
| 201 | + |
| 202 | +If you don't separate this flow, and instead combine it with your app's sign-in experience, the process can become confusing. Users might encounter multiple consent prompts. To improve the experience, consider adding a message in your app to inform users that they might be asked to consent more than one time: |
| 203 | + |
| 204 | +- For Microsoft accounts, expect at least two consent prompts: one for the client app and one for the API. |
| 205 | +- Typically, for work or school accounts, only one consent prompt is required. |
| 206 | + |
| 207 | +The following is an end-to-end code sample that demonstrates a smooth user experience. This code supports all account types and prompts for consent only when necessary. |
| 208 | + |
| 209 | +```csharp |
| 210 | +string[] msGraphScopes = { "User.Read", "Mail.Send", "Calendar.Read" } |
| 211 | +String[] apiScopes = { "api://ae5a0bbe-d6b3-4a20-867b-c8d9fd442160/access_as_user" }; |
| 212 | +String[] msGraphDefaultScope = { "https://graph.microsoft.com/.default" }; |
| 213 | +String[] apiDefaultScope = { "api://ae5a0bbe-d6b3-4a20-867b-c8d9fd442160/.default" }; |
| 214 | + |
| 215 | +var accounts = await clientApp.GetAccountsAsync(); |
| 216 | +IAccount account = accounts.FirstOrDefault(); |
| 217 | + |
| 218 | +AuthenticationResult msGraphTokenResult = null; |
| 219 | +AuthenticationResult apiTokenResult = null; |
| 220 | + |
| 221 | +try |
| 222 | +{ |
| 223 | + msGraphTokenResult = await clientApp.AcquireTokenSilent(msGraphScopes, account).ExecuteAsync(); |
| 224 | + apiTokenResult = await clientApp.AcquireTokenSilent(apiScopes, account).ExecuteAsync(); |
| 225 | +} |
| 226 | +catch (Exception e1) |
| 227 | +{ |
| 228 | + |
| 229 | + string catch1Message = e1.Message; |
| 230 | + string catch2Message = String.Empty; |
| 231 | + |
| 232 | + try |
| 233 | + { |
| 234 | + // First possible consent experience |
| 235 | + var result = await clientApp.AcquireTokenInteractive(apiScopes) |
| 236 | + .WithExtraScopesToConsent(msGraphScopes) |
| 237 | + .WithAccount(account) |
| 238 | + .ExecuteAsync(); |
| 239 | + CurrentAccount = result.Account; |
| 240 | + msGraphTokenResult = await clientApp.AcquireTokenSilent(msGraphScopes, CurrentAccount).ExecuteAsync(); |
| 241 | + apiTokenResult = await clientApp.AcquireTokenSilent(apiScopes, CurrentAccount).ExecuteAsync(); |
| 242 | + } |
| 243 | + catch(Exception e2) |
| 244 | + { |
| 245 | + catch2Message = e2.Message; |
| 246 | + }; |
| 247 | + |
| 248 | + if(catch1Message.Contains("AADSTS650052") || catch2Message.Contains("AADSTS650052") || catch1Message.Contains("AADSTS70000") || catch2Message.Contains("AADSTS70000")) |
| 249 | + { |
| 250 | + // Second possible consent experience |
| 251 | + var result = await clientApp.AcquireTokenInteractive(apiDefaultScope) |
| 252 | + .WithAccount(account) |
| 253 | + .WithPrompt(Prompt.Consent) |
| 254 | + .ExecuteAsync(); |
| 255 | + CurrentAccount = result.Account; |
| 256 | + msGraphTokenResult = await GetTokenAfterConsentAsync(msGraphScopes); |
| 257 | + apiTokenResult = await GetTokenAfterConsentAsync(apiScopes); |
| 258 | + } |
| 259 | +} |
| 260 | + |
| 261 | +// Call API |
| 262 | +
|
| 263 | +apiResult = await MockApiCall(apiTokenResult.AccessToken); |
| 264 | +var contentMessage = await apiResult.Content.ReadAsStringAsync(); |
| 265 | + |
| 266 | +if(apiResult.StatusCode==HttpStatusCode.Forbidden) |
| 267 | +{ |
| 268 | + var result = await clientApp.AcquireTokenInteractive(apiDefaultScope) |
| 269 | + .WithAccount(account) |
| 270 | + .WithPrompt(Prompt.Consent) |
| 271 | + .ExecuteAsync(); |
| 272 | + CurrentAccount = result.Account; |
| 273 | + |
| 274 | + // Retry API call |
| 275 | + apiResult = await MockApiCall(result.AccessToken); |
| 276 | +} |
| 277 | +``` |
| 278 | + |
| 279 | +[!INCLUDE [Azure Help Support](../../../includes/azure-help-support.md)] |
0 commit comments