| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | using Google.Apis.Auth.OAuth2.Requests; |
| | using Google.Apis.Auth.OAuth2.Responses; |
| | using Google.Apis.Logging; |
| | using Google.Apis.Util; |
| | using System; |
| | using System.Collections.Generic; |
| | using System.Collections.Specialized; |
| | using System.Diagnostics; |
| | using System.IO; |
| | using System.Linq; |
| | using System.Net; |
| | using System.Net.Sockets; |
| | using System.Runtime.InteropServices; |
| | using System.Text; |
| | using System.Threading; |
| | using System.Threading.Tasks; |
| |
|
| | namespace Google.Apis.Auth.OAuth2 |
| | { |
| | |
| | |
| | |
| | |
| | public class LocalServerCodeReceiver : ICodeReceiver |
| | { |
| | |
| | |
| | |
| | |
| | public enum CallbackUriChooserStrategy |
| | { |
| | |
| | |
| | |
| | |
| | Default, |
| |
|
| | |
| | |
| | |
| | ForceLoopbackIp, |
| |
|
| | |
| | |
| | |
| | ForceLocalhost |
| | } |
| |
|
| | private static readonly ILogger Logger = ApplicationContext.Logger.ForType<LocalServerCodeReceiver>(); |
| |
|
| | |
| | internal const string LoopbackCallbackPath = "/authorize/"; |
| |
|
| | |
| | internal const string DefaultClosePageResponse = |
| | @"<html> |
| | <head><title>OAuth 2.0 Authentication Token Received</title></head> |
| | <body> |
| | Received verification code. You may now close this window. |
| | </body> |
| | </html>"; |
| |
|
| | |
| | |
| | |
| | public LocalServerCodeReceiver() : this(DefaultClosePageResponse, CallbackUriChooserStrategy.Default) { } |
| |
|
| | |
| | |
| | |
| | |
| | public LocalServerCodeReceiver(string closePageResponse) : |
| | this(closePageResponse, CallbackUriChooserStrategy.Default) { } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | public LocalServerCodeReceiver(string closePageResponse, CallbackUriChooserStrategy strategy) |
| | { |
| | _closePageResponse = closePageResponse; |
| | |
| | |
| | |
| | _callbackUriTemplate = CallbackUriChooser.Default.GetUriTemplate(strategy); |
| | } |
| |
|
| | |
| | private string _callbackUriTemplate; |
| |
|
| | |
| | private readonly string _closePageResponse; |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | internal class LimitedLocalhostHttpServer : IDisposable |
| | { |
| | |
| | |
| | private const int MaxRequestLineLength = 16 * 1024; |
| | private const int MaxHeadersLength = 64 * 1024; |
| | private const int NetworkReadBufferSize = 1024; |
| |
|
| | private static ILogger Logger = ApplicationContext.Logger.ForType<LimitedLocalhostHttpServer>(); |
| |
|
| | public class ServerException : Exception |
| | { |
| | public ServerException(string msg) : base(msg) { } |
| | } |
| |
|
| | public static LimitedLocalhostHttpServer Start(string url, string closePageResponse) |
| | { |
| | var uri = new Uri(url); |
| | if (!uri.IsLoopback) |
| | { |
| | throw new ArgumentException($"Url must be loopback, but given: '{url}'", nameof(url)); |
| | } |
| | var listener = new TcpListener(IPAddress.Loopback, uri.Port); |
| | return new LimitedLocalhostHttpServer(listener, closePageResponse); |
| | } |
| |
|
| | private LimitedLocalhostHttpServer(TcpListener listener, string closePageResponse) |
| | { |
| | _listener = listener; |
| | _closePageResponse = closePageResponse; |
| | _cts = new CancellationTokenSource(); |
| | _listener.Start(); |
| | Port = ((IPEndPoint)_listener.LocalEndpoint).Port; |
| | } |
| |
|
| | private readonly TcpListener _listener; |
| | private readonly CancellationTokenSource _cts; |
| |
|
| | |
| | private readonly string _closePageResponse; |
| |
|
| | public int Port { get; } |
| |
|
| | public async Task<Dictionary<string, string>> GetQueryParamsAsync(CancellationToken cancellationToken = default(CancellationToken)) |
| | { |
| | using (var cts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token, cancellationToken)) |
| | using (cts.Token.Register(_listener.Stop)) |
| | { |
| | try |
| | { |
| | using (TcpClient client = await _listener.AcceptTcpClientAsync().ConfigureAwait(false)) |
| | { |
| | try |
| | { |
| | return await GetQueryParamsFromClientAsync(client, cts.Token).ConfigureAwait(false); |
| | } |
| | catch (ServerException e) |
| | { |
| | Logger.Warning("{0}", e.Message); |
| | throw; |
| | } |
| | } |
| | } |
| | |
| | |
| | catch (ObjectDisposedException) when (cts.IsCancellationRequested) |
| | { |
| | cts.Token.ThrowIfCancellationRequested(); |
| | |
| | throw; |
| | } |
| | } |
| | } |
| |
|
| | private async Task<Dictionary<string, string>> GetQueryParamsFromClientAsync(TcpClient client, CancellationToken cancellationToken) |
| | { |
| | var stream = client.GetStream(); |
| | |
| | |
| | using (cancellationToken.Register(() => stream.Dispose())) |
| | { |
| | var buffer = new byte[NetworkReadBufferSize]; |
| | int bufferOfs = 0; |
| | int bufferSize = 0; |
| | Func<Task<char?>> getChar = async () => |
| | { |
| | if (bufferOfs == bufferSize) |
| | { |
| | try |
| | { |
| | bufferSize = await stream.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false); |
| | } |
| | |
| | catch (Exception e) when (e is ObjectDisposedException || e is IOException) |
| | { |
| | throw new OperationCanceledException(cancellationToken); |
| | } |
| | |
| | |
| | cancellationToken.ThrowIfCancellationRequested(); |
| | if (bufferSize == 0) |
| | { |
| | |
| | return null; |
| | } |
| | bufferOfs = 0; |
| | } |
| | byte b = buffer[bufferOfs++]; |
| | |
| | |
| | return (char)b; |
| | }; |
| |
|
| | string requestLine = await ReadRequestLine(getChar).ConfigureAwait(false); |
| | var requestParams = ValidateAndGetRequestParams(requestLine); |
| | await WaitForAllHeaders(getChar).ConfigureAwait(false); |
| | await WriteResponse(stream, cancellationToken).ConfigureAwait(false); |
| |
|
| | return requestParams; |
| | } |
| | } |
| |
|
| | private async Task<string> ReadRequestLine(Func<Task<char?>> getChar) |
| | { |
| | var requestLine = new StringBuilder(MaxRequestLineLength); |
| | do |
| | { |
| | if (requestLine.Length >= MaxRequestLineLength) |
| | { |
| | throw new ServerException($"Request line too long: > {MaxRequestLineLength} bytes."); |
| | } |
| | char? c = await getChar().ConfigureAwait(false); |
| | if (c == null) |
| | { |
| | throw new ServerException("Unexpected end of network stream reading request line."); |
| | } |
| | requestLine.Append(c); |
| | } while (requestLine.Length < 2 || requestLine[requestLine.Length - 2] != '\r' || requestLine[requestLine.Length - 1] != '\n'); |
| | requestLine.Length -= 2; |
| | return requestLine.ToString(); |
| | } |
| |
|
| | private Dictionary<string, string> ValidateAndGetRequestParams(string requestLine) |
| | { |
| | var requestLineParts = requestLine.Split(' '); |
| | if (requestLineParts.Length != 3) |
| | { |
| | throw new ServerException("Request line ill-formatted. Should be '<request-method> <request-path> HTTP/1.1'"); |
| | } |
| | string requestVerb = requestLineParts[0]; |
| | if (requestVerb != "GET") |
| | { |
| | throw new ServerException($"Expected 'GET' request, got '{requestVerb}'"); |
| | } |
| | string requestPath = requestLineParts[1]; |
| | if (!requestPath.StartsWith(LoopbackCallbackPath, StringComparison.Ordinal)) |
| | { |
| | throw new ServerException($"Expected request path to start '{LoopbackCallbackPath}', got '{requestPath}'"); |
| | } |
| | var pathParts = requestPath.Split('?'); |
| | if (pathParts.Length == 1) |
| | { |
| | return new Dictionary<string, string>(); |
| | } |
| | if (pathParts.Length != 2) |
| | { |
| | throw new ServerException($"Expected a single '?' in request path, got '{requestPath}'"); |
| | } |
| | var queryParams = pathParts[1]; |
| | var result = queryParams.Split(new[] { '&' }, StringSplitOptions.RemoveEmptyEntries).Select(param => |
| | { |
| | var keyValue = param.Split('='); |
| | if (keyValue.Length > 2) |
| | { |
| | throw new ServerException($"Invalid query parameter: '{param}'"); |
| | } |
| | var key = WebUtility.UrlDecode(keyValue[0]); |
| | var value = keyValue.Length == 2 ? WebUtility.UrlDecode(keyValue[1]) : ""; |
| | return new { key, value }; |
| | }).ToDictionary(x => x.key, x => x.value); |
| | return result; |
| | } |
| |
|
| | private async Task WaitForAllHeaders(Func<Task<char?>> getChar) |
| | { |
| | |
| | int byteCount = 0; |
| | int lineLength = 0; |
| | char c0 = '\0'; |
| | char c1 = '\0'; |
| | while (true) |
| | { |
| | if (byteCount > MaxHeadersLength) |
| | { |
| | throw new ServerException($"Headers too long: > {MaxHeadersLength} bytes."); |
| | } |
| | char? c = await getChar().ConfigureAwait(false); |
| | if (c == null) |
| | { |
| | throw new ServerException("Unexpected end of network stream waiting for headers."); |
| | } |
| | c0 = c1; |
| | c1 = (char)c; |
| | lineLength += 1; |
| | byteCount += 1; |
| | if (c0 == '\r' && c1 == '\n') |
| | { |
| | |
| | if (lineLength == 2) |
| | { |
| | return; |
| | } |
| | lineLength = 0; |
| | } |
| | } |
| | } |
| |
|
| | private async Task WriteResponse(NetworkStream stream, CancellationToken cancellationToken) |
| | { |
| | string fullResponse = $"HTTP/1.1 200 OK\r\n\r\n{_closePageResponse}"; |
| | var response = Encoding.ASCII.GetBytes(fullResponse); |
| | await stream.WriteAsync(response, 0, response.Length, cancellationToken).ConfigureAwait(false); |
| | await stream.FlushAsync(cancellationToken).ConfigureAwait(false); |
| | } |
| |
|
| | public void Dispose() |
| | { |
| | _cts.Cancel(); |
| | _listener.Stop(); |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| |
|
| | private string redirectUri; |
| | |
| | public string RedirectUri |
| | { |
| | get |
| | { |
| | if (string.IsNullOrEmpty(redirectUri)) |
| | { |
| | redirectUri = string.Format(_callbackUriTemplate, GetRandomUnusedPort()); |
| | } |
| | return redirectUri; |
| | } |
| | } |
| |
|
| | |
| | public async Task<AuthorizationCodeResponseUrl> ReceiveCodeAsync(AuthorizationCodeRequestUrl url, |
| | CancellationToken taskCancellationToken) |
| | { |
| | var authorizationUrl = url.Build().AbsoluteUri; |
| | |
| | |
| | |
| | using var listener = StartListener(); |
| | Logger.Debug("Open a browser with \"{0}\" URL", authorizationUrl); |
| | bool browserOpenedOk; |
| | try |
| | { |
| | browserOpenedOk = OpenBrowser(authorizationUrl); |
| | } |
| | catch (Exception e) |
| | { |
| | Logger.Error(e, "Failed to launch browser with \"{0}\" for authorization", authorizationUrl); |
| | throw new NotSupportedException( |
| | $"Failed to launch browser with \"{authorizationUrl}\" for authorization. See inner exception for details.", e); |
| | } |
| | if (!browserOpenedOk) |
| | { |
| | Logger.Error("Failed to launch browser with \"{0}\" for authorization; platform not supported.", authorizationUrl); |
| | throw new NotSupportedException( |
| | $"Failed to launch browser with \"{authorizationUrl}\" for authorization; platform not supported."); |
| | } |
| |
|
| | var ret = await GetResponseFromListener(listener, taskCancellationToken).ConfigureAwait(false); |
| |
|
| | return ret; |
| | } |
| |
|
| | |
| | private static int GetRandomUnusedPort() |
| | { |
| | var listener = new TcpListener(IPAddress.Loopback, 0); |
| | try |
| | { |
| | listener.Start(); |
| | return ((IPEndPoint)listener.LocalEndpoint).Port; |
| | } |
| | finally |
| | { |
| | listener.Stop(); |
| | } |
| | } |
| |
|
| | private HttpListener StartListener() |
| | { |
| | try |
| | { |
| | var listener = new HttpListener(); |
| | listener.Prefixes.Add(RedirectUri); |
| | listener.Start(); |
| | return listener; |
| | } |
| | catch |
| | { |
| | CallbackUriChooser.Default.ReportFailure(_callbackUriTemplate); |
| | throw; |
| | } |
| | } |
| |
|
| | private async Task<AuthorizationCodeResponseUrl> GetResponseFromListener(HttpListener listener, CancellationToken ct) |
| | { |
| | HttpListenerContext context; |
| | |
| | |
| | using (ct.Register(listener.Stop)) |
| | { |
| | |
| | try |
| | { |
| | context = await listener.GetContextAsync().ConfigureAwait(false); |
| | } |
| | catch (Exception) when (ct.IsCancellationRequested) |
| | { |
| | ct.ThrowIfCancellationRequested(); |
| | |
| | |
| | throw new InvalidOperationException(); |
| | } |
| | catch |
| | { |
| | CallbackUriChooser.Default.ReportFailure(_callbackUriTemplate); |
| | throw; |
| | } |
| | CallbackUriChooser.Default.ReportSuccess(_callbackUriTemplate); |
| | } |
| | NameValueCollection coll = context.Request.QueryString; |
| |
|
| | |
| | var bytes = Encoding.UTF8.GetBytes(_closePageResponse); |
| | context.Response.ContentLength64 = bytes.Length; |
| | context.Response.SendChunked = false; |
| | context.Response.KeepAlive = false; |
| | var output = context.Response.OutputStream; |
| | await output.WriteAsync(bytes, 0, bytes.Length).ConfigureAwait(false); |
| | await output.FlushAsync().ConfigureAwait(false); |
| | output.Close(); |
| | context.Response.Close(); |
| |
|
| | |
| | return new AuthorizationCodeResponseUrl(coll.AllKeys.ToDictionary(k => k, k => coll[k])); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | #if NETSTANDARD2_0 || NET6_0_OR_GREATER |
| | protected virtual bool OpenBrowser(string url) |
| | { |
| | |
| | |
| | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) |
| | { |
| | |
| | url = System.Text.RegularExpressions.Regex.Replace(url, @"(\\*)" + "\"", @"$1$1\" + "\""); |
| | url = System.Text.RegularExpressions.Regex.Replace(url, @"(\\+)$", @"$1$1"); |
| | Process.Start(new ProcessStartInfo("cmd", $"/c start \"\" \"{url}\"") { CreateNoWindow = true }); |
| | return true; |
| | } |
| | if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) |
| | { |
| | Process.Start("xdg-open", url); |
| | return true; |
| | } |
| | if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) |
| | { |
| | Process.Start("open", url); |
| | return true; |
| | } |
| | return false; |
| | } |
| | #elif NET462_OR_GREATER |
| | protected virtual bool OpenBrowser(string url) |
| | { |
| | Process.Start(url); |
| | return true; |
| | } |
| | #else |
| | #error Unsupported target |
| | #endif |
| |
|
| | internal class CallbackUriChooser |
| | { |
| | |
| | internal static readonly string CallbackUriTemplateLocalhost = $"http://localhost:{{0}}{LoopbackCallbackPath}"; |
| | |
| | internal static readonly string CallbackUriTemplate127001 = $"http://127.0.0.1:{{0}}{LoopbackCallbackPath}"; |
| |
|
| | private readonly IClock _clock; |
| | |
| | private readonly TimeSpan _timeout; |
| | private readonly Func<string, bool> _listenerFailsFor; |
| |
|
| | private readonly object _lock = new object(); |
| | |
| | private UriStatistics _loopbackIp; |
| | private UriStatistics _localhost; |
| |
|
| | public static CallbackUriChooser Default { get; } = new CallbackUriChooser(); |
| |
|
| | public CallbackUriChooser(): |
| | this(SystemClock.Default, TimeSpan.FromMinutes(1), FailsHttpListener) |
| | { } |
| |
|
| | internal CallbackUriChooser(IClock clock, TimeSpan timeout, Func<string, bool> listenerFailsFor) |
| | { |
| | _clock = clock; |
| | _timeout = timeout; |
| | _listenerFailsFor = listenerFailsFor; |
| | } |
| |
|
| | internal string GetUriTemplate(CallbackUriChooserStrategy strategy) |
| | { |
| | lock (_lock) |
| | { |
| | if (strategy == CallbackUriChooserStrategy.ForceLoopbackIp) |
| | { |
| | |
| | InitUriStatisticsIfNeeded(ref _loopbackIp, CallbackUriTemplate127001, false); |
| | return _loopbackIp.Uri; |
| | } |
| |
|
| | if (strategy == CallbackUriChooserStrategy.ForceLocalhost) |
| | { |
| | |
| | InitUriStatisticsIfNeeded(ref _localhost, CallbackUriTemplateLocalhost, false); |
| | return _localhost.Uri; |
| | } |
| |
|
| | |
| | |
| |
|
| | |
| | InitUriStatisticsIfNeeded(ref _loopbackIp, CallbackUriTemplate127001, true); |
| | |
| | |
| | if (_loopbackIp.CanBeUsed) |
| | { |
| | return _loopbackIp.Uri; |
| | } |
| |
|
| | |
| | |
| |
|
| | |
| | InitUriStatisticsIfNeeded(ref _localhost, CallbackUriTemplateLocalhost, true); |
| | |
| | |
| | if (_localhost.CanBeUsed) |
| | { |
| | return _localhost.Uri; |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | UriStatistics retriable = _loopbackIp.TotalResets switch |
| | { |
| | |
| | var loopbackResets when loopbackResets < _localhost.TotalResets => _loopbackIp, |
| | var loopbackResets when loopbackResets > _localhost.TotalResets => _localhost, |
| | |
| | |
| | _ when _loopbackIp.IsTimedOut => _loopbackIp, |
| | _ when _localhost.IsTimedOut => _localhost, |
| | |
| | _ => _loopbackIp |
| | }; |
| |
|
| | retriable.Reset(); |
| | return retriable.Uri; |
| | } |
| |
|
| | void InitUriStatisticsIfNeeded(ref UriStatistics statistics, string uri, bool checkListener) |
| | { |
| | if (statistics == null) |
| | { |
| | statistics = new UriStatistics(uri, _timeout, _clock); |
| |
|
| | |
| | |
| | if (checkListener && _listenerFailsFor(statistics.Uri)) |
| | { |
| | statistics.Failed(); |
| | } |
| | } |
| | } |
| | } |
| |
|
| | public void ReportSuccess(string uri) => GetStatisticsFor(uri).Succeeded(); |
| |
|
| | public void ReportFailure(string uri) => GetStatisticsFor(uri).Failed(); |
| |
|
| | private UriStatistics GetStatisticsFor(string uri) => |
| | uri == CallbackUriTemplate127001 ? _loopbackIp : |
| | uri == CallbackUriTemplateLocalhost ? _localhost : |
| | throw new ArgumentOutOfRangeException(nameof(uri)); |
| |
|
| | private static bool FailsHttpListener(string uri) |
| | { |
| | try |
| | { |
| | |
| | |
| | using var listener = new HttpListener(); |
| | listener.Prefixes.Add(string.Format(uri, GetRandomUnusedPort())); |
| | listener.Start(); |
| | } |
| | catch (HttpListenerException e) when (e.ErrorCode == 5) |
| | { |
| | |
| | return true; |
| | } |
| | catch |
| | { |
| | |
| | } |
| | return false; |
| | } |
| |
|
| | private class UriStatistics |
| | { |
| | private readonly TimeSpan _timeouts; |
| | private readonly IClock _clock; |
| |
|
| | public string Uri { get; } |
| |
|
| | public DateTimeOffset FirstServedAt { get; private set; } |
| |
|
| | public bool IsKnownToSucceed { get; private set; } |
| |
|
| | public bool IsKnownToFail { get; private set; } |
| |
|
| | public int TotalResets { get; private set; } |
| |
|
| | public bool IsTimedOut => |
| | |
| | !IsKnownToSucceed && !IsKnownToFail && |
| | FirstServedAt.Add(_timeouts) <= _clock.UtcNow; |
| |
|
| | public bool CanBeUsed => |
| | |
| | IsKnownToSucceed || |
| | (!IsKnownToFail && !IsTimedOut); |
| |
|
| | public UriStatistics(string uri, TimeSpan timeoutsAfter, IClock clock) |
| | { |
| | _timeouts = timeoutsAfter; |
| | _clock = clock; |
| | Uri = uri; |
| | FirstServedAt = new DateTimeOffset(_clock.UtcNow); |
| | IsKnownToSucceed = false; |
| | IsKnownToFail = false; |
| | TotalResets = 0; |
| | } |
| |
|
| | public void Succeeded() => IsKnownToSucceed = true; |
| |
|
| | public void Failed() => IsKnownToFail = true; |
| |
|
| | public void Reset() |
| | { |
| | TotalResets++; |
| | FirstServedAt = new DateTimeOffset(_clock.UtcNow); |
| | IsKnownToFail = false; |
| | } |
| | } |
| | } |
| | } |
| | } |
| |
|