Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rewrite AuthTokenRetrieverLib #109

Open
wants to merge 3 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion src/AuthTokenRetriever/AuthTokenRetriever.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
<ItemGroup>
<PackageReference Include="NewtonSoft.JSON" Version="12.0.1" />
<PackageReference Include="RestSharp" Version="106.6.9" />
<PackageReference Include="uHttpSharp" Version="0.1.6.22" />
</ItemGroup>

<ItemGroup>
Expand Down
32 changes: 22 additions & 10 deletions src/AuthTokenRetriever/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading;

namespace AuthTokenRetriever
{
Expand Down Expand Up @@ -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<OAuthToken> 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.");
}
Expand Down
261 changes: 167 additions & 94 deletions src/AuthTokenRetrieverLib/AuthTokenRetrieverLib.cs
Original file line number Diff line number Diff line change
@@ -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<OAuthToken> _completedAuthCallback;
private readonly string _state = Guid.NewGuid().ToString("N");

/// <summary>
/// A space or comma separated list of scopes the credentials can access
/// </summary>
public string Scope { get; set; }

/// <summary>
/// The URL the user should go to to authorise the application
/// </summary>
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; }

/// <summary>
/// Create a new instance of the Reddit.NET OAuth Token Retriever library.
/// </summary>
/// <param name="appId">Your Reddit App ID</param>
/// <param name="appSecret">Your Reddit App Secret (leave empty for installed apps)</param>
/// <param name="port">The port to listen on for the callback (default: 8080)</param>
/// <param name="scope">A space or comma separated list of scopes the credentials can access (default: all scopes)</param>
/// <param name="completedAuthCallback">The method to be called when the user successfully authenticates and obtains their tokens</param>
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<OAuthToken> 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<TextWriter> htmlWriter = delegate (TextWriter textWriter)
{
textWriter.WriteLine("<h1>Token retrieval completed successfully!</h1>");
textWriter.WriteLine($"<p><b>Access token:</b> {Credentials.AccessToken}</p>");
textWriter.WriteLine($"<p><b>Refresh token:</b> {Credentials.RefreshToken}</p>");
textWriter.WriteLine($"<p><b>Tokens saved to:</b> <a href=\"file://{CredentialsJsonPath}\">{CredentialsJsonPath}</a></p>");
};
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<OAuthToken> 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<string, string>()
{
{ "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<OAuthToken>(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
{
/// <summary>
Expand All @@ -21,7 +169,6 @@ public class AuthTokenRetrieverLib
internal string AppId
{
get;
private set;
}

/// <summary>
Expand All @@ -30,7 +177,6 @@ internal string AppId
internal string AppSecret
{
get;
private set;
}

/// <summary>
Expand All @@ -39,25 +185,28 @@ internal string AppSecret
internal int Port
{
get;
private set;
}

internal HttpServer HttpServer
internal AuthTokenRetrieverServer AuthServer
{
get;
private set;
}

public string AccessToken
{
get;
private set;
get
{
return AuthServer.Credentials.AccessToken;
}
}

public string RefreshToken
{
get;
private set;
get
{
return AuthServer.Credentials.RefreshToken;
}
}

/// <summary>
Expand All @@ -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("<b>ERROR: No code and/or state received!</b>"), 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<OAuthToken>(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)
Expand Down
10 changes: 1 addition & 9 deletions src/AuthTokenRetrieverLib/AuthTokenRetrieverLib.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,9 @@
</PropertyGroup>

<ItemGroup>
<None Remove="Templates\Success.html" />
</ItemGroup>

<ItemGroup>
<EmbeddedResource Include="Templates\Success.html" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="EmbedIO" Version="3.4.3" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.1" />
<PackageReference Include="RestSharp" Version="106.6.9" />
<PackageReference Include="uhttpsharp" Version="0.1.6.22" />
</ItemGroup>

</Project>
Loading