diff --git a/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Services/Http.ts b/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Services/Http.ts index d914b6b22..a0a1cd5f4 100644 --- a/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Services/Http.ts +++ b/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Services/Http.ts @@ -15,7 +15,7 @@ async function sendAsync(id: number, method: string, requestUri: string, body: s let response: Response; let responseText: string; - const requestInit = fetchArgs || {}; + const requestInit: RequestInit = fetchArgs || {}; requestInit.method = method; requestInit.body = body || undefined; diff --git a/src/Microsoft.AspNetCore.Blazor.Browser/Http/BrowserHttpMessageHandler.cs b/src/Microsoft.AspNetCore.Blazor.Browser/Http/BrowserHttpMessageHandler.cs index 4bf5dc6c4..beedb93db 100644 --- a/src/Microsoft.AspNetCore.Blazor.Browser/Http/BrowserHttpMessageHandler.cs +++ b/src/Microsoft.AspNetCore.Blazor.Browser/Http/BrowserHttpMessageHandler.cs @@ -17,6 +17,13 @@ namespace Microsoft.AspNetCore.Blazor.Browser.Http /// public class BrowserHttpMessageHandler : HttpMessageHandler { + /// + /// Gets or sets the default value of the 'credentials' option on outbound HTTP requests. + /// Defaults to . + /// + public static FetchCredentialsOption DefaultCredentials { get; set; } + = FetchCredentialsOption.SameOrigin; + static object _idLock = new object(); static int _nextRequestId = 0; static IDictionary> _pendingRequests @@ -47,7 +54,7 @@ protected override async Task SendAsync( request.RequestUri, request.Content == null ? null : await GetContentAsString(request.Content), SerializeHeadersAsJson(request), - fetchArgs); + fetchArgs ?? CreateDefaultFetchArgs()); return await tcs.Task; } @@ -93,6 +100,26 @@ private static void ReceiveResponse( } } + private static object CreateDefaultFetchArgs() + => new { credentials = GetDefaultCredentialsString() }; + + private static object GetDefaultCredentialsString() + { + // See https://developer.mozilla.org/en-US/docs/Web/API/Request/credentials for + // standard values and meanings + switch (DefaultCredentials) + { + case FetchCredentialsOption.Omit: + return "omit"; + case FetchCredentialsOption.SameOrigin: + return "same-origin"; + case FetchCredentialsOption.Include: + return "include"; + default: + throw new ArgumentException($"Unknown credentials option '{DefaultCredentials}'."); + } + } + // Keep in sync with TypeScript class in Http.ts private class ResponseDescriptor { diff --git a/src/Microsoft.AspNetCore.Blazor.Browser/Http/FetchCredentialsOption.cs b/src/Microsoft.AspNetCore.Blazor.Browser/Http/FetchCredentialsOption.cs new file mode 100644 index 000000000..1a5d95853 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Browser/Http/FetchCredentialsOption.cs @@ -0,0 +1,28 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Blazor.Browser.Http +{ + /// + /// Specifies a value for the 'credentials' option on outbound HTTP requests. + /// + public enum FetchCredentialsOption + { + /// + /// Advises the browser never to send credentials (such as cookies or HTTP auth headers). + /// + Omit, + + /// + /// Advises the browser to send credentials (such as cookies or HTTP auth headers) + /// only if the target URL is on the same origin as the calling application. + /// + SameOrigin, + + /// + /// Advises the browser to send credentials (such as cookies or HTTP auth headers) + /// even for cross-origin requests. + /// + Include, + } +} diff --git a/test/Microsoft.AspNetCore.Blazor.E2ETest/Infrastructure/BrowserFixture.cs b/test/Microsoft.AspNetCore.Blazor.E2ETest/Infrastructure/BrowserFixture.cs index 3cdd7ef79..3a3bc298a 100644 --- a/test/Microsoft.AspNetCore.Blazor.E2ETest/Infrastructure/BrowserFixture.cs +++ b/test/Microsoft.AspNetCore.Blazor.E2ETest/Infrastructure/BrowserFixture.cs @@ -15,6 +15,8 @@ public class BrowserFixture : IDisposable public BrowserFixture() { var opts = new ChromeOptions(); + + // Comment this out if you want to watch or interact with the browser (e.g., for debugging) opts.AddArgument("--headless"); // On Windows/Linux, we don't need to set opts.BinaryLocation diff --git a/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/HttpClientTest.cs b/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/HttpClientTest.cs index 2b6ab8d33..b18bcf845 100644 --- a/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/HttpClientTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/HttpClientTest.cs @@ -106,6 +106,38 @@ public void CanSetRequestReferer() Assert.EndsWith("/test-referrer", _responseBody.Text); } + [Fact] + public void CanSendAndReceiveCookies() + { + var app = MountTestComponent(); + var deleteButton = app.FindElement(By.Id("delete")); + var incrementButton = app.FindElement(By.Id("increment")); + app.FindElement(By.TagName("input")).SendKeys(_apiServerFixture.RootUri.ToString()); + + // Ensure we're starting from a clean state + deleteButton.Click(); + Assert.Equal("Reset completed", WaitAndGetResponseText()); + + // Observe that subsequent requests manage to preserve state via cookie + incrementButton.Click(); + Assert.Equal("Counter value is 1", WaitAndGetResponseText()); + incrementButton.Click(); + Assert.Equal("Counter value is 2", WaitAndGetResponseText()); + + // Verify that attempting to delete a cookie actually works + deleteButton.Click(); + Assert.Equal("Reset completed", WaitAndGetResponseText()); + incrementButton.Click(); + Assert.Equal("Counter value is 1", WaitAndGetResponseText()); + + string WaitAndGetResponseText() + { + new WebDriverWait(Browser, TimeSpan.FromSeconds(30)).Until( + driver => driver.FindElement(By.Id("response-text")) != null); + return app.FindElement(By.Id("response-text")).Text; + } + } + private void IssueRequest(string requestMethod, string relativeUri, string requestBody = null) { var targetUri = new Uri(_apiServerFixture.RootUri, relativeUri); diff --git a/test/testapps/BasicTestApp/HttpClientTest/CookieCounterComponent.cshtml b/test/testapps/BasicTestApp/HttpClientTest/CookieCounterComponent.cshtml new file mode 100644 index 000000000..9efde52e6 --- /dev/null +++ b/test/testapps/BasicTestApp/HttpClientTest/CookieCounterComponent.cshtml @@ -0,0 +1,38 @@ +@inject System.Net.Http.HttpClient Http + +

Cookie counter

+

The server increments the count by one on each request.

+

TestServer base URL:

+ + + +@if (!requestInProgress) +{ +

@responseText

+} + +@functions +{ + bool requestInProgress = false; + string testServerBaseUrl; + string responseText; + + async void DeleteCookie() + { + await DoRequest("api/cookie/reset"); + StateHasChanged(); + } + + async void GetAndIncrementCounter() + { + await DoRequest("api/cookie/increment"); + StateHasChanged(); + } + + async Task DoRequest(string url) + { + requestInProgress = true; + responseText = await Http.GetStringAsync(testServerBaseUrl + url); + requestInProgress = false; + } +} diff --git a/test/testapps/BasicTestApp/Program.cs b/test/testapps/BasicTestApp/Program.cs index c8b87d496..39e5bc6d0 100644 --- a/test/testapps/BasicTestApp/Program.cs +++ b/test/testapps/BasicTestApp/Program.cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using Microsoft.AspNetCore.Blazor.Browser.Http; using Microsoft.AspNetCore.Blazor.Browser.Interop; using Microsoft.AspNetCore.Blazor.Browser.Rendering; using Microsoft.AspNetCore.Blazor.Components; @@ -12,6 +13,10 @@ public class Program { static void Main(string[] args) { + // Needed because the test server runs on a different port than the client app, + // and we want to test sending/receiving cookies undering this config + BrowserHttpMessageHandler.DefaultCredentials = FetchCredentialsOption.Include; + // Signal to tests that we're ready RegisteredFunction.Invoke("testReady"); } diff --git a/test/testapps/BasicTestApp/wwwroot/index.html b/test/testapps/BasicTestApp/wwwroot/index.html index 1709d80ef..148d5a156 100644 --- a/test/testapps/BasicTestApp/wwwroot/index.html +++ b/test/testapps/BasicTestApp/wwwroot/index.html @@ -22,6 +22,7 @@ + diff --git a/test/testapps/TestServer/Controllers/CookieController.cs b/test/testapps/TestServer/Controllers/CookieController.cs new file mode 100644 index 000000000..72d5bd17e --- /dev/null +++ b/test/testapps/TestServer/Controllers/CookieController.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Mvc; + +namespace TestServer.Controllers +{ + [Route("api/[controller]/[action]")] + [EnableCors("AllowAll")] // Only because the test client apps runs on a different origin + public class CookieController : Controller + { + const string cookieKey = "test-counter-cookie"; + + public string Reset() + { + Response.Cookies.Delete(cookieKey); + return "Reset completed"; + } + + public string Increment() + { + var counter = 0; + if (Request.Cookies.TryGetValue(cookieKey, out var incomingValue)) + { + counter = int.Parse(incomingValue); + } + + counter++; + Response.Cookies.Append(cookieKey, counter.ToString()); + + return $"Counter value is {counter}"; + } + } +} diff --git a/test/testapps/TestServer/Startup.cs b/test/testapps/TestServer/Startup.cs index 3889da67b..44cf551b5 100644 --- a/test/testapps/TestServer/Startup.cs +++ b/test/testapps/TestServer/Startup.cs @@ -20,14 +20,7 @@ public void ConfigureServices(IServiceCollection services) services.AddMvc(); services.AddCors(options => { - options.AddPolicy("AllowAll", builder => - { - builder - .AllowAnyOrigin() - .AllowAnyHeader() - .AllowAnyMethod() - .WithExposedHeaders("MyCustomHeader"); - }); + options.AddPolicy("AllowAll", _ => { /* Controlled below */ }); }); } @@ -39,7 +32,32 @@ public void Configure(IApplicationBuilder app, IHostingEnvironment env) app.UseDeveloperExceptionPage(); } + AllowCorsForAnyLocalhostPort(app); app.UseMvc(); } + + private static void AllowCorsForAnyLocalhostPort(IApplicationBuilder app) + { + // It's not enough just to return "Access-Control-Allow-Origin: *", because + // browsers don't allow wildcards in conjunction with credentials. So we must + // specify explicitly which origin we want to allow. + app.Use((context, next) => + { + if (context.Request.Headers.TryGetValue("origin", out var incomingOriginValue)) + { + var origin = incomingOriginValue.ToArray()[0]; + if (origin.StartsWith("http://localhost:") || origin.StartsWith("http://127.0.0.1:")) + { + context.Response.Headers.Add("Access-Control-Allow-Origin", origin); + context.Response.Headers.Add("Access-Control-Allow-Credentials", "true"); + context.Response.Headers.Add("Access-Control-Allow-Methods", "HEAD,GET,PUT,POST,DELETE,OPTIONS"); + context.Response.Headers.Add("Access-Control-Allow-Headers", "Content-Type,TestHeader,another-header"); + context.Response.Headers.Add("Access-Control-Expose-Headers", "MyCustomHeader,TestHeader,another-header"); + } + } + + return next(); + }); + } } }