Skip to content
This repository has been archived by the owner on Feb 25, 2021. It is now read-only.

Enable same-origin credentials by default. Add E2E test to show they … #518

Closed
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ namespace Microsoft.AspNetCore.Blazor.Browser.Http
/// </summary>
public class BrowserHttpMessageHandler : HttpMessageHandler
{
/// <summary>
/// Gets or sets the default value of the 'credentials' option on outbound HTTP requests.
/// Defaults to <see cref="FetchCredentialsOption.SameOrigin"/>.
/// </summary>
public static FetchCredentialsOption DefaultCredentials { get; set; }
= FetchCredentialsOption.SameOrigin;

static object _idLock = new object();
static int _nextRequestId = 0;
static IDictionary<int, TaskCompletionSource<HttpResponseMessage>> _pendingRequests
Expand Down Expand Up @@ -47,7 +54,7 @@ protected override async Task<HttpResponseMessage> SendAsync(
request.RequestUri,
request.Content == null ? null : await GetContentAsString(request.Content),
SerializeHeadersAsJson(request),
fetchArgs);
fetchArgs ?? CreateDefaultFetchArgs());

return await tcs.Task;
}
Expand Down Expand Up @@ -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
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Specifies a value for the 'credentials' option on outbound HTTP requests.
/// </summary>
public enum FetchCredentialsOption
{
/// <summary>
/// Advises the browser never to send credentials (such as cookies or HTTP auth headers).
/// </summary>
Omit,

/// <summary>
/// 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.
/// </summary>
SameOrigin,

/// <summary>
/// Advises the browser to send credentials (such as cookies or HTTP auth headers)
/// even for cross-origin requests.
/// </summary>
Include,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/HttpClientTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,38 @@ public void CanSetRequestReferer()
Assert.EndsWith("/test-referrer", _responseBody.Text);
}

[Fact]
public void CanSendAndReceiveCookies()
{
var app = MountTestComponent<CookieCounterComponent>();
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);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
@inject System.Net.Http.HttpClient Http

<h1>Cookie counter</h1>
<p>The server increments the count by one on each request.</p>
<p>TestServer base URL: <input @bind(testServerBaseUrl) /></p>
<button id="delete" @onclick(DeleteCookie)>Delete cookie</button>
<button id="increment" @onclick(GetAndIncrementCounter)>Get and increment current value</button>

@if (!requestInProgress)
{
<p id="response-text">@responseText</p>
}

@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;
}
}
5 changes: 5 additions & 0 deletions test/testapps/BasicTestApp/Program.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<object>("testReady");
}
Expand Down
1 change: 1 addition & 0 deletions test/testapps/BasicTestApp/wwwroot/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
<option value="BasicTestApp.TextOnlyComponent">Plain text</option>
<option value="BasicTestApp.HierarchicalImportsTest.Subdir.ComponentUsingImports">Imports statement</option>
<option value="BasicTestApp.HttpClientTest.HttpRequestsComponent">HttpClient tester</option>
<option value="BasicTestApp.HttpClientTest.CookieCounterComponent">HttpClient cookies</option>
<option value="BasicTestApp.BindCasesComponent">@bind cases</option>
<option value="BasicTestApp.ExternalContentPackage">External content package</option>
<option value="BasicTestApp.SvgComponent">SVG</option>
Expand Down
32 changes: 32 additions & 0 deletions test/testapps/TestServer/Controllers/CookieController.cs
Original file line number Diff line number Diff line change
@@ -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}";
}
}
}
34 changes: 26 additions & 8 deletions test/testapps/TestServer/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 */ });
});
}

Expand All @@ -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();
});
}
}
}