Search code examples
c#.netasp.net-mvcidentityserver4

ASP.NET POST Form Data is lost after authentication on Identityserver


I have an asp.net mvc project using identityserver4 implicit flow as authentication method. I'm using Reference tokens instead of JWT, so that the MVC client application has to re-authorize the token every one in a while on the server.

The problem I'm facing is when I'm submitting a form on the client and on submit it goes to the authentication endpoint (right before the actual submit). After the authentication, I get sent back to my URL, but as a GET. Thus the POST data is lost and I have to fill in the form again.

What I'm trying to do now is keeping track of the form data before redirecting to identityserver4 and adjusting the response to make it a POST again:

The method below makes it possible to adjust the context (make it a POST request and add the correct data), but it still redirects back as a GET.

private Dictionary<string, IOwinRequest> tempRequests = new Dictionary<string, IOwinRequest>();

app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
{
    [removed for brevity...]

    Notifications = new OpenIdConnectAuthenticationNotifications()
    {
        RedirectToIdentityProvider = async (context) =>
        {
            if (context.ProtocolMessage.RequestType == OpenIdConnectRequestType.Authentication && context.Request.Method == HttpMethods.Post)
            {
                // When it's a POST request, save the request and keep track of it using a Guid reference
                string requestId = Guid.NewGuid().ToString();
                var stateQueryString = context.ProtocolMessage.State.Split('=');
                var protectedState = stateQueryString[1];
                var state = context.Options.StateDataFormat.Unprotect(protectedState);
                state.Dictionary.Add("OidcPostRedirectRequestId", requestId);
                tempRequests.Add(requestId, context.Request);
                context.ProtocolMessage.State = $"{stateQueryString[0]}={context.Options.StateDataFormat.Protect(state)}";
            }
        },
        SecurityTokenValidated = context =>
        {
            var stateQueryString = context.ProtocolMessage.State.Split('=');
            var protectedState = stateQueryString[1];
            var state = context.Options.StateDataFormat.Unprotect(protectedState);
            if (state.Dictionary.ContainsKey("OidcPostRedirectRequestId"))
            {
                // Reference found, update request and add form data back to it
                state.Dictionary.TryGetValue("OidcPostRedirectRequestId", out string requestId);
                if (!string.IsNullOrEmpty(requestId))
                {
                    state.Dictionary.Remove("OidcPostRedirectRequestId");
                    context.ProtocolMessage.State = $"{stateQueryString[0]}={context.Options.StateDataFormat.Protect(state)}";
                    tempRequests.TryGetValue(requestId, out IOwinRequest data);
                    if (data != null)
                    {
                        tempRequests.Remove(requestId);
                        context.Request.Body = data.Body;
                        context.Request.ContentType = data.ContentType;
                        context.Request.Method = data.Method;
                        context.Request.Headers.Clear();
                        foreach (var header in data.Headers)
                        {
                            context.Request.Headers.Add(header);
                        }
                    }
                }
            }
            return Task.FromResult(0);
        }
    }
});

I'm I going about this this wrong way? How can I make the redirect from Identityserver4 back to the client as a POST if necessary?

Thanks in advance.

-- UPDATE 17/07

Since it didn't work out, I'm trying to create a custom redirect handler by extending the OidcAuthorization middleware.

public static class OidcAuthenticationExtensions
{
    public static readonly string OidcPostRedirectKey = "OidcPostRedirectRequestId";
    public static Dictionary<string, IOwinRequest> PostRedirectRequests = new Dictionary<string, IOwinRequest>();
    public static IAppBuilder UseKpcOidcAuthentication(this IAppBuilder app, OpenIdConnectAuthenticationOptions options)
    {
        if (app == null)
        {
            throw new ArgumentNullException("app");
        }

        if (options == null)
        {
            throw new ArgumentNullException("openIdConnectOptions");
        }

        return app.Use(typeof(CustomAuthMiddleware), app, options);
    }
}

public class CustomOIDCAuthenticationHandler : OpenIdConnectAuthenticationHandler
{
    public CustomOIDCAuthenticationHandler(ILogger logger)
        : base(logger) { }

    public override Task<bool> InvokeAsync()
    {
        return InvokeReplyPathAsync();
    }

    private async Task<bool> InvokeReplyPathAsync()
    {
        AuthenticationTicket ticket = await AuthenticateAsync();

        if (ticket != null)
        {
            if (ticket.Properties.Dictionary.TryGetValue("HandledResponse", out string value) && value == "true")
            {
                return true;
            }
            if (ticket.Identity != null)
            {
                Request.Context.Authentication.SignIn(ticket.Properties, ticket.Identity);
            }
            // Redirect back to the original secured resource, if any.
            if (!string.IsNullOrWhiteSpace(ticket.Properties.RedirectUri))
            {
                Response.Redirect($"/Helper/RedirectHandler?redirectUrl={ticket.Properties.RedirectUri}");
                //Response.Redirect(ticket.Properties.RedirectUri);
                return true;
            }
        }
        return false;
    }
}

