AzureAD / microsoft-identity-web

Helps creating protected web apps and web APIs with Microsoft identity platform and Azure AD B2C
MIT License
681 stars 211 forks source link

How to silently renew Id Token using AddMicrosoftIdentityWebAppAuthentication to Call Downstream API #1978

Closed mschaefer-gresham closed 1 year ago

mschaefer-gresham commented 1 year ago

I am trying to implement the BFF-Gateway pattern (no tokens in the browser) to be used with a React SPA. The BFF is using AddMicrosoftIdentityWebAppAuthentication to handle login and issue a cookie to the SPA. And it is using YARP to proxy api requests to a downstream api. I'm using Azure B2C. Everything works perfectly until the BFF id_token expires in 1 hour. At that point, fetching the downstream api access token via GetAccessTokenForUserAsync (which is called in a piece of middleware) fails:

var scope = _configuration["CallApi:ScopeForAccessToken"];
var accessToken = await _tokenAcquisition.GetAccessTokenForUserAsync(new[] { scope });
ctx.Request.Headers.Add("Authorization", "Bearer " + accessToken);

Exception:

IDW10502: An MsalUiRequiredException was thrown due to a challenge for the user. See https://aka.ms/ms-id-web/ca_incremental-consent. 

ResponseBody: {"error":"invalid_grant","error_description":"AADB2C90085: The service has encountered an internal error. Please reauthenticate and try again.\r\nCorrelation ID: 622d6bd6-d06e-4142-86f2-b30a7a17b3b5\r\nTimestamp: 2022-11-25 09:31:23Z\r\n"} 

This is effectively the same as Call Downstream API Without The Helper Class example and this sample, except that I'm acquiring the access token in middleware, not a controller, so the downstream YARP requests contain the access token. BTW I get the same error if I do this inside a controller per this example. And I see no soluton to this in the sample.

There is a similar question here which references the sample referenced above, but for the B2C sample I see no solution to this problem.

I also found this sample and this explanation. But this uses Microsoft.Owin to configure auth, not AddMicrosoftIdentityWebAppAuthentication. This looks promising, but is a departure from most examples I see that use Microsoft.Identity.Web.

Can you please point to the correct solution? I need call to be able to call _tokenAcquisition.GetAccessTokenForUserAsync after the id token expires without asking the user to reauthenticate and/or the SPA to having to reload.

Feels like this is a very common scenario that isn't documented very well.

At the moment I am handling this issue in the SPA by catching the exception from MSAL and redirecting back to the login endpoint in the BFF which initiates the challenge. This gets me a new id_token and cookie, but this is just a temp workaround as it's very disruptive to user to be redirected away from the SPA.

jmprieur commented 1 year ago

@mschaefer-gresham : did you see this article? https://github.com/AzureAD/microsoft-identity-web/wiki/Managing-incremental-consent-and-conditional-access

It should provide you ideas on how to handle your scenario.

mschaefer-gresham commented 1 year ago

@jmprieur Thank you. I did read that article, but wasn't sure it was solving the same problem. Is it the Handling incremental consent or conditional access in web APIs section that I should focus on?

jmprieur commented 1 year ago

note quite. You are building an ASP.NET Core web app?. You'd want to use this section: https://github.com/AzureAD/microsoft-identity-web/wiki/Managing-incremental-consent-and-conditional-access#handling-incremental-consent-or-conditional-access-in-web-apps

mschaefer-gresham commented 1 year ago

@jmprieur could you please provide some clarification on the following:

  1. Based on my understanding CAE feature can be used by a CAE-aware client to call a CAE-enabled API, whereby the client can handle the response containing the claims challenge and request a new token.

Is the call to var accessToken = await _tokenAcquisition.GetAccessTokenForUserAsync(new[] { scope }); a call to a CAE-enabled api, which is returning a claims challenge which the [AuthorizeForScopes] attribute is handling and doing the work to renew the id_token when it's expired?

  1. Is the above is correct? How can I replicate what [AuthorizeForScopes] is doing explicitly since I'm not calling GetAccessTokenForUserAsync from inside a controller? I'm calling it in middleware for the requests handled by YARP.

I would really appreciate some clarification. The CAE documentation seems geared towards solving a different problem - that of revoking tokens before expiration due to various conditions. I'm only trying to silently renew by id_token silently so I can continue to acquire the downstream api access token.

mschaefer-gresham commented 1 year ago

