4
votes

I'm working with an angular SPA which implements authentication by using identity server 4 and oidc client js.

Something is not working at the silent access token renew level. The expected behavior is an automatic renew of the access token, which happens under the hood thanks to an iframe which calls the /connect/authorize endpoint. This call sends the identity server authentication cookie along with the HTTP request, doing so identity server knowns that the user session is still valid and is able to issue a fresh new access token without requiring the user to sign in again interactively. Up to this point I'm quite sure that my understanding is fine.

Here is the tricky part: my expectation is that the identity server authentication cookie should have a sliding expiration, so that its expiration date is moved forward in time each time a call to the /connect/authorize endpoint is made. Put another way, I expected that after the user signs in the first time no other interactive login is required to the user, because the user session expiration date is automatically moved forward in time each time a new access token is required by the silent renew iframe.

In order to obtain this behavior I've set up the following configuration at the identity server level.

This is the client configuration (notice that the access token lifetime is 2 minutes = 120 seconds):

                    new Client
                    {
                        ClientId = "web-client",
                        ClientName = "SPA web client",
                        AllowedGrantTypes = GrantTypes.Code,
                        RequireClientSecret = false,
                        RequirePkce = true,
                        RequireConsent = false,
                        AccessTokenLifetime = 120,
                        
                        RedirectUris =           { "https://localhost:4200/assets/signin-callback.html", "https://localhost:4200/assets/silent-callback.html" },
                        PostLogoutRedirectUris = { "https://localhost:4200/signout-callback" },
                        AllowedCorsOrigins =     { "https://localhost:4200" },

                        AllowedScopes =
                        {
                            IdentityServerConstants.StandardScopes.OpenId,
                            IdentityServerConstants.StandardScopes.Profile,
                            IdentityServerConstants.StandardScopes.Email,
                            "dataset",
                            "exercise",
                            "user-permissions"
                        }
                    }

This is the ConfigureServices, where I've added all of the identity server configuration. Notice that the cookie lifetime is set to 15 minutes and that the cookie sliding expiration is required:

        public void ConfigureServices(IServiceCollection services)
        {
            services.Configure<RequestLoggingOptions>(o =>
            {
                o.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
                {
                    diagnosticContext.Set("RemoteIpAddress", httpContext.Connection.RemoteIpAddress.MapToIPv4());
                };
            });

            services.AddControllersWithViews();

            var migrationsAssembly = GetRunningAssemblyName();
            var connectionString = this.Configuration.GetConnectionString(IdentityServerDatabaseConnectionString);

            var identityServerBuilder = services.AddIdentityServer(options =>
            {
                options.Authentication.CookieLifetime = TimeSpan.FromMinutes(15);
                options.Authentication.CookieSlidingExpiration = true;
            })
            .AddTestUsers(TestData.Users)
            .AddConfigurationStore(options =>
            {
                options.ConfigureDbContext = dbContextBuilder =>
                    dbContextBuilder.UseSqlServer(
                        connectionString,
                        sqlServerOptionsBuilder => sqlServerOptionsBuilder.MigrationsAssembly(migrationsAssembly)
                    );
            })
            .AddOperationalStore(options =>
            {
                options.ConfigureDbContext = dbContextBuilder =>
                    dbContextBuilder.UseSqlServer(
                        connectionString,
                        sqlServerOptionsBuilder => sqlServerOptionsBuilder.MigrationsAssembly(migrationsAssembly)
                    );
            });

            services.AddAuthentication(x => x.DefaultAuthenticateScheme = IdentityServer4.IdentityServerConstants.DefaultCookieAuthenticationScheme);

            identityServerBuilder.AddDeveloperSigningCredential();
        }

I've added the call to services.AddAuthentication(x => x.DefaultAuthenticateScheme = IdentityServer4.IdentityServerConstants.DefaultCookieAuthenticationScheme); after reading this github issue. Based on my understanding this call is redundant, because the call to services.AddIdentityServer should already set the cookie authentication as the default authentication scheme, by using the constant IdentityServer4.IdentityServerConstants.DefaultCookieAuthenticationScheme as the authentication scheme name.

By using this identity server configuration the silen access token renew does not work the way I expect.

