fix(devices): keep the existing device name on re-pair and re-login
A dedupe-keyed client re-mint (QR rescan, password/passkey re-login) replaces
the stored record, which also reset the operator-visible name to the app's
hardcoded default ('OpenChamber Mobile'). The app-reported name is now only a
fallback: an explicit pairing label wins, otherwise the replaced record's
label is kept, and the default applies only to a first-ever pairing.
This commit is contained in:
@@ -262,14 +262,15 @@ export const createClientPairingRuntime = ({
|
|||||||
if (!constantTimeEqual(session.secretHash, hashSecret(normalizedSecret), crypto)) throw redeemError();
|
if (!constantTimeEqual(session.secretHash, hashSecret(normalizedSecret), crypto)) throw redeemError();
|
||||||
|
|
||||||
// The operator's typed pairing label is THIS server's name for the device
|
// The operator's typed pairing label is THIS server's name for the device
|
||||||
// (shown in the device list). It wins over the device's self-reported
|
// (shown in the device list) and wins outright. The device's self-reported
|
||||||
// label; fall back to that only when no pairing label was set.
|
// label is only a fallback: on a re-pair with the same dedupeKey,
|
||||||
const label = normalizeOptionalString(session.label)
|
// createClient keeps the replaced record's label over it, so a rescan
|
||||||
|| normalizeOptionalString(clientLabel)
|
// without a typed name does not reset the device to the app default.
|
||||||
|| normalizeOptionalString(deviceName)
|
|
||||||
|| 'Remote client';
|
|
||||||
const result = await remoteClientAuthRuntime.createClient({
|
const result = await remoteClientAuthRuntime.createClient({
|
||||||
label,
|
label: normalizeOptionalString(session.label),
|
||||||
|
fallbackLabel: normalizeOptionalString(clientLabel)
|
||||||
|
|| normalizeOptionalString(deviceName)
|
||||||
|
|| 'Remote client',
|
||||||
clientKind: normalizedKind,
|
clientKind: normalizedKind,
|
||||||
dedupeKey: normalizeOptionalString(dedupeKey) || `pairing:${session.id}`,
|
dedupeKey: normalizeOptionalString(dedupeKey) || `pairing:${session.id}`,
|
||||||
authMethod: 'pairing',
|
authMethod: 'pairing',
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const makeRuntime = async (options = {}) => {
|
|||||||
createClient: vi.fn(async (input) => {
|
createClient: vi.fn(async (input) => {
|
||||||
const client = {
|
const client = {
|
||||||
id: `client-${createdClients.length + 1}`,
|
id: `client-${createdClients.length + 1}`,
|
||||||
label: input.label,
|
label: input.label ?? input.fallbackLabel,
|
||||||
clientKind: input.clientKind,
|
clientKind: input.clientKind,
|
||||||
authMethod: input.authMethod,
|
authMethod: input.authMethod,
|
||||||
pairingId: input.pairingId,
|
pairingId: input.pairingId,
|
||||||
@@ -61,6 +61,10 @@ describe('client auth pairing runtime', () => {
|
|||||||
pairingId: created.pairing.id,
|
pairingId: created.pairing.id,
|
||||||
clientKind: 'mobile',
|
clientKind: 'mobile',
|
||||||
dedupeKey: 'device-key',
|
dedupeKey: 'device-key',
|
||||||
|
// No operator-typed pairing label: the app-reported name is only a
|
||||||
|
// fallback so a re-pair keeps the existing device record's label.
|
||||||
|
label: null,
|
||||||
|
fallbackLabel: 'Iryna iPhone',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await expect(runtime.redeemPairingSession({
|
await expect(runtime.redeemPairingSession({
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP
|
|||||||
|
|
||||||
const createClient = async ({
|
const createClient = async ({
|
||||||
label,
|
label,
|
||||||
|
fallbackLabel,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
clientKind,
|
clientKind,
|
||||||
dedupeKey,
|
dedupeKey,
|
||||||
@@ -172,9 +173,16 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP
|
|||||||
const store = await readStore();
|
const store = await readStore();
|
||||||
const normalizedDedupeKey = normalizeOptionalString(dedupeKey);
|
const normalizedDedupeKey = normalizeOptionalString(dedupeKey);
|
||||||
const token = generateToken();
|
const token = generateToken();
|
||||||
|
// A dedupe-keyed mint REPLACES the previous record for the same device,
|
||||||
|
// so an operator-visible name must survive the replacement: an explicit
|
||||||
|
// label wins, otherwise the replaced record's label is kept, and only a
|
||||||
|
// first-ever mint falls back to the client-reported default.
|
||||||
|
const existing = normalizedDedupeKey
|
||||||
|
? store.clients.find((entry) => entry.dedupeKey === normalizedDedupeKey)
|
||||||
|
: null;
|
||||||
const client = {
|
const client = {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
label: normalizeLabel(label),
|
label: normalizeLabel(normalizeOptionalString(label) || existing?.label || fallbackLabel),
|
||||||
tokenHash: hashToken(token),
|
tokenHash: hashToken(token),
|
||||||
createdAt: nowIso(),
|
createdAt: nowIso(),
|
||||||
lastUsedAt: null,
|
lastUsedAt: null,
|
||||||
|
|||||||
@@ -83,6 +83,23 @@ describe('remote client auth runtime', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the replaced record label on a dedupe re-mint without an explicit label', async () => {
|
||||||
|
const { dir, runtime } = await createRuntime();
|
||||||
|
try {
|
||||||
|
await runtime.createClient({ label: 'Iryna iPhone', dedupeKey: 'mobile:device-1', fallbackLabel: 'OpenChamber Mobile' });
|
||||||
|
const remint = await runtime.createClient({ dedupeKey: 'mobile:device-1', fallbackLabel: 'OpenChamber Mobile' });
|
||||||
|
expect(remint.client.label).toBe('Iryna iPhone');
|
||||||
|
|
||||||
|
const renamed = await runtime.createClient({ label: 'Work phone', dedupeKey: 'mobile:device-1', fallbackLabel: 'OpenChamber Mobile' });
|
||||||
|
expect(renamed.client.label).toBe('Work phone');
|
||||||
|
|
||||||
|
const fresh = await runtime.createClient({ dedupeKey: 'mobile:device-2', fallbackLabel: 'OpenChamber Mobile' });
|
||||||
|
expect(fresh.client.label).toBe('OpenChamber Mobile');
|
||||||
|
} finally {
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps the token store private on disk', async () => {
|
it('keeps the token store private on disk', async () => {
|
||||||
const { dir, runtime } = await createRuntime();
|
const { dir, runtime } = await createRuntime();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -839,7 +839,7 @@ export const createUiAuth = ({
|
|||||||
let clientTokenResult = null;
|
let clientTokenResult = null;
|
||||||
if (req.body?.issueClientToken === true && typeof clientAuthController?.createClient === 'function') {
|
if (req.body?.issueClientToken === true && typeof clientAuthController?.createClient === 'function') {
|
||||||
clientTokenResult = await clientAuthController.createClient({
|
clientTokenResult = await clientAuthController.createClient({
|
||||||
label: req.body?.clientLabel,
|
fallbackLabel: req.body?.clientLabel,
|
||||||
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
|
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
|
||||||
clientKind: req.body?.clientKind,
|
clientKind: req.body?.clientKind,
|
||||||
dedupeKey: req.body?.dedupeKey,
|
dedupeKey: req.body?.dedupeKey,
|
||||||
@@ -907,7 +907,7 @@ export const createUiAuth = ({
|
|||||||
let clientTokenResult = null;
|
let clientTokenResult = null;
|
||||||
if (req.body?.issueClientToken === true && typeof clientAuthController?.createClient === 'function') {
|
if (req.body?.issueClientToken === true && typeof clientAuthController?.createClient === 'function') {
|
||||||
clientTokenResult = await clientAuthController.createClient({
|
clientTokenResult = await clientAuthController.createClient({
|
||||||
label: req.body?.clientLabel,
|
fallbackLabel: req.body?.clientLabel,
|
||||||
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
|
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
|
||||||
clientKind: req.body?.clientKind,
|
clientKind: req.body?.clientKind,
|
||||||
dedupeKey: req.body?.dedupeKey,
|
dedupeKey: req.body?.dedupeKey,
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ describe('ui auth client credential seam', () => {
|
|||||||
token: 'client-token',
|
token: 'client-token',
|
||||||
client: {
|
client: {
|
||||||
id: 'device-1',
|
id: 'device-1',
|
||||||
label: input.label,
|
label: input.label ?? input.fallbackLabel,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
lastUsedAt: null,
|
lastUsedAt: null,
|
||||||
revokedAt: null,
|
revokedAt: null,
|
||||||
@@ -295,7 +295,7 @@ describe('ui auth client credential seam', () => {
|
|||||||
await auth.handleSessionCreate(req, res);
|
await auth.handleSessionCreate(req, res);
|
||||||
|
|
||||||
expect(res.body.clientToken).toBe('client-token');
|
expect(res.body.clientToken).toBe('client-token');
|
||||||
expect(createClientInput.label).toBe('OpenChamber Desktop');
|
expect(createClientInput.fallbackLabel).toBe('OpenChamber Desktop');
|
||||||
const expiresAt = Date.parse(createClientInput.expiresAt);
|
const expiresAt = Date.parse(createClientInput.expiresAt);
|
||||||
expect(expiresAt).toBeGreaterThanOrEqual(before + 122_000);
|
expect(expiresAt).toBeGreaterThanOrEqual(before + 122_000);
|
||||||
expect(expiresAt).toBeLessThanOrEqual(Date.now() + 124_000);
|
expect(expiresAt).toBeLessThanOrEqual(Date.now() + 124_000);
|
||||||
|
|||||||
Reference in New Issue
Block a user