Commit 4b5b4232 authored by Robert Knight's avatar Robert Knight Committed by GitHub

Merge pull request #501 from hypothesis/oauth-logout

Implement logout when using OAuth
parents 1905bc7a cf420fbb
...@@ -33,6 +33,11 @@ function auth($http, $window, flash, localStorage, random, settings) { ...@@ -33,6 +33,11 @@ function auth($http, $window, flash, localStorage, random, settings) {
var accessTokenPromise; var accessTokenPromise;
var tokenUrl = resolve('token', settings.apiUrl); var tokenUrl = resolve('token', settings.apiUrl);
/**
* Timer ID of the current access token refresh timer.
*/
var refreshTimer;
/** /**
* Show an error message telling the user that the access token has expired. * Show an error message telling the user that the access token has expired.
*/ */
...@@ -76,14 +81,12 @@ function auth($http, $window, flash, localStorage, random, settings) { ...@@ -76,14 +81,12 @@ function auth($http, $window, flash, localStorage, random, settings) {
}; };
} }
// Post the given data to the tokenUrl endpoint as a form submission. function formPost(url, data) {
// Return a Promise for the access token response.
function postToTokenUrl(data) {
data = queryString.stringify(data); data = queryString.stringify(data);
var requestConfig = { var requestConfig = {
headers: {'Content-Type': 'application/x-www-form-urlencoded'}, headers: {'Content-Type': 'application/x-www-form-urlencoded'},
}; };
return $http.post(tokenUrl, data, requestConfig); return $http.post(url, data, requestConfig);
} }
function grantTokenFromHostPage() { function grantTokenFromHostPage() {
...@@ -146,7 +149,7 @@ function auth($http, $window, flash, localStorage, random, settings) { ...@@ -146,7 +149,7 @@ function auth($http, $window, flash, localStorage, random, settings) {
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: grantToken, assertion: grantToken,
}; };
return postToTokenUrl(data).then(function (response) { return formPost(tokenUrl, data).then(function (response) {
if (response.status !== 200) { if (response.status !== 200) {
throw new Error('Failed to retrieve access token'); throw new Error('Failed to retrieve access token');
} }
...@@ -164,7 +167,7 @@ function auth($http, $window, flash, localStorage, random, settings) { ...@@ -164,7 +167,7 @@ function auth($http, $window, flash, localStorage, random, settings) {
grant_type: 'authorization_code', grant_type: 'authorization_code',
code, code,
}; };
return postToTokenUrl(data).then((response) => { return formPost(tokenUrl, data).then((response) => {
if (response.status !== 200) { if (response.status !== 200) {
throw new Error('Authorization code exchange failed'); throw new Error('Authorization code exchange failed');
} }
...@@ -182,7 +185,7 @@ function auth($http, $window, flash, localStorage, random, settings) { ...@@ -182,7 +185,7 @@ function auth($http, $window, flash, localStorage, random, settings) {
*/ */
function refreshAccessToken(refreshToken, options) { function refreshAccessToken(refreshToken, options) {
var data = { grant_type: 'refresh_token', refresh_token: refreshToken }; var data = { grant_type: 'refresh_token', refresh_token: refreshToken };
return postToTokenUrl(data).then((response) => { return formPost(tokenUrl, data).then((response) => {
var tokenInfo = tokenInfoFrom(response); var tokenInfo = tokenInfoFrom(response);
if (options.persist) { if (options.persist) {
...@@ -227,7 +230,7 @@ function auth($http, $window, flash, localStorage, random, settings) { ...@@ -227,7 +230,7 @@ function auth($http, $window, flash, localStorage, random, settings) {
} }
} }
window.setTimeout(refreshAccessTokenIfNearExpiry, delay); refreshTimer = $window.setTimeout(refreshAccessTokenIfNearExpiry, delay);
} }
/** /**
...@@ -279,11 +282,15 @@ function auth($http, $window, flash, localStorage, random, settings) { ...@@ -279,11 +282,15 @@ function auth($http, $window, flash, localStorage, random, settings) {
return accessTokenPromise; return accessTokenPromise;
} }
// clearCache() isn't implemented (or needed) yet for OAuth. /**
// In the future, for example when OAuth-authenticated users can login and * Forget any cached credentials.
// logout of the client, this clearCache() will need to clear the access */
// token and cancel any scheduled refresh token requests.
function clearCache() { function clearCache() {
// Once cookie auth has been removed, the `clearCache` method can be removed
// from the public API of this service in favor of `logout`.
accessTokenPromise = Promise.resolve(null);
localStorage.removeItem(storageKey());
$window.clearTimeout(refreshTimer);
} }
/** /**
...@@ -352,9 +359,25 @@ function auth($http, $window, flash, localStorage, random, settings) { ...@@ -352,9 +359,25 @@ function auth($http, $window, flash, localStorage, random, settings) {
}); });
} }
/**
* Log out of the service (in the client only).
*
* This revokes and then forgets any OAuth credentials that the user has.
*/
function logout() {
return accessTokenPromise.then(accessToken => {
return formPost(settings.oauthRevokeUrl, {
token: accessToken,
});
}).then(() => {
clearCache();
});
}
return { return {
clearCache, clearCache,
login, login,
logout,
tokenGetter, tokenGetter,
}; };
} }
......
...@@ -150,7 +150,12 @@ function session($http, $q, $resource, $rootScope, analytics, annotationUI, auth ...@@ -150,7 +150,12 @@ function session($http, $q, $resource, $rootScope, analytics, annotationUI, auth
lastLoadTime = Date.now(); lastLoadTime = Date.now();
if (userChanged) { if (userChanged) {
if (!getAuthority()) { if (!auth.login) {
// When using cookie-based auth, notify the auth service that the current
// login has changed and API tokens need to be invalidated.
//
// This is not needed for OAuth-based auth because all login/logout
// activities happen through the auth service itself.
auth.clearCache(); auth.clearCache();
} }
...@@ -208,10 +213,32 @@ function session($http, $q, $resource, $rootScope, analytics, annotationUI, auth ...@@ -208,10 +213,32 @@ function session($http, $q, $resource, $rootScope, analytics, annotationUI, auth
return update(model); return update(model);
} }
/**
* Log the user out of the current session.
*/
function logout() { function logout() {
return resource.logout().$promise.then(function () { var loggedOut;
auth.clearCache();
}).catch(function (err) { if (auth.logout) {
loggedOut = auth.logout().then(() => {
// When using OAuth, we have to explicitly re-fetch the logged-out
// user's profile.
// When using cookie-based auth, `resource.logout()` handles this
// automatically.
return reload();
});
} else {
loggedOut = resource.logout().$promise.then(() => {
// When using cookie-based auth, notify the auth service that the current
// login has changed and API tokens need to be invalidated.
//
// This is not needed for OAuth-based auth because all login/logout
// activities happen through the auth service itself.
auth.clearCache();
});
}
return loggedOut.catch(function (err) {
flash.error('Log out failed'); flash.error('Log out failed');
analytics.track(analytics.events.LOGOUT_FAILURE); analytics.track(analytics.events.LOGOUT_FAILURE);
return $q.reject(new Error(err)); return $q.reject(new Error(err));
......
...@@ -20,6 +20,9 @@ class FakeWindow { ...@@ -20,6 +20,9 @@ class FakeWindow {
}; };
this.open = sinon.stub(); this.open = sinon.stub();
this.setTimeout = window.setTimeout.bind(window);
this.clearTimeout = window.clearTimeout.bind(window);
} }
addEventListener(event, callback) { addEventListener(event, callback) {
...@@ -55,12 +58,29 @@ describe('sidebar.oauth-auth', function () { ...@@ -55,12 +58,29 @@ describe('sidebar.oauth-auth', function () {
var clock; var clock;
var successfulFirstAccessTokenPromise; var successfulFirstAccessTokenPromise;
/**
* Login and retrieve an auth code.
*/
function login() {
var loggedIn = auth.login();
fakeWindow.sendMessage({
type: 'authorization_response',
code: 'acode',
state: 'notrandom',
});
return loggedIn;
}
before(() => { before(() => {
angular.module('app', []) angular.module('app', [])
.service('auth', require('../oauth-auth')); .service('auth', require('../oauth-auth'));
}); });
beforeEach(function () { beforeEach(function () {
// Setup fake clock. This has to be done before setting up the `window`
// fake which makes use of timers.
clock = sinon.useFakeTimers();
nowStub = sinon.stub(window.performance, 'now'); nowStub = sinon.stub(window.performance, 'now');
nowStub.returns(300); nowStub.returns(300);
...@@ -89,6 +109,7 @@ describe('sidebar.oauth-auth', function () { ...@@ -89,6 +109,7 @@ describe('sidebar.oauth-auth', function () {
apiUrl: 'https://hypothes.is/api/', apiUrl: 'https://hypothes.is/api/',
oauthAuthorizeUrl: 'https://hypothes.is/oauth/authorize/', oauthAuthorizeUrl: 'https://hypothes.is/oauth/authorize/',
oauthClientId: 'the-client-id', oauthClientId: 'the-client-id',
oauthRevokeUrl: 'https://hypothes.is/oauth/revoke/',
services: [{ services: [{
authority: 'publisher.org', authority: 'publisher.org',
grantToken: 'a.jwt.token', grantToken: 'a.jwt.token',
...@@ -100,6 +121,7 @@ describe('sidebar.oauth-auth', function () { ...@@ -100,6 +121,7 @@ describe('sidebar.oauth-auth', function () {
fakeLocalStorage = { fakeLocalStorage = {
getObject: sinon.stub().returns(null), getObject: sinon.stub().returns(null),
setObject: sinon.stub(), setObject: sinon.stub(),
removeItem: sinon.stub(),
}; };
angular.mock.module('app', { angular.mock.module('app', {
...@@ -114,8 +136,6 @@ describe('sidebar.oauth-auth', function () { ...@@ -114,8 +136,6 @@ describe('sidebar.oauth-auth', function () {
angular.mock.inject((_auth_) => { angular.mock.inject((_auth_) => {
auth = _auth_; auth = _auth_;
}); });
clock = sinon.useFakeTimers();
}); });
afterEach(function () { afterEach(function () {
...@@ -319,19 +339,6 @@ describe('sidebar.oauth-auth', function () { ...@@ -319,19 +339,6 @@ describe('sidebar.oauth-auth', function () {
}); });
describe('persistence of tokens to storage', () => { describe('persistence of tokens to storage', () => {
/**
* Login and retrieve an auth code.
*/
function login() {
var loggedIn = auth.login();
fakeWindow.sendMessage({
type: 'authorization_response',
code: 'acode',
state: 'notrandom',
});
return loggedIn;
}
beforeEach(() => { beforeEach(() => {
fakeSettings.services = []; fakeSettings.services = [];
}); });
...@@ -548,6 +555,45 @@ describe('sidebar.oauth-auth', function () { ...@@ -548,6 +555,45 @@ describe('sidebar.oauth-auth', function () {
}); });
}); });
describe('#logout', () => {
beforeEach(() => {
// logout() is only currently used when using the public
// Hypothesis service.
fakeSettings.services = [];
return login().then(() => {
return auth.tokenGetter();
}).then(token => {
assert.notEqual(token, null);
fakeHttp.post.reset();
});
});
it('forgets access tokens', () => {
return auth.logout().then(() => {
return auth.tokenGetter();
}).then(token => {
assert.equal(token, null);
});
});
it('removes cached tokens', () => {
return auth.logout().then(() => {
assert.calledWith(fakeLocalStorage.removeItem, TOKEN_KEY);
});
});
it('revokes tokens', () => {
return auth.logout().then(() => {
var expectedBody = 'token=firstAccessToken';
assert.calledWith(fakeHttp.post, 'https://hypothes.is/oauth/revoke/', expectedBody, {
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
});
});
});
});
// Advance time forward so that any current access tokens will have expired. // Advance time forward so that any current access tokens will have expired.
function expireAccessToken () { function expireAccessToken () {
clock.tick(DEFAULT_TOKEN_EXPIRES_IN_SECS * 1000); clock.tick(DEFAULT_TOKEN_EXPIRES_IN_SECS * 1000);
......
...@@ -295,11 +295,8 @@ describe('session', function () { ...@@ -295,11 +295,8 @@ describe('session', function () {
}); });
}); });
it('does not clear the access token when the host page provides a grant token', function () { it('does not clear the access token when using OAuth-based authorization', function () {
fakeServiceConfig.returns({ fakeAuth.login = Promise.resolve();
authority: 'publisher.org',
grantToken: 'a.jwt.token',
});
session.update({userid: 'different-user', csrf: 'dummytoken'}); session.update({userid: 'different-user', csrf: 'dummytoken'});
...@@ -349,46 +346,84 @@ describe('session', function () { ...@@ -349,46 +346,84 @@ describe('session', function () {
}); });
}); });
describe('#logout()', function () { describe('#logout', function () {
var postExpectation; context('when using cookie auth', () => {
beforeEach(function () { var postExpectation;
var logoutUrl = 'https://test.hypothes.is/root/app?__formid__=logout'; beforeEach(function () {
postExpectation = $httpBackend.expectPOST(logoutUrl).respond(200, { var logoutUrl = 'https://test.hypothes.is/root/app?__formid__=logout';
model: { postExpectation = $httpBackend.expectPOST(logoutUrl).respond(200, {
userid: 'logged-out-id', model: {
}, userid: 'logged-out-id',
},
});
}); });
});
it('logs the user out on the service and updates the session', function () { it('logs the user out on the service and updates the session', function () {
session.logout().then(function () { session.logout().then(function () {
assert.equal(session.state.userid, 'logged-out-id'); assert.equal(session.state.userid, 'logged-out-id');
});
$httpBackend.flush();
}); });
$httpBackend.flush();
});
it('clears the API access token cache', function () { it('clears the API access token cache', function () {
session.logout().then(function () { session.logout().then(function () {
assert.called(fakeAuth.clearCache); assert.called(fakeAuth.clearCache);
});
$httpBackend.flush();
}); });
$httpBackend.flush();
});
it('tracks successful logout actions in analytics', function () { it('tracks successful logout actions in analytics', function () {
session.logout().then(function () { session.logout().then(function () {
assert.calledWith(fakeAnalytics.track, fakeAnalytics.events.LOGOUT_SUCCESS); assert.calledWith(fakeAnalytics.track, fakeAnalytics.events.LOGOUT_SUCCESS);
});
$httpBackend.flush();
});
it('tracks unsuccessful logout actions in analytics', function () {
postExpectation.respond(500);
session.logout().catch(function(){
assert.calledWith(fakeAnalytics.track, fakeAnalytics.events.LOGOUT_FAILURE);
});
$httpBackend.flush();
}); });
$httpBackend.flush();
}); });
it('tracks unsuccessful logout actions in analytics', function () { context('when using OAuth', () => {
postExpectation.respond(500); beforeEach(() => {
var loggedIn = true;
session.logout().catch(function(){ fakeAuth.login = sinon.stub().returns(Promise.resolve());
assert.calledWith(fakeAnalytics.track, fakeAnalytics.events.LOGOUT_FAILURE); fakeAuth.logout = sinon.spy(() => {
loggedIn = false;
return Promise.resolve();
});
// Fake profile response after logout.
fakeStore.profile.read = () => Promise.resolve({
userid: null,
loggedIn,
});
}); });
$httpBackend.flush(); it('logs the user out', () => {
return session.logout().then(() => {
assert.called(fakeAuth.logout);
});
});
it('tracks successful logout actions in analytics', () => {
return session.logout().then(() => {
assert.calledWith(fakeAnalytics.track, fakeAnalytics.events.LOGOUT_SUCCESS);
});
});
it('updates the profile after logging out', () => {
return session.logout().then(() => {
assert.isFalse(session.state.loggedIn);
});
});
}); });
}); });
}); });
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment