Skip to content

Latest commit

 

History

History
625 lines (529 loc) · 28 KB

File metadata and controls

625 lines (529 loc) · 28 KB

IPification for Android

This document describes the IPification Android SDK and its usage. The main purpose of the SDK is to provide network-based authentication for mobile users.

1 . Android Requirements & Permissions

Item Details
Minimum OS Android 5.0 (API 21) or higher
Device prerequisite Mobile / cellular data must be enabled.
All IPification requests (and redirects) are forced over the cellular interface.
Required manifest permissions INTERNET, ACCESS_NETWORK_STATE, CHANGE_NETWORK_STATE, ACCESS_WIFI_STATE
Networking library IPification ships with OkHttp 5 (socket binding + custom DNS).

2. Cleartext HTTP support

This configuration is required only for the following markets and providers:

Market Provider or platform Cleartext-enabled domains
Indonesia Tri, XL, Smartfren ipification.com, xl.co.id, smartfren.com
Canada EnStream enstreamidentity.com
Mexico Telcel ipification.com
United Kingdom O2 (SmartDigits/TRUID), Vodafone smartdigits.io, vodafone.com
Argentina OpenXpand openxpand.com
Sri Lanka Ideabiz ideabiz.lk
India Vodafone Idea, Airtel, Jio ipification.com, airtel.in, jio.com, jiolabs.com
Malaysia CelcomDigi celcomdigi.com

To authenticate with these providers, enable cleartext traffic only for the listed domains. Use the maintained ipification_network_security_config.xml as the source of truth.

3 . Retrieving the User’s Phone Number (Optional)

Need the MSISDN for coverage checking? Use Android’s Phone Number Hint API as shown in our snippet:

Android Phone Number Hint guide

4. Main Flow of Mobile SDK :

  1. Check Coverage
  • Call the Coverage API with the client's phone number (GET) through the Cellular Network .
  • Receive a response with: is_available- true: supported | false: not supported
  1. Start Authentication
  • Prepare the authorization request with required parameters
  • Call Authorization API with authorization request ( GET ) through Cellular Network.
  • Receive a code response with:
    • result directly via redirect_uri (1) or
    • redirection url (301 or 302) (2)
  • (1) -> Parser the response then return the result to client
  • (2) -> Perform all url(s) redirection until receive the result with redirect_uri (through Cellular Network)

Note: The mobile Coverage, Authorization, and required redirect requests must use the cellular interface. The server-to-server token exchange is performed by your backend and does not use the device's cellular connection.

  1. Complete verification through your backend (server to server)
  • The final redirect_uri contains an authorization code and the returned state.
  • Before accepting the result, verify that the returned state matches the value created when the authentication flow started. Reject mismatched or missing state values.
  • Send the short-lived authorization code from the mobile app to your own backend over HTTPS. Include the same redirect_uri and any internal transaction/session identifier your backend needs to correlate the request.
  • Your backend sends the code to the configured IPification token endpoint using the confidential client credentials supplied during onboarding and the same redirect_uri used in the Authorization request.
  • Your backend validates the token response and verification result, then creates or updates the application's authenticated session and returns only the required result to the app.

Security requirements:

  • Never embed the client secret in the Android application or perform the confidential token exchange directly from the device.
  • Treat the authorization code as single-use and short-lived. Do not log, cache, or persist it.
  • Do not log tokens or return confidential client credentials to the mobile app.

Authorization Request (HTTP)

