1

we are curently developing a web application consisting of a ASP.NET Core Frontend, an Java JaxRS.Jersey API and a Keycloak as an OpenID-authentication server. In development, everything runs with http. For our OpenID we use code flow. Thus, the webapi returns no redirects in case of missing or old tokens. We have control over every component.

We are facing a problem when the user was inactive for a time longer than the access token lifetime: Console output

We suspect, that this is a configuration problem and we did not configure the CORS Header on one component correctly. Do we need to configure the CORS-Header on our Keycloak aswell? If so, how can we add the missing configuration?

This is our current code in ConfigureServices-Method form Startup.cs in the .NET Core Frontend:

using DefectsWebApp.Middleware;
using IdentityModel;
using IdentityModel.Client;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using System;
using System.Diagnostics;
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http;
using System.Security.Claims;
using System.Threading.Tasks;

namespace DefectsWebApp
{
    public class Startup
    {
        private bool isTokenRefreshRunning = false;
        private readonly object lockObj = new object();
        readonly string MyAllowSpecificOrigins = "_myAllowSpecificOrigins";

        private bool IsTokenRefreshRunning
        {
            get
            {
                lock(lockObj)
                {
                    return isTokenRefreshRunning;
                }
            }
            set
            {
                lock (lockObj)
                {
                    isTokenRefreshRunning = value;
                }
            }
        }

        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        // This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {
            JsonConvert.DefaultSettings = () => new JsonSerializerSettings
            {
                Formatting = Newtonsoft.Json.Formatting.Indented,
                ReferenceLoopHandling = Newtonsoft.Json.ReferenceLoopHandling.Ignore,                
            };

            services.AddCors(options =>
            {
                options.AddPolicy(name: MyAllowSpecificOrigins,
                    builder =>
                    {
                        builder.WithOrigins("http://keycloak:8080", "https://keycloak")
                            .AllowAnyHeader()
                            .AllowAnyMethod()
                            .AllowCredentials();
                    });
            });

            // get URL from Config
            services.Configure<QRoDServiceSettings>(Configuration.GetSection("QRodService"));

            services.AddSession();

            services.AddAuthorization(options =>
            {
                options.AddPolicy("Users", policy =>
                policy.RequireRole("Users"));
            });

            // source: https://stackoverflow.com/a/43875291
            services.AddAuthentication(options =>
            { 
                options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
                options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
                options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
            })

