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

Intermittent Token Refreshing Error with Multi-Authentication Scheme Setup in BFF Silent Login Endpoint #1527

Open
arttonoyan opened this issue Dec 30, 2024 · 3 comments
Assignees
Labels

Comments

@arttonoyan
Copy link

Which version of Duende BFF are you using?
Duende.BFF Version="2.2.0"

Which version of .NET are you using?
.Net 8

Describe the bug

Overview:
We are using Duende as our primary identity server, a Multi-Authentication Scheme Setup, and the YARP proxy with an authentication filter. While integrating the BFF Silent Login Endpoint, we encountered an intermittent issue during token refreshing.

Issue Details:

  • The error occurs inconsistently and only when Multi-Authentication Scheme Setup is configured.
  • During the token refresh process, the context.GetUserAccessTokenAsync() method is invoked without UserTokenRequestParameters, resulting in the following error:
Yarp.ReverseProxy.Forwarder.HttpForwarder: RequestCreation: An error was encountered while creating the request message. [InvalidOperationException] Unable to load OpenID configuration for configured scheme: Object reference not set to an instance of an object.

   at Duende.AccessTokenManagement.OpenIdConnect.UserTokenRequestSynchronization.SynchronizeAsync(String name, Func`1 func) in /_/src/Duende.AccessTokenManagement.OpenIdConnect/UserTokenRequestSynchronization.cs:line 23
   at Duende.AccessTokenManagement.OpenIdConnect.UserAccessAccessTokenManagementService.GetAccessTokenAsync(ClaimsPrincipal user, UserTokenRequestParameters parameters, CancellationToken cancellationToken) in /_/src/Duende.AccessTokenManagement.OpenIdConnect/UserAccessTokenManagementService.cs:line 112
   at Microsoft.AspNetCore.Authentication.TokenManagementHttpContextExtensions.GetUserAccessTokenAsync(HttpContext httpContext, UserTokenRequestParameters parameters, CancellationToken cancellationToken) in /_/src/Duende.AccessTokenManagement.OpenIdConnect/TokenManagementHttpContextExtensions.cs:line 34
   at ServiceTitan.Standalone.ApiGateway.ReverseProxy.BearerTokenTransformProvider.<Apply>b__4_0(RequestTransformContext transformContext) in /src/src/Standalone.ApiGateway/ReverseProxy/BearerTokenTransformProvider.cs:line 19
   at Yarp.ReverseProxy.Transforms.Builder.StructuredTransformer.TransformRequestAsync(HttpContext httpContext, HttpRequestMessage proxyRequest, String destinationPrefix, CancellationToken cancellationToken)
   at Yarp.ReverseProxy.Forwarder.HttpForwarder.CreateRequestMessageAsync(HttpContext context, String destinationPrefix, HttpTransformer transformer, ForwarderRequestConfig requestConfig, Boolean isStreamingRequest, ActivityCancellationTokenSource activityToken)
   at Yarp.ReverseProxy.Forwarder.HttpForwarder.SendAsync(HttpContext context, String destinationPrefix, HttpMessageInvoker httpClient, ForwarderRequestConfig requestConfig, HttpTransformer transformer, CancellationToken cancellationToken)

Analysis:
The issue appears to stem from GetUserAccessTokenAsync not handling missing or incomplete UserTokenRequestParameters when multiple authentication schemes are used.

Workaround:
To address this, I implemented a custom IUserTokenManagementService that ensures UserTokenRequestParameters are initialized correctly for multiple authentication schemes. This includes:

  • Decorating the original Duende UserAccessAccessTokenManagementService.
  • Introducing an interface IAuthSchemeManager to dynamically identify authentication schemes.
  • Properly registering the service with a Transient lifetime, as required by Duende's original implementation.

Note: The SignInScheme is hardcoded to CookieAuthenticationDefaults.AuthenticationScheme because we exclusively use cookie-based authentication, and this was the fastest way to address the issue.

Sample implementation:

/// <summary>
/// A custom implementation of <see cref="IUserTokenManagementService"/> for handling multi-authentication scheme policies.
/// This class decorates the original Duende implementation (<see cref="UserAccessAccessTokenManagementService"/>)
/// to address issues with multiple authentication schemes.
/// 
/// The issue in the original implementation leads to the following error:
/// <code>
/// at Duende.AccessTokenManagement.OpenIdConnect.UserTokenRequestSynchronization.SynchronizeAsync(String name, Func`1 func) in /_/src/Duende.AccessTokenManagement.OpenIdConnect/UserTokenRequestSynchronization.cs:line 23
/// at Duende.AccessTokenManagement.OpenIdConnect.UserAccessAccessTokenManagementService.GetAccessTokenAsync(ClaimsPrincipal user, UserTokenRequestParameters parameters, CancellationToken cancellationToken) in /_/src/Duende.AccessTokenManagement.OpenIdConnect/UserAccessTokenManagementService.cs:line 112
/// at Microsoft.AspNetCore.Authentication.TokenManagementHttpContextExtensions.GetUserAccessTokenAsync(HttpContext httpContext, UserTokenRequestParameters parameters, CancellationToken cancellationToken) in /_/src/Duende.AccessTokenManagement.OpenIdConnect/TokenManagementHttpContextExtensions.cs:line 34
/// at ServiceTitan.Standalone.ApiGateway.ReverseProxy.BearerTokenTransformProvider.&lt;Apply&gt;b__4_0(RequestTransformContext transformContext) in /src/src/Standalone.ApiGateway/ReverseProxy/BearerTokenTransformProvider.cs:line 19
/// at Yarp.ReverseProxy.Transforms.Builder.StructuredTransformer.TransformRequestAsync(HttpContext httpContext, HttpRequestMessage proxyRequest, String destinationPrefix, CancellationToken cancellationToken)
/// at Yarp.ReverseProxy.Forwarder.HttpForwarder.CreateRequestMessageAsync(HttpContext context, String destinationPrefix, HttpTransformer transformer, ForwarderRequestConfig requestConfig, Boolean isStreamingRequest, ActivityCancellationTokenSource activityToken)
/// at Yarp.ReverseProxy.Forwarder.HttpForwarder.SendAsync(HttpContext context, String destinationPrefix, HttpMessageInvoker httpClient, ForwarderRequestConfig requestConfig, HttpTransformer transformer, CancellationToken cancellationToken)
/// </code>
/// </summary>
// TODO [Artyom Tonoyan] [26/12/2024]: This implementation is temporary and will be removed when a better solution is found or when Duende or Microsoft fixes the issue.

