Editor/Hosting/HostSignIn.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
namespace HumanoidMocapX.EditorTools;
/// <summary>Signs the editor in to a provider's site without keys: the browser goes straight to
/// the provider, and the result returns to a one-shot listener on 127.0.0.1 (RFC 8252) as a
/// single-use code that only this editor's PKCE verifier can redeem.</summary>
internal sealed class HostSignIn : IDisposable
{
static readonly TimeSpan SignInTimeout = TimeSpan.FromMinutes(5), RequestTimeout = TimeSpan.FromSeconds(10);
const int MaximumRequestBytes = 8192;
readonly HttpClient http;
readonly JsonSerializerOptions json = new(JsonSerializerDefaults.Web);
internal HostSignIn(HttpMessageHandler handler = null)
{
http = new(handler ?? new HttpClientHandler { AllowAutoRedirect = false }) { Timeout = TimeSpan.FromSeconds(30) };
}
/// <summary>The sign-in options the provider's site offers. Throws when it is not a Humanoid
/// Mocap X provider or cannot be reached.</summary>
internal async Task<IReadOnlyList<HostProvider>> ProvidersAsync(Uri endpoint, CancellationToken cancellation)
{
using var response = await http.GetAsync(new Uri(endpoint, "auth/providers"), cancellation);
if (!response.IsSuccessStatusCode) throw new HttpRequestException($"The address answered HTTP {(int)response.StatusCode}; it does not look like a Humanoid Mocap X provider.");
var body = await Read<ProviderList>(response, cancellation);
if (body.Providers is not { Count: > 0 }) throw new InvalidDataException("This provider has not enabled any sign-in method yet.");
return body.Providers;
}
/// <summary>Opens the browser at the chosen provider and waits up to five minutes for the
/// sign-in to return, then applies the session to <paramref name="settings"/>.</summary>
internal async Task SignInAsync(HostingSettings settings, string provider, Action<string> openBrowser, CancellationToken cancellation)
{
var endpoint = settings.Endpoint();
var verifier = Base64Url(RandomNumberGenerator.GetBytes(48));
var challenge = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier)));
var state = Base64Url(RandomNumberGenerator.GetBytes(24));
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
var query = $"provider={Uri.EscapeDataString(provider)}&port={port}&state={state}&code_challenge={challenge}&code_challenge_method=S256";
openBrowser(new Uri(endpoint, "auth/editor?" + query).AbsoluteUri);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
timeout.CancelAfter(SignInTimeout);
var code = await Task.Run(() => ReceiveCode(listener, state, timeout.Token), timeout.Token);
using var exchange = new HttpRequestMessage(HttpMethod.Post, new Uri(endpoint, "auth/editor/token"))
{
Content = new StringContent(JsonSerializer.Serialize(new { code, verifier }), Encoding.UTF8, "application/json")
};
using var response = await http.SendAsync(exchange, cancellation);
if (!response.IsSuccessStatusCode) throw new HttpRequestException("The provider did not accept the sign-in. Try again.");
var result = await Read<TokenResponse>(response, cancellation);
if (result.Token is not { Length: 43 } token || result.User?.Id is null) throw new InvalidDataException("The provider returned an invalid sign-in.");
settings.SignIn(token, result.ExpiresAt, result.Profile());
}
/// <summary>Re-reads the profile (whitelist changes, expiry). Signs out on 401.</summary>
internal async Task RefreshAsync(HostingSettings settings, CancellationToken cancellation)
{
if (!settings.SignedIn) { settings.SignOut(); return; }
var token = settings.Token;
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(settings.Endpoint(), "auth/me"));
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
using var response = await http.SendAsync(request, cancellation);
var me = response.IsSuccessStatusCode ? await Read<TokenResponse>(response, cancellation) : null;
if (me is { SignedIn: true, User: not null }) settings.Update(token, me.Profile());
else if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.OK) settings.Expire(token);
else throw new HttpRequestException($"The provider returned HTTP {(int)response.StatusCode} while checking your sign-in.");
}
/// <summary>Revokes the editor token on the site (best effort) and forgets it locally.</summary>
internal async Task SignOutAsync(HostingSettings settings)
{
try
{
if (settings.SignedIn)
{
using var request = new HttpRequestMessage(HttpMethod.Post, new Uri(settings.Endpoint(), "auth/logout"));
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", settings.Token);
using var cancel = new CancellationTokenSource(RequestTimeout);
using var response = await http.SendAsync(request, cancel.Token);
}
}
// Signing out locally must not depend on the provider being reachable.
catch (Exception error) when (error is HttpRequestException or OperationCanceledException or ArgumentException) { }
settings.SignOut();
}
// Serves the single browser redirect; other requests (favicons, probes) get 404 and are ignored.
static async Task<string> ReceiveCode(TcpListener listener, string state, CancellationToken cancellation)
{
while (true)
{
using var client = await listener.AcceptTcpClientAsync(cancellation);
using var stream = client.GetStream();
using var perRequest = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
perRequest.CancelAfter(RequestTimeout);
string target;
try { target = await RequestTarget(stream, perRequest.Token); }
catch (Exception error) when (error is IOException or InvalidDataException || error is OperationCanceledException && !cancellation.IsCancellationRequested) { continue; }
if (target is null || !target.StartsWith("/callback?", StringComparison.Ordinal))
{ await Respond(stream, 404, "Not found."); continue; }
var query = HttpUtility.ParseQueryString(target["/callback".Length..]);
var returned = query["state"] ?? "";
if (!CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(returned), Encoding.ASCII.GetBytes(state)) || query["code"] is not { Length: 43 } code)
{ await Respond(stream, 400, "This sign-in link does not belong to this editor. Start signing in again from Humanoid Mocap X."); continue; }
await Respond(stream, 200, "You are signed in to Humanoid Mocap X. You can close this tab and return to s&box.");
return code;
}
}
// Reads the request line and headers (bounded), returning the GET target or null.
static async Task<string> RequestTarget(NetworkStream stream, CancellationToken cancellation)
{
var buffer = new byte[MaximumRequestBytes]; var length = 0;
while (length < buffer.Length)
{
var read = await stream.ReadAsync(buffer.AsMemory(length), cancellation);
if (read == 0) break;
length += read;
if (Encoding.ASCII.GetString(buffer, 0, length).Contains("\r\n\r\n", StringComparison.Ordinal)) break;
}
var line = Encoding.ASCII.GetString(buffer, 0, length).Split("\r\n", 2)[0].Split(' ');
if (line.Length != 3 || line[0] != "GET" || !line[2].StartsWith("HTTP/1.", StringComparison.Ordinal)) throw new InvalidDataException("Not an HTTP GET request.");
return line[1];
}
static async Task Respond(NetworkStream stream, int status, string message)
{
var html = "<!doctype html><meta charset=utf-8><title>Humanoid Mocap X</title>"
+ "<body style=\"font:16px system-ui,sans-serif;background:#1b1f1c;color:#e4ebe6;display:grid;place-items:center;height:100vh;margin:0\">"
+ "<p>" + WebUtility.HtmlEncode(message) + "</p>";
var body = Encoding.UTF8.GetBytes(html);
var head = $"HTTP/1.1 {status} {(status == 200 ? "OK" : status == 404 ? "Not Found" : "Bad Request")}\r\nContent-Type: text/html; charset=utf-8\r\n"
+ $"Content-Length: {body.Length}\r\nCache-Control: no-store\r\nReferrer-Policy: no-referrer\r\nConnection: close\r\n\r\n";
await stream.WriteAsync(Encoding.ASCII.GetBytes(head));
await stream.WriteAsync(body);
}
async Task<T> Read<T>(HttpResponseMessage response, CancellationToken cancellation)
{
await response.Content.LoadIntoBufferAsync(65536, cancellation);
return JsonSerializer.Deserialize<T>(await response.Content.ReadAsStringAsync(cancellation), json)
?? throw new InvalidDataException("The provider returned an empty response.");
}
static string Base64Url(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
public void Dispose() => http.Dispose();
sealed record ProviderList(List<HostProvider> Providers);
sealed record TokenResponse(string Token, DateTimeOffset ExpiresAt, bool SignedIn, HostUser User, bool? InGuild, bool GuildOwner,
bool Whitelisted, bool CanProcess, bool Admin, HostLimits Limits)
{
internal HostProfile Profile() => new(User, InGuild, GuildOwner, Whitelisted, CanProcess, Admin, Limits);
}
}