How to write in memory integration test for a WebApi secured by OpenId Connect, cookie authentication and hosted by NancyFx and Owin - integration-testing

I have a WabApi project which uses Owin and NancyFX. The api is secured by OpenId Connect and cookie authentication.
I have to write some in memory integration tests using HttpClient, which is quite easy as long as you don't try to use authentication based on OpenId Connect and cookie.
Does anyone know how to prepare a proper authentication cookie for HttpClient to let it connect with WebApi as authenticated user?
Currently I'm able to do some http calls to get the proper access token, id token etc. from OpenId Connect provider (implemented by IdentityServer v3), but I have no idea how to prepare authentication cookie for HttpClient.
PS: I uses Hybrid flow for OpenId Connect
Below you can find some of my files.
Server project:
AppStartup for WebApi:
The server application hosts WebApi and OpenId Connect provider (IdentityServer v3) at the same time, so its app starup looks like this:
public class ServerAppStartup
{
public static void Configuration(IAppBuilder app)
{
app.Map("/identity", idsrvApp =>
{
var factory = new IdentityServerServiceFactory {...};
idsrvApp.UseIdentityServer(new IdentityServerOptions
{
SiteName = "server app",
SigningCertificate = ...,
RequireSsl = false,
Factory = factory,
AuthenticationOptions = new AuthenticationOptions {
RememberLastUsername = true
},
EnableWelcomePage = false
});
});
app.SetDefaultSignInAsAuthenticationType("ClientCookie");
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
AuthenticationMode = AuthenticationMode.Active,
AuthenticationType = "ClientCookie",
CookieName = CookieAuthenticationDefaults.CookiePrefix + "ClientCookie",
ExpireTimeSpan = TimeSpan.FromMinutes(5)
});
app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
{
AuthenticationMode = AuthenticationMode.Active,
AuthenticationType = OpenIdConnectAuthenticationDefaults.AuthenticationType,
SignInAsAuthenticationType = app.GetDefaultSignInAsAuthenticationType(),
Authority = options.BaseUrl+ "identity",
ClientId = options.ClientId,
RedirectUri = options.RedirectUri,
PostLogoutRedirectUri = options.PostLogoutRedirectUri,
ResponseType = "code id_token",
Scope = "openid profile offline_access",
Notifications = new OpenIdConnectAuthenticationNotifications
{
AuthorizationCodeReceived = async n =>
{
/* stuff to get ACCESS TOKEN from CODE TOKEN */
},
RedirectToIdentityProvider = n =>
{
if (n.ProtocolMessage.RequestType == OpenIdConnectRequestType.LogoutRequest)
{
var idTokenHint = n.OwinContext.Authentication.User.FindFirst("id_token");
if (idTokenHint != null)
{
n.ProtocolMessage.IdTokenHint = idTokenHint.Value;
}
}
return Task.FromResult(0);
}
}
}
JwtSecurityTokenHandler.InboundClaimTypeMap = new Dictionary<string, string>();
app.UseNancy();
app.UseStageMarker(PipelineStage.MapHandler);
}
Sample nancy module (something like controller in MVC or WebApi):
using System;
using Nancy.ModelBinding;
using Nancy.Security;
namespace Server.Modules
{
public class UsersModule : BaseModule
{
public UsersModule() : base("/users")
{
Get["/getall"] = parameters =>
{
this.RequiresMSOwinAuthentication();
...
return ...;
};
}
}
}
Integration test project:
Test server to let me run WebApi in memory:
public class TestServer: IDisposable
{
private Func<IDictionary<string, object>, Task> _appFunc;
public static CookieContainer CookieContainer;
public Uri BaseAddress { get; set; }
// I uses OwinHttpMessageHandler becaouse it can handle http redirections
public OwinHttpMessageHandler Handler { get; private set; }
public HttpClient HttpClient => new HttpClient(Handler) { BaseAddress = BaseAddress };
public static TestServer Create()
{
CookieContainer = new CookieContainer();
var result = new TestServer();
var appBuilder = new AppBuilder();
appBuilder.Properties["host.AppName"] = "WebApi server";
/* Use configuration of server app */
ServerAppStartup.Configuration(appBuilder);
result._appFunc = appBuilder.Build();
result.Handler = new OwinHttpMessageHandler(result._appFunc)
{
AllowAutoRedirect = true,
AutoRedirectLimit = 1000,
CookieContainer = CookieContainer,
UseCookies = true
};
return result;
}
public void Dispose()
{
Handler.Dispose();
GC.SuppressFinalize(this);
}
}
Sample test:
namespace ServerSpec.Specs.Users
{
public class GetAllUsersSpec
{
private TestServer _server;
public GetAllUsersSpec(){
server = TestServer.create();
}
[Fact]
public void should_return_all_users()
{
/* here I will get error because http client or rather its cookie handler has no authentication cookie */
var users = Get("/users/getall");
...
}
public TResponse Get<TResponse>(string urlFragment)
{
var client = server.HttpClient();
var httpResponse = client.GetAsync(urlFragment).Result;
httpResponse.EnsureSuccessStatusCode();
return httpResponse.Content.ReadAsAsync<TResponse>().Result;
}
}
}

