Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/components/AuthorizerBasicAuthLogin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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}
Expand Down
1 change: 1 addition & 0 deletions src/components/AuthorizerMFASetup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/components/AuthorizerPasskeyLogin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
1 change: 1 addition & 0 deletions src/components/AuthorizerRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions src/components/AuthorizerSignup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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}
Expand Down
64 changes: 63 additions & 1 deletion src/components/AuthorizerVerifyOtp.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FC, useEffect, useState } from 'react';
import { FC, useEffect, useRef, useState } from 'react';
import {
VerifyOTPRequest,
isWebauthnSupported,
Expand All @@ -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;
Expand All @@ -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.
Expand All @@ -51,6 +56,7 @@ export const AuthorizerVerifyOtp: FC<{
is_totp,
offerWebauthnVerify,
hasCodeFactor,
hasSmsOtp,
onBack,
}) => {
const [error, setError] = useState(``);
Expand Down Expand Up @@ -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<AbortController | null>(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);
Expand Down
4 changes: 4 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading