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/AuthTokenRetriever/Program.cs b/src/AuthTokenRetriever/Program.cs index 0c66fe32..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,19 +52,30 @@ 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 + using (var waitHandle = new AutoResetEvent(false)) + { + Action tokenCallback = (OAuthToken token) => + { + Console.Clear(); + Console.WriteLine($"Access token: {token.AccessToken}"); + Console.WriteLine($"Refresh token: {token.RefreshToken}"); + waitHandle.Set(); + }; - // 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()); + 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(); // Hit any key to exit. --Kris + Thread.Sleep(100); // Wait a bit for the server to respond with the HTML before it is disposed ¯\_(ツ)_/¯ + } + } - authTokenRetrieverLib.StopListening(); + Console.WriteLine("Press any key to exit..."); + 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..7ef6c215 100644 --- a/src/AuthTokenRetrieverLib/AuthTokenRetrieverLib.cs +++ b/src/AuthTokenRetrieverLib/AuthTokenRetrieverLib.cs @@ -1,18 +1,166 @@ -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.Sockets; +using System.Net.Http; +using System.Net.Http.Headers; using System.Text; using System.Threading.Tasks; -using uhttpsharp; -using uhttpsharp.Listeners; -using uhttpsharp.RequestProviders; 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(); + } + } + + [Obsolete("This class has been deprecated in favour of " + nameof(AuthTokenRetrieverServer) + ".")] public class AuthTokenRetrieverLib { /// @@ -21,7 +169,6 @@ public class AuthTokenRetrieverLib internal string AppId { get; - private set; } /// @@ -30,7 +177,6 @@ internal string AppId internal string AppSecret { get; - private set; } /// @@ -39,10 +185,9 @@ internal string AppSecret internal int Port { get; - private set; } - internal HttpServer HttpServer + internal AuthTokenRetrieverServer AuthServer { get; private set; @@ -50,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; + } } /// @@ -76,94 +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 dde35dd6..984fc2c8 100644 --- a/src/AuthTokenRetrieverLib/AuthTokenRetrieverLib.csproj +++ b/src/AuthTokenRetrieverLib/AuthTokenRetrieverLib.csproj @@ -26,17 +26,9 @@ - - - - - - - - + - 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. - -