Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use access token to authenticate WebSocket connections #200

Merged
merged 4 commits into from
Jan 30, 2017
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
85 changes: 64 additions & 21 deletions src/sidebar/streamer.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use strict';

var queryString = require('query-string');
var uuid = require('node-uuid');

var events = require('./events');
Expand All @@ -19,8 +20,8 @@ var Socket = require('./websocket');
* @param settings - Application settings
*/
// @ngInject
function Streamer($rootScope, annotationMapper, annotationUI, groups, session,
settings) {
function Streamer($rootScope, annotationMapper, annotationUI, auth,
groups, session, settings) {
// The randomly generated session UUID
var clientId = uuid.v4();

Expand Down Expand Up @@ -149,41 +150,83 @@ function Streamer($rootScope, annotationMapper, annotationUI, groups, session,
}

var _connect = function () {
var url = settings.websocketUrl;

// If we have no URL configured, don't do anything.
if (!url) {
return;
if (!settings.websocketUrl) {
return Promise.resolve();
}

socket = new Socket(url);
return auth.tokenGetter().then(function (token) {
var url;
if (token) {
// Include the access token in the URL via a query param. This method
// is used to send credentials because the `WebSocket` constructor does
// not support setting the `Authorization` header directly as we do for
// other API requests.
var parsedURL = new URL(settings.websocketUrl);
var queryParams = queryString.parse(parsedURL.search);
queryParams.access_token = token;
parsedURL.search = queryString.stringify(queryParams);
url = parsedURL.toString();
} else {
url = settings.websocketUrl;
}

socket = new Socket(url);

socket.on('open', sendClientConfig);
socket.on('error', handleSocketOnError);
socket.on('message', handleSocketOnMessage);
socket.on('open', sendClientConfig);
socket.on('error', handleSocketOnError);
socket.on('message', handleSocketOnMessage);

// Configure the client ID
setConfig('client-id', {
messageType: 'client_id',
value: clientId,
// Configure the client ID
setConfig('client-id', {
messageType: 'client_id',
value: clientId,
});

// Send a "whoami" message. The server will respond with a "whoyouare"
// reply which is useful for verifying that authentication worked as
// expected.
setConfig('auth-check', {
type: 'whoami',
id: 1,
});
}).catch(function (err) {
console.error('Failed to fetch token for WebSocket authentication', err);
});
};

var connect = function () {
/**
* Connect to the Hypothesis real time update service.
*
* If the service has already connected this does nothing.
*
* @return {Promise} Promise which resolves once the WebSocket connection
* process has started.
*/
function connect() {
if (socket) {
return;
return Promise.resolve();
}

_connect();
};
return _connect();
}

var reconnect = function () {
/**
* Connect to the Hypothesis real time update service.
*
* If the service has already connected this closes the existing connection
* and reconnects.
*
* @return {Promise} Promise which resolves once the WebSocket connection
* process has started.
*/
function reconnect() {
if (socket) {
socket.close();
}

_connect();
};
return _connect();
}

function applyPendingUpdates() {
var updates = Object.values(pendingUpdates);
Expand Down
149 changes: 106 additions & 43 deletions src/sidebar/test/streamer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,10 @@ var fixtures = {
// the most recently created FakeSocket instance
var fakeWebSocket = null;

function FakeSocket() {
function FakeSocket(url) {
fakeWebSocket = this; // eslint-disable-line consistent-this

this.url = url;
this.messages = [];
this.didClose = false;

Expand All @@ -67,6 +68,7 @@ inherits(FakeSocket, EventEmitter);
describe('Streamer', function () {
var fakeAnnotationMapper;
var fakeAnnotationUI;
var fakeAuth;
var fakeGroups;
var fakeRootScope;
var fakeSession;
Expand All @@ -79,6 +81,7 @@ describe('Streamer', function () {
fakeRootScope,
fakeAnnotationMapper,
fakeAnnotationUI,
fakeAuth,
fakeGroups,
fakeSession,
fakeSettings
Expand All @@ -88,6 +91,12 @@ describe('Streamer', function () {
beforeEach(function () {
var emitter = new EventEmitter();

fakeAuth = {
tokenGetter: function () {
return Promise.resolve('dummy-access-token');
},
};

fakeRootScope = {
$apply: function (callback) {
callback();
Expand Down Expand Up @@ -132,8 +141,10 @@ describe('Streamer', function () {
it('should not create a websocket connection if websocketUrl is not provided', function () {
fakeSettings = {};
createDefaultStreamer();
activeStreamer.connect();
assert.isNull(fakeWebSocket);

return activeStreamer.connect().then(function () {
assert.isNull(fakeWebSocket);
});
});

it('should not create a websocket connection', function () {
Expand All @@ -146,46 +157,93 @@ describe('Streamer', function () {
assert.ok(activeStreamer.clientId);
});

it('should send the client ID on connection', function () {
it('should send the client ID after connecting', function () {
createDefaultStreamer();
return activeStreamer.connect().then(function () {
var clientIdMsg = fakeWebSocket.messages.find(function (msg) {
return msg.messageType === 'client_id';
});
assert.ok(clientIdMsg);
assert.equal(clientIdMsg.value, activeStreamer.clientId);
});
});

it('should request the logged-in user ID after connecting', function () {
createDefaultStreamer();
activeStreamer.connect();
assert.equal(fakeWebSocket.messages.length, 1);
assert.equal(fakeWebSocket.messages[0].messageType, 'client_id');
assert.equal(fakeWebSocket.messages[0].value, activeStreamer.clientId);
return activeStreamer.connect().then(function () {
var whoamiMsg = fakeWebSocket.messages.find(function (msg) {
return msg.type === 'whoami';
});
assert.ok(whoamiMsg);
});
});

describe('#connect()', function () {
it('should create a websocket connection', function () {
createDefaultStreamer();
activeStreamer.connect();
assert.ok(fakeWebSocket);
return activeStreamer.connect().then(function () {
assert.ok(fakeWebSocket);
});
});

it('should include credentials in the URL if the client has an access token', function () {
createDefaultStreamer();
return activeStreamer.connect().then(function () {
assert.equal(fakeWebSocket.url, 'ws://example.com/ws?access_token=dummy-access-token');
});
});

it('should preserve query params when adding access token to URL', function () {
fakeSettings.websocketUrl = 'ws://example.com/ws?foo=bar';
createDefaultStreamer();
return activeStreamer.connect().then(function () {
assert.equal(fakeWebSocket.url, 'ws://example.com/ws?access_token=dummy-access-token&foo=bar');
});
});

it('should not include credentials in the URL if the client has no access token', function () {
fakeAuth.tokenGetter = function () {
return Promise.resolve(null);
};

createDefaultStreamer();
return activeStreamer.connect().then(function () {
assert.equal(fakeWebSocket.url, 'ws://example.com/ws');
});
});

it('should not close any existing socket', function () {
var oldWebSocket;
createDefaultStreamer();
activeStreamer.connect();
var oldWebSocket = fakeWebSocket;
activeStreamer.connect();
assert.ok(!oldWebSocket.didClose);
assert.ok(!fakeWebSocket.didClose);
return activeStreamer.connect().then(function () {
oldWebSocket = fakeWebSocket;
return activeStreamer.connect();
}).then(function () {
assert.ok(!oldWebSocket.didClose);
assert.ok(!fakeWebSocket.didClose);
});
});
});

describe('#reconnect()', function () {
it('should close the existing socket', function () {
var oldWebSocket;
createDefaultStreamer();
activeStreamer.connect();
var oldWebSocket = fakeWebSocket;
activeStreamer.reconnect();
assert.ok(oldWebSocket.didClose);
assert.ok(!fakeWebSocket.didClose);

return activeStreamer.connect().then(function () {
oldWebSocket = fakeWebSocket;
return activeStreamer.reconnect();
}).then(function () {
assert.ok(oldWebSocket.didClose);
assert.ok(!fakeWebSocket.didClose);
});
});
});

describe('annotation notifications', function () {
beforeEach(function () {
createDefaultStreamer();
activeStreamer.connect();
return activeStreamer.connect();
});

context('when the app is the stream', function () {
Expand Down Expand Up @@ -271,7 +329,7 @@ describe('Streamer', function () {
describe('#applyPendingUpdates', function () {
beforeEach(function () {
createDefaultStreamer();
activeStreamer.connect();
return activeStreamer.connect();
});

it('applies pending updates', function () {
Expand Down Expand Up @@ -307,7 +365,7 @@ describe('Streamer', function () {

beforeEach(function () {
createDefaultStreamer();
activeStreamer.connect();
return activeStreamer.connect();
});

unroll('discards pending updates when #event occurs', function (testCase) {
Expand All @@ -330,40 +388,45 @@ describe('Streamer', function () {
describe('when the focused group changes', function () {
it('clears pending updates and deletions', function () {
createDefaultStreamer();
activeStreamer.connect();

fakeWebSocket.notify(fixtures.createNotification);
fakeRootScope.$broadcast(events.GROUP_FOCUSED);
return activeStreamer.connect().then(function () {
fakeWebSocket.notify(fixtures.createNotification);
fakeRootScope.$broadcast(events.GROUP_FOCUSED);

assert.equal(activeStreamer.countPendingUpdates(), 0);
assert.equal(activeStreamer.countPendingUpdates(), 0);
});
});
});

describe('session change notifications', function () {
it('updates the session when a notification is received', function () {
createDefaultStreamer();
activeStreamer.connect();
var model = {
groups: [{
id: 'new-group',
}],
};
fakeWebSocket.notify({
type: 'session-change',
model: model,
return activeStreamer.connect().then(function () {
var model = {
groups: [{
id: 'new-group',
}],
};
fakeWebSocket.notify({
type: 'session-change',
model: model,
});
assert.ok(fakeSession.update.calledWith(model));
});
assert.ok(fakeSession.update.calledWith(model));
});
});

describe('reconnections', function () {
it('resends configuration messages when a reconnection occurs', function () {
createDefaultStreamer();
activeStreamer.connect();
fakeWebSocket.messages = [];
fakeWebSocket.emit('open');
assert.equal(fakeWebSocket.messages.length, 1);
assert.equal(fakeWebSocket.messages[0].messageType, 'client_id');
return activeStreamer.connect().then(function () {
fakeWebSocket.messages = [];
fakeWebSocket.emit('open');

var configMsgTypes = fakeWebSocket.messages.map(function (msg) {
return msg.type || msg.messageType;
});
assert.deepEqual(configMsgTypes, ['client_id', 'whoami']);
});
});
});
});