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

Commit

Permalink
Add error to negotiate (#2998)
Browse files Browse the repository at this point in the history
  • Loading branch information
BrennanConroy committed Sep 28, 2018
1 parent bdc2d9f commit 1f91b52
Show file tree
Hide file tree
Showing 10 changed files with 177 additions and 31 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,13 @@ public HubConnection(String url, Transport transport, Logger logger, boolean ski
};
}

private NegotiateResponse handleNegotiate() throws IOException {
private NegotiateResponse handleNegotiate() throws IOException, HubException {
accessToken = (negotiateResponse == null) ? null : negotiateResponse.getAccessToken();
negotiateResponse = Negotiate.processNegotiate(url, accessToken);

if (negotiateResponse.getError() != null) {
throw new HubException(negotiateResponse.getError());
}
if (negotiateResponse.getConnectionId() != null) {
if (url.contains("?")) {
url = url + "&id=" + negotiateResponse.getConnectionId();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,74 @@

package com.microsoft.aspnet.signalr;

import java.io.IOException;
import java.io.StringReader;
import java.util.HashSet;
import java.util.Set;

import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.stream.JsonReader;

class NegotiateResponse {
private String connectionId;
private Set<String> availableTransports = new HashSet<>();
private String redirectUrl;
private String accessToken;
private JsonParser jsonParser = new JsonParser();

public NegotiateResponse(String negotiatePayload) {
JsonObject negotiateResponse = jsonParser.parse(negotiatePayload).getAsJsonObject();
if (negotiateResponse.has("url")) {
this.redirectUrl = negotiateResponse.get("url").getAsString();
if (negotiateResponse.has("accessToken")) {
this.accessToken = negotiateResponse.get("accessToken").getAsString();
private String error;

public NegotiateResponse(String negotiatePayload) throws IOException {
JsonReader reader = new JsonReader(new StringReader(negotiatePayload));
reader.beginObject();

do {
String name = reader.nextName();
switch (name) {
case "error":
this.error = reader.nextString();
break;
case "url":
this.redirectUrl = reader.nextString();
break;
case "accessToken":
this.accessToken = reader.nextString();
break;
case "availableTransports":
reader.beginArray();
while (reader.hasNext()) {
reader.beginObject();
while (reader.hasNext()) {
String transport = null;
String property = reader.nextName();
switch (property) {
case "transport":
transport = reader.nextString();
break;
case "transferFormats":
// transfer formats aren't supported currently
reader.skipValue();
break;
default:
// Skip unknown property, allows new clients to still work with old protocols
reader.skipValue();
break;
}
this.availableTransports.add(transport);
}
reader.endObject();
}
reader.endArray();
break;
case "connectionId":
this.connectionId = reader.nextString();
break;
default:
// Skip unknown property, allows new clients to still work with old protocols
reader.skipValue();
break;
}
return;
}
this.connectionId = negotiateResponse.get("connectionId").getAsString();
JsonArray transports = (JsonArray) negotiateResponse.get("availableTransports");
for (int i = 0; i < transports.size(); i++) {
availableTransports.add(transports.get(i).getAsJsonObject().get("transport").getAsString());
}
} while (reader.hasNext());

reader.endObject();
reader.close();
}

public String getConnectionId() {
Expand All @@ -48,4 +88,8 @@ public String getRedirectUrl() {
public String getAccessToken() {
return accessToken;
}

public String getError() {
return error;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@

import static org.junit.jupiter.api.Assertions.*;

import java.io.IOException;

import org.junit.jupiter.api.Test;


class NegotiateResponseTest {

@Test
public void VerifyNegotiateResponse() {
public void VerifyNegotiateResponse() throws IOException {
String stringNegotiateResponse = "{\"connectionId\":\"bVOiRPG8-6YiJ6d7ZcTOVQ\",\"" +
"availableTransports\":[{\"transport\":\"WebSockets\",\"transferFormats\":[\"Text\",\"Binary\"]}," +
"{\"transport\":\"ServerSentEvents\",\"transferFormats\":[\"Text\"]}," +
Expand All @@ -26,7 +27,7 @@ public void VerifyNegotiateResponse() {
}

@Test
public void VerifyRedirectNegotiateResponse() {
public void VerifyRedirectNegotiateResponse() throws IOException {
String stringNegotiateResponse = "{\"url\":\"www.example.com\"," +
"\"accessToken\":\"some_access_token\"," +
"\"availableTransports\":[]}";
Expand All @@ -37,4 +38,20 @@ public void VerifyRedirectNegotiateResponse() {
assertEquals("www.example.com", negotiateResponse.getRedirectUrl());
assertNull(negotiateResponse.getConnectionId());
}

@Test
public void NegotiateResponseIgnoresExtraProperties() throws IOException {
String stringNegotiateResponse = "{\"connectionId\":\"bVOiRPG8-6YiJ6d7ZcTOVQ\"," +
"\"extra\":\"something\"}";
NegotiateResponse negotiateResponse = new NegotiateResponse(stringNegotiateResponse);
assertEquals("bVOiRPG8-6YiJ6d7ZcTOVQ", negotiateResponse.getConnectionId());
}

@Test
public void NegotiateResponseIgnoresExtraComplexProperties() throws IOException {
String stringNegotiateResponse = "{\"connectionId\":\"bVOiRPG8-6YiJ6d7ZcTOVQ\"," +
"\"extra\":[\"something\"]}";
NegotiateResponse negotiateResponse = new NegotiateResponse(stringNegotiateResponse);
assertEquals("bVOiRPG8-6YiJ6d7ZcTOVQ", negotiateResponse.getConnectionId());
}
}
5 changes: 5 additions & 0 deletions clients/ts/signalr/src/HttpConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface INegotiateResponse {
availableTransports?: IAvailableTransport[];
url?: string;
accessToken?: string;
error?: string;
}

/** @private */
Expand Down Expand Up @@ -169,6 +170,10 @@ export class HttpConnection implements IConnection {
return;
}

if (negotiateResponse.error) {
throw Error(negotiateResponse.error);
}

if ((negotiateResponse as any).ProtocolVersion) {
throw Error("Detected a connection attempt to an ASP.NET SignalR Server. This client only supports connecting to an ASP.NET Core SignalR Server. See https://aka.ms/signalr-core-differences for details.");
}
Expand Down
20 changes: 20 additions & 0 deletions clients/ts/signalr/tests/HttpConnection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,26 @@ describe("HttpConnection", () => {
});
});

it("throws error if negotiate response has error", async () => {
await VerifyLogger.run(async (logger) => {
const httpClient = new TestHttpClient()
.on("POST", /negotiate$/, () => ({ error: "Negotiate error." }));

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

const connection = new HttpConnection("http://tempuri.org", options);
await expect(connection.start(TransferFormat.Text))
.rejects
.toThrow("Negotiate error.");
},
"Failed to start the connection: Error: Negotiate error.");
});

it("authorization header removed when token factory returns null and using LongPolling", async () => {
await VerifyLogger.run(async (logger) => {
const availableTransport = { transport: "LongPolling", transferFormats: ["Text"] };
Expand Down
21 changes: 17 additions & 4 deletions specs/TransportProtocols.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ Throughout this document, the term `[endpoint-base]` is used to refer to the rou

## `POST [endpoint-base]/negotiate` request

The `POST [endpoint-base]/negotiate` request is used to establish a connection between the client and the server. The content type of the response is `application/json`. The response to the `POST [endpoint-base]/negotiate` request contains one of two types of responses:
The `POST [endpoint-base]/negotiate` request is used to establish a connection between the client and the server. The content type of the response is `application/json`. The response to the `POST [endpoint-base]/negotiate` request contains one of three types of responses:

1. A response that contains the `id` which will be used to identify the connection on the server and the list of the transports supported by the server.
1. A response that contains the `connectionId` which will be used to identify the connection on the server and the list of the transports supported by the server.

```
{
"id":"807809a5-31bf-470d-9e23-afaee35d8a0d",
"connectionId":"807809a5-31bf-470d-9e23-afaee35d8a0d",
"availableTransports":[
{
"transport": "WebSockets",
Expand All @@ -44,7 +44,7 @@ The `POST [endpoint-base]/negotiate` request is used to establish a connection b

The payload returned from this endpoint provides the following data:

* The `id` which is **required** by the Long Polling and Server-Sent Events transports (in order to correlate sends and receives).
* The `connectionId` which is **required** by the Long Polling and Server-Sent Events transports (in order to correlate sends and receives).
* The `availableTransports` list which describes the transports the server supports. For each transport, the name of the transport (`transport`) is listed, as is a list of "transfer formats" supported by the transport (`transferFormats`)


Expand All @@ -62,6 +62,19 @@ The `POST [endpoint-base]/negotiate` request is used to establish a connection b
* The `url` which is the URL the client should connect to.
* The `accessToken` which is an optional bearer token for accessing the specified url.


3. A response that contains an `error` which should stop the connection attempt.

```
{
"error": "This connection is not allowed."
}
```

The payload returned from this endpoint provides the following data:

* The `error` that gives details about why the negotiate failed.

## Transfer Formats

ASP.NET Endpoints support two different transfer formats: `Text` and `Binary`. `Text` refers to UTF-8 text, and `Binary` refers to any arbitrary binary data. The transfer format serves two purposes. First, in the WebSockets transport, it is used to determine if `Text` or `Binary` WebSocket frames should be used to carry data. This is useful in debugging as most browser Dev Tools only show the content of `Text` frames. When using a text-based protocol like JSON, it is preferable for the WebSockets transport to use `Text` frames. How a client/server indicate the transfer format currently being used is implementation-defined.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,9 @@ internal HttpConnection(HttpConnectionOptions httpConnectionOptions, ILoggerFact
/// A connection cannot be restarted after it has stopped. To restart a connection
/// a new instance should be created using the same options.
/// </remarks>
public async Task StartAsync(CancellationToken cancellationToken = default)
public Task StartAsync(CancellationToken cancellationToken = default)
{
await StartAsync(TransferFormat.Binary, cancellationToken);
return StartAsync(TransferFormat.Binary, cancellationToken);
}

/// <summary>
Expand Down Expand Up @@ -428,6 +428,10 @@ private async Task<NegotiationResponse> NegotiateAsync(Uri url, HttpClient httpC
{
negotiateResponse = NegotiateProtocol.ParseResponse(responseStream);
}
if (!string.IsNullOrEmpty(negotiateResponse.Error))
{
throw new Exception(negotiateResponse.Error);
}
Log.ConnectionEstablished(_logger, negotiateResponse.ConnectionId);
return negotiateResponse;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public static class NegotiateProtocol
private const string AvailableTransportsPropertyName = "availableTransports";
private const string TransportPropertyName = "transport";
private const string TransferFormatsPropertyName = "transferFormats";
private const string ErrorPropertyName = "error";
// Used to detect ASP.NET SignalR Server connection attempt
private const string ProtocolVersionPropertyName = "ProtocolVersion";

Expand Down Expand Up @@ -99,6 +100,7 @@ public static NegotiationResponse ParseResponse(Stream content)
string url = null;
string accessToken = null;
List<AvailableTransport> availableTransports = null;
string error = null;

var completed = false;
while (!completed && JsonUtils.CheckRead(reader))
Expand Down Expand Up @@ -136,6 +138,9 @@ public static NegotiationResponse ParseResponse(Stream content)
}
}
break;
case ErrorPropertyName:
error = JsonUtils.ReadAsString(reader, ErrorPropertyName);
break;
case ProtocolVersionPropertyName:
throw new InvalidOperationException("Detected a connection attempt to an ASP.NET SignalR Server. This client only supports connecting to an ASP.NET Core SignalR Server. See https://aka.ms/signalr-core-differences for details.");
default:
Expand All @@ -151,9 +156,9 @@ public static NegotiationResponse ParseResponse(Stream content)
}
}

if (url == null)
if (url == null && error == null)
{
// if url isn't specified, connectionId and available transports are required
// if url isn't specified or there isn't an error, connectionId and available transports are required
if (connectionId == null)
{
throw new InvalidDataException($"Missing required property '{ConnectionIdPropertyName}'.");
Expand All @@ -170,7 +175,8 @@ public static NegotiationResponse ParseResponse(Stream content)
ConnectionId = connectionId,
Url = url,
AccessToken = accessToken,
AvailableTransports = availableTransports
AvailableTransports = availableTransports,
Error = error,
};
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ public class NegotiationResponse
public string AccessToken { get; set; }
public string ConnectionId { get; set; }
public IList<AvailableTransport> AvailableTransports { get; set; }
public string Error { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Connections;
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.AspNetCore.Http.Connections.Client;
using Microsoft.AspNetCore.Http.Connections.Client.Internal;
using Microsoft.AspNetCore.SignalR.Tests;
using Microsoft.Extensions.Logging.Testing;
using Moq;
using Newtonsoft.Json;
using Xunit;
Expand Down Expand Up @@ -380,6 +382,37 @@ public async Task StartSkipsOverTransportsThatDoNotSupportTheRequredTransferForm
});
}

[Fact]
public async Task NegotiateThatReturnsErrorThrowsFromStart()
{
bool ExpectedError(WriteContext writeContext)
{
return writeContext.LoggerName == typeof(HttpConnection).FullName &&
writeContext.EventId.Name == "ErrorWithNegotiation";
}

var testHttpHandler = new TestHttpMessageHandler(autoNegotiate: false);
testHttpHandler.OnNegotiate((request, cancellationToken) =>
{
return ResponseUtils.CreateResponse(HttpStatusCode.OK,
JsonConvert.SerializeObject(new
{
error = "Test error."
}));
});

using (var noErrorScope = new VerifyNoErrorsScope(expectedErrorsFilter: ExpectedError))
{
await WithConnectionAsync(
CreateConnection(testHttpHandler, loggerFactory: noErrorScope.LoggerFactory),
async (connection) =>
{
var exception = await Assert.ThrowsAsync<Exception>(() => connection.StartAsync(TransferFormat.Text).OrTimeout());
Assert.Equal("Test error.", exception.Message);
});
}
}

private async Task RunInvalidNegotiateResponseTest<TException>(string negotiatePayload, string expectedExceptionMessage) where TException : Exception
{
var testHttpHandler = new TestHttpMessageHandler(autoNegotiate: false);
Expand Down

0 comments on commit 1f91b52

Please sign in to comment.