Enable same-origin credentials by default. Add E2E test to show they can be sent to different-origin domains too.
This commit is contained in:
parent
e096d7e0b1
commit
281d5a8751
|
|
@ -15,7 +15,7 @@ async function sendAsync(id: number, method: string, requestUri: string, body: s
|
||||||
let response: Response;
|
let response: Response;
|
||||||
let responseText: string;
|
let responseText: string;
|
||||||
|
|
||||||
const requestInit = fetchArgs || {};
|
const requestInit: RequestInit = fetchArgs || {};
|
||||||
requestInit.method = method;
|
requestInit.method = method;
|
||||||
requestInit.body = body || undefined;
|
requestInit.body = body || undefined;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,13 @@ namespace Microsoft.AspNetCore.Blazor.Browser.Http
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class BrowserHttpMessageHandler : HttpMessageHandler
|
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 object _idLock = new object();
|
||||||
static int _nextRequestId = 0;
|
static int _nextRequestId = 0;
|
||||||
static IDictionary<int, TaskCompletionSource<HttpResponseMessage>> _pendingRequests
|
static IDictionary<int, TaskCompletionSource<HttpResponseMessage>> _pendingRequests
|
||||||
|
|
@ -47,7 +54,7 @@ namespace Microsoft.AspNetCore.Blazor.Browser.Http
|
||||||
request.RequestUri,
|
request.RequestUri,
|
||||||
request.Content == null ? null : await GetContentAsString(request.Content),
|
request.Content == null ? null : await GetContentAsString(request.Content),
|
||||||
SerializeHeadersAsJson(request),
|
SerializeHeadersAsJson(request),
|
||||||
fetchArgs);
|
fetchArgs ?? CreateDefaultFetchArgs());
|
||||||
|
|
||||||
return await tcs.Task;
|
return await tcs.Task;
|
||||||
}
|
}
|
||||||
|
|
@ -93,6 +100,26 @@ namespace Microsoft.AspNetCore.Blazor.Browser.Http
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// Keep in sync with TypeScript class in Http.ts
|
||||||
private class ResponseDescriptor
|
private class ResponseDescriptor
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,8 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Infrastructure
|
||||||
public BrowserFixture()
|
public BrowserFixture()
|
||||||
{
|
{
|
||||||
var opts = new ChromeOptions();
|
var opts = new ChromeOptions();
|
||||||
|
|
||||||
|
// Comment this out if you want to watch or interact with the browser (e.g., for debugging)
|
||||||
opts.AddArgument("--headless");
|
opts.AddArgument("--headless");
|
||||||
|
|
||||||
// On Windows/Linux, we don't need to set opts.BinaryLocation
|
// On Windows/Linux, we don't need to set opts.BinaryLocation
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,38 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
|
||||||
Assert.EndsWith("/test-referrer", _responseBody.Text);
|
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)
|
private void IssueRequest(string requestMethod, string relativeUri, string requestBody = null)
|
||||||
{
|
{
|
||||||
var targetUri = new Uri(_apiServerFixture.RootUri, relativeUri);
|
var targetUri = new Uri(_apiServerFixture.RootUri, relativeUri);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// Copyright (c) .NET Foundation. All rights reserved.
|
// 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.
|
// 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.Interop;
|
||||||
using Microsoft.AspNetCore.Blazor.Browser.Rendering;
|
using Microsoft.AspNetCore.Blazor.Browser.Rendering;
|
||||||
using Microsoft.AspNetCore.Blazor.Components;
|
using Microsoft.AspNetCore.Blazor.Components;
|
||||||
|
|
@ -12,6 +13,10 @@ namespace BasicTestApp
|
||||||
{
|
{
|
||||||
static void Main(string[] args)
|
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
|
// Signal to tests that we're ready
|
||||||
RegisteredFunction.Invoke<object>("testReady");
|
RegisteredFunction.Invoke<object>("testReady");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
<option value="BasicTestApp.TextOnlyComponent">Plain text</option>
|
<option value="BasicTestApp.TextOnlyComponent">Plain text</option>
|
||||||
<option value="BasicTestApp.HierarchicalImportsTest.Subdir.ComponentUsingImports">Imports statement</option>
|
<option value="BasicTestApp.HierarchicalImportsTest.Subdir.ComponentUsingImports">Imports statement</option>
|
||||||
<option value="BasicTestApp.HttpClientTest.HttpRequestsComponent">HttpClient tester</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.BindCasesComponent">@bind cases</option>
|
||||||
<option value="BasicTestApp.ExternalContentPackage">External content package</option>
|
<option value="BasicTestApp.ExternalContentPackage">External content package</option>
|
||||||
<option value="BasicTestApp.SvgComponent">SVG</option>
|
<option value="BasicTestApp.SvgComponent">SVG</option>
|
||||||
|
|
|
||||||
|
|
@ -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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,14 +20,7 @@ namespace TestServer
|
||||||
services.AddMvc();
|
services.AddMvc();
|
||||||
services.AddCors(options =>
|
services.AddCors(options =>
|
||||||
{
|
{
|
||||||
options.AddPolicy("AllowAll", builder =>
|
options.AddPolicy("AllowAll", _ => { /* Controlled below */ });
|
||||||
{
|
|
||||||
builder
|
|
||||||
.AllowAnyOrigin()
|
|
||||||
.AllowAnyHeader()
|
|
||||||
.AllowAnyMethod()
|
|
||||||
.WithExposedHeaders("MyCustomHeader");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,7 +32,32 @@ namespace TestServer
|
||||||
app.UseDeveloperExceptionPage();
|
app.UseDeveloperExceptionPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AllowCorsForAnyLocalhostPort(app);
|
||||||
app.UseMvc();
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue