@adminforth/two-factors-auth 2.1.1 → 2.1.3
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
|
|
17
|
-
total size is
|
|
16
|
+
sent 76,042 bytes received 172 bytes 152,428.00 bytes/sec
|
|
17
|
+
total size is 75,402 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,20 +195,34 @@
|
|
|
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
|
});
|
|
172
205
|
|
|
173
206
|
async function isCMAAvailable() {
|
|
207
|
+
let isCMA_supported = false;
|
|
208
|
+
let isParsingSupported = false;
|
|
174
209
|
if (window.PublicKeyCredential &&
|
|
175
210
|
PublicKeyCredential.isConditionalMediationAvailable) {
|
|
176
211
|
const isCMA = await PublicKeyCredential.isConditionalMediationAvailable();
|
|
177
|
-
if (isCMA) {
|
|
178
|
-
|
|
179
|
-
}
|
|
212
|
+
if (isCMA) {
|
|
213
|
+
isCMA_supported = true;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
const temp = PublicKeyCredential.parseRequestOptionsFromJSON({challenge: 'test123', timeout: 60000, userVerification: 'required'});
|
|
218
|
+
if (temp && typeof temp === 'object') {
|
|
219
|
+
isParsingSupported = true;
|
|
220
|
+
}
|
|
221
|
+
} catch (e) {
|
|
222
|
+
adminforth.alert({message: 'Please ensure your browser supports passkeys.', variant: 'warning'});
|
|
223
|
+
isParsingSupported = false;
|
|
180
224
|
}
|
|
225
|
+
isPasskeysSupported.value = (isCMA_supported && isParsingSupported);
|
|
181
226
|
}
|
|
182
227
|
|
|
183
228
|
onBeforeUnmount(() => {
|
|
@@ -185,11 +230,15 @@
|
|
|
185
230
|
document.removeEventListener('focusin', handleGlobalFocusIn, true);
|
|
186
231
|
const rootEl = otpRoot.value;
|
|
187
232
|
rootEl && rootEl.removeEventListener('focusout', handleFocusOut, true);
|
|
233
|
+
// Abort any in-flight WebAuthn request when leaving the component
|
|
234
|
+
cancelPendingWebAuthn('component-unmount');
|
|
188
235
|
});
|
|
189
236
|
|
|
190
237
|
async function sendCode (value: any, factorMode: 'TOTP' | 'passkey', passkeyOptions: any) {
|
|
191
238
|
inProgress.value = true;
|
|
192
239
|
const usePasskey = factorMode === 'passkey';
|
|
240
|
+
console.log("Sending code with factorMode:", factorMode);
|
|
241
|
+
console.log("Passkey options:", passkeyOptions);
|
|
193
242
|
const resp = await callAdminForthApi({
|
|
194
243
|
method: 'POST',
|
|
195
244
|
path: '/plugin/twofa/confirmLogin',
|
|
@@ -200,12 +249,15 @@
|
|
|
200
249
|
secret: null,
|
|
201
250
|
}
|
|
202
251
|
})
|
|
252
|
+
console.log("Response from confirmLogin:", resp);
|
|
203
253
|
if ( resp.allowedLogin ) {
|
|
204
254
|
if ( route.meta.isPasskeysEnabled && !doesUserHavePasskeys.value ) {
|
|
205
255
|
handlePasskeyAlert(route.meta.suggestionPeriod, router);
|
|
206
256
|
}
|
|
257
|
+
console.log("Login confirmed, finishing login...");
|
|
207
258
|
await user.finishLogin();
|
|
208
259
|
} else {
|
|
260
|
+
console.log("Login not allowed, showing error:", resp.error);
|
|
209
261
|
if (usePasskey) {
|
|
210
262
|
showErrorTost(t(resp.error));
|
|
211
263
|
codeError.value = resp.error || t('Passkey authentication failed');
|
|
@@ -249,8 +301,16 @@
|
|
|
249
301
|
}
|
|
250
302
|
|
|
251
303
|
async function usePasskeyButton() {
|
|
304
|
+
// Cancel any stray pending requests from earlier flows
|
|
305
|
+
cancelPendingWebAuthn('button-pressed');
|
|
306
|
+
codeError.value = null;
|
|
252
307
|
isFetchingPasskey.value = true;
|
|
253
|
-
const
|
|
308
|
+
const signIn = await createSignInRequest();
|
|
309
|
+
if (!signIn) {
|
|
310
|
+
isFetchingPasskey.value = false;
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const { _options, challengeId } = signIn;
|
|
254
314
|
const options = PublicKeyCredential.parseRequestOptionsFromJSON(_options);
|
|
255
315
|
const credential = await authenticate(options);
|
|
256
316
|
if (!credential) {
|
|
@@ -269,15 +329,18 @@
|
|
|
269
329
|
|
|
270
330
|
async function createSignInRequest() {
|
|
271
331
|
let response;
|
|
332
|
+
console.log("Creating sign-in request for passkey...");
|
|
272
333
|
try {
|
|
273
334
|
response = await callAdminForthApi({
|
|
274
335
|
path: `/plugin/passkeys/signInRequest`,
|
|
275
336
|
method: 'POST',
|
|
276
337
|
});
|
|
277
338
|
} catch (error) {
|
|
339
|
+
console.log("Error creating sign-in request:", error);
|
|
278
340
|
console.error('Error creating sign-in request:', error);
|
|
279
341
|
return;
|
|
280
342
|
}
|
|
343
|
+
console.log("Sign-in request response:", response);
|
|
281
344
|
if (response.ok === true) {
|
|
282
345
|
return { _options: response.data, challengeId: response.challengeId };
|
|
283
346
|
} else {
|
|
@@ -287,25 +350,54 @@
|
|
|
287
350
|
}
|
|
288
351
|
|
|
289
352
|
async function authenticate(options) {
|
|
353
|
+
console.log("Authenticating with options:", options);
|
|
290
354
|
try {
|
|
355
|
+
// Guard: prevent concurrent navigator.credentials.get calls
|
|
356
|
+
if (webAuthn.inFlight) {
|
|
357
|
+
console.warn('[WebAuthn] A request is already in flight. Aborting previous and retrying.');
|
|
358
|
+
cancelPendingWebAuthn('pre-auth-guard');
|
|
359
|
+
}
|
|
360
|
+
|
|
291
361
|
const abortController = new AbortController();
|
|
362
|
+
webAuthn.controller = abortController;
|
|
363
|
+
webAuthn.inFlight = true;
|
|
364
|
+
|
|
292
365
|
const credential = await navigator.credentials.get({
|
|
293
366
|
publicKey: options,
|
|
294
367
|
signal: abortController.signal,
|
|
368
|
+
// mediation can be set if using conditional UI, omitted here intentionally
|
|
295
369
|
});
|
|
370
|
+
console.log("Credential obtained:", credential);
|
|
296
371
|
return credential;
|
|
297
372
|
} catch (error) {
|
|
298
373
|
console.error('Error during authentication:', error);
|
|
374
|
+
// Handle specific concurrent/pending request error cases gracefully
|
|
375
|
+
const name = (error && (error.name || error.constructor?.name)) || '';
|
|
376
|
+
const message = (error && error.message) || '';
|
|
377
|
+
if (name === 'AbortError') {
|
|
378
|
+
// Aborted intentionally; no user-facing error needed
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
if (name === 'InvalidStateError' || name === 'OperationError' || /pending/i.test(message)) {
|
|
382
|
+
adminforth.alert({ message: t('Another security prompt is already open. Please try again.'), variant: 'warning' });
|
|
383
|
+
codeError.value = t('A previous passkey attempt was still pending. Please try again.');
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
299
386
|
adminforth.alert({message: `Error during authentication: ${error}`, variant: 'warning'});
|
|
300
387
|
codeError.value = 'Error during authentication.';
|
|
301
388
|
}
|
|
389
|
+
finally {
|
|
390
|
+
// Clear in-flight state regardless of outcome
|
|
391
|
+
webAuthn.inFlight = false;
|
|
392
|
+
webAuthn.controller = null;
|
|
393
|
+
}
|
|
302
394
|
}
|
|
303
395
|
|
|
304
396
|
|
|
305
|
-
function getOtpInputs() {
|
|
397
|
+
function getOtpInputs(): HTMLInputElement[] {
|
|
306
398
|
const root = otpRoot.value;
|
|
307
399
|
if (!root) return [];
|
|
308
|
-
return Array.from(root.querySelectorAll('input.otp-input'));
|
|
400
|
+
return Array.from(root.querySelectorAll('input.otp-input')) as HTMLInputElement[];
|
|
309
401
|
}
|
|
310
402
|
|
|
311
403
|
function focusFirstAvailableOtpInput() {
|
|
@@ -318,7 +410,7 @@
|
|
|
318
410
|
function handleGlobalFocusIn(event) {
|
|
319
411
|
const inputs = getOtpInputs();
|
|
320
412
|
if (!inputs.length) return;
|
|
321
|
-
const target = event.target;
|
|
413
|
+
const target = event.target as HTMLInputElement | null;
|
|
322
414
|
if (!target) return;
|
|
323
415
|
if (!inputs.includes(target)) {
|
|
324
416
|
requestAnimationFrame(() => {
|
|
@@ -331,8 +423,9 @@
|
|
|
331
423
|
requestAnimationFrame(() => {
|
|
332
424
|
const inputs = getOtpInputs();
|
|
333
425
|
if (!inputs.length) return;
|
|
334
|
-
const active = document.activeElement;
|
|
335
|
-
|
|
426
|
+
const active = document.activeElement as Element | null;
|
|
427
|
+
const activeInput = active instanceof HTMLInputElement ? active : null;
|
|
428
|
+
if (!activeInput || !inputs.includes(activeInput)) {
|
|
336
429
|
focusFirstAvailableOtpInput();
|
|
337
430
|
}
|
|
338
431
|
});
|
|
@@ -327,6 +327,8 @@
|
|
|
327
327
|
}
|
|
328
328
|
|
|
329
329
|
function checkForCompatibility() {
|
|
330
|
+
let isCMA_supported = false;
|
|
331
|
+
let isParsingSupported = false;
|
|
330
332
|
if (window.PublicKeyCredential &&
|
|
331
333
|
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable &&
|
|
332
334
|
PublicKeyCredential.isConditionalMediationAvailable) {
|
|
@@ -335,13 +337,23 @@
|
|
|
335
337
|
PublicKeyCredential.isConditionalMediationAvailable(),
|
|
336
338
|
]).then(results => {
|
|
337
339
|
if (results.every(r => r === true)) {
|
|
338
|
-
|
|
340
|
+
isCMA_supported = true;
|
|
339
341
|
} else {
|
|
340
|
-
|
|
342
|
+
isCMA_supported = false;
|
|
341
343
|
addPasskeyMode.value = 'cross-platform';
|
|
342
344
|
}
|
|
343
345
|
});
|
|
344
|
-
}
|
|
346
|
+
}
|
|
347
|
+
try {
|
|
348
|
+
const temp = PublicKeyCredential.parseRequestOptionsFromJSON({challenge: 'test123', timeout: 60000, userVerification: 'required'});
|
|
349
|
+
if (temp && typeof temp === 'object') {
|
|
350
|
+
isParsingSupported = true;
|
|
351
|
+
}
|
|
352
|
+
} catch (e) {
|
|
353
|
+
adminforth.alert({message: 'Please ensure your browser supports passkeys.', variant: 'warning'});
|
|
354
|
+
isParsingSupported = false;
|
|
355
|
+
}
|
|
356
|
+
isPasskeySupported.value = (isCMA_supported && isParsingSupported);
|
|
345
357
|
}
|
|
346
358
|
|
|
347
359
|
async function fetchInformationFromTheBackend(confirmationResult) {
|
|
@@ -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,20 +195,34 @@
|
|
|
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
|
});
|
|
172
205
|
|
|
173
206
|
async function isCMAAvailable() {
|
|
207
|
+
let isCMA_supported = false;
|
|
208
|
+
let isParsingSupported = false;
|
|
174
209
|
if (window.PublicKeyCredential &&
|
|
175
210
|
PublicKeyCredential.isConditionalMediationAvailable) {
|
|
176
211
|
const isCMA = await PublicKeyCredential.isConditionalMediationAvailable();
|
|
177
|
-
if (isCMA) {
|
|
178
|
-
|
|
179
|
-
}
|
|
212
|
+
if (isCMA) {
|
|
213
|
+
isCMA_supported = true;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
const temp = PublicKeyCredential.parseRequestOptionsFromJSON({challenge: 'test123', timeout: 60000, userVerification: 'required'});
|
|
218
|
+
if (temp && typeof temp === 'object') {
|
|
219
|
+
isParsingSupported = true;
|
|
220
|
+
}
|
|
221
|
+
} catch (e) {
|
|
222
|
+
adminforth.alert({message: 'Please ensure your browser supports passkeys.', variant: 'warning'});
|
|
223
|
+
isParsingSupported = false;
|
|
180
224
|
}
|
|
225
|
+
isPasskeysSupported.value = (isCMA_supported && isParsingSupported);
|
|
181
226
|
}
|
|
182
227
|
|
|
183
228
|
onBeforeUnmount(() => {
|
|
@@ -185,11 +230,15 @@
|
|
|
185
230
|
document.removeEventListener('focusin', handleGlobalFocusIn, true);
|
|
186
231
|
const rootEl = otpRoot.value;
|
|
187
232
|
rootEl && rootEl.removeEventListener('focusout', handleFocusOut, true);
|
|
233
|
+
// Abort any in-flight WebAuthn request when leaving the component
|
|
234
|
+
cancelPendingWebAuthn('component-unmount');
|
|
188
235
|
});
|
|
189
236
|
|
|
190
237
|
async function sendCode (value: any, factorMode: 'TOTP' | 'passkey', passkeyOptions: any) {
|
|
191
238
|
inProgress.value = true;
|
|
192
239
|
const usePasskey = factorMode === 'passkey';
|
|
240
|
+
console.log("Sending code with factorMode:", factorMode);
|
|
241
|
+
console.log("Passkey options:", passkeyOptions);
|
|
193
242
|
const resp = await callAdminForthApi({
|
|
194
243
|
method: 'POST',
|
|
195
244
|
path: '/plugin/twofa/confirmLogin',
|
|
@@ -200,12 +249,15 @@
|
|
|
200
249
|
secret: null,
|
|
201
250
|
}
|
|
202
251
|
})
|
|
252
|
+
console.log("Response from confirmLogin:", resp);
|
|
203
253
|
if ( resp.allowedLogin ) {
|
|
204
254
|
if ( route.meta.isPasskeysEnabled && !doesUserHavePasskeys.value ) {
|
|
205
255
|
handlePasskeyAlert(route.meta.suggestionPeriod, router);
|
|
206
256
|
}
|
|
257
|
+
console.log("Login confirmed, finishing login...");
|
|
207
258
|
await user.finishLogin();
|
|
208
259
|
} else {
|
|
260
|
+
console.log("Login not allowed, showing error:", resp.error);
|
|
209
261
|
if (usePasskey) {
|
|
210
262
|
showErrorTost(t(resp.error));
|
|
211
263
|
codeError.value = resp.error || t('Passkey authentication failed');
|
|
@@ -249,8 +301,16 @@
|
|
|
249
301
|
}
|
|
250
302
|
|
|
251
303
|
async function usePasskeyButton() {
|
|
304
|
+
// Cancel any stray pending requests from earlier flows
|
|
305
|
+
cancelPendingWebAuthn('button-pressed');
|
|
306
|
+
codeError.value = null;
|
|
252
307
|
isFetchingPasskey.value = true;
|
|
253
|
-
const
|
|
308
|
+
const signIn = await createSignInRequest();
|
|
309
|
+
if (!signIn) {
|
|
310
|
+
isFetchingPasskey.value = false;
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const { _options, challengeId } = signIn;
|
|
254
314
|
const options = PublicKeyCredential.parseRequestOptionsFromJSON(_options);
|
|
255
315
|
const credential = await authenticate(options);
|
|
256
316
|
if (!credential) {
|
|
@@ -269,15 +329,18 @@
|
|
|
269
329
|
|
|
270
330
|
async function createSignInRequest() {
|
|
271
331
|
let response;
|
|
332
|
+
console.log("Creating sign-in request for passkey...");
|
|
272
333
|
try {
|
|
273
334
|
response = await callAdminForthApi({
|
|
274
335
|
path: `/plugin/passkeys/signInRequest`,
|
|
275
336
|
method: 'POST',
|
|
276
337
|
});
|
|
277
338
|
} catch (error) {
|
|
339
|
+
console.log("Error creating sign-in request:", error);
|
|
278
340
|
console.error('Error creating sign-in request:', error);
|
|
279
341
|
return;
|
|
280
342
|
}
|
|
343
|
+
console.log("Sign-in request response:", response);
|
|
281
344
|
if (response.ok === true) {
|
|
282
345
|
return { _options: response.data, challengeId: response.challengeId };
|
|
283
346
|
} else {
|
|
@@ -287,25 +350,54 @@
|
|
|
287
350
|
}
|
|
288
351
|
|
|
289
352
|
async function authenticate(options) {
|
|
353
|
+
console.log("Authenticating with options:", options);
|
|
290
354
|
try {
|
|
355
|
+
// Guard: prevent concurrent navigator.credentials.get calls
|
|
356
|
+
if (webAuthn.inFlight) {
|
|
357
|
+
console.warn('[WebAuthn] A request is already in flight. Aborting previous and retrying.');
|
|
358
|
+
cancelPendingWebAuthn('pre-auth-guard');
|
|
359
|
+
}
|
|
360
|
+
|
|
291
361
|
const abortController = new AbortController();
|
|
362
|
+
webAuthn.controller = abortController;
|
|
363
|
+
webAuthn.inFlight = true;
|
|
364
|
+
|
|
292
365
|
const credential = await navigator.credentials.get({
|
|
293
366
|
publicKey: options,
|
|
294
367
|
signal: abortController.signal,
|
|
368
|
+
// mediation can be set if using conditional UI, omitted here intentionally
|
|
295
369
|
});
|
|
370
|
+
console.log("Credential obtained:", credential);
|
|
296
371
|
return credential;
|
|
297
372
|
} catch (error) {
|
|
298
373
|
console.error('Error during authentication:', error);
|
|
374
|
+
// Handle specific concurrent/pending request error cases gracefully
|
|
375
|
+
const name = (error && (error.name || error.constructor?.name)) || '';
|
|
376
|
+
const message = (error && error.message) || '';
|
|
377
|
+
if (name === 'AbortError') {
|
|
378
|
+
// Aborted intentionally; no user-facing error needed
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
if (name === 'InvalidStateError' || name === 'OperationError' || /pending/i.test(message)) {
|
|
382
|
+
adminforth.alert({ message: t('Another security prompt is already open. Please try again.'), variant: 'warning' });
|
|
383
|
+
codeError.value = t('A previous passkey attempt was still pending. Please try again.');
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
299
386
|
adminforth.alert({message: `Error during authentication: ${error}`, variant: 'warning'});
|
|
300
387
|
codeError.value = 'Error during authentication.';
|
|
301
388
|
}
|
|
389
|
+
finally {
|
|
390
|
+
// Clear in-flight state regardless of outcome
|
|
391
|
+
webAuthn.inFlight = false;
|
|
392
|
+
webAuthn.controller = null;
|
|
393
|
+
}
|
|
302
394
|
}
|
|
303
395
|
|
|
304
396
|
|
|
305
|
-
function getOtpInputs() {
|
|
397
|
+
function getOtpInputs(): HTMLInputElement[] {
|
|
306
398
|
const root = otpRoot.value;
|
|
307
399
|
if (!root) return [];
|
|
308
|
-
return Array.from(root.querySelectorAll('input.otp-input'));
|
|
400
|
+
return Array.from(root.querySelectorAll('input.otp-input')) as HTMLInputElement[];
|
|
309
401
|
}
|
|
310
402
|
|
|
311
403
|
function focusFirstAvailableOtpInput() {
|
|
@@ -318,7 +410,7 @@
|
|
|
318
410
|
function handleGlobalFocusIn(event) {
|
|
319
411
|
const inputs = getOtpInputs();
|
|
320
412
|
if (!inputs.length) return;
|
|
321
|
-
const target = event.target;
|
|
413
|
+
const target = event.target as HTMLInputElement | null;
|
|
322
414
|
if (!target) return;
|
|
323
415
|
if (!inputs.includes(target)) {
|
|
324
416
|
requestAnimationFrame(() => {
|
|
@@ -331,8 +423,9 @@
|
|
|
331
423
|
requestAnimationFrame(() => {
|
|
332
424
|
const inputs = getOtpInputs();
|
|
333
425
|
if (!inputs.length) return;
|
|
334
|
-
const active = document.activeElement;
|
|
335
|
-
|
|
426
|
+
const active = document.activeElement as Element | null;
|
|
427
|
+
const activeInput = active instanceof HTMLInputElement ? active : null;
|
|
428
|
+
if (!activeInput || !inputs.includes(activeInput)) {
|
|
336
429
|
focusFirstAvailableOtpInput();
|
|
337
430
|
}
|
|
338
431
|
});
|
|
@@ -327,6 +327,8 @@
|
|
|
327
327
|
}
|
|
328
328
|
|
|
329
329
|
function checkForCompatibility() {
|
|
330
|
+
let isCMA_supported = false;
|
|
331
|
+
let isParsingSupported = false;
|
|
330
332
|
if (window.PublicKeyCredential &&
|
|
331
333
|
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable &&
|
|
332
334
|
PublicKeyCredential.isConditionalMediationAvailable) {
|
|
@@ -335,13 +337,23 @@
|
|
|
335
337
|
PublicKeyCredential.isConditionalMediationAvailable(),
|
|
336
338
|
]).then(results => {
|
|
337
339
|
if (results.every(r => r === true)) {
|
|
338
|
-
|
|
340
|
+
isCMA_supported = true;
|
|
339
341
|
} else {
|
|
340
|
-
|
|
342
|
+
isCMA_supported = false;
|
|
341
343
|
addPasskeyMode.value = 'cross-platform';
|
|
342
344
|
}
|
|
343
345
|
});
|
|
344
|
-
}
|
|
346
|
+
}
|
|
347
|
+
try {
|
|
348
|
+
const temp = PublicKeyCredential.parseRequestOptionsFromJSON({challenge: 'test123', timeout: 60000, userVerification: 'required'});
|
|
349
|
+
if (temp && typeof temp === 'object') {
|
|
350
|
+
isParsingSupported = true;
|
|
351
|
+
}
|
|
352
|
+
} catch (e) {
|
|
353
|
+
adminforth.alert({message: 'Please ensure your browser supports passkeys.', variant: 'warning'});
|
|
354
|
+
isParsingSupported = false;
|
|
355
|
+
}
|
|
356
|
+
isPasskeySupported.value = (isCMA_supported && isParsingSupported);
|
|
345
357
|
}
|
|
346
358
|
|
|
347
359
|
async function fetchInformationFromTheBackend(confirmationResult) {
|