Check out the unit and integration tests in this project:
https://github.com/IdentityModel/IdentityModel.Owin.PopAuthentication
It shows doing in-memory integration testing with IdentityServer in one pipeline, and a (fake) web api in another pipeline that accepts tokens.

Related

Sustainsys.Saml2 multitenant implementation with app.Map()

I have multi tenant application where each tenant can use different IdP to authenticate. Below code correctly redirects to IdP but problem is to get back the response to ACS endpoint.
Key is the Configuration method which configures the paths and their authentication:
[assembly: OwinStartup(typeof(SSOSamlDemoASPNET.App_Start.Startup))]
namespace SSOSamlDemoASPNET.App_Start
{
public class Startup
{
public void Configuration(IAppBuilder app)
{
app.Map("/client/okta", (appx) =>
{
ConfigureAuthentication(appx, "/client/okta/Saml2", ...);
});
app.Map("/client/azuread", (appx) =>
{
ConfigureAuthentication(appx, "/client/azuread/Saml2", ...);
});
}
private static void ConfigureAuthentication(IAppBuilder app, string modulePath, string audience, string issuer, string metadataUrl)
{
app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
AuthenticationType = CookieAuthenticationDefaults.AuthenticationType,
CookieName = "LoggedUser",
CookiePath = "/",
CookieManager = new SystemWebCookieManager(),
});
app.UseExternalSignInCookie(DefaultAuthenticationTypes.ExternalCookie);
ConfigureSaml(app, modulePath, audience, issuer, metadataUrl);
}
private static void ConfigureSaml(IAppBuilder app, string modulePath, string audience, string issuer, string metadataUrl)
{
var saml2options = new Saml2AuthenticationOptions(false);
var spOptions = new SPOptions
{
EntityId = new EntityId(audience),
ModulePath = modulePath,
PublicOrigin = new Uri("https://localhost:44340/"),
};
spOptions.Logger = new ConsoleLoggerAdapter();
saml2options.SPOptions = spOptions;
saml2options.IdentityProviders.Add(new IdentityProvider(new EntityId(issuer), spOptions)
{
AllowUnsolicitedAuthnResponse = true,
MetadataLocation = metadataUrl,
LoadMetadata = true,
Binding = Saml2BindingType.HttpPost,
});
app.UseSaml2Authentication(saml2options);
}
}
}
Authenticating against individual IdP is done like this:
authProperties.Dictionary["idp"] = "https://sts.windows.net/xxx/";
authProperties.RedirectUri = "https://localhost:44340/client/azuread/ExternalLoginCallback";
HttpContext.Current.Request.GetOwinContext().Authentication.Challenge(authProperties, "Saml2");
When inspecting code of the Sustainsys.Saml2 library (especially Saml2AuthenticationHandler). I found the conditions do not take into account OwinRequest.PathBase and therefore the identity is not coming back to the application.
An example can be (Saml2AuthenticationHandler.Invoke method).
Options.SPOptions.ModulePath = /client/azuread/Saml2
Request.Path = /Saml2/Acs
==> therefore the code inside the condition is not executed.
public override async Task<bool> InvokeAsync()
{
var Saml2Path = new PathString(Options.SPOptions.ModulePath);
if (Request.Path.StartsWithSegments(Saml2Path, out PathString remainingPath))
{
if (remainingPath == new PathString("/" + CommandFactory.AcsCommandName))
{
var ticket = (MultipleIdentityAuthenticationTicket)await AuthenticateAsync();
if (ticket.Identities.Any())
{
Context.Authentication.SignIn(ticket.Properties, ticket.Identities.ToArray());
// No need to redirect here. Command result is applied in AuthenticateCoreAsync.
}
else
{
Response.Redirect(ticket.Properties.RedirectUri);
}
return true;
}
Is there any way to change this behavioral? e.g. saml2Options.Notifications to get this working?
That is obviously a bug/lack of feature, but nothing that will be fixed on the Owin module - it's on life support.
The solution for a multi tenancy owin app is to register one Saml2 middleware and add multiple IdentityProviders to that one. The middleware will handle all responses on the same endpoint and use the configuration from the right IdentityProvider based on where the response came from.

ASP.Net MVC Client (.net framework 4.6.2) does not get redirected to after logout

I have a Identity Server 4 implementation in .Net core 3. And I also created 3 clients: Angular, .Net Core MVC (.Net Core 3.0) and .Net framework MVC (.Net framework 4.6.2).
The Angular and .Net Core MVC clients work without any problems but I have a problem with the .Net framework MVC client. It will not redirect back to the client from Identity Server.
.Net Framework MVC startup
private void ConfigureAuth(IAppBuilder app)
{
app.UseCookieAuthentication(new CookieAuthenticationOptions {AuthenticationType = CookieAuthenticationDefaults.AuthenticationType,});
app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
{
AuthenticationType = OpenIdConnectAuthenticationDefaults.AuthenticationType,
SignInAsAuthenticationType = CookieAuthenticationDefaults.AuthenticationType,
Authority = "https://localhost:5001/",
RequireHttpsMetadata = false,
ResponseType = "id_token",
RedirectUri = "https://localhost:44333/signin-oidc",
PostLogoutRedirectUri = "https://localhost:44333/signout-callback-oidc",
ClientId = "mvc-framework",
SaveTokens = true
});
}
Logout code:
[Authorize]
public ActionResult SignOut()
{
HttpContext.GetOwinContext().Authentication.SignOut(CookieAuthenticationDefaults.AuthenticationType, OpenIdConnectAuthenticationDefaults.AuthenticationType);
return RedirectToAction("Index", "Home");
}
Identity Server Setup:
internal static IServiceCollection AddConfiguredIdentityServer4InMemory(this IServiceCollection services, IConfiguration configuration, IWebHostEnvironment webHostingEnvironment)
{
var builder = services.AddIdentityServer()
.AddInMemoryIdentityResources(InMemoryData.GetIdentityResources())
.AddInMemoryApiResources(InMemoryData.GetApiResources())
.AddInMemoryClients(InMemoryData.GetClients())
.AddTestUsers(InMemoryData.GetUsers());
if (webHostingEnvironment.IsDevelopment())
builder.AddDeveloperSigningCredential();
else
throw new Exception("need to configure key material"); //ToDo: work with certificate in key vault.
return services;
}
Client configuration:
internal static IEnumerable<Client> GetClients()
{
return new[]
{
// OpenID Connect implicit flow MVC .Net Framework client
new Client
{
ClientId = "mvc-framework",
ClientName = "MVC .Net Framework Client",
AllowedGrantTypes = GrantTypes.Implicit,
RequireConsent = false,
// where to redirect to after login
RedirectUris = { "https://localhost:44333/signin-oidc" },
// where to redirect to after logout
PostLogoutRedirectUris = { "https://localhost:44333/signout-callback-oidc" },
// scopes
AllowedScopes = new List<string> {IdentityServerConstants.StandardScopes.OpenId, IdentityServerConstants.StandardScopes.Profile}
},
// OpenID Connect implicit flow MVC .Net Core client
new Client
{
ClientId = "mvc-core",
ClientName = "MVC .Net Core Client",
AllowedGrantTypes = GrantTypes.Implicit,
RequireConsent = false,
// where to redirect to after login
RedirectUris = { "https://localhost:5003/signin-oidc" },
// where to redirect to after logout
PostLogoutRedirectUris = { "https://localhost:5003/signout-callback-oidc" },
AllowedScopes = new List<string> {IdentityServerConstants.StandardScopes.OpenId, IdentityServerConstants.StandardScopes.Profile}
},
new Client
{
ClientId = "angular_spa",
ClientName = "Angular SPA",
AllowedGrantTypes = GrantTypes.Implicit,
RequireConsent = false,
// where to redirect to after login
RedirectUris = { "http://localhost:4200/auth-callback" },
// where to redirect to after logout
PostLogoutRedirectUris = { "http://localhost:4200/" },
// cors
AllowedCorsOrigins = {"http://localhost:4200"},
AllowedScopes = new List<string> {IdentityServerConstants.StandardScopes.OpenId, IdentityServerConstants.StandardScopes.Profile}
}
};
}
Identity Server Account configuration:
public class AccountOptions
{
public static bool AllowLocalLogin = true;
public static bool AllowRememberLogin = true;
public static TimeSpan RememberMeLoginDuration = TimeSpan.FromDays(30);
public static bool ShowLogoutPrompt = false;
public static bool AutomaticRedirectAfterSignOut = true;
public static readonly string WindowsAuthenticationSchemeName = Microsoft.AspNetCore.Server.IISIntegration.IISDefaults.AuthenticationScheme;
public static bool IncludeWindowsGroups = false;
public static string InvalidCredentialsErrorMessage = "Invalid username or password";
}
When I use the .Net framework MVC client and logout I'm redirected to Identity Server and the user is logged out without a problem but my browser gets stuck on:
LogOut page of Identity Server
The PostLogoutRedirectUri is empty on the LoggedOutViewModel but I'm not sure why. Both other clients get redirect to after logout.
Any ideas why my .Net framework MVC (.Net framework 4.6.2) client does not get redirected to?
or why its PostLogoutRedirectUri is empty on the LoggedOutViewModel?
The IdentityServer needs the id_token in order to proceed with (automatic) redirect. Because this doesn't occur, it seems the id token is not present.
Take a look at the issue here for more information.
To solve it you'll have to include the token on logout:
app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
{
Notifications = new OpenIdConnectAuthenticationNotifications
{
RedirectToIdentityProvider = n =>
{
// if signing out, add the id_token_hint
if (n.ProtocolMessage.RequestType == OpenIdConnectRequestType.LogoutRequest)
{
var idTokenHint = n.OwinContext.Authentication.User.FindFirst("id_token");
if (idTokenHint != null)
n.ProtocolMessage.IdTokenHint = idTokenHint.Value;
return Task.FromResult(0);
}
}
}
}
}
To enable automatic redirect take a look at my answer here.