GET https://{api-server}/auth/realms/ipification/protocol/openid-connect/auth?
response_type=code&
client_id={client-id}&
redirect_uri={client-callback-uri}&
scope=openid ip:phone_verify&
state={state}&
login_hint={login_hint}
-------
Response:
(1) 200 - redirect_uri?code=abcxyz&state={state}
(2) 302 - url redirection (ex: https://mnv.telco.com/webhook/api/webhook/auth?state=xyzabc)
Parameters:
Name Description
api-server API host for the configured environment or client deployment.
client_id unique identifier of the client that is generated by IPification and provided to client during the onboarding process.
redirect_uri is used when redirecting a user back to the client application. During the onboarding process, the client's redirect_uri will be provided, this value can represent wildcard uri and will be used to validate provided redirect_uri in the request. Redirect URI must be same accross all requestst in a flow.
scope use openid ip:phone_verify for phone number verifying
login_hint end-user phone number (MSISDN).Phone number should be specified according to the E.164 number formatting (http://en.wikipedia.org/wiki/E.164) without leading + sign.
consent_id (optional) Unique ID for the consent that is traceable if consent audit is required. Value will be provided if needed in integration process.
consent_timestamp (optional) The time stamp when consent was accepted by end user. Accepted format is UNIX time stamp in seconds.
mcc (optional) Mobile Country Code
mnc (optional) Mobile Network Code

5. Android code snippets

Choose the routing scope that matches the integration:

Option Routing scope Cleanup
5.1 Force cellular for each request Only the IPification HTTP client/request uses cellular. Recommended. Keep the NetworkCallback registered for the complete cellular sequence, then unregister it after the final required cellular request finishes.
5.2 Force cellular for the whole app process All future sockets and DNS lookups in the app process use cellular. Android API 23+. After all requests finish, restore the default network and unregister the shared NetworkCallback.

5.1 Force cellular for each request

Request a cellular Network and bind only the IPification OkHttp client to its socket factory and DNS resolver. Other application traffic continues using Android's default network. Keep the same cellular network request active while required requests and redirects are still in progress.

import android.annotation.TargetApi

import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import android.util.Log
import com.ipification.mobile.sdk.android.interceptor.HandleRedirectInterceptor
import com.ipification.mobile.sdk.android.request.AuthRequest
import com.ipification.mobile.sdk.android.utils.NetworkUtils
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.IOException
import java.util.*
import java.util.concurrent.atomic.AtomicBoolean

// Manifest.xml: required permission:  INTERNET, ACCESS_WIFI_STATE, ACCESS_NETWORK_STATE, CHANGE_NETWORK_STATE, android:usesCleartextTraffic="true"
// external library: OkHttp : com.squareup.okhttp3:okhttp 5.x

class CellularConnection {

    companion object {
        private const val TAG = "CellularConnection"
    }

    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
    fun performRequest(
        context: Context,
        authRequest: AuthRequest,
        onSuccess: (responseBody: String) -> Unit,
        onFailure: (error: Throwable) -> Unit,
    ) {
        // wifi is OFF, DATA is ON -> request with current network interface
        if (NetworkUtils.isMobileDataEnabled(context) && !NetworkUtils.isWifiEnabled(context)) {
            processRequest(
                context,
                null,
                authRequest,
                onSuccess = onSuccess,
                onFailure = { error -> onFailure(error) },
            )
        } else {
            requestCellularNetwork(context, authRequest, onSuccess, onFailure)
        }
    }

    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
    private fun requestCellularNetwork(
        context: Context,
        authRequest: AuthRequest,
        onSuccess: (responseBody: String) -> Unit,
        onFailure: (error: Throwable) -> Unit,
    ) {
        // 1. force network connection via cellular interface
        // If your app supports Android 21+, you need to implement handling timeout manually.
        // Android 21++ support requestNetwork (NetworkRequest request,
        //                ConnectivityManager.NetworkCallback networkCallback)
        // Android 26++ support requestNetwork(NetworkRequest request,
        //                ConnectivityManager.NetworkCallback networkCallback,
        //                int timeoutMs)
        // https://developer.android.com/reference/android/net/ConnectivityManager#requestNetwork(android.net.NetworkRequest,%20android.net.ConnectivityManager.NetworkCallback)

        val connectivityManager =
            context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
        val request = NetworkRequest.Builder()
            .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
            .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET).build()

        val requestStarted = AtomicBoolean(false)
        val isCompleted = AtomicBoolean(false)
        var timeoutTimer: Timer? = null
        lateinit var networkCallback: ConnectivityManager.NetworkCallback

        // Every terminal path supplies a Result here. This function cancels the timeout,
        // unregisters the requested cellular Network, and delivers one public callback.
        // compareAndSet prevents duplicate cleanup if timeout and HTTP callbacks race.
        fun finishCellularRequest(result: Result<String>) {
            if (!isCompleted.compareAndSet(false, true)) return

            timeoutTimer?.cancel()
            try {
                connectivityManager.unregisterNetworkCallback(networkCallback)
            } catch (_: IllegalArgumentException) {
                // Android may already have released a timed-out network request.
            }

            result.exceptionOrNull()?.let { error ->
                Log.e(TAG, "Cellular sequence failed", error)
            }
            result.fold(
                onSuccess = onSuccess,
                onFailure = onFailure,
            )
        }

        networkCallback = object : ConnectivityManager.NetworkCallback() {
            override fun onAvailable(network: Network) {
                // Ignore duplicate availability callbacks for this cellular sequence.
                if (!requestStarted.compareAndSet(false, true)) return

                processRequest(
                    context,
                    network,
                    authRequest,
                    onSuccess = { responseBody ->
                        // OkHttp has followed all required redirects. Cleanup, then forward the
                        // response so the caller can validate `state` and extract `code`.
                        finishCellularRequest(Result.success(responseBody))
                    },
                    onFailure = { error ->
                        finishCellularRequest(Result.failure(error))
                    },
                )
            }

            override fun onUnavailable() {
                finishCellularRequest(
                    Result.failure(IOException("Cellular network is unavailable"))
                )
            }
        }

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            connectivityManager.requestNetwork(request, networkCallback, 5000)
        } else {
            timeoutTimer = Timer("cellular-network-timeout", true).apply {
                schedule(object : TimerTask() {
                    override fun run() {
                        finishCellularRequest(
                            Result.failure(IOException("Timed out waiting for cellular network"))
                        )
                    }
                }, 5000)
            }
            connectivityManager.requestNetwork(request, networkCallback)
        }
    }

    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
    private fun processRequest(
        context: Context,
        network: Network?,
        authRequest: AuthRequest,
        onSuccess: (responseBody: String) -> Unit,
        onFailure: (error: IOException) -> Unit,
    ) {
         // using OkHTTP library to make the connection
         val httpBuilder =OkHttpClient.Builder()
         if (network != null) {
            // enable socket for network
            httpBuilder.socketFactory(network.socketFactory)

            // Add network-specific DNS resolution when a cellular Network is selected.
            val dns = NetworkDns.instance
            dns.setNetwork(network)
            httpBuilder.dns(dns)
         }

         // Keep intermediate HTTP redirects on this cellular-bound OkHttp client.
         // HandleRedirectInterceptor allows normal telco redirects to continue, but when a
         // Location header starts with the registered redirect_uri, it treats that URL as the
         // terminal callback and returns it in the response body. The caller can then parse
         // the authorization code and state without opening the redirect URI in a browser.
         httpBuilder.addNetworkInterceptor(
            HandleRedirectInterceptor(authRequest.mRedirectUri.toString())
         )

         // Optional Singtel compatibility: Singtel may inspect or reject requests containing a
         // User-Agent header. Enable this only when required for the Singtel integration.
         // A network interceptor is required because OkHttp may add its default User-Agent
         // after application interceptors have already run.
         //
         // httpBuilder.addNetworkInterceptor { chain ->
         //     val requestWithoutUserAgent = chain.request()
         //         .newBuilder()
         //         .removeHeader("User-Agent")
         //         .build()
         //     chain.proceed(requestWithoutUserAgent)
         // }


         val httpClient = httpBuilder.build()

         val okHttpRequestBuilder = Request.Builder()
         //url
         okHttpRequestBuilder.url(authRequest.getUrl())

         val okHttpRequest: Request = okHttpRequestBuilder
            .build()
         httpClient.newCall(okHttpRequest).enqueue(object : Callback {
            override fun onResponse(call: Call, response: Response) {
                // Runs asynchronously on an OkHttp dispatcher thread, not the main thread.
                // This callback is delivered after OkHttp follows intermediate redirects. If
                // HandleRedirectInterceptor detects the registered redirect_uri, the response
                // body contains that terminal callback URL for parsing `code` and `state`.
                try {
                    // body.string() consumes the response body and can be called only once.
                    // Parse/store the value here, then post UI updates to the main thread.
                    val responseBody = response.body?.string().orEmpty()

                    // OkHttp delivers HTTP error status codes here as well. Report only 2xx
                    // responses as success; transport and HTTP failures share onFailure.
                    if (response.isSuccessful) {
                        // The caller handles parsing, state validation, and cellular cleanup.
                        // Avoid logging the authorization response in production.
                        onSuccess(responseBody)
                    } else {
                        onFailure(IOException("HTTP request failed with ${response.code}"))
                    }
                } finally {
                    // Always close the response to release the socket back to OkHttp.
                    response.close()
                }
            }

            override fun onFailure(call: Call, e: IOException) {
                // Called for transport failures, cancellation, DNS errors, or timeouts; HTTP
                // error status codes are still delivered to onResponse(). This also runs on an
                // OkHttp dispatcher thread, so post any UI work to the main thread.
                // The caller decides whether to retry on the same cellular Network, fall back,
                // or complete the sequence. Do not expose the raw exception to users.
                onFailure(e)
            }
         })

    }
}
--------------------------
NetworkDns.kt
Use this class to resolve the hostname through the selected cellular network when Wi-Fi is also active.
--------------------------
import android.net.Network
import android.os.Build
import android.os.Build.VERSION_CODES
import okhttp3.Dns
import java.net.InetAddress
import java.net.UnknownHostException


