The article shows how to fully logout from IdentityServer4 using an OpenID Connect Implicit Flow. Per design when using an access token to use protected data from a resource server, even if the client has logged out from the server, the access token can be used so long it is valid (AccessTokenLifetime) as it is a consent. This it the normal use case.
Sometimes, it is required that once a user logs out from IdentityServer4, no client with the same user can continue to use the protected data without logging in again. Reference tokens can be used to implement this. With reference tokens, you have full control over the lifecycle.
Code: https://github.com/damienbod/AspNet5IdentityServerAngularImplicitFlow
History:
2019-09-20: Updated ASP.NET Core 3.0, Angular 8.2.6
2018-06-22: Updated ASP.NET Core 2.1, Angular 6.0.6, ASP.NET Core Identity 2.1
Full history:
https://github.com/damienbod/AspNet5IdentityServerAngularImplicitFlow#history
Other posts in this series:
- Authorization Policies and Data Protection with IdentityServer4 in ASP.NET Core
- Angular OpenID Connect Implicit Flow with IdentityServer4
- Angular secure file download without using an access token in URL or cookies
- Full Server logout with IdentityServer4 and OpenID Connect Implicit Flow
- IdentityServer4, WebAPI and Angular in a single ASP.NET Core project
- Extending Identity in IdentityServer4 to manage users in ASP.NET Core
- Implementing a silent token renew in Angular for the OpenID Connect Implicit flow
- OpenID Connect Session Management using an Angular application and IdentityServer4
To use reference tokens in IdentityServer4, the client can be defined with the AccessTokenType property set to AccessTokenType.Reference. When a user and the client successfully login, a reference token as well as an id_token is returned to the client and not an access token and an id_token. (response_type: id_token token)
public static IEnumerable<Client> GetClients() { // client credentials client return new List<Client> { new Client { ClientName = "singleapp", ClientId = "singleapp", AccessTokenType = AccessTokenType.Reference, //AccessTokenLifetime = 600, // 10 minutes, default 60 minutes AllowedGrantTypes = GrantTypes.Implicit, RequireConsent = false, AllowAccessTokensViaBrowser = true, RedirectUris = new List<string> { HOST_URL + "/index.html" }, PostLogoutRedirectUris = new List<string> { HOST_URL + "/index.html" }, AllowedCorsOrigins = new List<string> { HOST_URL }, AllowedScopes = new List<string> { "openid", "dataEventRecords", "dataeventrecordsscope", "securedFiles", "securedfilesscope", "role" } } }; }
In IdentityServer4, when a user decides to logout, the IPersistedGrantService can be used to remove reference tokens for this user and client. The RemoveAllGrantsAsync method from the IPersistedGrantService uses the Identity subject and the client id to delete all of the corresponding grants. The GetSubjectId method is an IdentityServer4 extension method for the Identity. The HttpContext.User can be used to get this. The client id must match the client from the configuration.
using System; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.Extensions.Logging; using IdentityServerWithAspNetIdentity.Models.AccountViewModels; using IdentityServer4.Quickstart.UI.Models; using IdentityServer4.Services; using IdentityServer4.Stores; using IdentityServer4.Models; using IdentityServerWithAspNetIdentity.Models; using IdentityServerWithAspNetIdentity.Services; using IdentityModel; using IdentityServer4; using Microsoft.AspNetCore.Http.Authentication; using IdentityServer4.Extensions; using QuickstartIdentityServer; namespace IdentityServerWithAspNetIdentity.Controllers { [Authorize] public class AccountController : Controller { private readonly UserManager<ApplicationUser> _userManager; private readonly SignInManager<ApplicationUser> _signInManager; private readonly IEmailSender _emailSender; private readonly ISmsSender _smsSender; private readonly ILogger _logger; private readonly IIdentityServerInteractionService _interaction; private readonly IClientStore _clientStore; private readonly IPersistedGrantService _persistedGrantService; public AccountController( UserManager<ApplicationUser> userManager, IPersistedGrantService persistedGrantService, SignInManager<ApplicationUser> signInManager, IEmailSender emailSender, ISmsSender smsSender, ILoggerFactory loggerFactory, IIdentityServerInteractionService interaction, IClientStore clientStore) { _userManager = userManager; _persistedGrantService = persistedGrantService; _signInManager = signInManager; _emailSender = emailSender; _smsSender = smsSender; _logger = loggerFactory.CreateLogger<AccountController>(); _interaction = interaction; _clientStore = clientStore; } [HttpPost] [ValidateAntiForgeryToken] [AllowAnonymous] public async Task<IActionResult> Logout(LogoutViewModel model) { var idp = User?.FindFirst(JwtClaimTypes.IdentityProvider)?.Value; var subjectId = HttpContext.User.Identity.GetSubjectId(); if (idp != null && idp != IdentityServerConstants.LocalIdentityProvider) { if (model.LogoutId == null) { // if there's no current logout context, we need to create one // this captures necessary info from the current logged in user // before we signout and redirect away to the external IdP for signout model.LogoutId = await _interaction.CreateLogoutContextAsync(); } string url = "/Account/Logout?logoutId=" + model.LogoutId; try { // hack: try/catch to handle social providers that throw await HttpContext.Authentication.SignOutAsync(idp, new AuthenticationProperties { RedirectUri = url }); } catch(NotSupportedException) { } } // delete authentication cookie await _signInManager.SignOutAsync(); // set this so UI rendering sees an anonymous user HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity()); // get context information (client name, post logout redirect URI and iframe for federated signout) var logout = await _interaction.GetLogoutContextAsync(model.LogoutId); var vm = new LoggedOutViewModel { PostLogoutRedirectUri = logout?.PostLogoutRedirectUri, ClientName = logout?.ClientId, SignOutIframeUrl = logout?.SignOutIFrameUrl }; await _persistedGrantService.RemoveAllGrantsAsync(subjectId, "angular2client"); return Redirect(Config.HOST_URL + "/index.html"); //return View("LoggedOut", vm); }
The IdentityServer4.AccessTokenValidation NuGet package is used on the resource server to validate the reference token sent from the client. The IdentityServerAuthenticationOptions options are configured as required.
"IdentityServer4.AccessTokenValidation": "3.0.0"
This package is configured in the Startup class in the Configure method and the ConfigureServices method.
public void ConfigureServices(IServiceCollection services) { var connection = Configuration.GetConnectionString("DefaultConnection"); var useLocalCertStore = Convert.ToBoolean(Configuration["UseLocalCertStore"]); var certificateThumbprint = Configuration["CertificateThumbprint"]; X509Certificate2 cert; if (_webHostEnvironment.IsProduction()) { if (useLocalCertStore) { using (X509Store store = new X509Store(StoreName.My, StoreLocation.LocalMachine)) { store.Open(OpenFlags.ReadOnly); var certs = store.Certificates.Find(X509FindType.FindByThumbprint, certificateThumbprint, false); cert = certs[0]; store.Close(); } } else { // Azure deployment, will be used if deployed to Azure var vaultConfigSection = Configuration.GetSection("Vault"); var keyVaultService = new KeyVaultCertificateService(vaultConfigSection["Url"], vaultConfigSection["ClientId"], vaultConfigSection["ClientSecret"]); cert = keyVaultService.GetCertificateFromKeyVault(vaultConfigSection["CertificateName"]); } } else { cert = new X509Certificate2(Path.Combine(_webHostEnvironment.ContentRootPath, "damienbodserver.pfx"), ""); } // Important The folderForKeyStore needs to be backed up. // services.AddDataProtection() // .SetApplicationName("ResourceServer") // .PersistKeysToFileSystem(new DirectoryInfo(folderForKeyStore)) // .ProtectKeysWithCertificate(cert); services.AddDataProtection() .SetApplicationName("ResourceServer") .ProtectKeysWithCertificate(cert) .AddKeyManagementOptions(options => options.XmlRepository = new SqlXmlRepository( new DataProtectionDbContext( new DbContextOptionsBuilder<DataProtectionDbContext>().UseSqlite(connection).Options ) ) ); services.AddDbContext<DataEventRecordContext>(options => options.UseSqlite(connection) ); services.AddCors(options => { options.AddPolicy("AllowAllOrigins", builder => { builder .AllowCredentials() .WithOrigins( "https://localhost:44311", "https://localhost:44352", "https://localhost:44372", "https://localhost:44378", "https://localhost:44390") .SetIsOriginAllowedToAllowWildcardSubdomains() .AllowAnyHeader() .AllowAnyMethod(); }); }); var guestPolicy = new AuthorizationPolicyBuilder() .RequireAuthenticatedUser() .RequireClaim("scope", "dataEventRecords") .Build(); services.AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme) .AddIdentityServerAuthentication(options => { options.Authority = "https://localhost:44318/"; options.ApiName = "dataEventRecords"; options.ApiSecret = "dataEventRecordsSecret"; }); services.AddAuthorization(options => { options.AddPolicy("dataEventRecordsAdmin", policyAdmin => { policyAdmin.RequireClaim("role", "dataEventRecords.admin"); }); options.AddPolicy("dataEventRecordsUser", policyUser => { policyUser.RequireClaim("role", "dataEventRecords.user"); }); options.AddPolicy("dataEventRecords", policyUser => { policyUser.RequireClaim("scope", "dataEventRecords"); }); }); services.AddControllers() .AddNewtonsoftJson() .SetCompatibilityVersion(CompatibilityVersion.Version_3_0); services.AddScoped<IDataEventRecordRepository, DataEventRecordRepository>(); } public void Configure(IApplicationBuilder app) { app.UseExceptionHandler("/Home/Error"); app.UseCors("AllowAllOrigins"); app.UseStaticFiles(); app.UseAuthentication(); app.UseAuthorization(); app.UseRouting(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); }
The SPA client can then be used to login, logout from the server. If 2 or more clients with the same user are logged in, once the user logs out from the server, none will have access to the protected data. All existing reference tokens for this user and client can no longer be used to access the protected data.
By using reference tokens, you have full control over the access lifecycle to the protected data. Caution should be taken when using long running access tokens.
Another strategy would be to use short lived access tokens and make the client refresh this regularly. This reduces to time which an access token lives after a logout, but the access token can still be used to access the private data until it has timed out.
Links
http://openid.net/specs/openid-connect-core-1_0.html
http://openid.net/specs/openid-connect-implicit-1_0.html
https://github.com/IdentityServer/IdentityServer4/issues/313#issuecomment-247589782
https://github.com/IdentityServer/IdentityServer4
https://github.com/IdentityServer/IdentityServer4/issues/313
https://github.com/IdentityServer/IdentityServer4/issues/310
This information is worth everyone’s attention. How can I find out more?
Hi,
thanks for great tutorial!
Have an issue with retrieving the SubjectID in AccountController :
var subjectId = HttpContext.User.Identity.GetSubjectId();
There is no sub claim in Identity object. Any idea what I miss ?
@Joonas you missed maybe the AlwaysIncludeUserClaimsInIdToken = true;