I have reviewed a great deal of questions, but have not yet solved the issue. For authentication, Microsoft.AspNetCore.Authentication.MicrosoftAccount is included. Authentication and authorization work well. It seems awkward that the library would automate everything but the token refresh.
For token refresh, offline_access is included:
app.UseMicrosoftAccountAuthentication(new MicrosoftAccountOptions
{
ClientId = Startup.Config["Data:MSAppId"],
ClientSecret = Startup.Config["Data:MSAppSecret"],
CallbackPath = new PathString("/signin-microsoft-token"),
AuthorizationEndpoint = MicrosoftAccountDefaults.AuthorizationEndpoint,
SignInScheme = new IdentityCookieOptions().ExternalCookieAuthenticationScheme,
TokenEndpoint = MicrosoftAccountDefaults.TokenEndpoint,
Scope = { "openid", "email", "profile", "offline_access", "Contacts.Read", "Mail.ReadWrite", "Mail.Send" },
AutomaticAuthenticate = true,
AutomaticChallenge = true,
SaveTokens = true
});
The tokens are saved in the AspNetUserTokens table.

Using the access token, contacts and emails are successfully returned, and emails are successfully sent on behalf of the user. However when attempting to use the refresh token, a 'Bad Request' [400] error is returned.
string clientId = /* ... */
string clientSecret = /* ... */
string access_token = /* ... */
string refresh_token = /* ... */
DateTime expiration_date = /* ... */
const string refreshTokenUrl = "https://login.microsoftonline.com/common/oauth2/token";
const string redirectUri = "http://localhost/";
if (DateTime.Now < (expiration_date - TimeSpan.FromMinutes(10)))
return access_token;
HttpClient client = new HttpClient();
string parameters = $"grant_type=refresh_token&refresh_token={ WebUtility.UrlEncode(refresh_token) }&client_id={ WebUtility.UrlEncode(clientId) }&client_secret={ WebUtility.UrlEncode(clientSecret) }&redirect_uri={ WebUtility.UrlEncode(redirectUri) }";
var contentBody = new StringContent(parameters, System.Text.Encoding.UTF8, "application/x-www-form-urlencoded");
HttpResponseMessage response = await client.PostAsync(refreshTokenUrl, contentBody);
if (!response.IsSuccessStatusCode)
{
// process 400 - bad request error ...
}
I have looked at the request with fiddler, and it appears correct. I have used similar code above with a pure REST solution, and didn't have difficulty.
I would have thought their library would have a better solution to refresh the token, since it works so effortlessly for the original authentication and authorization. Is there a better way to solve this? If not, why is the request being rejected?
EDIT:
Removing the redirect_uri resulted in the same error response. Looking deeper with fiddler, the body of the error response (JSON) begins with the following:
{
"error":"invalid_grant",
"error_description":"AADSTS70000: Transmission data parser failure: Refresh Token is malformed or invalid.
Trace ID: 92beaa67-97f9-48c3-8281-87da228b0000
// next is Correlation ID, Timestamp, trace_id, etc
However I am certain that the entire refresh_token is properly saved and sent, which the last test had 953 characters.