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 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(); } } [HttpGet("start/{state}/{sourceid}")] public async Task GetAsync([FromRoute]string state, [FromRoute] string sourceid) { //sourceid is actually the license id or trial 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($"

Error - Unable to fetch Discovery document from QuickBooks Online

Cannot proceed"); } var AuthorizationEndpoint = DiscoveryDoc["authorization_endpoint"].Value(); if (string.IsNullOrWhiteSpace(AuthorizationEndpoint)) { return ContentPage($"

Error - Unable to find AuthorizationEndpoint value in Discovery document from QuickBooks Online

Cannot proceed"); } //GET AUTHORIZATION CODE AND REDIRECT string url = string.Empty; var queryParams = new Dictionary() { {"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 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($"

Error - Unable to fetch Discovery document from QuickBooks Online

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(); if (string.IsNullOrWhiteSpace(TokenEndpoint)) { return ContentPage($"

Error - Unable to find TokenEndpoint value in Discovery document from QuickBooks Online

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() { {"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(); var access_token = AccessTokenObject["access_token"].Value(); var x_refresh_token_expires_in = AccessTokenObject["x_refresh_token_expires_in"].Value(); var access_token_expires_in = AccessTokenObject["expires_in"].Value(); //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($"

Error - Failed to authenticate with QuickBooks Online

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("

Success!

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 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($"

Error - Unable to fetch Discovery document from QuickBooks Online

Cannot proceed with Revoke"); } var revocation_endpoint = DiscoveryDoc["revocation_endpoint"].Value(); if (string.IsNullOrWhiteSpace(revocation_endpoint)) { return ContentPage($"

Error - Unable to find revocation_endpoint value in Discovery document from QuickBooks Online

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() // { // {"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!"); } } /// /// 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 /// /// /// private ContentResult ContentPage(string bodyHtml) { return new ContentResult { ContentType = "text/html", StatusCode = (int)Microsoft.AspNetCore.Http.StatusCodes.Status200OK, Content = $"{bodyHtml}" }; } // ************************************************************************************************************* } }