Microsoft Graph in asp.net web forms access token expires - how to refresh tokens in web forms application and not MVC

I have an asp.net 4.6 web forms application (no MVC). I am updating the security in my application. I am using OpenIdConnectAuthentication to authenticate with our Azure AD. Then I pass the access token to Microsoft graph to send an email with Office 365. My token is set to expire in 60 minutes. I either need to expand the expiration to 8 hours or refresh the token. Without having MVC I am not sure how to handle this. I am looking for help with direction to take and possibly code samples.
(I original tried to utilize an MVC sample and put it into my project using a Session Token class. Once we tested with multiple users I believe I had a memory leak and it would crash in about 5 minutes.)
Startup code:
public class Startup
{
private readonly string _clientId = ConfigurationManager.AppSettings["ClientId"];
private readonly string _redirectUri = ConfigurationManager.AppSettings["RedirectUri"];
private readonly string _authority = ConfigurationManager.AppSettings["Authority"];
private readonly string _clientSecret = ConfigurationManager.AppSettings["ClientSecret"];
public void Configuration(IAppBuilder app)
{
ConfigureAuth(app);
}
public void ConfigureAuth(IAppBuilder app)
{
app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
CookieManager = new SystemWebCookieManager(),
});
app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
{
ClientId = _clientId,
ClientSecret = _clientSecret,
//Authority = _authority,
Authority = String.Format(_authority, domain, "/v2.0"),
RedirectUri = _redirectUri,
ResponseType = OpenIdConnectResponseType.CodeIdToken,
Scope = OpenIdConnectScope.OpenIdProfile,
UseTokenLifetime = false,
TokenValidationParameters = new TokenValidationParameters { NameClaimType = "name", RequireExpirationTime = false},
Notifications = new OpenIdConnectAuthenticationNotifications
{
AuthorizationCodeReceived = async n =>
{
// Exchange code for access and ID tokens
var auth = String.Format(_authority, "common/oauth2/v2.0", "/token");
var tokenClient = new TokenClient($"{auth}", _clientId, _clientSecret);
var tokenResponse = await tokenClient.RequestAuthorizationCodeAsync(n.Code, _redirectUri);
if (tokenResponse.IsError)
{
throw new Exception(tokenResponse.Error);
}
var claims = new List<Claim>()
{
new Claim("id_token", tokenResponse.IdentityToken),
new Claim("access_token", tokenResponse.AccessToken)
};
n.AuthenticationTicket.Identity.AddClaims(claims);
},
},
});
}
}
SDK Helper:
public class SDKHelper
{
// Get an authenticated Microsoft Graph Service client.
public static GraphServiceClient GetAuthenticatedClient()
{
GraphServiceClient graphClient = new GraphServiceClient(
new DelegateAuthenticationProvider(
async (requestMessage) =>
{
string accessToken = System.Security.Claims.ClaimsPrincipal.Current.FindFirst("access_token").Value;
// Append the access token to the request.
requestMessage.Headers.Authorization = new AuthenticationHeaderValue("bearer", accessToken);
// Get event times in the current time zone.
requestMessage.Headers.Add("Prefer", "outlook.timezone=\"" + TimeZoneInfo.Local.Id + "\"");
// This header has been added to identify our sample in the Microsoft Graph service. If extracting this code for your project please remove.
requestMessage.Headers.Add("SampleID", "aspnet-snippets-sample");
}));
return graphClient;
}
}
Sending Email:
GraphServiceClient graphClient = SDKHelper.GetAuthenticatedClient();
string address = emailaddress;
string guid = Guid.NewGuid().ToString();
List<Recipient> recipients = new List<Recipient>();
recipients.Add(new Recipient
{
EmailAddress = new Microsoft.Graph.EmailAddress
{
Address = address
}
});
// Create the message.
Message email = new Message
{
Body = new ItemBody
{
ContentType = Microsoft.Graph.BodyType.Text,
},
Subject = "TEST",
ToRecipients = recipients,
From = new Recipient
{
EmailAddress = new Microsoft.Graph.EmailAddress
{
Address = address
}
}
};
// Send the message.
try
{
graphClient.Me.SendMail(email, true).Request().PostAsync().Wait();
}
catch (ServiceException exMsg)
{
}
You need to request the scope offline_access. Once you've requested that, the /token endpoint will return both an access_token and a refresh_token. When your token expires, you can make another call to the /token endpoint to request a new set of access and refresh tokens.
You might find this article helpful: Microsoft v2 Endpoint Primer. In particular, the section on refresh tokens.