The access token is silently renewed 14 times, then the fifteenth attempt to renew the access token fails with the message SilentRenewService._tokenExpiring: Error from signinSilent: login_required.

This basically means that the authentication cookie sliding expiration is not working, because my authentication cookie has a 15 minutes lifetime, the access token for my SPA client has a 2 minutes lifetime and the oidc client js library is doing the silent refresh cycle once per minute (the access token is renewed 60 seconds before its expiration time, so with my settings the silent renew in done every minute). At the fifteenth attempt to renew the access token the authentication cookie is finally expired and the identity server authorize endpoint returns an error response to the https://localhost:4200/assets/silent-callback.html static page.

These are my console logs (notice that for 14 times the silen renew has worked as expected):

enter image description here

These are the server side logs written by identity server, which confirms that the user session is expired at the fifteenth attempt:

enter image description here

These are the response headers returned by identity server when the /connect/authorize endpoint is called during a successful attempt to renew the access token (one of the first 14 attempts to renew the access token). Notice that there is a response header which sets a new value for the idsrv cookie:

enter image description here

These are the response headers returned by identity server when the /connect/authorize endpoint is called during a failed attempt to renew the access token (the fifteenth attempt to renew the access token). Notice that the idsrv.session cookie is invalidated, because its expiration date is set to a past date in 2019:

enter image description here

Am I missing anything about the relationship between the silent access token renew and the authentication cookie sliding expiration ?

Is this the expected behavior ?

Is there a way to make the silent access token renew work without requiring a new user login interaction ?

Update 16th September 2020

I finally managed to solve this issue.

The fix is the updating of the IdentityServer4.EntityFramework nuget package to the latest available version (4.1.0 as of today).

All the details are reported in my own github issue on the oidc-client-js github repository.

To summarize, the underlying cause of the strange behavior with the cookie sliding expiration is this identity server bug, fixed by the 4.1.0 release of IdentityServer4.EntityFramework nuget package, as pointed in the release notes.

1

1 Answers

1
votes

Here is my configuration on your request:

public void ConfigureServices(IServiceCollection services)
    {
        services.AddHealthChecks();

        services.AddControllersWithViews();

        services.AddCustomOptions(Configuration);

        services.AddCustomDbContext(Configuration)
            .ResolveDependencies();

        services.AddIdentityServer(options =>
                {
                    options.Authentication.CookieLifetime = AccountOptions.RememberMeLoginDuration;
                    options.Events.RaiseSuccessEvents = true;
                    options.Events.RaiseFailureEvents = true;
                    options.Events.RaiseErrorEvents = true;
                })
            .AddProfileService<ProfileService>()
            .AddSigningCertificate(Configuration)
            .AddInMemoryClients(Configuration.GetSection("IdentityServer:Clients"))
            .AddInMemoryIdentityResources(Resources.GetIdentityResources())
            .AddInMemoryApiResources(Resources.GetApis());

        var externalProviders = Configuration.GetSection(nameof(ApplicationOptions.ExternalProviders))
            .Get<ExternalProvidersOptions>();

        services.AddWindowsIdentityProvider(externalProviders.UseWindows);

        services.AddLocalization(options => options.ResourcesPath = Constants.Resources);

        services.AddMvc()
            .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix)
            .AddDataAnnotationsLocalization();

        services.AddMvcCore()
            .AddCustomCors();
    }

Also, follwing is the client configuration in the appsettings:

{
    "Enabled": true,
    "ClientId": "dashboard",
    "ClientName": "Web Client",
    "ClientSecrets": [ { "Value": "K7gNU3sdo+OL0wNhqoVWhr3g6s1xYv72ol/pe/Unols=" } ],
    "AllowedGrantTypes": [ "implicit", "authorization_code" ],
    "AllowedScopes": [ "openid", "email", "profile", "role" ],
    "AllowOfflineAccess": true,
    "AllowAccessTokensViaBrowser": true,
    "AllowedCorsOrigins": [
      "http://localhost:7004"
    ],
    "RedirectUris": [
      "http://localhost:7004/callback",
      "http://localhost:7004/refreshtoken"
    ],
    "PostLogoutRedirectUris": [
      "http://localhost:7004"
    ],
    "AccessTokenLifetime": 3600,
    "RequireConsent": false
  }