            // source: https://stackoverflow.com/questions/40032851/how-to-handle-expired-access-token-in-asp-net-core-using-refresh-token-with-open
            .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
            {
                options.Events = new CookieAuthenticationEvents
                {
                    // this event is fired everytime the cookie has been validated by the cookie middleware,
                    // so basically during every authenticated request
                    // the decryption of the cookie has already happened so we have access to the user claims
                    // and cookie properties - expiration, etc..
                    OnValidatePrincipal = async x =>
                    {
                        // since our cookie lifetime is based on the access token one,
                        // check if we're more than halfway of the cookie lifetime
                        var identity = (ClaimsIdentity)x.Principal.Identity;
                        var accessTokenClaim = identity.FindFirst("access_token");
                        var refreshTokenClaim = identity.FindFirst("refresh_token");
                        var accessToken = new JwtSecurityToken(accessTokenClaim.Value);

                        var now = DateTime.UtcNow.AddMinutes(2);
                        var timeRemaining = accessToken.ValidTo.Subtract(now);

                        var refreshtoken = new JwtSecurityToken(refreshTokenClaim.Value);
                        var timeRemainingRT = refreshtoken.ValidTo.Subtract(now);

                        timeRemaining = timeRemaining.TotalSeconds > 0 ? timeRemaining : new TimeSpan(0);
                        timeRemainingRT = timeRemainingRT.TotalSeconds > 0 ? timeRemainingRT : new TimeSpan(0);

                        Debug.WriteLine("Access-Token: {0} | timeleft: {1}", accessToken.Id, timeRemaining.ToString(@"hh\:mm\:ss"));
                        Debug.WriteLine("Refresh-Token: {0} | timeleft: {1}", refreshtoken.Id, timeRemainingRT.ToString(@"hh\:mm\:ss"));

                        if (timeRemaining.TotalMinutes <= 0 && !IsTokenRefreshRunning)
                        {
                            IsTokenRefreshRunning = true;

                            // if we have to refresh, grab the refresh token from the claims, and request
                            // new access token and refresh token
                            var refreshToken = refreshTokenClaim.Value;
                            var refreshTokenRequest = new RefreshTokenRequest
                            {
                                Address = Configuration["Authentication:oidc:OIDCRoot"] + Configuration["Authentication:oidc:Token"],
                                ClientId = Configuration["Authentication:oidc:ClientId"],
                                ClientSecret = Configuration["Authentication:oidc:ClientSecret"],
                                RefreshToken = refreshToken,
                            };

                            if (!refreshTokenRequest.Headers.Contains(Constants.ORIGIN_HEADER))
                            {
                                refreshTokenRequest.Headers.Add(Constants.ORIGIN_HEADER, Configuration["Authentication:oidc:OIDCRoot"] + "/*, *");
                            }
                            if (!refreshTokenRequest.Headers.Contains(Constants.CONTENT_HEADER))
                            {
                                refreshTokenRequest.Headers.Add(Constants.CONTENT_HEADER, "Origin, X-Requested-With, Content-Type, Accept");
                            }

                            var response = await new HttpClient().RequestRefreshTokenAsync(refreshTokenRequest);
                            Debug.WriteLine("Cookie.OnValidatePrincipal - Trying to refresh Token");

                            if (!response.IsError)
                            {
                                Debug.WriteLine("Cookie.OnValidatePrincipal - Response received");

                                // everything went right, remove old tokens and add new ones
                                identity.RemoveClaim(accessTokenClaim);
                                identity.RemoveClaim(refreshTokenClaim);

                                // indicate to the cookie middleware to renew the session cookie
                                // the new lifetime will be the same as the old one, so the alignment
                                // between cookie and access token is preserved

                                identity.AddClaims(new[]
                                {
                                    new Claim("access_token", response.AccessToken),
                                    new Claim("refresh_token", response.RefreshToken)
                                });

                                x.ShouldRenew = true;
                                x.HttpContext.Session.Set<string>(Constants.ACCESS_TOKEN_SESSION_ID, response.AccessToken);

                                Debug.WriteLine("Cookie.OnValidatePrincipal - Token refreshed");
                                IsTokenRefreshRunning = false;
                            }
                            else
                            {
                                Debug.WriteLine(string.Format("Cookie.OnValidatePrincipal - {0}", response.Error));
                                IsTokenRefreshRunning = false;
                            }
                        }
                    }
                };
            })
            .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
            {
                //options.AuthenticationMethod = OpenIdConnectRedirectBehavior.RedirectGet;
                options.Authority = Configuration["Authentication:oidc:OIDCRoot"];
                options.ClientId = Configuration["Authentication:oidc:ClientId"];
                options.ClientSecret = Configuration["Authentication:oidc:ClientSecret"];
                options.MetadataAddress = Configuration["Authentication:oidc:OIDCRoot"] + Configuration["Authentication:oidc:MetadataAddress"];

                options.CallbackPath = new PathString("/Home");
                options.RequireHttpsMetadata = false;

                // openid is already present by default: https://github.com/aspnet/Security/blob/e98a0d243a7a5d8076ab85c3438739118cdd53ff/src/Microsoft.AspNetCore.Authentication.OpenIdConnect/OpenIdConnectOptions.cs#L44-L45
                // adding offline_access to get a refresh token
                options.Scope.Add("offline_access");

                // we want IdSrv to post the data back to us
                //options.ResponseMode = OidcConstants.ResponseModes.FormPost;

                // we use the authorisation code flow, so only asking for a code
                options.ResponseType = OidcConstants.ResponseTypes.Code;

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

                // when the identity has been created from the data we receive,
                // persist it with this authentication scheme, hence in a cookie
                options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;

                // using this property would align the expiration of the cookie
                // with the expiration of the identity token
                options.UseTokenLifetime = true;

                options.Events = new OpenIdConnectEvents
                {
                    // that event is called after the OIDC middleware received the auhorisation code,
                    // redeemed it for an access token and a refresh token,
                    // and validated the identity token
                    OnTokenValidated = x =>
                    {
                        // store both access and refresh token in the claims - hence in the cookie
                        var identity = (ClaimsIdentity)x.Principal.Identity;
                        identity.AddClaims(new[]
                        {
                            new Claim("access_token", x.TokenEndpointResponse.AccessToken),
                            new Claim("refresh_token", x.TokenEndpointResponse.RefreshToken)
                        });

                        // so that we don't issue a session cookie but one with a fixed expiration
                        x.Properties.IsPersistent = true;

                        // align expiration of the cookie with expiration of the
                        // access token
                        var accessToken = new JwtSecurityToken(x.TokenEndpointResponse.AccessToken);
                        x.Properties.ExpiresUtc = accessToken.ValidTo;
                        x.Properties.IssuedUtc = DateTime.UtcNow;
                        x.Properties.AllowRefresh = true;

                        Debug.WriteLine("OIDC.OnTokenValidated - Token validated, Issued UTC: {0}, Expires UTC: {1}", x.Properties.IssuedUtc, x.Properties.ExpiresUtc);

                        x.HttpContext.Session.Set<string>(Constants.ACCESS_TOKEN_SESSION_ID, x.TokenEndpointResponse.AccessToken);

                        return Task.CompletedTask;
                    }
                };
            });

            services.AddAntiforgery(options => options.HeaderName = "X-CSRF-TOKEN");
            services.AddControllersWithViews();
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerFactory loggerFactory)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler("/Home/Error");
                // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
                app.UseHsts();
            }

            loggerFactory.AddLog4Net();
            app.UseSession();
            //Register Syncfusion license
            Syncfusion.Licensing.SyncfusionLicenseProvider.RegisterLicense("License");

            app.UseAuthentication();
            app.UseCors();
            app.UseCorsHeaderMiddleware();
            app.UseExceptionHandlingMiddleware();

            if (!env.IsDevelopment())
            {
                app.UseHttpsRedirection();
            }

            app.UseStaticFiles();

            app.UseRouting();

            app.UseCors(MyAllowSpecificOrigins);

            app.UseAuthorization();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllerRoute(
                    name: "default",
                    pattern: "{controller=Home}/{action=Index}/{id?}");
            });
        }
    }
}

