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
22 changes: 14 additions & 8 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -658,15 +658,15 @@ authentication
requirements?.enroll?.let { enrollTypes ->
println("User needs to enroll MFA")
println("Available enrollment types: ${enrollTypes.map { it.type }}")
// Example output: ["otp", "sms", "push-notification"]
// Example output: ["otp", "phone", "push-notification"]
// Proceed with MFA enrollment using one of these types
}

// Check if challenge is available (user already enrolled)
requirements?.challenge?.let { challengeTypes ->
println("User has enrolled MFA factors")
println("Available challenge types: ${challengeTypes.map { it.type }}")
// Example output: ["otp", "sms"]
// Example output: ["otp", "phone"]
// Get authenticators and challenge one of them
}

Expand Down Expand Up @@ -748,14 +748,14 @@ try {
requirements?.enroll?.let { enrollTypes ->
println("User needs to enroll MFA")
println("Available enrollment types: ${enrollTypes.map { it.type }}")
// Example output: ["otp", "sms", "push-notification"]
// Example output: ["otp", "phone", "push-notification"]
}

// Check if challenge is available
requirements?.challenge?.let { challengeTypes ->
println("User has enrolled MFA factors")
println("Available challenge types: ${challengeTypes.map { it.type }}")
// Example output: ["otp", "sms"]
// Example output: ["otp", "phone"]
}

// Proceed with MFA flow using mfaToken
Expand Down Expand Up @@ -966,10 +966,13 @@ mfaClient
override fun onFailure(exception: MfaEnrollmentException) { }

override fun onSuccess(enrollment: EnrollmentChallenge) {
// Display QR code or secret for user to scan/enter in authenticator app
// Display QR code or secret for user to scan/enter in authenticator app.
// The `/mfa/associate` endpoint returns the manual-entry key as `secret`
// (not `manualInputCode`, which is only populated by the My Account API).
if (enrollment is TotpEnrollmentChallenge) {
val secret = enrollment.manualInputCode
val secret = enrollment.secret
val barcodeUri = enrollment.barcodeUri
val recoveryCodes = enrollment.recoveryCodes
}
}
})
Expand All @@ -987,11 +990,14 @@ mfaClient

@Override
public void onSuccess(EnrollmentChallenge enrollment) {
// Display QR code or secret for user to scan/enter in authenticator app
// Display QR code or secret for user to scan/enter in authenticator app.
// The `/mfa/associate` endpoint returns the manual-entry key as `secret`
// (not `manualInputCode`, which is only populated by the My Account API).
if (enrollment instanceof TotpEnrollmentChallenge) {
TotpEnrollmentChallenge totpEnrollment = (TotpEnrollmentChallenge) enrollment;
String secret = totpEnrollment.getManualInputCode();
String secret = totpEnrollment.getSecret();
String barcodeUri = totpEnrollment.getBarcodeUri();
List<String> recoveryCodes = totpEnrollment.getRecoveryCodes();
}
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,16 +124,19 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA
* ## Usage
*
* ```kotlin
* mfaClient.getAuthenticators(listOf("otp", "oob"))
* mfaClient.getAuthenticators(listOf("otp", "phone"))
* .start(object : Callback<List<Authenticator>, MfaListAuthenticatorsException> {
* override fun onSuccess(result: List<Authenticator>) {
* // Only OTP and OOB authenticators returned
* // Only authenticators whose `type` is "otp" or "phone" are returned
* }
* override fun onFailure(error: MfaListAuthenticatorsException) { }
* })
* ```
*
* @param factorsAllowed Array of factor types to filter the authenticators (e.g., `["otp", "oob", "recovery-code"]`).
* Filtering matches each authenticator's [Authenticator.type] field against the
* provided values using exact (case-sensitive) equality.
*
* @param factorsAllowed Array of factor types to filter the authenticators (e.g., `["otp", "phone", "email", "recovery-code"]`).
* Must contain at least one factor type.
* @return a request to configure and start that will yield a list of [Authenticator]
*
Expand Down Expand Up @@ -307,11 +310,11 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA
* transparently by the SDK.
*
* **Filtering:**
* Authenticators are filtered by their effective type:
* - OOB authenticators: matched by their channel ("sms" or "email")
* - Other authenticators: matched by their type ("otp", "recovery-code", etc.)
* An authenticator is included when [Authenticator.type] exactly matches one of the
* provided [factorsAllowed] values (case-sensitive equality). This mirrors the
* filtering behavior of the Auth0.swift SDK.
*
* @param factorsAllowed List of factor types to include (e.g., ["sms", "email", "otp"])
* @param factorsAllowed List of factor types to include (e.g., ["phone", "email", "otp"])
* @return A JsonAdapter that produces a filtered list of authenticators
*/
private fun createFilteringAuthenticatorsAdapter(factorsAllowed: List<String>): JsonAdapter<List<Authenticator>> {
Expand All @@ -321,66 +324,12 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA
val allAuthenticators = baseAdapter.fromJson(reader, metadata)

return allAuthenticators.filter { authenticator ->
matchesFactorType(authenticator, factorsAllowed)
factorsAllowed.contains(authenticator.type)
}
}
}
}

