diff --git a/app/build.gradle b/app/build.gradle
index 1177bbe..20f4ef7 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -8,6 +8,9 @@ allprojects {
url 'https://pkgs.dev.azure.com/MicrosoftDeviceSDK/DuoSDK-Public/_packaging/Duo-SDK-Feed/maven/v1'
name 'Duo-SDK-Feed'
}
+ maven {
+ url = "https://prove.jfrog.io/artifactory/libs-public-maven/"
+ }
mavenCentral()
google()
}
@@ -31,6 +34,7 @@ android {
buildFeatures {
viewBinding true
}
+ kotlinOptions { jvmTarget = '1.8' }
buildTypes {
release {
minifyEnabled false
@@ -83,4 +87,6 @@ dependencies {
// Downloads and Builds MSAL from maven central.
implementation 'com.microsoft.identity.client:msal:[8.1.0,)'
}
+
+ implementation 'com.prove.sdk:proveauth:6.9.0'
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 618807a..6bed2de 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,7 +4,17 @@
+
+
+
+
+
+
+
+
+
-
+
diff --git a/app/src/main/assets/config.json b/app/src/main/assets/config.json
new file mode 100644
index 0000000..e4eb16d
--- /dev/null
+++ b/app/src/main/assets/config.json
@@ -0,0 +1,6 @@
+{
+ "Prove": {
+ "clientId": "nativeauthfraudcheck-poc-e7971c6c-c35b-4e17-80f8-99aefdb611c0-1770372962304",
+ "clientSecret": "CgvbgUQpZ95TXZOkYgi3TgOs1MITJj6L"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/azuresamples/msalnativeauthandroidkotlinsampleapp/AuthClient.kt b/app/src/main/java/com/azuresamples/msalnativeauthandroidkotlinsampleapp/AuthClient.kt
index 7ba8329..093c1e5 100644
--- a/app/src/main/java/com/azuresamples/msalnativeauthandroidkotlinsampleapp/AuthClient.kt
+++ b/app/src/main/java/com/azuresamples/msalnativeauthandroidkotlinsampleapp/AuthClient.kt
@@ -28,5 +28,8 @@ object AuthClient : Application() {
context,
R.raw.auth_config_native_auth
)
+
+ // Initialize Prove Bot Detection SDK
+ ProveBotDetectionHelper.initialize(context)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/azuresamples/msalnativeauthandroidkotlinsampleapp/EmailPasswordSignInSignUpFragment.kt b/app/src/main/java/com/azuresamples/msalnativeauthandroidkotlinsampleapp/EmailPasswordSignInSignUpFragment.kt
index a82f9e3..15258e2 100644
--- a/app/src/main/java/com/azuresamples/msalnativeauthandroidkotlinsampleapp/EmailPasswordSignInSignUpFragment.kt
+++ b/app/src/main/java/com/azuresamples/msalnativeauthandroidkotlinsampleapp/EmailPasswordSignInSignUpFragment.kt
@@ -2,6 +2,7 @@ package com.azuresamples.msalnativeauthandroidkotlinsampleapp
import android.app.AlertDialog
import android.os.Bundle
+import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -97,6 +98,27 @@ class EmailPasswordSignInSignUpFragment : Fragment() {
val password = CharArray(binding.passwordText.length())
binding.passwordText.text?.getChars(0, binding.passwordText.length(), password, 0)
+ // Prove Bot Detection Gate
+ val phoneNumber = email // Replace with actual phone number field if available
+ if (ProveBotDetectionHelper.hasProveKey()) {
+ val botResult = ProveBotDetectionHelper.performBotDetection(phoneNumber)
+ when (botResult) {
+ is ProveBotDetectionHelper.BotDetectionResult.Failed -> {
+ binding.passwordText.text?.clear()
+ StringUtil.overwriteWithNull(password)
+ displayDialog("Fraud Detection", "Sign-in blocked: ${botResult.reason}")
+ return@launch
+ }
+ is ProveBotDetectionHelper.BotDetectionResult.Error -> {
+ Log.w(TAG, "Bot detection error: ${botResult.message}")
+ // Continue on error — adjust policy as needed
+ }
+ is ProveBotDetectionHelper.BotDetectionResult.Passed -> {
+ Log.i(TAG, "Bot detection passed. Proceeding with sign-in.")
+ }
+ }
+ }
+
val parameters = NativeAuthSignInParameters(username = email)
parameters.password = password
val actionResult: SignInResult = authClient.signIn(parameters)
@@ -137,6 +159,27 @@ class EmailPasswordSignInSignUpFragment : Fragment() {
val password = CharArray(binding.passwordText.length())
binding.passwordText.text?.getChars(0, binding.passwordText.length(), password, 0)
+ // Prove Bot Detection Gate
+ val phoneNumber = email // Replace with actual phone number field if available
+ if (ProveBotDetectionHelper.hasProveKey()) {
+ val botResult = ProveBotDetectionHelper.performBotDetection(phoneNumber)
+ when (botResult) {
+ is ProveBotDetectionHelper.BotDetectionResult.Failed -> {
+ binding.passwordText.text?.clear()
+ StringUtil.overwriteWithNull(password)
+ displayDialog("Fraud Detection", "Sign-up blocked: ${botResult.reason}")
+ return@launch
+ }
+ is ProveBotDetectionHelper.BotDetectionResult.Error -> {
+ Log.w(TAG, "Bot detection error: ${botResult.message}")
+ // Continue on error — adjust policy as needed
+ }
+ is ProveBotDetectionHelper.BotDetectionResult.Passed -> {
+ Log.i(TAG, "Bot detection passed. Proceeding with sign-up.")
+ }
+ }
+ }
+
val parameters = NativeAuthSignUpParameters(username = email)
parameters.password = password
val actionResult: SignUpResult = authClient.signUp(parameters)
diff --git a/app/src/main/java/com/azuresamples/msalnativeauthandroidkotlinsampleapp/EmailSignInSignUpFragment.kt b/app/src/main/java/com/azuresamples/msalnativeauthandroidkotlinsampleapp/EmailSignInSignUpFragment.kt
index 5f2d5b3..91b823a 100644
--- a/app/src/main/java/com/azuresamples/msalnativeauthandroidkotlinsampleapp/EmailSignInSignUpFragment.kt
+++ b/app/src/main/java/com/azuresamples/msalnativeauthandroidkotlinsampleapp/EmailSignInSignUpFragment.kt
@@ -2,6 +2,7 @@ package com.azuresamples.msalnativeauthandroidkotlinsampleapp
import android.app.AlertDialog
import android.os.Bundle
+import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -94,6 +95,25 @@ class EmailSignInSignUpFragment : Fragment() {
CoroutineScope(Dispatchers.Main).launch {
val email = binding.emailText.text.toString()
+ // Prove Bot Detection Gate
+ val phoneNumber = email // Replace with actual phone number field if available
+ if (ProveBotDetectionHelper.hasProveKey()) {
+ val botResult = ProveBotDetectionHelper.performBotDetection(phoneNumber)
+ when (botResult) {
+ is ProveBotDetectionHelper.BotDetectionResult.Failed -> {
+ displayDialog("Fraud Detection", "Sign-in blocked: ${botResult.reason}")
+ return@launch
+ }
+ is ProveBotDetectionHelper.BotDetectionResult.Error -> {
+ Log.w(TAG, "Bot detection error: ${botResult.message}")
+ // Continue on error — adjust policy as needed
+ }
+ is ProveBotDetectionHelper.BotDetectionResult.Passed -> {
+ Log.i(TAG, "Bot detection passed. Proceeding with sign-in.")
+ }
+ }
+ }
+
val parameters = NativeAuthSignInParameters(username = email)
val actionResult = authClient.signIn(parameters)
@@ -125,6 +145,25 @@ class EmailSignInSignUpFragment : Fragment() {
CoroutineScope(Dispatchers.Main).launch {
val email = binding.emailText.text.toString()
+ // Prove Bot Detection Gate
+ val phoneNumber = email // Replace with actual phone number field if available
+ if (ProveBotDetectionHelper.hasProveKey()) {
+ val botResult = ProveBotDetectionHelper.performBotDetection(phoneNumber)
+ when (botResult) {
+ is ProveBotDetectionHelper.BotDetectionResult.Failed -> {
+ displayDialog("Fraud Detection", "Sign-up blocked: ${botResult.reason}")
+ return@launch
+ }
+ is ProveBotDetectionHelper.BotDetectionResult.Error -> {
+ Log.w(TAG, "Bot detection error: ${botResult.message}")
+ // Continue on error — adjust policy as needed
+ }
+ is ProveBotDetectionHelper.BotDetectionResult.Passed -> {
+ Log.i(TAG, "Bot detection passed. Proceeding with sign-up.")
+ }
+ }
+ }
+
val parameters = NativeAuthSignUpParameters(username = email)
val actionResult = authClient.signUp(parameters)
diff --git a/app/src/main/java/com/azuresamples/msalnativeauthandroidkotlinsampleapp/MFAFragment.kt b/app/src/main/java/com/azuresamples/msalnativeauthandroidkotlinsampleapp/MFAFragment.kt
index 1fec133..1109d0d 100644
--- a/app/src/main/java/com/azuresamples/msalnativeauthandroidkotlinsampleapp/MFAFragment.kt
+++ b/app/src/main/java/com/azuresamples/msalnativeauthandroidkotlinsampleapp/MFAFragment.kt
@@ -29,6 +29,7 @@ import com.microsoft.identity.nativeauth.statemachine.states.RegisterStrongAuthS
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import java.util.Arrays
class MFAFragment : Fragment() {
@@ -100,6 +101,7 @@ class MFAFragment : Fragment() {
val parameters = NativeAuthSignInParameters(username = email)
parameters.password = password
+ parameters.scopes = Arrays.asList("openid", "offline_access", "profile", "api://019f8c18-e680-43b3-9ac3-d9e118b69c0d/App.Read")
val actionResult: SignInResult = authClient.signIn(parameters)
binding.passwordText.text?.clear()
StringUtil.overwriteWithNull(password)
diff --git a/app/src/main/java/com/azuresamples/msalnativeauthandroidkotlinsampleapp/MainApplication.kt b/app/src/main/java/com/azuresamples/msalnativeauthandroidkotlinsampleapp/MainApplication.kt
new file mode 100644
index 0000000..cfa31f5
--- /dev/null
+++ b/app/src/main/java/com/azuresamples/msalnativeauthandroidkotlinsampleapp/MainApplication.kt
@@ -0,0 +1,43 @@
+package com.azuresamples.msalnativeauthandroidkotlinsampleapp
+
+import android.app.Application
+import android.util.Log
+import org.json.JSONObject
+
+class MainApplication : Application() {
+
+ override fun onCreate() {
+ super.onCreate()
+
+ Log.d("MS", "Application onCreate called")
+ initializeProveIfConfigured()
+ }
+
+ private fun initializeProveIfConfigured() {
+ // Read Prove credentials directly from the packaged config file:
+ // `app/src/main/assets/config.json`
+ val (clientId, clientSecret) = try {
+ val jsonText = assets.open("config.json").bufferedReader().use { it.readText() }
+ val proveJson = JSONObject(jsonText).optJSONObject("Prove")
+ val id = proveJson?.optString("clientId").orEmpty().trim()
+ val secret = proveJson?.optString("clientSecret").orEmpty().trim()
+ id to secret
+ } catch (t: Throwable) {
+ Log.e("MS", "Failed to read Prove config from assets/config.json", t)
+ "" to ""
+ }
+
+ if (clientId.isBlank() || clientSecret.isBlank()) {
+ Log.w(
+ "MS",
+ "Prove not initialized. Set Prove.clientId and Prove.clientSecret in app/src/main/assets/config.json."
+ )
+ return
+ }
+
+ ProveManager.getInstance().initialize(
+ clientId = clientId,
+ clientSecret = clientSecret
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/azuresamples/msalnativeauthandroidkotlinsampleapp/ProveBotDetectionHelper.kt b/app/src/main/java/com/azuresamples/msalnativeauthandroidkotlinsampleapp/ProveBotDetectionHelper.kt
new file mode 100644
index 0000000..598a102
--- /dev/null
+++ b/app/src/main/java/com/azuresamples/msalnativeauthandroidkotlinsampleapp/ProveBotDetectionHelper.kt
@@ -0,0 +1,140 @@
+package com.azuresamples.msalnativeauthandroidkotlinsampleapp
+
+import android.content.Context
+import android.util.Log
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONObject
+
+/**
+ * Helper class for integrating Prove Bot Detection into the sign-in/sign-up flows.
+ *
+ * Flow overview (per https://developer.prove.com/docs/check-for-prove-key#prompt-for-phone-number-3):
+ * 1. Initialize the Prove SDK on app start to obtain a device-level "Prove Key".
+ * 2. Before sending an OTP / completing sign-in, call YOUR backend with the phone number + Prove Key.
+ * 3. Your backend calls Prove's /v3/verify API with verificationType="bot".
+ * 4. The backend returns the bot-detection result to the app.
+ * 5. The app decides whether to proceed with authentication or block the attempt.
+ */
+object ProveBotDetectionHelper {
+
+ private const val TAG = "ProveBotDetection"
+
+ // IMPORTANT: Replace with your own backend endpoint that proxies to Prove's API.
+ // NEVER call Prove's API directly from the client — your access token must stay server-side.
+ private const val BOT_DETECTION_BACKEND_URL = "https://your-backend.example.com/api/prove/bot-detect"
+
+ private val httpClient = OkHttpClient()
+ private var proveKey: String? = null
+
+ /**
+ * Initialize the Prove SDK and retrieve the Prove Key (device fingerprint).
+ * Call this once during app startup (e.g., in AuthClient.initialize).
+ */
+ fun initialize(context: Context) {
+ try {
+ // Initialize the Prove SDK — the exact API depends on the SDK version.
+ // Refer to: https://developer.prove.com/reference/unify-android-sdk
+ //
+ // Example (pseudocode — adapt to actual SDK):
+ // ProveAuth.initialize(context, "YOUR_PROVE_CLIENT_ID")
+ // proveKey = ProveAuth.getDeviceId()
+
+ Log.i(TAG, "Prove SDK initialized. Prove Key obtained.")
+
+ // TODO: Replace the line below with real SDK call
+ // proveKey = ProveAuth.getDeviceId()
+ proveKey = "PLACEHOLDER_PROVE_KEY" // Remove this after real integration
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to initialize Prove SDK", e)
+ }
+ }
+
+ /**
+ * Check whether the Prove Key is available.
+ */
+ fun hasProveKey(): Boolean {
+ return !proveKey.isNullOrBlank()
+ }
+
+ /**
+ * Perform bot detection by calling YOUR backend with the phone number and Prove Key.
+ *
+ * Your backend should:
+ * 1. Obtain a Prove access token (OAuth2 client_credentials grant).
+ * 2. POST to https://platform.prove.com/v3/verify with:
+ * {
+ * "verificationType": "bot",
+ * "phoneNumber": "",
+ * "deviceId": "",
+ * "clientRequestId": ""
+ * }
+ * 3. Return the result to the mobile app.
+ *
+ * @param phoneNumber The phone number entered by the user (E.164 format recommended)
+ * @return BotDetectionResult indicating whether the user passed or failed bot detection
+ */
+ suspend fun performBotDetection(phoneNumber: String): BotDetectionResult {
+ if (!hasProveKey()) {
+ Log.w(TAG, "Prove Key not available. Cannot perform bot detection.")
+ return BotDetectionResult.Error("Prove Key not available. Please restart the app.")
+ }
+
+ return withContext(Dispatchers.IO) {
+ try {
+ val requestBody = JSONObject().apply {
+ put("phoneNumber", phoneNumber)
+ put("deviceId", proveKey)
+ put("clientRequestId", java.util.UUID.randomUUID().toString())
+ }.toString()
+
+ val request = Request.Builder()
+ .url(BOT_DETECTION_BACKEND_URL)
+ .post(requestBody.toRequestBody("application/json".toMediaType()))
+ .build()
+
+ val response = httpClient.newCall(request).execute()
+
+ if (response.isSuccessful) {
+ val responseBody = response.body?.string() ?: "{}"
+ val json = JSONObject(responseBody)
+
+ val isHuman = json.optBoolean("success", false)
+ val score = json.optDouble("score", 0.0)
+
+ if (isHuman) {
+ Log.i(TAG, "Bot detection passed. Score: $score")
+ BotDetectionResult.Passed(score)
+ } else {
+ Log.w(TAG, "Bot detection failed. Score: $score")
+ BotDetectionResult.Failed(score, "Phone number flagged as potential fraud.")
+ }
+ } else {
+ Log.e(TAG, "Bot detection API returned ${response.code}")
+ BotDetectionResult.Error("Bot detection service unavailable (HTTP ${response.code}).")
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Bot detection request failed", e)
+ BotDetectionResult.Error("Bot detection failed: ${e.message}")
+ }
+ }
+ }
+
+ /**
+ * Sealed class representing the result of a bot detection check.
+ */
+ sealed class BotDetectionResult {
+ /** The phone number passed bot detection (appears to be a real human). */
+ data class Passed(val score: Double) : BotDetectionResult()
+
+ /** The phone number failed bot detection (potential bot/fraud). */
+ data class Failed(val score: Double, val reason: String) : BotDetectionResult()
+
+ /** An error occurred during bot detection. */
+ data class Error(val message: String) : BotDetectionResult()
+ }
+}
diff --git a/app/src/main/java/com/azuresamples/msalnativeauthandroidkotlinsampleapp/ProveManager.kt b/app/src/main/java/com/azuresamples/msalnativeauthandroidkotlinsampleapp/ProveManager.kt
new file mode 100644
index 0000000..cfabf34
--- /dev/null
+++ b/app/src/main/java/com/azuresamples/msalnativeauthandroidkotlinsampleapp/ProveManager.kt
@@ -0,0 +1,256 @@
+package com.azuresamples.msalnativeauthandroidkotlinsampleapp
+
+import android.util.Log
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.FormBody
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONObject
+import java.util.UUID
+import java.util.concurrent.TimeUnit
+
+
+/**
+ * ProveManager handles Prove Identity verification for MFA with SMS
+ * using customer-supplied possession flow.
+ *
+ * Uses OkHttp for REST calls because the Prove server SDK (com.prove:proveapi)
+ * relies on java.net.http.HttpClient which is not available on Android.
+ *
+ * Documentation: https://developer.prove.com/docs/check-for-prove-key#prove-possession-mobile
+ */
+class ProveManager private constructor() {
+
+ companion object {
+ private const val TAG = "ProveManager"
+ private const val BASE_URL = "https://platform.uat.proveapis.com"
+ private const val TOKEN_URL = "$BASE_URL/token"
+ private const val V3_UNIFY_URL = "$BASE_URL/v3/unify"
+ private const val V3_VALIDATE_URL = "$BASE_URL/v3/validate"
+ private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
+
+ @Volatile
+ private var instance: ProveManager? = null
+
+ fun getInstance(): ProveManager {
+ return instance ?: synchronized(this) {
+ instance ?: ProveManager().also { instance = it }
+ }
+ }
+ }
+
+ // Store the correlation ID from the start response for subsequent calls
+ private var correlationId: String? = null
+ private var authToken: String? = null
+
+ private var clientId: String? = null
+ private var clientSecret: String? = null
+
+ // Cached OAuth access token
+ private var accessToken: String? = null
+
+ private val httpClient = OkHttpClient.Builder()
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .writeTimeout(30, TimeUnit.SECONDS)
+ .build()
+
+ /**
+ * Result class for Prove verification operations
+ */
+ sealed class ProveResult {
+ data class Success(
+ val correlationId: String,
+ val authToken: String? = null,
+ val phoneVerified: Boolean = false,
+ val possessionResult: String? = null
+ ) : ProveResult()
+
+ /**
+ * The Prove call failed or possession could not be verified.
+ *
+ * Note: In this sample we fail closed (possession must verify) before proceeding to Microsoft SMS OTP.
+ */
+ data class Error(
+ val message: String,
+ val errorCode: String? = null
+ ) : ProveResult()
+ }
+
+ /**
+ * Initialize the Prove API credentials.
+ * NOTE: In production, credentials should be fetched from your backend server,
+ * not stored in the app.
+ */
+ fun initialize(clientId: String, clientSecret: String) {
+ this.clientId = clientId
+ this.clientSecret = clientSecret
+ Log.d(TAG, "Prove API credentials stored")
+ }
+
+ /**
+ * Obtain an OAuth2 access token using the client-credentials grant.
+ * Caches the token for subsequent calls (no expiry handling in this sample).
+ */
+ private fun fetchAccessToken(): String {
+ Log.d(TAG, "First touch of fetchAccessToken() - fetching new token")
+ accessToken?.let { return it }
+
+ val id = clientId
+ ?: throw IllegalStateException("Prove clientId not set. Call initialize() first.")
+ val secret = clientSecret
+ ?: throw IllegalStateException("Prove clientSecret not set. Call initialize() 2.")
+ Log.d(TAG, "2 touch of fetchAccessToken() - fetching new token")
+ val formBody = FormBody.Builder()
+ .add("grant_type", "client_credentials")
+ .add("client_id", id)
+ .add("client_secret", secret)
+ .build()
+ Log.d(TAG, "${formBody}")
+ val request = Request.Builder()
+ .url(TOKEN_URL)
+ .post(formBody)
+ .build()
+
+ Log.d(TAG, "${request}")
+ httpClient.newCall(request).execute().use { response ->
+
+ if (!response.isSuccessful) {
+ throw RuntimeException("Token request failed: ${response.code} ${response.body?.string()}")
+ }
+
+ val json = JSONObject(response.body!!.string())
+ Log.d(TAG, "${json}")
+ val token = json.getString("access_token")
+ accessToken = token
+ Log.d(TAG, "${token}")
+ return token
+ }
+
+ }
+
+ /**
+ * Start a V3 Unify session and store authToken / correlationId.
+ */
+ suspend fun initializeWithAuthToken(phoneNumber: String?): ProveResult = withContext(Dispatchers.IO) {
+ try {
+ val token = fetchAccessToken()
+
+ val payload = FormBody.Builder()
+ .add("clientRequestId", UUID.randomUUID().toString())
+ .add("possessionType", "none")
+ .add("phoneNumber", phoneNumber ?: "")
+ .build()
+
+ val request = Request.Builder()
+ .url(V3_UNIFY_URL)
+ .addHeader("Authorization", "Bearer $token")
+ .post(payload.toString().toRequestBody(JSON_MEDIA_TYPE))
+ .build()
+
+ httpClient.newCall(request).execute().use { response ->
+ val responseBody = response.body?.string()
+ if (!response.isSuccessful) {
+ return@withContext ProveResult.Error(
+ message = "V3 Unify failed: ${response.code} $responseBody",
+ errorCode = response.code.toString()
+ )
+ }
+
+ val json = JSONObject(responseBody!!)
+ authToken = json.optString("authToken", null)
+ correlationId = json.optString("correlationId", null)
+
+ Log.d(TAG, "V3 Unify successful. CorrelationId: $correlationId")
+
+ return@withContext ProveResult.Success(
+ correlationId = correlationId!!,
+ authToken = authToken
+ )
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "initializeWithAuthToken failed", e)
+ ProveResult.Error(message = e.message ?: "Unknown error occurred")
+ }
+ }
+
+ /**
+ * Step 2: Validate/Check for Prove Key (Possession Check)
+ *
+ * This checks if the device has Prove Key installed and can verify possession.
+ *
+ * Policy for this sample:
+ * - If Prove Key is available and possession verifies: proceed to Microsoft SMS OTP.
+ * - If not available / not verified / API error: FAIL CLOSED and do not proceed.
+ *
+ * @param correlationId The correlation ID from initializeWithAuthToken
+ */
+ suspend fun checkPossession(correlationId: String? = null): ProveResult = withContext(Dispatchers.IO) {
+ try {
+ clearSession()
+
+ val token = fetchAccessToken()
+
+ val corrId = correlationId ?: this@ProveManager.correlationId
+ ?: return@withContext ProveResult.Error(
+ message = "No correlation ID available. Call initializeWithAuthToken() first."
+ )
+
+ Log.d(TAG, "Checking possession for correlationId: $corrId")
+
+ val payload = JSONObject().apply {
+ put("correlationId", corrId)
+ }
+
+ val request = Request.Builder()
+ .url(V3_VALIDATE_URL)
+ .addHeader("Authorization", "Bearer $token")
+ .post(payload.toString().toRequestBody(JSON_MEDIA_TYPE))
+ .build()
+
+ httpClient.newCall(request).execute().use { response ->
+ val responseBody = response.body?.string()
+ if (!response.isSuccessful) {
+ return@withContext ProveResult.Error(
+ message = "V3 Validate failed: ${response.code} $responseBody",
+ errorCode = response.code.toString()
+ )
+ }
+
+ val json = JSONObject(responseBody!!)
+ val verified = json.optBoolean("success", false)
+
+ Log.d(TAG, "Possession check result: success=$verified")
+
+ if (verified) {
+ return@withContext ProveResult.Success(
+ correlationId = corrId,
+ phoneVerified = true,
+ possessionResult = "verified"
+ )
+ }
+
+ val returnedPhone = json.optString("phoneNumber", "")
+ Log.w(TAG, "Possession not verified. success=$verified, phoneNumber=${returnedPhone.takeLast(4)}")
+ return@withContext ProveResult.Error(
+ message = "Prove possession could not be verified. Cannot proceed with SMS OTP."
+ )
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Possession check exception", e)
+ ProveResult.Error(message = e.message ?: "Unknown error occurred")
+ }
+ }
+
+ /**
+ * Clear the current session data.
+ */
+ private fun clearSession() {
+ correlationId = null
+ authToken = null
+ Log.d(TAG, "Session cleared")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/azuresamples/msalnativeauthandroidkotlinsampleapp/StrongAuthVerificationContactFragment.kt b/app/src/main/java/com/azuresamples/msalnativeauthandroidkotlinsampleapp/StrongAuthVerificationContactFragment.kt
index 6a86125..856b11a 100644
--- a/app/src/main/java/com/azuresamples/msalnativeauthandroidkotlinsampleapp/StrongAuthVerificationContactFragment.kt
+++ b/app/src/main/java/com/azuresamples/msalnativeauthandroidkotlinsampleapp/StrongAuthVerificationContactFragment.kt
@@ -55,20 +55,70 @@ class StrongAuthVerificationContactFragment : Fragment() {
private fun verifyContact() {
CoroutineScope(Dispatchers.Main).launch {
- var optionalEmail = binding.emailText.text.toString()
- if (optionalEmail.isBlank()) {
- optionalEmail = authMethod.loginHint.orEmpty()
- }
- val params = NativeAuthChallengeAuthMethodParameters(authMethod, optionalEmail)
- val actionResult = currentState.challengeAuthMethod(params)
+ binding.verifyCode.isEnabled = false
+
+ try {
+ var contactValue = binding.emailText.text.toString().trim()
+ if (contactValue.isBlank()) {
+ contactValue = authMethod.loginHint.orEmpty().trim()
+ }
- when (actionResult) {
- is RegisterStrongAuthChallengeResult.VerificationRequired -> {
- navigateToStrongAuthChallengeFragment(actionResult.result.getNextState(), actionResult.result.getChannel(), actionResult.result.getSentTo())
+ if (contactValue.isBlank()) {
+ displayDialog(getString(R.string.unexpected_sdk_error_title), "A phone number is required for SMS verification")
+ return@launch
}
- is RegisterStrongAuthChallengeError -> {
- handleRegisterStrongAuthChallengeError(actionResult)
+
+ // CHANGE (Prove doc summary #2): for SMS, run Prove customer-supplied possession verification
+ // BEFORE triggering Microsoft NativeAuth to send the SMS OTP.
+ if (authMethod.challengeChannel.equals("SMS", ignoreCase = true)) {
+ val proveManager = ProveManager.getInstance()
+
+ when (val startResult = proveManager.initializeWithAuthToken(phoneNumber = contactValue)) {
+ is ProveManager.ProveResult.Success -> {
+ when (val possessionResult = proveManager.checkPossession(startResult.correlationId)) {
+ is ProveManager.ProveResult.Success -> {
+ displayDialog(
+ getString(R.string.yes_message),
+ possessionResult.phoneVerified.toString()
+ )
+ // Continue to Microsoft SMS OTP (below)
+ }
+ is ProveManager.ProveResult.Error -> {
+ displayDialog(
+ getString(R.string.unexpected_sdk_error_title),
+ possessionResult.message
+ )
+ return@launch
+ }
+ }
+ }
+ is ProveManager.ProveResult.Error -> {
+ displayDialog(
+ getString(R.string.unexpected_sdk_error_title),
+ startResult.message
+ )
+ return@launch
+ }
+ }
+ }
+
+ val params = NativeAuthChallengeAuthMethodParameters(authMethod, contactValue)
+ val actionResult = currentState.challengeAuthMethod(params)
+
+ when (actionResult) {
+ is RegisterStrongAuthChallengeResult.VerificationRequired -> {
+ navigateToStrongAuthChallengeFragment(
+ actionResult.result.getNextState(),
+ actionResult.result.getChannel(),
+ actionResult.result.getSentTo()
+ )
+ }
+ is RegisterStrongAuthChallengeError -> {
+ handleRegisterStrongAuthChallengeError(actionResult)
+ }
}
+ } finally {
+ binding.verifyCode.isEnabled = true
}
}
}
diff --git a/app/src/main/res/raw/auth_config_native_auth.json b/app/src/main/res/raw/auth_config_native_auth.json
index 3c0f378..d8505aa 100644
--- a/app/src/main/res/raw/auth_config_native_auth.json
+++ b/app/src/main/res/raw/auth_config_native_auth.json
@@ -1,13 +1,14 @@
{
- "client_id": "Enter_the_Application_Id_Here",
+ "client_id": "019f8c18-e680-43b3-9ac3-d9e118b69c0d",
"authorities": [
{
"type": "CIAM",
- "authority_url": "https://Enter_the_Tenant_Subdomain_Here.ciamlogin.com/Enter_the_Tenant_Subdomain_Here.onmicrosoft.com/"
+ "authority_url": "https://nativetesting.online/628ff5bc-c796-4a6c-930e-f1ed859b77bc/"
}
],
"challenge_types": ["oob", "password"],
"capabilities": ["mfa_required", "registration_required"],
+ "dc": "ESTS-PUB-WEULR1-FD000-TEST1-100",
"logging": {
"pii_enabled": false,
"log_level": "INFO",
diff --git a/auto-config.json b/auto-config.json
index a53a508..f919f11 100644
--- a/auto-config.json
+++ b/auto-config.json
@@ -4,6 +4,12 @@
"Level": 200,
"Client": "Android"
},
+
+ "Prove": {
+ "clientId": "",
+ "clientSecret": ""
+ },
+
"AppRegistrations": [
{
"x-ms-id": "ciam-android-native-app",
@@ -33,8 +39,8 @@
{
"settingFile": "app/src/main/res/raw/auth_config_native_auth.json",
"replaceTokens": {
- "appId": "Enter_the_Application_Id_Here",
- "tenantName": "Enter_the_Tenant_Subdomain_Here"
+ "appId": "019f8c18-e680-43b3-9ac3-d9e118b69c0d",
+ "tenantName": "628ff5bc-c796-4a6c-930e-f1ed859b77bc"
}
}
]
diff --git a/build.gradle b/build.gradle
index 18c4ea8..bfdc246 100644
--- a/build.gradle
+++ b/build.gradle
@@ -6,6 +6,11 @@ buildscript {
repositories {
google()
jcenter()
+
+ mavenCentral()
+ maven {
+ url = "https://prove.jfrog.io/artifactory/libs-public-maven/"
+ }
}
dependencies {
classpath "com.android.tools.build:gradle:$rootProject.ext.gradleVersion"
@@ -15,14 +20,6 @@ buildscript {
}
}
-allprojects {
- repositories {
- google()
- jcenter()
-
- }
-}
-
task clean(type: Delete) {
delete rootProject.buildDir
}
diff --git a/gradle/versions.gradle b/gradle/versions.gradle
index 717ded2..886d40a 100644
--- a/gradle/versions.gradle
+++ b/gradle/versions.gradle
@@ -7,8 +7,8 @@ ext {
compileSdkVersion = 34
buildToolsVersion = "28.0.3"
coreKtxVersion = "1.6.0"
- kotlinXCoroutinesVersion = "1.6.4"
- kotlinVersion = '1.7.21'
+ kotlinXCoroutinesVersion = "1.9.0"
+ kotlinVersion = '2.1.0'
// Plugins
gradleVersion = '8.11.1'