I'd like to add refresh token functionnality in my API, so when a user in y application call the API, if its token is still valid, it will be refresh.
I added the authorizationController, here is the code :
[HttpPost("~/connect/token"), Produces("application/json")]
public IActionResult Exchange(OpenIdConnectRequest request)
{
if (request.IsPasswordGrantType())
{
// Validate the user credentials.
// Note: to mitigate brute force attacks, you SHOULD strongly consider
// applying a key derivation function like PBKDF2 to slow down
// the password validation process. You SHOULD also consider
// using a time-constant comparer to prevent timing attacks.
User user = new Models.User();
using (var db = new UserContext())
{
user = db.User.Where(u => u.login == request.Username).FirstOrDefault();
if (request.Username != user.login || request.Password != user.pwd)
{
return Forbid(OpenIdConnectServerDefaults.AuthenticationScheme);
}
}
// Create a new ClaimsIdentity holding the user identity.
var identity = new ClaimsIdentity(
OpenIdConnectServerDefaults.AuthenticationScheme,
OpenIdConnectConstants.Claims.Name,
OpenIdConnectConstants.Claims.Role);
// Add a "sub" claim containing the user identifier, and attach
// the "access_token" destination to allow OpenIddict to store it
// in the access token, so it can be retrieved from your controllers.
identity.AddClaim(OpenIdConnectConstants.Claims.Subject,
"71346D62-9BA5-4B6D-9ECA-755574D628D8",
OpenIdConnectConstants.Destinations.AccessToken);
identity.AddClaim(OpenIdConnectConstants.Claims.Name, user.login,
OpenIdConnectConstants.Destinations.AccessToken);
// ... add other claims, if necessary.
var principal = new ClaimsPrincipal(identity);
// Ask OpenIddict to generate a new token and return an OAuth2 token response.
return SignIn(principal, OpenIdConnectServerDefaults.AuthenticationScheme);
}
throw new InvalidOperationException("The specified grant type is not supported.");
}
and my Startup.cs code :
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc();
services.AddDbContext<UserContext>(options =>
{
options.UseOpenIddict();
});
services.Configure<IdentityOptions>(options =>
{
options.ClaimsIdentity.UserNameClaimType = OpenIdConnectConstants.Claims.Name;
options.ClaimsIdentity.UserIdClaimType = OpenIdConnectConstants.Claims.Subject;
options.ClaimsIdentity.RoleClaimType = OpenIdConnectConstants.Claims.Role;
});
services.AddOpenIddict(options =>
{
options.AddEntityFrameworkCoreStores<UserContext>();
options.AddMvcBinders();
options.EnableTokenEndpoint("/connect/token");
options.AllowPasswordFlow().AllowRefreshTokenFlow()
.SetAccessTokenLifetime(TimeSpan.FromMinutes(20))
.SetRefreshTokenLifetime(TimeSpan.FromMinutes(10));
options.DisableHttpsRequirement();
});
services.AddDistributedMemoryCache();
services.Configure<IISOptions>(options =>
{
options.AutomaticAuthentication = true;
});
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
loggerFactory.AddConsole(Configuration.GetSection("Logging"));
loggerFactory.AddFile("Logs/mylog-{Date}.txt");
loggerFactory.AddDebug();
app.UseOpenIddict();
app.UseOAuthValidation();
app.UseMvcWithDefaultRoute();
app.UseMvc();
}
The authentication and token generation are working, but not the refreshing of the token. I added SetAccessTokenLifetime(TimeSpan.FromMinutes(20)) and SetRefreshTokenLifetime(TimeSpan.FromMinutes(10));
The first one is working, my token expiration equals 20 minutes, but the token is never refreshed.
Related
I've got an ASP.NET Core application.
The configuration regarding Openiddict is as follows:
builder.Services.AddOpenIddict()
// Register the OpenIddict core components.
.AddCore(options =>
{
options.UseEntityFrameworkCore().UseDbContext<IdentityDataContext>();
options.Services.TryAddTransient<OpenIddictQuartzJob>();
// Note: TryAddEnumerable() is used here to ensure the initializer is registered only once.
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IConfigureOptions<QuartzOptions>, OpenIddictQuartzConfiguration>());
})
// Register the OpenIddict server components.
.AddServer(options =>
options.SetAuthorizationEndpointUris("/connect/authorize")
.SetLogoutEndpointUris("/connect/logout")
.SetTokenEndpointUris("/connect/token")
.SetUserinfoEndpointUris("/connect/userinfo")
// Mark the "email", "profile" and "roles" scopes as supported scopes.
.RegisterScopes(Scopes.Email, Scopes.Profile, Scopes.Roles)
// Note: the sample uses the code and refresh token flows but you can enable
// the other flows if you need to support implicit, password or client credentials.
.AllowAuthorizationCodeFlow()
.AllowRefreshTokenFlow()
// Register the signing and encryption credentials.
.AddDevelopmentEncryptionCertificate()
.AddDevelopmentSigningCertificate()
// Register the ASP.NET Core host and configure the ASP.NET Core-specific options.
.UseAspNetCore()
.EnableAuthorizationEndpointPassthrough()
.EnableLogoutEndpointPassthrough()
.EnableUserinfoEndpointPassthrough()
.EnableTokenEndpointPassthrough()
.EnableStatusCodePagesIntegration())
// Register the OpenIddict validation components.
.AddValidation(options =>
{
// Import the configuration from the local OpenIddict server instance.
options.UseLocalServer();
// Register the ASP.NET Core host.
options.UseAspNetCore();
});
builder.Services.ConfigureApplicationCookie(options => options.LoginPath = "/account/auth");
In tests I use a server factory:
public class InMemoryWebApplicationFactory<TStartup> : WebApplicationFactory<TStartup>
where TStartup : class
{
protected override void ConfigureWebHost(IWebHostBuilder builder) =>
builder.ConfigureServices(services =>
{
var descriptor = services.SingleOrDefault(
d => d.ServiceType ==
typeof(DbContextOptions<IdentityDataContext>))!;
services.Remove(descriptor);
services.AddDbContext<IdentityDataContext>(options =>
{
options.UseInMemoryDatabase("InMemoryDbForTesting");
// without this I get a NPE
options.UseOpenIddict();
});
var sp = services.BuildServiceProvider();
using var scope = sp.CreateScope();
var scopedServices = scope.ServiceProvider;
var db = scopedServices.GetRequiredService<IdentityDataContext>();
db.Database.EnsureCreated();
});
protected override void ConfigureClient(HttpClient client)
{
base.ConfigureClient(client);
// without this I get Bad request due to Opeiddict filters
client.DefaultRequestHeaders.Host = client.BaseAddress!.Host;
}
}
The test looks like this (taken from here):
[Fact]
public async Task AuthorizedRequestReturnsValue()
{
var client = _factory.WithWebHostBuilder(builder => builder
.ConfigureTestServices(services => services.AddAuthentication("TestScheme")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("TestScheme", _ => { })))
.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("TestScheme");
var response = await client.GetAsync(new Uri("https://localhost/connect/userinfo"));
// I get Unauthorized here instead
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
The /connect/userinfo is as follows:
[HttpGet("~/connect/userinfo")]
[HttpPost("~/connect/userinfo")]
[IgnoreAntiforgeryToken]
[Produces("application/json")]
public async Task<IActionResult> Userinfo()
{
var user = await _userManager.FindByIdAsync(User.GetClaim(Claims.Subject)!);
if (user is null)
{
return Challenge(
properties: new AuthenticationProperties(new Dictionary<string, string?>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidToken,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] =
"The specified access token is bound to an account that no longer exists.",
}),
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
var claims = new Dictionary<string, object>(StringComparer.Ordinal)
{
// Note: the "sub" claim is a mandatory claim and must be included in the JSON response.
[Claims.Subject] = await _userManager.GetUserIdAsync(user),
};
claims[Claims.Email] = (await _userManager.GetEmailAsync(user))!;
claims[Claims.EmailVerified] = await _userManager.IsEmailConfirmedAsync(user);
claims[Claims.Name] = (await _userManager.GetUserNameAsync(user))!;
claims[Claims.PhoneNumber] = (await _userManager.GetPhoneNumberAsync(user))!;
claims[Claims.PhoneNumberVerified] = await _userManager.IsPhoneNumberConfirmedAsync(user);
claims[Claims.Role] = await _userManager.GetRolesAsync(user);
// Note: the complete list of standard claims supported by the OpenID Connect specification
// can be found here: http://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
return Ok(claims);
}
As far as I understand by default the TestScheme should be used for authentication. But it is not and OpenidDict takes precedence.
Is there a way to make authenticated/authorized requests in integration tests?
P.S. The test was working OK until I added OpenIddict. Before that I used Asp Identity directly for authentication
I am trying to send a message with SignalR to a specific user.
I implemented the default project authentication with Blazor Server side and Net6.
I can log in / log out / register.
I implemented the IUSerIdProvider Interface to get the UserId.
The first time I launch the app, I can retrieved the user (from connection.GetHttpContext(); or connection.User.FindFirstValue(ClaimTypes.Name); but when I navigate to an other page and call the hub again, the HubConnectionContext loses my User and all his informations.
If I force the id with a constant string it works but why do I lose the informations the second time ?
I don't know if I need to use cookies because the first time I have informations.
// CustomUserIdProvider.cs
public class CustomUserIdProvider : IUserIdProvider
{
public string? GetUserId(HubConnectionContext connection)
{
var httpContext = connection.GetHttpContext();
var userId = connection.User.FindFirstValue(ClaimTypes.Name);
if (string.IsNullOrWhiteSpace(userId))
return string.Empty;
return userId;
}
}
// Program.cs
-----
builder.Services.AddSingleton<IUserIdProvider, CustomUserIdProvider>();
-----
app.UseAuthentication();
app.UseAuthorization();
// SignalR.razor (where I test to receive / send a message and here I lost the informations)
protected override async Task OnInitializedAsync()
{
hubConnection = new HubConnectionBuilder()
.WithUrl(NavigationManager.ToAbsoluteUri("/notifyhub"))
.Build();
hubConnection.On<int, string>("ReceiveMessage", (id, message) =>
{
var encodedMsg = $"{id}: {message}";
InvokeAsync(StateHasChanged);
});
await hubConnection.StartAsync();
}
private async Task Send()
{
if (hubConnection is not null)
{
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var user = authState.User;
authMessage = $"{user.Identity.Name} is authenticated.";
claims = user.Claims;
surnameMessage =
$"Surname: {user.FindFirst(c => c.Type == ClaimTypes.Surname)?.Value}";
await hubConnection.SendAsync("Send", user.Identity.Name, 1, "Message envoyé");
}
}
System.InvalidOperationException: The OpenID Connect request cannot be
retrieved from the ASP.NET context. Make sure that
'app.UseOpenIddict()' is called before 'app.UseMvc()' and that the
action route corresponds to the endpoint path registered via
'services.AddOpenIddict().Enable[...]Endpoint(...)'. at
OpenIddict.Mvc.OpenIddictModelBinder.BindModelAsync(ModelBindingContext
context)
MyStartup.cs
public void ConfigureServices(IServiceCollection services)
{
services.AddApplicationInsightsTelemetry(Configuration);
services.AddDbContext<ApplicationUserDbContext>(options =>
options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));
services.AddIdentity<ApplicationUser, ApplicationRole>()
.AddEntityFrameworkStores<ApplicationUserDbContext>()
.AddDefaultTokenProviders();
services.AddMvc();
.AddMvcBinders()
.EnableAuthorizationEndpoint("/connect/authorize")
.EnableLogoutEndpoint("/connect/logout")
.EnableTokenEndpoint("/connect/token")
.EnableUserinfoEndpoint("/Account/Userinfo")
.AllowAuthorizationCodeFlow()
.AllowPasswordFlow()
.AllowRefreshTokenFlow()
.RequireClientIdentification()
// During development, you can disable the HTTPS requirement.
.DisableHttpsRequirement()
.AddEphemeralSigningKey();
services.AddTransient<IEmailSender, AuthMessageSender>();
services.AddTransient<ISmsSender, AuthMessageSender>();
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
loggerFactory.AddConsole(Configuration.GetSection("Logging"));
loggerFactory.AddDebug();
app.UseApplicationInsightsRequestTelemetry();
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseDatabaseErrorPage();
app.UseBrowserLink();
}
else
{
app.UseExceptionHandler("/Home/Error");
}
app.UseApplicationInsightsExceptionTelemetry();
app.UseStaticFiles();
app.UseCsp(options => options.DefaultSources(directive => directive.Self())
.ImageSources(directive => directive.Self()
.CustomSources("*"))
.ScriptSources(directive => directive.Self()
.UnsafeInline())
.StyleSources(directive => directive.Self()
.UnsafeInline()));
app.UseXContentTypeOptions();
app.UseXfo(options => options.Deny());
app.UseXXssProtection(options => options.EnabledWithBlockMode());
app.UseIdentity();
// Add a middleware used to validate access
// tokens and protect the API endpoints.
app.UseOAuthValidation();
app.UseGoogleAuthentication(new GoogleOptions
{
});
app.UseStatusCodePagesWithReExecute("/error");
app.UseOpenIddict();
app.UseMvcWithDefaultRoute();
}
Update
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using AspNet.Security.OpenIdConnect.Extensions;
using AspNet.Security.OpenIdConnect.Server;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Mvc.Server.Models;
using Mvc.Server.ViewModels.Authorization;
using Mvc.Server.ViewModels.Shared;
using OpenIddict;
public class AuthorizationController : Controller {
private readonly OpenIddictApplicationManager<OpenIddictApplication> _applicationManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly UserManager<ApplicationUser> _userManager;
public AuthorizationController(
OpenIddictApplicationManager<OpenIddictApplication> applicationManager,
SignInManager<ApplicationUser> signInManager,
UserManager<ApplicationUser> userManager) {
_applicationManager = applicationManager;
_signInManager = signInManager;
_userManager = userManager;
}
// Note: to support interactive flows like the code flow,
// you must provide your own authorization endpoint action:
[Authorize, HttpGet, Route("~/connect/authorize")]
public async Task<IActionResult> Authorize(OpenIdConnectRequest request) {
// Retrieve the application details from the database.
var application = await _applicationManager.FindByClientIdAsync(request.ClientId);
if (application == null) {
return View("Error", new ErrorViewModel {
Error = OpenIdConnectConstants.Errors.InvalidClient,
ErrorDescription = "Details concerning the calling client application cannot be found in the database"
});
}
// Flow the request_id to allow OpenIddict to restore
// the original authorization request from the cache.
return View(new AuthorizeViewModel {
ApplicationName = application.DisplayName,
RequestId = request.RequestId,
Scope = request.Scope
});
}
[Authorize, HttpPost("~/connect/authorize/accept"), ValidateAntiForgeryToken]
public async Task<IActionResult> Accept(OpenIdConnectRequest request) {
// Retrieve the profile of the logged in user.
var user = await _userManager.GetUserAsync(User);
if (user == null) {
return View("Error", new ErrorViewModel {
Error = OpenIdConnectConstants.Errors.ServerError,
ErrorDescription = "An internal error has occurred"
});
}
// Create a new authentication ticket.
var ticket = await CreateTicketAsync(request, user);
// Returning a SignInResult will ask OpenIddict to issue the appropriate access/identity tokens.
return SignIn(ticket.Principal, ticket.Properties, ticket.AuthenticationScheme);
}
[Authorize, HttpPost("~/connect/authorize/deny"), ValidateAntiForgeryToken]
public IActionResult Deny() {
// Notify OpenIddict that the authorization grant has been denied by the resource owner
// to redirect the user agent to the client application using the appropriate response_mode.
return Forbid(OpenIdConnectServerDefaults.AuthenticationScheme);
}
// Note: the logout action is only useful when implementing interactive
// flows like the authorization code flow or the implicit flow.
[HttpGet("~/connect/logout")]
public IActionResult Logout(OpenIdConnectRequest request) {
// Flow the request_id to allow OpenIddict to restore
// the original logout request from the distributed cache.
return View(new LogoutViewModel {
RequestId = request.RequestId
});
}
[HttpPost("~/connect/logout"), ValidateAntiForgeryToken]
public async Task<IActionResult> Logout() {
// Ask ASP.NET Core Identity to delete the local and external cookies created
// when the user agent is redirected from the external identity provider
// after a successful authentication flow (e.g Google or Facebook).
await _signInManager.SignOutAsync();
// Returning a SignOutResult will ask OpenIddict to redirect the user agent
// to the post_logout_redirect_uri specified by the client application.
return SignOut(OpenIdConnectServerDefaults.AuthenticationScheme);
}
// Note: to support non-interactive flows like password,
// you must provide your own token endpoint action:
[HttpPost("~/connect/token")]
[Produces("application/json")]
public async Task<IActionResult> Exchange(OpenIdConnectRequest request) {
if (request.IsPasswordGrantType()) {
var user = await _userManager.FindByNameAsync(request.Username);
if (user == null) {
return BadRequest(new OpenIdConnectResponse {
Error = OpenIdConnectConstants.Errors.InvalidGrant,
ErrorDescription = "The username/password couple is invalid."
});
}
// Ensure the user is allowed to sign in.
if (!await _signInManager.CanSignInAsync(user)) {
return BadRequest(new OpenIdConnectResponse {
Error = OpenIdConnectConstants.Errors.InvalidGrant,
ErrorDescription = "The specified user is not allowed to sign in."
});
}
// Reject the token request if two-factor authentication has been enabled by the user.
if (_userManager.SupportsUserTwoFactor && await _userManager.GetTwoFactorEnabledAsync(user)) {
return BadRequest(new OpenIdConnectResponse {
Error = OpenIdConnectConstants.Errors.InvalidGrant,
ErrorDescription = "The specified user is not allowed to sign in."
});
}
// Ensure the user is not already locked out.
if (_userManager.SupportsUserLockout && await _userManager.IsLockedOutAsync(user)) {
return BadRequest(new OpenIdConnectResponse {
Error = OpenIdConnectConstants.Errors.InvalidGrant,
ErrorDescription = "The username/password couple is invalid."
});
}
// Ensure the password is valid.
if (!await _userManager.CheckPasswordAsync(user, request.Password)) {
if (_userManager.SupportsUserLockout) {
await _userManager.AccessFailedAsync(user);
}
return BadRequest(new OpenIdConnectResponse {
Error = OpenIdConnectConstants.Errors.InvalidGrant,
ErrorDescription = "The username/password couple is invalid."
});
}
if (_userManager.SupportsUserLockout) {
await _userManager.ResetAccessFailedCountAsync(user);
}
// Create a new authentication ticket.
var ticket = await CreateTicketAsync(request, user);
return SignIn(ticket.Principal, ticket.Properties, ticket.AuthenticationScheme);
}
return BadRequest(new OpenIdConnectResponse {
Error = OpenIdConnectConstants.Errors.UnsupportedGrantType,
ErrorDescription = "The specified grant type is not supported."
});
}
private async Task<AuthenticationTicket> CreateTicketAsync(OpenIdConnectRequest request, ApplicationUser user) {
// Create a new ClaimsPrincipal containing the claims that
// will be used to create an id_token, a token or a code.
var principal = await _signInManager.CreateUserPrincipalAsync(user);
// Note: by default, claims are NOT automatically included in the access and identity tokens.
// To allow OpenIddict to serialize them, you must attach them a destination, that specifies
// whether they should be included in access tokens, in identity tokens or in both.
foreach (var claim in principal.Claims) {
// In this sample, every claim is serialized in both the access and the identity tokens.
// In a real world application, you'd probably want to exclude confidential claims
// or apply a claims policy based on the scopes requested by the client application.
claim.SetDestinations(OpenIdConnectConstants.Destinations.AccessToken,
OpenIdConnectConstants.Destinations.IdentityToken);
}
// Create a new authentication ticket holding the user identity.
var ticket = new AuthenticationTicket(
principal, new AuthenticationProperties(),
OpenIdConnectServerDefaults.AuthenticationScheme);
// Set the list of scopes granted to the client application.
// Note: the offline_access scope must be granted
// to allow OpenIddict to return a refresh token.
ticket.SetScopes(new[] {
OpenIdConnectConstants.Scopes.OpenId,
OpenIdConnectConstants.Scopes.Email,
OpenIdConnectConstants.Scopes.Profile,
OpenIdConnectConstants.Scopes.OfflineAccess,
OpenIddictConstants.Scopes.Roles
}.Intersect(request.GetScopes()));
return ticket;
}
}
Changed:
[Authorize, HttpPost("~/connect/authorize"), ValidateAntiForgeryToken]
public async Task<IActionResult> Accept(OpenIdConnectRequest request) {
// Retrieve the profile of the logged in user.
var user = await _userManager.GetUserAsync(User);
if (user == null) {
return View("Error", new ErrorViewModel {
Error = OpenIdConnectConstants.Errors.ServerError,
ErrorDescription = "An internal error has occurred"
});
}
// Create a new authentication ticket.
var ticket = await CreateTicketAsync(request, user);
// Returning a SignInResult will ask OpenIddict to issue the appropriate access/identity tokens.
return SignIn(ticket.Principal, ticket.Properties, ticket.AuthenticationScheme);
}
OpenIddict used to allow "subroutes" like /connect/authorize/accept or /connect/authorize/deny to be recognized as valid authorization endpoint paths when /connect/authorize was specified, but this feature was removed recently.
With the latest OpenIddict bits, you're encouraged to use the same route template for all your authorization endpoint actions.
[Authorize, HttpGet("~/connect/authorize")]
public async Task<IActionResult> Authorize(OpenIdConnectRequest request)
{
// ...
}
[Authorize, FormValueRequired("submit.Accept")]
[HttpPost("~/connect/authorize"), ValidateAntiForgeryToken]
public async Task<IActionResult> Accept(OpenIdConnectRequest request)
{
// ...
}
[Authorize, FormValueRequired("submit.Deny")]
[HttpPost("~/connect/authorize"), ValidateAntiForgeryToken]
public IActionResult Deny()
{
// ...
}
You can use Orchard's [FormValueRequired] approach to discriminate your actions:
public sealed class FormValueRequiredAttribute : ActionMethodSelectorAttribute
{
private readonly string _name;
public FormValueRequiredAttribute(string name)
{
_name = name;
}
public override bool IsValidForRequest(RouteContext context, ActionDescriptor action)
{
if (string.Equals(context.HttpContext.Request.Method, "GET", StringComparison.OrdinalIgnoreCase) ||
string.Equals(context.HttpContext.Request.Method, "HEAD", StringComparison.OrdinalIgnoreCase) ||
string.Equals(context.HttpContext.Request.Method, "DELETE", StringComparison.OrdinalIgnoreCase) ||
string.Equals(context.HttpContext.Request.Method, "TRACE", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (string.IsNullOrEmpty(context.HttpContext.Request.ContentType))
{
return false;
}
if (!context.HttpContext.Request.ContentType.StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase))
{
return false;
}
return !string.IsNullOrEmpty(context.HttpContext.Request.Form[_name]);
}
}
Don't forget to also update your submit buttons:
<input class="btn btn-lg btn-success" name="submit.Accept" type="submit" value="Yes" />
<input class="btn btn-lg btn-danger" name="submit.Deny" type="submit" value="No" />
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);
}
}
}
I'm trying to implement a simple OAuthAuthorizationServerProvider in ASP.NET WebAPI 2. My main purpose is to learn how to have a token for a mobile app. I would like users to login with username & password, and then receive a token (and a refresh token so they won't have to re-enter credentials once token expires). Later on, I would like to have the chance to open the API for external use by other applications (like one uses Facebook api and such...).
Here is how I've set-up my AuthorizationServer:
app.UseOAuthAuthorizationServer(new OAuthAuthorizationServerOptions()
{
AllowInsecureHttp = true,
TokenEndpointPath = new PathString("/token"),
AccessTokenExpireTimeSpan = TimeSpan.FromMinutes(5),
Provider = new SimpleAuthorizationServerProvider(new SimpleAuthorizationServerProviderOptions()
{
ValidateUserCredentialsFunction = ValidateUser
}),
RefreshTokenProvider = new SimpleRefreshTokenProvider()
});
This is my SimpleAuthorizationServerProviderOptions implementation:
public class SimpleAuthorizationServerProvider : OAuthAuthorizationServerProvider
{
public delegate Task<bool> ClientCredentialsValidationFunction(string clientid, string secret);
public delegate Task<IEnumerable<Claim>> UserCredentialValidationFunction(string username, string password);
public SimpleAuthorizationServerProviderOptions Options { get; private set; }
public SimpleAuthorizationServerProvider(SimpleAuthorizationServerProviderOptions options)
{
if (options.ValidateUserCredentialsFunction == null)
{
throw new NullReferenceException("ValidateUserCredentialsFunction cannot be null");
}
Options = options;
}
public SimpleAuthorizationServerProvider(UserCredentialValidationFunction userCredentialValidationFunction)
{
Options = new SimpleAuthorizationServerProviderOptions()
{
ValidateUserCredentialsFunction = userCredentialValidationFunction
};
}
public SimpleAuthorizationServerProvider(UserCredentialValidationFunction userCredentialValidationFunction, ClientCredentialsValidationFunction clientCredentialsValidationFunction)
{
Options = new SimpleAuthorizationServerProviderOptions()
{
ValidateUserCredentialsFunction = userCredentialValidationFunction,
ValidateClientCredentialsFunction = clientCredentialsValidationFunction
};
}
public override async Task ValidateClientAuthentication(OAuthValidateClientAuthenticationContext context)
{
if (Options.ValidateClientCredentialsFunction != null)
{
string clientId, clientSecret;
if (!context.TryGetBasicCredentials(out clientId, out clientSecret))
{
context.TryGetFormCredentials(out clientId, out clientSecret);
}
var clientValidated = await Options.ValidateClientCredentialsFunction(clientId, clientSecret);
if (!clientValidated)
{
context.Rejected();
return;
}
}
context.Validated();
}
public override async Task GrantResourceOwnerCredentials(OAuthGrantResourceOwnerCredentialsContext context)
{
if (Options.ValidateUserCredentialsFunction == null)
{
throw new NullReferenceException("ValidateUserCredentialsFunction cannot be null");
}
var claims = await Options.ValidateUserCredentialsFunction(context.UserName, context.Password);
if (claims == null)
{
context.Rejected();
return;
}
// create identity
var identity = new ClaimsIdentity(claims, context.Options.AuthenticationType);
// create metadata to pass to refresh token provider
var props = new AuthenticationProperties(new Dictionary<string, string>()
{
{ "as:client_id", context.UserName }
});
var ticket = new AuthenticationTicket(identity, props);
context.Validated(ticket);
}
public override async Task GrantRefreshToken(OAuthGrantRefreshTokenContext context)
{
var originalClient = context.Ticket.Properties.Dictionary["as:client_id"];
var currentClient = context.ClientId;
// enforce client binding of refresh token
if (originalClient != currentClient)
{
context.Rejected();
return;
}
// chance to change authentication ticket for refresh token requests
var newIdentity = new ClaimsIdentity(context.Ticket.Identity);
newIdentity.AddClaim(new Claim("newClaim", "refreshToken"));
var newTicket = new AuthenticationTicket(newIdentity, context.Ticket.Properties);
context.Validated(newTicket);
}
}
And my SimpleRefreshTokenProvider implementation:
public class SimpleRefreshTokenProvider : IAuthenticationTokenProvider
{
private static ConcurrentDictionary<string, AuthenticationTicket> _refreshTokens =
new ConcurrentDictionary<string, AuthenticationTicket>();
public void Create(AuthenticationTokenCreateContext context)
{
}
public async Task CreateAsync(AuthenticationTokenCreateContext context)
{
var guid = Guid.NewGuid().ToString();
var refreshTokenProperties = new AuthenticationProperties(context.Ticket.Properties.Dictionary)
{
IssuedUtc = context.Ticket.Properties.IssuedUtc,
ExpiresUtc = DateTime.UtcNow.AddYears(1)
};
var refreshTokenTicket = new AuthenticationTicket(context.Ticket.Identity, refreshTokenProperties);
_refreshTokens.TryAdd(guid, refreshTokenTicket);
context.SetToken(guid);
}
public void Receive(AuthenticationTokenReceiveContext context)
{
}
public async Task ReceiveAsync(AuthenticationTokenReceiveContext context)
{
AuthenticationTicket ticket;
if (_refreshTokens.TryRemove(context.Token, out ticket))
{
context.SetTicket(ticket);
}
}
}
What I don't fully understand is the use of ClientId and Secret vs Username and Password. The code I pasted generates a token by username and password and I can work with that token (until it expires), but when I try to get a refresh token, I must have the ClientId.
Also, if a token expires, the correct way is to send the refresh token and get a new token? What if the refresh token gets stolen? isn't it the same as a username & password getting stolen?
What I don't fully understand is the use of ClientId and Secret vs Username and Password. The code I pasted generates a token by username and password and I can work with that token (until it expires), but when I try to get a refresh token, I must have the ClientId.
Also, if a token expires, the correct way is to send the refresh token and get a new token? What if the refresh token gets stolen? isn't it the same as a username & password getting stolen?
In OAuth2 is essential to authenticate both the user and the client in any authorization flow defined by the protocol. The client authentication (as you may guess) enforces the use of your API only by known clients. The serialized access token, once generated, is not bound to a specific client directly. Please note that the ClientSecret must be treated as a confidential information, and can be used only by clients that can store this information in some secure way (e.g. external services clients, but not javascript clients).
The refresh token is simply an alternative "grant type" for OAuth2, and, as you stated correctly, will substitute the username and password pair for a User. This token must be treated as confidential data (even more confidential than the access token), but gives advantages over storing the username & password on the client:
it can be revoked by the user if compromised;
it has a limited lifetime (usually days or weeks);
it does not expose user credentials (an attacker can only get access tokens for the "scope" the refresh token was issued).
I suggest you to read more about the different grant types defined in OAuth 2 checking in the official draft. I also recommend you this resource I found very useful when firstly implemented OAuth2 in Web API myself.
Sample requests
Here are two request examples using fiddler, for Resource Owner Password Credentials Grant:
and for Refresh Token Grant: