-
Notifications
You must be signed in to change notification settings - Fork 10.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add Microsoft Identity Platform auth option to the Blazor Web App template #51202
Comments
Thanks for contacting us. We're moving this issue to the |
Seeing this moved to .Net 9 is disappointing. Once again templates won't have a simple and functional way to have a working configuration with Azure ID/Entra, and the current status of the documentation for .Net 8 is also poor, since pages with the details shown for .Net 7 have been removed. |
Unless I'm missing something this is just the server rendering solution. It doesn't support the other render modes such as Auto. |
For now, it is the component with InterectiveServerRenderMode only. For Auto, you need to add code to the Client project. Something like this https://learn.microsoft.com/en-us/aspnet/core/blazor/security/webassembly/standalone-with-azure-active-directory-b2c?view=aspnetcore-7.0 |
I guess the question is how we cover both scenarios that may happen when using auto: the first access is server-side Blazor, the following accesses should be WASM, so the auth workflow changes depending on the render mode or am I missing anything? |
Very disappointing not to see this in .NET 8 given it's an LTS. |
This is the kind of guidance I am missing as well @danroth27 |
That's correct although the the transfer to WASM could be at any point and in an advanced scenario it may even go back. I think it needs keeping in sync between the two modes using something like the PersistingRevalidatingAuthenticationStateProvider implemented in the template. I did notice the HttpClient call for the Weather was also removed from the templates #51204. I suspect this was for similar reasons. The HttpClient could be called from Client (WASM) or Server (which is a little wasteful) so you need to register HttpClient on both. Then when you add Authentication and Authorization you have to keep them in sync without using the HttpContext on the server. I know there is the alternative of two implementations of a weather service one for Client and one for Server but code wise that isn't ideal if you are migrating from WASM with WebAPI backend and looking to quickly get the benefits of Auto mode. It doesn't appear trivial to implement authentication and authorization that works in Auto mode and the templates have neglected to demonstrate it. Essentially anyone using any kind of Auth (most projects?) or HttpClient can't automatically move to Auto mode. If the templates won't be updated soon, some example projects with Authentication and Authorization scenarios would be great. Ideally I would ideally like to see a Microsoft Identity (Entra) login that works in Auto mode and can make HttpClient calls from Client or Server to an APi using authorization. Additionally it would be great if the project demonstrated adding additional claims in code and [AllowAnonymous] attribute applied on some pages. Some changes appear to have happened around AllowAnonymous to make this work? |
@peterthorpe81 if you look at what happens in the new blazor web app when auto and identity with local user accounts are used, you'll see that the server app manages all the authentication work, and the client (wasm) synchronizes the authentication state with the server app. Following this logic, auth in auto mode with Microsoft Identity Platform may be done following a similar logic. 🤔 |
Yes this is what I have been looking at but I don't seem to be able to get a config that works in both render modes. I think one of the differences is that local accounts are authenticated within your site using the scaffolded pages. Microsoft Identify Platform is going out to an external site so the redirect loses the state. |
@peterthorpe81 agreed. One less than ideal way is to have the server authenticate with AD, then store the information in the cookie to pass it to the client, but that means the SPA workflow of AD is ignored entirely also when in WASM mode, which I do not think is a great solution. |
I think if we can get a template for just Interactive Server Per Page/Component, that would be great for now. |
Interactive server should have not changed (see the old template). It is just a web app. Similarly, setting up a hosted WASM web-app. The main difference is when using auto mode (but after a few tests, it seems also in this case changes may not be too deep). |
The key point from my perspective, is that the "old" template only allows choosing .NET 6/7. If one wants to remain on a "supported" version, the only option seems to be to go through the upgrade steps involved ... which is just nowhere near as simple as it has been for previous releases. Further, Visual Studio IntelliSense is breaking when using newer razor components. I've submitted a separate issue via VS for that. |
This issue really breaks Blazor quite a bit for me. |
I agree that this is a must have. I've been struggling with this. |
Switched from ASP.NET Identity to Microsoft Identity because of Duende debacle. Now switching back lol, hope my users don't complain too much. I watched the dotnet conf, I don't think it was made clear enough to me that Asp.net Identity no longer depends on Duende? |
@AlbertoPa does it handle Multi-Tenant Microsoft Entra accounts? This is the one issue that required me to use the "Azure B2C with custom policies" route. |
@ADefWebserver Look up how to do that through Microsoft Partner ID. When you’ve done it - then you get a little blue icon showing you’ve been validated by MS. |
So, you were exactly in my current position (regarding the frustration, that is) - good, at least for me. 😅 But since you're also going the BFF-route: my case is a Blazor WASM standalone app, communicating (excl. MSAL which handles sign-ins without our backend) with the BFF which deals with all authentication & authorization stuff regarding calls to all other backend micro-services - effectively freeing those of doing all that auth stuff over & over again. |
@Eagle3386 if I understand your case correctly, yes. The type of apps you can register and the interaction with the external tenants are more or less the same you can have in b2c (API connectors are different and a bit easier to deal with). I don't follow what "there is no externally accessible system" means though, in your second statement. Does it refer to the API-BFF only? |
@AlbertoPa Since there's no API connector (we're using Azure B2C / would use Entra External ID only for authenticating users & authorization via claims, e.g., email, customer ID, name, roles) & then we're redirecting back to our internal systems - even in case of the "API-BFF", which brings me to your question. What I meant was: there's only the Blazor WASM app's BFF which is externally accessible right now, but there are plans for a future "API-BFF", serving as endpoint for partners using our data/services & those will be required to authenticate via Azure B2C / Entra External ID as well (probably via device code instead of personal access tokens as those are services, not actual persons 😉). |
@Eagle3386 I asked about API connectors because those are used in B2C to enrich the token with custom claims. That said, it would seem that shifting from B2C to an Entra external tenant is feasible (assuming you can configure your external providers as you need. I do not see Apple being supported at the moment, for example). |
@AlbertoPa I see, thanks for the clarification. Since you brought custom claims up: I thought that those could be added on-premise, i.e., after sign-in, the Blazor WASM app passes the token to the BFF which validates it & if authentic, adds claims as required - or does such enrichment explicitly require the configuration as an API connector in Entra & exposing an endpoint of our service to Azure/Entra for that? 🤔 Sorry for the confusing, but that's not really explained well within the docs, hence my questions.. |
@Eagle3386 that is precisely what the "token issuance start" event for an API connector does: https://learn.microsoft.com/en-us/entra/external-id/customers/concept-custom-extensions#token-issuance-start-event. And because keeping the same name would have been too simple (😂), you now must pay attention the fact that "API connectors" and "custom authentication extensions" are the same thing. See here for an overview: https://learn.microsoft.com/en-us/entra/identity-platform/custom-extension-overview?context=%2Fentra%2Fexternal-id%2Fcustomers%2Fcontext%2Fcustomers-context |
I created Blazor Web App sample that uses Microsoft.Identity.Web to connect to Entra/Azure AD B2C. You can take a look at dotnet/blazor-samples#355. Eventually, we plan to turn this into something you can scaffold in VS. Feel free to add feedback on the PR if you have any. |
We think the way we're going to do this is to base this experience on the new dotnet scaffolder experience, so that once the Blazor Web App project is created, the Auth scaffolding experience kicks-off automatically. |
Sounds promising 👌🏻
On 30 Oct 2024, at 18:58, Artak ***@***.***> wrote:
This email was sent to you by someone outside the University.
You should only click on links or attachments if you are certain that the email is genuine and the content is safe.
We think the way we're going to do this is to base this experience on the new dotnet scaffolder experience, so that once the Blazor Web App project is created, the Auth scaffolding experience kicks-off automatically.
@vijayrkn<https://github.com/vijayrkn> let's discuss the details and build the plan for how we can make this work and in which timeframes.
—
Reply to this email directly, view it on GitHub<#51202 (comment)>, or unsubscribe<https://github.com/notifications/unsubscribe-auth/AYPH367EHYKWXK2MDY4NPZDZ6ET5PAVCNFSM6AAAAAA5XEZFZ6VHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDINBYGA4TMMZWGA>.
You are receiving this because you commented.Message ID: ***@***.***>
The University of Edinburgh is a charitable body, registered in Scotland, with registration number SC005336. Is e buidheann carthannais a th’ ann an Oilthigh Dhùn Èideann, clàraichte an Alba, àireamh clàraidh SC005336.
|
Since .NET 9 is out, any update on this guys? |
fwiw, I was able to get this example to work: https://learn.microsoft.com/en-us/aspnet/core/blazor/security/blazor-web-app-with-entra?view=aspnetcore-9.0 The only issue is that when a user logs out the browser just displays a message to close the browser to finish, and does not redirect to the home page (as indicated in the example link). |
Do you have https://localhost/signout-callback-oidc (the default SignedOutCallbackPath) registered as a Redirect URI for your app registration? If you don’t, Entra will refuse to redirect back to your app and just ask to close your browser window. @guardrex, is this something we can add to any documentation we have that deals with Entra auth? This applies to all ASP.NET Core applications using Entra, not just Blazor. Here’s a related issue from a while back. |
The link does not mention anything about https://localhost/signout-callback-oidc; just the https://localhost/signin-oidc web redirect. I just tried adding the second one and I still get the same issue. According to the article: In the app's registration in the Entra or Azure portal, use a Web platform configuration with a Redirect URI of https://localhost/signin-oidc (a port isn't required). |
I just found this other article which appears to indicate this is correct (just give message to close browser): Sign out of the application |
Where is the sample with API call? This article is about Microsoft ID authorized web API call on Blazor Server but not on Blazor Web App. This article is about handle consent with Microsoft ID, but there is no description for Blazor Web App provided. |
@halter73 ... Let's take it up on dotnet/AspNetCore.Docs#34378, where I float sample content for the BWA+Entra article. I also state what the main doc set has thus far on the subject, and I can open an additional issue if there's something for the doc team to address. UPDATE (12/17): PR for that issue is at dotnet/AspNetCore.Docs#34385. |
@marqdouj You definitely can redirect back to your Blazor app after logging out. Is it possible that you tested this with the primary admin user (root account) or external user? The logout redirect is known not to work in that case. Otherwise, things should work fine as long as you register entra-logout-recording.mp4
@RamType0 You can take a look at the Blazor Web App with OpenID Connect (OIDC) and BFF pattern doc (sample code). It uses |
@halter73 I suspect that is the case, I am the only user in AzureAD - personal account for testing. So why does the admin account not work... kinda like a SQL SVR sa thingy? This is relatively new to me, just trying to build my skills. Tried it, could not get it to work 😒 Perhaps it's an Aspire issue? |
Thanks for the response! But what am I facing is incremental consent. |
I have solved incremental consent issue by myself, When we send request via http, it could be solved with BFF forwarding. |
@RamType0 how did you fix the incremental consent issue? I am experiencing the same. |
Shared API definition public interface IApiClient
{
public abstract Task<ApiResult> CallApiAsync(HttpRequestMessage request, HttpStatusCode successCode);
public abstract Task<ApiResult<T>> CallApiAsync<T>(HttpRequestMessage request, HttpStatusCode successCode) where T : notnull;
// API sample
public async Task<ApiResult<User>> GetUserAsync(string userId)
{
return await CallApiAsync<User>(new HttpRequestMessage
{
RequestUri = new($"Users/{userId}", UriKind.Relative),
Method = HttpMethod.Get,
}, HttpStatusCode.OK);
}
}
public record ApiResult<T>
where T : notnull
{
[MemberNotNullWhen(true, nameof(Value))]
public bool Succeeded { get; init; }
public T? Value { get; init; }
[MemberNotNullWhen(true, nameof(ChallengeParameters))]
public bool ChallengeRequired { get; init; }
public ChallengeParameters? ChallengeParameters { get; init; }
// For API failure other than challenge required
public ValidationProblemDetails? ProblemDetails { get; init; }
}
public record ApiResult
{
public bool Succeeded { get; init; }
[MemberNotNullWhen(true, nameof(ChallengeParameters))]
public bool ChallengeRequired { get; init; }
public ChallengeParameters? ChallengeParameters { get; init; }
// For API failure other than challenge required
public ValidationProblemDetails? ProblemDetails { get; init; }
public static ApiResult Success()
{
return new()
{
Succeeded = true,
};
}
public static ApiResult<T> Success<T>(T value)
{
return new()
{
Succeeded = true,
Value = value,
};
}
public static ApiResult Failed(ValidationProblemDetails problemDetails)
{
return new()
{
Succeeded = false,
ProblemDetails = problemDetails,
};
}
public static ApiResult<T> Failed<T>(ValidationProblemDetails problemDetails)
{
return new()
{
Succeeded = false,
ProblemDetails = problemDetails,
};
}
public static ApiResult Challenge(ChallengeParameters challengeParameters)
{
return new()
{
Succeeded = false,
ChallengeRequired = true,
ChallengeParameters = challengeParameters,
};
}
public static ApiResult<T> Challenge<T>(ChallengeParameters challengeParameters)
{
return new()
{
Succeeded = false,
ChallengeRequired = true,
ChallengeParameters = challengeParameters,
};
}
public static async ValueTask<ApiResult> ReadFromResponseAsync(HttpResponseMessage response, HttpStatusCode successCode)
{
if (response.StatusCode == successCode)
{
return Success();
}
if (response.StatusCode == HttpStatusCode.Forbidden && WwwAuthenticateParameters.CreateFromAuthenticationHeaders(response.Headers, "Bearer") is { AuthenticationScheme: "Bearer" } authParams)
{
return Challenge(new()
{
Claims = authParams.Claims,
});
}
if(response.Content.Headers.ContentType?.MediaType is "application/problem+json")
{
var validationProblemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>() ?? throw new FormatException();
return Failed(validationProblemDetails);
}
return Failed(new() { Status = (int)response.StatusCode });
}
public static async ValueTask<ApiResult<T>> ReadFromResponseAsync<T>(HttpResponseMessage response, HttpStatusCode successCode)
{
if (response.StatusCode == successCode)
{
var value = await response.Content.ReadFromJsonAsync<T>() ?? throw new FormatException();
return Success(value);
}
if (response.StatusCode == HttpStatusCode.Forbidden && WwwAuthenticateParameters.CreateFromAuthenticationHeaders(response.Headers, "Bearer") is { AuthenticationScheme: "Bearer" } authParams)
{
return Challenge<T>(new()
{
Claims = authParams.Claims,
});
}
if (response.Content.Headers.ContentType?.MediaType is "application/problem+json")
{
var validationProblemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>() ?? throw new FormatException();
return Failed<T>(validationProblemDetails);
}
return Failed<T>(new() { Status = (int)response.StatusCode });
}
}
public record ChallengeParameters
{
public required string? Claims { get; init; }
} Extension method to begin incremental consent public static class NavigationHelpers
{
public static void Challenge(this NavigationManager navigationManager, ChallengeParameters challengeParameters)
{
var query = HttpUtility.ParseQueryString("");
if (challengeParameters.Claims != null)
{
query.Add("claims", challengeParameters.Claims);
}
query.Add("returnUrl", navigationManager.Uri);
navigationManager.NavigateTo($"Challenge?{query}", forceLoad: true);
}
} ApiClient used for InteractiveWebAssembly render mode public class WebAssemblyApiClient(HttpClient httpClient) : IApiClient
{
public async Task<ApiResult> CallApiAsync(HttpRequestMessage request, HttpStatusCode successCode)
{
// Access token is added during forwarding
var response = await httpClient.SendAsync(request);
return await ApiResult.ReadFromResponseAsync(response, successCode);
}
public async Task<ApiResult<T>> CallApiAsync<T>(HttpRequestMessage request, HttpStatusCode successCode)
where T : notnull
{
// Access token is added during forwarding
var response = await httpClient.SendAsync(request);
return await ApiResult.ReadFromResponseAsync<T>(response, successCode);
}
} ApiClient used for prerendering, InteractiveServer render mode internal class WebServerApiClient(HttpClient httpClient, ITokenAcquisition tokenAcquisition, IOptionsMonitor<WebServerApiClientOptions> optionsMonitor) : IApiClient
{
public async Task<ApiResult> CallApiAsync(HttpRequestMessage request, HttpStatusCode successCode)
{
var options = optionsMonitor.CurrentValue;
try
{
var accessToken = await tokenAcquisition.GetAccessTokenForUserAsync(options.Scopes);
request.Headers.Authorization = new("Bearer", accessToken);
}
catch (MicrosoftIdentityWebChallengeUserException exception)
{
return ApiResult.Challenge(new()
{
Claims = exception.MsalUiRequiredException.Claims,
});
}
catch (MsalUiRequiredException exception)
{
return ApiResult.Challenge(new()
{
Claims = exception.Claims,
});
}
var response = await httpClient.SendAsync(request);
return await ApiResult.ReadFromResponseAsync(response, successCode);
}
public async Task<ApiResult<T>> CallApiAsync<T>(HttpRequestMessage request, HttpStatusCode successCode)
where T : notnull
{
var options = optionsMonitor.CurrentValue;
try
{
var accessToken = await tokenAcquisition.GetAccessTokenForUserAsync(options.Scopes);
request.Headers.Authorization = new("Bearer", accessToken);
}
catch (MicrosoftIdentityWebChallengeUserException exception)
{
return ApiResult.Challenge<T>(new()
{
Claims = exception.MsalUiRequiredException.Claims,
});
}
catch (MsalUiRequiredException exception)
{
return ApiResult.Challenge<T>(new()
{
Claims = exception.Claims,
});
}
var response = await httpClient.SendAsync(request);
return await ApiResult.ReadFromResponseAsync<T>(response, successCode);
}
}
internal class WebServerApiClientOptions
{
[Required]
public required IEnumerable<string> Scopes { get; set; }
} App.Web.Client/Program.cs var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.Services.AddAuthenticationStateDeserialization();
// Don't access API server directly. Use forwarding to add access token.
Uri apiEndpoint = new(new(builder.HostEnvironment.BaseAddress), "Api/");
builder.Services.AddHttpClient<IApiClient, WebAssemblyApiClient>(http =>
{
http.BaseAddress = apiEndpoint;
});
await builder.Build().RunAsync(); App.Web/Program.cs using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Identity.Client;
using Microsoft.Identity.Web;
using Microsoft.Identity.Web.UI;
using Yarp.ReverseProxy.Forwarder;
using Yarp.ReverseProxy.Transforms;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents()
.AddAuthenticationStateSerialization();
var scopes = builder.Configuration.GetSection("Api:Scopes").Get<string[]>() ?? throw new InvalidOperationException();
builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApp(builder.Configuration)
.EnableTokenAcquisitionToCallDownstreamApi(scopes)
.AddDistributedTokenCaches();
builder.Services.AddRazorPages().AddMicrosoftIdentityUI();
builder.Services.AddHttpForwarderWithServiceDiscovery();
builder.Services.AddOptions<WebServerApiClientOptions>().ValidateDataAnnotations().Configure(options => options.Scopes = scopes);
builder.Services.AddHttpClient<IApiClient, WebServerApiClient>(http =>
{
http.BaseAddress = new("https+http://api-service");
});
var app = builder.Build();
app.MapRazorPages();
// For WebAssemblyApiClient
app.MapForwarder("/Api/{**apiPath}", "https://api-service", ForwarderRequestConfig.Empty,
transformBuilderContext
=> transformBuilderContext
.AddPathRemovePrefix("/Api")
.AddRequestTransform(async transformContext =>
{
HttpContext httpContext = transformContext.HttpContext;
var tokenAcquisition = httpContext.RequestServices.GetRequiredService<ITokenAcquisition>();
string accessToken;
try
{
accessToken = await tokenAcquisition.GetAccessTokenForUserAsync(scopes, user: httpContext.User);
}
catch (MicrosoftIdentityWebChallengeUserException exception)
{
await tokenAcquisition.ReplyForbiddenWithWwwAuthenticateHeaderAsync(exception.Scopes, exception.MsalUiRequiredException, httpContext.Response);
return;
}
catch (MsalUiRequiredException exception)
{
await tokenAcquisition.ReplyForbiddenWithWwwAuthenticateHeaderAsync(scopes, exception, httpContext.Response);
return;
}
transformContext.ProxyRequest.Headers.Authorization = new("Bearer", accessToken);
}));
// Call with NavigationManager.Challenge
app.MapGet("/Challenge",
(
[FromServices] IOptionsMonitor<MicrosoftIdentityOptions> optionsMonitor,
ClaimsPrincipal principal,
// Use ChallengeParameters.Claims
[FromQuery] string? claims,
[FromQuery] string? returnUrl
) =>
{
// https://github.com/AzureAD/microsoft-identity-web/blob/9bd521186bf9b00a2af4fc920be8c7f87683a012/src/Microsoft.Identity.Web/MicrosoftIdentityConsentAndConditionalAccessHandler.cs#L160-L216
IEnumerable<string> effectiveScopes = scopes ?? [];
string[] additionalBuiltInScopes =
[
"openid",
"offline_access",
"profile",
];
effectiveScopes = effectiveScopes.Union(additionalBuiltInScopes);
string url = $"/MicrosoftIdentity/Account/Challenge?redirectUri={returnUrl}"
+ $"&scope={string.Join(" ", effectiveScopes)}&loginHint={principal.GetLoginHint()}"
+ $"&domainHint={principal.GetDomainHint()}&claims={claims}"
+ $"&policy={optionsMonitor.CurrentValue.SignUpSignInPolicyId}";
return TypedResults.LocalRedirect(url);
}).RequireAuthorization(); I also found that Yarp forwarding works well for SignalR Hub without any additional setup. |
We don't currently support the Microsoft Identity Platform auth option with the Blazor Web App template. We should add it.
The text was updated successfully, but these errors were encountered: