az: rework error handling

* _RequestHelper no longer eats exceptions.
* Delete the "no internet" error message.
* Wrap exceptions coming out of Azure API in a well-known type.
* Catch by type.
* Extract error codes for known failures (keep polling, invalid grant).
* When we get an Invalid Grant, dispose of the cached refresh token and force the user to log in again.
* Catch all printable exceptions and print them.
* Remove the NoConnect state completely -- just bail out when an exception hits the toplevel of the output thread.
* Move 3x logic into _RefreshTokens and pop exceptions out of it.
* Begin abstracting into AzureClient
This commit is contained in:
Dustin L. Howett
2020-04-11 23:04:15 -07:00
committed by Dustin Howett
parent 37e62dabd5
commit 61f06e7aa5
4 changed files with 135 additions and 76 deletions

View File

@@ -0,0 +1,46 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
#pragma once
#include "cpprest/json.h"
namespace Microsoft::Terminal::Azure
{
class AzureException : public std::runtime_error
{
std::wstring _code;
public:
static bool IsErrorPayload(const web::json::value& errorObject)
{
return errorObject.has_string_field(L"error");
}
AzureException(const web::json::value& errorObject) :
runtime_error(til::u16u8(errorObject.at(L"error_description").as_string())), // surface the human-readable description as .what()
_code(errorObject.at(L"error").as_string())
{
}
std::wstring_view GetCode() const noexcept
{
return _code;
}
};
namespace ErrorCodes
{
static constexpr std::wstring_view AuthorizationPending{ L"authorization_pending" };
static constexpr std::wstring_view InvalidGrant{ L"invalid_grant" };
}
}
#define THROW_IF_AZURE_ERROR(payload) \
do \
{ \
if (AzureException::IsErrorPayload((payload))) \
{ \
throw AzureException((payload)); \
} \
} while (0)

View File