@jmprieur I've followed the webapp instructions, and as a test, tried to acquire the token in a controller decorated with the [AuthorizeForScopesAttribute]. Debugging the failed request I see that I end up in the attribute, which sets a Challenge on the result property of the Exception context, and then re-raises the exception:

context.Result = new ChallengeResult(properties);
...
base.OnException(context);

This exception is still coming back to my React SPA. I was under the impression that I was going to be re-authenticated within the server and that my request was going to be re-tried (perhaps wishful thinking).

How is my React SPA suppose to make use of this exception with the Challenge result?

Before I was catching the Msal exception in the SPA and redirecting back to the login, which went to the Challenge. This get me reauthenticated, but I'm still redirected away from the SPA.

It still feels like I'm missing a piece of this.

What I want to achieve is a back channel call from inside the BFF server app to renew the BFF id token without the front end being involved (and then retry getting the downstream api access token and complete the request) .

jmprieur commented 1 year ago

The challenge is not supposed to be handled by your React SPA, but by the server side web app. I'm not familiar with client side parts (React). @EmLauber @peterzenz would you know what to do?

mschaefer-gresham commented 1 year ago

@jmprieur my React SPA is calling an endpoint/controller in my server side "webapp", which is my BFF. This controller is decorated with [AuthorizeForScopes] and as described above, tries to acquire an access token for a downstream api. Acquiring the access token fails as soon as the webApp(BFF) id token expires.

How is the webApp(BFF) suppose to handle the [AuthorizeForScopes] challenge/exception which is being thrown by it's own controller?

Just to recap, my webApp(BFF) handles the login process for the SPA and also acts as a gateway to a downstream api. I want to renew the webApp(BFF) id token when it expires from within the webApp(BFF) via a back channel call to Azure such that the SPA is not involved and the user is unaware.

jmprieur commented 1 year ago

I don't think that this is possible, @mschaefer-gresham. Re-signing it the user requires going to the authorization endpoint, so your SPA page would be replaced by a call to AAD (which can be quick and without requiring the user interaction), and then the SPA code would be again be available in the backend, end your frontend would redeem it to get an access token

@kalyankrishna1 what do you think?

kalyankrishna1 commented 1 year ago

@mschaefer-gresham , you should look at this sample Accessing the logged-in user's token cache from background apps, APIs and services for your scenario

mschaefer-gresham commented 1 year ago

@kalyankrishna1 I'm looking at the two examples 1) 1-1-WebApp-BgWorker and 2) 1-2-WebAPI-BgWorker. I assume you are referring to 1-1-WebApp-BgWorker since the other requires tokens in the browser.

I see no difference here from what I am already doing. My WebApp (BFF) is also acquiring the token for my downstream API using the token acquisition service the same way this example acquires the Graph API access token. The only difference is that my downstream API is my own api application. And herein lies the problem: as soon as the WebApp id token expires, acquiring the downstream token using GetAccessTokenForUserAsync fails, which ultimately forces the SPA to redirect to the the idp.

What am I missing? I don't see anything here that solves this problem.

If there is something inside the background worker app that is relevant to solving this problem in the WebApp then I'm not grasping it.

Incidentally, this example does solve this problem by using Microsoft.AspNetCore.Authentication extensions (AddCookie, AddOpenIdConnect) together with the following strategy: It acquires access and refresh tokens for the BFF which can be renewed before they expire, and uses the BFF access token to acquire the downstream api token using an "on_behalf_of" token exchange. The BFF access token expiry is checked before the downstream api access token is fetched such that the downstream request will not fail because of an expired BFF token.

The problem is that on_behalf_of is not supported by Azure B2C.

I am assuming Duende solves it, but I have not tested it.

The only possible solution I can see now is for the BFF to know the client id and secret of each downstream api and to request their access tokens this way. But I'm not sure even this works because I need to acquire them on behalf of the logged in user. Will the downstream api access token have the correct claims?

It still feels like there is something basic that I'm missing. The BFF (no token in the browser) pattern is now considered the defacto way to secure a public SPA. A BFF that also acts as gateway to downstream APIs is also a standard pattern. The fact that Microsoft.Identity.Web cannot support this pattern such that the BFF id token can be silently renewed in the BFF strikes me as a major shortcoming.

In our application, it's not feasible to expect the user re-authenticate just because the BFF id token has expired. Moreover, it introduces the problem that the SPA can't anticipate which request is going to fail when the BFF id token expires. For example, the user clicks a button to do a POST/PUT action which subsequently fails causing the SPA to redirect to Azure AD. What happens to that request?

