Bug 1127638 - Provide a way to get an OAuth token for a set of desired scopes for the currently logged in user r=markh

This commit is contained in:
Zachary Carter 2015-02-05 13:31:23 -08:00
parent e15c2cd772
commit 3e89e578f1
8 changed files with 555 additions and 1 deletions

View File

@ -1756,6 +1756,12 @@ pref("identity.fxaccounts.remote.signin.uri", "https://accounts.firefox.com/sign
// "identity.fxaccounts.remote.signup.uri" pref.
pref("identity.fxaccounts.settings.uri", "https://accounts.firefox.com/settings");
// The remote URL of the FxA Profile Server
pref("identity.fxaccounts.remote.profile.uri", "https://profile.accounts.firefox.com/v1");
// The remote URL of the FxA OAuth Server
pref("identity.fxaccounts.remote.oauth.uri", "https://oauth.accounts.firefox.com/v1");
// Migrate any existing Firefox Account data from the default profile to the
// Developer Edition profile.
#ifdef MOZ_DEV_EDITION

View File

@ -23,6 +23,9 @@ XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsClient",
XPCOMUtils.defineLazyModuleGetter(this, "jwcrypto",
"resource://gre/modules/identity/jwcrypto.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsOAuthGrantClient",
"resource://gre/modules/FxAccountsOAuthGrantClient.jsm");
// All properties exposed by the public FxAccounts API.
let publicProperties = [
"accountStatus",
@ -32,6 +35,7 @@ let publicProperties = [
"getAssertion",
"getKeys",
"getSignedInUser",
"getOAuthToken",
"loadAndPoll",
"localtimeOffsetMsec",
"now",
@ -904,6 +908,28 @@ FxAccountsInternal.prototype = {
newQueryPortion += "email=" + encodeURIComponent(accountData.email);
return url + newQueryPortion;
}).then(result => currentState.resolve(result));
},
/*
* Get an OAuth token for the user
*/
getOAuthToken: function (options = {}) {
log.debug("getOAuthToken enter");
if (!options.scope) {
throw new Error("Missing 'scope' option");
}
let oAuthURL = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.oauth.uri");
let client = options.client || new FxAccountsOAuthGrantClient({
serverURL: oAuthURL,
client_id: FX_OAUTH_CLIENT_ID
});
return this.getAssertion(oAuthURL)
.then(assertion => client.getTokenFromAssertion(assertion, options.scope))
.then(result => result.access_token);
}
};

View File

@ -94,6 +94,9 @@ exports.ON_FXA_UPDATE_NOTIFICATION = "fxaccounts:update";
exports.UI_REQUEST_SIGN_IN_FLOW = "signInFlow";
exports.UI_REQUEST_REFRESH_AUTH = "refreshAuthentication";
// The OAuth client ID for Firefox Desktop
exports.FX_OAUTH_CLIENT_ID = "5882386c6d801776";
// Server errno.
// From https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#response-format
exports.ERRNO_ACCOUNT_ALREADY_EXISTS = 101;

View File

