I'm working on a refactor in a ASP.Net Core 2.1 application to swap from the Implicit flow using a SPA, to an Authorization Code flow using an MVC client app. Since we're using the OpenIDDict library, I followed the documented Code Flow Example which was fantastic in getting up and running, but I quickly found that my access tokens were expiring and (as expected) the resource server began refusing requests.
My question is: How best do I refresh the access token?
I am new to OpenID Connect in general, but I understand the patterns in theory from the multitude of resources available. The verbiage is still a bit opaque to me (grant, principal, scopes, etc.), but given a good example I'm confident I can get this going.
Thanks in advance!
What I've tried:
Based on what seemed like similar questions, I attempted to implement a refresh token flow using the Refresh Flow example from the same source above. Although I believe I got the auth server plumbing setup correctly, I was unable to find any examples of this using a C# client (the above example uses an angular app).
Edit: When I send a post to my token endpoint with the refresh_token grant, I correctly get back a new access token. My issue is that I'm not sure how best to handle it from there. GetTokenAsync continues to return the stale token.
Client Startup:
services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
options.LoginPath = new PathString("/signin");
})
.AddOpenIdConnect(options =>
{
// Note: these settings must match the application details
// inserted in the database at the server level.
options.ClientId = "Portal"; //TODO replace via configuration
options.ClientSecret = "---";
options.RequireHttpsMetadata = false;
options.GetClaimsFromUserInfoEndpoint = true;
options.SaveTokens = true;
options.ResponseType = OpenIdConnectResponseType.CodeIdToken;
options.AuthenticationMethod = OpenIdConnectRedirectBehavior.RedirectGet;
// Note: setting the Authority allows the OIDC client middleware to automatically
// retrieve the identity provider's configuration and spare you from setting
// the different endpoints URIs or the token validation parameters explicitly.
options.Authority = "https://localhost:57851"; //TODO replace via configuration
options.Scope.Add("email");
options.Scope.Add("roles");
options.Scope.Add("offline_access");
options.SecurityTokenValidator = new JwtSecurityTokenHandler
{
// Disable the built-in JWT claims mapping feature.
InboundClaimTypeMap = new Dictionary<string, string>()
};
options.TokenValidationParameters.NameClaimType = "name";
options.TokenValidationParameters.RoleClaimType = "role";
});
Auth Startup:
.AddServer(options =>
{
// Register the ASP.NET Core MVC services used by OpenIddict.
// Note: if you don't call this method, you won't be able to
// bind OpenIdConnectRequest or OpenIdConnectResponse parameters.
options.UseMvc();
// Enable the authorization, logout, token and userinfo endpoints.
options.EnableAuthorizationEndpoint("/connect/authorize")
.EnableLogoutEndpoint("/connect/logout")
.EnableTokenEndpoint("/connect/token")
.EnableUserinfoEndpoint("/api/userinfo");
options
.AllowAuthorizationCodeFlow()
.AllowRefreshTokenFlow();
// Mark the "email", "profile" and "roles" scopes as supported scopes.
options.RegisterScopes(
OpenIdConnectConstants.Scopes.Email,
OpenIdConnectConstants.Scopes.Profile,
OpenIddictConstants.Scopes.Roles,
OpenIddictConstants.Scopes.OfflineAccess);
// When request caching is enabled, authorization and logout requests
// are stored in the distributed cache by OpenIddict and the user agent
// is redirected to the same page with a single parameter (request_id).
// This allows flowing large OpenID Connect requests even when using
// an external authentication provider like Google, Facebook or Twitter.
options.EnableRequestCaching();
// During development, you can disable the HTTPS requirement.
if (env.IsDevelopment())
{
options.DisableHttpsRequirement();
options.AddEphemeralSigningKey(); // TODO: In production, use a X.509 certificate ?
}
options.SetAccessTokenLifetime(TimeSpan.FromMinutes(openIdConnectConfig.AccessTokenLifetimeInMinutes));
options.SetRefreshTokenLifetime(TimeSpan.FromHours(12));
})
.AddValidation();
Descriptor:
var descriptor = new OpenIddictApplicationDescriptor{
ClientId = config.Id,
ClientSecret = config.Secret,
DisplayName = config.DisplayName,
PostLogoutRedirectUris = { new Uri($"{config.ClientOrigin}/signout-callback-oidc") },
RedirectUris = { new Uri($"{config.ClientOrigin}/signin-oidc") },
Permissions =
{
OpenIddictConstants.Permissions.Endpoints.Authorization,
OpenIddictConstants.Permissions.Endpoints.Logout,
OpenIddictConstants.Permissions.Endpoints.Token,
OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode,
OpenIddictConstants.Permissions.GrantTypes.RefreshToken,
OpenIddictConstants.Permissions.Scopes.Email,
OpenIddictConstants.Permissions.Scopes.Profile,
OpenIddictConstants.Permissions.Scopes.Roles
}};
Token Endpoint:
if (request.IsRefreshTokenGrantType()){
// Retrieve the claims principal stored in the refresh token.
var info = await HttpContext.AuthenticateAsync(OpenIdConnectServerDefaults.AuthenticationScheme);
// Retrieve the user profile corresponding to the refresh token.
// Note: if you want to automatically invalidate the refresh token
// when the user password/roles change, use the following line instead:
// var user = _signInManager.ValidateSecurityStampAsync(info.Principal);
var user = await _userManager.GetUserAsync(info.Principal);
if (user == null)
{
return BadRequest(new OpenIdConnectResponse
{
Error = OpenIdConnectConstants.Errors.InvalidGrant,
ErrorDescription = "The refresh token is no longer valid."
});
}
// Ensure the user is still allowed to sign in.
if (!await _signInManager.CanSignInAsync(user))
{
return BadRequest(new OpenIdConnectResponse
{
Error = OpenIdConnectConstants.Errors.InvalidGrant,
ErrorDescription = "The user is no longer allowed to sign in."
});
}
// Create a new authentication ticket, but reuse the properties stored
// in the refresh token, including the scopes originally granted.
var ticket = await CreateTicketAsync(request, user, info.Properties);
ticket.SetScopes(OpenIdConnectConstants.Scopes.OfflineAccess);
return SignIn(ticket.Principal, ticket.Properties, ticket.AuthenticationScheme);}