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:
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.