public class CustomAuthMiddleware : OpenIdConnectAuthenticationMiddleware
{
    private readonly ILogger _logger;
    public CustomAuthMiddleware(OwinMiddleware nextMiddleware, IAppBuilder app, OpenIdConnectAuthenticationOptions authOptions)
        : base(nextMiddleware, app, authOptions)
    {
        _logger = app.CreateLogger<CustomAuthMiddleware>();
    }

    protected override AuthenticationHandler<OpenIdConnectAuthenticationOptions> CreateHandler()
    {
        return new CustomOIDCAuthenticationHandler(_logger);
    }
}
public async Task<ActionResult> RedirectHandler(string redirectUrl)
{
    var claim = (((ClaimsPrincipal)User).FindFirst(OidcAuthenticationExtensions.OidcPostRedirectKey));
    if (claim != null)
    {
        var requestId = claim.Value;
        OidcAuthenticationExtensions.PostRedirectRequests.TryGetValue(requestId, out IOwinRequest data);
        if (data != null)
        {
            OidcAuthenticationExtensions.PostRedirectRequests.Remove(requestId);
            // TODO: post to action
            IFormCollection formData = await data.ReadFormAsync();
            //FormData is correct, but I need to be able to post it...
        }
    }
    return Redirect(redirectUrl);
}

Using this, I can just Redirect to the URL if the request was just a GET. When it's a POST though, I'd like to post the data. Any ideas on how to best achieve this?


Solution

  • For anyone else having the same problem, I got it solved by overriding the OpenIdConnectAuthenticationHandler.

    What I did first was implement my own custom OpenIdConnectAuthenticationMiddleware, which implements my custom AuthenticationHandler. By default, the OpenIdConnectAuthenticationHandler will always redirect by returning a 302 response code, thus resulting in loss of POST data.

    public static class OidcAuthenticationExtensions
    {
        public static readonly string OidcPostRedirectKey = "OidcPostRedirectRequestId";
        public static Dictionary<string, HttpContext> PostRedirectRequests = new Dictionary<string, HttpContext>();
        public static IAppBuilder UseKpcOidcAuthentication(this IAppBuilder app, OpenIdConnectAuthenticationOptions options)
        {
            if (app == null)
            {
                throw new ArgumentNullException("app");
            }
    
            if (options == null)
            {
                throw new ArgumentNullException("openIdConnectOptions");
            }
    
            return app.Use(typeof(CustomAuthMiddleware), app, options);
        }
    }
    
    public class CustomOIDCAuthenticationHandler : OpenIdConnectAuthenticationHandler
    {
        public CustomOIDCAuthenticationHandler(ILogger logger)
            : base(logger) { }
    
        public override Task<bool> InvokeAsync()
        {
            return InvokeReplyPathAsync();
        }
    
        private async Task<bool> InvokeReplyPathAsync()
        {
            AuthenticationTicket ticket = await AuthenticateAsync();
    
            if (ticket != null)
            {
                if (ticket.Properties.Dictionary.TryGetValue("HandledResponse", out string value) && value == "true")
                {
                    return true;
                }
                if (ticket.Identity != null)
                {
                    Request.Context.Authentication.SignIn(ticket.Properties, ticket.Identity);
                }
                // Redirect back to the original secured resource, if any.
                if (!string.IsNullOrWhiteSpace(ticket.Properties.RedirectUri))
                {
                    var claim = ((ClaimsIdentity)HttpContext.Current.User.Identity).FindFirst(OidcAuthenticationExtensions.OidcPostRedirectKey);
                    if (claim != null)
                    {
                        var requestId = claim.Value;
                        OidcAuthenticationExtensions.PostRedirectRequests.TryGetValue(requestId, out HttpContext data);
                        if (data != null)
                        {
                            WebExtensions.RedirectWithData(data.Request, data.Request.RawUrl);
                        }
                        ((ClaimsIdentity)HttpContext.Current.User.Identity).RemoveClaim(claim);
                        OidcAuthenticationExtensions.PostRedirectRequests.Remove(requestId);
                        if (data == null)
                        {
                            Response.Redirect(ticket.Properties.RedirectUri);
                        }
                    }
                    else
                    {
                        Response.Redirect(ticket.Properties.RedirectUri);
                    }
                    return true;
                }
            }
            return false;
        }
    }
    
    public class CustomAuthMiddleware : OpenIdConnectAuthenticationMiddleware
    {
        private readonly ILogger _logger;
        public CustomAuthMiddleware(OwinMiddleware nextMiddleware, IAppBuilder app, OpenIdConnectAuthenticationOptions authOptions)
            : base(nextMiddleware, app, authOptions)
        {
            _logger = app.CreateLogger<CustomAuthMiddleware>();
        }
    
        protected override AuthenticationHandler<OpenIdConnectAuthenticationOptions> CreateHandler()
        {
            return new CustomOIDCAuthenticationHandler(_logger);
        }
    }
    

    So if there is a Post action to perform, the middleware will recreate the request using following function:

    public static class WebExtensions
        {
            public static void RedirectWithData(HttpRequest request, string url)
            {
                HttpContext.Current.Response.Clear();
                StringBuilder s = new StringBuilder();
                s.Append("<html>");
                s.AppendFormat("<body onload='document.forms[\"form\"].submit()'>");
                s.AppendFormat("<form name='form' action='{0}' method='post'>", url);
                foreach (string key in request.Form)
                {
                    s.AppendFormat("<input type='hidden' name='{0}' value='{1}' />", key, request.Form[key]);
                }
                s.Append("</form></body></html>");
                HttpContext.Current.Response.Write(s.ToString());
                HttpContext.Current.Response.End();
            }
        }
    

    By implementing this, we can initialze the middleware in the Startup class as following:

    app.UseKpcOidcAuthentication(new OpenIdConnectAuthenticationOptions
    {
        [Removed for brevity...]
        Notifications = new OpenIdConnectAuthenticationNotifications()
        {
            RedirectToIdentityProvider = context =>
            {
                if (context.ProtocolMessage.RequestType == OpenIdConnectRequestType.Authentication && context.Request.Method == HttpMethods.Post)
                {
                    // When it's a POST request, save the request and keep track of it using a Guid reference
                    // Also add the requestId to the request state, so we can retrieve it after authentication on the IdentityServer
                    string requestId = Guid.NewGuid().ToString();
                    var stateQueryString = context.ProtocolMessage.State.Split('=');
                    var protectedState = stateQueryString[1];
                    var state = context.Options.StateDataFormat.Unprotect(protectedState);
                    state.Dictionary.Add(OidcAuthenticationExtensions.OidcPostRedirectKey, requestId);
                    OidcAuthenticationExtensions.PostRedirectRequests.Add(requestId, System.Web.HttpContext.Current);
                    context.ProtocolMessage.State = $"{stateQueryString[0]}={context.Options.StateDataFormat.Protect(state)}";
                }
                
                return Task.FromResult(0);
            },
            SecurityTokenValidated = context =>
            {
                // Retrieve possible requestId from the state
                var stateQueryString = context.ProtocolMessage.State.Split('=');
                var protectedState = stateQueryString[1];
                var state = context.Options.StateDataFormat.Unprotect(protectedState);
                if (state.Dictionary.ContainsKey(OidcAuthenticationExtensions.OidcPostRedirectKey))
                {
                    // Reference found, update request and add form data back to it
                    state.Dictionary.TryGetValue(OidcAuthenticationExtensions.OidcPostRedirectKey, out string requestId);
                    if (!string.IsNullOrEmpty(requestId))
                    {
                        state.Dictionary.Remove(OidcAuthenticationExtensions.OidcPostRedirectKey);
                        context.ProtocolMessage.State = $"{stateQueryString[0]}={context.Options.StateDataFormat.Protect(state)}";
                        // Temporarily add the request id to the user claims, so it can be read in the redirect handler
                        user.AddClaim(new System.Security.Claims.Claim(OidcAuthenticationExtensions.OidcPostRedirectKey, requestId));
                    }
                }
                return Task.FromResult(0);
            }
        }
    });
    

    So the flow we get now is:

    1. User submits a form on the client (which has an invalidated identity token)
    2. The client performs a redirect the the /connect/authorize endpoint to get a new token. Before the actual redirect, we store the initial HttpContext in memory referenced by a Guid. We also add this Guid to the request state. (See RedirectToIdentityProvider event)
    3. IdentityServer issues a new IdenityToken and redirects back to the client
    4. SecurityTokenValidated got triggered. We retrieve the reference Guid from the request and temporarily add it to the User Claims.
    5. The AuthenticationMiddleware invokes a redirect to the original Uri. We check if the user has a Claim referencing an original POST Request. If so, we will manually trigger the Postusing the RedirectWithData function; otherwise just redirect using 302.