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

Notify push and email on code exchanges #2985

Merged
merged 1 commit into from
Apr 1, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/devices.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ module.exports = (log, db, push) => {
deviceName = synthesizeName(deviceInfo);
}
if (credentials.tokenVerified) {
request.app.devices.then(devices => {
db.devices(credentials.uid).then(devices => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why was this needed? I wonder if we can find a way to fix request.app.devices so that we maintain the nice caching behaviour.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const otherDevices = devices.filter(device => device.id !== result.id);
return push.notifyDeviceConnected(credentials.uid, otherDevices, deviceName);
});
Expand Down
26 changes: 26 additions & 0 deletions lib/oauthdb/check-access-token.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/* 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';

const Joi = require('joi');
const validators = require('../routes/validators');

module.exports = (config) => {
return {
path: '/v1/verify',
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get rid of this, /token endpoint can provide the uid

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fxa-uid field

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in #3000

method: 'POST',
validate: {
payload: {
token: validators.accessToken.required(),
},
response: {
user: Joi.string().required(),
client_id: Joi.string().required(),
scope: Joi.array(),
profile_changed_at: Joi.number().min(0)
}
}
};
};
12 changes: 9 additions & 3 deletions lib/oauthdb/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ module.exports = (log, config) => {
grantTokensFromAuthorizationCode: require('./grant-tokens-from-authorization-code')(config),
grantTokensFromRefreshToken: require('./grant-tokens-from-refresh-token')(config),
grantTokensFromCredentials: require('./grant-tokens-from-credentials')(config),
checkAccessToken: require('./check-access-token')(config),
});

const api = new OAuthAPI(config.oauth.url, config.oauth.poolee);
Expand Down Expand Up @@ -115,16 +116,21 @@ module.exports = (log, config) => {
}
},

async checkAccessToken(token) {
try {
return await api.checkAccessToken(token);
} catch (err) {
throw mapOAuthError(log, err);
}
}

/* As we work through the process of merging oauth-server
* into auth-server, future methods we might want to include
* here will be things like the following:

async getClientInstances(account) {
},

async checkAccessToken(token) {
}

async revokeAccessToken(token) {
}

Expand Down
2 changes: 1 addition & 1 deletion lib/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ module.exports = function (
push,
verificationReminders,
);
const oauth = require('./oauth')(log, config, oauthdb);
const oauth = require('./oauth')(log, config, oauthdb, db, mailer, devicesImpl);
const devicesSessions = require('./devices-and-sessions')(log, db, config, customs, push, pushbox, devicesImpl, oauthdb);
const emails = require('./emails')(log, db, mailer, config, customs, push, verificationReminders);
const password = require('./password')(
Expand Down
21 changes: 17 additions & 4 deletions lib/routes/oauth.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@
const Joi = require('joi');

const error = require('../error');
const oauthRouteUtils = require('./utils/oauth');

module.exports = (log, config, oauthdb) => {
module.exports = (log, config, oauthdb, db, mailer, devices) => {
const routes = [
{
method: 'GET',
Expand Down Expand Up @@ -110,19 +111,31 @@ module.exports = (log, config, oauthdb) => {
},
handler: async function (request) {
const sessionToken = request.auth.credentials;
let grant;
switch (request.payload.grant_type) {
case 'authorization_code':
return await oauthdb.grantTokensFromAuthorizationCode(request.payload);
grant = await oauthdb.grantTokensFromAuthorizationCode(request.payload);
break;
case 'refresh_token':
return await oauthdb.grantTokensFromRefreshToken(request.payload);
grant = await oauthdb.grantTokensFromRefreshToken(request.payload);
break;
case 'fxa-credentials':
if (! sessionToken) {
throw error.invalidToken();
}
return await oauthdb.grantTokensFromSessionToken(sessionToken, request.payload);
grant = await oauthdb.grantTokensFromSessionToken(sessionToken, request.payload);
break;
default:
throw error.internalValidationError();
}

if (grant.refresh_token) {
// if a refresh token has been provisioned as part of the flow
// then we want to send some notifications to the user
await oauthRouteUtils.newTokenNotification(db, oauthdb, mailer, devices, request, grant);
}

return grant;
}
},
];
Expand Down
62 changes: 62 additions & 0 deletions lib/routes/utils/oauth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/* 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';

const encrypt = require('../../../fxa-oauth-server/lib/encrypt');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this going to be OK at runtime in the docker images we build for production? I wonder if it would be less risky to just copy the code over here, it's only 3 lines worth.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah! we have the technology!

const ScopeSet = require('fxa-shared').oauth.scopes;

// right now we only care about notifications for the following scopes
// if not a match, then we don't notify
const NOTIFICATION_SCOPES = ScopeSet.fromArray(['https://identity.mozilla.com/apps/oldsync']);

module.exports = {
newTokenNotification: async function newTokenNotification (db, oauthdb, mailer, devices, request, grant) {
const clientId = request.payload.client_id;
const scopeSet = ScopeSet.fromString(grant.scope);
const credentials = request.auth && request.auth.credentials || {};

if (! scopeSet.intersects(NOTIFICATION_SCOPES)) {
// right now we only care about notifications for the `oldsync` scope
// if not a match, then we don't do any notifications
return;
}

if (! credentials.uid) {
// this can be removed once issue #3000 has been resolved
const tokenVerify = await oauthdb.checkAccessToken({
token: grant.access_token
});
// some grant flows won't have the uid in `credentials`
credentials.uid = tokenVerify.user;
}

if (! credentials.refreshTokenId) {
// provide a refreshToken for the device creation below
credentials.refreshTokenId = encrypt.hash(grant.refresh_token).toString('hex');
}

// we set tokenVerified because the granted scope is part of NOTIFICATION_SCOPES
credentials.tokenVerified = true;
credentials.client = await oauthdb.getClientInfo(clientId);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We ought to be able to do a cached lookup here, but not for this PR; I filed https://github.com/mozilla/fxa-auth-server/issues/3006 as a followup.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


// The following upsert gets no `deviceInfo`.
// However, `credentials.client` lets it generate a default name for the device.
await devices.upsert(request, credentials, {});

const geoData = request.app.geo;
const ip = request.app.clientAddress;
const emailOptions = {
acceptLanguage: request.app.acceptLanguage,
ip,
location: geoData.location,
service: clientId,
timeZone: geoData.timeZone,
uid: credentials.uid
};

const account = await db.account(credentials.uid);
await mailer.sendNewDeviceLoginNotification(account.emails, account, emailOptions);
}
};
24 changes: 22 additions & 2 deletions test/local/oauthdb.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ const mockConfig = {
domain: 'accounts.example.com'
};

const MOCK_UID = 'ABCDEF';
const MOCK_UID = '1a147912d8de4ab5842ecc9fb7186800';
const MOCK_CLIENT_ID = '0123456789ABCDEF';
const MOCK_SCOPES = 'mock-scope another-scope';
const MOCK_TOKEN = '8ddd955475561c723d38863defc558788aee362c4f28df76b997ae62646a7b43';
const MOCK_CLIENT_INFO = {
id: MOCK_CLIENT_ID,
name: 'mock client',
Expand Down Expand Up @@ -133,7 +135,6 @@ describe('oauthdb', () => {
describe('getScopedKeyData', () => {

const ZEROS = Buffer.alloc(32).toString('hex');
const MOCK_SCOPES = 'mock-scope another-scope';
const MOCK_CREDENTIALS = {
uid: MOCK_UID,
verifierSetAt: 12345,
Expand Down Expand Up @@ -301,4 +302,23 @@ describe('oauthdb', () => {

});

describe('checkAccessToken', () => {
it('works', async () => {
const verifyResponse = {
user: MOCK_UID,
client_id: MOCK_CLIENT_ID,
scope: ['https://identity.mozilla.com/apps/oldsync', 'openid']
};

mockOAuthServer.post('/v1/verify', body => true)
.reply(200, verifyResponse);
oauthdb = oauthdbModule(mockLog(), mockConfig);
const response = await oauthdb.checkAccessToken({
token: MOCK_TOKEN
});

assert.deepEqual(verifyResponse, response);
});
});

});
100 changes: 100 additions & 0 deletions test/local/routes/utils/oauth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/* 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';

const sinon = require('sinon');
const assert = { ...sinon.assert, ...require('chai').assert };
const mocks = require('../../../mocks');

const TEST_EMAIL = '[email protected]';
const MOCK_UID = '23d4847823f24b0f95e1524987cb0391';
const MOCK_REFRESH_TOKEN = '40f61392cf69b0be709fbd3122d0726bb32247b476b2a28451345e7a5555cec7';
const MOCK_REFRESH_TOKEN_2 = '00661392cf69b0be709fbd3122d0726bb32247b476b2a28451345e7a5555cec7';
const MOCK_REFRESH_TOKEN_ID_2 = '0e4f2255bed0ae53af401150488e69f22beae103b7d6857a5194df00c9827d19';
const OAUTH_CLIENT_ID = '3c49430b43dfba77';
const MOCK_CHECK_RESPONSE = {
user: MOCK_UID,
client_id: OAUTH_CLIENT_ID,
scope: ['https://identity.mozilla.com/apps/oldsync', 'openid']
};

describe('newTokenNotification', () => {
let db;
let oauthdb;
let mailer;
let devices;
let request;
let credentials;
let grant;
const oauthUtils = require('../../../../lib/routes/utils/oauth');

beforeEach(() => {
db = mocks.mockDB({
email: TEST_EMAIL,
emailVerified: true,
uid: MOCK_UID
});
oauthdb = mocks.mockOAuthDB({
checkAccessToken: sinon.spy(async () => {
return MOCK_CHECK_RESPONSE;
})
});
mailer = mocks.mockMailer();
devices = mocks.mockDevices();
credentials = {
uid: MOCK_UID,
refreshTokenId: MOCK_REFRESH_TOKEN
};
request = mocks.mockRequest({credentials});
grant = {
scope: 'profile https://identity.mozilla.com/apps/oldsync',
refresh_token: MOCK_REFRESH_TOKEN_2
};
});

it('creates a device and sends an email with credentials uid', async () => {
await oauthUtils.newTokenNotification(db, oauthdb, mailer, devices, request, grant);

assert.equal(oauthdb.checkAccessToken.callCount, 0);
assert.equal(mailer.sendNewDeviceLoginNotification.callCount, 1, 'sent email notification');
assert.equal(devices.upsert.callCount, 1, 'created a device');
const args = devices.upsert.args[0];
assert.equal(args[1].refreshTokenId, request.auth.credentials.refreshTokenId);
});

it('creates a device and sends an email with checkAccessToken uid', async () => {
credentials = {};
request = mocks.mockRequest({credentials});
await oauthUtils.newTokenNotification(db, oauthdb, mailer, devices, request, grant);

assert.equal(oauthdb.checkAccessToken.callCount, 1);
assert.equal(mailer.sendNewDeviceLoginNotification.callCount, 1, 'sent email notification');
assert.equal(devices.upsert.callCount, 1, 'created a device');
});

it('does nothing for non-NOTIFICATION_SCOPES', async () => {
grant.scope = 'profile';
await oauthUtils.newTokenNotification(db, oauthdb, mailer, devices, request, grant);

assert.equal(oauthdb.checkAccessToken.callCount, 0);
assert.equal(mailer.sendNewDeviceLoginNotification.callCount, 0);
assert.equal(devices.upsert.callCount, 0);
});

it('uses refreshTokenId from grant if not provided', async () => {
credentials = {
uid: MOCK_UID,
};
request = mocks.mockRequest({credentials});
await oauthUtils.newTokenNotification(db, oauthdb, mailer, devices, request, grant);

assert.equal(oauthdb.checkAccessToken.callCount, 0);
assert.equal(mailer.sendNewDeviceLoginNotification.callCount, 1);
assert.equal(devices.upsert.callCount, 1);
const args = devices.upsert.args[0];
assert.equal(args[1].refreshTokenId, MOCK_REFRESH_TOKEN_ID_2);
});

});
1 change: 1 addition & 0 deletions test/mocks.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ const DB_METHOD_NAMES = [
];

const OAUTHDB_METHOD_NAMES = [
'checkAccessToken',
'checkRefreshToken',
'revokeRefreshTokenById',
'getClientInfo',
Expand Down
Loading