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

kestrel .net5 dateHeaderValues is null #28112

Closed
chris-kruining opened this issue Nov 24, 2020 · 16 comments
Closed

kestrel .net5 dateHeaderValues is null #28112

chris-kruining opened this issue Nov 24, 2020 · 16 comments
Labels
area-networking Includes servers, yarp, json patch, bedrock, websockets, http client factory, and http abstractions feature-kestrel

Comments

@chris-kruining
Copy link

Hi,

I've been breaking my head on this issue for 2 days now, I followed the sample on IdentityModel.OidcClient.Samples to implement a local server to handle the oidc callback page. but I keep running into the issue of a null reference exception inside kestrel itself. Having delved deeper into this issue I have identified the source to be on aspnetcore/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.cs:1188. Looking into the call I have a suspicion it is "simply" a racing condition. since I have modified the sample a bit I'll post it below.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using IdentityModel.OidcClient;
using IdentityModel.OidcClient.Browser;
using IdentityModel.OidcClient.Results;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Serilog;
using Serilog.Sinks.SystemConsole.Themes;

namespace Fyn.Windows.Service
{
    public static class Authentication
    {
        private static readonly String Authority = "https://unifyned.cloud";
        private static readonly String Api = "https://unifyned.cloud/v1/cms/file";

        private static OidcClient? oidcClient;
        private static HttpClient apiClient = new HttpClient { BaseAddress = new Uri(Api), DefaultRequestVersion = new Version(2, 0) };

        public static async ValueTask Signin()
        {
            SystemBrowser browser = new SystemBrowser(5002);
            String redirectUri = $"http://127.0.0.1:{browser.Port}";

            OidcClientOptions options = new OidcClientOptions
            {
                Authority = Authority,
                ClientId = "Shell.Windows",
                RedirectUri = redirectUri,
                Scope = "openid profile email",
                FilterClaims = false,

                Browser = browser,
                IdentityTokenValidator = new JwtHandlerIdentityTokenValidator(),
                RefreshTokenInnerHttpHandler = new HttpClientHandler(),
            };

            options.LoggerFactory.AddSerilog(
                new LoggerConfiguration()
                    .MinimumLevel.Debug()
                    .Enrich.FromLogContext()
                    .WriteTo.Console(
                        outputTemplate: "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message}{NewLine}{Exception}{NewLine}", 
                        theme: AnsiConsoleTheme.Code
                    )
                    .CreateLogger()
            );

            oidcClient = new OidcClient(options);
            LoginResult? result = await oidcClient.LoginAsync();

            apiClient = new HttpClient(result.RefreshTokenHandler)
            {
                BaseAddress = new Uri(Api),
            };

            result.Show();

            await result.NextSteps();
        }

        private static void Show(this LoginResult result)
        {
            if (result.IsError)
            {
                Console.WriteLine($"\n\nError:\n{result.Error}");

                return;
            }

            Console.WriteLine("\n\nClaims:");

            foreach (Claim claim in result.User.Claims)
            {
                Console.WriteLine($"{claim.Type}: {claim.Value}");
            }

            Dictionary<String, JsonElement>? values = JsonSerializer.Deserialize<Dictionary<String, JsonElement>>(result.TokenResponse.Raw);

            Console.WriteLine("token response...");

            if (values == null)
            {
                return;
            }

            foreach ((String key, JsonElement value) in values)
            {
                Console.WriteLine($"{key}: {value}");
            }
        }

