Merge pull request #19698 from mozilla/FXA-12692

chore(fxa-auth): Replace accountDevices call for refresh-token auth scheme
This commit is contained in:
Nick Shirley
2025-12-12 11:14:28 -07:00
committed by GitHub
13 changed files with 429 additions and 32 deletions

View File

@@ -0,0 +1,36 @@
SET NAMES utf8mb4 COLLATE utf8mb4_bin;
CALL assertPatchLevel('179');
-- This adds a new procedure to find a device by refresh token id.
-- Does not return sessionToken information.
CREATE PROCEDURE `deviceFromRefreshTokenId_1` (
IN `uidArg` BINARY(16),
IN `refreshTokenIdArg` BINARY(32)
)
BEGIN
SELECT
d.uid,
d.id,
d.refreshTokenId,
d.nameUtf8 AS name,
d.type,
d.createdAt,
d.callbackURL,
d.callbackPublicKey,
d.callbackAuthKey,
d.callbackIsExpired,
ci.commandName,
dc.commandData
FROM devices AS d
LEFT JOIN (
deviceCommands AS dc FORCE INDEX (PRIMARY)
INNER JOIN deviceCommandIdentifiers AS ci FORCE INDEX (PRIMARY)
ON ci.commandId = dc.commandId
) ON (dc.uid = d.uid AND dc.deviceId = d.id)
WHERE d.uid = uidArg
AND d.refreshTokenId = refreshTokenIdArg;
END;
UPDATE dbMetadata SET value = '180' WHERE name = 'schema-patch-level';

View File

@@ -0,0 +1,5 @@
-- SET NAMES utf8mb4 COLLATE utf8mb4_bin;
-- DROP PROCEDURE `deviceFromRefreshTokenId_1`;
-- UPDATE dbMetadata SET value = '179' WHERE name = 'schema-patch-level';

View File

@@ -1,3 +1,3 @@
{
"level": 179
"level": 180
}

View File