public class MultiAuthUserAccessAccessTokenService : IUserTokenManagementService
{
    private readonly IAuthSchemeManager _authSchemeManager;
    private readonly IUserTokenManagementService _userTokenManagementService;
    private readonly ILogger<MultiAuthUserAccessAccessTokenService> _logger;

    public MultiAuthUserAccessAccessTokenService(IAuthSchemeManager authSchemeManager, IUserTokenManagementService userTokenManagementService, ILogger<MultiAuthUserAccessAccessTokenService> logger)
    {
        _authSchemeManager = authSchemeManager;
        _userTokenManagementService = userTokenManagementService;
        _logger = logger;
    }
    public Task<UserToken> GetAccessTokenAsync(ClaimsPrincipal user, UserTokenRequestParameters? parameters = null, CancellationToken cancellationToken = default)
    {
        parameters = EnsureTokenRequestParameters(parameters);

        var userName = user.Identity?.Name ?? "Unknown";
        _logger.Debug($"Retrieving access token for user '{userName}' with challenge scheme '{parameters.ChallengeScheme}' and sign-in scheme '{parameters.SignInScheme}'.");

        return _userTokenManagementService.GetAccessTokenAsync(user, parameters, cancellationToken);
    }

    public Task RevokeRefreshTokenAsync(ClaimsPrincipal user, UserTokenRequestParameters? parameters = null, CancellationToken cancellationToken = default)
    {
        parameters = EnsureTokenRequestParameters(parameters);

        var userName = user.Identity?.Name ?? "Unknown";
        _logger.Debug($"Revoking refresh token for user '{userName}' with challenge scheme '{parameters.ChallengeScheme}' and sign-in scheme '{parameters.SignInScheme}'.");

        return _userTokenManagementService.RevokeRefreshTokenAsync(user, parameters, cancellationToken);
    }