        private static async ValueTask NextSteps(this LoginResult result)
        {
            String currentAccessToken = result.AccessToken;
            String currentRefreshToken = result.RefreshToken;

            String menu = "  x...exit  c...call api   ";

            if (currentRefreshToken != null)
            {
                menu += "r...refresh token   ";
            }

            while (true)
            {
                Console.WriteLine("\n\n");

                Console.Write(menu);
                ConsoleKeyInfo key = Console.ReadKey();

                switch (key.Key)
                {
                    case ConsoleKey.X:
                    {
                        return;
                    }

                    case ConsoleKey.C:
                    {
                        await CallApi();
                        break;
                    }

                    case ConsoleKey.R:
                    {
                        RefreshTokenResult refreshResult = await oidcClient.RefreshTokenAsync(currentRefreshToken);
                        if (refreshResult.IsError)
                        {
                            Console.WriteLine($"Error: {refreshResult.Error}");
                        }
                        else
                        {
                            currentRefreshToken = refreshResult.RefreshToken;
                            currentAccessToken = refreshResult.AccessToken;

                            Console.WriteLine("\n\n");
                            Console.WriteLine($"access token:   {currentAccessToken}");
                            Console.WriteLine($"refresh token:  {currentRefreshToken ?? "none"}");
                        }

                        break;
                    }
                }
            }
        }

        private static async ValueTask CallApi()
        {
            HttpResponseMessage response = await apiClient.GetAsync("");

            if (response.IsSuccessStatusCode)
            {
                JsonDocument json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
                Console.WriteLine("\n\n");
                Console.WriteLine(json.RootElement);
            }
            else
            {
                Console.WriteLine($"Error: {response.ReasonPhrase}");
            }
        }
    }

    public class SystemBrowser : IBrowser
    {
        public Int32 Port { get; }
        private readonly String _path;

        public SystemBrowser(Int32? port = null, String? path = null)
        {
            _path = path;
            Port = port ?? GetRandomUnusedPort();
        }

        private static Int32 GetRandomUnusedPort()
        {
            TcpListener listener = new TcpListener(IPAddress.Loopback, 0);
            listener.Start();

            Int32 port = ((IPEndPoint)listener.LocalEndpoint).Port;
            listener.Stop();

            return port;
        }

        public async Task<BrowserResult> InvokeAsync(BrowserOptions options, CancellationToken cancellationToken)
        {
            await using LoopbackHttpListener listener = new LoopbackHttpListener(Port, _path);
            await listener.Start();

            OpenBrowser(options.StartUrl);

            try
            {
                String? result = await listener.WaitForCallbackAsync();

                return String.IsNullOrWhiteSpace(result) 
                    ? new BrowserResult
                    {
                        ResultType = BrowserResultType.UnknownError, 
                        Error = "Empty response.",
                    } 
                    : new BrowserResult
                    {
                        ResultType = BrowserResultType.Success, 
                        Response = result,
                    };
            }
            catch (TaskCanceledException ex)
            {
                return new BrowserResult
                {
                    ResultType = BrowserResultType.Timeout, 
                    Error = ex.Message,
                };
            }
            catch (Exception ex)
            {
                return new BrowserResult
                {
                    ResultType = BrowserResultType.UnknownError, 
                    Error = ex.Message,
                };
            }
        }
        public static void OpenBrowser(String url)
        {
            // hack because of this: https://github.com/dotnet/corefx/issues/10361
            if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
            {
                url = url.Replace("&", "^&");
                Process.Start(new ProcessStartInfo("cmd", $"/c start {url}") { CreateNoWindow = true });
            }
            else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
            {
                Process.Start("xdg-open", url);
            }
            else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
            {
                Process.Start("open", url);
            }
            else
            {
                Process.Start(url);
            }
        }
    }

    public class LoopbackHttpListener : IAsyncDisposable
    {
        const Int32 DefaultTimeout = 300_000;

        private readonly IWebHost _host;
        private readonly TaskCompletionSource<String> _source = new TaskCompletionSource<String>();

