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 = {