Skip to content

Commit c3f2f80

Browse files
authored
feat: MFA Enrollment (SMS) (#2242)
* feat: MFA Enrollment (SMS) * fixes
1 parent 75712c1 commit c3f2f80

File tree

2 files changed

+773
-0
lines changed

2 files changed

+773
-0
lines changed
Lines changed: 372 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,372 @@
1+
/*
2+
* Copyright 2025 Google Inc. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
5+
* in compliance with the License. You may obtain a copy of the License at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software distributed under the
10+
* License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
11+
* express or implied. See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*/
14+
15+
package com.firebase.ui.auth.compose.mfa
16+
17+
import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider
18+
import com.google.firebase.auth.FirebaseAuth
19+
import com.google.firebase.auth.FirebaseUser
20+
import com.google.firebase.auth.MultiFactorAssertion
21+
import com.google.firebase.auth.PhoneAuthCredential
22+
import com.google.firebase.auth.PhoneAuthProvider
23+
import com.google.firebase.auth.PhoneMultiFactorGenerator
24+
import kotlinx.coroutines.tasks.await
25+
26+
/**
27+
* Handler for SMS multi-factor authentication enrollment.
28+
*
29+
* This class manages the complete SMS enrollment flow, including:
30+
* - Sending SMS verification codes to phone numbers
31+
* - Resending codes with timer support
32+
* - Verifying SMS codes entered by users
33+
* - Finalizing enrollment with Firebase Authentication
34+
*
35+
* This handler uses the existing [AuthProvider.Phone.verifyPhoneNumberAwait] infrastructure
36+
* for sending and verifying SMS codes, ensuring consistency with the primary phone auth flow.
37+
*
38+
* **Usage:**
39+
* ```kotlin
40+
* val handler = SmsEnrollmentHandler(auth, user)
41+
*
42+
* // Step 1: Send verification code
43+
* val session = handler.sendVerificationCode("+1234567890")
44+
*
45+
* // Step 2: Display masked phone number and wait for user input
46+
* val masked = session.getMaskedPhoneNumber()
47+
*
48+
* // Step 3: If needed, resend code after timer expires
49+
* val newSession = handler.resendVerificationCode(session)
50+
*
51+
* // Step 4: Verify the code entered by the user
52+
* val verificationCode = "123456" // From user input
53+
* handler.enrollWithVerificationCode(session, verificationCode, "My Phone")
54+
* ```
55+
*
56+
* @property auth The [FirebaseAuth] instance
57+
* @property user The [FirebaseUser] to enroll in SMS MFA
58+
*
59+
* @since 10.0.0
60+
* @see TotpEnrollmentHandler
61+
* @see AuthProvider.Phone.verifyPhoneNumberAwait
62+
*/
63+
class SmsEnrollmentHandler(
64+
private val auth: FirebaseAuth,
65+
private val user: FirebaseUser
66+
) {
67+
private val phoneProvider = AuthProvider.Phone(
68+
defaultNumber = null,
69+
defaultCountryCode = null,
70+
allowedCountries = null,
71+
smsCodeLength = SMS_CODE_LENGTH,
72+
timeout = VERIFICATION_TIMEOUT_SECONDS,
73+
isInstantVerificationEnabled = true
74+
)
75+
/**
76+
* Sends an SMS verification code to the specified phone number.
77+
*
78+
* This method initiates the SMS enrollment process by sending a verification code
79+
* to the provided phone number. The code will be sent via SMS and should be
80+
* displayed to the user for entry.
81+
*
82+
* **Important:** The user must re-authenticate before calling this method if their
83+
* session is not recent. Use [FirebaseUser.reauthenticate] if needed.
84+
*
85+
* @param phoneNumber The phone number in E.164 format (e.g., "+1234567890")
86+
* @return An [SmsEnrollmentSession] containing the verification ID and metadata
87+
* @throws Exception if the user needs to re-authenticate, phone number is invalid,
88+
* or SMS sending fails
89+
*
90+
* @see resendVerificationCode
91+
* @see SmsEnrollmentSession.getMaskedPhoneNumber
92+
*/
93+
suspend fun sendVerificationCode(phoneNumber: String): SmsEnrollmentSession {
94+
require(isValidPhoneNumber(phoneNumber)) {
95+
"Phone number must be in E.164 format (e.g., +1234567890)"
96+
}
97+
98+
val multiFactorSession = user.multiFactor.session.await()
99+
val result = phoneProvider.verifyPhoneNumberAwait(
100+
auth = auth,
101+
phoneNumber = phoneNumber,
102+
multiFactorSession = multiFactorSession,
103+
forceResendingToken = null
104+
)
105+
106+
return when (result) {
107+
is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> {
108+
SmsEnrollmentSession(
109+
verificationId = "", // Not needed when auto-verified
110+
phoneNumber = phoneNumber,
111+
forceResendingToken = null,
112+
sentAt = System.currentTimeMillis(),
113+
autoVerifiedCredential = result.credential
114+
)
115+
}
116+
is AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification -> {
117+
SmsEnrollmentSession(
118+
verificationId = result.verificationId,
119+
phoneNumber = phoneNumber,
120+
forceResendingToken = result.token,
121+
sentAt = System.currentTimeMillis()
122+
)
123+
}
124+
}
125+
}
126+
127+
/**
128+
* Resends the SMS verification code to the phone number.
129+
*
130+
* This method uses the force resending token from the original session to
131+
* explicitly request a new SMS code. This should only be called after the
132+
* [RESEND_DELAY_SECONDS] has elapsed to respect rate limits.
133+
*
134+
* @param session The original [SmsEnrollmentSession] from [sendVerificationCode]
135+
* @return A new [SmsEnrollmentSession] with updated verification ID and timestamp
136+
* @throws Exception if resending fails or if the session doesn't have a resend token
137+
*
138+
* @see sendVerificationCode
139+
*/
140+
suspend fun resendVerificationCode(session: SmsEnrollmentSession): SmsEnrollmentSession {
141+
require(session.forceResendingToken != null) {
142+
"Cannot resend code without a force resending token"
143+
}
144+
145+
val multiFactorSession = user.multiFactor.session.await()
146+
val result = phoneProvider.verifyPhoneNumberAwait(
147+
auth = auth,
148+
phoneNumber = session.phoneNumber,
149+
multiFactorSession = multiFactorSession,
150+
forceResendingToken = session.forceResendingToken
151+
)
152+
153+
return when (result) {
154+
is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> {
155+
SmsEnrollmentSession(
156+
verificationId = "", // Not needed when auto-verified
157+
phoneNumber = session.phoneNumber,
158+
forceResendingToken = session.forceResendingToken,
159+
sentAt = System.currentTimeMillis(),
160+
autoVerifiedCredential = result.credential
161+
)
162+
}
163+
is AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification -> {
164+
SmsEnrollmentSession(
165+
verificationId = result.verificationId,
166+
phoneNumber = session.phoneNumber,
167+
forceResendingToken = result.token,
168+
sentAt = System.currentTimeMillis()
169+
)
170+
}
171+
}
172+
}
173+
174+
/**
175+
* Verifies an SMS code and completes the enrollment process.
176+
*
177+
* This method creates a multi-factor assertion using the provided session and
178+
* verification code, then enrolls the user in SMS MFA with Firebase Authentication.
179+
*
180+
* @param session The [SmsEnrollmentSession] from [sendVerificationCode] or [resendVerificationCode]
181+
* @param verificationCode The 6-digit code from the SMS message
182+
* @param displayName Optional friendly name for this MFA factor (e.g., "My Phone")
183+
* @throws Exception if the verification code is invalid or if enrollment fails
184+
*
185+
* @see sendVerificationCode
186+
* @see resendVerificationCode
187+
*/
188+
suspend fun enrollWithVerificationCode(
189+
session: SmsEnrollmentSession,
190+
verificationCode: String,
191+
displayName: String? = null
192+
) {
193+
require(isValidCodeFormat(verificationCode)) {
194+
"Verification code must be 6 digits"
195+
}
196+
197+
val credential = session.autoVerifiedCredential
198+
?: PhoneAuthProvider.getCredential(session.verificationId, verificationCode)
199+
200+
val multiFactorAssertion = PhoneMultiFactorGenerator.getAssertion(credential)
201+
user.multiFactor.enroll(multiFactorAssertion, displayName).await()
202+
}
203+
204+
/**
205+
* Validates that a verification code has the correct format for SMS.
206+
*
207+
* This method performs basic client-side validation to ensure the code:
208+
* - Is not null or empty
209+
* - Contains only digits
210+
* - Has exactly 6 digits (the standard SMS code length)
211+
*
212+
* **Note:** This does not verify the code against the server. Use
213+
* [enrollWithVerificationCode] to perform actual verification with Firebase.
214+
*
215+
* @param code The verification code to validate
216+
* @return `true` if the code has a valid format, `false` otherwise
217+
*/
218+
fun isValidCodeFormat(code: String): Boolean {
219+
return code.isNotBlank() &&
220+
code.length == SMS_CODE_LENGTH &&
221+
code.all { it.isDigit() }
222+
}
223+
224+
/**
225+
* Validates that a phone number is in the correct E.164 format.
226+
*
227+
* E.164 format requirements:
228+
* - Starts with "+"
229+
* - Followed by 1-15 digits
230+
* - No spaces, hyphens, or other characters
231+
* - Minimum 4 digits total (country code + subscriber number)
232+
*
233+
* Examples of valid numbers:
234+
* - +1234567890 (US)
235+
* - +447911123456 (UK)
236+
* - +33612345678 (France)
237+
*
238+
* @param phoneNumber The phone number to validate
239+
* @return `true` if the phone number is in E.164 format, `false` otherwise
240+
*/
241+
fun isValidPhoneNumber(phoneNumber: String): Boolean {
242+
return phoneNumber.matches(Regex("^\\+[1-9]\\d{3,14}$"))
243+
}
244+
245+
companion object {
246+
/**
247+
* The standard length for SMS verification codes.
248+
*/
249+
const val SMS_CODE_LENGTH = 6
250+
251+
/**
252+
* The verification timeout in seconds for phone authentication.
253+
* This is how long Firebase will wait for auto-verification before
254+
* falling back to manual code entry.
255+
*/
256+
const val VERIFICATION_TIMEOUT_SECONDS = 60L
257+
258+
/**
259+
* The recommended delay in seconds before allowing code resend.
260+
* This prevents users from spamming the resend functionality and
261+
* respects carrier rate limits.
262+
*/
263+
const val RESEND_DELAY_SECONDS = 30
264+
265+
/**
266+
* The Firebase factor ID for SMS multi-factor authentication.
267+
*/
268+
const val FACTOR_ID = PhoneMultiFactorGenerator.FACTOR_ID
269+
}
270+
}
271+
272+
/**
273+
* Represents an active SMS enrollment session with verification state.
274+
*
275+
* This class holds all the information needed to complete an SMS enrollment,
276+
* including the verification ID, phone number, and resend token.
277+
*
278+
* @property verificationId The verification ID from Firebase
279+
* @property phoneNumber The phone number being verified in E.164 format
280+
* @property forceResendingToken Optional token for resending the SMS code
281+
* @property sentAt Timestamp in milliseconds when the code was sent
282+
* @property autoVerifiedCredential Optional credential if auto-verification succeeded
283+
*
284+
* @since 10.0.0
285+
*/
286+
data class SmsEnrollmentSession(
287+
val verificationId: String,
288+
val phoneNumber: String,
289+
val forceResendingToken: PhoneAuthProvider.ForceResendingToken?,
290+
val sentAt: Long,
291+
val autoVerifiedCredential: PhoneAuthCredential? = null
292+
) {
293+
/**
294+
* Returns a masked version of the phone number for display purposes.
295+
*
296+
* Masks the middle digits of the phone number while keeping the country code
297+
* and last few digits visible for user confirmation.
298+
*
299+
* Examples:
300+
* - "+1234567890" → "+1••••••890"
301+
* - "+447911123456" → "+44•••••••456"
302+
*
303+
* @return The masked phone number string
304+
*/
305+
fun getMaskedPhoneNumber(): String {
306+
return maskPhoneNumber(phoneNumber)
307+
}
308+
309+
/**
310+
* Checks if the resend delay has elapsed since the code was sent.
311+
*
312+
* @param delaySec The delay in seconds (default: [SmsEnrollmentHandler.RESEND_DELAY_SECONDS])
313+
* @return `true` if enough time has passed to allow resending
314+
*/
315+
fun canResend(delaySec: Int = SmsEnrollmentHandler.RESEND_DELAY_SECONDS): Boolean {
316+
val elapsed = (System.currentTimeMillis() - sentAt) / 1000
317+
return elapsed >= delaySec
318+
}
319+
320+
/**
321+
* Returns the remaining seconds until resend is allowed.
322+
*
323+
* @param delaySec The delay in seconds (default: [SmsEnrollmentHandler.RESEND_DELAY_SECONDS])
324+
* @return The number of seconds remaining, or 0 if resend is already allowed
325+
*/
326+
fun getRemainingResendSeconds(delaySec: Int = SmsEnrollmentHandler.RESEND_DELAY_SECONDS): Int {
327+
val elapsed = (System.currentTimeMillis() - sentAt) / 1000
328+
return maxOf(0, delaySec - elapsed.toInt())
329+
}
330+
}
331+
332+
/**
333+
* Masks the middle digits of a phone number for privacy.
334+
*
335+
* The function keeps the country code (first 1-3 characters after +) and
336+
* the last 2-4 digits visible, masking everything in between with bullets.
337+
* Longer phone numbers show more last digits for better user confirmation.
338+
*
339+
* Examples:
340+
* - "+1234567890" → "+1••••••890" (11 chars, last 3 digits)
341+
* - "+447911123456" → "+44•••••••456" (13 chars, last 3 digits)
342+
* - "+33612345678" → "+33•••••••678" (12 chars, last 3 digits)
343+
* - "+8861234567890" → "+88••••••••7890" (14+ chars, last 4 digits)
344+
*
345+
* @param phoneNumber The phone number to mask in E.164 format
346+
* @return The masked phone number string
347+
*/
348+
fun maskPhoneNumber(phoneNumber: String): String {
349+
if (!phoneNumber.startsWith("+") || phoneNumber.length < 8) {
350+
return phoneNumber
351+
}
352+
353+
// Determine country code length (typically 1-3 digits after +)
354+
val digitsOnly = phoneNumber.substring(1) // Remove +
355+
val countryCodeLength = when {
356+
digitsOnly.length > 10 -> 2 // Likely 2-digit country code
357+
digitsOnly[0] == '1' -> 1 // North America
358+
else -> 2 // Most other countries
359+
}
360+
361+
val countryCode = phoneNumber.substring(0, countryCodeLength + 1) // Include +
362+
// Keep last 3-4 digits visible, with longer numbers showing more
363+
val lastDigitsCount = when {
364+
phoneNumber.length >= 14 -> 4 // Long numbers show 4 digits
365+
phoneNumber.length >= 11 -> 3 // Medium numbers show 3 digits
366+
else -> 2 // Short numbers show 2 digits
367+
}
368+
val lastDigits = phoneNumber.takeLast(lastDigitsCount)
369+
val maskedLength = phoneNumber.length - countryCode.length - lastDigitsCount
370+
371+
return "$countryCode${"".repeat(maskedLength)}$lastDigits"
372+
}

0 commit comments

Comments
 (0)