@ -0,0 +1,215 @@
/* 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/. */
/**
* Firefox Accounts OAuth Grant Client allows clients to obtain
* an OAuth token from a BrowserID assertion. Only certain client
* IDs support this privilage.
*/
this.EXPORTED_SYMBOLS = ["FxAccountsOAuthGrantClient", "FxAccountsOAuthGrantClientError"];
const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
Cu.import("resource://gre/modules/Promise.jsm");
Cu.import("resource://gre/modules/Log.jsm");
Cu.import("resource://gre/modules/FxAccountsCommon.js");
Cu.import("resource://services-common/rest.js");
Cu.importGlobalProperties(["URL"]);
const AUTH_ENDPOINT = "/authorization";
/**
* Create a new FxAccountsOAuthClient for browser some service.
*
* @param {Object} options Options
* @param {Object} options.parameters
* @param {String} options.parameters.client_id
* OAuth id returned from client registration
* @param {String} options.parameters.serverURL
* The FxA OAuth server URL
* @param [authorizationEndpoint] {String}
* Optional authorization endpoint for the OAuth server
* @constructor
*/
this.FxAccountsOAuthGrantClient = function(options) {
this._validateOptions(options);
this.parameters = options;
try {
this.serverURL = new URL(this.parameters.serverURL);
} catch (e) {
throw new Error("Invalid 'serverURL'");
}
log.debug("FxAccountsOAuthGrantClient Initialized");
};
this.FxAccountsOAuthGrantClient.prototype = {
/**
* Retrieves an OAuth access token for the signed in user
*
* @param {Object} assertion BrowserID assertion
* @param {String} scope OAuth scope
* @return Promise
* Resolves: {Object} Object with access_token property
*/
getTokenFromAssertion: function (assertion, scope) {
if (!assertion) {
throw new Error("Missing 'assertion' parameter");
}
if (!scope) {
throw new Error("Missing 'scope' parameter");
}
let params = {
scope: scope,
client_id: this.parameters.client_id,
assertion: assertion,
response_type: "token"
};
return this._createRequest(AUTH_ENDPOINT, "POST", params);
},
/**
* Validates the required FxA OAuth parameters
*
* @param options {Object}
* OAuth client options
* @private
*/
_validateOptions: function (options) {
if (!options) {
throw new Error("Missing configuration options");
}
["serverURL", "client_id"].forEach(option => {
if (!options[option]) {
throw new Error("Missing '" + option + "' parameter");
}
});
},
/**
* Interface for making remote requests.
*/
_Request: RESTRequest,
/**
* Remote request helper
*
* @param {String} path
* Profile server path, i.e "/profile".
* @param {String} [method]
* Type of request, i.e "GET".
* @return Promise
* Resolves: {Object} Successful response from the Profile server.
* Rejects: {FxAccountsOAuthGrantClientError} Profile client error.
* @private
*/
_createRequest: function(path, method = "POST", params) {
return new Promise((resolve, reject) => {
let profileDataUrl = this.serverURL + path;
let request = new this._Request(profileDataUrl);
method = method.toUpperCase();
request.setHeader("Accept", "application/json");
request.setHeader("Content-Type", "application/json");
request.onComplete = function (error) {
if (error) {
return reject(new FxAccountsOAuthGrantClientError({
error: ERROR_NETWORK,
errno: ERRNO_NETWORK,
message: error.toString(),
}));
}
let body = null;
try {
body = JSON.parse(request.response.body);
} catch (e) {
return reject(new FxAccountsOAuthGrantClientError({
error: ERROR_PARSE,
errno: ERRNO_PARSE,
code: request.response.status,
message: request.response.body,
}));
}
// "response.success" means status code is 200
if (request.response.success) {
return resolve(body);
}
return reject(new FxAccountsOAuthGrantClientError(body));
};
if (method === "POST") {
request.post(params);
} else {
// method not supported
return reject(new FxAccountsOAuthGrantClientError({
error: ERROR_NETWORK,
errno: ERRNO_NETWORK,
code: ERROR_CODE_METHOD_NOT_ALLOWED,
message: ERROR_MSG_METHOD_NOT_ALLOWED,
}));
}
});
},
};
/**
* Normalized profile client errors
* @param {Object} [details]
* Error details object
* @param {number} [details.code]
* Error code
* @param {number} [details.errno]
* Error number
* @param {String} [details.error]
* Error description
* @param {String|null} [details.message]
* Error message
* @constructor
*/
this.FxAccountsOAuthGrantClientError = function(details) {
details = details || {};
this.name = "FxAccountsOAuthGrantClientError";
this.code = details.code || null;
this.errno = details.errno || ERRNO_UNKNOWN_ERROR;
this.error = details.error || ERROR_UNKNOWN;
this.message = details.message || null;
};
/**
* Returns error object properties
*
* @returns {{name: *, code: *, errno: *, error: *, message: *}}
* @private
*/
FxAccountsOAuthGrantClientError.prototype._toStringFields = function() {
return {
name: this.name,
code: this.code,
errno: this.errno,
error: this.error,
message: this.message,
};
};
/**
* String representation of a oauth grant client error
*
* @returns {String}
*/
FxAccountsOAuthGrantClientError.prototype.toString = function() {
return this.name + "(" + JSON.stringify(this._toStringFields()) + ")";
};

View File

@ -13,6 +13,7 @@ EXTRA_JS_MODULES += [
'FxAccountsClient.jsm',
'FxAccountsCommon.js',
'FxAccountsOAuthClient.jsm',
'FxAccountsOAuthGrantClient.jsm',
'FxAccountsProfileClient.jsm',
]

View File

