microsoft-identity-web icon indicating copy to clipboard operation
microsoft-identity-web copied to clipboard

[Documentation] .Net Core Razor page app & API

Open GeorgeTTD opened this issue 2 years ago • 12 comments

Documentation related to component

Please check all that apply

  • [ ] typo
  • [x] documentation doesn't exist
  • [x] documentation needs clarification
  • [x] error(s) in the example
  • [x] needs an example

Description of the issue

We currently have a hybrid app with a .net core razor page web application and Vue.js front SPA application which calls through to an api hosted by the razor application.

We have an issue whereby users regularly spend more than an hour on one page and the token expires. This is fine when the user is on the razor page side of the application as the user goes through the refresh loop, however when on the SPA side we start getting 302 status codes which are then blocked by the browser when making AJAX requests.

What we need is an example of how we can send 401's on API routes only or handle the token refresh on the SPA side.

GeorgeTTD avatar Oct 19 '21 11:10 GeorgeTTD

I think this is what I am asking for but I am not 100% what it is doing. https://github.com/AzureAD/microsoft-identity-web/wiki/1.2.0#ajax-calls-can-now-participate-in-incremental-consent-and-conditional-access is the x-returnurl header on the request the important part here?

GeorgeTTD avatar Oct 19 '21 12:10 GeorgeTTD

@GeorgeTTD you found the right page, and more is explained here. let us know if this is enough and you're able to resolve your issue.

jennyf19 avatar Oct 20 '21 16:10 jennyf19

@jennyf19 I checked out the example AjaxCallActionsWithDynamicConsent yesterday and when I run it on my local machine with a breakpoint set before the ajax call is called but the page has loaded in the browser so that the auth cookie is set. I then deleted the auth cookie as to be unauthenticated again. I then continued on from my breakpoint which caused the ajax call to be made. Now from the documentation on that project I am expecting to see a 401 response from the controller but instead I get a 302 redirect to login again which causes the CORS issue. Is this a bug?

GeorgeTTD avatar Oct 20 '21 16:10 GeorgeTTD

@creativebrother do you have any thoughts on this one? thanks.

jennyf19 avatar Oct 20 '21 17:10 jennyf19

We have found this solution works for us as we are only calling API Endpoints from a SPA. If you were to call page actions async then you would need a way to recognise AJAX requests (Potentially x-RequestedWith). I still think there is a bug or a documentation change is required as I don't think this scenario is clear enough.

 services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
             .AddMicrosoftIdentityWebApp(options =>
             {
               Configuration.Bind("AzureAd", options);
               options.Events ??= new OpenIdConnectEvents();
               options.Events.OnRedirectToIdentityProvider += OnRedirectToIdentityProviderFunc;
             })
             .EnableTokenAcquisitionToCallDownstreamApi()
             .AddDistributedTokenCaches();
private async Task OnRedirectToIdentityProviderFunc(RedirectContext context)
{
     if (context.Request.Path.Value.StartsWith("/api"))
     {
       context.Response.StatusCode = 401;
       context.HandleResponse();
     }

     // Don't remove this line
     await Task.CompletedTask.ConfigureAwait(false);
 }

GeorgeTTD avatar Oct 21 '21 08:10 GeorgeTTD

@jennyf19 @creativebrother Any update on this at all?

GeorgeTTD avatar Nov 11 '21 15:11 GeorgeTTD

not on my end @GeorgeTTD I hope to have time next week to revisit this.

jennyf19 avatar Nov 11 '21 17:11 jennyf19

@GeorgeTTD I am experiencing the problem you referenced on 20 Oct (getting 302 instead of 401)

I've tried adapting your OnRedirectToIdentityProviderFunc method as a workaround but I take it the call to HandleResponse stops the additional response handling that occurs in the code for the AuthorizeForScopesAttribute? The response headers (like 'location') aren't set. I can do something like this (below), but then I get an OpenIdConnectAuthenticationHandler exception: "message.State is null or empty". This event handler also seems to get called twice for one call - I wouldn't know if that's normal or not:

private Task OnRedirectToIdentityProviderFunc(RedirectContext context)
        {
            if (context.Request.Path.Value.StartsWith("/api"))
            {
                context.Response.StatusCode = 401;

                if (!context.Response.Headers.ContainsKey("Location"))
                    context.Response.Headers.Add("Location", context.ProtocolMessage.BuildRedirectUrl());
                context.HandleResponse();
            }

            // Don't remove this line
            return Task.CompletedTask;
        }

Any help @jennyf19 or @creativebrother may be able to provide would be most appreciated!

Failing that, you may have other ideas about how to approach my scenario... I have an app that requests Graph specific scopes like User.ReadBasic.All, Team.ReadBasic.All etc. - I'm using the ASP Core React SPA template, when users login and authenticate for the first time, they can consent to the requested resources just fine. There is a particular part of my app where I am managing/hosting data in SharePoint (files and permissions), so I have attempted to use AuthorizeForScopes on the relevant Web API endpoints to dynamically request mytenant.sharepoint.com/AllSites.Write when this feature is engaged. I think this probably would have worked using the current documented approach, until for whatever reason it started returning 302 instead of 401... any tips/ideas?

