You have a custom client and a custom API. There is an application registration in Azure AD for each of these apps, one for the custom client, and one for the custom API. You want your users to be able to bundle the consent for these apps.
You might see one of the following errors…
- 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
- AADSTS650052: The app needs access to a service (\”{name}\”) that your organization \”{organization}\” has not subscribed to or enabled. Contact your IT Admin to review the configuration of your service subscriptions.
- This is the old error replaced by the new error below
- 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
Step 1: Configure knownClientApplications for the API app registration
First, you will need to add the custom client app ID to the custom APIs app registration knownClientApplications property…
Step 2: Configure API permissions
Second, make sure all API permissions are configured correctly on the custom client and custom API app registrations. Also make sure that all of the custom API app registration API permissions are also added to the custom client app registration.
Step 3: The sign-in request
Third, your authentication request must use the .default scope. For Microsoft accounts, the scope must be for the custom API. For example. This will also work for school and work accounts.
https://login.microsoftonline.com/common/oauth2/v2.0/authorize
?response_type=code
&Client_id=72333f42-5078-4212-abb2-e4f9521ec76a
&redirect_uri=https://localhost
&scope=openid profile offline_access app_uri_id1/.default
&prompt=consent
However, the client will not be listed as having a permission for the API. This is ok because the client will be listed as knownClientApplications.
If you’re not concerned about supporting Microsoft Accounts, and will only be supporting work and school accounts, then use the following recommended approach. For example…
https://login.microsoftonline.com/common/oauth2/v2.0/authorize
?response_type=code
&Client_id=72333f42-5078-4212-abb2-e4f9521ec76a
&redirect_uri=https://localhost
&scope=openid profile offline_access User.Read https://graph.microsoft.com/.default
&prompt=consent
The Implementation
If using MSAL.Net for example…
String[] consentScope = { "api://ae5a0bbe-d6b3-4a20-867b-c8d9fd442160/.default" };
var loginResult = await clientApp.AcquireTokenInteractive(consentScope)
.WithAccount(account)
.WithPrompt(Prompt.Consent)
.ExecuteAsync();
Keep in mind that consent propagation of new servicePrincipals and permissions may take a little time. Your applications must be able to handle this.
Acquiring tokens for more than one resource
If your custom client will also acquire tokens for another resource like Microsoft Graph, you will need to build logic as attempting to acquire these tokens immediately after the consent may fail.
- Use the default scope
- Track the acquired scopes until the required scope returns
- Add a delay if the result still does not have the required scope.
Note: Currently, if acquireTokenSilent fails, MSAL will force you to perform a successful Interaction before it will allow you to use AcquireTokenSilent again, even if you have a valid refresh token to use.
Here is some sample code to handle this …
public static async Task<AuthenticationResult> GetTokenAfterConsentAsync(string[] resourceScopes)
{
AuthenticationResult result = null;
int retryCount = 0;
int index = resourceScopes[0].LastIndexOf("/");
string resource = String.Empty;
// Determine resource of scope
if (index < 0)
{
resource = "https://graph.microsoft.com";
}
else
{
resource = resourceScopes[0].Substring(0, index);
}
string[] defaultScope = { $"{resource}/.default" };
string[] acquiredScopes = { "" };
string[] scopes = defaultScope;
while (!acquiredScopes.Contains(resourceScopes[0]) && retryCount <= 15)
{
try
{
result = await clientApp.AcquireTokenSilent(scopes, CurrentAccount).WithForceRefresh(true).ExecuteAsync();
acquiredScopes = result.Scopes.ToArray();
if (acquiredScopes.Contains(resourceScopes[0])) continue;
}
catch (Exception e)
{ }
// 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
scopes = scopes == resourceScopes ? defaultScope : resourceScopes;
retryCount++;
// Obvisouly something went wrong
if(retryCount==15)
{
throw new Exception();
}
// MSA tokens do not return scope in expected format when .default is used
int i = 0;
foreach(var acquiredScope in acquiredScopes)
{
if(acquiredScope.IndexOf('/')==0) acquiredScopes[i].Replace("/", $"{resource}/");
i++;
}
Thread.Sleep(2000);
}
return result;
}
On the custom API using the On-behalf-of flow
Similarity, the custom API when trying to acquire tokens for another resource might fail immediately after consent.
while (result == null && retryCount >= 6)
{
UserAssertion assertion = new UserAssertion(accessToken);
try
{
result = await apiMsalClient.AcquireTokenOnBehalfOf(scopes, assertion).ExecuteAsync();
}
catch { }
retryCount++;
if (result == null)
{
Thread.Sleep(1000 * retryCount * 2);
}
}
If (result==null) return new HttpStatusCodeResult(HttpStatusCode.Forbidden, "Need Consent");
If this continues to fail and you run out of retries, its probably a good time to throw an error to the client and have it perform a full consent.
Example of client code assuming your API throws a 403…
HttpResponseMessage apiResult = null;
apiResult = await MockApiCall(result.AccessToken);
if(apiResult.StatusCode==HttpStatusCode.Forbidden)
{
var authResult = await clientApp.AcquireTokenInteractive(apiDefaultScope)
.WithAccount(account)
.WithPrompt(Prompt.Consent)
.ExecuteAsync();
CurrentAccount = authResult.Account;
// Retry API call
apiResult = await MockApiCall(result.AccessToken);
}
Recommendations and expected behavior
Building an app for handling bundled consent is not as straight forward. Preferably you have a separate process you can walk your users through to perform this bundled consent, provision your app and API within their tenant or on their Microsoft Account and only get the consent experience once. (Separate from actually signing into the app.) If you don’t have this process and trying to build it into your app and your sign in experience, it gets messy and your users will have multiple consent prompts. I would recommend that you build a experience within your app that warns users they may get prompted to consent (multiple times).
For Microsoft Accounts, I would expect minimum of two consent prompts. One for the application, and one for the API.
For work and school accounts, I would expect only one consent prompt. Azure AD handles bundled consent much better than Microsoft Accounts.
Here is a end to end example sample of code. This has a pretty good user experience considering trying to support all account types and only prompting consent if required. Its not perfect as perfect is virtually non-existent.
string[] msGraphScopes = { "User.Read", "Mail.Send", "Calendar.Read" }
String[] apiScopes = { "api://ae5a0bbe-d6b3-4a20-867b-c8d9fd442160/access_as_user" };
String[] msGraphDefaultScope = { "https://graph.microsoft.com/.default" };
String[] apiDefaultScope = { "api://ae5a0bbe-d6b3-4a20-867b-c8d9fd442160/.default" };
var accounts = await clientApp.GetAccountsAsync();
IAccount account = accounts.FirstOrDefault();
AuthenticationResult msGraphTokenResult = null;
AuthenticationResult apiTokenResult = null;
try
{
msGraphTokenResult = await clientApp.AcquireTokenSilent(msGraphScopes, account).ExecuteAsync();
apiTokenResult = await clientApp.AcquireTokenSilent(apiScopes, account).ExecuteAsync();
}
catch (Exception e1)
{
string catch1Message = e1.Message;
string catch2Message = String.Empty;
try
{
// First possible consent experience
var result = await clientApp.AcquireTokenInteractive(apiScopes)
.WithExtraScopesToConsent(msGraphScopes)
.WithAccount(account)
.ExecuteAsync();
CurrentAccount = result.Account;
msGraphTokenResult = await clientApp.AcquireTokenSilent(msGraphScopes, CurrentAccount).ExecuteAsync();
apiTokenResult = await clientApp.AcquireTokenSilent(apiScopes, CurrentAccount).ExecuteAsync();
}
catch(Exception e2)
{
catch2Message = e2.Message;
};
if(catch1Message.Contains("AADSTS650052") || catch2Message.Contains("AADSTS650052") || catch1Message.Contains("AADSTS70000") || catch2Message.Contains("AADSTS70000"))
{
// Second possible consent experience
var result = await clientApp.AcquireTokenInteractive(apiDefaultScope)
.WithAccount(account)
.WithPrompt(Prompt.Consent)
.ExecuteAsync();
CurrentAccount = result.Account;
msGraphTokenResult = await GetTokenAfterConsentAsync(msGraphScopes);
apiTokenResult = await GetTokenAfterConsentAsync(apiScopes);
}
}
// Call API
apiResult = await MockApiCall(apiTokenResult.AccessToken);
var contentMessage = await apiResult.Content.ReadAsStringAsync();
if(apiResult.StatusCode==HttpStatusCode.Forbidden)
{
var result = await clientApp.AcquireTokenInteractive(apiDefaultScope)
.WithAccount(account)
.WithPrompt(Prompt.Consent)
.ExecuteAsync();
CurrentAccount = result.Account;
// Retry API call
apiResult = await MockApiCall(result.AccessToken);
}
If anyone has better ideas or solutions, please comment on this post.