Merge pull request #2985 from mozilla/fenix-token-exchanges
Notify push and email on code exchanges
This commit is contained in:
Коммит
2e1b01f87b
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
|
@ -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) {
|
||||
}
|
||||
|
||||
|
|
|
@ -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')(
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
];
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
||||
});
|
|
@ -87,6 +87,7 @@ const DB_METHOD_NAMES = [
|
|||
];
|
||||
|
||||
const OAUTHDB_METHOD_NAMES = [
|
||||
'checkAccessToken',
|
||||
'checkRefreshToken',
|
||||
'revokeRefreshTokenById',
|
||||
'getClientInfo',
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
Загрузка…
Ссылка в новой задаче