        public LoopbackHttpListener(Int32 port, String? path = null)
        {
            _host = new WebHostBuilder()
                .UseUrls($"http://127.0.0.1:{port}/{path?.TrimStart('/')}")
                .UseKestrel()
                .Configure(builder =>
                {
                    builder.Run(async context =>
                    {
                        switch (context.Request.Method)
                        {
                            case "GET":
                            {
                                await SetResult(context.Request.QueryString.Value, context);
                                break;
                            }

                            case "POST" when !context.Request.ContentType.Equals("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase):
                            {
                                context.Response.StatusCode = 415;
                                break;
                            }

                            case "POST":
                            {
                                using StreamReader sr = new StreamReader(context.Request.Body, Encoding.UTF8);
                                await SetResult(await sr.ReadToEndAsync(), context);
                                break;
                            }

                            default:
                            {
                                context.Response.StatusCode = 405;
                                break;
                            }
                        }
                    });
                })
                .ConfigureLogging(options =>
                {
                    options.AddSerilog(
                        new LoggerConfiguration()
                            .MinimumLevel.Debug()
                            .Enrich.FromLogContext()
                            .WriteTo.Console(
                                outputTemplate: "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message}{NewLine}{Exception}{NewLine}",
                                theme: AnsiConsoleTheme.Code
                            )
                            .CreateLogger()
                    );
                })
                .Build();
        }

        public Task Start()
        {
            return _host.StartAsync();
        }

        public async ValueTask DisposeAsync()
        {
            await Task.Delay(500);

            _host.Dispose();
        }

        private async ValueTask SetResult(String value, HttpContext context)
        {
            try
            {
                context.Response.StatusCode = 200;
                context.Response.ContentType = "text/html";
                await context.Response.WriteAsync("<h1>You can now return to the application.</h1>");
                await context.Response.Body.FlushAsync();

                _source.TrySetResult(value);
            }
            catch(Exception exception)
            {
                context.Response.StatusCode = 400;
                context.Response.ContentType = "text/html";
                await context.Response.WriteAsync("<h1>Invalid request.</h1>");

#if DEBUG
                await context.Response.WriteAsync($"<p>{exception.Message}</p>");
                await context.Response.WriteAsync($"<p>{exception.StackTrace}</p>");
#endif

                await context.Response.Body.FlushAsync();
            }
        }

        public async ValueTask<String> WaitForCallbackAsync(Int32 timeout = DefaultTimeout)
        {
            await Task.Delay(timeout);

            _source.TrySetCanceled();

            return await _source.Task;
        }
    }
}

It is the this line which triggers the exception

await context.Response.WriteAsync("<h1>You can now return to the application.</h1>");

Am I just an idiot and blind for a missing await somewhere?
Is the Heartbeat in kestrel internally broken?
Is my config of the WebHost correct?

Thank you in advance!

@davidfowl
Copy link
Member

davidfowl commented Nov 24, 2020

There were changes made to the heartbeat in 5.0 so it's definitely possible there's an issue.

What's the exception you're seeing?

@chris-kruining
Copy link
Author

chris-kruining commented Nov 24, 2020

image

And it is dateHeaderValues that is null here

@davidfowl
Copy link
Member

@chris-kruining do you have a reliable repro?

@davidfowl
Copy link
Member

I wonder if this is related to using the WebHost directly vs using the generic host... My only guess is that somehow a request is being sent before the value is initially set. This shouldn't happen because we're supposed to do this during startup but clearly something else is happening here.

@davidfowl
Copy link
Member

cc @halter73

@halter73
Copy link
Member

@chris-kruining If you don't have a reliable repro which would be ideal, can you provide a stacktrace for where the NullReferenceException occurs? DateHeaderValueManager should be fully initialized by the time Kestrel's Heartbeat.Start() completes here:

This is before Kestrel binds to any endpoints or processes any requests.

@chris-kruining
Copy link
Author

image

at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpProtocol.CreateResponseHeader(Boolean appCompleted) in /_/src/Kestrel.Core/Internal/Http/HttpProtocol.cs:line 1193

I'm afraid a single line stacktrace wont help much :S

I'll try to set up a repro repo instead.

@chris-kruining
Copy link
Author

https://github.com/chris-kruining/dotnet5-kestrel-issue-repro here you go

I haven't removed all logic around it in the case it's my code that's wrong.

@Kahbazi
Copy link
Member

Kahbazi commented Nov 25, 2020

@halter73 I was a little curious and checked the code. Here's what I found.

There's an exception in OnHeartbeat method.

System.TypeLoadException: Could not load type 'Microsoft.Extensions.Primitives.InplaceStringBuilder' from assembly 'Microsoft.Extensions.Primitives, Version=5.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60'.
at Microsoft.Net.Http.Headers.DateTimeFormatter.ToRfc1123String(DateTimeOffset dateTime, Boolean quoted)
at Microsoft.Net.Http.Headers.HeaderUtilities.FormatDate(DateTimeOffset dateTime, Boolean quoted)
at Microsoft.Net.Http.Headers.HeaderUtilities.FormatDate(DateTimeOffset dateTime)
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.DateHeaderValueManager.SetDateValues(DateTimeOffset value)
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.DateHeaderValueManager.OnHeartbeat(DateTimeOffset now)
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure.Heartbeat.OnHeartbeat()

@chris-kruining This project is using kestrel nuget package with version 2.2 which depends on InplaceStringBuilder and it's removed in 5.0.
Based on what I run. Removing Microsoft.AspNetCore.Server.Kestrel package and setting the project sdk to Microsoft.NET.Sdk.Web should fix the problem.

@chris-kruining
Copy link
Author

@Kahbazi Can I swap that sdk??? I am making a wpf desktop app, just need a localhost listener to handle the callback of oidc

@Kahbazi
Copy link
Member

Kahbazi commented Nov 25, 2020

As far as I know it's not a problem. Using the web sdk adds the packages you need for listener which is Kestrel, but just to be sure you could wait for @davidfowl or @halter73 to give the final answer.

@davidfowl
Copy link
Member

@chris-kruining you don't need to change the SDK. You can just add a framework reference to ASP.NET COre:

<ItemGroup>
+    <FrameworkReference Include="Microsoft.AspNetCore.App" />
-    <PackageReference Include="Microsoft.AspNetCore.Server.Kestrel" Version="2.2.0" />
</ItemGroup>

@davidfowl
Copy link
Member

Thanks for looking @Kahbazi !

@chris-kruining
Copy link
Author

works like a charm!

For my understanding, what is it I did wrong? because 2.2.0 is the latest version on nuget. And I feel like adding a whole framework for a single feature is a bit overkill. Is FrameworkReference the norm over PackageReference. Or is this an exception because the nuget package is out of date?

@davidfowl
Copy link
Member

I'd recommend reading this https://docs.microsoft.com/en-us/aspnet/core/migration/22-to-30?view=aspnetcore-5.0&tabs=visual-studio#framework-reference

@ilharp
Copy link

ilharp commented Dec 18, 2020

Wooooooooooooo

I've been breaking my head on this issue for 2 days now

exactly the same


One difference is that I try to run multiple Hosts while using PackageReference in a single Console app. The problem disappears after modified to FrameworkReference. (Commit)

  <ItemGroup>
+    <FrameworkReference Include="Microsoft.AspNetCore.App" />
-    <PackageReference Include="Microsoft.AspNetCore" Version="2.2.0" />
-    <PackageReference Include="Microsoft.AspNetCore.Http.Connections" Version="1.1.0" />
-    <PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.2.5" />
  </ItemGroup>

@ghost ghost locked as resolved and limited conversation to collaborators Jan 17, 2021
@amcasey amcasey added area-networking Includes servers, yarp, json patch, bedrock, websockets, http client factory, and http abstractions and removed area-runtime labels Jun 2, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
area-networking Includes servers, yarp, json patch, bedrock, websockets, http client factory, and http abstractions feature-kestrel
Projects
None yet
Development

No branches or pull requests

7 participants