Skip to content

Commit ac4aa3a

Browse files
authored
Merge pull request #9007 from genlin/main5825
AB#5825 bundle-consent-application-registrations
2 parents 8d485ad + 76019f2 commit ac4aa3a

File tree

2 files changed

+281
-0
lines changed

2 files changed

+281
-0
lines changed
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
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)]

support/entra/entra-id/toc.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,8 @@
136136
href: app-integration/error-aadsts900439-usgclientnotsupportedonpublicendpoint.md
137137
- name: Error AADSTS50000 - issuing a token or an issue with our sign-in service
138138
href: app-integration/error-code-aadsts50000-issuing-token-sign-in-service.md
139+
- name: Enable bundle consent for apps
140+
href: app-integration/bundle-consent-application-registrations.md
139141
- name: Troubleshoot signing in to SAML-based single sign-on configured apps
140142
href: app-integration/troubleshoot-sign-in-saml-based-apps.md
141143
- name: Troubleshooting infinite redirection between OIDC app and Entra ID

0 commit comments

Comments
 (0)