.Net Core 2.0 - Get AAD access token to use with Microsoft Graph

When starting up a fresh .Net Core 2.0 project with Azure AD Authentication you get a working sample that can sign in to your tenant, great!
Now I want to get an access token for the signed in user and use that to work with Microsoft Graph API.
I am not finding any documentation on how to achieve this. I just want a simple way to get an access token and access the graph API, using the template created when you start a new .NET Core 2.0 project. From there I should be able to figure out the rest.
Very important that it works with the project that gets created when following the process where you select Work and school accounts for authentication when creating a new 2.0 MVC Core app in Visual Studio.
I wrote a blog article which shows just how to do that: ASP.NET Core 2.0 Azure AD Authentication
The TL;DR is that you should add a handler like this for when you receive an authorization code from AAD:
.AddOpenIdConnect(opts =>
{
Configuration.GetSection("Authentication").Bind(opts);
opts.Events = new OpenIdConnectEvents
{
OnAuthorizationCodeReceived = async ctx =>
{
var request = ctx.HttpContext.Request;
var currentUri = UriHelper.BuildAbsolute(request.Scheme, request.Host, request.PathBase, request.Path);
var credential = new ClientCredential(ctx.Options.ClientId, ctx.Options.ClientSecret);
var distributedCache = ctx.HttpContext.RequestServices.GetRequiredService<IDistributedCache>();
string userId = ctx.Principal.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier").Value;
var cache = new AdalDistributedTokenCache(distributedCache, userId);
var authContext = new AuthenticationContext(ctx.Options.Authority, cache);
var result = await authContext.AcquireTokenByAuthorizationCodeAsync(
ctx.ProtocolMessage.Code, new Uri(currentUri), credential, ctx.Options.Resource);
ctx.HandleCodeRedemption(result.AccessToken, result.IdToken);
}
};
});
Here my context.Options.Resource is https://graph.microsoft.com (Microsoft Graph), which I'm binding from config along with other settings (client id etc.).
We redeem a token using ADAL, and store the resulting token in a token cache.
The token cache is something you will have to make, here is the example from the example app:
public class AdalDistributedTokenCache : TokenCache
{
private readonly IDistributedCache _cache;
private readonly string _userId;
public AdalDistributedTokenCache(IDistributedCache cache, string userId)
{
_cache = cache;
_userId = userId;
BeforeAccess = BeforeAccessNotification;
AfterAccess = AfterAccessNotification;
}
private string GetCacheKey()
{
return $"{_userId}_TokenCache";
}
private void BeforeAccessNotification(TokenCacheNotificationArgs args)
{
Deserialize(_cache.Get(GetCacheKey()));
}
private void AfterAccessNotification(TokenCacheNotificationArgs args)
{
if (HasStateChanged)
{
_cache.Set(GetCacheKey(), Serialize(), new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(1)
});
HasStateChanged = false;
}
}
}
The token cache here uses a distributed cache to store tokens, so that all instances serving your app have access to the tokens. They are cached per user, so you can retrieve a token for any user later.
Then when you want to get a token and use MS graph, you'd do something like (important stuff in GetAccessTokenAsync()):
[Authorize]
public class HomeController : Controller
{
private static readonly HttpClient Client = new HttpClient();
private readonly IDistributedCache _cache;
private readonly IConfiguration _config;
public HomeController(IDistributedCache cache, IConfiguration config)
{
_cache = cache;
_config = config;
}
[AllowAnonymous]
public IActionResult Index()
{
return View();
}
public async Task<IActionResult> MsGraph()
{
HttpResponseMessage res = await QueryGraphAsync("/me");
ViewBag.GraphResponse = await res.Content.ReadAsStringAsync();
return View();
}
private async Task<HttpResponseMessage> QueryGraphAsync(string relativeUrl)
{
var req = new HttpRequestMessage(HttpMethod.Get, "https://graph.microsoft.com/v1.0" + relativeUrl);
string accessToken = await GetAccessTokenAsync();
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
return await Client.SendAsync(req);
}
private async Task<string> GetAccessTokenAsync()
{
string authority = _config["Authentication:Authority"];
string userId = User.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier").Value;
var cache = new AdalDistributedTokenCache(_cache, userId);
var authContext = new AuthenticationContext(authority, cache);
string clientId = _config["Authentication:ClientId"];
string clientSecret = _config["Authentication:ClientSecret"];
var credential = new ClientCredential(clientId, clientSecret);
var result = await authContext.AcquireTokenSilentAsync("https://graph.microsoft.com", credential, new UserIdentifier(userId, UserIdentifierType.UniqueId));
return result.AccessToken;
}
}
There we acquire a token silently (using the token cache), and attach it to requests to the Graph.