@ -8,6 +8,7 @@ Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/FxAccounts.jsm");
Cu.import("resource://gre/modules/FxAccountsClient.jsm");
Cu.import("resource://gre/modules/FxAccountsCommon.js");
Cu.import("resource://gre/modules/FxAccountsOAuthGrantClient.jsm");
Cu.import("resource://gre/modules/Promise.jsm");
Cu.import("resource://gre/modules/Log.jsm");
@ -26,6 +27,11 @@ log.level = Log.Level.Debug;
// See verbose logging from FxAccounts.jsm
Services.prefs.setCharPref("identity.fxaccounts.loglevel", "DEBUG");
// The oauth server is mocked, but set these prefs to pass param checks
Services.prefs.setCharPref("identity.fxaccounts.remote.oauth.uri", "https://example.com/v1");
Services.prefs.setCharPref("identity.fxaccounts.oauth.client_id", "abc123");
function run_test() {
run_next_test();
}
@ -681,6 +687,46 @@ add_test(function test_sign_out_with_remote_error() {
fxa.signOut();
});
add_test(function test_getOAuthToken() {
let fxa = new MockFxAccounts();
let alice = getTestUser("alice");
let getTokenFromAssertionCalled = false;
fxa.internal._d_signCertificate.resolve("cert1");
// create a mock oauth client
let client = new FxAccountsOAuthGrantClient({
serverURL: "http://example.com/v1",
client_id: "abc123"
});
client.getTokenFromAssertion = function () {
getTokenFromAssertionCalled = true;
return Promise.resolve({ access_token: "token" });
};
fxa.setSignedInUser(alice).then(
() => {
fxa.getOAuthToken({ scope: "profile", client: client }).then(
(result) => {
do_check_true(getTokenFromAssertionCalled);
do_check_eq(result, "token");
run_next_test();
}
)
}
);
});
add_test(function test_getOAuthToken_missing_scope() {
let fxa = new MockFxAccounts();
do_check_throws_message(() => {
fxa.getOAuthToken();
}, "Missing 'scope' option");
run_next_test();
});
/*
* End of tests.
* Utility functions follow.
@ -743,7 +789,7 @@ function do_check_throws(func, result, stack)
if (ex.name == result) {
return;
}
do_throw("Expected result " + result + ", caught " + ex, stack);
do_throw("Expected result " + result + ", caught " + ex.name, stack);
}
if (result) {

View File

@ -0,0 +1,256 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
Cu.import("resource://gre/modules/FxAccountsCommon.js");
Cu.import("resource://gre/modules/FxAccountsOAuthGrantClient.jsm");
const CLIENT_OPTIONS = {
serverURL: "http://127.0.0.1:9010/v1",
client_id: 'abc123'
};
const STATUS_SUCCESS = 200;
/**
* Mock request responder
* @param {String} response
* Mocked raw response from the server
* @returns {Function}
*/
let mockResponse = function (response) {
return function () {
return {
setHeader: function () {},
post: function () {
this.response = response;
this.onComplete();
}
};
};
};
/**
* Mock request error responder
* @param {Error} error
* Error object
* @returns {Function}
*/
let mockResponseError = function (error) {
return function () {
return {
setHeader: function () {},
post: function () {
this.onComplete(error);
}
};
};
};
add_test(function missingParams () {
let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS);
try {
client.getTokenFromAssertion()
} catch (e) {
do_check_eq(e.message, "Missing 'assertion' parameter");
}
try {
client.getTokenFromAssertion("assertion")
} catch (e) {
do_check_eq(e.message, "Missing 'scope' parameter");
}
run_next_test();
});
add_test(function successfulResponse () {
let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS);
let response = {
success: true,
status: STATUS_SUCCESS,
body: "{\"access_token\":\"http://example.com/image.jpeg\",\"id\":\"0d5c1a89b8c54580b8e3e8adadae864a\"}",
};
client._Request = new mockResponse(response);
client.getTokenFromAssertion("assertion", "scope")
.then(
function (result) {
do_check_eq(result.access_token, "http://example.com/image.jpeg");
run_next_test();
}
);
});
add_test(function parseErrorResponse () {
let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS);
let response = {
success: true,
status: STATUS_SUCCESS,
body: "unexpected",
};
client._Request = new mockResponse(response);
client.getTokenFromAssertion("assertion", "scope")
.then(
null,
function (e) {
do_check_eq(e.name, "FxAccountsOAuthGrantClientError");
do_check_eq(e.code, STATUS_SUCCESS);
do_check_eq(e.errno, ERRNO_PARSE);
do_check_eq(e.error, ERROR_PARSE);
do_check_eq(e.message, "unexpected");
run_next_test();
}
);
});
add_test(function serverErrorResponse () {
let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS);
let response = {
status: 400,
body: "{ \"code\": 400, \"errno\": 104, \"error\": \"Bad Request\", \"message\": \"Unauthorized\", \"reason\": \"Invalid fxa assertion\" }",
};
client._Request = new mockResponse(response);
client.getTokenFromAssertion("blah", "scope")
.then(
null,
function (e) {
do_check_eq(e.name, "FxAccountsOAuthGrantClientError");
do_check_eq(e.code, 400);
do_check_eq(e.errno, 104);
do_check_eq(e.error, "Bad Request");
do_check_eq(e.message, "Unauthorized");
run_next_test();
}
);
});
add_test(function networkErrorResponse () {
let client = new FxAccountsOAuthGrantClient({
serverURL: "http://",
client_id: "abc123"
});
client.getTokenFromAssertion("assertion", "scope")
.then(
null,
function (e) {
do_check_eq(e.name, "FxAccountsOAuthGrantClientError");
do_check_eq(e.code, null);
do_check_eq(e.errno, ERRNO_NETWORK);
do_check_eq(e.error, ERROR_NETWORK);
run_next_test();
}
);
});
add_test(function unsupportedMethod () {
let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS);
return client._createRequest("/", "PUT")
.then(
null,
function (e) {
do_check_eq(e.name, "FxAccountsOAuthGrantClientError");
do_check_eq(e.code, ERROR_CODE_METHOD_NOT_ALLOWED);
do_check_eq(e.errno, ERRNO_NETWORK);
do_check_eq(e.error, ERROR_NETWORK);
do_check_eq(e.message, ERROR_MSG_METHOD_NOT_ALLOWED);
run_next_test();
}
);
});
add_test(function onCompleteRequestError () {
let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS);
client._Request = new mockResponseError(new Error("onComplete error"));
client.getTokenFromAssertion("assertion", "scope")
.then(
null,
function (e) {
do_check_eq(e.name, "FxAccountsOAuthGrantClientError");
do_check_eq(e.code, null);
do_check_eq(e.errno, ERRNO_NETWORK);
do_check_eq(e.error, ERROR_NETWORK);
do_check_eq(e.message, "Error: onComplete error");
run_next_test();
}
);
});
add_test(function constructorTests() {
validationHelper(undefined,
"Error: Missing configuration options");
validationHelper({},
"Error: Missing 'serverURL' parameter");
validationHelper({ serverURL: "http://example.com" },
"Error: Missing 'client_id' parameter");
validationHelper({ client_id: "123ABC" },
"Error: Missing 'serverURL' parameter");
validationHelper({ client_id: "123ABC", serverURL: "badUrl" },
"Error: Invalid 'serverURL'");
run_next_test();
});
add_test(function errorTests() {
let error1 = new FxAccountsOAuthGrantClientError();
do_check_eq(error1.name, "FxAccountsOAuthGrantClientError");
do_check_eq(error1.code, null);
do_check_eq(error1.errno, ERRNO_UNKNOWN_ERROR);
do_check_eq(error1.error, ERROR_UNKNOWN);
do_check_eq(error1.message, null);
let error2 = new FxAccountsOAuthGrantClientError({
code: STATUS_SUCCESS,
errno: 1,
error: "Error",
message: "Something",
});
let fields2 = error2._toStringFields();
let statusCode = 1;
do_check_eq(error2.name, "FxAccountsOAuthGrantClientError");
do_check_eq(error2.code, STATUS_SUCCESS);
do_check_eq(error2.errno, statusCode);
do_check_eq(error2.error, "Error");
do_check_eq(error2.message, "Something");
do_check_eq(fields2.name, "FxAccountsOAuthGrantClientError");
do_check_eq(fields2.code, STATUS_SUCCESS);
do_check_eq(fields2.errno, statusCode);
do_check_eq(fields2.error, "Error");
do_check_eq(fields2.message, "Something");
do_check_true(error2.toString().indexOf("Something") >= 0);
run_next_test();
});
function run_test() {
run_next_test();
}
/**
* Quick way to test the "FxAccountsOAuthGrantClient" constructor.
*
* @param {Object} options
* FxAccountsOAuthGrantClient constructor options
* @param {String} expected
* Expected error message
* @returns {*}
*/
function validationHelper(options, expected) {
try {
new FxAccountsOAuthGrantClient(options);
} catch (e) {
return do_check_eq(e.toString(), expected);
}
throw new Error("Validation helper error");
}

View File

@ -13,4 +13,5 @@ skip-if = appname == 'b2g' # login manager storage only used on desktop.
skip-if = appname != 'b2g'
reason = FxAccountsManager is only available for B2G for now
[test_oauth_client.js]
[test_oauth_grant_client.js]
[test_profile_client.js]