packages/fxa-auth-server/lib/routes/devices-and-sessions.js (690 lines of code) (raw):
/* 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 { URL } = require('url');
const Ajv = require('ajv');
const ajv = new Ajv();
const hex = require('buf').to.hex;
const error = require('../error');
const fs = require('fs');
const isA = require('joi');
const path = require('path');
const validators = require('./validators');
const DEVICES_AND_SERVICES_DOCS =
require('../../docs/swagger/devices-and-sessions-api').default;
const DESCRIPTION = require('../../docs/swagger/shared/descriptions').default;
const HEX_STRING = validators.HEX_STRING;
const DEVICES_SCHEMA = require('../devices').schema;
const PUSH_PAYLOADS_SCHEMA_PATH = path.resolve(
__dirname,
'../pushpayloads.schema.json'
);
// Assign a default TTL for well-known commands if a request didn't specify it.
const DEFAULT_COMMAND_TTL = new Map([
['https://identity.mozilla.com/cmd/open-uri', 30 * 24 * 3600], // 30 days
]);
module.exports = (
log,
db,
oauth,
config,
customs,
push,
pushbox,
devices,
clientUtils,
redis
) => {
// Loads and compiles a json validator for the payloads received
// in /account/devices/notify
const validatePushSchema = JSON.parse(
fs.readFileSync(PUSH_PAYLOADS_SCHEMA_PATH)
);
const validatePushPayloadAjv = ajv.compile(validatePushSchema);
function validatePushPayload(payload, endpoint) {
if (endpoint === 'accountVerify') {
if (isEmpty(payload)) {
return true;
}
return false;
}
return validatePushPayloadAjv(payload);
}
function isEmpty(payload) {
return payload && Object.keys(payload).length === 0;
}
// Creates a "full" device response, provided a credentials object and an optional
// updated DB device record.
function buildDeviceResponse(credentials, device = {}) {
// We must respond with the full device record,
// including any default values for missing fields.
return {
// These properties can be picked from sessionToken or device as appropriate.
pushCallback: credentials.deviceCallbackURL,
pushPublicKey: credentials.deviceCallbackPublicKey,
pushAuthKey: credentials.deviceCallbackAuthKey,
pushEndpointExpired: !!credentials.deviceCallbackIsExpired,
...device,
// But these need to be non-falsey, using default fallbacks if necessary
id: device.id || credentials.deviceId,
name:
device.name ||
credentials.deviceName ||
devices.synthesizeName(credentials),
type:
device.type ||
credentials.deviceType ||
(credentials.client || device.refreshTokenId ? 'mobile' : 'desktop'),
availableCommands:
(device && device.availableCommands) ||
credentials.deviceAvailableCommands ||
{},
};
}
return [
{
method: 'POST',
path: '/account/device',
options: {
...DEVICES_AND_SERVICES_DOCS.ACCOUNT_DEVICE_POST,
auth: {
strategies: ['sessionToken', 'refreshToken'],
},
validate: {
payload: isA
.object({
id: DEVICES_SCHEMA.id.optional(),
name: DEVICES_SCHEMA.name.optional(),
type: DEVICES_SCHEMA.type.optional(),
pushCallback: DEVICES_SCHEMA.pushCallback.optional(),
pushPublicKey: DEVICES_SCHEMA.pushPublicKey.optional(),
pushAuthKey: DEVICES_SCHEMA.pushAuthKey.optional(),
availableCommands: DEVICES_SCHEMA.availableCommands.optional(),
// Some versions of desktop firefox send a zero-length
// "capabilities" array, for historical reasons.
// We accept but ignore it.
capabilities: isA.array().length(0).optional(),
})
.and('pushCallback', 'pushPublicKey', 'pushAuthKey'),
},
response: {
schema: isA
.object({
id: DEVICES_SCHEMA.id.required(),
createdAt: isA.number().positive().optional(),
name: DEVICES_SCHEMA.nameResponse.optional(),
type: DEVICES_SCHEMA.type.optional(),
pushCallback: DEVICES_SCHEMA.pushCallback.optional(),
pushPublicKey: DEVICES_SCHEMA.pushPublicKey.optional(),
pushAuthKey: DEVICES_SCHEMA.pushAuthKey.optional(),
pushEndpointExpired:
DEVICES_SCHEMA.pushEndpointExpired.optional(),
availableCommands: DEVICES_SCHEMA.availableCommands.optional(),
})
.and('pushCallback', 'pushPublicKey', 'pushAuthKey'),
},
},
handler: async function (request) {
log.begin('Account.device', request);
const payload = request.payload;
const credentials = request.auth.credentials;
// Remove obsolete field, so we don't try to echo it back to the client.
delete payload.capabilities;
// Some additional, slightly tricky validation to detect bad public keys.
if (
payload.pushPublicKey &&
!push.isValidPublicKey(payload.pushPublicKey)
) {
throw error.invalidRequestParameter('invalid pushPublicKey');
}
if (payload.id) {
// Don't write out the update if nothing has actually changed.
if (devices.isSpuriousUpdate(payload, credentials)) {
return buildDeviceResponse(credentials);
}
// We also reserve the right to disable updates until
// we're confident clients are behaving correctly.
if (config.deviceUpdatesEnabled === false) {
throw error.featureNotEnabled();
}
} else if (credentials.deviceId) {
// Keep the old id, which is probably from a synthesized device record
payload.id = credentials.deviceId;
}
const pushEndpointOk =
!payload.id || // New device.
(payload.id &&
payload.pushCallback &&
payload.pushCallback !== credentials.deviceCallbackURL); // Updating the pushCallback
if (pushEndpointOk) {
payload.pushEndpointExpired = false;
}
// We're doing a gradual rollout of the 'device commands' feature
// in support of pushbox, so accept an 'availableCommands' field
// if pushbox is enabled.
if (payload.availableCommands && !config.pushbox.enabled) {
payload.availableCommands = {};
}
const device = await devices.upsert(request, credentials, payload);
return buildDeviceResponse(credentials, device);
},
},
{
method: 'GET',
path: '/account/device/commands',
options: {
...DEVICES_AND_SERVICES_DOCS.ACCOUNT_DEVICE_COMMANDS_GET,
validate: {
query: isA.object({
index: isA.number().optional().description(DESCRIPTION.indexQuery),
limit: isA
.number()
.optional()
.min(0)
.max(100)
.default(100)
.description(DESCRIPTION.limit),
}),
},
auth: {
strategies: ['sessionToken', 'refreshToken'],
},
response: {
schema: isA
.object({
index: isA
.number()
.required()
.description(DESCRIPTION.indexSchema),
last: isA.boolean().optional().description(DESCRIPTION.last),
messages: isA
.array()
.items(
isA.object({
index: isA.number().required(),
data: isA
.object({
command: isA.string().max(255).required(),
payload: isA.object().required(),
sender: DEVICES_SCHEMA.id.optional(),
})
.required(),
})
)
.optional()
.description(DESCRIPTION.messages),
})
.and('last', 'messages'),
},
},
handler: async function (request) {
log.begin('Account.deviceCommands', request);
const credentials = request.auth.credentials;
const uid = credentials.uid;
const deviceId = credentials.deviceId;
const query = request.query || {};
const { index, limit } = query;
if (
config.oauth.deviceCommandsEnabled === false &&
credentials.refreshTokenId
) {
throw new error.featureNotEnabled();
}
if (!deviceId) {
log.error('device.command.deviceIdMissing', {
clientId: credentials.client?.id ? hex(credentials.client.id) : '',
clientName: credentials.client?.name ? credentials.client.name : '',
uaBrowser: credentials.uaBrowser,
uaBrowserVersion: credentials.uaBrowserVersion,
uaOS: credentials.uaOS,
uaOSVersion: credentials.uaOSVersion,
});
throw new error.unknownDevice();
}
const response = await pushbox.retrieve(uid, deviceId, limit, index);
// To measure command delivery, we emit a "retrieved" event for each retrieved
// command, which should match to an "invoked" event emitted when it was invoked.
for (const msg of response.messages) {
const data = msg.data || {}; // should always be present, but you never know...
log.info('device.command.retrieved', {
uid,
target: deviceId,
index: msg.index,
sender: data.sender,
command: data.command,
});
}
return response;
},
},
{
method: 'POST',
path: '/account/devices/invoke_command',
options: {
...DEVICES_AND_SERVICES_DOCS.ACCOUNT_DEVICES_INVOKE_COMMAND_POST,
auth: {
strategies: ['sessionToken', 'refreshToken'],
},
validate: {
payload: isA.object({
target: DEVICES_SCHEMA.id
.required()
.description(DESCRIPTION.target),
command: isA.string().required().description(DESCRIPTION.command),
payload: isA.object().required().description(DESCRIPTION.payload),
ttl: isA
.number()
.integer()
.min(0)
.max(10000000)
.optional()
.description(DESCRIPTION.ttl),
}),
},
response: {
schema: isA.object({
enqueued: isA.boolean().optional(),
notified: isA.boolean().optional(),
notifyError: isA.string().optional(),
}),
},
},
handler: async function (request) {
log.begin('Account.invokeDeviceCommand', request);
const { target, command, payload } = request.payload;
let { ttl } = request.payload;
const credentials = request.auth.credentials;
const uid = credentials.uid;
const sender = credentials.deviceId;
if (
config.oauth.deviceCommandsEnabled === false &&
credentials.refreshTokenId
) {
throw new error.featureNotEnabled();
}
await customs.checkAuthenticated(request, uid, 'invokeDeviceCommand');
const targetDevice = await db.device(uid, target);
// eslint-disable-next-line no-prototype-builtins
if (!targetDevice.availableCommands.hasOwnProperty(command)) {
throw error.unavailableDeviceCommand();
}
// 0 is perfectly acceptable TTL, hence the strict equality check.
if (ttl === undefined && DEFAULT_COMMAND_TTL.has(command)) {
ttl = DEFAULT_COMMAND_TTL.get(command);
}
const data = { command, payload, sender };
const { index } = await pushbox.store(uid, targetDevice.id, data, ttl);
// To measure command delivery, we emit an initial "invoked" event for each invoked
// command, and expect a matching "retrieved" event when the target retreives it.
const metricsTags = {
uid,
target,
index,
sender,
command,
targetOS: targetDevice.uaOS,
targetType: targetDevice.type,
senderOS: credentials.uaOS,
senderType: credentials.deviceType,
};
log.info('device.command.invoked', metricsTags);
const url = new URL('v1/account/device/commands', config.publicUrl);
url.searchParams.set('index', index);
url.searchParams.set('limit', 1);
let notifyError;
try {
await push.notifyCommandReceived(
uid,
targetDevice,
command,
sender,
index,
url.href,
ttl
);
} catch (e) {
notifyError = e;
}
if (!notifyError) {
log.info('device.command.notified', metricsTags);
} else {
log.info('device.command.notifyError', {
err: notifyError,
...metricsTags,
});
}
return {
enqueued: true,
notified: !notifyError,
notifyError: notifyError
? notifyError.errCode || 'unknown'
: undefined,
};
},
},
{
method: 'POST',
path: '/account/devices/notify',
options: {
...DEVICES_AND_SERVICES_DOCS.ACCOUNT_DEVICES_NOTIFY_POST,
auth: {
strategies: ['sessionToken', 'refreshToken'],
},
validate: {
payload: isA.alternatives().try(
isA.object({
to: isA
.string()
.valid('all')
.required()
.description(DESCRIPTION.to),
_endpointAction: isA.string().valid('accountVerify').optional(),
excluded: isA
.array()
.items(isA.string().length(32).regex(HEX_STRING))
.optional()
.description(DESCRIPTION.excluded),
payload: isA
.object()
.when('_endpointAction', {
is: 'accountVerify',
then: isA.required(),
otherwise: isA.required(),
})
.description(DESCRIPTION.pushPayload),
TTL: isA
.number()
.integer()
.min(0)
.optional()
.description(DESCRIPTION.ttlPushNotification),
}),
isA.object({
to: isA
.array()
.items(isA.string().length(32).regex(HEX_STRING))
.required(),
_endpointAction: isA.string().valid('accountVerify').optional(),
payload: isA.object().when('_endpointAction', {
is: 'accountVerify',
then: isA.required(),
otherwise: isA.required(),
}),
TTL: isA.number().integer().min(0).optional(),
})
),
},
response: {
schema: isA.object({}),
},
},
handler: async function (request) {
log.begin('Account.devicesNotify', request);
// We reserve the right to disable notifications until
// we're confident clients are behaving correctly.
if (config.deviceNotificationsEnabled === false) {
throw error.featureNotEnabled();
}
const body = request.payload;
const sessionToken = request.auth.credentials;
const uid = sessionToken.uid;
const payload = body.payload;
const endpointAction = body._endpointAction || 'devicesNotify';
if (!validatePushPayload(payload, endpointAction)) {
throw error.invalidRequestParameter('invalid payload');
}
const pushOptions = {
data: payload,
};
if (body.TTL) {
pushOptions.TTL = body.TTL;
}
let [, deviceArray] = await Promise.all([
customs.checkAuthenticated(request, uid, endpointAction),
request.app.devices,
]);
if (body.to !== 'all') {
const include = new Set(body.to);
deviceArray = deviceArray.filter((device) => include.has(device.id));
if (deviceArray.length === 0) {
log.error('Account.devicesNotify', {
uid: uid,
error: 'devices empty',
});
}
} else if (body.excluded) {
const exclude = new Set(body.excluded);
deviceArray = deviceArray.filter((device) => !exclude.has(device.id));
}
if (deviceArray.length !== 0) {
try {
await push.sendPush(uid, deviceArray, endpointAction, pushOptions);
} catch (err) {
// push may fail due to not found devices or a bad push action
// log the error but still respond with a 200
log.error('Account.devicesNotify', {
uid: uid,
error: err,
});
}
}
// Emit a metrics event for when a user sends tabs between devices.
// In the future we will aim to get this event directly from sync telemetry,
// but we're doing it here for now as a quick way to get metrics on the feature.
if (
payload &&
payload.command === 'sync:collection_changed' &&
// Note that payload schema validation ensures that these properties exist.
payload.data.collections.length === 1 &&
payload.data.collections[0] === 'clients'
) {
let deviceId;
if (sessionToken.deviceId) {
deviceId = sessionToken.deviceId;
}
await request.emitMetricsEvent('sync.sentTabToDevice', {
device_id: deviceId,
service: 'sync',
uid: uid,
});
}
return {};
},
},
{
method: 'GET',
path: '/account/devices',
options: {
...DEVICES_AND_SERVICES_DOCS.ACCOUNT_DEVICES_GET,
auth: {
strategies: ['sessionToken', 'refreshToken'],
},
validate: {
query: isA.object({
filterIdleDevicesTimestamp: isA
.number()
.description(DESCRIPTION.filterIdleDevicesTimestamp)
.optional(),
}),
},
response: {
schema: isA.array().items(
isA
.object({
id: DEVICES_SCHEMA.id.required(),
isCurrentDevice: isA.boolean().required(),
lastAccessTime: isA.number().min(0).required().allow(null),
lastAccessTimeFormatted: isA.string().optional().allow(''),
approximateLastAccessTime: isA.number().min(0).optional(),
approximateLastAccessTimeFormatted: isA
.string()
.optional()
.allow(''),
location: DEVICES_SCHEMA.location,
name: DEVICES_SCHEMA.nameResponse.allow('').required(),
type: DEVICES_SCHEMA.type.required(),
pushCallback: DEVICES_SCHEMA.pushCallback
.allow(null)
.optional(),
pushPublicKey: DEVICES_SCHEMA.pushPublicKey
.allow(null)
.optional(),
pushAuthKey: DEVICES_SCHEMA.pushAuthKey.allow(null).optional(),
pushEndpointExpired:
DEVICES_SCHEMA.pushEndpointExpired.optional(),
availableCommands: DEVICES_SCHEMA.availableCommands.optional(),
})
.and('pushPublicKey', 'pushAuthKey')
),
},
},
handler: async function (request) {
log.begin('Account.devices', request);
const credentials = request.auth.credentials;
// The only reason a device calls this endpoint is to get a list of other devices
// it can send commands to, so feature-flag it as part of that feature.
if (
config.oauth.deviceCommandsEnabled === false &&
credentials.refreshTokenId
) {
throw new error.featureNotEnabled();
}
// If this request is using a session token we bump the last access time
if (credentials.id) {
credentials.lastAccessTime = Date.now();
await db.touchSessionToken(credentials, {}, true);
}
const deviceArray = await request.app.devices;
// If the user has oauth clients that register a device record,
// then the oauth DB may have more up-to-date information about them.
// Since it's an additional request, only make it if necessary.
const oauthRefreshTokensById = new Map();
if (deviceArray.some((d) => d.refreshTokenId)) {
for (const token of await oauth.getRefreshTokensByUid(
credentials.uid
)) {
// OAuth annoyingly returns buffers rather than hex strings.
oauthRefreshTokensById.set(hex(token.tokenId), token);
}
}
let responseDevices = deviceArray.map((device) => {
const refreshToken = oauthRefreshTokensById.get(
device.refreshTokenId
); // null for session-token based devices.
const formattedDevice = {
id: device.id,
isCurrentDevice: !!(
(credentials.id && credentials.id === device.sessionTokenId) ||
(credentials.refreshTokenId &&
credentials.refreshTokenId === device.refreshTokenId)
),
// The devices table `lastAccessTime` column is not updated for OAuth-based
// FxA devices, so we get this information in the OAuth db.
lastAccessTime: refreshToken
? Math.max(
device.lastAccessTime,
refreshToken.lastUsedAt.getTime()
)
: device.lastAccessTime,
location: device.location,
name: device.name || devices.synthesizeName(device),
// For now we assume that all oauth clients that register a device record are mobile apps.
// Ref https://github.com/mozilla/fxa/issues/449
type:
device.type ||
device.uaDeviceType ||
(device.refreshTokenId ? 'mobile' : 'desktop'),
pushCallback: device.pushCallback,
pushPublicKey: device.pushPublicKey,
pushAuthKey: device.pushAuthKey,
pushEndpointExpired: device.pushEndpointExpired,
availableCommands: device.availableCommands,
};
clientUtils.formatTimestamps(formattedDevice, request);
clientUtils.formatLocation(formattedDevice, request);
return formattedDevice;
});
// To help reduce duplicate devices and help improve send tab
// performance a client can request to filter device last access
// time by a specified number of days. For reference, Sync currently
// considers devices that have been accessed in the last 21 days to
// be active.
const idleDeviceTimestamp = request.query.filterIdleDevicesTimestamp;
if (idleDeviceTimestamp) {
responseDevices = responseDevices.filter((device) => {
return device.lastAccessTime > idleDeviceTimestamp;
});
}
return responseDevices;
},
},
{
method: 'GET',
// N.B. This route is deprecated in favour of /account/attached_clients
path: '/account/sessions',
options: {
...DEVICES_AND_SERVICES_DOCS.ACCOUNT_SESSIONS_GET,
auth: {
strategies: [
'sessionToken',
// this endpoint is only used by the content server
// no refreshToken access here
],
},
response: {
schema: isA.array().items(
isA.object({
id: isA.string().regex(HEX_STRING).required(),
lastAccessTime: isA.number().min(0).required().allow(null),
lastAccessTimeFormatted: isA.string().optional().allow(''),
approximateLastAccessTime: isA.number().min(0).optional(),
approximateLastAccessTimeFormatted: isA
.string()
.optional()
.allow(''),
createdTime: isA.number().min(0).required().allow(null),
createdTimeFormatted: isA.string().optional().allow(''),
location: DEVICES_SCHEMA.location,
userAgent: isA.string().max(255).required().allow(''),
os: isA.string().max(255).allow('').allow(null),
deviceId: DEVICES_SCHEMA.id.allow(null).required(),
deviceName: DEVICES_SCHEMA.nameResponse
.allow('')
.allow(null)
.required(),
deviceAvailableCommands: DEVICES_SCHEMA.availableCommands
.allow(null)
.required(),
deviceType: DEVICES_SCHEMA.type.allow(null).required(),
deviceCallbackURL: DEVICES_SCHEMA.pushCallback
.allow(null)
.required(),
deviceCallbackPublicKey: DEVICES_SCHEMA.pushPublicKey
.allow(null)
.required(),
deviceCallbackAuthKey: DEVICES_SCHEMA.pushAuthKey
.allow(null)
.required(),
deviceCallbackIsExpired: DEVICES_SCHEMA.pushEndpointExpired
.allow(null)
.required(),
isDevice: isA.boolean().required(),
isCurrentDevice: isA.boolean().required(),
})
),
},
},
handler: async function (request) {
log.begin('Account.sessions', request);
const sessionToken = request.auth.credentials;
const uid = sessionToken.uid;
const sessions = await db.sessions(uid);
return sessions.map((session) => {
const deviceId = session.deviceId;
const isDevice = !!deviceId;
let deviceName = session.deviceName;
if (!deviceName) {
deviceName = devices.synthesizeName(session);
}
let userAgent;
if (!session.uaBrowser) {
userAgent = '';
} else if (!session.uaBrowserVersion) {
userAgent = session.uaBrowser;
} else {
const { uaBrowser: browser, uaBrowserVersion: version } = session;
userAgent = `${browser} ${version.split('.')[0]}`;
}
const formattedSession = {
deviceId,
deviceName,
deviceType: session.uaDeviceType || 'desktop',
deviceAvailableCommands: session.deviceAvailableCommands || null,
deviceCallbackURL: session.deviceCallbackURL,
deviceCallbackPublicKey: session.deviceCallbackPublicKey,
deviceCallbackAuthKey: session.deviceCallbackAuthKey,
deviceCallbackIsExpired: !!session.deviceCallbackIsExpired,
id: session.id,
isCurrentDevice: session.id === sessionToken.id,
isDevice,
lastAccessTime: session.lastAccessTime,
location: session.location,
createdTime: session.createdAt,
os: session.uaOS,
userAgent,
};
clientUtils.formatTimestamps(formattedSession, request);
clientUtils.formatLocation(formattedSession, request);
return formattedSession;
});
},
},
{
method: 'POST',
path: '/account/device/destroy',
options: {
...DEVICES_AND_SERVICES_DOCS.ACCOUNT_DEVICE_DESTROY_POST,
auth: {
strategies: ['sessionToken', 'refreshToken'],
},
validate: {
payload: isA.object({
id: DEVICES_SCHEMA.id.required(),
}),
},
response: {
schema: isA.object({}),
},
},
handler: async function (request) {
log.begin('Account.deviceDestroy', request);
await devices.destroy(request, request.payload.id);
return {};
},
},
];
};