From 48f7dc747c6f579aa71c4c531c057604edf56327 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Fri, 24 Jul 2026 04:31:23 +0530 Subject: [PATCH] fix(mfa): wire real WebOTP auto-fill into AuthorizerVerifyOtp autoComplete="one-time-code" was a declarative hint with nothing behind it - no call site anywhere called navigator.credentials.get(), so platforms that need the explicit WebOTP call (e.g. Android Chrome) got no auto-fill benefit at all. Add a feature-detected (window.OTPCredential) navigator.credentials.get() call, scoped to an actual SMS-OTP factor (not TOTP/email-OTP, which never arrive by SMS) via a new hasSmsOtp prop threaded from every caller that knows which factor is pending. Aborted via AbortController on unmount, on manual submit, and whenever the screen switches away from the code form, so a late resolution can never clobber a value the user already submitted. Closes #66 --- src/components/AuthorizerBasicAuthLogin.tsx | 2 + src/components/AuthorizerMFASetup.tsx | 1 + src/components/AuthorizerPasskeyLogin.tsx | 1 + src/components/AuthorizerRoot.tsx | 1 + src/components/AuthorizerSignup.tsx | 2 + src/components/AuthorizerVerifyOtp.tsx | 64 ++++++++++++++++++++- src/types/index.ts | 4 ++ 7 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/components/AuthorizerBasicAuthLogin.tsx b/src/components/AuthorizerBasicAuthLogin.tsx index 5d4dcd4..81667fb 100644 --- a/src/components/AuthorizerBasicAuthLogin.tsx +++ b/src/components/AuthorizerBasicAuthLogin.tsx @@ -167,6 +167,7 @@ export const AuthorizerBasicAuthLogin: FC<{ is_totp: step.totp, offer_webauthn_verify: step.webauthn, has_code_factor: step.totp || step.email || step.mobile, + has_sms_otp: step.mobile, }); return; } @@ -267,6 +268,7 @@ export const AuthorizerBasicAuthLogin: FC<{ is_totp: otpData.is_totp || false, offerWebauthnVerify: otpData.offer_webauthn_verify || false, hasCodeFactor: otpData.has_code_factor || false, + hasSmsOtp: otpData.has_sms_otp || false, onBack: () => setOtpData({ ...initOtpData }), }} urlProps={urlProps} diff --git a/src/components/AuthorizerMFASetup.tsx b/src/components/AuthorizerMFASetup.tsx index 11ed468..81437b9 100644 --- a/src/components/AuthorizerMFASetup.tsx +++ b/src/components/AuthorizerMFASetup.tsx @@ -294,6 +294,7 @@ export const AuthorizerMFASetup: FC<{ phone_number={loginContext?.phone_number} is_totp={false} hasCodeFactor + hasSmsOtp={otpMethodPending === 'sms_otp'} onLogin={(data) => { if (loginContext && data && (data as AuthTokenLike).access_token) { loginContext.onComplete(data as AuthTokenLike); diff --git a/src/components/AuthorizerPasskeyLogin.tsx b/src/components/AuthorizerPasskeyLogin.tsx index 583fc95..d9f068b 100644 --- a/src/components/AuthorizerPasskeyLogin.tsx +++ b/src/components/AuthorizerPasskeyLogin.tsx @@ -191,6 +191,7 @@ export const AuthorizerPasskeyLogin: FC<{ is_totp={mfaStep.totp} offerWebauthnVerify={mfaStep.webauthn} hasCodeFactor={mfaStep.totp || mfaStep.email || mfaStep.mobile} + hasSmsOtp={mfaStep.mobile} onBack={() => setMfaStep(null)} onLogin={(data) => { setAuthData({ diff --git a/src/components/AuthorizerRoot.tsx b/src/components/AuthorizerRoot.tsx index 298b12e..83078bd 100644 --- a/src/components/AuthorizerRoot.tsx +++ b/src/components/AuthorizerRoot.tsx @@ -104,6 +104,7 @@ export const AuthorizerRoot: FC<{ mfaRedirect.mfaMethods.includes('email_otp') || mfaRedirect.mfaMethods.includes('sms_otp') } + hasSmsOtp={mfaRedirect.mfaMethods.includes('sms_otp')} onBack={onCancelMfa} onLogin={(data: any) => { if (onLogin) { diff --git a/src/components/AuthorizerSignup.tsx b/src/components/AuthorizerSignup.tsx index 023afe9..b4dce2d 100644 --- a/src/components/AuthorizerSignup.tsx +++ b/src/components/AuthorizerSignup.tsx @@ -216,6 +216,7 @@ export const AuthorizerSignup: FC<{ is_totp: step.totp, offer_webauthn_verify: step.webauthn, has_code_factor: step.totp || step.email || step.mobile, + has_sms_otp: step.mobile, }); return; } @@ -382,6 +383,7 @@ export const AuthorizerSignup: FC<{ is_totp: otpData.is_totp || false, offerWebauthnVerify: otpData.offer_webauthn_verify || false, hasCodeFactor: otpData.has_code_factor || false, + hasSmsOtp: otpData.has_sms_otp || false, onBack: () => setOtpData({ ...initOtpData }), }} urlProps={urlProps} diff --git a/src/components/AuthorizerVerifyOtp.tsx b/src/components/AuthorizerVerifyOtp.tsx index 1dc7815..029208d 100644 --- a/src/components/AuthorizerVerifyOtp.tsx +++ b/src/components/AuthorizerVerifyOtp.tsx @@ -1,4 +1,4 @@ -import { FC, useEffect, useState } from 'react'; +import { FC, useEffect, useRef, useState } from 'react'; import { VerifyOTPRequest, isWebauthnSupported, @@ -15,6 +15,7 @@ import { AuthorizerTOTPScanner } from './AuthorizerTOTPScanner'; import { AuthorizerMfaLocked } from './AuthorizerMfaLocked'; import { IconPasskey } from '../icons/mfa'; import { resolveAuthStep } from '../utils/mfaTriage'; +import { hasWindow } from '../utils/window'; interface InputDataType { otp: string | null; @@ -38,6 +39,10 @@ export const AuthorizerVerifyOtp: FC<{ is_totp?: boolean; offerWebauthnVerify?: boolean; hasCodeFactor?: boolean; + // True specifically when the pending code factor is SMS-delivered (as + // opposed to email OTP or TOTP) - gates the WebOTP auto-fill call, which + // only makes sense for an actual incoming SMS. + hasSmsOtp?: boolean; // When present, a "Back" link lets the user leave this challenge (e.g. // return to the login screen) instead of being stuck once a factor is // being verified. @@ -51,6 +56,7 @@ export const AuthorizerVerifyOtp: FC<{ is_totp, offerWebauthnVerify, hasCodeFactor, + hasSmsOtp, onBack, }) => { const [error, setError] = useState(``); @@ -151,8 +157,64 @@ export const AuthorizerVerifyOtp: FC<{ setFormData({ ...formData, [field]: value }); }; + // WebOTP: races navigator.credentials.get() against the user manually + // typing/pasting the SMS code, so the code auto-fills where the platform + // supports it. autoComplete="one-time-code" on the input below is only a + // declarative hint - browsers require this explicit call to actually + // fill the field. Scoped to SMS-OTP alone (not TOTP/email-OTP), since + // those codes never arrive by SMS. Feature-detected via + // window.OTPCredential, matching what the autocomplete hint already + // implies; unsupported platforms simply never resolve/reject here. + const otpAbortControllerRef = useRef(null); + useEffect(() => { + if ( + !hasCodeFactor || + is_totp || + !hasSmsOtp || + totpData.is_screen_visible || + webauthnLocked || + !hasWindow() || + !('OTPCredential' in window) + ) { + return; + } + + const controller = new AbortController(); + otpAbortControllerRef.current = controller; + + ( + navigator.credentials.get({ + otp: { transport: ['sms'] }, + signal: controller.signal, + } as any) as Promise<{ code?: string } | null> + ) + .then((otpCredential) => { + if (otpCredential?.code) { + setFormData((prev) => ({ ...prev, otp: otpCredential.code as string })); + } + }) + .catch(() => { + // Aborted on unmount/submit, or the platform declined - the user + // can still type/paste the code manually, so this isn't an error. + }); + + return () => { + controller.abort(); + }; + }, [ + hasCodeFactor, + is_totp, + hasSmsOtp, + totpData.is_screen_visible, + webauthnLocked, + ]); + const onSubmit = async (e: any) => { e.preventDefault(); + // Stop racing WebOTP once the user (or an auto-filled value) submits - + // otherwise a late resolution could overwrite formData.otp after the + // fact on a re-shown form (e.g. a failed attempt). + otpAbortControllerRef.current?.abort(); setSuccessMessage(``); try { setLoading(true); diff --git a/src/types/index.ts b/src/types/index.ts index da1676c..38f66b1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -95,6 +95,10 @@ export type OtpDataType = { // a verified email/SMS OTP factor (is_totp false) alongside webauthn, and // that code path must stay reachable even when a passkey is also offered. has_code_factor?: boolean; + // True specifically for a verified SMS-OTP factor (as opposed to email + // OTP) - drives AuthorizerVerifyOtp's WebOTP (navigator.credentials.get) + // auto-fill, which only makes sense for an SMS-delivered code. + has_sms_otp?: boolean; }; export type TotpDataType = {