IdentityModel / oidc-client-js

OpenID Connect (OIDC) and OAuth2 protocol support for browser-based JavaScript applications
Apache License 2.0
2.43k stars 841 forks source link

Access token silent renew is not working along with authentication cookie sliding expiration #1199

Closed EnricoMassone closed 4 years ago

EnricoMassone commented 4 years ago

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

Something is not working with the silent access token renew.

My 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 would like 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.

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

new Client
{
    ClientId = "web-portal",
    ClientName = "SPA web portal",
    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 method, 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 the issue #911 . I'm quite sure that this call is redundant because the call to services.AddIdentityServer should do the same thing; in any case I have seen no difference in behaviour by removing this line of code.

By using this 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 by default). At the 15th 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 silent renew has worked fine):

console-logs

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

server-logs

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:

successfull-call

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 15th 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:

failed-call

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 ?

brockallen commented 4 years ago

my expectation is that the identity server authentication cookie should have a sliding expiration

If you're using our registered cookie scheme as the default, we use a static/fixed expiration of 8 or 10 hours (I forget which). If you want to change that, then put a Configure for the cookie options for the cookie scheme and tweak the config values.

EnricoMassone commented 4 years ago

@brockallen I finally managed to solve my issue.

By doing some debugging sessions I've noticed that the identity server of my application was re issuing the idsrv session cookie each time the authorize endpoint was called in order to renew the access token. According to this docs the cookie should instead be reissued only for a request which is more than halfway through the expiration window.

I've executed locally this sample from the identity server repository and I've verified that the sample works as expected (the authentication cookie is not reissued each time).

The root cause of my issue is probably this bug of identity server

I've finally solved the problem by upgrading the package IdentityServer4.EntityFramework from version 4.0.4 to version 4.1.0. As pointed out in the release notes, the version 4.1.0 fixes the session cookie bug linked above.