Skip to content
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

Open
danroth27 opened this issue Oct 7, 2023 · 95 comments
Open
Assignees
Labels
area-blazor Includes: Blazor, Razor Components enhancement This issue represents an ask for new feature or an enhancement to an existing one feature-templates Pillar: Complete Blazor Web Pillar: Dev Experience Priority:0 Work that we can't release without triaged

Comments

@danroth27
Copy link
Member

danroth27 commented Oct 7, 2023

We don't currently support the Microsoft Identity Platform auth option with the Blazor Web App template. We should add it.

@dotnet-issue-labeler dotnet-issue-labeler bot added the area-blazor Includes: Blazor, Razor Components label Oct 7, 2023
@danroth27
Copy link
Member Author

danroth27 commented Oct 7, 2023

@mkArtakMSFT mkArtakMSFT added enhancement This issue represents an ask for new feature or an enhancement to an existing one feature-templates labels Oct 9, 2023
@mkArtakMSFT mkArtakMSFT added this to the .NET 9 Planning milestone Oct 9, 2023
@ghost
Copy link

ghost commented Oct 9, 2023

Thanks for contacting us.

We're moving this issue to the .NET 9 Planning milestone for future evaluation / consideration. We would like to keep this around to collect more feedback, which can help us with prioritizing this work. We will re-evaluate this issue, during our next planning meeting(s).
If we later determine, that the issue has no community involvement, or it's very rare and low-impact issue, we will close it - so that the team can focus on more important and high impact issues.
To learn more about what to expect next and how this issue will be handled you can read more about our triage process here.

@AlbertoPa
Copy link

AlbertoPa commented Oct 11, 2023

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.

@VladislavAntonyuk
Copy link

https://vladislavantonyuk.github.io/articles/Microsoft-Identity-Platform-Authentication-in-Blazor-Web-Application

You can also find a template here: https://www.nuget.org/packages/VladislavAntonyuk.DotNetTemplates/3.0.247-pre4

@peterthorpe81
Copy link

https://vladislavantonyuk.github.io/articles/Microsoft-Identity-Platform-Authentication-in-Blazor-Web-Application

You can also find a template here: https://www.nuget.org/packages/VladislavAntonyuk.DotNetTemplates/3.0.247-pre4

Unless I'm missing something this is just the server rendering solution. It doesn't support the other render modes such as Auto.

@VladislavAntonyuk
Copy link

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

@AlbertoPa
Copy link

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?

@Viajaz
Copy link

Viajaz commented Nov 20, 2023

Very disappointing not to see this in .NET 8 given it's an LTS.

@leastprivilege
Copy link
Contributor

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

This is the kind of guidance I am missing as well @danroth27

@peterthorpe81
Copy link

peterthorpe81 commented Nov 21, 2023

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?

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?

@AlbertoPa
Copy link

@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. 🤔

@peterthorpe81
Copy link

@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.

@AlbertoPa
Copy link

@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.

@moshali1
Copy link

I think if we can get a template for just Interactive Server Per Page/Component, that would be great for now.

@AlbertoPa
Copy link

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).

@shoffma1
Copy link

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.

@sequarell
Copy link

This issue really breaks Blazor quite a bit for me.
In my opinion, authentication with microsoft identity is cruicial for blazor applications, which (at least in my case) are usually small webapps hostet on azure. If there is no template to use this (from an azure-architectural viewpoint) tightly coupled technologies togehter to create a minimal webapp, then this will massivley slow down development of prototypes, which will lead me to consider using other webapp frameworks to build upon.
And I don't think that I am alone with this opinion.
Please at least provide a timeline for such anupdated template.

@mreisz7
Copy link

mreisz7 commented Nov 29, 2023

I agree that this is a must have. I've been struggling with this.

@Mason742
Copy link

Mason742 commented Dec 4, 2023

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?

@ADefWebserver
Copy link

@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.

@swegele
Copy link

swegele commented Sep 30, 2024

@ADefWebserver
Yes you can do multi-tenant and/or personal Microsoft Xbox accounts.
BUT - you must get verified/validated FIRST by MS.

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.

@Eagle3386
Copy link

@AlbertoPa

(…)
I started using external tenants some time ago out of frustration with AD B2C to achieve even the most basic task, I have no idea why they are recommending AD B2C with its convoluted custom policies: it may be specific to your use case, but for my case (a BFF app and a server-side Blazor app, both of which use custom API connectors), it has been easier to use and has been working fine.

So, you were exactly in my current position (regarding the frustration, that is) - good, at least for me. 😅
They (Tek Experts) suggested that right away when I came up with aforementioned requirements (excl. Apple, only requesting social logins in general).

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.
Yes, there will be another BFF acting as "API-BFF" for 3rd parties which connect to our systems, but other than that, there's no externally accessible system.
Is that scenario "doable" with Entra External ID for customers?

@AlbertoPa
Copy link

@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?

@Eagle3386
Copy link

@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 😉).

@AlbertoPa
Copy link

@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).

@Eagle3386
Copy link

@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..

@AlbertoPa
Copy link