For the sake of completeness, here is the code for our cors-middleware:

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using System.Threading.Tasks;

namespace DefectsWebApp.Middleware
{
    public class CorsHeaderMiddleware
    {
        private readonly RequestDelegate _next;
        private IConfiguration _configuration;
        private string _origin;

        /// <summary>
        /// Ctor
        /// </summary>
        /// <param name="next">Reference to following request</param>
        public CorsHeaderMiddleware(RequestDelegate next, IConfiguration configuration)
        {
            _next = next;
            _configuration = configuration;
            _origin = _configuration["Authentication:oidc:OIDCRoot"] + "/*, /*";
        }

        /// <summary>
        /// Fügt dem Request IMMER den Header "Access-Control-Allow-Origin" hinzu
        /// </summary>
        public async Task Invoke(HttpContext httpContext)
        {
            var request = httpContext.Request;
            if (!request.Headers.ContainsKey(Constants.ORIGIN_HEADER))
            {
                request.Headers.Add(Constants.ORIGIN_HEADER, _origin);
            }
            if (!request.Headers.ContainsKey(Constants.CONTENT_HEADER))
            {
                request.Headers.Add(Constants.CONTENT_HEADER, "Origin, X-Requested-With, Content-Type, Accept");
            }

            await _next(httpContext);
        }
    }

    public static class CorsHeaderMiddlewareExtensions
    {
        public static IApplicationBuilder UseCorsHeaderMiddleware(this IApplicationBuilder builder)
        {
            return builder.UseMiddleware<CorsHeaderMiddleware>();
        }
    }
}

Edit 1 [2020-04-30 10:45]

This is our current configuration. To eliminate problems regarding localhost, we entered our test machine's DNS name as web origin. Keycloak Configuration

T.M.
  • 79
  • 6

2 Answers2

0

I guess you didn't configure Web Origins (that is not the same as Redirect URIs) in your OIDC client configuration in the Keycloak. You can use '*', because you are using http protocol.

I hope you know that https protocol is mandatory for OIDC flows and also that '*' is not valid Web Origin value for https protocol. So it is good idea to configure Web Origins explicitly, instead of wildcard for the future.

The best idea is to use already in dev:

  • https, because it may work in dev on http, but then you will move to https in prod and it will be broken
  • domain (you can "fake" it with local hosts file) instead of localhost, because some browsers may have a problem with localhost/127.0.0.1/...
Jan Garaj
  • 25,598
  • 3
  • 38
  • 59
  • Thank you very much for your feedback, I edited the original post to include our current configuration. From what we assume, it looks good. Are you aware of problems regarding configuring localhost as a web origin? – T.M. Apr 30 '20 at 08:41
  • @T.M. from your input it is not clear if that `pc-XX` is correct web origin or not. – Jan Garaj Apr 30 '20 at 09:19
  • Thanks for your fast reply. We'll try using https in dev environment. I'll check back if there that still does not solve the problem. Thanks for your input! – T.M. Apr 30 '20 at 12:55
0

this is actually is a bug in dotnet core.

try to add cors policy right in the "Configure" method.

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }
    app.UseRouting();
    app.UseCors(option =>
        option.AllowAnyOrigin()
            .AllowAnyMethod()
            .AllowAnyHeader()
            );
}
  • Thank you for your answer. Just added that code snippet, but it did not work. A similiar code snippet is also in `ConfigureServices`, are there any differences between these two? – T.M. Apr 30 '20 at 12:53