@@ -16,9 +16,13 @@
#include "AzureConnection.g.cpp"
#include "AzureClient.h"
#include "winrt/Windows.System.UserProfile.h"
#include "../../types/inc/Utils.hpp"
using namespace ::Microsoft::Console;
using namespace ::Microsoft::Terminal::Azure;
using namespace utility;
using namespace web;
@@ -40,7 +44,7 @@ static constexpr auto HttpUserAgent = L"Terminal/0.0";
{ \
return E_FAIL; \
} \
} while (0, 0)
} while (0)
static constexpr int USER_INPUT_COLOR = 93; // yellow - the color of something the user can type
static constexpr int USER_INFO_COLOR = 97; // white - the color of clarifying information
@@ -91,6 +95,27 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
_TerminalOutputHandlers(str + L"\r\n");
}
// Method description:
// - helper that prints exception information to the output stream.
// Arguments:
// - [IMPLICIT] the current exception context
void AzureConnection::_WriteCaughtExceptionRecord()
{
try
{
throw;
}
catch (const std::exception& runtimeException)
{
// This also catches the AzureException, which has a .what()
_TerminalOutputHandlers(_colorize(91, til::u8u16(std::string{ runtimeException.what() })));
}
catch (...)
{
LOG_CAUGHT_EXCEPTION();
}
}
// Method description:
// - ascribes to the ITerminalConnection interface
// - creates the output thread (where we will do the authentication and actually connect to Azure)
@@ -366,17 +391,13 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
}
return S_OK;
}
case AzureState::NoConnect:
{
_WriteStringWithNewline(RS_(L"AzureInternetOrServerIssue"));
_transitionToState(ConnectionState::Failed);
return E_FAIL;
}
}
}
catch (...)
{
_state = AzureState::NoConnect;
_WriteCaughtExceptionRecord();
_transitionToState(ConnectionState::Failed);
return E_FAIL;
}
}
}
@@ -500,12 +521,24 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
// Check if the token is close to expiring and refresh if so
if (timeNow + _expireLimit > _expiry)
{
const auto refreshResponse = _RefreshTokens();
_accessToken = refreshResponse.at(L"access_token").as_string();
_refreshToken = refreshResponse.at(L"refresh_token").as_string();
_expiry = std::stoi(refreshResponse.at(L"expires_on").as_string());
// Store the updated tokens under the same username
_StoreCredential();
try
{
_RefreshTokens();
// Store the updated tokens under the same username
_StoreCredential();
}
catch (const AzureException& e)
{
if (e.GetCode() == ErrorCodes::InvalidGrant)
{
_WriteCaughtExceptionRecord();
vault.Remove(desiredCredential);
// Delete this credential and try again.
_state = AzureState::AccessStored;
return S_FALSE;
}
throw; // rethrow. we couldn't handle this error.
}
}
// We have everything we need, so go ahead and connect
@@ -532,17 +565,7 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
const auto expiresIn = std::stoi(deviceCodeResponse.at(L"expires_in").as_string());
// Wait for user authentication and obtain the access/refresh tokens
json::value authenticatedResponse;
try
{
authenticatedResponse = _WaitForUser(devCode, pollInterval, expiresIn);
}
catch (...)
{
_WriteStringWithNewline(RS_(L"AzureExitStr"));
return E_FAIL;
}
json::value authenticatedResponse = _WaitForUser(devCode, pollInterval, expiresIn);
_accessToken = authenticatedResponse.at(L"access_token").as_string();
_refreshToken = authenticatedResponse.at(L"refresh_token").as_string();
@@ -562,11 +585,7 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
std::tie(_tenantID, _displayName) = _crackTenant(chosenTenant);
// We have to refresh now that we have the tenantID
const auto refreshResponse = _RefreshTokens();
_accessToken = refreshResponse.at(L"access_token").as_string();
_refreshToken = refreshResponse.at(L"refresh_token").as_string();
_expiry = std::stoi(refreshResponse.at(L"expires_on").as_string());
_RefreshTokens();
_state = AzureState::StoreTokens;
}
else
@@ -626,11 +645,7 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
std::tie(_tenantID, _displayName) = _crackTenant(chosenTenant);
// We have to refresh now that we have the tenantID
const auto refreshResponse = _RefreshTokens();
_accessToken = refreshResponse.at(L"access_token").as_string();
_refreshToken = refreshResponse.at(L"refresh_token").as_string();
_expiry = std::stoi(refreshResponse.at(L"expires_on").as_string());
_RefreshTokens();
_state = AzureState::StoreTokens;
return S_OK;
}
@@ -724,19 +739,14 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
json::value AzureConnection::_RequestHelper(http_client theClient, http_request theRequest)
{
json::value jsonResult;
try
{
const auto responseTask = theClient.request(theRequest);
responseTask.wait();
const auto response = responseTask.get();
const auto responseJsonTask = response.extract_json();
responseJsonTask.wait();
jsonResult = responseJsonTask.get();
}
catch (...)
{
_WriteStringWithNewline(RS_(L"AzureInternetOrServerIssue"));
}
const auto responseTask = theClient.request(theRequest);
responseTask.wait();
const auto response = responseTask.get();
const auto responseJsonTask = response.extract_json();
responseJsonTask.wait();
jsonResult = responseJsonTask.get();
THROW_IF_AZURE_ERROR(jsonResult);
return jsonResult;
}
@@ -776,7 +786,6 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
// Continuously send a poll request until the user authenticates
const auto body = hstring() + L"grant_type=device_code&resource=" + _wantedResource + L"&client_id=" + AzureClientID + L"&code=" + deviceCode;
const auto requestUri = L"common/oauth2/token";
json::value responseJson;
for (int count = 0; count < expiresIn / pollInterval; count++)
{
// User might close the tab while we wait for them to authenticate, this case handles that
@@ -784,28 +793,32 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
{
throw "Tab closed.";
}
http_request pollRequest(L"POST");
pollRequest.set_request_uri(requestUri);
pollRequest.set_body(body.c_str(), L"application/x-www-form-urlencoded");
responseJson = _RequestHelper(pollingClient, pollRequest);
if (responseJson.has_field(L"error"))
{
Sleep(pollInterval * 1000); // Sleep takes arguments in milliseconds
continue; // Still waiting for authentication
}
else
try
{
auto response{ _RequestHelper(pollingClient, pollRequest) };
_WriteStringWithNewline(RS_(L"AzureSuccessfullyAuthenticated"));
break; // Authentication is done, break from loop
// Got a valid response: we're done
return response;
}
catch (const AzureException& e)
{
if (e.GetCode() == ErrorCodes::AuthorizationPending)
{
// Handle the "auth pending" exception by retrying.
Sleep(pollInterval * 1000);
continue;
}
throw;
} // uncaught exceptions bubble up to the caller
}
if (responseJson.has_field(L"error"))
{
throw "Time out.";
}
return responseJson;
throw "Time out.";
return json::value::null();
}
// Method description:
@@ -830,7 +843,7 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
// - helper function to refresh the access/refresh tokens
// Return value:
// - the response with the new tokens
json::value AzureConnection::_RefreshTokens()
void AzureConnection::_RefreshTokens()
{
// Initialize the client
http_client refreshClient(_loginUri);
@@ -843,7 +856,10 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
refreshRequest.headers().add(L"User-Agent", HttpUserAgent);
// Send the request and return the response as a json value
return _RequestHelper(refreshClient, refreshRequest);
auto refreshResponse{ _RequestHelper(refreshClient, refreshRequest) };
_accessToken = refreshResponse.at(L"access_token").as_string();
_refreshToken = refreshResponse.at(L"refresh_token").as_string();
_expiry = std::stoi(refreshResponse.at(L"expires_on").as_string());
}
// Method description:

View File

@@ -40,7 +40,6 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
StoreTokens,
TermConnecting,
TermConnected,
NoConnect
};
AzureState _state{ AzureState::AccessStored };
@@ -69,11 +68,12 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
utility::string_t _terminalID;
void _WriteStringWithNewline(const std::wstring_view str);
void _WriteCaughtExceptionRecord();
web::json::value _RequestHelper(web::http::client::http_client theClient, web::http::http_request theRequest);
web::json::value _GetDeviceCode();
web::json::value _WaitForUser(utility::string_t deviceCode, int pollInterval, int expiresIn);
web::json::value _GetTenants();
web::json::value _RefreshTokens();
void _RefreshTokens();
web::json::value _GetCloudShellUserSettings();
utility::string_t _GetCloudShell();
utility::string_t _GetTerminal(utility::string_t shellType);

View File

@@ -123,15 +123,15 @@
<data name="AzureEnterTenant" xml:space="preserve">
<value>Please enter the desired tenant number.</value>
</data>
<data name="AzureNewLogin" xml:space="default">
<data name="AzureNewLogin" xml:space="preserve">
<value>Enter {0} to login with a new account</value>
<comment>{0} will be replaced with the resource from AzureUserEntry_NewLogin; it is intended to be a single-character shorthand for "new account"</comment>
</data>
<data name="AzureRemoveStored" xml:space="default">
<data name="AzureRemoveStored" xml:space="preserve">
<value>Enter {0} to remove the above saved connection settings.</value>
<comment>{0} will be replaced with the resource from AzureUserEntry_RemoveStored; it is intended to be a single-character shorthand for "remove stored"</comment>
</data>
<data name="AzureInvalidAccessInput" xml:space="default">
<data name="AzureInvalidAccessInput" xml:space="preserve">
<value>Please enter a valid number to access the stored connection settings, {0} to log in with a new account, or {1} to remove the saved connection settings.</value>
<comment>{0} will be replaced with the resource from AzureUserEntry_NewLogin, and {1} will be replaced with AzureUserEntry_RemoveStored. This is an error message, used after AzureNewLogin/AzureRemoveStored if the user enters an invalid value.</comment>
</data>
@@ -148,11 +148,11 @@
<value>You have not set up your cloud shell account yet. Please go to https://shell.azure.com to set it up.</value>
<comment>{Locked="https://shell.azure.com"} This URL should not be localized. Everything else should.</comment>
</data>
<data name="AzureStorePrompt" xml:space="default">
<data name="AzureStorePrompt" xml:space="preserve">
<value>Do you want to save these connection settings for future logins? [{0}/{1}]</value>
<comment>{0} and {1} will be replaced with AzureUserEntry_Yes and AzureUserEntry_No. They are single-character shorthands for "yes" and "no" in this language.</comment>
</data>
<data name="AzureInvalidStoreInput" xml:space="default">
<data name="AzureInvalidStoreInput" xml:space="preserve">
<value>Please enter {0} or {1}</value>
<comment>{0} and {1} will be replaced with AzureUserEntry_Yes and AzureUserEntry_No. This resource will be used as an error response after AzureStorePrompt.</comment>
</data>
@@ -180,16 +180,13 @@
<data name="AzureAuthString" xml:space="preserve">
<value>Authenticated.</value>
</data>
<data name="AzureInternetOrServerIssue" xml:space="preserve">
<value>Could not connect to Azure. You may not have internet or the server might be down.</value>
</data>
<data name="AzureOldCredentialsFlushedMessage" xml:space="preserve">
<value>Authentication parameters changed. You'll need to log in again.</value>
</data>
<data name="AzureUnknownTenantName" xml:space="preserve">
<value>&lt;unknown tenant name&gt;</value>
</data>
<data name="AzureIthTenant" xml:space="default">
<data name="AzureIthTenant" xml:space="preserve">
<value>Tenant {0}: {1} ({2})</value>
<comment>{0} is the tenant's number, which the user will enter to connect to the tenant. {1} is the tenant's display name, which will be meaningful for the user. {2} is the tenant's internal ID number.</comment>
</data>