UseCookieAuthentication with Distributed Cache Ticket Store removing cache entries before set timetout

I have an asp.net core application where I am configuring cookie authentication and OpenID Connect authentication. I am also using Session and a distributed SQL server cache.
In the cookie authentication configuration, I am setting the SessionStore property to use my distributed cache ticket store. I have my session timeout set to 60 minutes. When I put items into session directly, it uses the sql cache and the entries show a 60 minute sliding expire time. That all works fine. Now, when the cookie auth uses the distributed cache ticket store, i can see the entries in the database as well (with a sliding 60 minute timeout). But if I let a web page sit for 20 minutes or more and then refresh the page, the ticket store removes the cache entry in the database for the cookie; even though the full 60 minutes have not passed. When debugging the ticket store, i see the call to Retrieve get called, then a call to Remove, and then another call to Retrieve; which at that point there is no more cache entry.
I'm sure I'm missing some setting somewhere, but the just the cookie cache entries are being cleaned up and removed prematurely. I cannot figure out why.
Here are the relevant parts of my startup:
public class Startup
{
// ...
public void ConfigureServices(IServiceCollection services)
{
// app insights
services.AddApplicationInsightsTelemetry(this.Configuration);
// authentication
services.AddAuthentication(options => options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme);
// options
services.AddOptions()
.Configure<ConfigurationOptions>(this.Configuration)
.AddUtilitiesLayerConfigurationOptions(this.Configuration)
.AddDataLayerConfigurationOptions(this.Configuration)
.AddServicesLayerConfigurationOptions(this.Configuration)
.AddWebAppLayerConfigurationOptions(this.Configuration);
// caching/session
services.AddDistributedSqlServerCache(this.ConfigureSqlServerCacheOptions);
services.AddSession(this.ConfigureSessionOptions);
// mvc
services.AddMvc(ConfigureMvcOptions);
// custom services
services
.AddConfigurationLayerServices()
.AddCommonLayerServices()
.AddUtilitiesLayerServices()
.AddServicesLayerServices(this.Configuration);
}
public void Configure(
IApplicationBuilder app,
IHostingEnvironment env,
IServiceProvider serviceProvider,
ILoggerFactory loggerFactory,
ITicketStore distributedCacheTicketStore)
{
// setup logging
loggerFactory.AddDebug();
// app insights request telemetry (this must be first)
app.UseApplicationInsightsRequestTelemetry();
// exceptions
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage(new DeveloperExceptionPageOptions { SourceCodeLineCount = 10 });
}
else
{
app.UseExceptionHandler("/error");
}
// app insights exception telemetry (right after exception config)
app.UseApplicationInsightsExceptionTelemetry();
// session
app.UseSession();
// status code pages (redirect to error controller)
app.UseStatusCodePagesWithRedirects("/error/{0}");
// static files
// this is before auth, so no static files require auth
// if we wanth auth for static files, move this after the auth middleware
app.UseStaticFiles();
// auth
app.UseCookieAuthentication(this.BuildCookieAuthenticationOptions(distributedCacheTicketStore));
app.UseOpenIdConnectAuthentication(this.BuildOpenIdConnectOptions());
// mvc
app.UseMvc();
}
private CookieAuthenticationOptions BuildCookieAuthenticationOptions(ITicketStore ticketStore)
{
var configuration = new ConfigurationOptions();
this.Configuration.Bind(configuration);
return new CookieAuthenticationOptions
{
CookieSecure = CookieSecurePolicy.SameAsRequest,
CookieName = configuration.Session.AuthenticationCookieName,
AccessDeniedPath = "/access-denied",
SessionStore = ticketStore
};
}
private OpenIdConnectOptions BuildOpenIdConnectOptions()
{
var configuration = new ConfigurationOptions();
this.Configuration.Bind(configuration);
return new OpenIdConnectOptions
{
ClientId = configuration.AzureActiveDirectory.ClientID,
Authority = configuration.AzureActiveDirectory.Authority,
PostLogoutRedirectUri = configuration.AzureActiveDirectory.PostLogoutRedirectUri,
ResponseType = OpenIdConnectResponseType.CodeIdToken,
Events = new OpenIdConnectEvents
{
OnRedirectToIdentityProvider = this.OnRedirectToIdentityProvider,
OnRemoteFailure = this.OnRemoteFailure,
OnTokenValidated = this.OnTokenValidated,
OnAuthorizationCodeReceived = this.OnAuthorizationCodeReceived,
OnAuthenticationFailed = this.OnAuthenticationFailed
}
};
}
}
Here is my DistributedCacheTicketStore:
public class DistributedCacheTicketStore : ITicketStore
{
private readonly DistributedCacheTicketStoreOptions options;
private readonly IDistributedCache distributedCache;
private readonly IDataProtector dataProtector;
private readonly ILogger<DistributedCacheTicketStore> logger;
public DistributedCacheTicketStore(
IOptions<DistributedCacheTicketStoreOptions> optionsAccessor,
IDistributedCache distributedCache,
IDataProtectionProvider dataProtectionProvider,
ILogger<DistributedCacheTicketStore> logger)
{
this.options = optionsAccessor.Value;
this.distributedCache = distributedCache;
this.dataProtector = dataProtectionProvider.CreateProtector(this.GetType().FullName);
this.logger = logger;
}
public async Task<string> StoreAsync(AuthenticationTicket ticket)
{
var key = Guid.NewGuid().ToString();
var ticketBytes = this.dataProtector.Protect(TicketSerializer.Default.Serialize(ticket));
await this.distributedCache.SetAsync(key, ticketBytes, new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(this.options.Session.TimeoutMinutes) });
this.logger.AuthenticationTicketStoredInCache(key);
return key;
}
public async Task RenewAsync(string key, AuthenticationTicket ticket)
{
var ticketBytes = this.dataProtector.Protect(TicketSerializer.Default.Serialize(ticket));
await this.distributedCache.SetAsync(key, ticketBytes, new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(this.options.Session.TimeoutMinutes) });
this.logger.AuthenticationTicketRenewedInCache(key);
}
public async Task<AuthenticationTicket> RetrieveAsync(string key)
{
var ticketBytes = await this.distributedCache.GetAsync(key);
var ticket = TicketSerializer.Default.Deserialize(this.dataProtector.Unprotect(ticketBytes));
this.logger.AuthenticationTicketRetrievedFromCache(key);
return ticket;
}
public async Task RemoveAsync(string key)
{
var ticketBytes = await this.distributedCache.GetStringAsync(key);
if (ticketBytes != null)
{
await this.distributedCache.RemoveAsync(key);
this.logger.AuthenticationTicketRemovedFromCache(key);
}
}
}

Resources