Search code examples
asp.net-coreidentityserver4openid-connect

OpenIdConnect Redirect on Form POST


Why does a form POST with an expired access_token result in a GET when using the Microsoft.AspNetCore.Authentication.OpenIdConnect middleware? When this happens, any data entered into a form is lost, since it doesn't reach the HttpPost endpoint. Instead, the request is redirected to the same URI with a GET, following the signin-oidc redirect. Is this a limitation, or do I have something configured incorrectly?

I noticed this issue after shortening the AccessTokenLifetime with the intent of forcing the user's claims to be renewed more frequently (i.e. if the user were disabled or they had claims revoked). I have only reproduced this when the OpenIdConnect middleware's OpenIdConnectionOptions are set to true options.UseTokenLifetime = true; (setting this to false results in the authenticated user's claims not being updated as expected).

I was able to recreate and demonstrate this behavior using the IdentityServer4 sample quickstart 5_HybridFlowAuthenticationWithApiAccess with the following changes below. Basically, there is an authorized form that has an HttpGet and an HttpPost method. If you wait longer than the AccessTokenLifetime (configured to only 30 seconds in this example) to submit the form, the HttpGet method is called instead of the HttpPost method.

Modifications to MvcClient/Startup.cs

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc();

    JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();

    services.AddAuthentication(options =>
        {
            options.DefaultScheme = "Cookies";
            options.DefaultChallengeScheme = "oidc";
        })
        .AddCookie("Cookies", options =>
        {
            // the following was added
            options.SlidingExpiration = false;
        })
        .AddOpenIdConnect("oidc", options =>
        {
            options.SignInScheme = "Cookies";

            options.Authority = "http://localhost:5000";
            options.RequireHttpsMetadata = false;

            options.ClientId = "mvc";
            options.ClientSecret = "secret";
            options.ResponseType = "code id_token";

            options.SaveTokens = true;
            options.GetClaimsFromUserInfoEndpoint = true;

            options.Scope.Add("openid");
            options.Scope.Add("api1");

            options.ClaimActions.MapJsonKey("website", "website");

            // the following were changed
            options.UseTokenLifetime = true;
            options.Scope.Add("offline_access");
        });
}

Modifications to the Client list in IdentityServer/Config.cs

new Client
{
    ClientId = "mvc",
    ClientName = "MVC Client",
    AllowedGrantTypes = GrantTypes.Hybrid,

    ClientSecrets =
    {
        new Secret("secret".Sha256())
    },

    RedirectUris           = { "http://localhost:5002/signin-oidc" },
    PostLogoutRedirectUris = { "http://localhost:5002/signout-callback-oidc" },

    AllowedScopes =
    {
        IdentityServerConstants.StandardScopes.OpenId,
        IdentityServerConstants.StandardScopes.Profile,
        "api1",
        IdentityServerConstants.StandardScopes.OfflineAccess,
    },

    AllowOfflineAccess = true,

    // the following properties were configured:
    AbsoluteRefreshTokenLifetime = 14*60*60,
    AccessTokenLifetime = 30,
    IdentityTokenLifetime = 15,
    AuthorizationCodeLifetime = 15,
    SlidingRefreshTokenLifetime = 60,
    RefreshTokenUsage = TokenUsage.OneTimeOnly,
    UpdateAccessTokenClaimsOnRefresh = true,                    
    RequireConsent = false,
}

Added to MvcClient/Controllers/HomeController

[Authorize]
[HttpGet]
[Route("home/test", Name = "TestRouteGet")]
public async Task<IActionResult> Test()
{
    TestViewModel viewModel = new TestViewModel
    {
        Message = "GET at " + DateTime.Now,
        TestData = DateTime.Now.ToString(),
        AccessToken = await this.HttpContext.GetTokenAsync("access_token"),
        RefreshToken = await this.HttpContext.GetTokenAsync("refresh_token"),
    };

    return View("Test", viewModel);
}
[Authorize]
[HttpPost]
[Route("home/test", Name = "TestRoutePost")]
public async Task<IActionResult> Test(TestViewModel viewModel)
{
    viewModel.Message = "POST at " + DateTime.Now;
    viewModel.AccessToken = await this.HttpContext.GetTokenAsync("access_token");
    viewModel.RefreshToken = await this.HttpContext.GetTokenAsync("refresh_token");

    return View("Test", viewModel);
}