/**
* Checks if an authenticator matches any of the allowed factor types.
*
* The matching logic handles various factor type aliases:
* - "sms" or "phone": matches OOB authenticators with SMS channel
* - "email": matches OOB authenticators with email channel
* - "otp" or "totp": matches time-based one-time password authenticators
* - "oob": matches any out-of-band authenticator regardless of channel
* - "recovery-code": matches recovery code authenticators
* - "push-notification": matches push notification authenticators
*
* @param authenticator The authenticator to check
* @param factorsAllowed List of allowed factor types
* @return true if the authenticator matches any allowed factor type
*/
private fun matchesFactorType(
authenticator: Authenticator,
factorsAllowed: List<String>
): Boolean {
val effectiveType = getEffectiveType(authenticator)

return factorsAllowed.any { factor ->
val normalizedFactor = factor.lowercase(java.util.Locale.ROOT)
when (normalizedFactor) {
"sms", "phone" -> effectiveType == "sms" || effectiveType == "phone"
"email" -> effectiveType == "email"
"otp", "totp" -> effectiveType == "otp" || effectiveType == "totp"
"oob" -> authenticator.authenticatorType == "oob" || authenticator.type == "oob"
"recovery-code" -> effectiveType == "recovery-code"
"push-notification" -> effectiveType == "push-notification"
else -> effectiveType == normalizedFactor ||
authenticator.authenticatorType?.lowercase(java.util.Locale.ROOT) == normalizedFactor ||
authenticator.type.lowercase(java.util.Locale.ROOT) == normalizedFactor
}
}
}

/**
* Resolves the effective type of an authenticator for filtering purposes.
*
* OOB (out-of-band) authenticators use their channel ("sms" or "email") as the
* effective type, since users typically filter by delivery method rather than
* the generic "oob" type. Other authenticators use their authenticatorType directly.
*
* @param authenticator The authenticator to get the type for
* @return The effective type string used for filtering
*/
private fun getEffectiveType(authenticator: Authenticator): String {
return when (authenticator.authenticatorType) {
"oob" -> authenticator.oobChannel ?: "oob"
else -> authenticator.authenticatorType ?: authenticator.type
}
}

/**
* Helper function for OOB enrollment (SMS, email, push).
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,53 @@ public data class OobEnrollmentChallenge(
public val bindingMethod: String? = null
) : EnrollmentChallenge()

/**
* Enrollment challenge for TOTP (authenticator app) and Push factors, returned by both
* the MFA `/mfa/associate` endpoint and the My Account `/authentication-methods` endpoint.
*
* The two endpoints return different field sets, so every field except [barcodeUri] is
* optional:
* - `/mfa/associate` (ROPG MFA flow) returns [authenticatorType], [secret], [barcodeUri]
* and [recoveryCodes]. It does NOT return `id`, `auth_session` or `manual_input_code`.
* - `/authentication-methods` (My Account) returns [id], [authSession], [barcodeUri] and
* [manualInputCode].
*
* [secret] and [manualInputCode] both carry the human-readable key for manual entry into an
* authenticator app; only one is populated depending on the endpoint. The [barcodeUri]
* (`otpauth://` URI) is always present and embeds the same secret.
*
* @property id Identifier of the created authentication method. `null` on the `/mfa/associate`
* response (which does not return it); populated by the My Account `/authentication-methods`
* endpoint.
* @property authSession Authentication session for the enrollment. `null` on the
* `/mfa/associate` response; populated by the My Account `/authentication-methods` endpoint.
* @property barcodeUri The `otpauth://` URI to render as a QR code. Always present on both
* endpoints.
* @property manualInputCode Human-readable key for manual entry, returned only by the My Account
* `/authentication-methods` endpoint; `null` on the `/mfa/associate` response (which returns the
* key as [secret] instead).
* @property authenticatorType Low-level authenticator type (e.g. `otp`), returned only by the
* `/mfa/associate` endpoint; `null` on the My Account response.
* @property secret Human-readable key for manual entry, returned only by the `/mfa/associate`
* endpoint; `null` on the My Account response (which returns the key as [manualInputCode]).
* @property recoveryCodes Recovery codes generated during enrollment, returned only by the
* `/mfa/associate` endpoint; `null` on the My Account response.
*/
public data class TotpEnrollmentChallenge(
@SerializedName("id")
override val id: String,
override val id: String? = null,
@SerializedName("auth_session")
override val authSession: String,
override val authSession: String? = null,
@SerializedName("barcode_uri")
public val barcodeUri: String,
@SerializedName("manual_input_code")
public val manualInputCode: String?
public val manualInputCode: String? = null,
@SerializedName("authenticator_type")
public val authenticatorType: String? = null,
@SerializedName("secret")
public val secret: String? = null,
@SerializedName("recovery_codes")
public val recoveryCodes: List<String>? = null
) : EnrollmentChallenge()

public data class RecoveryCodeEnrollmentChallenge(
Expand Down
Loading
Loading