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

Commit 2e1b01f

Browse files
authored
Merge pull request #2985 from mozilla/fenix-token-exchanges
Notify push and email on code exchanges
2 parents 158e1ad + 2e25c45 commit 2e1b01f

File tree

10 files changed

+268
-12
lines changed

10 files changed

+268
-12
lines changed

lib/devices.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ module.exports = (log, db, push) => {
114114
deviceName = synthesizeName(deviceInfo);
115115
}
116116
if (credentials.tokenVerified) {
117-
request.app.devices.then(devices => {
117+
db.devices(credentials.uid).then(devices => {
118118
const otherDevices = devices.filter(device => device.id !== result.id);
119119
return push.notifyDeviceConnected(credentials.uid, otherDevices, deviceName);
120120
});

lib/oauthdb/check-access-token.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
'use strict';
6+
7+
const Joi = require('joi');
8+
const validators = require('../routes/validators');
9+
10+
module.exports = (config) => {
11+
return {
12+
path: '/v1/verify',
13+
method: 'POST',
14+
validate: {
15+
payload: {
16+
token: validators.accessToken.required(),
17+
},
18+
response: {
19+
user: Joi.string().required(),
20+
client_id: Joi.string().required(),
21+
scope: Joi.array(),
22+
profile_changed_at: Joi.number().min(0)
23+
}
24+
}
25+
};
26+
};

lib/oauthdb/index.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ module.exports = (log, config) => {
3232
grantTokensFromAuthorizationCode: require('./grant-tokens-from-authorization-code')(config),
3333
grantTokensFromRefreshToken: require('./grant-tokens-from-refresh-token')(config),
3434
grantTokensFromCredentials: require('./grant-tokens-from-credentials')(config),
35+
checkAccessToken: require('./check-access-token')(config),
3536
});
3637

3738
const api = new OAuthAPI(config.oauth.url, config.oauth.poolee);
@@ -115,16 +116,21 @@ module.exports = (log, config) => {
115116
}
116117
},
117118

119+
async checkAccessToken(token) {
120+
try {
121+
return await api.checkAccessToken(token);
122+
} catch (err) {
123+
throw mapOAuthError(log, err);
124+
}
125+
}
126+
118127
/* As we work through the process of merging oauth-server
119128
* into auth-server, future methods we might want to include
120129
* here will be things like the following:
121130
122131
async getClientInstances(account) {
123132
},
124133
125-
async checkAccessToken(token) {
126-
}
127-
128134
async revokeAccessToken(token) {
129135
}
130136

lib/routes/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ module.exports = function (
3838
push,
3939
verificationReminders,
4040
);
41-
const oauth = require('./oauth')(log, config, oauthdb);
41+
const oauth = require('./oauth')(log, config, oauthdb, db, mailer, devicesImpl);
4242
const devicesSessions = require('./devices-and-sessions')(log, db, config, customs, push, pushbox, devicesImpl, oauthdb);
4343
const emails = require('./emails')(log, db, mailer, config, customs, push, verificationReminders);
4444
const password = require('./password')(

lib/routes/oauth.js

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@
1818
const Joi = require('joi');
1919

2020
const error = require('../error');
21+
const oauthRouteUtils = require('./utils/oauth');
2122

22-
module.exports = (log, config, oauthdb) => {
23+
module.exports = (log, config, oauthdb, db, mailer, devices) => {
2324
const routes = [
2425
{
2526
method: 'GET',
@@ -110,19 +111,31 @@ module.exports = (log, config, oauthdb) => {
110111
},
111112
handler: async function (request) {
112113
const sessionToken = request.auth.credentials;
114+
let grant;
113115
switch (request.payload.grant_type) {
114116
case 'authorization_code':
115-
return await oauthdb.grantTokensFromAuthorizationCode(request.payload);
117+
grant = await oauthdb.grantTokensFromAuthorizationCode(request.payload);
118+
break;
116119
case 'refresh_token':
117-
return await oauthdb.grantTokensFromRefreshToken(request.payload);
120+
grant = await oauthdb.grantTokensFromRefreshToken(request.payload);
121+
break;
118122
case 'fxa-credentials':
119123
if (! sessionToken) {
120124
throw error.invalidToken();
121125
}
122-
return await oauthdb.grantTokensFromSessionToken(sessionToken, request.payload);
126+
grant = await oauthdb.grantTokensFromSessionToken(sessionToken, request.payload);
127+
break;
123128
default:
124129
throw error.internalValidationError();
125130
}
131+
132+
if (grant.refresh_token) {
133+
// if a refresh token has been provisioned as part of the flow
134+
// then we want to send some notifications to the user
135+
await oauthRouteUtils.newTokenNotification(db, oauthdb, mailer, devices, request, grant);
136+
}
137+
138+
return grant;
126139
}
127140
},
128141
];

lib/routes/utils/oauth.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
'use strict';
6+
7+
const encrypt = require('../../../fxa-oauth-server/lib/encrypt');
8+
const ScopeSet = require('fxa-shared').oauth.scopes;
9+
10+
// right now we only care about notifications for the following scopes
11+
// if not a match, then we don't notify
12+
const NOTIFICATION_SCOPES = ScopeSet.fromArray(['https://identity.mozilla.com/apps/oldsync']);
13+
14+
module.exports = {
15+
newTokenNotification: async function newTokenNotification (db, oauthdb, mailer, devices, request, grant) {
16+
const clientId = request.payload.client_id;
17+
const scopeSet = ScopeSet.fromString(grant.scope);
18+
const credentials = request.auth && request.auth.credentials || {};
19+
20+
if (! scopeSet.intersects(NOTIFICATION_SCOPES)) {
21+
// right now we only care about notifications for the `oldsync` scope
22+
// if not a match, then we don't do any notifications
23+
return;
24+
}
25+
26+
if (! credentials.uid) {
27+
// this can be removed once issue #3000 has been resolved
28+
const tokenVerify = await oauthdb.checkAccessToken({
29+
token: grant.access_token
30+
});
31+
// some grant flows won't have the uid in `credentials`
32+
credentials.uid = tokenVerify.user;
33+
}
34+
35+
if (! credentials.refreshTokenId) {
36+
// provide a refreshToken for the device creation below
37+
credentials.refreshTokenId = encrypt.hash(grant.refresh_token).toString('hex');
38+
}
39+
40+
// we set tokenVerified because the granted scope is part of NOTIFICATION_SCOPES
41+
credentials.tokenVerified = true;
42+
credentials.client = await oauthdb.getClientInfo(clientId);
43+
44+
// The following upsert gets no `deviceInfo`.
45+
// However, `credentials.client` lets it generate a default name for the device.
46+
await devices.upsert(request, credentials, {});
47+
48+
const geoData = request.app.geo;
49+
const ip = request.app.clientAddress;
50+
const emailOptions = {
51+
acceptLanguage: request.app.acceptLanguage,
52+
ip,
53+
location: geoData.location,
54+
service: clientId,
55+
timeZone: geoData.timeZone,
56+
uid: credentials.uid
57+
};
58+
59+
const account = await db.account(credentials.uid);
60+
await mailer.sendNewDeviceLoginNotification(account.emails, account, emailOptions);
61+
}
62+
};

test/local/oauthdb.js

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ const mockConfig = {
2222
domain: 'accounts.example.com'
2323
};
2424

25-
const MOCK_UID = 'ABCDEF';
25+
const MOCK_UID = '1a147912d8de4ab5842ecc9fb7186800';
2626
const MOCK_CLIENT_ID = '0123456789ABCDEF';
27+
const MOCK_SCOPES = 'mock-scope another-scope';
28+
const MOCK_TOKEN = '8ddd955475561c723d38863defc558788aee362c4f28df76b997ae62646a7b43';
2729
const MOCK_CLIENT_INFO = {
2830
id: MOCK_CLIENT_ID,
2931
name: 'mock client',
@@ -133,7 +135,6 @@ describe('oauthdb', () => {
133135
describe('getScopedKeyData', () => {
134136

135137
const ZEROS = Buffer.alloc(32).toString('hex');
136-
const MOCK_SCOPES = 'mock-scope another-scope';
137138
const MOCK_CREDENTIALS = {
138139
uid: MOCK_UID,
139140
verifierSetAt: 12345,
@@ -301,4 +302,23 @@ describe('oauthdb', () => {
301302

302303
});
303304

305+
describe('checkAccessToken', () => {
306+
it('works', async () => {
307+
const verifyResponse = {
308+
user: MOCK_UID,
309+
client_id: MOCK_CLIENT_ID,
310+
scope: ['https://identity.mozilla.com/apps/oldsync', 'openid']
311+
};
312+
313+
mockOAuthServer.post('/v1/verify', body => true)
314+
.reply(200, verifyResponse);
315+
oauthdb = oauthdbModule(mockLog(), mockConfig);
316+
const response = await oauthdb.checkAccessToken({
317+
token: MOCK_TOKEN
318+
});
319+
320+
assert.deepEqual(verifyResponse, response);
321+
});
322+
});
323+
304324
});

test/local/routes/utils/oauth.js

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
'use strict';
6+
7+
const sinon = require('sinon');
8+
const assert = { ...sinon.assert, ...require('chai').assert };
9+
const mocks = require('../../../mocks');
10+
11+
const TEST_EMAIL = 'foo@gmail.com';
12+
const MOCK_UID = '23d4847823f24b0f95e1524987cb0391';
13+
const MOCK_REFRESH_TOKEN = '40f61392cf69b0be709fbd3122d0726bb32247b476b2a28451345e7a5555cec7';
14+
const MOCK_REFRESH_TOKEN_2 = '00661392cf69b0be709fbd3122d0726bb32247b476b2a28451345e7a5555cec7';
15+
const MOCK_REFRESH_TOKEN_ID_2 = '0e4f2255bed0ae53af401150488e69f22beae103b7d6857a5194df00c9827d19';
16+
const OAUTH_CLIENT_ID = '3c49430b43dfba77';
17+
const MOCK_CHECK_RESPONSE = {
18+
user: MOCK_UID,
19+
client_id: OAUTH_CLIENT_ID,
20+
scope: ['https://identity.mozilla.com/apps/oldsync', 'openid']
21+
};
22+
23+
describe('newTokenNotification', () => {
24+
let db;
25+
let oauthdb;
26+
let mailer;
27+
let devices;
28+
let request;
29+
let credentials;
30+
let grant;
31+
const oauthUtils = require('../../../../lib/routes/utils/oauth');
32+
33+
beforeEach(() => {
34+
db = mocks.mockDB({
35+
email: TEST_EMAIL,
36+
emailVerified: true,
37+
uid: MOCK_UID
38+
});
39+
oauthdb = mocks.mockOAuthDB({
40+
checkAccessToken: sinon.spy(async () => {
41+
return MOCK_CHECK_RESPONSE;
42+
})
43+
});
44+
mailer = mocks.mockMailer();
45+
devices = mocks.mockDevices();
46+
credentials = {
47+
uid: MOCK_UID,
48+
refreshTokenId: MOCK_REFRESH_TOKEN
49+
};
50+
request = mocks.mockRequest({credentials});
51+
grant = {
52+
scope: 'profile https://identity.mozilla.com/apps/oldsync',
53+
refresh_token: MOCK_REFRESH_TOKEN_2
54+
};
55+
});
56+
57+
it('creates a device and sends an email with credentials uid', async () => {
58+
await oauthUtils.newTokenNotification(db, oauthdb, mailer, devices, request, grant);
59+
60+
assert.equal(oauthdb.checkAccessToken.callCount, 0);
61+
assert.equal(mailer.sendNewDeviceLoginNotification.callCount, 1, 'sent email notification');
62+
assert.equal(devices.upsert.callCount, 1, 'created a device');
63+
const args = devices.upsert.args[0];
64+
assert.equal(args[1].refreshTokenId, request.auth.credentials.refreshTokenId);
65+
});
66+
67+
it('creates a device and sends an email with checkAccessToken uid', async () => {
68+
credentials = {};
69+
request = mocks.mockRequest({credentials});
70+
await oauthUtils.newTokenNotification(db, oauthdb, mailer, devices, request, grant);
71+
72+
assert.equal(oauthdb.checkAccessToken.callCount, 1);
73+
assert.equal(mailer.sendNewDeviceLoginNotification.callCount, 1, 'sent email notification');
74+
assert.equal(devices.upsert.callCount, 1, 'created a device');
75+
});
76+
77+
it('does nothing for non-NOTIFICATION_SCOPES', async () => {
78+
grant.scope = 'profile';
79+
await oauthUtils.newTokenNotification(db, oauthdb, mailer, devices, request, grant);
80+
81+
assert.equal(oauthdb.checkAccessToken.callCount, 0);
82+
assert.equal(mailer.sendNewDeviceLoginNotification.callCount, 0);
83+
assert.equal(devices.upsert.callCount, 0);
84+
});
85+
86+
it('uses refreshTokenId from grant if not provided', async () => {
87+
credentials = {
88+
uid: MOCK_UID,
89+
};
90+
request = mocks.mockRequest({credentials});
91+
await oauthUtils.newTokenNotification(db, oauthdb, mailer, devices, request, grant);
92+
93+
assert.equal(oauthdb.checkAccessToken.callCount, 0);
94+
assert.equal(mailer.sendNewDeviceLoginNotification.callCount, 1);
95+
assert.equal(devices.upsert.callCount, 1);
96+
const args = devices.upsert.args[0];
97+
assert.equal(args[1].refreshTokenId, MOCK_REFRESH_TOKEN_ID_2);
98+
});
99+
100+
});

test/mocks.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ const DB_METHOD_NAMES = [
8787
];
8888

8989
const OAUTHDB_METHOD_NAMES = [
90+
'checkAccessToken',
9091
'checkRefreshToken',
9192
'revokeRefreshTokenById',
9293
'getClientInfo',

0 commit comments

Comments
 (0)