@adminforth/two-factors-auth 2.1.1 → 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 CHANGED
@@ -13,5 +13,5 @@ custom/package.json
13
13
  custom/tsconfig.json
14
14
  custom/utils.js
15
15
 
16
- sent 71,446 bytes received 172 bytes 143,236.00 bytes/sec
17
- total size is 70,806 speedup is 0.99
16
+ sent 74,931 bytes received 172 bytes 150,206.00 bytes/sec
17
+ total size is 74,291 speedup is 0.99
@@ -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,11 +173,16 @@
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();
181
+ console.log("Checking if device supports passkeys:", isPasskeysSupported.value);
153
182
  if (isPasskeysSupported.value === true) {
183
+ console.log("Device supports passkeys, checking if user has passkeys...");
154
184
  await checkIfUserHasPasskeys();
185
+ console.log("Does user have passkeys:", doesUserHavePasskeys.value);
155
186
  }
156
187
  document.addEventListener('focusin', handleGlobalFocusIn, true);
157
188
  focusFirstAvailableOtpInput();
@@ -164,8 +195,10 @@
164
195
  watch(route, (newRoute) => {
165
196
  codeError.value = null;
166
197
  if ( newRoute.hash === '#passkey' ) {
198
+ cancelPendingWebAuthn('switch-to-passkey');
167
199
  confirmationMode.value = 'passkey';
168
200
  } else if ( newRoute.hash === '#code' ) {
201
+ cancelPendingWebAuthn('switch-to-code');
169
202
  confirmationMode.value = 'code';
170
203
  }
171
204
  });
@@ -185,11 +218,15 @@
185
218
  document.removeEventListener('focusin', handleGlobalFocusIn, true);
186
219
  const rootEl = otpRoot.value;
187
220
  rootEl && rootEl.removeEventListener('focusout', handleFocusOut, true);
221
+ // Abort any in-flight WebAuthn request when leaving the component
222
+ cancelPendingWebAuthn('component-unmount');
188
223
  });
189
224
 
190
225
  async function sendCode (value: any, factorMode: 'TOTP' | 'passkey', passkeyOptions: any) {
191
226
  inProgress.value = true;
192
227
  const usePasskey = factorMode === 'passkey';
228
+ console.log("Sending code with factorMode:", factorMode);
229
+ console.log("Passkey options:", passkeyOptions);
193
230
  const resp = await callAdminForthApi({
194
231
  method: 'POST',
195
232
  path: '/plugin/twofa/confirmLogin',
@@ -200,12 +237,15 @@
200
237
  secret: null,
201
238
  }
202
239
  })
240
+ console.log("Response from confirmLogin:", resp);
203
241
  if ( resp.allowedLogin ) {
204
242
  if ( route.meta.isPasskeysEnabled && !doesUserHavePasskeys.value ) {
205
243
  handlePasskeyAlert(route.meta.suggestionPeriod, router);
206
244
  }
245
+ console.log("Login confirmed, finishing login...");
207
246
  await user.finishLogin();
208
247
  } else {
248
+ console.log("Login not allowed, showing error:", resp.error);
209
249
  if (usePasskey) {
210
250
  showErrorTost(t(resp.error));
211
251
  codeError.value = resp.error || t('Passkey authentication failed');
@@ -249,8 +289,16 @@
249
289
  }
250
290
 
251
291
  async function usePasskeyButton() {
292
+ // Cancel any stray pending requests from earlier flows
293
+ cancelPendingWebAuthn('button-pressed');
294
+ codeError.value = null;
252
295
  isFetchingPasskey.value = true;
253
- const { _options, challengeId } = await createSignInRequest();
296
+ const signIn = await createSignInRequest();
297
+ if (!signIn) {
298
+ isFetchingPasskey.value = false;
299
+ return;
300
+ }
301
+ const { _options, challengeId } = signIn;
254
302
  const options = PublicKeyCredential.parseRequestOptionsFromJSON(_options);
255
303
  const credential = await authenticate(options);
256
304
  if (!credential) {
@@ -269,15 +317,18 @@
269
317
 
270
318
  async function createSignInRequest() {
271
319
  let response;
320
+ console.log("Creating sign-in request for passkey...");
272
321
  try {
273
322
  response = await callAdminForthApi({
274
323
  path: `/plugin/passkeys/signInRequest`,
275
324
  method: 'POST',
276
325
  });
277
326
  } catch (error) {
327
+ console.log("Error creating sign-in request:", error);
278
328
  console.error('Error creating sign-in request:', error);
279
329
  return;
280
330
  }
331
+ console.log("Sign-in request response:", response);
281
332
  if (response.ok === true) {
282
333
  return { _options: response.data, challengeId: response.challengeId };
283
334
  } else {
@@ -287,25 +338,54 @@
287
338
  }
288
339
 
289
340
  async function authenticate(options) {
341
+ console.log("Authenticating with options:", options);
290
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
+
291
349
  const abortController = new AbortController();
350
+ webAuthn.controller = abortController;
351
+ webAuthn.inFlight = true;
352
+
292
353
  const credential = await navigator.credentials.get({
293
354
  publicKey: options,
294
355
  signal: abortController.signal,
356
+ // mediation can be set if using conditional UI, omitted here intentionally
295
357
  });
358
+ console.log("Credential obtained:", credential);
296
359
  return credential;
297
360
  } catch (error) {
298
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
+ }
299
374
  adminforth.alert({message: `Error during authentication: ${error}`, variant: 'warning'});
300
375
  codeError.value = 'Error during authentication.';
301
376
  }
377
+ finally {
378
+ // Clear in-flight state regardless of outcome
379
+ webAuthn.inFlight = false;
380
+ webAuthn.controller = null;
381
+ }
302
382
  }
303
383
 
304
384
 
305
- function getOtpInputs() {
385
+ function getOtpInputs(): HTMLInputElement[] {
306
386
  const root = otpRoot.value;
307
387
  if (!root) return [];
308
- return Array.from(root.querySelectorAll('input.otp-input'));
388
+ return Array.from(root.querySelectorAll('input.otp-input')) as HTMLInputElement[];
309
389
  }
310
390
 
311
391
  function focusFirstAvailableOtpInput() {
@@ -318,7 +398,7 @@
318
398
  function handleGlobalFocusIn(event) {
319
399
  const inputs = getOtpInputs();
320
400
  if (!inputs.length) return;
321
- const target = event.target;
401
+ const target = event.target as HTMLInputElement | null;
322
402
  if (!target) return;
323
403
  if (!inputs.includes(target)) {
324
404
  requestAnimationFrame(() => {
@@ -331,8 +411,9 @@
331
411
  requestAnimationFrame(() => {
332
412
  const inputs = getOtpInputs();
333
413
  if (!inputs.length) return;
334
- const active = document.activeElement;
335
- if (!active || !inputs.includes(active)) {
414
+ const active = document.activeElement as Element | null;
415
+ const activeInput = active instanceof HTMLInputElement ? active : null;
416
+ if (!activeInput || !inputs.includes(activeInput)) {
336
417
  focusFirstAvailableOtpInput();
337
418
  }
338
419
  });
@@ -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,11 +173,16 @@
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();
181
+ console.log("Checking if device supports passkeys:", isPasskeysSupported.value);
153
182
  if (isPasskeysSupported.value === true) {
183
+ console.log("Device supports passkeys, checking if user has passkeys...");
154
184
  await checkIfUserHasPasskeys();
185
+ console.log("Does user have passkeys:", doesUserHavePasskeys.value);
155
186
  }
156
187
  document.addEventListener('focusin', handleGlobalFocusIn, true);
157
188
  focusFirstAvailableOtpInput();
@@ -164,8 +195,10 @@
164
195
  watch(route, (newRoute) => {
165
196
  codeError.value = null;
166
197
  if ( newRoute.hash === '#passkey' ) {
198
+ cancelPendingWebAuthn('switch-to-passkey');
167
199
  confirmationMode.value = 'passkey';
168
200
  } else if ( newRoute.hash === '#code' ) {
201
+ cancelPendingWebAuthn('switch-to-code');
169
202
  confirmationMode.value = 'code';
170
203
  }
171
204
  });
@@ -185,11 +218,15 @@
185
218
  document.removeEventListener('focusin', handleGlobalFocusIn, true);
186
219
  const rootEl = otpRoot.value;
187
220
  rootEl && rootEl.removeEventListener('focusout', handleFocusOut, true);
221
+ // Abort any in-flight WebAuthn request when leaving the component
222
+ cancelPendingWebAuthn('component-unmount');
188
223
  });
189
224
 
190
225
  async function sendCode (value: any, factorMode: 'TOTP' | 'passkey', passkeyOptions: any) {
191
226
  inProgress.value = true;
192
227
  const usePasskey = factorMode === 'passkey';
228
+ console.log("Sending code with factorMode:", factorMode);
229
+ console.log("Passkey options:", passkeyOptions);
193
230
  const resp = await callAdminForthApi({
194
231
  method: 'POST',
195
232
  path: '/plugin/twofa/confirmLogin',
@@ -200,12 +237,15 @@
200
237
  secret: null,
201
238
  }
202
239
  })
240
+ console.log("Response from confirmLogin:", resp);
203
241
  if ( resp.allowedLogin ) {
204
242
  if ( route.meta.isPasskeysEnabled && !doesUserHavePasskeys.value ) {
205
243
  handlePasskeyAlert(route.meta.suggestionPeriod, router);
206
244
  }
245
+ console.log("Login confirmed, finishing login...");
207
246
  await user.finishLogin();
208
247
  } else {
248
+ console.log("Login not allowed, showing error:", resp.error);
209
249
  if (usePasskey) {
210
250
  showErrorTost(t(resp.error));
211
251
  codeError.value = resp.error || t('Passkey authentication failed');
@@ -249,8 +289,16 @@
249
289
  }
250
290
 
251
291
  async function usePasskeyButton() {
292
+ // Cancel any stray pending requests from earlier flows
293
+ cancelPendingWebAuthn('button-pressed');
294
+ codeError.value = null;
252
295
  isFetchingPasskey.value = true;
253
- const { _options, challengeId } = await createSignInRequest();
296
+ const signIn = await createSignInRequest();
297
+ if (!signIn) {
298
+ isFetchingPasskey.value = false;
299
+ return;
300
+ }
301
+ const { _options, challengeId } = signIn;
254
302
  const options = PublicKeyCredential.parseRequestOptionsFromJSON(_options);
255
303
  const credential = await authenticate(options);
256
304
  if (!credential) {
@@ -269,15 +317,18 @@
269
317
 
270
318
  async function createSignInRequest() {
271
319
  let response;
320
+ console.log("Creating sign-in request for passkey...");
272
321
  try {
273
322
  response = await callAdminForthApi({
274
323
  path: `/plugin/passkeys/signInRequest`,
275
324
  method: 'POST',
276
325
  });
277
326
  } catch (error) {
327
+ console.log("Error creating sign-in request:", error);
278
328
  console.error('Error creating sign-in request:', error);
279
329
  return;
280
330
  }
331
+ console.log("Sign-in request response:", response);
281
332
  if (response.ok === true) {
282
333
  return { _options: response.data, challengeId: response.challengeId };
283
334
  } else {
@@ -287,25 +338,54 @@
287
338
  }
288
339
 
289
340
  async function authenticate(options) {
341
+ console.log("Authenticating with options:", options);
290
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
+
291
349
  const abortController = new AbortController();
350
+ webAuthn.controller = abortController;
351
+ webAuthn.inFlight = true;
352
+
292
353
  const credential = await navigator.credentials.get({
293
354
  publicKey: options,
294
355
  signal: abortController.signal,
356
+ // mediation can be set if using conditional UI, omitted here intentionally
295
357
  });
358
+ console.log("Credential obtained:", credential);
296
359
  return credential;
297
360
  } catch (error) {
298
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
+ }
299
374
  adminforth.alert({message: `Error during authentication: ${error}`, variant: 'warning'});
300
375
  codeError.value = 'Error during authentication.';
301
376
  }
377
+ finally {
378
+ // Clear in-flight state regardless of outcome
379
+ webAuthn.inFlight = false;
380
+ webAuthn.controller = null;
381
+ }
302
382
  }
303
383
 
304
384
 
305
- function getOtpInputs() {
385
+ function getOtpInputs(): HTMLInputElement[] {
306
386
  const root = otpRoot.value;
307
387
  if (!root) return [];
308
- return Array.from(root.querySelectorAll('input.otp-input'));
388
+ return Array.from(root.querySelectorAll('input.otp-input')) as HTMLInputElement[];
309
389
  }
310
390
 
311
391
  function focusFirstAvailableOtpInput() {
@@ -318,7 +398,7 @@
318
398
  function handleGlobalFocusIn(event) {
319
399
  const inputs = getOtpInputs();
320
400
  if (!inputs.length) return;
321
- const target = event.target;
401
+ const target = event.target as HTMLInputElement | null;
322
402
  if (!target) return;
323
403
  if (!inputs.includes(target)) {
324
404
  requestAnimationFrame(() => {
@@ -331,8 +411,9 @@
331
411
  requestAnimationFrame(() => {
332
412
  const inputs = getOtpInputs();
333
413
  if (!inputs.length) return;
334
- const active = document.activeElement;
335
- if (!active || !inputs.includes(active)) {
414
+ const active = document.activeElement as Element | null;
415
+ const activeInput = active instanceof HTMLInputElement ? active : null;
416
+ if (!activeInput || !inputs.includes(activeInput)) {
336
417
  focusFirstAvailableOtpInput();
337
418
  }
338
419
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/two-factors-auth",
3
- "version": "2.1.1",
3
+ "version": "2.1.2",
4
4
  "description": "AdminForth plugin for two factors authentication",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",