diff --git a/lib/devices.js b/lib/devices.js index cb40323d6..2c927c0a0 100644 --- a/lib/devices.js +++ b/lib/devices.js @@ -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 => { const otherDevices = devices.filter(device => device.id !== result.id); return push.notifyDeviceConnected(credentials.uid, otherDevices, deviceName); }); diff --git a/lib/oauthdb/check-access-token.js b/lib/oauthdb/check-access-token.js new file mode 100644 index 000000000..ce8f50d23 --- /dev/null +++ b/lib/oauthdb/check-access-token.js @@ -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', + 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) + } + } + }; +}; diff --git a/lib/oauthdb/index.js b/lib/oauthdb/index.js index f29feeed2..15f242f79 100644 --- a/lib/oauthdb/index.js +++ b/lib/oauthdb/index.js @@ -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); @@ -115,6 +116,14 @@ 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: @@ -122,9 +131,6 @@ module.exports = (log, config) => { async getClientInstances(account) { }, - async checkAccessToken(token) { - } - async revokeAccessToken(token) { } diff --git a/lib/routes/index.js b/lib/routes/index.js index e3c40f281..2589d5130 100644 --- a/lib/routes/index.js +++ b/lib/routes/index.js @@ -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')( diff --git a/lib/routes/oauth.js b/lib/routes/oauth.js index 95871f85a..4a3577ab9 100644 --- a/lib/routes/oauth.js +++ b/lib/routes/oauth.js @@ -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', @@ -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; } }, ]; diff --git a/lib/routes/utils/oauth.js b/lib/routes/utils/oauth.js new file mode 100644 index 000000000..8d8a80614 --- /dev/null +++ b/lib/routes/utils/oauth.js @@ -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'); +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); + + // 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); + } +}; diff --git a/test/local/oauthdb.js b/test/local/oauthdb.js index 951a01168..179203406 100644 --- a/test/local/oauthdb.js +++ b/test/local/oauthdb.js @@ -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', @@ -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, @@ -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); + }); + }); + }); diff --git a/test/local/routes/utils/oauth.js b/test/local/routes/utils/oauth.js new file mode 100644 index 000000000..aa7892eea --- /dev/null +++ b/test/local/routes/utils/oauth.js @@ -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 = 'foo@gmail.com'; +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); + }); + +}); diff --git a/test/mocks.js b/test/mocks.js index 6aceb89dd..b0607c8ec 100644 --- a/test/mocks.js +++ b/test/mocks.js @@ -87,6 +87,7 @@ const DB_METHOD_NAMES = [ ]; const OAUTHDB_METHOD_NAMES = [ + 'checkAccessToken', 'checkRefreshToken', 'revokeRefreshTokenById', 'getClientInfo', diff --git a/test/remote/oauth_tests.js b/test/remote/oauth_tests.js index 0aaaf1844..aebf79cda 100644 --- a/test/remote/oauth_tests.js +++ b/test/remote/oauth_tests.js @@ -13,6 +13,7 @@ const testUtils = require('../lib/util'); const oauthServerModule = require('../../fxa-oauth-server/lib/server'); const PUBLIC_CLIENT_ID = '3c49430b43dfba77'; +const OAUTH_CLIENT_NAME = 'Android Components Reference Browser'; const MOCK_CODE_VERIFIER = 'abababababababababababababababababababababa'; const MOCK_CODE_CHALLENGE = 'YPhkZqm08uTfwjNSiYcx80-NPT9Zn94kHboQW97KyV0'; @@ -86,8 +87,12 @@ describe('/oauth/ routes', function () { } }); - it('successfully grants tokens from sessionToken', async () => { + it('successfully grants tokens from sessionToken and notifies user', async () => { const SCOPE = 'https://identity.mozilla.com/apps/oldsync'; + + let devices = await client.devices(); + assert.equal(devices.length, 0, 'no devices yet'); + const res = await client.grantOAuthTokensFromSessionToken({ grant_type: 'fxa-credentials', client_id: PUBLIC_CLIENT_ID, @@ -101,11 +106,25 @@ describe('/oauth/ routes', function () { assert.ok(res.auth_at); assert.ok(res.expires_in); assert.ok(res.token_type); + + // got an email notification + const emailData = await server.mailbox.waitForEmail(email); + assert.equal(emailData.headers['x-template-name'], 'newDeviceLoginEmail', 'correct template'); + assert.equal(emailData.subject, `New sign-in to ${OAUTH_CLIENT_NAME}`, 'has client name'); + assert.equal(emailData.headers['x-service-id'], PUBLIC_CLIENT_ID, 'has client id'); + + // added a new device + devices = await client.devicesWithRefreshToken(res.refresh_token); + assert.equal(devices.length, 1, 'new device'); + assert.equal(devices[0].name, OAUTH_CLIENT_NAME); }); it('successfully grants tokens via authentication code flow, and refresh token flow', async () => { const SCOPE = 'https://identity.mozilla.com/apps/oldsync openid'; + let devices = await client.devices(); + assert.equal(devices.length, 0, 'no devices yet'); + let res = await client.createAuthorizationCode({ client_id: PUBLIC_CLIENT_ID, state: 'abc', @@ -116,6 +135,9 @@ describe('/oauth/ routes', function () { }); assert.ok(res.code); + devices = await client.devices(); + assert.equal(devices.length, 0, 'no devices yet'); + res = await client.grantOAuthTokens({ client_id: PUBLIC_CLIENT_ID, code: res.code, @@ -129,6 +151,9 @@ describe('/oauth/ routes', function () { assert.ok(res.expires_in); assert.ok(res.token_type); + devices = await client.devices(); + assert.equal(devices.length, 1, 'has a new device after the code grant'); + const res2 = await client.grantOAuthTokens({ client_id: PUBLIC_CLIENT_ID, refresh_token: res.refresh_token, @@ -139,5 +164,8 @@ describe('/oauth/ routes', function () { assert.ok(res.expires_in); assert.ok(res.token_type); assert.notEqual(res.access_token, res2.access_token); + + devices = await client.devices(); + assert.equal(devices.length, 1, 'still only one device after a refresh_token grant'); }); });