    /// <summary>
    /// Ensures that the token request parameters are properly initialized.
    /// </summary>
    /// <param name="authSchemeProvider">The authentication scheme provider.</param>
    /// <param name="parameters">The parameters to ensure or initialize.</param>
    /// <returns>Initialized user token request parameters.</returns>
    private UserTokenRequestParameters EnsureTokenRequestParameters(
        UserTokenRequestParameters? parameters)
    {
        if (parameters == null) {
            parameters = new UserTokenRequestParameters {
                ChallengeScheme = _authSchemeManager.GetAuthScheme(),
                SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme
            };
        }
        else {
            parameters.ChallengeScheme ??= _authSchemeManager.GetAuthScheme();
            parameters.SignInScheme ??= CookieAuthenticationDefaults.AuthenticationScheme;
        }

        return parameters;
    }
}

Registration Details:

// It is important to register UserAccessAccessTokenManagementService as Transient
// because the original Duende implementation uses Transient lifetime.
// Reference: Duende.AccessTokenManagement.OpenIdConnect.OpenIdConnectTokenManagementServiceCollectionExtensions.
// Source code: https://github.com/DuendeSoftware/Duende.AccessTokenManagement/blob/main/src/Duende.AccessTokenManagement.OpenIdConnect/OpenIdConnectTokenManagementServiceCollectionExtensions.cs#L37
services.TryAddTransient<UserAccessAccessTokenManagementService>();
services.AddTransient<IUserTokenManagementService>(sp =>
{
    var authSchemeManager = sp.GetRequiredService<IAuthSchemeManager>();
    var logger = sp.GetRequiredService<ILogger<MultiAuthUserAccessAccessTokenService>>();
    var originalService = sp.GetRequiredService<UserAccessAccessTokenManagementService>();

    return new MultiAuthUserAccessAccessTokenService(authSchemeManager, originalService, logger);
});

Request for Feedback:

  • Could this issue be addressed directly by Duende or Microsoft?
  • If this is considered a limitation of Duende's current implementation, I am happy to migrate this ticket to Microsoft or suggest improvements for Duende's roadmap.

Additional Note: If you think the provided information is insufficient, please let me know, and I can find time to create a small project that closely mimics our configuration to help debug the issue.

Please let me know if further clarification or details are needed.

@RolandGuijt
Copy link

Question:

When you're saying Multi-Authentication Scheme Setup, are there multiple schemes configured in the BFF or in IdentityServer?

@arttonoyan
Copy link
Author

I’m referring to the BFF side. Specifically, I’m using the following packages:

  • Duende.BFF (version 2.2.0)
  • Duende.AccessTokenManagement.OpenIdConnect (version 2.1.2)

@arttonoyan
Copy link
Author

Multi-Authentication Scheme Configuration with BFF

Here is a similar setup that we are using to enable multi-authentication scheme support

builder.Services.AddBff(options => {
    // Some configuration
});

builder.Services
    .AddAuthentication(options => {
        options.DefaultScheme = MultiAuthenticationScheme;
        options.DefaultChallengeScheme = MultiAuthenticationScheme;
    })
    .AddCookie(options => {
        options.Cookie.Name = AccessTokenDefaults.Key;
        options.Cookie.HttpOnly = true;
        options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        options.Cookie.SameSite = SameSiteMode.None;
    })
    .AddJwtBearer("Schema1", options => { 
        // Configuration for Schema1
    })
    .AddOpenIdConnect("Schema2", options => {
        // Configuration for Schema2
    })
    .AddOpenIdConnect("SchemaN", options => {
        // Configuration for SchemaN
    })
    .AddPolicyScheme(MultiAuthenticationScheme, MultiAuthenticationDisplayName, options => {
        options.ForwardDefaultSelector = context =>
             context.RequestServices.GetRequiredService<IAuthSchemeManager>().GetAuthScheme();
    });

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

2 participants