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

Commit

Permalink
Merge pull request #183 from mozilla-services/fxa-oauth
Browse files Browse the repository at this point in the history
Bug 1041814 — FxA oauth support
  • Loading branch information
Natim committed Aug 22, 2014
2 parents b4beaba + 50cbc5d commit 6fbb560
Show file tree
Hide file tree
Showing 12 changed files with 525 additions and 7 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG
Expand Up @@ -3,6 +3,12 @@ Changelog

This document describes changes between each past release.

0.11.0 (unreleased)
-------------------

- [feature] 1041814 — Add FxA oauth support


0.10.0 (2014-08-05)
-------------------

Expand Down
7 changes: 6 additions & 1 deletion config/test.json
Expand Up @@ -46,5 +46,10 @@
"ringingDuration": 0.05,
"connectionDuration": 0.05
},
"maxSimplePushUrls": 2
"maxSimplePushUrls": 2,

"fxaOAuth": {
"client_id": "263ceaa5546dce83",
"client_secret": "852ae8d050d6805a402272e0c776193cfba263ceaa5546dce837191be98db91e"
}
}
32 changes: 32 additions & 0 deletions loop/config.js
Expand Up @@ -334,6 +334,38 @@ var conf = convict({
doc: "An array of push server URIs",
format: Array,
default: ["wss://push.services.mozilla.com/"]
},
fxaOAuth: {
client_id: {
doc: "The FxA client_id (8 bytes key encoded as hex)",
format: hexKeyOfSize(8),
default: ""
},
client_secret: {
doc: "The FxA client secret (32 bytes key encoded as hex)",
format: hexKeyOfSize(32),
default: ""
},
oauth_uri: {
doc: "The location of the FxA OAuth server.",
format: "url",
default: "https://oauth.accounts.firefox.com/v1"
},
redirect_uri: {
doc: "The redirect_uri.",
format: String,
default: "urn:ietf:wg:oauth:2.0:fx:webchannel"
},
profile_uri: {
doc: "The FxA profile uri.",
format: "url",
default: "https://profile.firefox.com/v1"
},
scope: {
doc: "The scope we're requesting access to",
format: String,
default: "profile"
}
}
});

Expand Down
1 change: 1 addition & 0 deletions loop/errno.json
Expand Up @@ -6,5 +6,6 @@
"INVALID_AUTH_TOKEN": 110,
"EXPIRED": 111,
"REQUEST_TOO_LARGE": 113,
"INVALID_OAUTH_STATE": 114,
"BACKEND": 201
}
12 changes: 9 additions & 3 deletions loop/index.js
Expand Up @@ -14,7 +14,6 @@ http.globalAgent.maxSockets = conf.get('maxHTTPSockets');

var express = require('express');
var bodyParser = require('body-parser');
var request = require('request');
var raven = require('raven');
var cors = require('cors');
var StatsdClient = require('statsd-node').client;
Expand Down Expand Up @@ -49,7 +48,9 @@ if (conf.get('statsdEnabled') === true) {
}

function logError(err) {
console.log(err);
if (conf.get('env') !== 'test') {
console.log(err);
}
ravenClient.captureError(err);
}

