From bea51594ed9f92c376d294118591875c1c10f30c Mon Sep 17 00:00:00 2001 From: Daniel Clowry Date: Wed, 15 Jul 2020 12:16:29 +1000 Subject: [PATCH 1/3] Add new auth token retriever server This commit adds a new class, AuthTokenRetrieverServer, that intends to replace the existing AuthTokenRetrieverLib class. The new class works similarly to the existing one. However, there are some key differences. - The new class implements IDisposable and should be wrapped in a using block, or otherwise disposed of, once it is no longer needed to release unmanaged resources. - The new class accepts an Action delegate in its constructor. The delegate is invoked once the user authorised the application and the server retrieves the user's access and refresh tokens. This allows the caller to programmatically obtain the user's new tokens. - The new class uses the EmbedIO web server instead of the previous uhttpsharp. This was done due to EmbedIO's greater ease of use, community support, and to hide that pesky error message that appears when using uhttpsharp ;) --- src/AuthTokenRetriever/Program.cs | 26 +-- .../AuthTokenRetrieverLib.cs | 155 +++++++++++++++++- .../AuthTokenRetrieverLib.csproj | 1 + 3 files changed, 169 insertions(+), 13 deletions(-) diff --git a/src/AuthTokenRetriever/Program.cs b/src/AuthTokenRetriever/Program.cs index 0c66fe32..4449cfd5 100644 --- a/src/AuthTokenRetriever/Program.cs +++ b/src/AuthTokenRetriever/Program.cs @@ -51,19 +51,23 @@ static void Main(string[] args) Console.ReadKey(); } - // Create a new instance of the auth token retrieval library. --Kris - AuthTokenRetrieverLib authTokenRetrieverLib = new AuthTokenRetrieverLib(appId, appSecret, port); - - // Start the callback listener. --Kris - authTokenRetrieverLib.AwaitCallback(); - Console.Clear(); // Gets rid of that annoying logging exception message generated by the uHttpSharp library. --Kris - - // Open the browser to the Reddit authentication page. Once the user clicks "accept", Reddit will redirect the browser to localhost:8080, where AwaitCallback will take over. --Kris - OpenBrowser(authTokenRetrieverLib.AuthURL()); + Action tokenCallback = (OAuthToken token) => + { + Console.Clear(); + Console.WriteLine($"Access token: {token.AccessToken}"); + Console.WriteLine($"Refresh token: {token.RefreshToken}"); + Console.WriteLine("Press any key to exit..."); + }; - Console.ReadKey(); // Hit any key to exit. --Kris + using (var tokenRetriever = new AuthTokenRetrieverServer(appId, appSecret, port, completedAuthCallback: tokenCallback)) + { + // Open the browser to the Reddit authentication page. Once the user clicks "accept", Reddit will redirect the browser to localhost:8080, where the tokenCallback delegate will be called. + OpenBrowser(tokenRetriever.AuthorisationUrl); + Console.WriteLine("Please open the following URL in your browser if it doesn't automatically open:"); + Console.WriteLine(tokenRetriever.AuthorisationUrl); - authTokenRetrieverLib.StopListening(); + Console.ReadKey(true); // Hit any key to exit. --Kris + } Console.WriteLine("Token retrieval utility terminated."); } diff --git a/src/AuthTokenRetrieverLib/AuthTokenRetrieverLib.cs b/src/AuthTokenRetrieverLib/AuthTokenRetrieverLib.cs index 454d891a..c7e727c1 100644 --- a/src/AuthTokenRetrieverLib/AuthTokenRetrieverLib.cs +++ b/src/AuthTokenRetrieverLib/AuthTokenRetrieverLib.cs @@ -1,9 +1,15 @@ -using Newtonsoft.Json; +using EmbedIO; +using EmbedIO.Actions; +using Newtonsoft.Json; using RestSharp; +using Swan.Logging; using System; using System.Collections.Generic; +using System.Collections.Specialized; using System.IO; using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; using System.Net.Sockets; using System.Text; using System.Threading.Tasks; @@ -13,6 +19,152 @@ namespace Reddit.AuthTokenRetriever { + public class AuthTokenRetrieverServer : IDisposable + { + private readonly string _appId; + private readonly string _appSecret; + private readonly int _port; + private string _baseUrl + { + get + { + return $"http://localhost:{_port}/"; + } + } + private string _redirectUrl + { + get + { + return $"{_baseUrl}Reddit.NET/oauthRedirect"; + } + } + private Action _completedAuthCallback; + private readonly string _state = Guid.NewGuid().ToString("N"); + + /// + /// A space or comma separated list of scopes the credentials can access + /// + public string Scope { get; set; } + + /// + /// The URL the user should go to to authorise the application + /// + public string AuthorisationUrl + { + get + { + return $"https://www.reddit.com/api/v1/authorize?client_id={_appId}&response_type=code&" + + $"state={_state}&redirect_uri={_redirectUrl}&duration=permanent&scope={Scope}"; + } + } + + private WebServer _webServer; + private MemoryStream _memoryStream; + private TextWriter _textWriter; + private static readonly HttpClient _httpClient = new HttpClient(); + + public OAuthToken Credentials { get; private set; } + public string CredentialsJsonPath { get; private set; } + + /// + /// Create a new instance of the Reddit.NET OAuth Token Retriever library. + /// + /// Your Reddit App ID + /// Your Reddit App Secret (leave empty for installed apps) + /// The port to listen on for the callback (default: 8080) + /// A space or comma separated list of scopes the credentials can access (default: all scopes) + /// The method to be called when the user successfully authenticates and obtains their tokens + public AuthTokenRetrieverServer(string appId = null, string appSecret = null, + int port = 8080, string scope = "creddits%20modcontributors%20modmail%20modconfig%20subscribe%20structuredstyles%20vote%20wikiedit%20mysubreddits%20submit%20modlog%20modposts%20modflair%20save%20modothers%20read%20privatemessages%20report%20identity%20livemanage%20account%20modtraffic%20wikiread%20edit%20modwiki%20modself%20history%20flair", + Action completedAuthCallback = null) + { + _appId = appId; + _appSecret = appSecret; + _port = port; + Scope = scope; + _completedAuthCallback = completedAuthCallback; + Logger.NoLogging(); + _webServer = CreateWebServer(); + _webServer.RunAsync(); + } + + private WebServer CreateWebServer() + { + _memoryStream = new MemoryStream(); + _textWriter = new StreamWriter(_memoryStream); + Action htmlWriter = delegate (TextWriter textWriter) + { + textWriter.WriteLine("

Token retrieval completed successfully!

"); + textWriter.WriteLine($"

Access token: {Credentials.AccessToken}

"); + textWriter.WriteLine($"

Refresh token: {Credentials.RefreshToken}

"); + textWriter.WriteLine($"

Tokens saved to: {CredentialsJsonPath}

"); + }; + return new WebServer(o => o + .WithUrlPrefix(_baseUrl) + .WithMode(HttpListenerMode.EmbedIO)) + .WithLocalSessionManager() + .WithModule(new ActionModule("/Reddit.NET/oauthRedirect", HttpVerbs.Any, ctx => + { + Credentials = RetrieveToken(ctx.GetRequestQueryData()).Result; + CredentialsJsonPath = WriteCredentialsToJson(Credentials); + _completedAuthCallback?.Invoke(Credentials); + return ctx.SendStandardHtmlAsync(200, htmlWriter); + })); + } + + private async Task RetrieveToken(NameValueCollection queryData) + { + if (!string.IsNullOrWhiteSpace(queryData["error"])) + { + throw new Exception($"Reddit returned error regarding authorisation. Error value: {queryData["error"]}"); + } + + if (queryData["state"] != _state) + { + throw new Exception($"State returned by Reddit does not match state sent."); + } + + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_appId}:{_appSecret}"))); + string code = queryData["code"]; + var tokenRequestData = new Dictionary() + { + { "grant_type", "authorization_code" }, + { "code", code }, + { "redirect_uri", _redirectUrl } + }; + HttpResponseMessage tokenResponse = await _httpClient.PostAsync("https://www.reddit.com/api/v1/access_token", new FormUrlEncodedContent(tokenRequestData)); + if (!tokenResponse.IsSuccessStatusCode) + { + throw new Exception("Reddit returned non-success status code when getting access token."); + } + string tokenResponseContent = await tokenResponse.Content.ReadAsStringAsync(); + if (tokenResponseContent.Contains("error")) + { + throw new Exception($"Reddit returned error when getting access token. JSON response: {tokenResponseContent}"); + } + var credentials = JsonConvert.DeserializeObject(tokenResponseContent); + return credentials; + } + + private string WriteCredentialsToJson(OAuthToken oAuthToken) + { + string fileExt = "." + _appId + "." + (!string.IsNullOrWhiteSpace(_appSecret) ? _appSecret + "." : "") + "json"; + + string tokenPath = Directory.GetCurrentDirectory() + Path.DirectorySeparatorChar + + "RDNOauthToken_" + DateTime.Now.ToString("yyyyMMddHHmmssffff") + fileExt; + + File.WriteAllText(tokenPath, JsonConvert.SerializeObject(oAuthToken)); + return tokenPath; + } + + public void Dispose() + { + _webServer.Dispose(); + _textWriter.Dispose(); + _memoryStream.Dispose(); + } + } + public class AuthTokenRetrieverLib { /// @@ -79,7 +231,6 @@ public void AwaitCallback() using (HttpServer = new HttpServer(new HttpRequestProvider())) { HttpServer.Use(new TcpListenerAdapter(new TcpListener(IPAddress.Loopback, Port))); - HttpServer.Use((context, next) => { string code = null; diff --git a/src/AuthTokenRetrieverLib/AuthTokenRetrieverLib.csproj b/src/AuthTokenRetrieverLib/AuthTokenRetrieverLib.csproj index dde35dd6..86b30a73 100644 --- a/src/AuthTokenRetrieverLib/AuthTokenRetrieverLib.csproj +++ b/src/AuthTokenRetrieverLib/AuthTokenRetrieverLib.csproj @@ -34,6 +34,7 @@ + From 5c0764714cea5667d9663e826d452429a2e80169 Mon Sep 17 00:00:00 2001 From: Daniel Clowry Date: Wed, 15 Jul 2020 16:37:45 +1000 Subject: [PATCH 2/3] Make class AuthTokenRetrieverLib obsolete BREAKING CHANGES: -The internal property HttpServer has been removed from class AuthTokenRetrieverLib. The impact of this change should be extremely small as it only effects consumers in the same assembly as the class. - The HTML of the token success page has been changed. This may effect programs that are parsing the tokens HTML page. Changes the AuthTokenRetrieverLib class to use the new AuthTokenRetrieverServer under the hood while still keeping the existing external interface. Removes uhttpsharp from AuthTokenRetriever and AuthTokenRetrieverLib as they are no longer needed. --- .../AuthTokenRetriever.csproj | 1 - .../AuthTokenRetrieverLib.cs | 106 +++--------------- .../AuthTokenRetrieverLib.csproj | 9 -- .../Templates/Success.html | 34 ------ 4 files changed, 14 insertions(+), 136 deletions(-) delete mode 100644 src/AuthTokenRetrieverLib/Templates/Success.html diff --git a/src/AuthTokenRetriever/AuthTokenRetriever.csproj b/src/AuthTokenRetriever/AuthTokenRetriever.csproj index 8cdcbf15..7bf43337 100644 --- a/src/AuthTokenRetriever/AuthTokenRetriever.csproj +++ b/src/AuthTokenRetriever/AuthTokenRetriever.csproj @@ -26,7 +26,6 @@ - diff --git a/src/AuthTokenRetrieverLib/AuthTokenRetrieverLib.cs b/src/AuthTokenRetrieverLib/AuthTokenRetrieverLib.cs index c7e727c1..7ef6c215 100644 --- a/src/AuthTokenRetrieverLib/AuthTokenRetrieverLib.cs +++ b/src/AuthTokenRetrieverLib/AuthTokenRetrieverLib.cs @@ -7,15 +7,10 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.IO; -using System.Net; using System.Net.Http; using System.Net.Http.Headers; -using System.Net.Sockets; using System.Text; using System.Threading.Tasks; -using uhttpsharp; -using uhttpsharp.Listeners; -using uhttpsharp.RequestProviders; namespace Reddit.AuthTokenRetriever { @@ -165,6 +160,7 @@ public void Dispose() } } + [Obsolete("This class has been deprecated in favour of " + nameof(AuthTokenRetrieverServer) + ".")] public class AuthTokenRetrieverLib { /// @@ -173,7 +169,6 @@ public class AuthTokenRetrieverLib internal string AppId { get; - private set; } /// @@ -182,7 +177,6 @@ internal string AppId internal string AppSecret { get; - private set; } /// @@ -191,10 +185,9 @@ internal string AppSecret internal int Port { get; - private set; } - internal HttpServer HttpServer + internal AuthTokenRetrieverServer AuthServer { get; private set; @@ -202,14 +195,18 @@ internal HttpServer HttpServer public string AccessToken { - get; - private set; + get + { + return AuthServer.Credentials.AccessToken; + } } public string RefreshToken { - get; - private set; + get + { + return AuthServer.Credentials.RefreshToken; + } } /// @@ -228,93 +225,18 @@ public AuthTokenRetrieverLib(string appId = null, string appSecret = null, int p public void AwaitCallback() { - using (HttpServer = new HttpServer(new HttpRequestProvider())) - { - HttpServer.Use(new TcpListenerAdapter(new TcpListener(IPAddress.Loopback, Port))); - HttpServer.Use((context, next) => - { - string code = null; - string state = null; - try - { - code = context.Request.QueryString.GetByName("code"); - state = context.Request.QueryString.GetByName("state"); // This app formats state as: AppId + ":" [+ AppSecret] - } - catch (KeyNotFoundException) - { - context.Response = new uhttpsharp.HttpResponse(HttpResponseCode.Ok, Encoding.UTF8.GetBytes("ERROR: No code and/or state received!"), false); - throw new Exception("ERROR: Request received without code and/or state!"); - } - - if (!string.IsNullOrWhiteSpace(code) - && !string.IsNullOrWhiteSpace(state)) - { - // Send request with code and JSON-decode the return for token retrieval. --Kris - RestRequest restRequest = new RestRequest("/api/v1/access_token", Method.POST); - - restRequest.AddHeader("Authorization", "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes(state))); - restRequest.AddHeader("Content-Type", "application/x-www-form-urlencoded"); - - restRequest.AddParameter("grant_type", "authorization_code"); - restRequest.AddParameter("code", code); - restRequest.AddParameter("redirect_uri", - "http://localhost:" + Port.ToString() + "/Reddit.NET/oauthRedirect"); // This must be an EXACT match in the app settings on Reddit! --Kris - - OAuthToken oAuthToken = JsonConvert.DeserializeObject(ExecuteRequest(restRequest)); - - AccessToken = oAuthToken.AccessToken; - RefreshToken = oAuthToken.RefreshToken; - - string[] sArr = state.Split(':'); - if (sArr == null || sArr.Length == 0) - { - throw new Exception("State must consist of 'appId:appSecret'!"); - } - - string appId = sArr[0]; - string appSecret = (sArr.Length >= 2 ? sArr[1] : null); - - string fileExt = "." + appId + "." + (!string.IsNullOrWhiteSpace(appSecret) ? appSecret + "." : "") + "json"; - - string tokenPath = Directory.GetCurrentDirectory() + Path.DirectorySeparatorChar - + "RDNOauthToken_" + DateTime.Now.ToString("yyyyMMddHHmmssffff") + fileExt; - - File.WriteAllText(tokenPath, JsonConvert.SerializeObject(oAuthToken)); - - string html; - using (Stream stream = System.Reflection.Assembly.GetExecutingAssembly().GetManifestResourceStream("AuthTokenRetrieverLib.Templates.Success.html")) - { - using (StreamReader streamReader = new StreamReader(stream)) - { - html = streamReader.ReadToEnd(); - } - } - - html = html.Replace("REDDIT_OAUTH_ACCESS_TOKEN", oAuthToken.AccessToken); - html = html.Replace("REDDIT_OAUTH_REFRESH_TOKEN", oAuthToken.RefreshToken); - html = html.Replace("LOCAL_TOKEN_PATH", tokenPath); - - context.Response = new uhttpsharp.HttpResponse(HttpResponseCode.Ok, Encoding.UTF8.GetBytes(html), false); - } - - return Task.Factory.GetCompleted(); - }); - - HttpServer.Start(); - } + AuthServer = new AuthTokenRetrieverServer(AppId, AppSecret, Port); } public void StopListening() { - HttpServer.Dispose(); + AuthServer.Dispose(); } public string AuthURL(string scope = "creddits%20modcontributors%20modmail%20modconfig%20subscribe%20structuredstyles%20vote%20wikiedit%20mysubreddits%20submit%20modlog%20modposts%20modflair%20save%20modothers%20read%20privatemessages%20report%20identity%20livemanage%20account%20modtraffic%20wikiread%20edit%20modwiki%20modself%20history%20flair") { - return "https://www.reddit.com/api/v1/authorize?client_id=" + AppId + "&response_type=code" - + "&state=" + AppId + ":" + AppSecret - + "&redirect_uri=http://localhost:" + Port.ToString() + "/Reddit.NET/oauthRedirect&duration=permanent" - + "&scope=" + scope; + AuthServer.Scope = scope; + return AuthServer.AuthorisationUrl; } public string ExecuteRequest(RestRequest restRequest) diff --git a/src/AuthTokenRetrieverLib/AuthTokenRetrieverLib.csproj b/src/AuthTokenRetrieverLib/AuthTokenRetrieverLib.csproj index 86b30a73..984fc2c8 100644 --- a/src/AuthTokenRetrieverLib/AuthTokenRetrieverLib.csproj +++ b/src/AuthTokenRetrieverLib/AuthTokenRetrieverLib.csproj @@ -25,19 +25,10 @@ - - - - - - - - - diff --git a/src/AuthTokenRetrieverLib/Templates/Success.html b/src/AuthTokenRetrieverLib/Templates/Success.html deleted file mode 100644 index 5c88be14..00000000 --- a/src/AuthTokenRetrieverLib/Templates/Success.html +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - Token Retrieval Successful! - - -

Token retrieval completed successfully!

- - - - - - - - - - - - -
Access Token: REDDIT_OAUTH_ACCESS_TOKEN
Refresh Token: REDDIT_OAUTH_REFRESH_TOKEN
- -
-
- - Token Saved to: LOCAL_TOKEN_PATH - -
-
- - You may now close this window whenever you're ready. - - From 542f3f117a6bfc3f5bd9c6057814b816f0956cc3 Mon Sep 17 00:00:00 2001 From: Daniel Clowry Date: Wed, 15 Jul 2020 18:50:19 +1000 Subject: [PATCH 3/3] Block AuthTokenRetriever until tokens are retrieved --- src/AuthTokenRetriever/Program.cs | 34 +++++++++++++++++++------------ 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/src/AuthTokenRetriever/Program.cs b/src/AuthTokenRetriever/Program.cs index 4449cfd5..54a997be 100644 --- a/src/AuthTokenRetriever/Program.cs +++ b/src/AuthTokenRetriever/Program.cs @@ -2,6 +2,7 @@ using System; using System.Diagnostics; using System.Runtime.InteropServices; +using System.Threading; namespace AuthTokenRetriever { @@ -51,24 +52,31 @@ static void Main(string[] args) Console.ReadKey(); } - Action tokenCallback = (OAuthToken token) => + using (var waitHandle = new AutoResetEvent(false)) { - Console.Clear(); - Console.WriteLine($"Access token: {token.AccessToken}"); - Console.WriteLine($"Refresh token: {token.RefreshToken}"); - Console.WriteLine("Press any key to exit..."); - }; + Action tokenCallback = (OAuthToken token) => + { + Console.Clear(); + Console.WriteLine($"Access token: {token.AccessToken}"); + Console.WriteLine($"Refresh token: {token.RefreshToken}"); + waitHandle.Set(); + }; - using (var tokenRetriever = new AuthTokenRetrieverServer(appId, appSecret, port, completedAuthCallback: tokenCallback)) - { - // Open the browser to the Reddit authentication page. Once the user clicks "accept", Reddit will redirect the browser to localhost:8080, where the tokenCallback delegate will be called. - OpenBrowser(tokenRetriever.AuthorisationUrl); - Console.WriteLine("Please open the following URL in your browser if it doesn't automatically open:"); - Console.WriteLine(tokenRetriever.AuthorisationUrl); + using (var tokenRetriever = new AuthTokenRetrieverServer(appId, appSecret, port, completedAuthCallback: tokenCallback)) + { + // Open the browser to the Reddit authentication page. Once the user clicks "accept", Reddit will redirect the browser to localhost:8080, where the tokenCallback delegate will be called. + OpenBrowser(tokenRetriever.AuthorisationUrl); + Console.WriteLine("Please open the following URL in your browser if it doesn't automatically open:"); + Console.WriteLine(tokenRetriever.AuthorisationUrl); + waitHandle.WaitOne(); - Console.ReadKey(true); // Hit any key to exit. --Kris + Thread.Sleep(100); // Wait a bit for the server to respond with the HTML before it is disposed ¯\_(ツ)_/¯ + } } + Console.WriteLine("Press any key to exit..."); + Console.ReadKey(true); // Hit any key to exit. --Kris + Console.WriteLine("Token retrieval utility terminated."); }