Skip to content
This repository has been archived by the owner on Dec 18, 2018. It is now read-only.

Added support for negotiate response to redirect the client to anothe… #2082

Merged
merged 1 commit into from
Apr 18, 2018
Merged
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
147 changes: 146 additions & 1 deletion clients/ts/signalr/spec/HttpConnection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,10 +267,11 @@ describe("HttpConnection", () => {
}
});

it("does not send negotiate request if WebSockets transport requested explicitly", async (done) => {
it("does not send negotiate request if WebSockets transport requested explicitly and skipNegotiation is true", async (done) => {
const options: IHttpConnectionOptions = {
...commonOptions,
httpClient: new TestHttpClient(),
skipNegotiation: true,
transport: HttpTransportType.WebSockets,
} as IHttpConnectionOptions;

Expand All @@ -287,6 +288,150 @@ describe("HttpConnection", () => {
}
});

it("does not start non WebSockets transport requested explicitly and skipNegotiation is true", async (done) => {
const options: IHttpConnectionOptions = {
...commonOptions,
httpClient: new TestHttpClient(),
skipNegotiation: true,
transport: HttpTransportType.LongPolling,
} as IHttpConnectionOptions;

const connection = new HttpConnection("http://tempuri.org", options);
try {
await connection.start(TransferFormat.Text);
fail();
done();
} catch (e) {
// WebSocket is created when the transport is connecting which happens after
// negotiate request would be sent. No better/easier way to test this.
expect(e.message).toBe("Negotiation can only be skipped when using the WebSocket transport directly.");
done();
}
});

it("redirects to url when negotiate returns it", async (done) => {
let firstNegotiate = true;
let firstPoll = true;
const httpClient = new TestHttpClient()
.on("POST", /negotiate$/, (r) => {
if (firstNegotiate) {
firstNegotiate = false;
return { url: "https://another.domain.url/chat" };
}
return {
availableTransports: [{ transport: "LongPolling", transferFormats: ["Text"] }],
connectionId: "0rge0d00-0040-0030-0r00-000q00r00e00",
};
})
.on("GET", (r) => {
if (firstPoll) {
firstPoll = false;
return "";
}
return new HttpResponse(204, "No Content", "");
});

const options: IHttpConnectionOptions = {
...commonOptions,
httpClient,
transport: HttpTransportType.LongPolling,
} as IHttpConnectionOptions;

try {
const connection = new HttpConnection("http://tempuri.org", options);
await connection.start(TransferFormat.Text);
} catch (e) {
fail(e);
done();
}

expect(httpClient.sentRequests.length).toBe(4);
expect(httpClient.sentRequests[0].url).toBe("http://tempuri.org/negotiate");
expect(httpClient.sentRequests[1].url).toBe("https://another.domain.url/chat/negotiate");
expect(httpClient.sentRequests[2].url).toMatch(/^https:\/\/another\.domain\.url\/chat\?id=0rge0d00-0040-0030-0r00-000q00r00e00/i);
expect(httpClient.sentRequests[3].url).toMatch(/^https:\/\/another\.domain\.url\/chat\?id=0rge0d00-0040-0030-0r00-000q00r00e00/i);
done();
});

it("fails to start if negotiate redirects more than 100 times", async (done) => {
const httpClient = new TestHttpClient()
.on("POST", /negotiate$/, (r) => ({ url: "https://another.domain.url/chat" }));

const options: IHttpConnectionOptions = {
...commonOptions,
httpClient,
transport: HttpTransportType.LongPolling,
} as IHttpConnectionOptions;

try {
const connection = new HttpConnection("http://tempuri.org", options);
await connection.start(TransferFormat.Text);
fail();
} catch (e) {
expect(e.message).toBe("Negotiate redirection limit exceeded.");
done();
}
});

it("redirects to url when negotiate returns it with access token", async (done) => {
let firstNegotiate = true;
let firstPoll = true;
const httpClient = new TestHttpClient()
.on("POST", /negotiate$/, (r) => {
if (firstNegotiate) {
firstNegotiate = false;

if (r.headers && r.headers.Authorization !== "Bearer firstSecret") {
return new HttpResponse(401, "Unauthorized", "");
}

return { url: "https://another.domain.url/chat", accessToken: "secondSecret" };
}

if (r.headers && r.headers.Authorization !== "Bearer secondSecret") {
return new HttpResponse(401, "Unauthorized", "");
}

return {
availableTransports: [{ transport: "LongPolling", transferFormats: ["Text"] }],
connectionId: "0rge0d00-0040-0030-0r00-000q00r00e00",
};
})
.on("GET", (r) => {
if (r.headers && r.headers.Authorization !== "Bearer secondSecret") {
return new HttpResponse(401, "Unauthorized", "");
}

if (firstPoll) {
firstPoll = false;
return "";
}
return new HttpResponse(204, "No Content", "");
});

const options: IHttpConnectionOptions = {
...commonOptions,
accessTokenFactory: () => "firstSecret",
httpClient,
transport: HttpTransportType.LongPolling,
} as IHttpConnectionOptions;

try {
const connection = new HttpConnection("http://tempuri.org", options);
await connection.start(TransferFormat.Text);
} catch (e) {
fail(e);
done();
}

expect(httpClient.sentRequests.length).toBe(4);
expect(httpClient.sentRequests[0].url).toBe("http://tempuri.org/negotiate");
expect(httpClient.sentRequests[1].url).toBe("https://another.domain.url/chat/negotiate");
expect(httpClient.sentRequests[2].url).toMatch(/^https:\/\/another\.domain\.url\/chat\?id=0rge0d00-0040-0030-0r00-000q00r00e00/i);
expect(httpClient.sentRequests[3].url).toMatch(/^https:\/\/another\.domain\.url\/chat\?id=0rge0d00-0040-0030-0r00-000q00r00e00/i);
done();
});

it("authorization header removed when token factory returns null and using LongPolling", async (done) => {
const availableTransport = { transport: "LongPolling", transferFormats: ["Text"] };

Expand Down
5 changes: 4 additions & 1 deletion clients/ts/signalr/spec/TestHttpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,18 @@ export type TestHttpHandler = (request: HttpRequest, next?: (request: HttpReques

export class TestHttpClient extends HttpClient {
private handler: (request: HttpRequest) => Promise<HttpResponse>;
public sentRequests: HttpRequest[];

constructor() {
super();
this.sentRequests = [];
this.handler = (request: HttpRequest) =>
Promise.reject(`Request has no handler: ${request.method} ${request.url}`);

}

public send(request: HttpRequest): Promise<HttpResponse> {
this.sentRequests.push(request);
return this.handler(request);
}

Expand Down Expand Up @@ -59,7 +62,7 @@ export class TestHttpClient extends HttpClient {
if (typeof val === "string") {
// string payload
return new HttpResponse(200, "OK", val);
} else if(typeof val === "object" && val.statusCode) {
} else if (typeof val === "object" && val.statusCode) {
// HttpResponse payload
return val as HttpResponse;
} else {
Expand Down
110 changes: 75 additions & 35 deletions clients/ts/signalr/src/HttpConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface IHttpConnectionOptions {
logger?: ILogger | LogLevel;
accessTokenFactory?: () => string | Promise<string>;
logMessageContent?: boolean;
skipNegotiation?: boolean;
}

const enum ConnectionState {
Expand All @@ -27,24 +28,27 @@ const enum ConnectionState {
interface INegotiateResponse {
connectionId: string;
availableTransports: IAvailableTransport[];
url: string;
accessToken: string;
}

interface IAvailableTransport {
transport: keyof typeof HttpTransportType;
transferFormats: Array<keyof typeof TransferFormat>;
}

const MAX_REDIRECTS = 100;

export class HttpConnection implements IConnection {
private connectionState: ConnectionState;
private baseUrl: string;
private url: string;
private readonly httpClient: HttpClient;
private readonly logger: ILogger;
private readonly options: IHttpConnectionOptions;
private transport: ITransport;
private connectionId: string;
private startPromise: Promise<void>;
private stopError?: Error;
private accessTokenFactory?: () => string | Promise<string>;

public readonly features: any = {};
public onreceive: (data: string | ArrayBuffer) => void;
Expand Down Expand Up @@ -110,29 +114,53 @@ export class HttpConnection implements IConnection {
}

private async startInternal(transferFormat: TransferFormat): Promise<void> {
// Store the original base url and the access token factory since they may change
// as part of negotiating
let url = this.baseUrl;
this.accessTokenFactory = this.options.accessTokenFactory;

try {
if (this.options.transport === HttpTransportType.WebSockets) {
// No need to add a connection ID in this case
this.url = this.baseUrl;
this.transport = this.constructTransport(HttpTransportType.WebSockets);
// We should just call connect directly in this case.
// No fallback or negotiate in this case.
await this.transport.connect(this.url, transferFormat);
if (this.options.skipNegotiation) {
if (this.options.transport === HttpTransportType.WebSockets) {
// No need to add a connection ID in this case
this.transport = this.constructTransport(HttpTransportType.WebSockets);
// We should just call connect directly in this case.
// No fallback or negotiate in this case.
await this.transport.connect(url, transferFormat);
} else {
throw Error("Negotiation can only be skipped when using the WebSocket transport directly.");
}
} else {
const token = await this.options.accessTokenFactory();
let headers;
if (token) {
headers = {
["Authorization"]: `Bearer ${token}`,
};
let negotiateResponse: INegotiateResponse = null;
let redirects = 0;

do {
negotiateResponse = await this.getNegotiationResponse(url);
// the user tries to stop the connection when it is being started
if (this.connectionState === ConnectionState.Disconnected) {
return;
}

if (negotiateResponse.url) {
url = negotiateResponse.url;
}

if (negotiateResponse.accessToken) {
// Replace the current access token factory with one that uses
// the returned access token
const accessToken = negotiateResponse.accessToken;
this.accessTokenFactory = () => accessToken;
}

redirects++;
}
while (negotiateResponse.url && redirects < MAX_REDIRECTS);

const negotiateResponse = await this.getNegotiationResponse(headers);
// the user tries to stop the the connection when it is being started
if (this.connectionState === ConnectionState.Disconnected) {
return;
if (redirects === MAX_REDIRECTS && negotiateResponse.url) {
throw Error("Negotiate redirection limit exceeded.");
}
await this.createTransport(this.options.transport, negotiateResponse, transferFormat, headers);

await this.createTransport(url, this.options.transport, negotiateResponse, transferFormat);
}

if (this.transport instanceof LongPollingTransport) {
Expand All @@ -153,32 +181,44 @@ export class HttpConnection implements IConnection {
}
}

private async getNegotiationResponse(headers: any): Promise<INegotiateResponse> {
const negotiateUrl = this.resolveNegotiateUrl(this.baseUrl);
private async getNegotiationResponse(url: string): Promise<INegotiateResponse> {
const token = await this.accessTokenFactory();
let headers;
if (token) {
headers = {
["Authorization"]: `Bearer ${token}`,
};
}

const negotiateUrl = this.resolveNegotiateUrl(url);
this.logger.log(LogLevel.Debug, `Sending negotiation request: ${negotiateUrl}`);
try {
const response = await this.httpClient.post(negotiateUrl, {
content: "",
headers,
});
return JSON.parse(response.content as string);

if (response.statusCode !== 200) {
throw Error(`Unexpected status code returned from negotiate ${response.statusCode}`);
}

return JSON.parse(response.content as string) as INegotiateResponse;
} catch (e) {
this.logger.log(LogLevel.Error, "Failed to complete negotiation with the server: " + e);
throw e;
}
}

private updateConnectionId(negotiateResponse: INegotiateResponse) {
this.connectionId = negotiateResponse.connectionId;
this.url = this.baseUrl + (this.baseUrl.indexOf("?") === -1 ? "?" : "&") + `id=${this.connectionId}`;
private createConnectUrl(url: string, connectionId: string) {
return url + (url.indexOf("?") === -1 ? "?" : "&") + `id=${connectionId}`;
}

private async createTransport(requestedTransport: HttpTransportType | ITransport, negotiateResponse: INegotiateResponse, requestedTransferFormat: TransferFormat, headers: any): Promise<void> {
this.updateConnectionId(negotiateResponse);
private async createTransport(url: string, requestedTransport: HttpTransportType | ITransport, negotiateResponse: INegotiateResponse, requestedTransferFormat: TransferFormat): Promise<void> {
let connectUrl = this.createConnectUrl(url, negotiateResponse.connectionId);
if (this.isITransport(requestedTransport)) {
this.logger.log(LogLevel.Debug, "Connection was provided an instance of ITransport, using that directly.");
this.transport = requestedTransport;
await this.transport.connect(this.url, requestedTransferFormat);
await this.transport.connect(connectUrl, requestedTransferFormat);

// only change the state if we were connecting to not overwrite
// the state if the connection is already marked as Disconnected
Expand All @@ -193,11 +233,11 @@ export class HttpConnection implements IConnection {
if (typeof transport === "number") {
this.transport = this.constructTransport(transport);
if (negotiateResponse.connectionId === null) {
negotiateResponse = await this.getNegotiationResponse(headers);
this.updateConnectionId(negotiateResponse);
negotiateResponse = await this.getNegotiationResponse(url);
connectUrl = this.createConnectUrl(url, negotiateResponse.connectionId);
}
try {
await this.transport.connect(this.url, requestedTransferFormat);
await this.transport.connect(connectUrl, requestedTransferFormat);
this.changeState(ConnectionState.Connecting, ConnectionState.Connected);
return;
} catch (ex) {
Expand All @@ -214,11 +254,11 @@ export class HttpConnection implements IConnection {
private constructTransport(transport: HttpTransportType) {
switch (transport) {
case HttpTransportType.WebSockets:
return new WebSocketTransport(this.options.accessTokenFactory, this.logger, this.options.logMessageContent);
return new WebSocketTransport(this.accessTokenFactory, this.logger, this.options.logMessageContent);
case HttpTransportType.ServerSentEvents:
return new ServerSentEventsTransport(this.httpClient, this.options.accessTokenFactory, this.logger, this.options.logMessageContent);
return new ServerSentEventsTransport(this.httpClient, this.accessTokenFactory, this.logger, this.options.logMessageContent);
case HttpTransportType.LongPolling:
return new LongPollingTransport(this.httpClient, this.options.accessTokenFactory, this.logger, this.options.logMessageContent);
return new LongPollingTransport(this.httpClient, this.accessTokenFactory, this.logger, this.options.logMessageContent);
default:
throw new Error(`Unknown transport: ${transport}.`);
}
Expand Down
Loading