emailengine-app 2.66.0 → 2.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/data/google-crawlers.json +1 -1
- package/lib/consts.js +2 -0
- package/lib/email-client/imap/sync-operations.js +21 -1
- package/lib/email-client/imap-client.js +19 -3
- package/lib/email-client/outlook-client.js +154 -0
- package/lib/passkeys.js +56 -15
- package/lib/routes-ui.js +34 -25
- package/package.json +1 -1
- package/sbom.json +1 -1
- package/static/licenses.html +2 -2
- package/translations/de.mo +0 -0
- package/translations/de.po +56 -52
- package/translations/en.mo +0 -0
- package/translations/en.po +50 -46
- package/translations/et.mo +0 -0
- package/translations/et.po +59 -56
- package/translations/fr.mo +0 -0
- package/translations/fr.po +67 -63
- package/translations/ja.mo +0 -0
- package/translations/ja.po +58 -54
- package/translations/messages.pot +85 -81
- package/translations/nl.mo +0 -0
- package/translations/nl.po +57 -53
- package/translations/pl.mo +0 -0
- package/translations/pl.po +71 -68
- package/views/oauth-scope-error.hbs +10 -1
- package/workers/api.js +28 -2
- package/workers/imap.js +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.67.0](https://github.com/postalsys/emailengine/compare/v2.66.0...v2.67.0) (2026-03-31)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* handle MS Graph 'missed' lifecycle event for Outlook notification recovery ([d55f48c](https://github.com/postalsys/emailengine/commit/d55f48c9a183f9e0108801563b1085c176dbeb06))
|
|
9
|
+
* show human-readable missing scopes on OAuth scope error page ([4ee2365](https://github.com/postalsys/emailengine/commit/4ee2365449e3cbece6f4a83d540ae63c18464535))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
* add IMAP fetch retry timeout and harden passkey registration ([3a1b61b](https://github.com/postalsys/emailengine/commit/3a1b61b00e916c308cae5e7493a2207164d9d45f))
|
|
15
|
+
* close command client proactively after subconnection setup ([02d3687](https://github.com/postalsys/emailengine/commit/02d36874d8533beabe5ad0fba978851b3791cbb5))
|
|
16
|
+
* correct translation errors and remove EmailEngine branding from OAuth scope error ([9284ddd](https://github.com/postalsys/emailengine/commit/9284ddd1e28e40fd24abeea7296636b21c4b3878))
|
|
17
|
+
* use STATUS instead of NOOP as keepalive for 163.com IMAP ([3b3516f](https://github.com/postalsys/emailengine/commit/3b3516feb14e64bab5ac586378237f2160cdf010))
|
|
18
|
+
|
|
3
19
|
## [2.66.0](https://github.com/postalsys/emailengine/compare/v2.65.0...v2.66.0) (2026-03-29)
|
|
4
20
|
|
|
5
21
|
|
package/lib/consts.js
CHANGED
|
@@ -193,6 +193,8 @@ module.exports = {
|
|
|
193
193
|
FETCH_RETRY_INTERVAL: 1000,
|
|
194
194
|
FETCH_RETRY_EXPONENTIAL: 2,
|
|
195
195
|
FETCH_RETRY_MAX: 60 * 1000,
|
|
196
|
+
FETCH_RETRY_MAX_TIME: 24 * 60 * 60 * 1000, // 1 day maximum retry duration
|
|
197
|
+
FETCH_RETRY_MIN_ATTEMPTS: 20, // minimum attempts before time limit applies
|
|
196
198
|
DEFAULT_FETCH_BATCH_SIZE: 1000,
|
|
197
199
|
|
|
198
200
|
// MS Graph webhook subscription settings
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
4
|
const { compareExisting, calculateFetchBackoff, readEnvValue } = require('../../tools');
|
|
5
5
|
const config = require('@zone-eu/wild-config');
|
|
6
|
-
const { DEFAULT_FETCH_BATCH_SIZE } = require('../../consts');
|
|
6
|
+
const { DEFAULT_FETCH_BATCH_SIZE, FETCH_RETRY_MAX_TIME, FETCH_RETRY_MIN_ATTEMPTS } = require('../../consts');
|
|
7
7
|
|
|
8
8
|
// Configurable batch size for fetching messages (default: 250)
|
|
9
9
|
const FETCH_BATCH_SIZE = Number(readEnvValue('EENGINE_FETCH_BATCH_SIZE') || config.service.fetchBatchSize) || DEFAULT_FETCH_BATCH_SIZE;
|
|
@@ -11,6 +11,15 @@ const FETCH_BATCH_SIZE = Number(readEnvValue('EENGINE_FETCH_BATCH_SIZE') || conf
|
|
|
11
11
|
// Do not check for flag updates using full sync more often than this value (30 minutes)
|
|
12
12
|
const FULL_SYNC_DELAY = 30 * 60 * 1000;
|
|
13
13
|
|
|
14
|
+
function checkFetchRetryTimeout(retryCount, startTime) {
|
|
15
|
+
if (retryCount >= FETCH_RETRY_MIN_ATTEMPTS && Date.now() - startTime > FETCH_RETRY_MAX_TIME) {
|
|
16
|
+
const err = new Error('FETCH retry time limit exceeded');
|
|
17
|
+
err.code = 'FetchRetryTimeout';
|
|
18
|
+
err.statusCode = 504;
|
|
19
|
+
throw err;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
14
23
|
/**
|
|
15
24
|
* Calculates the next range of sequence numbers to fetch based on the last fetched range
|
|
16
25
|
* @param {Number} totalMessages - Total number of messages in the mailbox
|
|
@@ -234,6 +243,7 @@ class SyncOperations {
|
|
|
234
243
|
// Fetch messages with retry logic
|
|
235
244
|
let fetchCompleted = false;
|
|
236
245
|
let fetchRetryCount = 0;
|
|
246
|
+
let fetchStartTime = Date.now();
|
|
237
247
|
|
|
238
248
|
while (!fetchCompleted) {
|
|
239
249
|
try {
|
|
@@ -330,6 +340,8 @@ class SyncOperations {
|
|
|
330
340
|
connErr.statusCode = 503;
|
|
331
341
|
throw connErr;
|
|
332
342
|
}
|
|
343
|
+
|
|
344
|
+
checkFetchRetryTimeout(fetchRetryCount, fetchStartTime);
|
|
333
345
|
}
|
|
334
346
|
}
|
|
335
347
|
}
|
|
@@ -383,6 +395,7 @@ class SyncOperations {
|
|
|
383
395
|
// only fetch messages if there are some
|
|
384
396
|
let fetchCompleted = false;
|
|
385
397
|
let fetchRetryCount = 0;
|
|
398
|
+
let fetchStartTime = Date.now();
|
|
386
399
|
while (!fetchCompleted) {
|
|
387
400
|
// Get fresh imapClient reference inside retry loop
|
|
388
401
|
let imapClient = this.connection.imapClient;
|
|
@@ -483,6 +496,8 @@ class SyncOperations {
|
|
|
483
496
|
connErr.statusCode = 503;
|
|
484
497
|
throw connErr;
|
|
485
498
|
}
|
|
499
|
+
|
|
500
|
+
checkFetchRetryTimeout(fetchRetryCount, fetchStartTime);
|
|
486
501
|
}
|
|
487
502
|
}
|
|
488
503
|
}
|
|
@@ -553,6 +568,8 @@ class SyncOperations {
|
|
|
553
568
|
let range = false;
|
|
554
569
|
let lastHighestUid = 0;
|
|
555
570
|
let batchNumber = 0;
|
|
571
|
+
let fetchStartTime = Date.now();
|
|
572
|
+
let totalFetchRetryCount = 0;
|
|
556
573
|
// process messages in batches
|
|
557
574
|
while ((range = getFetchRange(mailboxStatus.messages, range))) {
|
|
558
575
|
batchNumber++;
|
|
@@ -738,6 +755,7 @@ class SyncOperations {
|
|
|
738
755
|
}
|
|
739
756
|
|
|
740
757
|
// Retry with exponential backoff
|
|
758
|
+
totalFetchRetryCount++;
|
|
741
759
|
const fetchRetryDelay = calculateFetchBackoff(++fetchRetryCount);
|
|
742
760
|
this.logger.error({ msg: `FETCH failed, retrying in ${Math.round(fetchRetryDelay / 1000)}s`, loopId, batchNumber });
|
|
743
761
|
await new Promise(r => setTimeout(r, fetchRetryDelay));
|
|
@@ -776,6 +794,8 @@ class SyncOperations {
|
|
|
776
794
|
newMessages: mailboxStatus.messages,
|
|
777
795
|
range
|
|
778
796
|
});
|
|
797
|
+
|
|
798
|
+
checkFetchRetryTimeout(totalFetchRetryCount, fetchStartTime);
|
|
779
799
|
}
|
|
780
800
|
}
|
|
781
801
|
}
|
|
@@ -299,7 +299,9 @@ class IMAPClient extends BaseClient {
|
|
|
299
299
|
|
|
300
300
|
// Set up error handling for the command connection
|
|
301
301
|
const onErr = err => {
|
|
302
|
-
|
|
302
|
+
// Idle timeout (ETIMEOUT) is expected - the command client has no keepalive
|
|
303
|
+
const logLevel = err.code === 'ETIMEOUT' ? 'debug' : 'warn';
|
|
304
|
+
commandClient?.log[logLevel]({ msg: 'IMAP connection error', cid: commandCid, channel: 'command', account: this.account, err });
|
|
303
305
|
commandClient.close();
|
|
304
306
|
this.commandClient = null;
|
|
305
307
|
};
|
|
@@ -1214,9 +1216,14 @@ class IMAPClient extends BaseClient {
|
|
|
1214
1216
|
}
|
|
1215
1217
|
|
|
1216
1218
|
// Provider-specific workarounds
|
|
1217
|
-
if (
|
|
1218
|
-
//
|
|
1219
|
+
if (/\.rambler\.ru$/i.test(imapConfig.host)) {
|
|
1220
|
+
// Rambler supports IDLE but needs it restarted frequently
|
|
1219
1221
|
imapConfig.maxIdleTime = 55 * 1000;
|
|
1222
|
+
} else if (/\.163\.com$/i.test(imapConfig.host)) {
|
|
1223
|
+
// 163.com (Coremail) does not support IDLE and ignores NOOP for idle timer reset.
|
|
1224
|
+
// Use STATUS as the keepalive command - it counts as real activity for the server.
|
|
1225
|
+
imapConfig.maxIdleTime = 55 * 1000;
|
|
1226
|
+
imapConfig.missingIdleCommand = 'STATUS';
|
|
1220
1227
|
} else if (/\.yahoo\.com$/i.test(imapConfig.host)) {
|
|
1221
1228
|
// Special case for Yahoo. Break IDLE at least once every three minutes
|
|
1222
1229
|
imapConfig.maxIdleTime = 3 * 60 * 1000;
|
|
@@ -2468,6 +2475,9 @@ class IMAPClient extends BaseClient {
|
|
|
2468
2475
|
const mailboxes = [];
|
|
2469
2476
|
|
|
2470
2477
|
const listing = await this.getCurrentListing(false, { allowSecondary: true });
|
|
2478
|
+
// Capture reference so we can close it after the await-heavy loop below
|
|
2479
|
+
// without risking closure of a different command client obtained by a concurrent caller
|
|
2480
|
+
const commandClientForListing = this.commandClient;
|
|
2471
2481
|
|
|
2472
2482
|
// Process each configured subconnection
|
|
2473
2483
|
for (const path of accountData.subconnections || []) {
|
|
@@ -2595,6 +2605,12 @@ class IMAPClient extends BaseClient {
|
|
|
2595
2605
|
await subconnection.init();
|
|
2596
2606
|
}
|
|
2597
2607
|
|
|
2608
|
+
// Close the command client if it is still the same instance we captured above
|
|
2609
|
+
if (commandClientForListing && this.commandClient === commandClientForListing) {
|
|
2610
|
+
this.commandClient.close();
|
|
2611
|
+
this.commandClient = null;
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2598
2614
|
return this.subconnections.length;
|
|
2599
2615
|
}
|
|
2600
2616
|
|
|
@@ -3857,8 +3857,11 @@ class OutlookClient extends BaseClient {
|
|
|
3857
3857
|
async processHistory() {
|
|
3858
3858
|
let event;
|
|
3859
3859
|
let newMessageOptions;
|
|
3860
|
+
let processedAny = false;
|
|
3860
3861
|
|
|
3861
3862
|
while ((event = await this.accountObject.pullQueueEvent()) !== null) {
|
|
3863
|
+
processedAny = true;
|
|
3864
|
+
|
|
3862
3865
|
switch (event.type) {
|
|
3863
3866
|
case 'updated':
|
|
3864
3867
|
await this.processUpdatedMessage(event.message);
|
|
@@ -3900,6 +3903,157 @@ class OutlookClient extends BaseClient {
|
|
|
3900
3903
|
break;
|
|
3901
3904
|
}
|
|
3902
3905
|
}
|
|
3906
|
+
|
|
3907
|
+
if (processedAny) {
|
|
3908
|
+
await this._updateLastNotificationTime();
|
|
3909
|
+
}
|
|
3910
|
+
}
|
|
3911
|
+
|
|
3912
|
+
async _updateLastNotificationTime() {
|
|
3913
|
+
try {
|
|
3914
|
+
await this.redis.hset(this.getAccountKey(), 'outlookLastNotification', Date.now().toString());
|
|
3915
|
+
} catch (err) {
|
|
3916
|
+
this.logger.error({ msg: 'Failed to update last notification time', err });
|
|
3917
|
+
}
|
|
3918
|
+
}
|
|
3919
|
+
|
|
3920
|
+
/**
|
|
3921
|
+
* Recover messages missed due to failed webhook delivery.
|
|
3922
|
+
* Triggered by MS Graph 'missed' lifecycle event.
|
|
3923
|
+
* Queries recent messages and processes any not already seen via normal webhooks.
|
|
3924
|
+
* @returns {boolean} Whether recovery ran (false if debounced)
|
|
3925
|
+
*/
|
|
3926
|
+
async syncMissedMessages() {
|
|
3927
|
+
// Debounce: skip if recovery already ran recently
|
|
3928
|
+
const cooldownKey = `${REDIS_PREFIX}iam:${this.account}:missedRecovery`;
|
|
3929
|
+
const acquired = await this.redis.set(cooldownKey, '1', 'EX', 300, 'NX');
|
|
3930
|
+
if (!acquired) {
|
|
3931
|
+
this.logger.debug({ msg: 'Missed notification recovery skipped (cooldown active)', account: this.account });
|
|
3932
|
+
return false;
|
|
3933
|
+
}
|
|
3934
|
+
|
|
3935
|
+
// Determine lookback window
|
|
3936
|
+
let sinceTime;
|
|
3937
|
+
const lastNotification = await this.redis.hget(this.getAccountKey(), 'outlookLastNotification');
|
|
3938
|
+
if (lastNotification) {
|
|
3939
|
+
// 2-minute safety margin before last known good notification
|
|
3940
|
+
sinceTime = new Date(Number(lastNotification) - 2 * 60 * 1000);
|
|
3941
|
+
} else {
|
|
3942
|
+
sinceTime = new Date(Date.now() - 30 * 60 * 1000);
|
|
3943
|
+
}
|
|
3944
|
+
|
|
3945
|
+
this.logger.info({
|
|
3946
|
+
msg: 'Recovering missed notifications',
|
|
3947
|
+
account: this.account,
|
|
3948
|
+
since: sinceTime.toISOString()
|
|
3949
|
+
});
|
|
3950
|
+
|
|
3951
|
+
let messages = [];
|
|
3952
|
+
let reqUrl = `/${this.oauth2UserPath}/messages`;
|
|
3953
|
+
let reqPayload = {
|
|
3954
|
+
$filter: `receivedDateTime gt ${sinceTime.toISOString()}`,
|
|
3955
|
+
$select: 'id,parentFolderId',
|
|
3956
|
+
$top: 250,
|
|
3957
|
+
$orderby: 'receivedDateTime desc'
|
|
3958
|
+
};
|
|
3959
|
+
|
|
3960
|
+
try {
|
|
3961
|
+
while (reqUrl) {
|
|
3962
|
+
let pageResult = await this.request(reqUrl, 'get', reqPayload);
|
|
3963
|
+
|
|
3964
|
+
if (pageResult?.value?.length) {
|
|
3965
|
+
messages.push(...pageResult.value);
|
|
3966
|
+
if (messages.length >= 1000) {
|
|
3967
|
+
messages.length = 1000;
|
|
3968
|
+
break;
|
|
3969
|
+
}
|
|
3970
|
+
}
|
|
3971
|
+
|
|
3972
|
+
if (pageResult['@odata.nextLink']) {
|
|
3973
|
+
reqUrl = pageResult['@odata.nextLink'];
|
|
3974
|
+
reqPayload = null; // nextLink includes all query params
|
|
3975
|
+
} else {
|
|
3976
|
+
reqUrl = null;
|
|
3977
|
+
}
|
|
3978
|
+
}
|
|
3979
|
+
} catch (err) {
|
|
3980
|
+
this.logger.error({ msg: 'Failed to query recent messages for missed notification recovery', account: this.account, err });
|
|
3981
|
+
await this.redis.del(cooldownKey);
|
|
3982
|
+
return false;
|
|
3983
|
+
}
|
|
3984
|
+
|
|
3985
|
+
if (!messages.length) {
|
|
3986
|
+
this.logger.debug({ msg: 'No recent messages found during missed notification recovery', account: this.account });
|
|
3987
|
+
return true;
|
|
3988
|
+
}
|
|
3989
|
+
|
|
3990
|
+
this.logger.info({
|
|
3991
|
+
msg: 'Found recent messages for missed notification recovery',
|
|
3992
|
+
account: this.account,
|
|
3993
|
+
count: messages.length
|
|
3994
|
+
});
|
|
3995
|
+
|
|
3996
|
+
let newMessageOptions = await this.getMessageFetchOptions();
|
|
3997
|
+
newMessageOptions.showPath = true;
|
|
3998
|
+
|
|
3999
|
+
// Pre-resolve unique folder IDs once to avoid per-message Redis lookups
|
|
4000
|
+
let folderMap = new Map();
|
|
4001
|
+
let cachedListing = await this.getCachedMailboxListing();
|
|
4002
|
+
if (cachedListing) {
|
|
4003
|
+
for (let msg of messages) {
|
|
4004
|
+
if (msg.parentFolderId && !folderMap.has(msg.parentFolderId)) {
|
|
4005
|
+
let folder = cachedListing.find(entry => entry.id === msg.parentFolderId);
|
|
4006
|
+
if (folder) {
|
|
4007
|
+
folderMap.set(msg.parentFolderId, folder);
|
|
4008
|
+
}
|
|
4009
|
+
}
|
|
4010
|
+
}
|
|
4011
|
+
}
|
|
4012
|
+
|
|
4013
|
+
let recoveredCount = 0;
|
|
4014
|
+
|
|
4015
|
+
for (const msg of messages) {
|
|
4016
|
+
try {
|
|
4017
|
+
// Pre-filter: check rollingBucketLock BEFORE the expensive getMessage() call
|
|
4018
|
+
let preFilterChecked = false;
|
|
4019
|
+
let folder = folderMap.get(msg.parentFolderId);
|
|
4020
|
+
if (folder) {
|
|
4021
|
+
let recentlySeen = await this.rollingBucketLock(`${msg.id}:created`, folder.pathName);
|
|
4022
|
+
if (recentlySeen) {
|
|
4023
|
+
continue;
|
|
4024
|
+
}
|
|
4025
|
+
preFilterChecked = true;
|
|
4026
|
+
}
|
|
4027
|
+
|
|
4028
|
+
let messageData = await this.prepareNewMessage(msg.id, newMessageOptions);
|
|
4029
|
+
if (!messageData) {
|
|
4030
|
+
continue;
|
|
4031
|
+
}
|
|
4032
|
+
|
|
4033
|
+
if (!preFilterChecked) {
|
|
4034
|
+
let recentlySeen = await this.rollingBucketLock(`${messageData.id}:created`, messageData.path);
|
|
4035
|
+
if (recentlySeen) {
|
|
4036
|
+
continue;
|
|
4037
|
+
}
|
|
4038
|
+
}
|
|
4039
|
+
|
|
4040
|
+
await this.processNew(messageData, newMessageOptions);
|
|
4041
|
+
recoveredCount++;
|
|
4042
|
+
} catch (err) {
|
|
4043
|
+
this.logger.error({ msg: 'Failed to process message during missed notification recovery', emailId: msg.id, account: this.account, err });
|
|
4044
|
+
}
|
|
4045
|
+
}
|
|
4046
|
+
|
|
4047
|
+
this.logger.info({
|
|
4048
|
+
msg: 'Missed notification recovery completed',
|
|
4049
|
+
account: this.account,
|
|
4050
|
+
checked: messages.length,
|
|
4051
|
+
recovered: recoveredCount
|
|
4052
|
+
});
|
|
4053
|
+
|
|
4054
|
+
await this._updateLastNotificationTime();
|
|
4055
|
+
|
|
4056
|
+
return true;
|
|
3903
4057
|
}
|
|
3904
4058
|
|
|
3905
4059
|
/**
|
package/lib/passkeys.js
CHANGED
|
@@ -48,6 +48,47 @@ async function fetchCredentialsBySet(setKey) {
|
|
|
48
48
|
return credentials;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
redis.defineCommand('webauthnSaveIfUnderLimit', {
|
|
52
|
+
numberOfKeys: 3,
|
|
53
|
+
lua: `
|
|
54
|
+
local userSetKey = KEYS[1]
|
|
55
|
+
local credKey = KEYS[2]
|
|
56
|
+
local allSetKey = KEYS[3]
|
|
57
|
+
local maxCount = tonumber(ARGV[1])
|
|
58
|
+
local credId = ARGV[2]
|
|
59
|
+
|
|
60
|
+
local currentCount = redis.call('SCARD', userSetKey)
|
|
61
|
+
if currentCount >= maxCount then
|
|
62
|
+
return 0
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
redis.call('SADD', userSetKey, credId)
|
|
66
|
+
redis.call('SADD', allSetKey, credId)
|
|
67
|
+
redis.call('HSET', credKey, unpack(ARGV, 3))
|
|
68
|
+
return 1
|
|
69
|
+
`
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
function serializeCredential({ id, publicKey, counter, transports, name, user }) {
|
|
73
|
+
return {
|
|
74
|
+
id,
|
|
75
|
+
publicKey,
|
|
76
|
+
counter: String(counter),
|
|
77
|
+
transports: JSON.stringify(transports || []),
|
|
78
|
+
name: name || 'Unnamed passkey',
|
|
79
|
+
user,
|
|
80
|
+
createdAt: new Date().toISOString()
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function credentialKeys(id, user) {
|
|
85
|
+
return {
|
|
86
|
+
credKey: `${KEY_PREFIX}cred:${id}`,
|
|
87
|
+
userSetKey: `${KEY_PREFIX}creds:${user}`,
|
|
88
|
+
allSetKey: `${KEY_PREFIX}all`
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
51
92
|
module.exports = {
|
|
52
93
|
async getRpConfig() {
|
|
53
94
|
let serviceUrl = await settings.get('serviceUrl');
|
|
@@ -73,26 +114,26 @@ module.exports = {
|
|
|
73
114
|
return challenge || null;
|
|
74
115
|
},
|
|
75
116
|
|
|
76
|
-
async saveCredential(
|
|
77
|
-
let credKey =
|
|
78
|
-
let userSetKey = `${KEY_PREFIX}creds:${user}`;
|
|
79
|
-
let allSetKey = `${KEY_PREFIX}all`;
|
|
117
|
+
async saveCredential(cred) {
|
|
118
|
+
let { credKey, userSetKey, allSetKey } = credentialKeys(cred.id, cred.user);
|
|
80
119
|
|
|
81
120
|
let pipeline = redis.pipeline();
|
|
82
|
-
pipeline.hset(credKey,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
counter: String(counter),
|
|
86
|
-
transports: JSON.stringify(transports || []),
|
|
87
|
-
name: name || 'Unnamed passkey',
|
|
88
|
-
user,
|
|
89
|
-
createdAt: new Date().toISOString()
|
|
90
|
-
});
|
|
91
|
-
pipeline.sadd(userSetKey, id);
|
|
92
|
-
pipeline.sadd(allSetKey, id);
|
|
121
|
+
pipeline.hset(credKey, serializeCredential(cred));
|
|
122
|
+
pipeline.sadd(userSetKey, cred.id);
|
|
123
|
+
pipeline.sadd(allSetKey, cred.id);
|
|
93
124
|
await pipeline.exec();
|
|
94
125
|
},
|
|
95
126
|
|
|
127
|
+
async saveCredentialIfUnderLimit(cred, maxCount) {
|
|
128
|
+
let { credKey, userSetKey, allSetKey } = credentialKeys(cred.id, cred.user);
|
|
129
|
+
let fields = serializeCredential(cred);
|
|
130
|
+
let fieldArgs = Object.entries(fields).flat();
|
|
131
|
+
|
|
132
|
+
let added = await redis.webauthnSaveIfUnderLimit(userSetKey, credKey, allSetKey, maxCount, cred.id, ...fieldArgs);
|
|
133
|
+
|
|
134
|
+
return !!added;
|
|
135
|
+
},
|
|
136
|
+
|
|
96
137
|
async getCredential(credentialId) {
|
|
97
138
|
if (!credentialId || typeof credentialId !== 'string') {
|
|
98
139
|
return null;
|
package/lib/routes-ui.js
CHANGED
|
@@ -4258,20 +4258,22 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
|
|
|
4258
4258
|
}
|
|
4259
4259
|
|
|
4260
4260
|
let authData = await settings.get('authData');
|
|
4261
|
-
if (authData
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4261
|
+
if (!authData || !authData.password) {
|
|
4262
|
+
return h.response({ error: 'Account password must be configured before registering passkeys' }).code(403);
|
|
4263
|
+
}
|
|
4264
|
+
|
|
4265
|
+
if (!request.payload.password) {
|
|
4266
|
+
return h.response({ error: 'Current password is required' }).code(403);
|
|
4267
|
+
}
|
|
4268
|
+
let valid;
|
|
4269
|
+
try {
|
|
4270
|
+
valid = await pbkdf2.verify(authData.password, request.payload.password);
|
|
4271
|
+
} catch (err) {
|
|
4272
|
+
request.logger.error({ msg: 'Failed to verify password for passkey registration', err });
|
|
4273
|
+
valid = false;
|
|
4274
|
+
}
|
|
4275
|
+
if (!valid) {
|
|
4276
|
+
return h.response({ error: 'Invalid password' }).code(403);
|
|
4275
4277
|
}
|
|
4276
4278
|
|
|
4277
4279
|
let { rpId, origin } = await passkeys.getRpConfig();
|
|
@@ -4359,19 +4361,26 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
|
|
|
4359
4361
|
let { credential } = verification.registrationInfo;
|
|
4360
4362
|
let user = (request.auth && request.auth.credentials && request.auth.credentials.user) || 'admin';
|
|
4361
4363
|
|
|
4362
|
-
let
|
|
4363
|
-
if (
|
|
4364
|
-
return h.response({ error: '
|
|
4364
|
+
let authData = await settings.get('authData');
|
|
4365
|
+
if (!authData || !authData.password) {
|
|
4366
|
+
return h.response({ error: 'Account password must be configured before registering passkeys' }).code(403);
|
|
4365
4367
|
}
|
|
4366
4368
|
|
|
4367
|
-
await passkeys.
|
|
4368
|
-
|
|
4369
|
-
|
|
4370
|
-
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
|
|
4374
|
-
|
|
4369
|
+
let saved = await passkeys.saveCredentialIfUnderLimit(
|
|
4370
|
+
{
|
|
4371
|
+
id: credential.id,
|
|
4372
|
+
publicKey: Buffer.from(credential.publicKey).toString('base64url'),
|
|
4373
|
+
counter: credential.counter,
|
|
4374
|
+
transports: credential.transports || [],
|
|
4375
|
+
name: request.payload.name,
|
|
4376
|
+
user
|
|
4377
|
+
},
|
|
4378
|
+
consts.MAX_PASSKEYS_PER_USER
|
|
4379
|
+
);
|
|
4380
|
+
|
|
4381
|
+
if (!saved) {
|
|
4382
|
+
return h.response({ error: 'Maximum number of passkeys reached' }).code(400);
|
|
4383
|
+
}
|
|
4375
4384
|
|
|
4376
4385
|
request.logger.info({
|
|
4377
4386
|
msg: 'Passkey registered',
|