From c45510c8cb481413440276c128b35bbf46cd49e9 Mon Sep 17 00:00:00 2001 From: Brennan Date: Mon, 28 Oct 2019 14:34:59 -0700 Subject: [PATCH] Add 'withCredentials' option to TS client (#15076) --- .../FunctionalTests/EchoConnectionHandler.cs | 8 +++++ .../ts/FunctionalTests/ts/ConnectionTests.ts | 36 +++++++++++++++++++ .../clients/ts/signalr/src/FetchHttpClient.ts | 2 +- .../clients/ts/signalr/src/HttpClient.ts | 3 ++ .../clients/ts/signalr/src/HttpConnection.ts | 12 +++++-- .../ts/signalr/src/IHttpConnectionOptions.ts | 8 +++++ .../ts/signalr/src/LongPollingTransport.ts | 8 +++-- .../signalr/src/ServerSentEventsTransport.ts | 10 +++--- src/SignalR/clients/ts/signalr/src/Utils.ts | 4 ++- .../clients/ts/signalr/src/XhrHttpClient.ts | 2 +- .../tests/LongPollingTransport.test.ts | 8 ++--- .../tests/ServerSentEventsTransport.test.ts | 8 ++--- 12 files changed, 89 insertions(+), 20 deletions(-) diff --git a/src/SignalR/clients/ts/FunctionalTests/EchoConnectionHandler.cs b/src/SignalR/clients/ts/FunctionalTests/EchoConnectionHandler.cs index 646d328a5b..8dbdfd28f8 100644 --- a/src/SignalR/clients/ts/FunctionalTests/EchoConnectionHandler.cs +++ b/src/SignalR/clients/ts/FunctionalTests/EchoConnectionHandler.cs @@ -4,6 +4,7 @@ using System.Buffers; using System.Threading.Tasks; using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Http.Connections; namespace FunctionalTests { @@ -11,6 +12,13 @@ namespace FunctionalTests { public async override Task OnConnectedAsync(ConnectionContext connection) { + var context = connection.GetHttpContext(); + // The 'withCredentials' tests wont send a cookie for cross-site requests + if (!context.WebSockets.IsWebSocketRequest && !context.Request.Cookies.ContainsKey("testCookie")) + { + return; + } + while (true) { var result = await connection.Transport.Input.ReadAsync(); diff --git a/src/SignalR/clients/ts/FunctionalTests/ts/ConnectionTests.ts b/src/SignalR/clients/ts/FunctionalTests/ts/ConnectionTests.ts index 3b559af265..14c1091b49 100644 --- a/src/SignalR/clients/ts/FunctionalTests/ts/ConnectionTests.ts +++ b/src/SignalR/clients/ts/FunctionalTests/ts/ConnectionTests.ts @@ -10,6 +10,7 @@ import { TestLogger } from "./TestLogger"; // We want to continue testing HttpConnection, but we don't export it anymore. So just pull it in directly from the source file. import { HttpConnection } from "@microsoft/signalr/dist/esm/HttpConnection"; +import { Platform } from "@microsoft/signalr/dist/esm/Utils"; import "./LogBannerReporter"; const commonOptions: IHttpConnectionOptions = { @@ -153,6 +154,41 @@ describe("connection", () => { done(); }); }); + + // withCredentials doesn't make sense in Node or when using WebSockets + if (!Platform.isNode && transportType !== HttpTransportType.WebSockets && + // tests run through karma during automation which is cross-site, but manually running the server will result in these tests failing + // so we check for cross-site + !(window && ECHOENDPOINT_URL.match(`^${window.location.href}`))) { + it("honors withCredentials flag", (done) => { + TestLogger.saveLogsAndReset(); + const message = "Hello World!"; + + // The server will set some response headers for the '/negotiate' endpoint + const connection = new HttpConnection(ECHOENDPOINT_URL, { + ...commonOptions, + httpClient, + transport: transportType, + withCredentials: false, + }); + + connection.onreceive = (data: any) => { + fail(new Error(`Unexpected messaged received '${data}'.`)); + }; + + // @ts-ignore: We don't use the error parameter intentionally. + connection.onclose = (error) => { + done(); + }; + + connection.start(TransferFormat.Text).then(() => { + connection.send(message); + }).catch((e: any) => { + fail(e); + done(); + }); + }); + } }); }); }); diff --git a/src/SignalR/clients/ts/signalr/src/FetchHttpClient.ts b/src/SignalR/clients/ts/signalr/src/FetchHttpClient.ts index 48840b42c2..f446e31ac5 100644 --- a/src/SignalR/clients/ts/signalr/src/FetchHttpClient.ts +++ b/src/SignalR/clients/ts/signalr/src/FetchHttpClient.ts @@ -55,7 +55,7 @@ export class FetchHttpClient extends HttpClient { response = await fetch(request.url!, { body: request.content!, cache: "no-cache", - credentials: "include", + credentials: request.withCredentials === true ? "include" : "same-origin", headers: { "Content-Type": "text/plain;charset=UTF-8", "X-Requested-With": "XMLHttpRequest", diff --git a/src/SignalR/clients/ts/signalr/src/HttpClient.ts b/src/SignalR/clients/ts/signalr/src/HttpClient.ts index c50289bbf1..57614bb86b 100644 --- a/src/SignalR/clients/ts/signalr/src/HttpClient.ts +++ b/src/SignalR/clients/ts/signalr/src/HttpClient.ts @@ -25,6 +25,9 @@ export interface HttpRequest { /** The time to wait for the request to complete before throwing a TimeoutError. Measured in milliseconds. */ timeout?: number; + + /** This controls whether credentials such as cookies are sent in cross-site requests. */ + withCredentials?: boolean; } /** Represents an HTTP response. */ diff --git a/src/SignalR/clients/ts/signalr/src/HttpConnection.ts b/src/SignalR/clients/ts/signalr/src/HttpConnection.ts index 9fca7a6e56..65c491a32c 100644 --- a/src/SignalR/clients/ts/signalr/src/HttpConnection.ts +++ b/src/SignalR/clients/ts/signalr/src/HttpConnection.ts @@ -81,7 +81,12 @@ export class HttpConnection implements IConnection { this.baseUrl = this.resolveUrl(url); options = options || {}; - options.logMessageContent = options.logMessageContent || false; + options.logMessageContent = options.logMessageContent === undefined ? false : options.logMessageContent; + if (typeof options.withCredentials === "boolean" || options.withCredentials === undefined) { + options.withCredentials = options.withCredentials === undefined ? true : options.withCredentials; + } else { + throw new Error("withCredentials option was not a 'boolean' or 'undefined' value"); + } if (!Platform.isNode && typeof WebSocket !== "undefined" && !options.WebSocket) { options.WebSocket = WebSocket; @@ -319,6 +324,7 @@ export class HttpConnection implements IConnection { const response = await this.httpClient.post(negotiateUrl, { content: "", headers, + withCredentials: this.options.withCredentials, }); if (response.statusCode !== 200) { @@ -410,9 +416,9 @@ export class HttpConnection implements IConnection { if (!this.options.EventSource) { throw new Error("'EventSource' is not supported in your environment."); } - return new ServerSentEventsTransport(this.httpClient, this.accessTokenFactory, this.logger, this.options.logMessageContent || false, this.options.EventSource); + return new ServerSentEventsTransport(this.httpClient, this.accessTokenFactory, this.logger, this.options.logMessageContent || false, this.options.EventSource, this.options.withCredentials!); case HttpTransportType.LongPolling: - return new LongPollingTransport(this.httpClient, this.accessTokenFactory, this.logger, this.options.logMessageContent || false); + return new LongPollingTransport(this.httpClient, this.accessTokenFactory, this.logger, this.options.logMessageContent || false, this.options.withCredentials!); default: throw new Error(`Unknown transport: ${transport}.`); } diff --git a/src/SignalR/clients/ts/signalr/src/IHttpConnectionOptions.ts b/src/SignalR/clients/ts/signalr/src/IHttpConnectionOptions.ts index a980370b64..128872722b 100644 --- a/src/SignalR/clients/ts/signalr/src/IHttpConnectionOptions.ts +++ b/src/SignalR/clients/ts/signalr/src/IHttpConnectionOptions.ts @@ -53,4 +53,12 @@ export interface IHttpConnectionOptions { * @internal */ EventSource?: EventSourceConstructor; + + /** + * Default value is 'true'. + * This controls whether credentials such as cookies are sent in cross-site requests. + * + * Cookies are used by many load-balancers for sticky sessions which is required when your app is deployed with multiple servers. + */ + withCredentials?: boolean; } diff --git a/src/SignalR/clients/ts/signalr/src/LongPollingTransport.ts b/src/SignalR/clients/ts/signalr/src/LongPollingTransport.ts index 92194279a9..f7a7da1784 100644 --- a/src/SignalR/clients/ts/signalr/src/LongPollingTransport.ts +++ b/src/SignalR/clients/ts/signalr/src/LongPollingTransport.ts @@ -15,6 +15,7 @@ export class LongPollingTransport implements ITransport { private readonly accessTokenFactory: (() => string | Promise) | undefined; private readonly logger: ILogger; private readonly logMessageContent: boolean; + private readonly withCredentials: boolean; private readonly pollAbort: AbortController; private url?: string; @@ -30,12 +31,13 @@ export class LongPollingTransport implements ITransport { return this.pollAbort.aborted; } - constructor(httpClient: HttpClient, accessTokenFactory: (() => string | Promise) | undefined, logger: ILogger, logMessageContent: boolean) { + constructor(httpClient: HttpClient, accessTokenFactory: (() => string | Promise) | undefined, logger: ILogger, logMessageContent: boolean, withCredentials: boolean) { this.httpClient = httpClient; this.accessTokenFactory = accessTokenFactory; this.logger = logger; this.pollAbort = new AbortController(); this.logMessageContent = logMessageContent; + this.withCredentials = withCredentials; this.running = false; @@ -66,6 +68,7 @@ export class LongPollingTransport implements ITransport { abortSignal: this.pollAbort.signal, headers, timeout: 100000, + withCredentials: this.withCredentials, }; if (transferFormat === TransferFormat.Binary) { @@ -182,7 +185,7 @@ export class LongPollingTransport implements ITransport { if (!this.running) { return Promise.reject(new Error("Cannot send until the transport is connected")); } - return sendMessage(this.logger, "LongPolling", this.httpClient, this.url!, this.accessTokenFactory, data, this.logMessageContent); + return sendMessage(this.logger, "LongPolling", this.httpClient, this.url!, this.accessTokenFactory, data, this.logMessageContent, this.withCredentials); } public async stop(): Promise { @@ -204,6 +207,7 @@ export class LongPollingTransport implements ITransport { const deleteOptions: HttpRequest = { headers, + withCredentials: this.withCredentials, }; const token = await this.getAccessToken(); this.updateHeaderToken(deleteOptions, token); diff --git a/src/SignalR/clients/ts/signalr/src/ServerSentEventsTransport.ts b/src/SignalR/clients/ts/signalr/src/ServerSentEventsTransport.ts index ab8ae0f8cc..de4bf3e2b7 100644 --- a/src/SignalR/clients/ts/signalr/src/ServerSentEventsTransport.ts +++ b/src/SignalR/clients/ts/signalr/src/ServerSentEventsTransport.ts @@ -13,6 +13,7 @@ export class ServerSentEventsTransport implements ITransport { private readonly accessTokenFactory: (() => string | Promise) | undefined; private readonly logger: ILogger; private readonly logMessageContent: boolean; + private readonly withCredentials: boolean; private readonly eventSourceConstructor: EventSourceConstructor; private eventSource?: EventSource; private url?: string; @@ -21,11 +22,12 @@ export class ServerSentEventsTransport implements ITransport { public onclose: ((error?: Error) => void) | null; constructor(httpClient: HttpClient, accessTokenFactory: (() => string | Promise) | undefined, logger: ILogger, - logMessageContent: boolean, eventSourceConstructor: EventSourceConstructor) { + logMessageContent: boolean, eventSourceConstructor: EventSourceConstructor, withCredentials: boolean) { this.httpClient = httpClient; this.accessTokenFactory = accessTokenFactory; this.logger = logger; this.logMessageContent = logMessageContent; + this.withCredentials = withCredentials; this.eventSourceConstructor = eventSourceConstructor; this.onreceive = null; @@ -58,7 +60,7 @@ export class ServerSentEventsTransport implements ITransport { let eventSource: EventSource; if (Platform.isBrowser || Platform.isWebWorker) { - eventSource = new this.eventSourceConstructor(url, { withCredentials: true }); + eventSource = new this.eventSourceConstructor(url, { withCredentials: this.withCredentials }); } else { // Non-browser passes cookies via the dictionary const cookies = this.httpClient.getCookieString(url); @@ -68,7 +70,7 @@ export class ServerSentEventsTransport implements ITransport { const [name, value] = getUserAgentHeader(); headers[name] = value; - eventSource = new this.eventSourceConstructor(url, { withCredentials: true, headers } as EventSourceInit); + eventSource = new this.eventSourceConstructor(url, { withCredentials: this.withCredentials, headers } as EventSourceInit); } try { @@ -110,7 +112,7 @@ export class ServerSentEventsTransport implements ITransport { if (!this.eventSource) { return Promise.reject(new Error("Cannot send until the transport is connected")); } - return sendMessage(this.logger, "SSE", this.httpClient, this.url!, this.accessTokenFactory, data, this.logMessageContent); + return sendMessage(this.logger, "SSE", this.httpClient, this.url!, this.accessTokenFactory, data, this.logMessageContent, this.withCredentials); } public stop(): Promise { diff --git a/src/SignalR/clients/ts/signalr/src/Utils.ts b/src/SignalR/clients/ts/signalr/src/Utils.ts index df833c96d9..3f7318cd68 100644 --- a/src/SignalR/clients/ts/signalr/src/Utils.ts +++ b/src/SignalR/clients/ts/signalr/src/Utils.ts @@ -84,7 +84,8 @@ export function isArrayBuffer(val: any): val is ArrayBuffer { } /** @private */ -export async function sendMessage(logger: ILogger, transportName: string, httpClient: HttpClient, url: string, accessTokenFactory: (() => string | Promise) | undefined, content: string | ArrayBuffer, logMessageContent: boolean): Promise { +export async function sendMessage(logger: ILogger, transportName: string, httpClient: HttpClient, url: string, accessTokenFactory: (() => string | Promise) | undefined, + content: string | ArrayBuffer, logMessageContent: boolean, withCredentials: boolean): Promise { let headers = {}; if (accessTokenFactory) { const token = await accessTokenFactory(); @@ -105,6 +106,7 @@ export async function sendMessage(logger: ILogger, transportName: string, httpCl content, headers, responseType, + withCredentials, }); logger.log(LogLevel.Trace, `(${transportName} transport) request complete. Response status: ${response.statusCode}.`); diff --git a/src/SignalR/clients/ts/signalr/src/XhrHttpClient.ts b/src/SignalR/clients/ts/signalr/src/XhrHttpClient.ts index 3b27bdded5..41eba9c564 100644 --- a/src/SignalR/clients/ts/signalr/src/XhrHttpClient.ts +++ b/src/SignalR/clients/ts/signalr/src/XhrHttpClient.ts @@ -31,7 +31,7 @@ export class XhrHttpClient extends HttpClient { const xhr = new XMLHttpRequest(); xhr.open(request.method!, request.url!, true); - xhr.withCredentials = true; + xhr.withCredentials = request.withCredentials === undefined ? true : request.withCredentials; xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest"); // Explicitly setting the Content-Type header for React Native on Android platform. xhr.setRequestHeader("Content-Type", "text/plain;charset=UTF-8"); diff --git a/src/SignalR/clients/ts/signalr/tests/LongPollingTransport.test.ts b/src/SignalR/clients/ts/signalr/tests/LongPollingTransport.test.ts index ae99b42c95..853b3527ec 100644 --- a/src/SignalR/clients/ts/signalr/tests/LongPollingTransport.test.ts +++ b/src/SignalR/clients/ts/signalr/tests/LongPollingTransport.test.ts @@ -40,7 +40,7 @@ describe("LongPollingTransport", () => { } }) .on("DELETE", () => new HttpResponse(202)); - const transport = new LongPollingTransport(client, undefined, logger, false); + const transport = new LongPollingTransport(client, undefined, logger, false, true); await transport.connect("http://example.com", TransferFormat.Text); const stopPromise = transport.stop(); @@ -64,7 +64,7 @@ describe("LongPollingTransport", () => { return new HttpResponse(204); } }); - const transport = new LongPollingTransport(client, undefined, logger, false); + const transport = new LongPollingTransport(client, undefined, logger, false, true); const stopPromise = makeClosedPromise(transport); @@ -97,7 +97,7 @@ describe("LongPollingTransport", () => { return new HttpResponse(202); }); - const transport = new LongPollingTransport(httpClient, undefined, logger, false); + const transport = new LongPollingTransport(httpClient, undefined, logger, false, true); await transport.connect("http://tempuri.org", TransferFormat.Text); @@ -146,7 +146,7 @@ describe("LongPollingTransport", () => { return new HttpResponse(202); }); - const transport = new LongPollingTransport(httpClient, undefined, logger, false); + const transport = new LongPollingTransport(httpClient, undefined, logger, false, true); await transport.connect("http://tempuri.org", TransferFormat.Text); diff --git a/src/SignalR/clients/ts/signalr/tests/ServerSentEventsTransport.test.ts b/src/SignalR/clients/ts/signalr/tests/ServerSentEventsTransport.test.ts index f14a60b262..49dd872fa3 100644 --- a/src/SignalR/clients/ts/signalr/tests/ServerSentEventsTransport.test.ts +++ b/src/SignalR/clients/ts/signalr/tests/ServerSentEventsTransport.test.ts @@ -17,7 +17,7 @@ registerUnhandledRejectionHandler(); describe("ServerSentEventsTransport", () => { it("does not allow non-text formats", async () => { await VerifyLogger.run(async (logger) => { - const sse = new ServerSentEventsTransport(new TestHttpClient(), undefined, logger, true, TestEventSource); + const sse = new ServerSentEventsTransport(new TestHttpClient(), undefined, logger, true, TestEventSource, true); await expect(sse.connect("", TransferFormat.Binary)) .rejects @@ -27,7 +27,7 @@ describe("ServerSentEventsTransport", () => { it("connect waits for EventSource to be connected", async () => { await VerifyLogger.run(async (logger) => { - const sse = new ServerSentEventsTransport(new TestHttpClient(), undefined, logger, true, TestEventSource); + const sse = new ServerSentEventsTransport(new TestHttpClient(), undefined, logger, true, TestEventSource, true); let connectComplete: boolean = false; const connectPromise = (async () => { @@ -169,7 +169,7 @@ describe("ServerSentEventsTransport", () => { it("send throws if not connected", async () => { await VerifyLogger.run(async (logger) => { - const sse = new ServerSentEventsTransport(new TestHttpClient(), undefined, logger, true, TestEventSource); + const sse = new ServerSentEventsTransport(new TestHttpClient(), undefined, logger, true, TestEventSource, true); await expect(sse.send("")) .rejects @@ -224,7 +224,7 @@ describe("ServerSentEventsTransport", () => { }); async function createAndStartSSE(logger: ILogger, url?: string, accessTokenFactory?: (() => string | Promise), httpClient?: HttpClient): Promise { - const sse = new ServerSentEventsTransport(httpClient || new TestHttpClient(), accessTokenFactory, logger, true, TestEventSource); + const sse = new ServerSentEventsTransport(httpClient || new TestHttpClient(), accessTokenFactory, logger, true, TestEventSource, true); const connectPromise = sse.connect(url || "http://example.com", TransferFormat.Text); await TestEventSource.eventSource.openSet;