mirror of
https://github.com/mozilla/fxa.git
synced 2025-12-13 20:36:41 +01:00
Merge pull request #19698 from mozilla/FXA-12692
chore(fxa-auth): Replace accountDevices call for refresh-token auth scheme
This commit is contained in:
@@ -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';
|
||||
@@ -0,0 +1,5 @@
|
||||
-- SET NAMES utf8mb4 COLLATE utf8mb4_bin;
|
||||
|
||||
-- DROP PROCEDURE `deviceFromRefreshTokenId_1`;
|
||||
|
||||
-- UPDATE dbMetadata SET value = '179' WHERE name = 'schema-patch-level';
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"level": 179
|
||||
"level": 180
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user