Closed SanderDevCode closed 4 years ago
Hello @swannet ... I'll look into this as soon as I can. We're busy with .NET 5 and other work right now. I'll try to look at this sometime this week. In the meantime if you figure it out, please post back on this issue what the problem was.
Ok ... back. So ...
I searched engineering issues, and I didn't see anything on this subject. Azure docs imply that with the implicit auth flow that one ... "probably" is the word that I think they used ... "probably" needs to call Graph API.
I know that you can make a Graph API call in the Server app, get a user's roles/groups for claims, and then have policies set based on the claims. What a pain tho, right?!! :smile: lol
I tried an approach looking at Azure AD docs where you specify optionalClaims
in the app's manifest (in the Azure portal) trying to get Azure AD to place a claim into the access token (for example, groups
) ...
"optionalClaims": {
"idToken": [],
"accessToken": [
{
"name": "groups",
"source": null,
"essential": false,
"additionalProperties": []
}
],
"saml2Token": []
},
Alas, it didn't work. :cry:
I'll leave this on the issue on the Blazor.Docs project for triage so that Artak can take a look at it.
The question is ...... For a hosted Blazor app with Azure AD, is the only way for the Server app to get the user's roles and groups via its own Graph API call? ... or is there a way to get those claims set up in the access token in a more automatic way so that the Server app receives them as claims and then the Server app merely needs one or more policies set up?
I did a bit more research on this today: Various remarks around the Net indicate that implicit flow OAuth 2.0 doesn't support the groups
claim in access tokens. I see devs in various places having their server API app call Graph API in OpenIdConnectOptions
> OnTokenValidated
and setting user claims that way. Then, policies would make quick work of authorizing controllers in the app.
I'll hack around with it in my test app and report back.
The ask is to document how to enforce Azure AD roles+groups for Server API controller calls with Graph API in the hosted Blazor with Azure AD scenario. This is a working approach based on ADAL (v1.0) bits and packages, which is supported until late 2022 but not recommended for new development. When RC1 lands, there will be a new approach (new topic version) for this scenario using the Identity Platform v2.0 API and packages.
Original Post: 8/21
Code Updated: 8/23 - Used a different approach to set up the GraphServiceClient
where the auth header is handed to the Request
instead of being configured in a DelegateAuthenticationProvider
. Include improved error trapping.
Code Updated: 8/24 - Return to DelegateAuthenticationProvider
and align closer to MS examples.
Code Updated: 8/24 - Institute use of the ADAL token cache (check for silent token acquisition first) before making a user assertion with a client credential. After a token is in the cache, silent token acquisition works.
This isn't suitable for production use until it receives the approval of Azure AD/Identity security pros who, unlike me 😨, actually know what they're doing! I'll ask Javier to look at this after RC1 lands.
This is definitely 💀 Use At Your Own Risk:tm: 💀 until then. ......... but it's fairly close to current MS published examples, which any dev can confirm via Identity/ADAL v1.0 topics.
The approach in this section only works for Azure built-in Administrator Roles and security groups, which are obtained by an ADAL (v1.0) call to MemberOf
. The Server API app has no understanding of a client app's user-defined roles. I'll consider expanding this for user-defined roles later.
Billing Administrator
role with an ObjectId of 69ff516a-b57d-4697-a429-9de4af7b5609
. I'll use that to see if I can authorize them to access the WeatherForecastController
with an authn policy.Directory.Read.All
, which should be the least privileged access level for security groups (IIRC). Make sure you grant Admin consent to it after making the permission assignment.ClientSecret
.Add packages to the Server app ...
<PackageReference Include="Microsoft.Graph" Version="3.10.0" />
<PackageReference Include="Microsoft.IdentityModel.Clients.ActiveDirectory" Version="5.2.8" />
Add a ClientSecret
entry with the Server app's client secret from the Azure portal.
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"Domain": "XXXXXXXXXXXX.onmicrosoft.com",
"TenantId": "{GUID}",
"ClientId": "{GUID}",
"ClientSecret": "{CLIENT SECRET}"
},
Set up the policy in Startup.ConfigureServices
of the Server app ...
services.AddAuthorization(options =>
{
options.AddPolicy("BillingAdmin", policy => policy.RequireClaim("group", "69ff516a-b57d-4697-a429-9de4af7b5609"));
});
Require the policy for weather data on the WeatherForecastController
in the Server app ...
[Authorize(Policy = "BillingAdmin")]
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
...
}
In Startup.ConfigureServices
use the following code.
This is going to be the legacy approach with ADAL (Identity v1.0). This will change considerably for Identity v2.0 with .NET 5. I'll start working up the new coverage at RC1, which will be in late September.
Full namespaces for the Startup
class ...
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.AzureAD.UI;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Graph;
using Microsoft.IdentityModel.Clients.ActiveDirectory;
using Microsoft.IdentityModel.Logging;
Inside Startup.ConfigureServices
...
#if DEBUG
// For help trobleshooting exceptions ...
IdentityModelEventSource.ShowPII = true;
#endif
services.AddAuthentication(AzureADDefaults.JwtBearerAuthenticationScheme)
.AddAzureADBearer(options => Configuration.Bind("AzureAd", options));
// Original code used the AzureADDefaults.BearerAuthenticationScheme
services.Configure<JwtBearerOptions>(AzureADDefaults.JwtBearerAuthenticationScheme, options =>
{
options.Events = new JwtBearerEvents()
{
OnAuthenticationFailed = (context) =>
{
// Log the exception
Console.WriteLine($"OnAuthenticationFailed: {context.Exception}");
return Task.CompletedTask;
},
OnTokenValidated = async context =>
{
var accessToken = context.SecurityToken as JwtSecurityToken;
var upn = accessToken.Claims.FirstOrDefault(x => x.Type == "upn")?.Value;
if (!string.IsNullOrEmpty(upn))
{
var authContext = new AuthenticationContext(
$"{Configuration["AzureAd:Instance"]}{Configuration["AzureAd:TenantId"]}");
AuthenticationResult authResult = null;
try
{
authResult = await authContext.AcquireTokenSilentAsync(
"https://graph.microsoft.com", Configuration["AzureAd:ClientId"]);
}
catch (AdalException adalException)
{
if (adalException.ErrorCode == AdalError.FailedToAcquireTokenSilently ||
adalException.ErrorCode == AdalError.UserInteractionRequired)
{
var userAssertion = new UserAssertion(accessToken.RawData,
"urn:ietf:params:oauth:grant-type:jwt-bearer", upn);
var clientCredential = new ClientCredential(Configuration["AzureAd:ClientId"],
Configuration["AzureAd:ClientSecret"]);
authResult = await authContext.AcquireTokenAsync(
"https://graph.microsoft.com", clientCredential, userAssertion);
}
}
var graphClient = new GraphServiceClient(
new DelegateAuthenticationProvider(async requestMessage => {
requestMessage.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", authResult.AccessToken);
await Task.CompletedTask;
}));
var userIdentity = (ClaimsIdentity)context.Principal.Identity;
IUserMemberOfCollectionWithReferencesPage groupsAndAzureRoles = null;
// To prefer direct and transient memberships, use ...
// IUserTransitiveMemberOfCollectionWithReferencesPage groupsAndRoles = null;
try
{
groupsAndAzureRoles = await graphClient.Users[upn].MemberOf.Request()
.GetAsync();
// To prefer direct and transient memberships, use ...
//groupsAndAzureRoles = await graphClient.Users[upn].TransitiveMemberOf
// .Request().GetAsync();
}
catch (ServiceException serviceException)
{
// Log the error
Console.WriteLine(
$"OnTokenValidated: Service Exception: {serviceException.Message}");
}
if (groupsAndAzureRoles != null)
{
foreach (var entry in groupsAndAzureRoles)
{
// Distinguish between built-in Azure Administrator Roles and
// security groups by checking entry.ODataType. The value is
// either "#microsoft.graph.directoryRole" or "#microsoft.graph.group".
userIdentity.AddClaim(new Claim("group", entry.Id));
}
}
}
else
{
// Log missing UPN
Console.WriteLine($"OnTokenValidated: UPN missing: {accessToken.RawData}");
}
await Task.CompletedTask;
}
};
});
🥁 roll plz! ...
FetchData
component page ...:tada: Booyeah! :beers:
If you change that GUID in the policy ... just knock the "9" off of the end of that GUID ... run the app again, log in, go to the FetchData
page ... 💥 403 - Forbidden 💥.
@jmprieur ... Do you have a few minutes to take a look at my ADAL v1.0 on-behalf-of approach for Graph API via a web API backend in a hosted Blazor solution? ... or can you suggest someone who might have a few minutes to take a look at this? I ask because I'm referencing docs for this approach that you've worked on in this area.
This is just to tide us over to .NET 5 GA. I'll have the coverage for the new Blazor templates with Identity v2.0 API and packages worked up for the release (I hope) by mid-October in new hosted Blazor WASM security topics. What I'm working on here is for the current coverage, which is all based on Identity v1.0.
Everything is in just one comment on this issue at :point_up: ...
https://github.com/dotnet/AspNetCore.Docs/issues/19573#issuecomment-678442702
... and it follows the MS examples fairly closely. It seems to run well.
One thing that doesn't match up to MS examples is that I don't attempt to acquire a token silently. I do the following ...
Grab the upn
from the JWT that arrives to the web API ...
var accessToken = context.SecurityToken as JwtSecurityToken;
var upn = accessToken.Claims.FirstOrDefault(x => x.Type == "upn")?.Value;
Use the upn
for my user assertion:
var userAssertion = new UserAssertion(accessToken.RawData, "urn:ietf:params:oauth:grant-type:jwt-bearer", upn);
... and my Graph API call ...
userDefinedRoles = await graphClient.Users[upn].AppRoleAssignments.Request().GetAsync();
If anything fails ... AdalException
, ServiceException
, etc. ... the code merely fails to provide a valid claim, thus the anthn policy fails and the web API controller is inaccessible with the calling app getting back a 403.
If the approach is a 💩 Rex Code Smell:tm: 👃😄, then I'll need a tip on the correct approach. Attempting to acquire a token silently first doesn't quite make sense to me for this scenario (yet ... I might be missing a critical concept on that point). It seems to make more sense to just attempt token acquisition on behalf of the user. If it fails ... 403 back ... if it succeeds ... let the web API run and return the result. Besides all of that, when I tried acquiring a token silently, it always threw :boom: (but perhaps my code was bad).
The whole process is laid out here ...
https://github.com/dotnet/AspNetCore.Docs/issues/19573#issuecomment-678442702
@swannet 2 things:
What is the exact exception?
However: ADAL is being deprecated and Microsoft Identity asks every first party partner to move to MSAL and MIcrosoft Graph. See Alex Simon's blog: https://techcommunity.microsoft.com/t5/azure-active-directory-identity/update-your-applications-to-use-microsoft-authentication-library/ba-p/1257363
So I would really not invest in any article or code sample on ADAL at that point.
@jmprieur
Thanks for the headsup. I can understand it would be better to go for the MSAL and Graph API approach, I followed the guide https://docs.microsoft.com/en-us/aspnet/core/blazor/security/webassembly/hosted-with-azure-active-directory?view=aspnetcore-3.1 for .NET core 3.1 which uses the "Microsoft.AspNetCore.Authentication.AzureAD.UI" package for the backend (ServerAPI) instead of the MSAL approach. The ClientApp is using "Microsoft.Authentication.WebAssembly.Msal"
Correct my if I'm wrong but is your suggestion to use a MSAL approach for the ServerAPI too? If so, is it just as easy as change the packages and setup the code just like the ClientApp project? And will the token be accepted by the ServerAPI that way?
Sorry @swannet ... Don't mean to step on your question. Let me interject in passing ...
that the tenant admin needs to provide admin consent for the Web API to call the downstream API (as API cannot interact with the user) ... You should not need to provide the upn, to my knowledge
Thanks ... it seems then that my code was bad in the silent token acquisition approach. I'll try again.
every first party partner to move to MSAL and MIcrosoft Graph
MSAL.NET relies on Identity v2.0 packages and API, which is still in preview ...
For ASP.NET Core web apps and web APIs, MSAL.NET is encapsulated in a higher level library named Microsoft.Identity.Web
The goal of this approach is to cover an end-to-end approach without the use of preview packages. Support for ADAL runs to 2022.
Yes tho on coverage of MSAL.NET with Identity v2.0 for .NET 5. That's the plan. New docs will cover the use of the new templates, new API, and new packages. The new content will (probably) be up in preview docs by mid-October and final content for release mid-November.
I may have an answer as to why silent token acquisition fails on the first pass and why the silent token call is there at all. Until a token is obtained on behalf of the user the first time, there's no token in the cache for silent token acquisition to occur (imo, not well covered by the docs I was looking at, but it's probably in other docs that I didn't see). Therefore, silent token acquisition should be implemented for future requests. I'll run some additional tests now to confirm that that's the case.
CONFIRMED! ... The behavior is ...
AdalError.FailedToAcquireTokenSilently
or AdalError.UserInteractionRequired
), a user assertion is made with the client credential to obtain the token on behalf of the user. Then, the GraphServiceClient
can go ahead and use the token to make the Graph API call. The token is then put into the cache.I wish I had caught an example that explained it out, but the docs that I hit weren't crystal clear. They relied upon looking at the API and knowing more about web API with OAuth 2.0. My professional experience didn't encompass it.
@swannet ... I've updated the code in the remark :point_up: with the latest approach. Of course, it's still 💀 use at your own risk 💀 until Javier gets free after RC1 is released and can take a look. However, it does follow the MS published examples now very closely.
WRT MSAL.NET (and the new Blazor template with Microsoft.Identity.Web
API+packages), absolutely that's next. I'll get that up in preview documentation shortly after we reach RC1 release. I don't usually doc things that complex until RC1 because I've (and MS) been burned before documenting API that shifts before RC1 lands. It's costly in 🕐 ... thus 💰 ... when it happens.
Hi,
After the documentation issue (https://github.com/dotnet/AspNetCore.Docs/issues/19304). I got the roles and groups correctly in the user-claims (setup as https://docs.microsoft.com/en-us/aspnet/core/blazor/security/webassembly/azure-active-directory-groups-and-roles?view=aspnetcore-3.1).
Authorization works fine in the web app. I can call the server API app (due the API permission and scope) but I can't get the groups and roles as claim in the server API app to authorize the user that calls the API.
Maybe I'm missing something, but I can't get it to work... Is it possible to document a a bit more for this usecase?
Document Details
⚠ Do not edit this section. It is required for docs.microsoft.com ➟ GitHub issue linking.