t0mgerman avatar Nov 18 '21 07:11 t0mgerman

@creativebrother 's post here worked for me in terms of getting a 401 back using AuthorizeForScopes https://github.com/AzureAD/microsoft-identity-web/issues/603#issuecomment-703316365

but I was continually prompted for consent as though the token / session cookie wasn't being updated on the client-side or something.

I tried it a different way - based on the docs here, and used the regular [Authorize] attribute, caught the MsalUiRequiredException myself and used _tokenAcquisition.ReplyForbiddenWithWwwAuthenticateHeaderAsync(scopes, ex); to return the www-authenticate header in my response. That has the consentUrl I need - which I can hackily parse on the client-side, something like:

     return fetch(`/api/controller/action`, {
        headers: {
            "X-Requested-With": "XMLHttpRequest",
            "x-ReturnUrl": `${window.location.protocol}//${window.location.host}`
        },
        credentials: 'include',
        cache: 'no-cache'
    }).then(async (response) => {
        if (response.ok) {
            return await response.json();
        } else {
            const wwwAuthHeader = response.headers.get('www-authenticate');
            if (wwwAuthHeader) {
                const consentUri = wwwAuthHeader.split(/,? /).filter(v => /consentUri/.test(v)).map((v) => v.replace(/consentUri="(.*)"/, '$1'));
                if (consentUri.length) {
                    // I have to manually replace the redirect_uri, as signin-oidc is throwing message.state is null or empty ??
                    consentUri[0] = consentUri[0].replace(/(?<=[?|&])(redirect_uri=)[^&]+/, `${window.location.protocol}//${window.location.host}`);
                    window.location.href = consentUri[0];
                }
            }
        }
    });

Using either of these methods I get the consent prompts, and if I check in the Azure portal, consent has been given, but again I'm getting loops - unless I reload the site or access MicrosoftIdentity/Account/SignIn -

EDIT / UPDATE: I cleared application cookies, revoked consent for my test user using powershell and started from scratch - and these methods (above) work fine for me now. The need to manually alter the redirect_uri on the client-side notwithstanding...

Obviously I'd still prefer a more seamless solution that requires less tinkering, but thank you guys for all the info you guys have put up on this issue and others..

t0mgerman avatar Nov 18 '21 17:11 t0mgerman

@jennyf19 I checked out the example AjaxCallActionsWithDynamicConsent yesterday and when I run it on my local machine with a breakpoint set before the ajax call is called but the page has loaded in the browser so that the auth cookie is set. I then deleted the auth cookie as to be unauthenticated again. I then continued on from my breakpoint which caused the ajax call to be made. Now from the documentation on that project I am expecting to see a 401 response from the controller but instead I get a 302 redirect to login again which causes the CORS issue. Is this a bug?

@jennyf19 This demo still has the same issue as far as i can see What are we expecting as i just get a 302 that is handled by the browser and blocked by CORS which this demo makes no sense? I must be getting confused as surely this is such a common scenario. Page loaded, come back tomorrow try and use any ajax function and nothing would happen for the user. Any help would be grateful thanks

mselley avatar Jun 09 '23 09:06 mselley

Almost 3 years later, and this is still a major issue.

The Wiki in this repo says that handling the AJAX stuff is easy-peasy. Just do "this." And then you have us look at the AjaxCallActionsWithDynamicConsent test. But it really DOESN'T work. As multiple people have said, it produces a 302 then a CORS issue.

I start with an initial scope of user.read and try to incrementally ask for calendars.read. Seems like a VERY easy problem to reproduce.

Can we get any kind of definitive help on this?

DaleyKD avatar Sep 21 '23 11:09 DaleyKD

What happens is that you perform the ajax request. Auth is expired. Thus the openidmiddleware starts to redirect to the openid provider, for example login.microsoft.com. And this response is returned. However because its an ajax request. all kind of things fails.

What we did is override the RedirectToIdentityProvider in the openidoptions.

There we do this:

//set our own appredirectionurl. This was needed for use because we host in a application under the main site. e.g: www.mydomain.net/myapp  which previously wasn't supported properly.

 context.ProtocolMessage.RedirectUri = AppRedirectUrl(context.Request);
 
 if (context.Request.IsAjaxRequest())
 {
     
     context.Response.Headers["Auth-Refresh"] = "1";
     context.Response.StatusCode = 401;
     context.HandleResponse();
 }

 return Task.CompletedTask;

Then clientside we have an global ajax response error handler. The example is with jquery but you can easily use your own js framework of choice.

 //force page refresh when ajax request fails with 401
 $(document).ajaxError(function(event, request, settings) {
     let authRefresh = request.getResponseHeader('Auth-Refresh');
     if (request.status === 401 && authRefresh === "1") {
         if ((window.parent !== null) && (window.parent !== undefined))
             window.parent.location.reload();
         else
             location.reload();
     }
 });

What this does it that it allows us to signal to the browser that there was an auth issue, and we then resolve that by simply reloading the entire page, causing a normal auth login flow.

Danthar avatar Nov 22 '23 13:11 Danthar