Full Server logout with IdentityServer4 and OpenID Connect Implicit Flow

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:

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://leastprivilege.com

https://github.com/IdentityServer/IdentityServer4/issues/313

https://github.com/IdentityServer/IdentityServer4/issues/310

3 comments

  1. This information is worth everyone’s attention. How can I find out more?

  2. Joonas · · Reply

    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 ?

  3. bastienJS · · Reply

    @Joonas you missed maybe the AlwaysIncludeUserClaimsInIdToken = true;

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.