@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

@halter73
Copy link
Member

halter73 commented Oct 3, 2024

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.

@mkArtakMSFT
Copy link
Member

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 let's discuss the details and build the plan for how we can make this work and in which timeframes.

@devonhubush
Copy link

devonhubush commented Oct 30, 2024 via email

@jaliyaudagedara
Copy link

Since .NET 9 is out, any update on this guys?

@marqdouj
Copy link

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).

@halter73
Copy link
Member

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.

@marqdouj
Copy link

marqdouj commented Dec 13, 2024

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).
...
The callback path (CallbackPath) must match the redirect URI (login callback path) configured when registering the application in the Entra or Azure portal. Paths are configured in the Authentication blade of the app's registration. The default value of CallbackPath is /signin-oidc for a registered redirect URI of https://localhost/signin-oidc (a port isn't required).

@marqdouj
Copy link

marqdouj commented Dec 13, 2024

I just found this other article which appears to indicate this is correct (just give message to close browser):
https://learn.microsoft.com/en-us/entra/external-id/customers/tutorial-web-app-dotnet-sign-in-sign-out

Sign out of the application
To sign out of the application, select Sign out in the navigation bar.
A window appears asking which account to sign out of.
Upon successful sign out, a final window appears advising you to close all browser windows.

@RamType0
Copy link

RamType0 commented Dec 16, 2024

Where is the sample with API call?
This article is only about Microsoft ID auth on Blazor Web App.
https://learn.microsoft.com/aspnet/core/blazor/security/blazor-web-app-with-entra?view=aspnetcore-9.0

This article is about Microsoft ID authorized web API call on Blazor Server but not on Blazor Web App.

https://learn.microsoft.com/en-us/samples/azure-samples/ms-identity-ciam-dotnet-tutorial/ms-identity-ciam-dotnet-tutorial-2-call-own-api-blazor-server/

This article is about handle consent with Microsoft ID, but there is no description for Blazor Web App provided.

https://github.com/AzureAD/microsoft-identity-web/wiki/Managing-incremental-consent-and-conditional-access

@guardrex
Copy link
Contributor

guardrex commented Dec 16, 2024

@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.

@halter73
Copy link
Member

I just found this other article which appears to indicate this is correct (just give message to close browser):

@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 https://localhost/signout-callback-oidc as a Redirect URI for your app. Entra will briefly show a page indicating "It's a good idea to close all browser windows", but it will quickly redirect. The following recording shows the redirect back in real time (no speedup).

entra-logout-recording.mp4

Where is the sample with API call?

@RamType0 You can take a look at the Blazor Web App with OpenID Connect (OIDC) and BFF pattern doc (sample code). It uses AddOpenIdConnect and AddCookie directly instead of AddMicrosoftIdentityWebApp, but it still describes how to get it hooked up with Entra. And if you to replace the AddOpenIdConnect and AddCookie calls with AddMicrosoftIdentityWebApp, things should still continue to work. Long term, I hope we add a BFF sample using AddMicrosoftIdentityWebApp too.

@marqdouj
Copy link

marqdouj commented Dec 18, 2024

@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.
I guess I could manually add a non-admin account?

Tried it, could not get it to work 😒

Perhaps it's an Aspire issue?

@RamType0
Copy link

@halter73

@RamType0 You can take a look at the Blazor Web App with OpenID Connect (OIDC) and BFF pattern doc (sample code). It uses AddOpenIdConnect and AddCookie directly instead of AddMicrosoftIdentityWebApp, but it still describes how to get it hooked up with Entra. And if you to replace the AddOpenIdConnect and AddCookie calls with AddMicrosoftIdentityWebApp, things should still continue to work. Long term, I hope we add a BFF sample using AddMicrosoftIdentityWebApp too.

Thanks for the response!

But what am I facing is incremental consent.
This sample does nothing about it.

@RamType0
Copy link

RamType0 commented Dec 23, 2024

I have solved incremental consent issue by myself,
but I am now facing with Azure AD B2C authenticated SignalR connection to API server with Blazor Web App.

When we send request via http, it could be solved with BFF forwarding.
But how could we do with SignalR connection?

@ghood97
Copy link

ghood97 commented Feb 9, 2025

I have solved incremental consent issue by myself, but I am now facing with Azure AD B2C authenticated SignalR connection to API server with Blazor Web App.

When we send request via http, it could be solved with BFF forwarding. But how could we do with SignalR connection?

@RamType0 how did you fix the incremental consent issue? I am experiencing the same.

@RamType0
Copy link

@ghood97

I have solved incremental consent issue by myself, but I am now facing with Azure AD B2C authenticated SignalR connection to API server with Blazor Web App.
When we send request via http, it could be solved with BFF forwarding. But how could we do with SignalR connection?

@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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-blazor Includes: Blazor, Razor Components enhancement This issue represents an ask for new feature or an enhancement to an existing one feature-templates Pillar: Complete Blazor Web Pillar: Dev Experience Priority:0 Work that we can't release without triaged
Projects
None yet
Development

No branches or pull requests