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

Performing Additional Authentication for Other Scopes #1394

Open
tylersouthard opened this issue Sep 3, 2024 · 1 comment
Open

Performing Additional Authentication for Other Scopes #1394

tylersouthard opened this issue Sep 3, 2024 · 1 comment
Assignees
Labels

Comments

@tylersouthard
Copy link

tylersouthard commented Sep 3, 2024

Which version of Duende BFF are you using?
2.2.0

Which version of .NET are you using?
8.0

Describe the question
I'm wondering if any guidance can be given for the following situation:

  • React SPA hosted with a .NET 8 BFF
  • Typical usage for the SPA involves making calls to an external API (mine) via YARP
  • Azure Entra ID is used as the identity provider, so there is a published API scope that the client requests as a scope during authentication (registered applications with Entra ID for the API as well as for the client)
  • Less commonly, administrators need to perform user management via the frontend, which requires the use of MS Graph. The Entra ID registered app provides these scopes as possible scopes to be requested, but it doesn't make sense to request them for all users all the time

My question is: Is there a standard/normative way to perform the authentication to get the additional scopes? The typical use case simply sets the browser location to /bff/login, which works fine, but I don't see a simple way to do something similar when I need additional scopes.

Additional context

Here is the configuration for the defaults and for the two OIDC/cookie scheme pairs in Program.cs:

builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = Schemes.CookiesTypical;
    options.DefaultChallengeScheme = Schemes.OpenIdConnectTypical;
    options.DefaultSignOutScheme = Schemes.OpenIdConnectTypical;
})
.AddCookie(Schemes.CookiesTypical, "Typical Use-Case Cookie", options =>
{
    options.ExpireTimeSpan = TimeSpan.FromHours(12);
    options.SlidingExpiration = true;
    options.Cookie.MaxAge = options.ExpireTimeSpan;
    options.Cookie.Name = "__Host-Typical";
    options.Cookie.SameSite = SameSiteMode.Strict;
    options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
    options.Cookie.HttpOnly = true;
})
.AddCookie(Schemes.CookiesMsGraph, "Microsoft Graph Cookie", options =>
{
    options.ExpireTimeSpan = TimeSpan.FromHours(1);
    options.SlidingExpiration = false;
    options.Cookie.MaxAge = options.ExpireTimeSpan;
    options.Cookie.Name = "__Host-Microsoft-Graph";
    options.Cookie.SameSite = SameSiteMode.Strict;
    options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
    options.Cookie.HttpOnly = true;
})
.AddOpenIdConnect(Schemes.OpenIdConnectTypical, "OIDC Typical", options =>
{
    options.Authority = builder.Configuration["AzureAdCredentials:Authority"];
    options.ClientId = builder.Configuration["AzureAdCredentials:ClientId"];
    options.ClientSecret = builder.Configuration["AppClientSecret"]; // From KeyVault
    options.ResponseType = OpenIdConnectResponseType.Code;
    options.ResponseMode = OpenIdConnectResponseMode.Query;

    options.MapInboundClaims = false;
    options.SaveTokens = true;

    options.Scope.Clear();
    options.Scope.Add("openid");
    options.Scope.Add("profile");
    options.Scope.Add("email");
    options.Scope.Add(builder.Configuration["AzureAdCredentials:ApiScope"]!);
    options.Scope.Add("offline_access");
})
.AddOpenIdConnect(Schemes.OpenIdConnectMsGraph, "OIDC Microsoft Graph", options =>
{
    options.SignInScheme = Schemes.CookiesMsGraph;
    options.CallbackPath = "/signin-oidc-msgraph";
    options.Authority = builder.Configuration["AzureAdCredentials:Authority"];
    options.ClientId = builder.Configuration["AzureAdCredentials:ClientId"];
    options.ClientSecret = builder.Configuration["AppClientSecret"]; // From KeyVault
    options.ResponseType = OpenIdConnectResponseType.Code;
    options.ResponseMode = OpenIdConnectResponseMode.Query;

    options.MapInboundClaims = false;
    options.SaveTokens = true;

    options.Scope.Clear();
    options.Scope.Add("openid");
    options.Scope.Add("profile");
    options.Scope.Add("email");
    options.Scope.Add("User.Read");
    options.Scope.Add("User.ReadBasic.All");
});

For reference, the current way I do this is by having an endpoint hosted by the BFF and an authorization policy that I can require for it. In the frontend, when I need to do something with users as an admin, I set the window location to this endpoint, with a return URL for where I want it to end up.

Policy in Program.cs:

builder.Services.AddAuthorization(options =>
{
    AuthorizationPolicyBuilder msGraphSchemePolicyBuilder = new AuthorizationPolicyBuilder(Schemes.OpenIdConnectMsGraph);
    options.AddPolicy(Policies.MsGraphUser, msGraphSchemePolicyBuilder
        .AddAuthenticationSchemes(Schemes.OpenIdConnectMsGraph)
        .RequireAuthenticatedUser()
        .Build());
});

Controller:

[ApiController]
[Authorize(Policy = Policies.MsGraphUser)]
[Route("/graph")]
public class GraphController(IGraphService graphService) : ControllerBase
{
  ...

    [HttpGet("login")]
    [ProducesResponseType(StatusCodes.Status302Found)]
    public Task<ActionResult> Login([FromQuery] string returnUrl)
    {
        return Task.FromResult((ActionResult)Redirect(returnUrl));
    }
    ...

I then set up a special HttpClient with a custom DelegatingHandler in order to grab the access token out of the MsGraph cookie and set it in the header for the requests that HttpClient uses.

This all seems like it probably isn't the normal way to do this. I also run into issues where (Edge more often than Chrome) gets stuck in a loop between my app and the auth endpoint. Any feedback would be greatly appreciated.

@RolandGuijt RolandGuijt self-assigned this Sep 9, 2024
@DuendeSoftware DuendeSoftware deleted a comment from RolandGuijt Sep 11, 2024
@AndersAbel AndersAbel self-assigned this Sep 11, 2024
@AndersAbel
Copy link
Member

If I understand this right, you are using the Duende BFF component to interact directly with Microsoft Entra Id, without using IdentityServer in between.

If the upstream provider had been Duende IdentityServer you could just have added all the scopes to the initial request and that would generate an access token that has both scopes. Entra Id however doesn't work that way, they enforce resource isolation and do not allow an access token to be valid for two different resources (i.e. families of APIs) on the same time.

The correct way to handle this is to use a single OIDC client configuration (with a single cookie) and request all scopes at once, including the special offline_access scope. offline_access is a special scope that requests a refresh token. Using the refresh token you can request additional access tokens without having to log in. It is possible to set specific scopes for each such request, which will make it possible to have two different access tokens on the same time.

Our BFF component uses the Duende.AccessTokenManagement.OpenIdConnect library internally to handle tokens. It has built in features for named Http Clients with different token request parameters. Using those feature, it is possible to get one named Http Client for each API (your own and Ms Graph) and automatically get the correct access token assigned to the client request. See https://docs.duendesoftware.com/identityserver/v7/bff/tokens/ and https://github.com/DuendeSoftware/Duende.AccessTokenManagement/wiki/Customizing-User-Token-Management for more details.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

3 participants