Expand Down Expand Up @@ -102,6 +103,12 @@ var storeUserCallTokens = calls(app, conf, logError, storage, tokBox,
var pushServerConfig = require("./routes/push-server-config");
pushServerConfig(app, conf);

var fxaOAuth = require("./routes/fxa-oauth");
fxaOAuth(app, conf, logError, storage, auth);

var session = require("./routes/session");
session(app, auth);

// Exception logging should come at the end of the list of middlewares.
app.use(raven.middleware.express(conf.get('sentryDSN')));

Expand Down Expand Up @@ -146,7 +153,6 @@ module.exports = {
server: server,
conf: conf,
storage: storage,
request: request,
tokBox: tokBox,
statsdClient: statsdClient,
shutdown: shutdown,
Expand Down
137 changes: 137 additions & 0 deletions loop/routes/fxa-oauth.js
@@ -0,0 +1,137 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

'use strict';

var randomBytes = require('crypto').randomBytes;
var request = require('request');
var sendError = require('../utils').sendError;
var errors = require('../errno.json');
var hmac = require('../hmac');

module.exports = function (app, conf, logError, storage, auth) {

var oauthConf = conf.get('fxaOAuth');

/**
* Provide the client with the parameters needed for the OAuth dance.
**/
app.get('/fxa-oauth/parameters', auth.requireHawkSession,
function(req, res) {
var callback = function(state) {
res.status(200).json({
client_id: oauthConf.client_id,
redirect_uri: oauthConf.redirect_uri,
oauth_uri: oauthConf.oauth_uri,
scope: oauthConf.scope,
state: state
});
};
storage.getHawkOAuthState(req.hawkIdHmac, function(err, state) {
if (res.serverError(err)) return;
if (state === null) {
state = randomBytes(32).toString('hex');
storage.setHawkOAuthState(req.hawkIdHmac, state, function(err) {
if (res.serverError(err)) return;
callback(state);
});
} else {
callback(state);
}
});
});

/**
* Returns the current status of the hawk session (e.g. if it's authenticated
* or not.
**/
app.get('/fxa-oauth/token', auth.requireHawkSession, function (req, res) {
storage.getHawkOAuthToken(req.hawkIdHmac, function(err, token) {
if (res.serverError(err)) return;
res.status(200).json({
oauthToken: token || undefined
});
});
});

/**
* Trade an OAuth code with an oauth bearer token.
**/
app.post('/fxa-oauth/token', auth.requireHawkSession, function (req, res) {
var state = req.query.state;
var code = req.query.code;

var missingParams = [];
if (!state) {
missingParams.push('state');
}
if (!code) {
missingParams.push('code');
}
if (missingParams.length > 0) {
sendError(res, 400, errors.MISSING_PARAMETERS,
"Missing: " + missingParams.join(", "));
return;
}

// State should match an existing state.
storage.getHawkOAuthState(req.hawkIdHmac, function(err, storedState) {
if (res.serverError(err)) return;

storage.clearHawkOAuthState(req.hawkIdHmac, function(err) {
if (res.serverError(err)) return;
});

if (storedState !== state) {
// Reset session state after an attempt was made to compare it.
sendError(res, 400,
errors.INVALID_OAUTH_STATE, "Invalid OAuth state");
return;
}

// Trade the OAuth code for a token.
request.post({
uri: oauthConf.oauth_uri + '/token',
json: {
code: code,
client_id: oauthConf.client_id,
client_secret: oauthConf.client_secret
}
}, function (err, r, body) {
if (res.serverError(err)) return;

var token = body.access_token;

// store the bearer token
storage.setHawkOAuthToken(req.hawkIdHmac, token);

// Make a request to the FxA server to have information about the
// profile.
request.get({
uri: oauthConf.profile_uri + '/profile',
headers: {
Authorization: 'Bearer ' + token
}
}, function (err, r, body) {
if (res.serverError(err)) return;
var data;
try {
data = JSON.parse(body);
} catch (e) {
if (res.serverError(new Error(e + " JSON: " + body))) return;
}
// Store the appropriate profile information into the database,
// associated with the hawk session.
var userHmac = hmac(data.email, conf.get('userMacSecret'));
storage.setHawkUser(userHmac, req.hawkIdHmac, function(err) {
if (res.serverError(err)) return;
res.status(200).json({
oauthToken: token
});
});
});
});
});
});
};
18 changes: 18 additions & 0 deletions loop/routes/session.js
@@ -0,0 +1,18 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

'use strict';

module.exports = function (app, auth) {

/**
* An endpoint you can use to retrieve a hawk session.
*
* In the case of OAuth, this session will be upgraded at the end of the
* authentication flow, with an attached identity.
**/
app.post('/session', auth.attachOrCreateHawkSession, function(req, res) {
res.status(204).json();
});
};
25 changes: 25 additions & 0 deletions loop/storage/redis.js
Expand Up @@ -610,6 +610,31 @@ RedisStorage.prototype = {
this._client.del('hawk.' + hawkIdHmac, callback);
},

setHawkOAuthToken: function(hawkIdHmac, token, callback) {
this._client.set('oauth.token.' + hawkIdHmac, token, callback);
},

getHawkOAuthToken: function(hawkIdHmac, callback) {
this._client.get('oauth.token.' + hawkIdHmac, callback);
},

setHawkOAuthState: function(hawkIdHmac, state, callback) {
this._client.setex(
'oauth.state.' + hawkIdHmac,
this._settings.hawkSessionDuration,
state,
callback
);
},

getHawkOAuthState: function(hawkIdHmac, callback) {
this._client.get('oauth.state.' + hawkIdHmac, callback);
},

clearHawkOAuthState: function(hawkIdHmac, callback) {
this._client.del('oauth.state.' + hawkIdHmac, callback);
},

drop: function(callback) {
this._client.flushdb(callback);
},
Expand Down
2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -31,7 +31,7 @@
"ws": "0.4.31",
"sodium": "1.0.11",
"atob": "1.1.2",
"express-hawkauth": "0.2.0",
"express-hawkauth": "git://github.com/mozilla-services/express-hawkauth.git#aaa098",
"eslint": "0.7.x",
"yargs": "1.3.1"
},
Expand Down
51 changes: 50 additions & 1 deletion test/functional_test.js
Expand Up @@ -13,7 +13,7 @@ var assert = sinon.assert;

var loop = require("../loop");
var app = loop.app;
var request = loop.request;
var request = require("request");

var conf = loop.conf;
var tokBox = loop.tokBox;
Expand All @@ -33,6 +33,7 @@ var errors = require("../loop/errno.json");

var auth = loop.auth;
var authenticate = auth.authenticate;
var attachOrCreateHawkSession = auth.attachOrCreateHawkSession;
var requireHawkSession = auth.requireHawkSession;

var validators = loop.validators;
Expand Down Expand Up @@ -72,6 +73,7 @@ describe("HTTP API exposed by the server", function() {
var routes = {
'/': ['get'],
'/registration': ['post'],
'/session': ['post'],
'/call-url': ['post', 'del'],
'/calls': ['get', 'post'],
'/calls/token': ['get', 'post'],
Expand Down Expand Up @@ -497,6 +499,53 @@ describe("HTTP API exposed by the server", function() {
});
});

describe("POST /session", function() {
var jsonReq;

beforeEach(function() {
jsonReq = supertest(app)
.post('/session')
.hawk(hawkCredentials);
});

it("should have the attachOrCreateHawkSession middleware installed",
function() {
expect(getMiddlewares(app, 'post', '/session'))
.include(attachOrCreateHawkSession);
});

it("should return a 204 if everything went fine", function(done) {
jsonReq
.expect(204).end(done);
});

it("should return a 503 if the database isn't available",
function(done) {
sandbox.stub(storage, "getHawkSession",
function(tokenId, cb) {
cb(new Error("error"));
});
jsonReq.expect(503).end(done);
});

it("should count new users if the session is created", function(done) {
sandbox.stub(statsdClient, "count");
supertest(app)
.post('/session')
.type('json')
.send({}).expect(204).end(function(err) {
if (err) throw err;
assert.calledOnce(statsdClient.count);
assert.calledWithExactly(
statsdClient.count,
"loop-activated-users",
1
);
done();
});
});
});

describe("POST /registration", function() {
var jsonReq;
var url1 = "http://www.example.org";
Expand Down

0 comments on commit 6fbb560

Please sign in to comment.