@@ -593,6 +593,35 @@ export const createDB = (
return device;
}
async deviceFromRefreshTokenId(uid: string, refreshTokenId: string) {
log.trace('DB.deviceFromRefreshTokenId', { uid, refreshTokenId });
const lastAccessTimeEnabled =
features.isLastAccessTimeEnabledForUser(uid);
const device = await Device.findByUidAndRefreshTokenId(
uid,
refreshTokenId
);
this.metrics?.increment('db.deviceFromRefreshTokenId.retrieve', {
result: device ? 'success' : 'notFound',
});
if (!device) {
// used in the refresh token scheme, we can have a token without
// a device and that's valid, so return null
return null;
}
// run devices through the 'mergeDevicesAndSessionTokens' function
// since it normalizes the device object how most of our handlers expect it.
const normalizedDevice = mergeDevicesAndSessionTokens(
[device],
{}, // no session tokens needed here.
lastAccessTimeEnabled
)[0];
return normalizedDevice;
}
// UPDATE
async setPrimaryEmail(uid: string, email: string) {

View File

@@ -72,12 +72,11 @@ module.exports = function schemeRefreshTokenScheme(config, db) {
if (!credentials.client || !credentials.client.publicClient) {
return h.unauthenticated(AppError.notPublicClient());
}
const devices = await db.devices(credentials.uid);
const device = await db.deviceFromRefreshTokenId(
credentials.uid,
credentials.refreshTokenId
);
// use the hashed refreshToken id to find devices
const device = devices.filter(
(device) => device.refreshTokenId === credentials.refreshTokenId
)[0];
if (device) {
credentials.deviceId = device.id;
credentials.deviceName = device.name;

View File

@@ -523,3 +523,186 @@ describe('redis enabled, token-pruning disabled:', () => {
.then(() => assert.equal(redis.pruneSessionTokens.callCount, 0));
});
});
describe('db.deviceFromRefreshTokenId:', () => {
const tokenLifetimes = {
sessionTokenWithoutDevice: 2419200000,
};
let log,
tokens,
db,
Device,
features,
mergeDevicesAndSessionTokens,
errorMock;
beforeEach(() => {
log = mocks.mockLog();
tokens = require(`${LIB_DIR}/tokens`)(log, { tokenLifetimes });
// Mock Device model
Device = {
findByUidAndRefreshTokenId: sinon.stub(),
};
// Mock features
features = {
isLastAccessTimeEnabledForUser: sinon.stub().returns(false),
};
// Mock mergeDevicesAndSessionTokens
mergeDevicesAndSessionTokens = sinon.stub();
// Mock error
errorMock = {
unknownDevice: sinon.stub().returns({
errno: 110,
message: 'Unknown device',
statusCode: 401,
}),
};
const { createDB } = proxyquire(`${LIB_DIR}/db.ts`, {
'./features': () => features,
'@fxa/accounts/errors': { AppError: errorMock },
'fxa-shared/connected-services': {
mergeDevicesAndSessionTokens,
filterExpiredTokens: () => [],
mergeCachedSessionTokens: () => [],
mergeDeviceAndSessionToken: () => ({}),
},
'fxa-shared/db': { setupAuthDatabase: () => {} },
'fxa-shared/db/models/auth': {
...models,
Device,
},
});
const DB = createDB(
{
tokenLifetimes,
tokenPruning: {},
redis: { ...config.redis, enabled: false },
},
log,
tokens,
{}
);
return DB.connect({}).then((result) => (db = result));
});
it('should return normalized device when device is found', async () => {
const uid = 'test-uid';
const refreshTokenId = 'test-refresh-token-id';
const mockDevice = {
id: 'device-id',
uid: uid,
refreshTokenId: refreshTokenId,
name: 'Test Device',
type: 'mobile',
createdAt: Date.now(),
};
const mockNormalizedDevice = {
id: 'device-id',
refreshTokenId: refreshTokenId,
name: 'Test Device',
type: 'mobile',
createdAt: mockDevice.createdAt,
availableCommands: {},
};
const metrics = {
increment: sinon.spy(),
};
db.metrics = metrics;
Device.findByUidAndRefreshTokenId.resolves(mockDevice);
features.isLastAccessTimeEnabledForUser.returns(false);
mergeDevicesAndSessionTokens.returns([mockNormalizedDevice]);
const result = await db.deviceFromRefreshTokenId(uid, refreshTokenId);
assert.equal(Device.findByUidAndRefreshTokenId.callCount, 1);
assert.equal(Device.findByUidAndRefreshTokenId.args[0][0], uid);
assert.equal(Device.findByUidAndRefreshTokenId.args[0][1], refreshTokenId);
assert.equal(features.isLastAccessTimeEnabledForUser.callCount, 1);
assert.equal(features.isLastAccessTimeEnabledForUser.args[0][0], uid);
assert.equal(mergeDevicesAndSessionTokens.callCount, 1);
assert.deepEqual(mergeDevicesAndSessionTokens.args[0][0], [mockDevice]);
assert.deepEqual(mergeDevicesAndSessionTokens.args[0][1], {});
assert.equal(mergeDevicesAndSessionTokens.args[0][2], false);
assert.deepEqual(result, mockNormalizedDevice);
// metrics
assert.equal(metrics.increment.callCount, 1);
assert.equal(
metrics.increment.args[0][0],
'db.deviceFromRefreshTokenId.retrieve'
);
assert.deepEqual(metrics.increment.args[0][1], { result: 'success' });
});
it('should return normalized device with lastAccessTime when feature is enabled', async () => {
const uid = 'test-uid';
const refreshTokenId = 'test-refresh-token-id';
const mockDevice = {
id: 'device-id',
uid: uid,
refreshTokenId: refreshTokenId,
name: 'Test Device',
type: 'mobile',
createdAt: Date.now(),
};
const mockNormalizedDevice = {
id: 'device-id',
refreshTokenId: refreshTokenId,
name: 'Test Device',
type: 'mobile',
createdAt: mockDevice.createdAt,
lastAccessTime: Date.now(),
availableCommands: {},
};
Device.findByUidAndRefreshTokenId.resolves(mockDevice);
features.isLastAccessTimeEnabledForUser.returns(true);
mergeDevicesAndSessionTokens.returns([mockNormalizedDevice]);
const result = await db.deviceFromRefreshTokenId(uid, refreshTokenId);
assert.equal(mergeDevicesAndSessionTokens.callCount, 1);
assert.deepEqual(mergeDevicesAndSessionTokens.args[0][0], [mockDevice]);
assert.deepEqual(mergeDevicesAndSessionTokens.args[0][1], {});
assert.equal(mergeDevicesAndSessionTokens.args[0][2], true);
assert.deepEqual(result, mockNormalizedDevice);
});
it('should return null and increment metrics when device is not found', async () => {
const uid = 'test-uid';
const refreshTokenId = 'test-refresh-token-id';
const metrics = {
increment: sinon.spy(),
};
db.metrics = metrics;
Device.findByUidAndRefreshTokenId.resolves(null);
const result = await db.deviceFromRefreshTokenId(uid, refreshTokenId);
assert.isNull(result);
assert.equal(metrics.increment.callCount, 1);
assert.equal(
metrics.increment.args[0][0],
'db.deviceFromRefreshTokenId.retrieve'
);
assert.deepEqual(metrics.increment.args[0][1], { result: 'notFound' });
});
it('should not increment metrics when metrics is not available', async () => {
const uid = 'test-uid';
const refreshTokenId = 'test-refresh-token-id';
db.metrics = undefined;
Device.findByUidAndRefreshTokenId.resolves(null);
const result = await db.deviceFromRefreshTokenId(uid, refreshTokenId);
// basically, just make sure it doesn't blow up without metrics
assert.isNull(result);
});
});

View File

@@ -52,25 +52,22 @@ describe('lib/routes/auth-schemes/refresh-token', () => {
config = { oauth: {} };
db = {
devices: sinon.spy(() =>
Promise.resolve([
{
id: '5eb89097bab6551de3614facaea59cab',
refreshTokenId:
'5b541d00ea0c0dc775e060c95a1ee7ca617cf95a05d177ec09fd6f62ca9b2913',
isCurrentDevice: false,
location: {},
name: 'first device',
type: 'mobile',
pushCallback: null,
pushPublicKey: null,
pushAuthKey: null,
pushEndpointExpired: false,
availableCommands: {},
lastAccessTime: 1552338763337,
lastAccessTimeFormatted: 'a few seconds ago',
},
])
deviceFromRefreshTokenId: sinon.spy(() =>
Promise.resolve({
id: '5eb89097bab6551de3614facaea59cab',
refreshTokenId:
'5b541d00ea0c0dc775e060c95a1ee7ca617cf95a05d177ec09fd6f62ca9b2913',
isCurrentDevice: false,
location: {},
name: 'first device',
type: 'mobile',
createdAt: 1716230400000,
callbackURL: 'https://example.com/callback',
callbackPublicKey: 'public_key',
callbackAuthKey: 'auth_key',
callbackIsExpired: false,
availableCommands: {},
})
),
};
@@ -167,11 +164,11 @@ describe('lib/routes/auth-schemes/refresh-token', () => {
refreshTokenId:
'5b541d00ea0c0dc775e060c95a1ee7ca617cf95a05d177ec09fd6f62ca9b2913',
deviceAvailableCommands: {},
deviceCallbackAuthKey: undefined,
deviceCallbackIsExpired: undefined,
deviceCallbackPublicKey: undefined,
deviceCallbackURL: undefined,
deviceCreatedAt: undefined,
deviceCallbackAuthKey: 'auth_key',
deviceCallbackIsExpired: false,
deviceCallbackPublicKey: 'public_key',
deviceCallbackURL: 'https://example.com/callback',
deviceCreatedAt: 1716230400000,
uaBrowser: app.ua.browser,
uaBrowserVersion: app.ua.browserVersion,
uaOS: app.ua.os,

View File

@@ -70,6 +70,7 @@ const DB_METHOD_NAMES = [
'deletePasswordChangeToken',
'deleteSessionToken',
'deviceFromTokenVerificationId',
'deviceFromRefreshTokenId',
'deleteRecoveryKey',
'deleteTotpToken',
'devices',
@@ -578,6 +579,9 @@ function mockDB(data, errors) {
assert.ok(device);
return Promise.resolve(device);
}),
deviceFromRefreshTokenId: sinon.spy(() => {
return Promise.resolve(null);
}),
deleteSessionToken: sinon.spy(() => {
return Promise.resolve();
}),

View File

@@ -40,6 +40,7 @@ export enum Proc {
DeleteTotpToken = 'deleteTotpToken_4',
Device = 'device_3',
DeviceFromTokenVerificationId = 'deviceFromTokenVerificationId_6',
DeviceFromRefreshTokenId = 'deviceFromRefreshTokenId_1',
EmailBounces = 'fetchEmailBounces_3',
FindLargeAccounts = 'findLargeAccounts_1',
ForgotPasswordVerified = 'forgotPasswordVerified_9',

View File

@@ -238,4 +238,13 @@ export class Device extends BaseAuthModel {
);
return this.fromRows(rows).shift() || null;
}
static async findByUidAndRefreshTokenId(uid: string, refreshTokenId: string) {
const { rows } = await this.callProcedure(
Proc.DeviceFromRefreshTokenId,
uuidTransformer.to(uid),
uuidTransformer.to(refreshTokenId)
);
return this.fromRows(rows).shift() || null;
}
}

View File

@@ -218,7 +218,7 @@ export async function testAuthDatabaseSetup(instance: Knex): Promise<void> {
'./recovery-phones.sql',
'./key-fetch-tokens.sql',
'./security-event-names.sql',
'./security-events.sql'
'./security-events.sql',
]);
// The order matters for inserts or foreign key refs
await runSql([
@@ -230,7 +230,8 @@ export async function testAuthDatabaseSetup(instance: Knex): Promise<void> {
'./sp_limitSessions.sql',
'./sp_findLargeAccounts.sql',
'./sp_resetAccount.sql',
'./sp_createSecurityEvent.sql'
'./sp_createSecurityEvent.sql',
'./sp_deviceFromRefreshTokenId.sql',
]);
/*/ Debugging Assistance

View File

@@ -29,6 +29,7 @@ import { chance, randomAccount, randomEmail } from './helpers';
const USER_1 = randomAccount();
const EMAIL_1 = randomEmail(USER_1);
import { Device } from '../../../../db/models/auth';
describe('#integration - auth', () => {
let knex: Knex;
@@ -559,4 +560,109 @@ describe('#integration - auth', () => {
assert.equal(unverifiedAccounts.length, 3);
});
});
describe('Device.findByUidAndRefreshTokenId', () => {
beforeEach(async () => {
const testTables = [
'devices',
'deviceCommandIdentifiers',
'deviceCommands',
];
for (const table of testTables) {
await knex.raw(`DELETE FROM ${table}`);
}
});
it('returns device when found with refreshTokenId', async () => {
const uid = USER_1.uid;
const deviceId = '0123456789abcdef0123456789abcdef';
const refreshTokenId =
'abcdef0123456789abcdef0123456789abcdef00000000000000000000000000';
await knex.raw(
`
INSERT INTO devices (uid, id, refreshTokenId, nameUtf8, type, createdAt)
VALUES (UNHEX(?), UNHEX(?), UNHEX(?), 'Test Device', 'mobile', ?)
`,
[uid, deviceId, refreshTokenId, Date.now()]
);
const device = await Device.findByUidAndRefreshTokenId(
uid,
refreshTokenId
);
assert.isNotNull(device);
assert.equal(device.id, deviceId);
assert.equal(device.refreshTokenId, refreshTokenId);
assert.equal(device.name, 'Test Device');
assert.equal(device.type, 'mobile');
assert.isObject(device.availableCommands);
});
it('returns null when device not found', async () => {
const uid = USER_1.uid;
const refreshTokenId =
'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff';
const device = await Device.findByUidAndRefreshTokenId(
uid,
refreshTokenId
);
assert.isNull(device);
});
it('aggregates deviceCommands correctly', async () => {
const uid = USER_1.uid;
const deviceId = '11111111111111111111111111111111';
const refreshTokenId =
'2222222222222222222222222222222222222222222222222222222222222222';
await knex.raw(
`
INSERT INTO devices (uid, id, refreshTokenId, nameUtf8, type, createdAt)
VALUES (UNHEX(?), UNHEX(?), UNHEX(?), 'Device with Commands', 'desktop', ?)
`,
[uid, deviceId, refreshTokenId, Date.now()]
);
await knex.raw(`
INSERT IGNORE INTO deviceCommandIdentifiers (commandName)
VALUES ('https://identity.mozilla.com/cmd/open-uri'),
('https://identity.mozilla.com/cmd/send-tab')
`);
// Insert device commands
const cmdIds = await knex.raw(`
SELECT commandId FROM deviceCommandIdentifiers
WHERE commandName IN ('https://identity.mozilla.com/cmd/open-uri',
'https://identity.mozilla.com/cmd/send-tab')
`);
for (const cmd of cmdIds[0]) {
await knex.raw(
`
INSERT INTO deviceCommands (uid, deviceId, commandId, commandData)
VALUES (UNHEX(?), UNHEX(?), ?, '{"enabled": true}')
`,
[uid, deviceId, cmd.commandId]
);
}
const device = await Device.findByUidAndRefreshTokenId(
uid,
refreshTokenId
);
assert.isNotNull(device);
assert.isObject(device.availableCommands);
assert.equal(Object.keys(device.availableCommands).length, 2);
assert.isString(
device.availableCommands['https://identity.mozilla.com/cmd/open-uri']
);
assert.isString(
device.availableCommands['https://identity.mozilla.com/cmd/send-tab']
);
});
});
});

View File

@@ -0,0 +1,27 @@
CREATE PROCEDURE `deviceFromRefreshTokenId_1` (
IN `uidArg` BINARY(16),
IN `refreshTokenIdArg` BINARY(32)
)
BEGIN
SELECT
d.uid,
d.id,
d.refreshTokenId,
d.nameUtf8 AS name,
d.type,
d.createdAt,
d.callbackURL,
d.callbackPublicKey,
d.callbackAuthKey,
d.callbackIsExpired,
ci.commandName,
dc.commandData
FROM devices AS d
LEFT JOIN (
deviceCommands AS dc FORCE INDEX (PRIMARY)
INNER JOIN deviceCommandIdentifiers AS ci FORCE INDEX (PRIMARY)
ON ci.commandId = dc.commandId
) ON (dc.uid = d.uid AND dc.deviceId = d.id)
WHERE d.uid = uidArg
AND d.refreshTokenId = refreshTokenIdArg;
END;