At the very least, I would be extremely useful/time saving if you could at least confirm that Microsoft.Identity.Web + Azure B2C is not capabale of solving the BFF (no token in browser) pattern with silent BFF id token refresh. I think @jmprieur confirmed this, but I'd like to be sure.

jmprieur commented 1 year ago

@mschaefer-gresham yes, this flow: Hybrid SPA is supported by AAD, but not AAD B2C,

mschaefer-gresham commented 1 year ago

@jmprieur I was not referring to the Hybrid SPA flow. Once again, I am trying to implement the BFF no-tokens-in-the-browser solution for securing a public SPA. It feels like we keep having two different conversations.

My SPA currently works by acquiring tokens in the browser. My objective is to stop doing this according to current best practice without relying on Duende.

I think it would be helpful to a lot of people if you could answer my original question. For the sake of completeness, I am trying to achieve the following and the only piece I cannot solve is #3 together with #4:

Goal:

  1. The SPA should only rely on a httponly cookie issued by a "BFF" server app. The SPA should have zero dependencies on any auth type libraries (and obviously no tokens).

  2. The BFF server should handle login, acquire and manage tokens for itself, and be able to acquire tokens for downstream apis on behalf of the logged in user.

  3. When the BFF server token (id or access) expires, the BFF should be able to renew this token silently such that the SPA is not involved at all (not redirected to the idp), allowing the BFF to continue acquiring tokens for downstream apis.

  4. This should work for Azure B2C.

Problem:

If I use Microsoft.Identity.Web, acquiring the downstream api access token fails when BFF server id token expires. There is no way to silently renew the BFF id token server-side.

If I use Microsoft.AspNetCore.Authentication extensions (AddCookie, AddOpenIdConnect) together with Azure B2C, I cannot achieve the token exchange for the downstream api token because the on_behalf_of flow is not supported in B2C.

Question:

Is there a solution for silently renewing the BFF server id token entirely on the server without the SPA needing to redirect to the idp using B2C? I've looked at the suggestions you and @kalyankrishna1 have sent me and I don't see how either can solve this problem.

If there is a solution, can you please be more specific on how to solve it.

kalyankrishna1 commented 1 year ago

Silent token acquisition within the browser (SPA) is only possible using MSAL.js.

Tagging @derisen to see if he can help

michiproep commented 1 year ago

Hi @mschaefer-gresham, I totally understand your frustration since nobody seems to understand your question.

Please correct me if I'm wrong but I don't think there is a need to renew IdTokens at all. With BFF, you authenticate once to get an IdToken and then use cookies to maintain your own session. If, for whatever reason, you need updated claims from the IdToken you need to reauthenticate or call the graph api (e.g. in cookie sliding expiration event).

For calling downstream APIs, AccessTokens are used. MSAL requests AccessToken (also for different scopes than the initial once) by using the refresh token. Therefore, it always injects the offline_access scope to the authorize-request.

My finding is, that using the IDownstreamApi helper class does not work for AzureB2C since it trys to use the OBO flow. You can use the ITokenAquisition to get accesstokens, though.

Is this answer going in the right direction?

mschaefer-gresham commented 1 year ago

Hi @michiproep

Thank you for responding. Based in this conversation and others I figured out that it is not possible to implement the BFF Pattern using Microsoft.Identity.Web since the only way to get a new access token is by using MSAL.js to acquire it in the browser.

Instead, I based my solution on this example that was referrenced by Damien Bowden on his blog. It's working quite well after making a few changes.

michiproep commented 1 year ago

Hi @mschaefer-gresham, may I ask if you managed to renew an IdToken with that solution? I'm asking for two reasons: First, I would be really interested if it's possible at all to silently renew IdToken and second, we use stricly BFF with MSAL thoughout all our applications and for us it makes no difference which auth lib we use.

mschaefer-gresham commented 1 year ago

Hi @michiproep, no you don't renew the id token using the approach I mention above, only the access token. Regarding this issue, I did not fully understand how this worked or what was possible when I started this project using Microsoft.Identity.Web so I was asking the wrong question.

mschaefer-gresham commented 1 year ago

Hi @michiproep, I might also mention that Azure B2C does not support renewing an access token by default using grant type "refresh_token". You have to create a custom policy.

michiproep commented 1 year ago

Oh that's interesting. well, I never tryed the built-in user flows. I used custom policies from the beginning