Still logged in MVC site, but can't call web API
I have an ASP.NET MVC site, IdentityServer4 host and a web API.
When I log in the MVC site, using external provider (Facebook), I'm logged in fine. From the MVC site I can also consume the web API correctly.
However, the next day, I'm still logged in into the MVC site, but when I then try to access the web API, I get a 'not authorized exception'.
So although I'm still logged in in the MVC site, I'm not authenticated anymore to call a web API from within the MVC site.
I'm wondering how to handle this situation, and how IdentityServer4 should be configured.
- Why am I still logged in the MVC site a day later? How can this be configured?
- Why can't I still call the web API, if I'm still logged in the MVC site?
- Can I sync the expiration times? Or how should I handle this?
The MVC application is configured like:
services.AddAuthentication(options =>
{
options.DefaultScheme = "Cookies";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("Cookies")
.AddOpenIdConnect("oidc", options =>
{
options.SignInScheme = "Cookies";
options.Authority = mgpIdSvrSettings.Authority;
options.RequireHttpsMetadata = false;
options.ClientId = mgpIdSvrSettings.ClientId;
options.ClientSecret = mgpIdSvrSettings.ClientSecret; // Should match the secret at IdentityServer
options.ResponseType = "code id_token"; // Use hybrid flow
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.Scope.Add("mgpApi");
options.Scope.Add("offline_access");
});
So it's using hybrid flow.
In IdentityServer the MVC client is configured like:
new Client
{
EnableLocalLogin = false,
ClientId = "mgpPortal",
ClientName = "MGP Portal Site",
AllowedGrantTypes = GrantTypes.Hybrid,
// where to redirect to after login
RedirectUris = mgpPortalSite.RedirectUris,
// where to redirect to after logout
PostLogoutRedirectUris = mgpPortalSite.PostLogoutRedirectUris,
// secret for authentication
ClientSecrets = mgpPortalSite.ClientSecrets.Select(cs => new Secret(cs.Sha256())).ToList(),
AllowedScopes = new List<string>
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
"mgpApi"
},
AllowOfflineAccess = true,
RequireConsent = false,
},
And finally the web API:
services.AddAuthentication("Bearer")
.AddIdentityServerAuthentication(options =>
{
options.Authority = mgpIdSvrSettings.Authority;
options.RequireHttpsMetadata = false;
options.ApiName = mgpIdSvrSettings.ApiName;
options.EnableCaching = true;
options.CacheDuration = TimeSpan.FromMinutes(10);
});
There are two types of authentication, cookie and bearer.
Where the cookie keeps you logged in, the bearer token can't. Because the bearer token is set to expire at some point, without allowing you to change the lifetime.
The only way to access the resource (api) after the access token expires is to either let the user login again or request a new access token using a refresh token, without needing user interaction.
You've already configured it:
options.Scope.Add("offline_access");
On each login the request will at least contain a refresh token. Store it at a safe place and use it when needed. By default it is set to one time use only.
You can use something like this code to renew the token (as you are not actually refreshing it, but rather replacing it). You'll need to include the 'IdentityModel' NuGet package, as seen in the samples from IdentityServer.
private async Task<TokenResponse> RenewTokensAsync()
{
// Initialize the token endpoint:
var client = _httpClientFactory.CreateClient();
var disco = await client.GetDiscoveryDocumentAsync("http://localhost:5000");
if (disco.IsError) throw new Exception(disco.Error);
// Read the stored refresh token:
var rt = await HttpContext.GetTokenAsync("refresh_token");
var tokenClient = _httpClientFactory.CreateClient();
// Request a new access token:
var tokenResult = await tokenClient.RequestRefreshTokenAsync(new RefreshTokenRequest
{
Address = disco.TokenEndpoint,
ClientId = "mvc",
ClientSecret = "secret",
RefreshToken = rt
});
if (!tokenResult.IsError)
{
var old_id_token = await HttpContext.GetTokenAsync("id_token");
var new_access_token = tokenResult.AccessToken;
var new_refresh_token = tokenResult.RefreshToken;
var expiresAt = DateTime.UtcNow + TimeSpan.FromSeconds(tokenResult.ExpiresIn);
// Save the information in the cookie
var info = await HttpContext.AuthenticateAsync("Cookies");
info.Properties.UpdateTokenValue("refresh_token", new_refresh_token);
info.Properties.UpdateTokenValue("access_token", new_access_token);
info.Properties.UpdateTokenValue("expires_at", expiresAt.ToString("o", CultureInfo.InvariantCulture));
await HttpContext.SignInAsync("Cookies", info.Principal, info.Properties);
return tokenResult;
}
return null;
}
By default the refresh token usage is configured as one time use. Please note that when storing the new refresh token fails and you should lose it, then the only way to request a new refresh token is to force the user to login again.
Also note that the refresh token can expire.
And taking it one step back, you'll need to use this when the access token expired or is about to expire:
var accessToken = await HttpContext.GetTokenAsync("access_token");
var tokenHandler = new JwtSecurityTokenHandler();
var jwtSecurityToken = tokenHandler.ReadJwtToken(accessToken);
// Depending on the lifetime of the access token.
// This is just an example. An access token may be valid
// for less than one minute.
if (jwtSecurityToken.ValidTo < DateTime.UtcNow.AddMinutes(5))
{
var responseToken = await RenewTokensAsync();
if (responseToken == null)
{
throw new Exception("Error");
}
accessToken = responseToken.AccessToken;
}
// Proceed, accessToken contains a valid token.