Skip to content

Commit

Permalink
feat(oauth): support Fenix WebChannels
Browse files Browse the repository at this point in the history
  • Loading branch information
vladikoff committed Aug 29, 2019
1 parent 0ada625 commit 8ee8749
Show file tree
Hide file tree
Showing 17 changed files with 617 additions and 89 deletions.
6 changes: 0 additions & 6 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -143,12 +143,6 @@ jobs:
- attach_workspace:
at: ~/

- run: ../../.circleci/test-content-server.sh

# run pairing tests on one node
- deploy:
command: ../../.circleci/test-content-server.sh pairing

- setup_remote_docker

- deploy:
Expand Down
12 changes: 12 additions & 0 deletions packages/fxa-content-server/app/scripts/lib/app-start.js
Original file line number Diff line number Diff line change
Expand Up @@ -334,8 +334,12 @@ Start.prototype = {
_chooseOAuthBrokerContext() {
if (this.isDevicePairingAsAuthority()) {
return Constants.DEVICE_PAIRING_AUTHORITY_CONTEXT;
} else if (this.isOAuthWebChannel() && this.isDevicePairingAsSupplicant()) {
return Constants.DEVICE_PAIRING_WEBCHANNEL_SUPPLICANT_CONTEXT;
} else if (this.isDevicePairingAsSupplicant()) {
return Constants.DEVICE_PAIRING_SUPPLICANT_CONTEXT;
} else if (this.isOAuthWebChannel()) {
return Constants.OAUTH_WEBCHANNEL_CONTEXT;
} else if (this.getUserAgent().isChromeAndroid()) {
return Constants.OAUTH_CHROME_ANDROID_CONTEXT;
} else {
Expand Down Expand Up @@ -646,6 +650,14 @@ Start.prototype = {
Constants.DEVICE_PAIRING_AUTHORITY_REDIRECT_URI
);
},
/**
* Is the user initiating an OAuth flow using WebChannels?
*
* @returns {Boolean}
*/
isOAuthWebChannel() {
return this._searchParam('context') === Constants.OAUTH_WEBCHANNEL_CONTEXT;
},

/**
* Is the user navigating to `/pair` or `/pair/` to start the pairing flow?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,13 @@ _.extend(WebChannelReceiver.prototype, Backbone.Events, {
},

receiveMessage(event) {
const detail = event.detail;

let detail;
try {
detail = JSON.parse(event.detail);
} catch (e) {
// TODO: check `typeof event.details`
detail = event.detail;
}
if (!(detail && detail.id)) {
// malformed message
this._logger.error(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const COMMANDS = {
LOADED: 'fxaccounts:loaded',
LOGIN: 'fxaccounts:login',
LOGOUT: 'fxaccounts:logout',
OAUTH_LOGIN: 'fxaccounts:oauth_login',
PAIR_AUTHORIZE: 'fxaccounts:pair_authorize',
PAIR_COMPLETE: 'fxaccounts:pair_complete',
PAIR_DECLINE: 'fxaccounts:pair_decline',
Expand Down
5 changes: 5 additions & 0 deletions packages/fxa-content-server/app/scripts/lib/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ module.exports = {
FX_FENNEC_V1_CONTEXT: 'fx_fennec_v1',
FX_IOS_V1_CONTEXT: 'fx_ios_v1',
OAUTH_CONTEXT: 'oauth',
OAUTH_WEBCHANNEL_CONTEXT: 'oauth_webchannel_v1',
OAUTH_CHROME_ANDROID_CONTEXT: 'oauth_chrome_android',

CONTENT_SERVER_SERVICE: 'content-server',
Expand Down Expand Up @@ -68,6 +69,8 @@ module.exports = {
'profile:email',
'profile:uid',
],
OAUTH_WEBCHANNEL_REDIRECT:
'urn:ietf:wg:oauth:2.0:oob:oauth-redirect-webchannel',

RELIER_KEYS_LENGTH: 32,
RELIER_KEYS_CONTEXT_INFO_PREFIX: 'identity.mozilla.com/picl/v1/oauth/',
Expand Down Expand Up @@ -144,6 +147,8 @@ module.exports = {
'https://identity.mozilla.com/apps/oldsync',
],
DEVICE_PAIRING_SUPPLICANT_CONTEXT: 'device_pairing_supplicant',
DEVICE_PAIRING_WEBCHANNEL_SUPPLICANT_CONTEXT:
'device_pairing_webchannel_supplicant',

TWO_STEP_AUTHENTICATION_ACR: 'AAL2',

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ import FxDesktopV3broker from '../auth_brokers/fx-desktop-v3';
import FxFennecV1Broker from '../auth_brokers/fx-fennec-v1';
import FxIosV1Broker from '../auth_brokers/fx-ios-v1';
import OauthRedirectBroker from '../auth_brokers/oauth-redirect';
import OauthWebChannelBroker from '../auth_brokers/oauth-webchannel-v1';
import OauthRedirectChromeAndroidBroker from '../auth_brokers/oauth-redirect-chrome-android';
import WebBroker from '../auth_brokers/web';
import AuthorityBroker from '../auth_brokers/pairing/authority';
import SupplicantBroker from '../auth_brokers/pairing/supplicant';
import SupplicantWebChannelBroker from '../auth_brokers/pairing/supplicant-webchannel';

const AUTH_BROKERS = [
/* eslint-disable sorting/sort-object-props */
Expand All @@ -41,6 +43,10 @@ const AUTH_BROKERS = [
context: Constants.OAUTH_CONTEXT,
Constructor: OauthRedirectBroker,
},
{
context: Constants.OAUTH_WEBCHANNEL_CONTEXT,
Constructor: OauthWebChannelBroker,
},
{
context: Constants.OAUTH_CHROME_ANDROID_CONTEXT,
Constructor: OauthRedirectChromeAndroidBroker,
Expand All @@ -57,6 +63,10 @@ const AUTH_BROKERS = [
context: Constants.DEVICE_PAIRING_SUPPLICANT_CONTEXT,
Constructor: SupplicantBroker,
},
{
context: Constants.DEVICE_PAIRING_WEBCHANNEL_SUPPLICANT_CONTEXT,
Constructor: SupplicantWebChannelBroker,
},
/* eslint-enable sorting/sort-object-props */
].reduce((authBrokers, authBroker) => {
authBrokers[authBroker.context] = authBroker.Constructor;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ export default BaseAuthenticationBroker.extend({
this.clearOriginalTabMarker();
return this.getOAuthResult(account).then(result => {
result = _.extend(result, additionalResultData);
return this.sendOAuthResultToRelier(result);
return this.sendOAuthResultToRelier(result, account);
});
});
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/* 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/. */

/**
* WebChannel OAuth broker that speaks 'v1' of the protocol.
*/

import _ from 'underscore';
import ChannelMixin from './mixins/channel';
import Cocktail from 'cocktail';
import Constants from '../../lib/constants';
import HaltBehavior from '../../views/behaviors/halt';
import OAuthRedirectAuthenticationBroker from './oauth-redirect';
import ScopedKeys from 'lib/crypto/scoped-keys';
import WebChannel from '../../lib/channels/web';
import SyncEngines from '../sync-engines';

const proto = OAuthRedirectAuthenticationBroker.prototype;

const OAuthWebChannelBroker = OAuthRedirectAuthenticationBroker.extend({
defaultBehaviors: _.extend({}, proto.defaultBehaviors, {
afterForceAuth: new HaltBehavior(),
afterSignIn: new HaltBehavior(),
}),

defaultCapabilities: _.extend({}, proto.defaultCapabilities, {
chooseWhatToSyncWebV1: true,
fxaStatus: true,
openWebmailButtonVisible: false,
}),

commands: _.pick(WebChannel, 'FXA_STATUS', 'OAUTH_LOGIN'),

type: 'oauth-webchannel-v1',

initialize(options = {}) {
this.session = options.session;
this._channel = options.channel;
this._scopedKeys = ScopedKeys;
this._metrics = options.metrics;

proto.initialize.call(this, options);

this.request(
this.getCommand('FXA_STATUS', {
service: this.relier.get('service'),
})
).then(response => this.onFxaStatus(response));
},

/**
* Handle a response to the `fxa_status` message.
*
* @param {any} [response={}]
* @private
*/
onFxaStatus(response = {}) {
const supportedEngines =
response.capabilities && response.capabilities.engines;
if (supportedEngines) {
// supportedEngines override the defaults
const syncEngines = new SyncEngines(null, {
engines: supportedEngines,
window: this.window,
});
return this.set('chooseWhatToSyncWebV1Engines', syncEngines);
}
},

/**
* Get a reference to a channel. If a channel has already been created,
* the cached channel will be returned. Used by the ChannelMixin.
*
* @method getChannel
* @returns {Object} channel
*/
getChannel() {
if (!this._channel) {
this._channel = this.createChannel();
}

return this._channel;
},

createChannel() {
const channel = new WebChannel(Constants.ACCOUNT_UPDATES_WEBCHANNEL_ID);
channel.initialize({
window: this.window,
});

return channel;
},

DELAY_BROKER_RESPONSE_MS: 100,

sendOAuthResultToRelier(result, account) {
return this._metrics.flush().then(() => {
const extraParams = {};
if (result.error) {
extraParams.error = result.error;
}
if (result.action) {
extraParams.action = result.action;
}

result.redirect = Constants.OAUTH_WEBCHANNEL_REDIRECT;
if (account) {
// pairing flow inherits from the broker, but at this time it doesn't offer CWTS
result.declinedSyncEngines = account.get('declinedSyncEngines');
result.offeredSyncEngines = account.get('offeredSyncEngines');
}

return this.send(this.getCommand('OAUTH_LOGIN'), result);
});
},

getCommand(commandName) {
if (!this.commands) {
throw new Error('this.commands must be specified');
}

const command = this.commands[commandName];
if (!command) {
throw new Error('command not found for: ' + commandName);
}

return command;
},
});

Cocktail.mixin(OAuthWebChannelBroker, ChannelMixin);

export default OAuthWebChannelBroker;
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/* 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/. */

import OAuthErrors from '../../../lib/oauth-errors';
import OAuthWebChannelBroker from '../oauth-webchannel-v1';
import PairingChannelClient from '../../../lib/pairing-channel-client';
import setRemoteMetaData from './remote-metadata';
import SupplicantStateMachine from '../../pairing/supplicant-state-machine';
import Url from '../../../lib/url';

export default class SupplicantWebChannelBroker extends OAuthWebChannelBroker {
type = 'supplicant';

initialize(options = {}) {
super.initialize(options);
const { config, notifier, relier } = options;

if (!config.pairingClients.includes(relier.get('clientId'))) {
// only approved clients may pair
throw OAuthErrors.toError('INVALID_PAIRING_CLIENT');
}

const channelServerUri = config.pairingChannelServerUri;
const { channelId, channelKey } = relier.toJSON();
if (channelId && channelKey && channelServerUri) {
this.pairingChannelClient = new PairingChannelClient(
{
channelId,
channelKey,
channelServerUri,
},
{
importPairingChannel: options.importPairingChannel,
notifier,
}
);

this.suppStateMachine = new SupplicantStateMachine(
{},
{
broker: this,
notifier,
pairingChannelClient: this.pairingChannelClient,
relier,
}
);

this.pairingChannelClient.open();
} else {
throw new Error('Failed to initialize supplicant');
}
}

afterSupplicantApprove() {
return Promise.resolve().then(() => {
this.notifier.trigger('pair:supp:authorize');
});
}

sendCodeToRelier() {
return Promise.resolve().then(() => {
const relier = this.relier;
const result = {
redirect: relier.get('redirectUri'),
code: relier.get('code'),
state: relier.get('state'),
};

this.sendOAuthResultToRelier(result);
});
}

setRemoteMetaData = setRemoteMetaData;
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ const SUPPLICANT_QUERY_PARAM_SCHEMA = {
.required()
.renameTo('keysJwk'),
redirect_uri: Vat.url()
.required()
// redirect URI is not required for OAuth flows,
// we only validate it in the OAuth broker if it is provided
// See `isCorrectRedirect` app/scripts/models/reliers/oauth.js
.renameTo('redirectUri'),
scope: Vat.string()
.required()
Expand Down
Loading

0 comments on commit 8ee8749

Please sign in to comment.