Skip to content
This repository has been archived by the owner on Mar 5, 2020. It is now read-only.

Commit

Permalink
oauth2 rather than persona-based login on the client side
Browse files Browse the repository at this point in the history
  • Loading branch information
Pomax committed Mar 30, 2015
1 parent 48aec87 commit 63f5e96
Show file tree
Hide file tree
Showing 3 changed files with 123 additions and 144 deletions.
123 changes: 97 additions & 26 deletions lib/teach-api.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
var EventEmitter = require('events').EventEmitter;
var util = require('util');
var urlResolve = require('url').resolve;
var url = require('url');
var urlResolve = url.resolve;
var _ = require('underscore');
var request = require('superagent');
var ga = require('react-ga');

var STORAGE_KEY = 'TEACH_API_LOGIN_INFO';

function generateRandom(charnum) {
charnum = charnum || 12;
var character = String.fromCharCode(0x41 + Math.random() * 25);
var tail = (charnum === 1) ? '' : generateRandom(charnum - 1);
return (Math.random() > 0.5 ? character.toLowerCase() : character.toUpperCase()) + tail;
}

function autobind(obj) {
var prototypes = [].slice.call(arguments, 1);
prototypes.forEach(function(prototype) {
Expand Down Expand Up @@ -53,37 +61,100 @@ _.extend(TeachAPI.prototype, {
var info = this.getLoginInfo();
return info && info.username;
},

// The first half of the oauth2 login work flow:
//
// Form an oauth2 URL that users can be redirected to, which will eventually
// lead to continueLogin being called when the oauth2 service is done handling
// the authentication and remote login of the user.
startLogin: function() {
if (!(process.browser && window.navigator.id)) {
return this.emit('login:error',
new Error('navigator.id does not exist'));
var state = generateRandom();
try {
window.sessionStorage.setItem('oauth2_token', state);
} catch (securityError) {
console.warn('window.sessionStorage is not available, attempting document.cookie fallback...');
document.cookie = 'oauth2_token=' + state;
}
window.navigator.id.get(function(assertion) {
if (!assertion) {
return this.emit('login:cancel');

if (document.cookie.indexOf('oauth2_token=' + state) === -1) {
console.error('document.cookie fallback failed. Login will be unable to complete successfully!');
}

// We redirect the user to the oauth2 login service on id.webmaker.org, which
// will take care off the magic for us. This will eventually call us back by
// redirecting the user back to teach.wmo/oauth2/callback, which is a page that
// can accept login credentials, compare the token to the one we saved to
// window.sessionStorage['oauth2_token'], and then call the teach-API for local
// log in to the actual teach-relevant bits of the site.

window.location = url.format({
protocol: 'https',
host: 'id.webmaker.org/login/oauth/authorize',
query: {
client_id: 'test',
response_type: 'code',
scopes: 'user',
state: state
}
});
},

// The second half of the oauth2 work flow:
//
// This function is used to process the oauth2 login callback, when the login
// server on id.wmo redirects to teach.wmo/oauth2/callback
continueLogin: function() {
// grab the url parameters sent by the oauth2 service
var parsed = url.parse(window.location.toString());
var params = parsed.query;

// for the oauth callback, there are three values we are interested in:
var clientId = params.client_id;
var code = params.code;
var state = params.state;

var correctState = false;

try {
correctState = window.sessionStorage.setItem('oauth2_token', state);
} catch (securityError) {
console.warn('window.sessionStorage is not available, attempting to read from document.cookie...');
correctState = document.cookie.toString().indexOf('oauth2_token=' + state) > -1;
}

// foremost, the client_id and "state" value (which we invented during startLogin)
// needs to match. Otherwise, this is not a genuine callback.
if (clientId === 'test' && correctState) {
// genuine call: we now call the teach-api with this information so that
// it can do server <-> server communication with id.wmo to verify that
// the code that we got in the callback is indeed a real auth code.
request
.post(this.baseURL + '/auth/persona')
.type('form')
.send({ assertion: assertion })
.end(function(err, res) {
if (err) {
err.hasNoWebmakerAccount = (
err.response && err.response.forbidden &&
err.response.text == 'invalid assertion or email'
);
return this.emit('login:error', err);
}

// TODO: Handle a thrown exception here.
this.storage[STORAGE_KEY] = JSON.stringify(res.body);

this.emit('username:change', res.body.username);
this.emit('login:success', res.body);
}.bind(this));
}.bind(this));
.post(this.baseURL + '/auth')
.type('form')
.send({ code: code })
.end(function(err, res) {
if (err) {
err.hasNoWebmakerAccount = (
err.response && err.response.forbidden &&
err.response.text == 'invalid authorization code'
);
return this.emit('login:error', err);
}
// TODO: Handle a thrown exception here.
this.storage[STORAGE_KEY] = JSON.stringify(res.body);
this.emit('username:change', res.body.username);
this.emit('login:success', res.body);
}.bind(this));
}

// cleanup after login, regardless of whether it succeeded or not
try {
window.sessionStorage.removeItem('oauth2_token');
} catch (securityError) {
document.cookie = 'oauth2_token=false';
}
},

request: function(method, path) {
var info = this.getLoginInfo();
var url = urlResolve(this.baseURL, path);
Expand Down
23 changes: 23 additions & 0 deletions pages/callback.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
var React = require('react');

var OAuth2Callback = React.createClass({

mixins: [
require('../lib/teach-api')
],

// The only thing this page does is take in the callback arguments,
// and verify they are in fact valie. If so, the teach-api function
// for continued login should redirect the user once authenticated
// or rejected.
componentDidMount: function() {
this.getTeachAPI().continueLogin();
ga.event({ category: 'Login', action: 'Continue Login' });
},

render: function() {
return <div>validating oauth2 callback information...</div>;
}
});

module.exports = OAuth2Callback;
121 changes: 3 additions & 118 deletions test/browser/teach-api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -303,123 +303,8 @@ describe('TeachAPI', function() {
});
});

describe('startLogin()', function() {
var personaCb;
// startLogin and continueLogin tests are required here,
// as the old tests were persona based and fully
// invalidated by the switchover to oauth2

beforeEach(function() {
personaCb = null;
window.navigator.id = {
get: function(cb) {
personaCb = cb;
}
};
});

afterEach(function() {
delete window.navigator.id;
});

it('does nothing if given no assertion', function(done) {
var api = new TeachAPI({storage: storage});

api.startLogin();
api.on('login:cancel', function() {
requests.should.eql([]);
done();
});
personaCb(null);
});

it('emits error if navigator.id is falsy', function(done) {
var api = new TeachAPI({storage: storage});

delete window.navigator.id;
api.once('login:error', function(err) {
err.message.should.eql('navigator.id does not exist');
done();
});
api.startLogin();
});

it('sends assertion to Teach API server', function() {
var api = new TeachAPI({
baseURL: 'http://example.org',
storage: storage
});

api.startLogin();
personaCb('hi');

requests.length.should.eql(1);

var r = requests[0];

r.url.should.eql('http://example.org/auth/persona');
r.requestHeaders.should.eql({
'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8'
});
r.requestBody.should.eql('assertion=hi');
});

it('emits error upon general failure', function(done) {
var api = new TeachAPI({storage: storage});

api.startLogin();
personaCb('hi');

api.on('login:error', function(err) {
err.response.text.should.eql('nope');
err.hasNoWebmakerAccount.should.be.false;
done();
});

requests[0].respond(403, {
'Content-Type': 'text/html'
}, 'nope');
});

it('reports when email has no Webmaker acct', function(done) {
var api = new TeachAPI({storage: storage});

api.startLogin();
personaCb('hi');

api.on('login:error', function(err) {
err.hasNoWebmakerAccount.should.be.true;
done();
});

requests[0].respond(403, {
'Content-Type': 'text/html'
}, 'invalid assertion or email');
});

it('stores login info and emits events upon success', function(done) {
var usernameEventEmitted = false;
var api = new TeachAPI({storage: storage});
var loginInfo = {
'username': 'foo',
'token': 'blah'
};

api.startLogin();
personaCb('hi');

api.on('username:change', function(username) {
username.should.eql('foo');
usernameEventEmitted = true;
});
api.on('login:success', function(info) {
info.should.eql(loginInfo);
JSON.parse(storage['TEACH_API_LOGIN_INFO'])
.should.eql(loginInfo);
usernameEventEmitted.should.be.true;
done();
});

requests[0].respond(200, {
'Content-Type': 'application/json'
}, JSON.stringify(loginInfo));
});
});
});

0 comments on commit 63f5e96

Please sign in to comment.