Solution

  • After further research and investigation, I came to the conclusion that completing a form POST that is redirected to the OIDC provider is not supported out of the box (at least for Identity Server, but I suspect that is also true for other identity connect providers as well). Here is the only mention I can find of this: Sending Custom Parameters to Login Page

    I was able to come up with a workaround for the issue, which I've outlined below and is hopefully useful to others. The key components are the following OpenIdConnect and Cookie middleware events:

    • OpenIdConnectEvents.OnRedirectToIdentityProvider - save Post requests for later retrieval
    • CookieAuthenticationEvents.OnValidatePrincipal - check for saved Post requests and update the current request with saved state

    The OpenIdConnect middleware exposes the OnRedirectToIdentityProvider event which gives us the opportunity to:

    • determine if this is a form post for an expired access token
    • modify the RedirectContext to include a custom request id with the AuthenticationProperties Items dictionary
    • Map the current HttpRequest to an HttpRequestLite object that can be persisted to a cache store, I recommend using an expiring, distributed cache for load-balanced environments. I'm using a static dictionary here for simplicity
        new OpenIdConnectEvents
        {
            OnRedirectToIdentityProvider = async (context) =>
            {
                if (context.HttpContext.Request.Method == HttpMethods.Post && context.Properties.ExpiresUtc == null)
                {
                    string requestId = Guid.NewGuid().ToString();
    
                    context.Properties.Items["OidcPostRedirectRequestId"] = requestId;
    
                    HttpRequest requestToSave = context.HttpContext.Request;
    
                    // EXAMPLE - saving this to memory which would work on a non-loadbalanced or stateful environment. Recommend persisting to external store such as Redis.
                    postedRequests[requestId] = await HttpRequestLite.BuildHttpRequestLite(requestToSave);
                }
    
                return;
            },
        };
    
    

    The Cookie middleware exposes the OnValidatePrincipal event which gives us the opportunity to:

    • check the CookieValidatePrincipalContext for AuthenticationProperties Items for custom dictionary items. We check it for the ID of our saved/cached request
      • it's important that we remove the item after we read it so that subsequent requests do not replay the wrong form submission, setting ShouldRenew to true persists any changes on subsequent requests
    • check our external cache for items that match our key, I recommend using an expiring, distributed cache for load-balanced environments. I'm using a static dictionary here for simplicity
    • read our custom HttpRequestLite object and override the Request object in the CookieValidatePrincipalContext object
    
        new CookieAuthenticationEvents
        {
            OnValidatePrincipal = (context) =>
            {
                if (context.Properties.Items.ContainsKey("OidcPostRedirectRequestId"))
                {
                    string requestId = context.Properties.Items["OidcPostRedirectRequestId"];
                    context.Properties.Items.Remove("OidcPostRedirectRequestId");
    
                    context.ShouldRenew = true;
    
                    if (postedRequests.ContainsKey(requestId))
                    {
                        HttpRequestLite requestLite = postedRequests[requestId];
                        postedRequests.Remove(requestId);
    
                        if (requestLite.Body?.Any() == true)
                        {
                            context.Request.Body = new MemoryStream(requestLite.Body);
                        }
                        context.Request.ContentLength = requestLite.ContentLength;
                        context.Request.ContentLength = requestLite.ContentLength;
                        context.Request.ContentType = requestLite.ContentType;
                        context.Request.Method = requestLite.Method;
                        context.Request.Headers.Clear();
                        foreach (var header in requestLite.Headers)
                        {
                            context.Request.Headers.Add(header);
                        }
                    }
    
                }
                return Task.CompletedTask;
            },
        };
    
    

    We need a class to map HttpRequest to/from for serialization purposes. This reads the HttpRequest and it's body without modifying the contents, it leaves the HttpRequest untouched for additional middleware that may try to read it after we do (this is important when trying to read the Body stream which can only be read once by default).

    
        using System.Collections.Generic;
        using System.IO;
        using System.Text;
        using System.Threading.Tasks;
        using Microsoft.AspNetCore.Http;
        using Microsoft.AspNetCore.Http.Internal;
        using Microsoft.Extensions.Primitives;
    
        public class HttpRequestLite
        {
            public static async Task<HttpRequestLite> BuildHttpRequestLite(HttpRequest request)
            {
                HttpRequestLite requestLite = new HttpRequestLite();
    
                try
                {
                    request.EnableRewind();
                    using (var reader = new StreamReader(request.Body))
                    {
                        string body = await reader.ReadToEndAsync();
                        request.Body.Seek(0, SeekOrigin.Begin);
    
                        requestLite.Body = Encoding.ASCII.GetBytes(body);
                    }
                    //requestLite.Form = request.Form;
                }
                catch
                {
    
                }
    
                requestLite.Cookies = request.Cookies;
                requestLite.ContentLength = request.ContentLength;
                requestLite.ContentType = request.ContentType;
                foreach (var header in request.Headers)
                {
                    requestLite.Headers.Add(header);
                }
                requestLite.Host = request.Host;
                requestLite.IsHttps = request.IsHttps;
                requestLite.Method = request.Method;
                requestLite.Path = request.Path;
                requestLite.PathBase = request.PathBase;
                requestLite.Query = request.Query;
                requestLite.QueryString = request.QueryString;
                requestLite.Scheme = request.Scheme;
    
                return requestLite;
    
            }
    
            public QueryString QueryString { get; set; }
    
            public byte[] Body { get; set; }
    
            public string ContentType { get; set; }
    
            public long? ContentLength { get; set; }
    
            public IRequestCookieCollection Cookies { get; set; }
    
            public IHeaderDictionary Headers { get; } = new HeaderDictionary();
    
            public IQueryCollection Query { get; set; }
    
            public IFormCollection Form { get; set; }
    
            public PathString Path { get; set; }
    
            public PathString PathBase { get; set; }
    
            public HostString Host { get; set; }
    
            public bool IsHttps { get; set; }
    
            public string Scheme { get; set; }
    
            public string Method { get; set; }
        }