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 responseText: string;
|
||||
|
||||
const requestInit = fetchArgs || {};
|
||||
const requestInit: RequestInit = fetchArgs || {};
|
||||
requestInit.method = method;
|
||||
requestInit.body = body || undefined;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -47,7 +54,7 @@ namespace Microsoft.AspNetCore.Blazor.Browser.Http
|
|||
request.RequestUri,
|
||||
request.Content == null ? null : await GetContentAsString(request.Content),
|
||||
SerializeHeadersAsJson(request),
|
||||
fetchArgs);
|
||||
fetchArgs ?? CreateDefaultFetchArgs());
|
||||
|
||||
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
|
||||
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()
|
||||
{
|
||||
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
|
||||
|
|
|
|||
|
|
@ -106,6 +106,38 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
// 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 @@ namespace BasicTestApp
|
|||
{
|
||||
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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy("AllowAll", builder =>
|
||||
{
|
||||
builder
|
||||
.AllowAnyOrigin()
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()
|
||||
.WithExposedHeaders("MyCustomHeader");
|
||||
});
|
||||
options.AddPolicy("AllowAll", _ => { /* Controlled below */ });
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -39,7 +32,32 @@ namespace TestServer
|
|||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue