@adminforth/two-factors-auth 2.1.0 → 2.1.2
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/build.log +2 -2
- package/custom/TwoFAModal.vue +85 -3
- package/custom/TwoFactorsConfirmation.vue +74 -6
- package/custom/TwoFactorsPasskeysSettings.vue +1 -0
- package/dist/custom/TwoFAModal.vue +85 -3
- package/dist/custom/TwoFactorsConfirmation.vue +74 -6
- package/dist/custom/TwoFactorsPasskeysSettings.vue +1 -0
- package/dist/index.js +0 -6
- package/index.ts +0 -6
- package/package.json +1 -1
package/build.log
CHANGED
|
@@ -13,5 +13,5 @@ custom/package.json
|
|
|
13
13
|
custom/tsconfig.json
|
|
14
14
|
custom/utils.js
|
|
15
15
|
|
|
16
|
-
sent
|
|
17
|
-
total size is
|
|
16
|
+
sent 74,931 bytes received 172 bytes 150,206.00 bytes/sec
|
|
17
|
+
total size is 74,291 speedup is 0.99
|
package/custom/TwoFAModal.vue
CHANGED
|
@@ -97,6 +97,25 @@
|
|
|
97
97
|
(e: 'closed'): void
|
|
98
98
|
}>();
|
|
99
99
|
|
|
100
|
+
function removeListeners() {
|
|
101
|
+
window.removeEventListener('paste', handlePaste);
|
|
102
|
+
document.removeEventListener('focusin', handleGlobalFocusIn, true);
|
|
103
|
+
const rootEl = otpRoot.value;
|
|
104
|
+
rootEl && rootEl.removeEventListener('focusout', handleFocusOut, true);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function addListeners() {
|
|
108
|
+
document.addEventListener('focusin', handleGlobalFocusIn, true);
|
|
109
|
+
|
|
110
|
+
// Wait for DOM to be ready and OTP inputs to be rendered
|
|
111
|
+
await nextTick();
|
|
112
|
+
await new Promise(resolve => setTimeout(resolve, 100)); // Small delay for v-otp-input to render
|
|
113
|
+
|
|
114
|
+
focusFirstAvailableOtpInput();
|
|
115
|
+
const rootEl = otpRoot.value;
|
|
116
|
+
rootEl && rootEl.addEventListener('focusout', handleFocusOut, true);
|
|
117
|
+
}
|
|
118
|
+
|
|
100
119
|
const modelShow = ref(false);
|
|
101
120
|
let resolveFn: ((confirmationResult: string) => void) | null = null;
|
|
102
121
|
let verifyingCallback: ((confirmationResult: string) => boolean) | null = null;
|
|
@@ -113,6 +132,7 @@
|
|
|
113
132
|
customDialogTitle.value = title;
|
|
114
133
|
}
|
|
115
134
|
modelShow.value = true;
|
|
135
|
+
await addListeners();
|
|
116
136
|
resolveFn = resolve;
|
|
117
137
|
rejectFn = reject;
|
|
118
138
|
verifyFn = verifyingCallback ?? null;
|
|
@@ -144,6 +164,7 @@
|
|
|
144
164
|
result: passkeyData
|
|
145
165
|
}
|
|
146
166
|
customDialogTitle.value = "";
|
|
167
|
+
removeListeners();
|
|
147
168
|
resolveFn(dataToReturn);
|
|
148
169
|
}
|
|
149
170
|
|
|
@@ -195,6 +216,7 @@
|
|
|
195
216
|
result: value
|
|
196
217
|
}
|
|
197
218
|
customDialogTitle.value = "";
|
|
219
|
+
removeListeners();
|
|
198
220
|
resolveFn(dataToReturn);
|
|
199
221
|
}
|
|
200
222
|
|
|
@@ -203,11 +225,22 @@
|
|
|
203
225
|
modelShow.value = false;
|
|
204
226
|
bindValue.value = '';
|
|
205
227
|
confirmationResult.value?.clearInput();
|
|
228
|
+
removeListeners();
|
|
206
229
|
rejectFn("Cancel");
|
|
207
230
|
emit('rejected', new Error('cancelled'));
|
|
208
231
|
emit('closed');
|
|
209
232
|
}
|
|
210
233
|
|
|
234
|
+
watch(modalMode, async (newMode) => {
|
|
235
|
+
if (newMode === 'totp' && modelShow.value && !isLoading.value) {
|
|
236
|
+
await nextTick();
|
|
237
|
+
setTimeout(() => {
|
|
238
|
+
tagOtpInputs();
|
|
239
|
+
focusFirstAvailableOtpInput();
|
|
240
|
+
}, 100);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
211
244
|
watch(modelShow, async (open) => {
|
|
212
245
|
if (open) {
|
|
213
246
|
await nextTick();
|
|
@@ -215,8 +248,17 @@
|
|
|
215
248
|
if (htmlRef) {
|
|
216
249
|
htmlRef.style.overflow = 'hidden';
|
|
217
250
|
}
|
|
218
|
-
|
|
219
|
-
|
|
251
|
+
|
|
252
|
+
// Wait for conditional rendering to complete
|
|
253
|
+
if (modalMode.value === 'totp' && !isLoading.value) {
|
|
254
|
+
await nextTick();
|
|
255
|
+
setTimeout(() => {
|
|
256
|
+
tagOtpInputs();
|
|
257
|
+
window.addEventListener('paste', handlePaste);
|
|
258
|
+
}, 100);
|
|
259
|
+
} else {
|
|
260
|
+
window.addEventListener('paste', handlePaste);
|
|
261
|
+
}
|
|
220
262
|
} else {
|
|
221
263
|
window.removeEventListener('paste', handlePaste);
|
|
222
264
|
const htmlRef = document.querySelector('html');
|
|
@@ -248,7 +290,47 @@
|
|
|
248
290
|
});
|
|
249
291
|
}
|
|
250
292
|
|
|
251
|
-
|
|
293
|
+
function getOtpInputs() {
|
|
294
|
+
const root = otpRoot.value;
|
|
295
|
+
if (!root) return [];
|
|
296
|
+
return Array.from(root.querySelectorAll('input.otp-input'));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function focusFirstAvailableOtpInput() {
|
|
300
|
+
const inputs = getOtpInputs();
|
|
301
|
+
if (!inputs.length) {
|
|
302
|
+
// Retry after a short delay if inputs aren't ready yet
|
|
303
|
+
setTimeout(() => focusFirstAvailableOtpInput(), 50);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const firstEmpty = inputs.find((i) => !(i as HTMLInputElement).value);
|
|
307
|
+
((firstEmpty || inputs[0]) as HTMLInputElement).focus();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function handleGlobalFocusIn(event) {
|
|
311
|
+
const inputs = getOtpInputs();
|
|
312
|
+
if (!inputs.length) return;
|
|
313
|
+
const target = event.target;
|
|
314
|
+
if (!target) return;
|
|
315
|
+
if (!inputs.includes(target)) {
|
|
316
|
+
requestAnimationFrame(() => {
|
|
317
|
+
focusFirstAvailableOtpInput();
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function handleFocusOut() {
|
|
323
|
+
requestAnimationFrame(() => {
|
|
324
|
+
const inputs = getOtpInputs();
|
|
325
|
+
if (!inputs.length) return;
|
|
326
|
+
const active = document.activeElement;
|
|
327
|
+
if (!active || !inputs.includes(active)) {
|
|
328
|
+
focusFirstAvailableOtpInput();
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
</script>
|
|
252
334
|
|
|
253
335
|
<style scoped>
|
|
254
336
|
:deep(.otp-input-container) {
|
|
@@ -98,6 +98,9 @@
|
|
|
98
98
|
import { IconShieldOutline } from '@iconify-prerendered/vue-flowbite';
|
|
99
99
|
import ErrorMessage from '@/components/ErrorMessage.vue';
|
|
100
100
|
import { handlePasskeyAlert } from './utils.js';
|
|
101
|
+
|
|
102
|
+
// Global provided by AdminForth runtime
|
|
103
|
+
declare const adminforth: any;
|
|
101
104
|
|
|
102
105
|
const { t } = useI18n();
|
|
103
106
|
const code = ref(null);
|
|
@@ -108,6 +111,29 @@
|
|
|
108
111
|
const codeError = ref(null);
|
|
109
112
|
const isFetchingPasskey = ref(false);
|
|
110
113
|
|
|
114
|
+
// Shared WebAuthn state to prevent concurrent navigator.credentials.get calls
|
|
115
|
+
const webAuthn = {
|
|
116
|
+
controller: null as AbortController | null,
|
|
117
|
+
inFlight: false,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
function cancelPendingWebAuthn(reason?: string) {
|
|
121
|
+
try {
|
|
122
|
+
if (webAuthn.controller) {
|
|
123
|
+
// Abort any pending WebAuthn request
|
|
124
|
+
webAuthn.controller.abort();
|
|
125
|
+
}
|
|
126
|
+
} catch (e) {
|
|
127
|
+
// no-op
|
|
128
|
+
} finally {
|
|
129
|
+
webAuthn.controller = null;
|
|
130
|
+
webAuthn.inFlight = false;
|
|
131
|
+
if (reason) {
|
|
132
|
+
console.debug('[WebAuthn] Aborted pending request:', reason);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
111
137
|
onBeforeMount(() => {
|
|
112
138
|
if (localStorage.getItem('isAuthorized') === 'true') {
|
|
113
139
|
coreStore.fetchMenuAndResource();
|
|
@@ -147,6 +173,8 @@
|
|
|
147
173
|
|
|
148
174
|
onMounted(async () => {
|
|
149
175
|
if (localStorage.getItem('isAuthorized') !== 'true') {
|
|
176
|
+
// Safety: ensure no pending WebAuthn request survives SPA navigation
|
|
177
|
+
cancelPendingWebAuthn('mount-init');
|
|
150
178
|
await nextTick();
|
|
151
179
|
await isCMAAvailable();
|
|
152
180
|
tagOtpInputs();
|
|
@@ -167,8 +195,10 @@
|
|
|
167
195
|
watch(route, (newRoute) => {
|
|
168
196
|
codeError.value = null;
|
|
169
197
|
if ( newRoute.hash === '#passkey' ) {
|
|
198
|
+
cancelPendingWebAuthn('switch-to-passkey');
|
|
170
199
|
confirmationMode.value = 'passkey';
|
|
171
200
|
} else if ( newRoute.hash === '#code' ) {
|
|
201
|
+
cancelPendingWebAuthn('switch-to-code');
|
|
172
202
|
confirmationMode.value = 'code';
|
|
173
203
|
}
|
|
174
204
|
});
|
|
@@ -188,6 +218,8 @@
|
|
|
188
218
|
document.removeEventListener('focusin', handleGlobalFocusIn, true);
|
|
189
219
|
const rootEl = otpRoot.value;
|
|
190
220
|
rootEl && rootEl.removeEventListener('focusout', handleFocusOut, true);
|
|
221
|
+
// Abort any in-flight WebAuthn request when leaving the component
|
|
222
|
+
cancelPendingWebAuthn('component-unmount');
|
|
191
223
|
});
|
|
192
224
|
|
|
193
225
|
async function sendCode (value: any, factorMode: 'TOTP' | 'passkey', passkeyOptions: any) {
|
|
@@ -257,8 +289,16 @@
|
|
|
257
289
|
}
|
|
258
290
|
|
|
259
291
|
async function usePasskeyButton() {
|
|
292
|
+
// Cancel any stray pending requests from earlier flows
|
|
293
|
+
cancelPendingWebAuthn('button-pressed');
|
|
294
|
+
codeError.value = null;
|
|
260
295
|
isFetchingPasskey.value = true;
|
|
261
|
-
const
|
|
296
|
+
const signIn = await createSignInRequest();
|
|
297
|
+
if (!signIn) {
|
|
298
|
+
isFetchingPasskey.value = false;
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const { _options, challengeId } = signIn;
|
|
262
302
|
const options = PublicKeyCredential.parseRequestOptionsFromJSON(_options);
|
|
263
303
|
const credential = await authenticate(options);
|
|
264
304
|
if (!credential) {
|
|
@@ -300,25 +340,52 @@
|
|
|
300
340
|
async function authenticate(options) {
|
|
301
341
|
console.log("Authenticating with options:", options);
|
|
302
342
|
try {
|
|
343
|
+
// Guard: prevent concurrent navigator.credentials.get calls
|
|
344
|
+
if (webAuthn.inFlight) {
|
|
345
|
+
console.warn('[WebAuthn] A request is already in flight. Aborting previous and retrying.');
|
|
346
|
+
cancelPendingWebAuthn('pre-auth-guard');
|
|
347
|
+
}
|
|
348
|
+
|
|
303
349
|
const abortController = new AbortController();
|
|
350
|
+
webAuthn.controller = abortController;
|
|
351
|
+
webAuthn.inFlight = true;
|
|
352
|
+
|
|
304
353
|
const credential = await navigator.credentials.get({
|
|
305
354
|
publicKey: options,
|
|
306
355
|
signal: abortController.signal,
|
|
356
|
+
// mediation can be set if using conditional UI, omitted here intentionally
|
|
307
357
|
});
|
|
308
358
|
console.log("Credential obtained:", credential);
|
|
309
359
|
return credential;
|
|
310
360
|
} catch (error) {
|
|
311
361
|
console.error('Error during authentication:', error);
|
|
362
|
+
// Handle specific concurrent/pending request error cases gracefully
|
|
363
|
+
const name = (error && (error.name || error.constructor?.name)) || '';
|
|
364
|
+
const message = (error && error.message) || '';
|
|
365
|
+
if (name === 'AbortError') {
|
|
366
|
+
// Aborted intentionally; no user-facing error needed
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
if (name === 'InvalidStateError' || name === 'OperationError' || /pending/i.test(message)) {
|
|
370
|
+
adminforth.alert({ message: t('Another security prompt is already open. Please try again.'), variant: 'warning' });
|
|
371
|
+
codeError.value = t('A previous passkey attempt was still pending. Please try again.');
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
312
374
|
adminforth.alert({message: `Error during authentication: ${error}`, variant: 'warning'});
|
|
313
375
|
codeError.value = 'Error during authentication.';
|
|
314
376
|
}
|
|
377
|
+
finally {
|
|
378
|
+
// Clear in-flight state regardless of outcome
|
|
379
|
+
webAuthn.inFlight = false;
|
|
380
|
+
webAuthn.controller = null;
|
|
381
|
+
}
|
|
315
382
|
}
|
|
316
383
|
|
|
317
384
|
|
|
318
|
-
function getOtpInputs() {
|
|
385
|
+
function getOtpInputs(): HTMLInputElement[] {
|
|
319
386
|
const root = otpRoot.value;
|
|
320
387
|
if (!root) return [];
|
|
321
|
-
return Array.from(root.querySelectorAll('input.otp-input'));
|
|
388
|
+
return Array.from(root.querySelectorAll('input.otp-input')) as HTMLInputElement[];
|
|
322
389
|
}
|
|
323
390
|
|
|
324
391
|
function focusFirstAvailableOtpInput() {
|
|
@@ -331,7 +398,7 @@
|
|
|
331
398
|
function handleGlobalFocusIn(event) {
|
|
332
399
|
const inputs = getOtpInputs();
|
|
333
400
|
if (!inputs.length) return;
|
|
334
|
-
const target = event.target;
|
|
401
|
+
const target = event.target as HTMLInputElement | null;
|
|
335
402
|
if (!target) return;
|
|
336
403
|
if (!inputs.includes(target)) {
|
|
337
404
|
requestAnimationFrame(() => {
|
|
@@ -344,8 +411,9 @@
|
|
|
344
411
|
requestAnimationFrame(() => {
|
|
345
412
|
const inputs = getOtpInputs();
|
|
346
413
|
if (!inputs.length) return;
|
|
347
|
-
const active = document.activeElement;
|
|
348
|
-
|
|
414
|
+
const active = document.activeElement as Element | null;
|
|
415
|
+
const activeInput = active instanceof HTMLInputElement ? active : null;
|
|
416
|
+
if (!activeInput || !inputs.includes(activeInput)) {
|
|
349
417
|
focusFirstAvailableOtpInput();
|
|
350
418
|
}
|
|
351
419
|
});
|
|
@@ -97,6 +97,25 @@
|
|
|
97
97
|
(e: 'closed'): void
|
|
98
98
|
}>();
|
|
99
99
|
|
|
100
|
+
function removeListeners() {
|
|
101
|
+
window.removeEventListener('paste', handlePaste);
|
|
102
|
+
document.removeEventListener('focusin', handleGlobalFocusIn, true);
|
|
103
|
+
const rootEl = otpRoot.value;
|
|
104
|
+
rootEl && rootEl.removeEventListener('focusout', handleFocusOut, true);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function addListeners() {
|
|
108
|
+
document.addEventListener('focusin', handleGlobalFocusIn, true);
|
|
109
|
+
|
|
110
|
+
// Wait for DOM to be ready and OTP inputs to be rendered
|
|
111
|
+
await nextTick();
|
|
112
|
+
await new Promise(resolve => setTimeout(resolve, 100)); // Small delay for v-otp-input to render
|
|
113
|
+
|
|
114
|
+
focusFirstAvailableOtpInput();
|
|
115
|
+
const rootEl = otpRoot.value;
|
|
116
|
+
rootEl && rootEl.addEventListener('focusout', handleFocusOut, true);
|
|
117
|
+
}
|
|
118
|
+
|
|
100
119
|
const modelShow = ref(false);
|
|
101
120
|
let resolveFn: ((confirmationResult: string) => void) | null = null;
|
|
102
121
|
let verifyingCallback: ((confirmationResult: string) => boolean) | null = null;
|
|
@@ -113,6 +132,7 @@
|
|
|
113
132
|
customDialogTitle.value = title;
|
|
114
133
|
}
|
|
115
134
|
modelShow.value = true;
|
|
135
|
+
await addListeners();
|
|
116
136
|
resolveFn = resolve;
|
|
117
137
|
rejectFn = reject;
|
|
118
138
|
verifyFn = verifyingCallback ?? null;
|
|
@@ -144,6 +164,7 @@
|
|
|
144
164
|
result: passkeyData
|
|
145
165
|
}
|
|
146
166
|
customDialogTitle.value = "";
|
|
167
|
+
removeListeners();
|
|
147
168
|
resolveFn(dataToReturn);
|
|
148
169
|
}
|
|
149
170
|
|
|
@@ -195,6 +216,7 @@
|
|
|
195
216
|
result: value
|
|
196
217
|
}
|
|
197
218
|
customDialogTitle.value = "";
|
|
219
|
+
removeListeners();
|
|
198
220
|
resolveFn(dataToReturn);
|
|
199
221
|
}
|
|
200
222
|
|
|
@@ -203,11 +225,22 @@
|
|
|
203
225
|
modelShow.value = false;
|
|
204
226
|
bindValue.value = '';
|
|
205
227
|
confirmationResult.value?.clearInput();
|
|
228
|
+
removeListeners();
|
|
206
229
|
rejectFn("Cancel");
|
|
207
230
|
emit('rejected', new Error('cancelled'));
|
|
208
231
|
emit('closed');
|
|
209
232
|
}
|
|
210
233
|
|
|
234
|
+
watch(modalMode, async (newMode) => {
|
|
235
|
+
if (newMode === 'totp' && modelShow.value && !isLoading.value) {
|
|
236
|
+
await nextTick();
|
|
237
|
+
setTimeout(() => {
|
|
238
|
+
tagOtpInputs();
|
|
239
|
+
focusFirstAvailableOtpInput();
|
|
240
|
+
}, 100);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
211
244
|
watch(modelShow, async (open) => {
|
|
212
245
|
if (open) {
|
|
213
246
|
await nextTick();
|
|
@@ -215,8 +248,17 @@
|
|
|
215
248
|
if (htmlRef) {
|
|
216
249
|
htmlRef.style.overflow = 'hidden';
|
|
217
250
|
}
|
|
218
|
-
|
|
219
|
-
|
|
251
|
+
|
|
252
|
+
// Wait for conditional rendering to complete
|
|
253
|
+
if (modalMode.value === 'totp' && !isLoading.value) {
|
|
254
|
+
await nextTick();
|
|
255
|
+
setTimeout(() => {
|
|
256
|
+
tagOtpInputs();
|
|
257
|
+
window.addEventListener('paste', handlePaste);
|
|
258
|
+
}, 100);
|
|
259
|
+
} else {
|
|
260
|
+
window.addEventListener('paste', handlePaste);
|
|
261
|
+
}
|
|
220
262
|
} else {
|
|
221
263
|
window.removeEventListener('paste', handlePaste);
|
|
222
264
|
const htmlRef = document.querySelector('html');
|
|
@@ -248,7 +290,47 @@
|
|
|
248
290
|
});
|
|
249
291
|
}
|
|
250
292
|
|
|
251
|
-
|
|
293
|
+
function getOtpInputs() {
|
|
294
|
+
const root = otpRoot.value;
|
|
295
|
+
if (!root) return [];
|
|
296
|
+
return Array.from(root.querySelectorAll('input.otp-input'));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function focusFirstAvailableOtpInput() {
|
|
300
|
+
const inputs = getOtpInputs();
|
|
301
|
+
if (!inputs.length) {
|
|
302
|
+
// Retry after a short delay if inputs aren't ready yet
|
|
303
|
+
setTimeout(() => focusFirstAvailableOtpInput(), 50);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const firstEmpty = inputs.find((i) => !(i as HTMLInputElement).value);
|
|
307
|
+
((firstEmpty || inputs[0]) as HTMLInputElement).focus();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function handleGlobalFocusIn(event) {
|
|
311
|
+
const inputs = getOtpInputs();
|
|
312
|
+
if (!inputs.length) return;
|
|
313
|
+
const target = event.target;
|
|
314
|
+
if (!target) return;
|
|
315
|
+
if (!inputs.includes(target)) {
|
|
316
|
+
requestAnimationFrame(() => {
|
|
317
|
+
focusFirstAvailableOtpInput();
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function handleFocusOut() {
|
|
323
|
+
requestAnimationFrame(() => {
|
|
324
|
+
const inputs = getOtpInputs();
|
|
325
|
+
if (!inputs.length) return;
|
|
326
|
+
const active = document.activeElement;
|
|
327
|
+
if (!active || !inputs.includes(active)) {
|
|
328
|
+
focusFirstAvailableOtpInput();
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
</script>
|
|
252
334
|
|
|
253
335
|
<style scoped>
|
|
254
336
|
:deep(.otp-input-container) {
|
|
@@ -98,6 +98,9 @@
|
|
|
98
98
|
import { IconShieldOutline } from '@iconify-prerendered/vue-flowbite';
|
|
99
99
|
import ErrorMessage from '@/components/ErrorMessage.vue';
|
|
100
100
|
import { handlePasskeyAlert } from './utils.js';
|
|
101
|
+
|
|
102
|
+
// Global provided by AdminForth runtime
|
|
103
|
+
declare const adminforth: any;
|
|
101
104
|
|
|
102
105
|
const { t } = useI18n();
|
|
103
106
|
const code = ref(null);
|
|
@@ -108,6 +111,29 @@
|
|
|
108
111
|
const codeError = ref(null);
|
|
109
112
|
const isFetchingPasskey = ref(false);
|
|
110
113
|
|
|
114
|
+
// Shared WebAuthn state to prevent concurrent navigator.credentials.get calls
|
|
115
|
+
const webAuthn = {
|
|
116
|
+
controller: null as AbortController | null,
|
|
117
|
+
inFlight: false,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
function cancelPendingWebAuthn(reason?: string) {
|
|
121
|
+
try {
|
|
122
|
+
if (webAuthn.controller) {
|
|
123
|
+
// Abort any pending WebAuthn request
|
|
124
|
+
webAuthn.controller.abort();
|
|
125
|
+
}
|
|
126
|
+
} catch (e) {
|
|
127
|
+
// no-op
|
|
128
|
+
} finally {
|
|
129
|
+
webAuthn.controller = null;
|
|
130
|
+
webAuthn.inFlight = false;
|
|
131
|
+
if (reason) {
|
|
132
|
+
console.debug('[WebAuthn] Aborted pending request:', reason);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
111
137
|
onBeforeMount(() => {
|
|
112
138
|
if (localStorage.getItem('isAuthorized') === 'true') {
|
|
113
139
|
coreStore.fetchMenuAndResource();
|
|
@@ -147,6 +173,8 @@
|
|
|
147
173
|
|
|
148
174
|
onMounted(async () => {
|
|
149
175
|
if (localStorage.getItem('isAuthorized') !== 'true') {
|
|
176
|
+
// Safety: ensure no pending WebAuthn request survives SPA navigation
|
|
177
|
+
cancelPendingWebAuthn('mount-init');
|
|
150
178
|
await nextTick();
|
|
151
179
|
await isCMAAvailable();
|
|
152
180
|
tagOtpInputs();
|
|
@@ -167,8 +195,10 @@
|
|
|
167
195
|
watch(route, (newRoute) => {
|
|
168
196
|
codeError.value = null;
|
|
169
197
|
if ( newRoute.hash === '#passkey' ) {
|
|
198
|
+
cancelPendingWebAuthn('switch-to-passkey');
|
|
170
199
|
confirmationMode.value = 'passkey';
|
|
171
200
|
} else if ( newRoute.hash === '#code' ) {
|
|
201
|
+
cancelPendingWebAuthn('switch-to-code');
|
|
172
202
|
confirmationMode.value = 'code';
|
|
173
203
|
}
|
|
174
204
|
});
|
|
@@ -188,6 +218,8 @@
|
|
|
188
218
|
document.removeEventListener('focusin', handleGlobalFocusIn, true);
|
|
189
219
|
const rootEl = otpRoot.value;
|
|
190
220
|
rootEl && rootEl.removeEventListener('focusout', handleFocusOut, true);
|
|
221
|
+
// Abort any in-flight WebAuthn request when leaving the component
|
|
222
|
+
cancelPendingWebAuthn('component-unmount');
|
|
191
223
|
});
|
|
192
224
|
|
|
193
225
|
async function sendCode (value: any, factorMode: 'TOTP' | 'passkey', passkeyOptions: any) {
|
|
@@ -257,8 +289,16 @@
|
|
|
257
289
|
}
|
|
258
290
|
|
|
259
291
|
async function usePasskeyButton() {
|
|
292
|
+
// Cancel any stray pending requests from earlier flows
|
|
293
|
+
cancelPendingWebAuthn('button-pressed');
|
|
294
|
+
codeError.value = null;
|
|
260
295
|
isFetchingPasskey.value = true;
|
|
261
|
-
const
|
|
296
|
+
const signIn = await createSignInRequest();
|
|
297
|
+
if (!signIn) {
|
|
298
|
+
isFetchingPasskey.value = false;
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const { _options, challengeId } = signIn;
|
|
262
302
|
const options = PublicKeyCredential.parseRequestOptionsFromJSON(_options);
|
|
263
303
|
const credential = await authenticate(options);
|
|
264
304
|
if (!credential) {
|
|
@@ -300,25 +340,52 @@
|
|
|
300
340
|
async function authenticate(options) {
|
|
301
341
|
console.log("Authenticating with options:", options);
|
|
302
342
|
try {
|
|
343
|
+
// Guard: prevent concurrent navigator.credentials.get calls
|
|
344
|
+
if (webAuthn.inFlight) {
|
|
345
|
+
console.warn('[WebAuthn] A request is already in flight. Aborting previous and retrying.');
|
|
346
|
+
cancelPendingWebAuthn('pre-auth-guard');
|
|
347
|
+
}
|
|
348
|
+
|
|
303
349
|
const abortController = new AbortController();
|
|
350
|
+
webAuthn.controller = abortController;
|
|
351
|
+
webAuthn.inFlight = true;
|
|
352
|
+
|
|
304
353
|
const credential = await navigator.credentials.get({
|
|
305
354
|
publicKey: options,
|
|
306
355
|
signal: abortController.signal,
|
|
356
|
+
// mediation can be set if using conditional UI, omitted here intentionally
|
|
307
357
|
});
|
|
308
358
|
console.log("Credential obtained:", credential);
|
|
309
359
|
return credential;
|
|
310
360
|
} catch (error) {
|
|
311
361
|
console.error('Error during authentication:', error);
|
|
362
|
+
// Handle specific concurrent/pending request error cases gracefully
|
|
363
|
+
const name = (error && (error.name || error.constructor?.name)) || '';
|
|
364
|
+
const message = (error && error.message) || '';
|
|
365
|
+
if (name === 'AbortError') {
|
|
366
|
+
// Aborted intentionally; no user-facing error needed
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
if (name === 'InvalidStateError' || name === 'OperationError' || /pending/i.test(message)) {
|
|
370
|
+
adminforth.alert({ message: t('Another security prompt is already open. Please try again.'), variant: 'warning' });
|
|
371
|
+
codeError.value = t('A previous passkey attempt was still pending. Please try again.');
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
312
374
|
adminforth.alert({message: `Error during authentication: ${error}`, variant: 'warning'});
|
|
313
375
|
codeError.value = 'Error during authentication.';
|
|
314
376
|
}
|
|
377
|
+
finally {
|
|
378
|
+
// Clear in-flight state regardless of outcome
|
|
379
|
+
webAuthn.inFlight = false;
|
|
380
|
+
webAuthn.controller = null;
|
|
381
|
+
}
|
|
315
382
|
}
|
|
316
383
|
|
|
317
384
|
|
|
318
|
-
function getOtpInputs() {
|
|
385
|
+
function getOtpInputs(): HTMLInputElement[] {
|
|
319
386
|
const root = otpRoot.value;
|
|
320
387
|
if (!root) return [];
|
|
321
|
-
return Array.from(root.querySelectorAll('input.otp-input'));
|
|
388
|
+
return Array.from(root.querySelectorAll('input.otp-input')) as HTMLInputElement[];
|
|
322
389
|
}
|
|
323
390
|
|
|
324
391
|
function focusFirstAvailableOtpInput() {
|
|
@@ -331,7 +398,7 @@
|
|
|
331
398
|
function handleGlobalFocusIn(event) {
|
|
332
399
|
const inputs = getOtpInputs();
|
|
333
400
|
if (!inputs.length) return;
|
|
334
|
-
const target = event.target;
|
|
401
|
+
const target = event.target as HTMLInputElement | null;
|
|
335
402
|
if (!target) return;
|
|
336
403
|
if (!inputs.includes(target)) {
|
|
337
404
|
requestAnimationFrame(() => {
|
|
@@ -344,8 +411,9 @@
|
|
|
344
411
|
requestAnimationFrame(() => {
|
|
345
412
|
const inputs = getOtpInputs();
|
|
346
413
|
if (!inputs.length) return;
|
|
347
|
-
const active = document.activeElement;
|
|
348
|
-
|
|
414
|
+
const active = document.activeElement as Element | null;
|
|
415
|
+
const activeInput = active instanceof HTMLInputElement ? active : null;
|
|
416
|
+
if (!activeInput || !inputs.includes(activeInput)) {
|
|
349
417
|
focusFirstAvailableOtpInput();
|
|
350
418
|
}
|
|
351
419
|
});
|
package/dist/index.js
CHANGED
|
@@ -343,21 +343,15 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
343
343
|
let verified = null;
|
|
344
344
|
if (body.usePasskey && this.options.passkeys) {
|
|
345
345
|
// passkeys are enabled and user wants to use them
|
|
346
|
-
console.log("Using passkey for 2FA verification");
|
|
347
346
|
const passkeysCookies = this.adminforth.auth.getCustomCookie({ cookies: cookies, name: `passkeyLoginTemporaryJWT` });
|
|
348
|
-
console.log("Passkey cookies:", passkeysCookies);
|
|
349
347
|
if (!passkeysCookies) {
|
|
350
348
|
return { error: 'Passkey token is required' };
|
|
351
349
|
}
|
|
352
|
-
console.log("Verifying passkey cookies");
|
|
353
350
|
const decodedPasskeysCookies = yield this.adminforth.auth.verify(passkeysCookies, 'tempLoginPasskeyChallenge', false);
|
|
354
351
|
if (!decodedPasskeysCookies) {
|
|
355
352
|
return { error: 'Invalid passkey' };
|
|
356
353
|
}
|
|
357
|
-
console.log("Passkey cookies decoded:", decodedPasskeysCookies);
|
|
358
|
-
console.log("Verifying passkey response");
|
|
359
354
|
const res = yield this.verifyPasskeyResponse(body.passkeyOptions, decoded.pk, decodedPasskeysCookies);
|
|
360
|
-
console.log("Passkey response verification result:", res);
|
|
361
355
|
if (res.ok && res.passkeyConfirmed) {
|
|
362
356
|
verified = true;
|
|
363
357
|
}
|
package/index.ts
CHANGED
|
@@ -383,21 +383,15 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
383
383
|
let verified = null;
|
|
384
384
|
if (body.usePasskey && this.options.passkeys) {
|
|
385
385
|
// passkeys are enabled and user wants to use them
|
|
386
|
-
console.log("Using passkey for 2FA verification");
|
|
387
386
|
const passkeysCookies = this.adminforth.auth.getCustomCookie({cookies: cookies, name: `passkeyLoginTemporaryJWT`});
|
|
388
|
-
console.log("Passkey cookies:", passkeysCookies);
|
|
389
387
|
if (!passkeysCookies) {
|
|
390
388
|
return { error: 'Passkey token is required' };
|
|
391
389
|
}
|
|
392
|
-
console.log("Verifying passkey cookies");
|
|
393
390
|
const decodedPasskeysCookies = await this.adminforth.auth.verify(passkeysCookies, 'tempLoginPasskeyChallenge', false);
|
|
394
391
|
if (!decodedPasskeysCookies) {
|
|
395
392
|
return { error: 'Invalid passkey' };
|
|
396
393
|
}
|
|
397
|
-
console.log("Passkey cookies decoded:", decodedPasskeysCookies);
|
|
398
|
-
console.log("Verifying passkey response");
|
|
399
394
|
const res = await this.verifyPasskeyResponse(body.passkeyOptions, decoded.pk, decodedPasskeysCookies);
|
|
400
|
-
console.log("Passkey response verification result:", res);
|
|
401
395
|
if (res.ok && res.passkeyConfirmed) {
|
|
402
396
|
verified = true;
|
|
403
397
|
}
|