class NetworkDns private constructor() : Dns {
   private var mNetwork: Network? = null
   fun setNetwork(network: Network?) {
       mNetwork = network
   }

   @Throws(UnknownHostException::class)
   override fun lookup(hostname: String): List<InetAddress> {
       return if (mNetwork != null && Build.VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
           try {
                // Preserve the DNS resolver's original IPv4/IPv6 address order.
                mNetwork!!.getAllByName(hostname).toList()
            } catch (ex: NullPointerException) {
                try {
                    Dns.SYSTEM.lookup(hostname)
                } catch (e: UnknownHostException) {
                    InetAddress.getAllByName(hostname).toList()
                }
            } catch (ex: UnknownHostException) {
                try {
                    Dns.SYSTEM.lookup(hostname)
                } catch (e: UnknownHostException) {
                    InetAddress.getAllByName(hostname).toList()
                }
            }
       } else Dns.SYSTEM.lookup(hostname)
   }

   companion object {
       private var sInstance: NetworkDns? = null
       val instance: NetworkDns
           get() {
               if (sInstance == null) {
                   sInstance = NetworkDns()
               }
               return sInstance!!
           }
   }
}

HandleRedirectInterceptor.kt

OkHttp normally follows HTTP redirects automatically. Add this as a network interceptor so it can inspect each redirect response before OkHttp follows it. Intermediate telco redirects are returned unchanged and continue normally. A redirect matching the registered client redirect_uri is captured as the terminal authentication result.

import android.net.Uri
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody

class HandleRedirectInterceptor(redirectUri: String) : Interceptor {
    private val expectedRedirectUri = Uri.parse(redirectUri).also { uri ->
        require(!uri.scheme.isNullOrBlank()) { "redirectUri must include a URI scheme" }
    }

    override fun intercept(chain: Interceptor.Chain): Response {
        val response = chain.proceed(chain.request())

        // OkHttp header lookup is case-insensitive, so one lookup handles Location/location.
        val location = response.header("Location")
        if (!response.isRedirect || location == null || !isTerminalRedirect(location)) {
            // Keep intermediate redirects unchanged so OkHttp can follow them using the same
            // cellular-bound client, socket factory, DNS resolver, cookies, and headers.
            return response
        }

        // Replace only the terminal redirect body and preserve the original request, protocol,
        // headers, timestamps, TLS handshake, and other response metadata.
        val terminalResponse = response.newBuilder()
            .code(200)
            .message("success")
            .removeHeader("Location")
            .body(location.toResponseBody("text/plain; charset=utf-8".toMediaType()))
            .build()

        // The original body is no longer returned and must be closed to avoid leaking a socket.
        response.close()
        return terminalResponse
    }

    private fun isTerminalRedirect(location: String): Boolean {
        val actualUri = Uri.parse(location)

        // Compare URI components instead of using startsWith(), which can incorrectly accept
        // a different callback such as "myapp://callback.attacker". Query parameters are not
        // compared because the terminal URI adds dynamic values such as `code` and `state`.
        if (actualUri.isOpaque || expectedRedirectUri.isOpaque) {
            return actualUri.isOpaque == expectedRedirectUri.isOpaque &&
                actualUri.scheme.equals(expectedRedirectUri.scheme, ignoreCase = true) &&
                actualUri.schemeSpecificPart.substringBefore('?') ==
                    expectedRedirectUri.schemeSpecificPart.substringBefore('?')
        }

        return actualUri.scheme.equals(expectedRedirectUri.scheme, ignoreCase = true) &&
            actualUri.authority.equals(expectedRedirectUri.authority, ignoreCase = true) &&
            actualUri.path.orEmpty() == expectedRedirectUri.path.orEmpty()
    }
}

Register it with addNetworkInterceptor(), as shown above. After receiving the captured URL, parse its query parameters and verify that state matches the value generated at the start of the flow before accepting the authorization code. If onboarding permits wildcard redirect URIs, replace isTerminalRedirect() with an explicit matcher for the approved wildcard pattern; do not fall back to unrestricted string-prefix matching.

Singtel compatibility — optional User-Agent removal: Singtel may inspect the User-Agent header and block requests that include it. If IPification confirms this behavior for the Singtel integration, use the network-interceptor example above to remove the header from every request and redirect in the cellular flow. Do not remove it globally or by default, because unrelated endpoints and SDKs may rely on normal HTTP headers. Test this behavior on the Singtel network before release.

More detail: IPificationService.kt

After the final required cellular request reaches a terminal success or failure callback, call ConnectivityManager.unregisterNetworkCallback() with the same callback passed to requestNetwork(). Do not unregister between redirects or while another required cellular request still needs the selected network. In the sample, finishCellularRequest() is the only terminal result and cleanup path. Its AtomicBoolean.compareAndSet(false, true) guard ensures that timeout, unavailability, success, and failure cannot publish a result or unregister the network more than once. Cellular acquisition failures—including onUnavailable() and the manual timeout—are delivered through the same public onFailure callback as HTTP transport failures.

5.2 Force cellular for the whole app, then unregister after all requests

Use this option only when every new network connection created by the application process must use cellular. Call bind() once before starting the batch. Create new HTTP clients and connections only after onBound is called.

Core functions: force cellular and restore normal routing

These are the two essential operations used by the complete helper below:

// Call only after requestNetwork() returns a cellular Network in onAvailable().
private fun forceWholeAppToCellular(cellularNetwork: Network): Boolean {
    // Core forcing call: all NEW sockets and DNS lookups created by this app process
    // use the supplied cellular network. Existing connections do not move.
    return connectivityManager.bindProcessToNetwork(cellularNetwork)
}

// Call after the FINAL required cellular request succeeds, fails, or is cancelled.
private fun stopForcingWholeAppToCellular(
    networkCallback: ConnectivityManager.NetworkCallback,
) {
    // Restore Android's normal default-network selection, usually Wi-Fi when available.
    connectivityManager.bindProcessToNetwork(null)

    // Release the cellular Network requested for this batch.
    try {
        connectivityManager.unregisterNetworkCallback(networkCallback)
    } catch (_: IllegalArgumentException) {
        // The callback may already have been released after timeout or unavailability.
    }
}

The line that forces the whole app process to cellular is connectivityManager.bindProcessToNetwork(cellularNetwork). It affects new connections created after the call succeeds. To stop forcing cellular, first call bindProcessToNetwork(null), then unregister the same NetworkCallback originally passed to requestNetwork().

We provide a sample helper named ProcessCellularBinding. Copy this class into the client application. It requests the cellular network, binds the app process, handles the shared NetworkCallback, and restores normal routing during cleanup. The client only needs to call its bind() and unbind() functions:

  1. Call bind() and wait for onBound.
  2. Create a new HTTP client and perform all required cellular requests.
  3. After the final success, failure, or cancellation, stop the client and call unbind().
  4. New connections can then use Android's normal default network again.
private val cellularBinding by lazy {
    ProcessCellularBinding(applicationContext)
}
private val cellularBatchStarted = AtomicBoolean(false)

fun performAllRequestsOnCellular() {
    cellularBinding.bind(
        timeoutMillis = 10_000,
        onBound = {
            // onBound may run again if Android replaces a lost cellular network.
            if (!cellularBatchStarted.compareAndSet(false, true)) return@bind

            // Create clients after the process is bound. Existing sockets do not move.
            val httpClient = OkHttpClient.Builder().build()

            performCoverageRequest(httpClient) { coverageResult ->
                if (coverageResult.isFailure) {
                    finishCellularWork()
                    return@performCoverageRequest
                }

                performAuthenticationRequest(httpClient) {
                    // All requests in this cellular-only batch are now complete.
                    finishCellularWork()
                }
            }
        },
        onUnavailable = {
            // The helper has already released its network request.
            cellularBatchStarted.set(false)
            handleCellularUnavailable()
        },
        onLost = {
            // Pause or fail pending work while Android looks for a replacement network.
        },
    )
}

private fun finishCellularWork() {
    // Restores normal routing with bindProcessToNetwork(null), then calls
    // unregisterNetworkCallback() for the shared cellular network request.
    cellularBinding.unbind()
    cellularBatchStarted.set(false)
}

A runnable project is available at samples/android/process-binding-ip-check.

License

Copyright 2022 IPification, Inc.

Licensed to the Apache Software Foundation (ASF) under one or more contributor
license agreements. See the NOTICE file distributed with this work for
additional information regarding copyright ownership. The ASF licenses this
file to you under the Apache License, Version 2.0 (the "License"); you may not
use this file except in compliance with the License. You may obtain a copy of
the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations under
the License.