Skip to content
Open
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
77 changes: 45 additions & 32 deletions docs/ip/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -962,28 +962,36 @@ IPificationServices.startAuthentication(

#### 3.2.1 Custom parameters for your SMS backend

If your `/sms/auth` or `/sms/token` contract needs partner-specific values (for example a tenant or routing id), declare them on the SMS channel. `addAuthParam` values are added to the `/sms/auth` body; `addTokenParam` values and headers are captured in the `SMSAuthResponse` and reused automatically by `verifySMSOTP()`.
All SMS custom values are optional. Add them only when your `/sms/auth` or `/sms/token` contract requires partner-specific fields.

| Parameter | Required | Description |
| --- | --- | --- |
| SMS auth params | Optional | Sent in the `/sms/auth` body. Add only when required by your backend auth contract. |
| SMS token params | Optional | Sent in the `/sms/token` body. Captured in the `SMSAuthResponse` and reused automatically by `verifySMSOTP()`. |
| SMS headers | Optional | Added to both SMS backend requests. |

<!-- tabs:start -->

#### **Kotlin**

```kotlin
// Optional, only if required by your backend:
authRequestBuilder.sms {
addAuthParam("server_id", serverId) // -> /sms/auth body
addTokenParam("server_id", serverId) // -> /sms/token body
addHeader("X-Tenant", tenantId) // -> both requests
addAuthParam("custom_auth_param", "value") // -> /sms/auth body
addTokenParam("custom_token_param", "value") // -> /sms/token body
addHeader("X-Custom-Header", "value") // -> both requests
}
```

#### **Java**

```java
// Optional, only if required by your backend:
authRequestBuilder.setSMSOptions(
new SMSChannelOptions.Builder()
.addAuthParam("server_id", serverId)
.addTokenParam("server_id", serverId)
.addHeader("X-Tenant", tenantId)
.addAuthParam("custom_auth_param", "value") // -> /sms/auth body
.addTokenParam("custom_token_param", "value") // -> /sms/token body
.addHeader("X-Custom-Header", "value") // -> both requests
.build()
);
```
Expand Down Expand Up @@ -1245,6 +1253,8 @@ Each channel talks to a different backend contract, so partner-specific values a
| `ts43 { ... }` / `setTS43Options` | `/ts43/auth` body | `/ts43/token` body | both TS43 requests |
| `sms { ... }` / `setSMSOptions` | `/sms/auth` body | `/sms/token` body (reused automatically by `verifySMSOTP`) | both SMS requests |

All channel options are optional. Configure a channel only when the backend behind that channel requires extra fields; a channel with no options behaves exactly as before.

Keys owned by the SDK (`client_id`, `login_hint`, `scope`, `code`, `auth_req_id`, `nonce`, `vp_token`, ...) are reserved: adding one throws `IllegalArgumentException` so the mistake is caught during development. Options configured for a channel that is not in `AUTH_CHANNELS` are ignored (a debug log line is written).

<!-- tabs:start -->
Expand All @@ -1255,45 +1265,48 @@ Keys owned by the SDK (`client_id`, `login_hint`, `scope`, `code`, `auth_req_id`
val authRequest = AuthRequest.Builder()
.setScope("openid ip:phone_verify")
.addQueryParam("login_hint", country_code + user_input_phone_number)
// Optional, only if required by your backend:
.ts43 {
addAuthParam("server_id", serverId)
addTokenParam("server_id", serverId)
setScope("openid ip:phone") // optional TS43-only scope override
addAuthParam("custom_auth_param", "value") // -> /ts43/auth body
addTokenParam("custom_token_param", "value") // -> /ts43/token body
setScope("openid ip:phone") // optional TS43-only scope override
}
.ip {
addAuthParam("consent_id", consentId) // IP authorization request only
addTokenParam("server_id", serverId) // IP_TOKEN_URL body only
addAuthParam("custom_auth_param", "value") // -> IP authorization request query string
addTokenParam("custom_token_param", "value") // -> IP_TOKEN_URL body
}
.sms {
addAuthParam("server_id", serverId)
addAuthParam("locale", "vi")
addTokenParam("server_id", serverId)
addAuthParam("custom_auth_param", "value") // -> /sms/auth body
addTokenParam("custom_token_param", "value") // -> /sms/token body
}
.build()

// Same value for every channel? Say so explicitly:
// .forAllChannels { addAuthParam("server_id", serverId) }
// .forAllChannels { addAuthParam("custom_auth_param", "value") }
```

#### **Java**

```java
AuthRequest authRequest = new AuthRequest.Builder()
.setScope("openid ip:phone_verify")
.setTS43Options(new TS43ChannelOptions.Builder()
.addAuthParam("server_id", serverId)
.addTokenParam("server_id", serverId)
.build())
.setIPOptions(new IPChannelOptions.Builder()
.addAuthParam("consent_id", consentId)
.addTokenParam("server_id", serverId)
.build())
.setSMSOptions(new SMSChannelOptions.Builder()
.addAuthParam("server_id", serverId)
.addTokenParam("server_id", serverId)
.build())
.build();
// Then call addQueryParam("login_hint", ...) on the builder before build(), as in the examples above.
AuthRequest.Builder authRequestBuilder = new AuthRequest.Builder();
authRequestBuilder.setScope("openid ip:phone_verify");
authRequestBuilder.addQueryParam("login_hint", country_code + user_input_phone_number);

// Optional, only if required by your backend:
authRequestBuilder.setTS43Options(new TS43ChannelOptions.Builder()
.addAuthParam("custom_auth_param", "value") // -> /ts43/auth body
.addTokenParam("custom_token_param", "value") // -> /ts43/token body
.build());
authRequestBuilder.setIPOptions(new IPChannelOptions.Builder()
.addAuthParam("custom_auth_param", "value") // -> IP authorization request query string
.addTokenParam("custom_token_param", "value") // -> IP_TOKEN_URL body
.build());
authRequestBuilder.setSMSOptions(new SMSChannelOptions.Builder()
.addAuthParam("custom_auth_param", "value") // -> /sms/auth body
.addTokenParam("custom_token_param", "value") // -> /sms/token body
.build());

AuthRequest authRequest = authRequestBuilder.build();
```

<!-- tabs:end -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ internal object IPHeaders {
/** Android API level. */
const val OS_API_LEVEL = "os-sdk"

/** Whether the device shows signs of being rooted. Value is `yes` or `no`. */
const val ROOTED = "rooted"

// First SIM headers

/** Mobile country code reported by SIM slot 1. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.ipification.mobile.sdk.ip.utils.DeviceUtils
import com.ipification.mobile.sdk.ip.utils.IPLogs
import com.ipification.mobile.sdk.ip.utils.LogUtils
import com.ipification.mobile.sdk.ip.utils.NetworkUtils
import com.ipification.mobile.sdk.ip.utils.RootUtils
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
Expand Down Expand Up @@ -56,6 +57,7 @@ class SdkHeadersInterceptor(
.addHeader(IPHeaders.DEVICE_NAME, "${Build.MANUFACTURER} - ${Build.MODEL}")
.addHeader(IPHeaders.OS_VERSION, Build.VERSION.RELEASE)
.addHeader(IPHeaders.OS_API_LEVEL, Build.VERSION.SDK_INT.toString())
.addHeader(IPHeaders.ROOTED, RootUtils.rootedHeaderValue())

if (includeCarrierHeaders) {
addCarrierHeaders(requestBuilder)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ class DeviceUtils private constructor(context: Context) {
.appendLine("WIFI: ${enabledState(NetworkUtils.isWifiEnabled(context))}")
.appendLine("VPN: ${enabledState(NetworkUtils.isVpnEnabled(context))}")
.appendLine("ROAMING: ${enabledState(NetworkUtils.isRoaming(context))}")
.appendLine("ROOTED: ${RootUtils.rootedHeaderValue()}")
.appendLine("-------------------------------------")
.appendLine("DEVICE NAME: ${Build.MANUFACTURER} - ${Build.MODEL}")
.appendLine("OS VERSION: ${Build.VERSION.RELEASE} - ${Build.VERSION.SDK_INT}")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package com.ipification.mobile.sdk.ip.utils

import android.os.Build
import java.io.File

/**
* Heuristic root detection used for SDK diagnostics and risk signals.
*
* The result is a best-effort indication, not a security guarantee: a determined user can hide root
* from every check below. It is intended to help IPification and partners spot risky sessions, and
* must never be used on its own to grant or deny authentication.
*
* Detection is intentionally cheap and side-effect free: it inspects build tags and well-known root
* artifacts on disk, and never spawns a process. The result is computed once per process and cached,
* so it can be read from request paths without measurable cost.
*/
internal object RootUtils {

/** Build tag left by non-production (typically rooted or self-signed) system images. */
private const val TEST_KEYS_TAG = "test-keys"

/** Common locations of the `su` binary on rooted devices. */
private val SU_BINARY_PATHS = arrayOf(
"/sbin/su",
"/system/bin/su",
"/system/xbin/su",
"/system/sbin/su",
"/vendor/bin/su",
"/su/bin/su",
"/data/local/su",
"/data/local/bin/su",
"/data/local/xbin/su"
)

/** Files installed by common root managers. */
private val ROOT_MANAGER_PATHS = arrayOf(
"/system/app/Superuser.apk",
"/system/app/SuperSU.apk",
"/system/app/Magisk.apk",
"/sbin/magisk",
"/sbin/.magisk",
"/data/adb/magisk",
"/data/adb/ksu",
"/system/xbin/daemonsu",
"/system/etc/init.d/99SuperSUDaemon",
"/dev/com.koushikdutta.superuser.daemon"
)

@Volatile
private var cachedResult: Boolean? = null

/**
* Returns whether the device shows signs of being rooted.
*
* The first call performs the checks and caches the outcome for the lifetime of the process.
*/
@JvmStatic
fun isDeviceRooted(): Boolean {
cachedResult?.let { return it }
synchronized(this) {
cachedResult?.let { return it }
val result = runCatching { detect() }
.onFailure { LogUtils.debug("Root detection failed: ${it.message}") }
.getOrDefault(false)
cachedResult = result
return result
}
}

/** Returns `yes` or `no`, for request headers and diagnostic logs. */
@JvmStatic
fun rootedHeaderValue(): String = if (isDeviceRooted()) "yes" else "no"

/** Clears the cached result. Intended for tests. */
internal fun reset() {
synchronized(this) { cachedResult = null }
}

private fun detect(): Boolean {
return hasTestKeysBuild() || hasAnyFile(SU_BINARY_PATHS) || hasAnyFile(ROOT_MANAGER_PATHS)
}

/** Checks whether the system image was signed with test keys. */
private fun hasTestKeysBuild(): Boolean {
return Build.TAGS?.contains(TEST_KEYS_TAG) == true
}

/** Checks whether any of the given paths exists, ignoring paths the app may not stat. */
private fun hasAnyFile(paths: Array<String>): Boolean {
return paths.any { path ->
runCatching { File(path).exists() }.getOrDefault(false)
}
}
}