Files
qbridge/Controllers/AuthController.cs
2019-10-07 21:43:46 +00:00

415 lines
17 KiB
C#

using System;
using System.Linq;
using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Net.Http;
using Newtonsoft.Json.Linq;
namespace qbridge.Controllers
{
//[Route("[controller]")]
[Route("")]
[ApiController]
[Produces("application/json")]
public class OAuthRedirectController : ControllerBase
{
public const string CLIENT_ID = "ABj70Wv5gDauFd9KgKFwuvpQjfzTwEgodEG8tnBbS8mSQhNrZJ";
public const string CLIENT_SECRET = "XUmJyvEcEuwQuyhARUAm0a8G3gzbEAeMiATCLyFZ";
#if (DEBUG)
public const string REDIRECT_URI = "https://localhost:3003/redirect";
#else
public const string REDIRECT_URI = "https://qboauth.ayanova.com/redirect";
#endif
public const string DISCOVERY_DOCUMENT_URL = "https://developer.api.intuit.com/.well-known/openid_sandbox_configuration";
public static Dictionary<string, QBToken> TOKEN_STORE = null;
//current 2019 fall disco doc urls
//Sandbox: https://developer.api.intuit.com/.well-known/openid_sandbox_configuration
//Production: https://developer.api.intuit.com/.well-known/openid_configuration
//used for discovery document
//https://docs.microsoft.com/en-us/aspnet/core/fundamentals/http-requests?view=aspnetcore-3.0
private readonly IHttpClientFactory _clientFactory;
public static JObject DiscoveryDoc { get; private set; }
/*
Discovery document
https://developer.intuit.com/app/developer/qbo/docs/develop/authentication-and-authorization/openid-connect#discovery-document
Endpoints:
https://developer.api.intuit.com/.well-known/openid_sandbox_configuration
https://developer.api.intuit.com/.well-known/openid_configuration
QB Online developer account login creds for sandbox:
login: support@ayanova.com
pw: i8BREAKfast!
Development tokens for QBOI oAuth2 "AyaNova_QBOI_2"
https://developer.intuit.com/v2/ui#/app/appdetail/b7urd26wgx/b7urd26xgp/keys
ClientID
ABj70Wv5gDauFd9KgKFwuvpQjfzTwEgodEG8tnBbS8mSQhNrZJ
Client Secret
XUmJyvEcEuwQuyhARUAm0a8G3gzbEAeMiATCLyFZ
Sandbox:
https://c50.sandbox.qbo.intuit.com/app/homepage
sandbox company_us_1
sandbox-quickbooks.api.intuit.com
*/
public OAuthRedirectController(IHttpClientFactory clientFactory)
{
_clientFactory = clientFactory;
if (TOKEN_STORE == null)
{
TOKEN_STORE = new Dictionary<string, QBToken>();
}
}
[HttpGet("start/{state}/{sourceid}")]
public async Task<IActionResult> GetAsync([FromRoute]string state, [FromRoute] string sourceid)
{
//sourceid is actually the license ID or serial number so I can tell who is using and ultimately filter them out if they are not licensed and up to date (down the road, not initially)
if (string.IsNullOrWhiteSpace(state))
{
return BadRequest("state value is missing and required");
}
if (string.IsNullOrWhiteSpace(sourceid))
{
return BadRequest("sourceid value is missing and required");
}
//Job one is to clean out the old entries in the token store if necessary
//rather than bothering with some kind of recurring task just do it on every fetch for now
SweepTokenStore();
//GET THE DISCOVERY DOCUMENT
//Discovery document contains the actual current endpoints to use for various ops
await GetQBDiscoveryDocument();
if (DiscoveryDoc == null)
{
return ContentPage($"<h1>Error - Unable to fetch Discovery document from QuickBooks Online</h1>Cannot proceed");
}
var AuthorizationEndpoint = DiscoveryDoc["authorization_endpoint"].Value<string>();
if (string.IsNullOrWhiteSpace(AuthorizationEndpoint))
{
return ContentPage($"<h1>Error - Unable to find AuthorizationEndpoint value in Discovery document from QuickBooks Online</h1>Cannot proceed");
}
//GET AUTHORIZATION CODE AND REDIRECT
string url = string.Empty;
var queryParams = new Dictionary<string, string>()
{
{"client_id", CLIENT_ID },
{"scope", "com.intuit.quickbooks.accounting" },
{"redirect_uri",REDIRECT_URI },
{"response_type","code"},
{"state",state}
};
url = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(AuthorizationEndpoint, queryParams);
return Redirect(url);
//will ask for login creds and permission then will redirect back to the Get method below with:
//State: MyUniqueStateID, Code: AB11569366500tshFDRPEuR28l4vTjpXWuwFldE44rMng98Gn9
//This Code value is the "Authorization code" that is then used to get the access token
//which in turn is *then* used to actually get the access token
}
// Redirect endpoint, this is used by the Intuit auth server to redirect back to with the good stuff
//Step 4 here: https://developer.intuit.com/app/developer/qbo/docs/develop/authentication-and-authorization/openid-connect
[HttpGet("redirect")]
public async Task<IActionResult> GetAsync([FromQuery] string state, [FromQuery]string code, [FromQuery]string realmId)
{
//NOTE: state is our own state provided in the initial auth redirect
//code is authorization code used to then get the refresh and access token
//realmId is the id of the actual company database to work with chosen by user on login
if (DiscoveryDoc == null)
{
return ContentPage($"<h2>Error - Unable to fetch Discovery document from QuickBooks Online</h2>Cannot proceed with Revoke");
}
//We arrive here after the user has logged in and now we should have the authorization code that can now be used to fetch the actual tokens we need
var TokenEndpoint = DiscoveryDoc["token_endpoint"].Value<string>();
if (string.IsNullOrWhiteSpace(TokenEndpoint))
{
return ContentPage($"<h2>Error - Unable to find TokenEndpoint value in Discovery document from QuickBooks Online</h2>Cannot proceed");
}
//Step 5: Exchange authorization code for refresh and access tokens
//Get access token
//which is step 5 here: https://developer.intuit.com/app/developer/qbo/docs/develop/authentication-and-authorization/openid-connect#step-5-exchange-authorization-code-to-obtain-id-token-and-access-token
var request = new HttpRequestMessage(HttpMethod.Post, TokenEndpoint);
request.Headers.Add("Accept", "application/json");
request.Headers.Add("User-Agent", "AyaNova-QBridge");
request.Headers.Add("Authorization", "Basic " + Base64Encode(CLIENT_ID + ":" + CLIENT_SECRET));
var bodyParams = new Dictionary<string, string>()
{
{"code", code },
{"redirect_uri", REDIRECT_URI },
{"grant_type","authorization_code"}
};
request.Content = new FormUrlEncodedContent(bodyParams);
var client = _clientFactory.CreateClient();
var response = await client.SendAsync(request);
JObject AccessTokenObject = null;
if (response.IsSuccessStatusCode)
{
string data = await response.Content.ReadAsStringAsync();
AccessTokenObject = JObject.Parse(data);
var refresh_token = AccessTokenObject["refresh_token"].Value<string>();
var access_token = AccessTokenObject["access_token"].Value<string>();
var x_refresh_token_expires_in = AccessTokenObject["x_refresh_token_expires_in"].Value<long>();
var access_token_expires_in = AccessTokenObject["expires_in"].Value<long>();
//Store the token!!
//Remove any prior tokens with same state if exist (this can happen if person reloads the auth page or navigates backwards in it)
TOKEN_STORE.Remove(state);
//now add it
TOKEN_STORE.Add(state, new QBToken() { realmId = realmId, access_token = access_token, refresh_token = refresh_token, TokenBirthday = DateTime.Now });
return Redirect("/success");
}
else
{
return ContentPage($"<h1>Error - Failed to authenticate with QuickBooks Online</h1>Error returned by QuickBooks online is: {response.ReasonPhrase}");
}
/*
Actual response example:
TOKEN: {
"x_refresh_token_expires_in": 8726400,
"refresh_token": "AB11578248140owdqiIJTvBVGdUpGHjObkuX2Cj1Lvoi2kIZCT",
"access_token": "eyJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwiYWxnIjoiZGlyIn0..PdqkhbQyVvMTr3DJ9QgPuQ.Nk4uN3c0nlQzX0_jcWVRDrh-mPI0KVg0cRhC-p-jal2lyU1sZffpbV0S6p5IDag-bd9lcQ8yl3xJQq1lVrccfHQ76kgEo28Yx9yz8MdSdliQTHYVMmy-mJgd-f34rYxFy2i_q7-tIC2rIemQ62OqoKXgNvNQluDklje1rbJTeObUpQSmHmkQA9BhdAERPKRmL6HDVAWTKzbscV_Luuvl5Jp2U8Nu4iw7GgChh-1yxV2Q3VtQrUjhMs6fVOSLtt48mJCi0Q23Bp9jWAw3hPw2rB6gPtvAxT6HE98pB1pLlRlI6sIOeefu5m0z3v90_0ZRI0ICTIk2jgbGj8EF8C3RCvR1ZZdWtPTwcX0UpiBIh8EGNhJ3aofpfJ1V8rfKmYrQKrx9mViLkv4GVpNBl-g9YDe8y5s5JfQy_3Nq4qKOF4d2Ho6djeMC4EZp39_wnFo68NK8YCY8Kt8r1IRvRk58NiN3i__vyNyPL8xcWPkHVYMZ-WRKD_yD-H74lnw7JAG_f6JRuOe0vKJu_m-l_J8ktVcHBHsUOC-YlBUoXxqzDuvj5FqQcmGu9crizdeaKKf_hfkoPSlDxf8ux-Gdvmdbpt3F2Esfa4Mv7i3MgSJQ-68Gz6C5SKz5w8YfqaYEgz826z7ZFG5Wlfkmw_B65gfJMFImoTS4Ps3UMLRkLGwtOqtdD3MpABVnqNdL4_y0iqma.eCczzq3eIeM3OofHecv18A",
"expires_in": 3600,
"token_type": "bearer"
}
*/
}
//return a success page to the user
//this is done as a redirect to scrub the url bar clean of the codes and state etc
[HttpGet("success")]
public IActionResult SuccessPage()
{
return ContentPage("<h2>Success!</h2>Token received from QuickBooks online and available for QBOI to automatically retrieve. You can close this browser window now.");
}
//examine stored tokens, if matching one found then return it and erase from the list?
[HttpGet("fetch/{state}")]
public IActionResult FetchToken([FromRoute]string state)
{
//clear out any tokens older than 1 hour
SweepTokenStore();
if (string.IsNullOrWhiteSpace(state))
{
return BadRequest("state value is required");
}
var token = TOKEN_STORE.FirstOrDefault(pair => pair.Key == state);
if (token.Key == null)
{
//delay failed request to foil fishing and dos attempts
Task.WaitAll(Task.Delay(5000));
return NotFound();
}
else
{
//User has fetched the token
//it's a one time thing so remove it and return it
TOKEN_STORE.Remove(state);
return Ok(token.Value);
}
}
public class QBToken
{
public string realmId { get; set; }
public string access_token { get; set; }
public string refresh_token { get; set; }
public DateTime TokenBirthday { get; set; }
}
//Remove stale tokens
public static void SweepTokenStore()
{
//ditch tokens older than 1 hour
//note that there should never be an old token because once QBOI fetches it it's removed automatically, this is just a just in case thing for now
//QBOI2 will refresh the token so here we store only the original access token
DateTime dtExpireAfter = DateTime.Now.AddHours(-1);
//if the token birthday is less than an hour then it's kept in the token store
TOKEN_STORE = TOKEN_STORE.Where(pair => pair.Value.TokenBirthday > dtExpireAfter)
.ToDictionary(pair => pair.Key,
pair => pair.Value);
}
public static string Base64Encode(string plainText)
{
var plainTextBytes = System.Text.Encoding.UTF8.GetBytes(plainText);
return System.Convert.ToBase64String(plainTextBytes);
}
//This block gets the QuickBooks official endpoints rather than statically coding them
public async Task GetQBDiscoveryDocument()
{
/*
Example:
issuer:"https://oauth.platform.intuit.com/op/v1",
authorization_endpoint:"https://appcenter.intuit.com/connect/oauth2",
token_endpoint:"https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer",
userinfo_endpoint:"https://accounts.intuit.com/v1/openid_connect/userinfo",
revocation_endpoint:"https://developer.API.intuit.com/v2/oauth2/tokens/revoke",
jwks_uri:"https://oauth.platform.intuit.com/op/v1/jwks"
*/
var request = new HttpRequestMessage(HttpMethod.Get, DISCOVERY_DOCUMENT_URL);
request.Headers.Add("Accept", "application/json");
request.Headers.Add("User-Agent", "AyaNova-QBridge");
var client = _clientFactory.CreateClient();
var response = await client.SendAsync(request);
if (response.IsSuccessStatusCode)
{
string data = await response.Content.ReadAsStringAsync();
DiscoveryDoc = JObject.Parse(data);
}
else
{
DiscoveryDoc = null;
}
return;
}
[HttpGet("Revoke/{tokenToRevoke}")]
public async Task<IActionResult> RevokeAsync([FromRoute]string tokenToRevoke)
{
//Revoke the access token for the app for the unique ID specified
/*
POST https://developer.api.intuit.com/v2/oauth2/tokens/revoke HTTP/1.1
Accept: application/json
Authorization: Basic UTM0dVBvRDIwanp2OUdxNXE1dmlMemppcTlwM1d2
NzRUdDNReGkwZVNTTDhFRWwxb0g6VEh0WEJlR3dheEtZSlVNaFhzeGxma1l
XaFg3ZlFlRzFtN2szTFRwbw==
Content-Type: application/json
{
"token": "{bearerToken or refreshToken}"
}
*/
//GET THE DISCOVERY DOCUMENT
//Discovery document contains the actual current endpoints to use for various ops
await GetQBDiscoveryDocument();
if (DiscoveryDoc == null)
{
return ContentPage($"<h1>Error - Unable to fetch Discovery document from QuickBooks Online</h1>Cannot proceed with Revoke");
}
var revocation_endpoint = DiscoveryDoc["revocation_endpoint"].Value<string>();
if (string.IsNullOrWhiteSpace(revocation_endpoint))
{
return ContentPage($"<h1>Error - Unable to find revocation_endpoint value in Discovery document from QuickBooks Online</h1>Cannot proceed");
}
var request = new HttpRequestMessage(HttpMethod.Post, revocation_endpoint);
request.Headers.Add("Accept", "application/json");
request.Headers.Add("User-Agent", "AyaNova-QBridge");
request.Headers.Add("Authorization", "Basic " + Base64Encode(CLIENT_ID + ":" + CLIENT_SECRET));
// var bodyParams = new Dictionary<string, string>()
// {
// {"code", code },
// {"redirect_uri", REDIRECT_URI },
// {"grant_type","authorization_code"}
// };
string jfrag = $"{{\"token\":\"{tokenToRevoke}\"}}";
request.Content = new StringContent(jfrag, System.Text.Encoding.UTF8, "application/json");
var client = _clientFactory.CreateClient();
var response = await client.SendAsync(request);
if (response.IsSuccessStatusCode)
{
return ContentPage("Token revoked");
}
else
{
return ContentPage("Token revocation FAILED!");
}
}
/// <summary>
/// Prepare a content result that includes the specified html
/// This is the .net core 2.x approved way of returning content from a web api route
/// </summary>
/// <param name="bodyHtml"></param>
/// <returns></returns>
private ContentResult ContentPage(string bodyHtml)
{
return new ContentResult
{
ContentType = "text/html",
StatusCode = (int)Microsoft.AspNetCore.Http.StatusCodes.Status200OK,
Content = $"<html><body>{bodyHtml}</body></html>"
};
}
// *************************************************************************************************************
}
}