android-app/app/how_to_use_Auth.md

30 KiB

Farm Auth Service - API Integration Guide

Complete integration guide for Farm Auth Service with Kotlin Multiplatform Mobile (KMM) or Kotlin Native applications.

Base URL

http://localhost:3000  (development)
https://your-domain.com (production)

Table of Contents


Authentication Flow

  1. Request OTP → User enters phone number
  2. Verify OTP → User enters code, receives tokens + user info
  3. Use Access Token → Include in Authorization header for protected endpoints
  4. Refresh Token → When access token expires, get new tokens (auto-rotation)
  5. Logout → Revoke refresh token on current device
  6. Device Management → View/manage active devices

API Endpoints

1. Request OTP

Endpoint: POST /auth/request-otp

Request:

{
  "phone_number": "+919876543210"
}

Response (200):

{
  "ok": true
}

Error (400):

{
  "error": "phone_number is required"
}

Error (500):

{
  "error": "Failed to send OTP"
}

Phone Number Normalization:

  • 9876543210 → Automatically becomes +919876543210 (10-digit assumed as Indian)
  • +919876543210 → Kept as is
  • Phone numbers should ideally be in E.164 format with + prefix

2. Verify OTP

Endpoint: POST /auth/verify-otp

Request:

{
  "phone_number": "+919876543210",
  "code": "123456",
  "device_id": "android-installation-id-123",
  "device_info": {
    "platform": "android",
    "model": "Samsung SM-M326B",
    "os_version": "Android 14",
    "app_version": "1.0.0",
    "language_code": "en-IN",
    "timezone": "Asia/Kolkata"
  }
}

Response (200):

{
  "user": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "phone_number": "+919876543210",
    "name": null,
    "role": "user",
    "user_type": null
  },
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "needs_profile": true,
  "is_new_device": true,
  "is_new_account": false,
  "active_devices_count": 2
}

Error (400):

{
  "error": "phone_number and code are required"
}

or

{
  "error": "Invalid or expired OTP"
}

Notes:

  • device_id is required and will be sanitized (must be 4-128 alphanumeric characters, otherwise hashed)
  • device_info is optional but recommended for better device tracking
  • needs_profile is true if name or user_type is null
  • is_new_device indicates if this device was seen for the first time
  • is_new_account indicates if the user account was just created
  • active_devices_count shows how many active devices the user has

3. Refresh Token

Endpoint: POST /auth/refresh

Request:

{
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

Response (200):

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

Error (400):

{
  "error": "refresh_token is required"
}

Error (401):

{
  "error": "Invalid refresh token"
}

Important:

  • Refresh tokens rotate on each use - always save the new refresh_token
  • Old refresh token is automatically revoked
  • If refresh token is compromised and reused, all tokens for that device are revoked

4. Logout

Endpoint: POST /auth/logout

Request:

{
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

Response (200):

{
  "ok": true
}

Note: Returns ok: true even if token is already invalid (idempotent)


5. Get Current User Profile

Endpoint: GET /users/me

Headers:

Authorization: Bearer <access_token>

Response (200):

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "phone_number": "+919876543210",
  "name": "John Doe",
  "role": "user",
  "user_type": "seller",
  "created_at": "2024-01-15T10:30:00Z",
  "last_login_at": "2024-01-20T14:22:00Z",
  "active_devices_count": 2
}

Error (401):

{
  "error": "Missing Authorization header"
}

or

{
  "error": "Invalid or expired token"
}

6. Update User Profile

Endpoint: PUT /users/me

Headers:

Authorization: Bearer <access_token>

Request:

{
  "name": "John Doe",
  "user_type": "seller"
}

Response (200):

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "phone_number": "+919876543210",
  "name": "John Doe",
  "role": "user",
  "user_type": "seller"
}

Valid user_type values:

  • seller
  • buyer
  • service_provider

Error (400):

{
  "error": "name and user_type are required"
}

7. List Active Devices

Endpoint: GET /users/me/devices

Headers:

Authorization: Bearer <access_token>

Response (200):

{
  "devices": [
    {
      "device_identifier": "android-installation-id-123",
      "device_platform": "android",
      "device_model": "Samsung SM-M326B",
      "os_version": "Android 14",
      "app_version": "1.0.0",
      "language_code": "en-IN",
      "timezone": "Asia/Kolkata",
      "first_seen_at": "2024-01-15T10:30:00Z",
      "last_seen_at": "2024-01-20T14:22:00Z",
      "is_active": true
    }
  ]
}

8. Logout Specific Device

Endpoint: DELETE /users/me/devices/:device_id

Headers:

Authorization: Bearer <access_token>

Response (200):

{
  "ok": true,
  "message": "Device logged out successfully"
}

Note: This will:

  • Mark the device as inactive in user_devices
  • Revoke all refresh tokens for that device
  • Log the action in auth_audit

9. Logout All Other Devices

Endpoint: POST /users/me/logout-all-other-devices

Headers:

Authorization: Bearer <access_token>
X-Device-Id: <current_device_id>

Alternative: Send current_device_id in request body:

{
  "current_device_id": "android-installation-id-123"
}

Response (200):

{
  "ok": true,
  "message": "Logged out 2 device(s)",
  "revoked_devices_count": 2
}

Error (400):

{
  "error": "current_device_id is required in header or body"
}

10. Health Check

Endpoint: GET /health

Response (200):

{
  "ok": true
}

Use this to verify the service is running before attempting authentication.


Kotlin Multiplatform Implementation

1. Data Models (Common Module)

// commonMain/kotlin/models/Requests.kt
package com.farm.auth.models

import kotlinx.serialization.Serializable

@Serializable
data class RequestOtpRequest(val phone_number: String)

@Serializable
data class RequestOtpResponse(val ok: Boolean)

@Serializable
data class DeviceInfo(
    val platform: String,
    val model: String? = null,
    @SerialName("os_version") val osVersion: String? = null,
    @SerialName("app_version") val appVersion: String? = null,
    @SerialName("language_code") val languageCode: String? = null,
    val timezone: String? = null
)

@Serializable
data class VerifyOtpRequest(
    @SerialName("phone_number") val phoneNumber: String,
    val code: String,
    @SerialName("device_id") val deviceId: String,
    @SerialName("device_info") val deviceInfo: DeviceInfo? = null
)

@Serializable
data class User(
    val id: String,
    @SerialName("phone_number") val phoneNumber: String,
    val name: String?,
    val role: String,
    @SerialName("user_type") val userType: String?,
    @SerialName("created_at") val createdAt: String? = null,
    @SerialName("last_login_at") val lastLoginAt: String? = null,
    @SerialName("active_devices_count") val activeDevicesCount: Int? = null
)

@Serializable
data class VerifyOtpResponse(
    val user: User,
    @SerialName("access_token") val accessToken: String,
    @SerialName("refresh_token") val refreshToken: String,
    @SerialName("needs_profile") val needsProfile: Boolean,
    @SerialName("is_new_device") val isNewDevice: Boolean,
    @SerialName("is_new_account") val isNewAccount: Boolean,
    @SerialName("active_devices_count") val activeDevicesCount: Int
)

@Serializable
data class RefreshRequest(@SerialName("refresh_token") val refreshToken: String)

@Serializable
data class RefreshResponse(
    @SerialName("access_token") val accessToken: String,
    @SerialName("refresh_token") val refreshToken: String
)

@Serializable
data class LogoutRequest(@SerialName("refresh_token") val refreshToken: String)

@Serializable
data class LogoutResponse(val ok: Boolean)

@Serializable
data class UpdateProfileRequest(
    val name: String,
    @SerialName("user_type") val userType: String
)

@Serializable
data class Device(
    @SerialName("device_identifier") val deviceIdentifier: String,
    @SerialName("device_platform") val devicePlatform: String,
    @SerialName("device_model") val deviceModel: String?,
    @SerialName("os_version") val osVersion: String?,
    @SerialName("app_version") val appVersion: String?,
    @SerialName("language_code") val languageCode: String?,
    val timezone: String?,
    @SerialName("first_seen_at") val firstSeenAt: String,
    @SerialName("last_seen_at") val lastSeenAt: String,
    @SerialName("is_active") val isActive: Boolean
)

@Serializable
data class DevicesResponse(val devices: List<Device>)

@Serializable
data class LogoutAllOtherDevicesRequest(
    @SerialName("current_device_id") val currentDeviceId: String
)

@Serializable
data class LogoutAllOtherDevicesResponse(
    val ok: Boolean,
    val message: String,
    @SerialName("revoked_devices_count") val revokedDevicesCount: Int
)

@Serializable
data class ErrorResponse(val error: String)

@Serializable
data class HealthResponse(val ok: Boolean)

2. API Client (Common Module)

// commonMain/kotlin/network/AuthApiClient.kt
package com.farm.auth.network

import com.farm.auth.models.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json

class AuthApiClient(
    private val baseUrl: String,
    private val httpClient: HttpClient
) {
    private val json = Json {
        ignoreUnknownKeys = true
        isLenient = true
        encodeDefaults = false
    }

    init {
        httpClient.config {
            install(ContentNegotiation) {
                json(json)
            }
        }
    }

    suspend fun requestOtp(phoneNumber: String): Result<RequestOtpResponse> {
        return try {
            val response = httpClient.post("$baseUrl/auth/request-otp") {
                contentType(ContentType.Application.Json)
                setBody(RequestOtpRequest(phoneNumber))
            }
            Result.success(response.body())
        } catch (e: Exception) {
            Result.failure(e)
        }
    }

    suspend fun verifyOtp(
        phoneNumber: String,
        code: String,
        deviceId: String,
        deviceInfo: DeviceInfo? = null
    ): Result<VerifyOtpResponse> {
        return try {
            val request = VerifyOtpRequest(phoneNumber, code, deviceId, deviceInfo)
            val response = httpClient.post("$baseUrl/auth/verify-otp") {
                contentType(ContentType.Application.Json)
                setBody(request)
            }
            Result.success(response.body())
        } catch (e: Exception) {
            Result.failure(e)
        }
    }

    suspend fun refreshToken(refreshToken: String): Result<RefreshResponse> {
        return try {
            val request = RefreshRequest(refreshToken)
            val response = httpClient.post("$baseUrl/auth/refresh") {
                contentType(ContentType.Application.Json)
                setBody(request)
            }
            Result.success(response.body())
        } catch (e: Exception) {
            Result.failure(e)
        }
    }

    suspend fun logout(refreshToken: String): Result<LogoutResponse> {
        return try {
            val request = LogoutRequest(refreshToken)
            val response = httpClient.post("$baseUrl/auth/logout") {
                contentType(ContentType.Application.Json)
                setBody(request)
            }
            Result.success(response.body())
        } catch (e: Exception) {
            Result.failure(e)
        }
    }

    suspend fun getCurrentUser(accessToken: String): Result<User> {
        return try {
            val response = httpClient.get("$baseUrl/users/me") {
                contentType(ContentType.Application.Json)
                header("Authorization", "Bearer $accessToken")
            }
            Result.success(response.body())
        } catch (e: Exception) {
            Result.failure(e)
        }
    }

    suspend fun updateProfile(
        name: String,
        userType: String,
        accessToken: String
    ): Result<User> {
        return try {
            val request = UpdateProfileRequest(name, userType)
            val response = httpClient.put("$baseUrl/users/me") {
                contentType(ContentType.Application.Json)
                header("Authorization", "Bearer $accessToken")
                setBody(request)
            }
            Result.success(response.body())
        } catch (e: Exception) {
            Result.failure(e)
        }
    }

    suspend fun getDevices(accessToken: String): Result<DevicesResponse> {
        return try {
            val response = httpClient.get("$baseUrl/users/me/devices") {
                contentType(ContentType.Application.Json)
                header("Authorization", "Bearer $accessToken")
            }
            Result.success(response.body())
        } catch (e: Exception) {
            Result.failure(e)
        }
    }

    suspend fun logoutDevice(
        deviceId: String,
        accessToken: String
    ): Result<LogoutResponse> {
        return try {
            val response = httpClient.delete("$baseUrl/users/me/devices/$deviceId") {
                contentType(ContentType.Application.Json)
                header("Authorization", "Bearer $accessToken")
            }
            Result.success(response.body())
        } catch (e: Exception) {
            Result.failure(e)
        }
    }

    suspend fun logoutAllOtherDevices(
        currentDeviceId: String,
        accessToken: String
    ): Result<LogoutAllOtherDevicesResponse> {
        return try {
            val request = LogoutAllOtherDevicesRequest(currentDeviceId)
            val response = httpClient.post("$baseUrl/users/me/logout-all-other-devices") {
                contentType(ContentType.Application.Json)
                header("Authorization", "Bearer $accessToken")
                header("X-Device-Id", currentDeviceId)
                setBody(request)
            }
            Result.success(response.body())
        } catch (e: Exception) {
            Result.failure(e)
        }
    }

    suspend fun healthCheck(): Result<HealthResponse> {
        return try {
            val response = httpClient.get("$baseUrl/health")
            Result.success(response.body())
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}

3. Token Storage Interface (Common Module)

// commonMain/kotlin/storage/TokenStorage.kt
package com.farm.auth.storage

interface TokenStorage {
    fun saveTokens(accessToken: String, refreshToken: String)
    fun getAccessToken(): String?
    fun getRefreshToken(): String?
    fun clearTokens()
}

4. Android Token Storage Implementation

// androidMain/kotlin/storage/AndroidTokenStorage.kt
package com.farm.auth.storage

import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey

class AndroidTokenStorage(private val context: Context) : TokenStorage {
    private val masterKey = MasterKey.Builder(context)
        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
        .build()

    private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
        context,
        "auth_tokens",
        masterKey,
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
    )

    override fun saveTokens(accessToken: String, refreshToken: String) {
        prefs.edit().apply {
            putString("access_token", accessToken)
            putString("refresh_token", refreshToken)
            apply()
        }
    }

    override fun getAccessToken(): String? = prefs.getString("access_token", null)

    override fun getRefreshToken(): String? = prefs.getString("refresh_token", null)

    override fun clearTokens() {
        prefs.edit().clear().apply()
    }
}

5. iOS Token Storage Implementation (using Keychain)

// iosMain/kotlin/storage/IosTokenStorage.kt
package com.farm.auth.storage

import platform.Security.*
import platform.Foundation.*

class IosTokenStorage : TokenStorage {
    private val accessTokenKey = "access_token"
    private val refreshTokenKey = "refresh_token"
    private val service = "com.farm.auth"

    override fun saveTokens(accessToken: String, refreshToken: String) {
        saveToKeychain(accessTokenKey, accessToken)
        saveToKeychain(refreshTokenKey, refreshToken)
    }

    override fun getAccessToken(): String? = getFromKeychain(accessTokenKey)

    override fun getRefreshToken(): String? = getFromKeychain(refreshTokenKey)

    override fun clearTokens() {
        deleteFromKeychain(accessTokenKey)
        deleteFromKeychain(refreshTokenKey)
    }

    private fun saveToKeychain(key: String, value: String): Boolean {
        val data = (value as NSString).dataUsingEncoding(NSUTF8StringEncoding) ?: return false
        val query = mapOf(
            kSecClass to kSecClassGenericPassword,
            kSecAttrService to service,
            kSecAttrAccount to key,
            kSecValueData to data
        )
        SecItemDelete(query.toCFDictionary())
        return SecItemAdd(query.toCFDictionary(), null) == errSecSuccess
    }

    private fun getFromKeychain(key: String): String? {
        val query = mapOf(
            kSecClass to kSecClassGenericPassword,
            kSecAttrService to service,
            kSecAttrAccount to key,
            kSecReturnData to kCFBooleanTrue
        )
        val result = alloc<AnyVar>()
        if (SecItemCopyMatching(query.toCFDictionary(), result.ptr) == errSecSuccess) {
            val data = result.value as? NSData ?: return null
            return NSString.create(data, NSUTF8StringEncoding) as String
        }
        return null
    }

    private fun deleteFromKeychain(key: String) {
        val query = mapOf(
            kSecClass to kSecClassGenericPassword,
            kSecAttrService to service,
            kSecAttrAccount to key
        )
        SecItemDelete(query.toCFDictionary())
    }
}

6. Device Info Provider Interface

// commonMain/kotlin/device/DeviceInfoProvider.kt
package com.farm.auth.device

import com.farm.auth.models.DeviceInfo

interface DeviceInfoProvider {
    fun getDeviceId(): String
    fun getDeviceInfo(): DeviceInfo
}

7. Authentication Manager

// commonMain/kotlin/auth/AuthManager.kt
package com.farm.auth.auth

import com.farm.auth.models.*
import com.farm.auth.network.AuthApiClient
import com.farm.auth.storage.TokenStorage
import com.farm.auth.device.DeviceInfoProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

class AuthManager(
    private val apiClient: AuthApiClient,
    private val tokenStorage: TokenStorage,
    private val deviceInfoProvider: DeviceInfoProvider
) {
    private val _currentUser = MutableStateFlow<User?>(null)
    val currentUser: StateFlow<User?> = _currentUser.asStateFlow()

    private val _isAuthenticated = MutableStateFlow(false)
    val isAuthenticated: StateFlow<Boolean> = _isAuthenticated.asStateFlow()

    suspend fun requestOtp(phoneNumber: String): Result<RequestOtpResponse> {
        return apiClient.requestOtp(phoneNumber)
    }

    suspend fun verifyOtp(phoneNumber: String, code: String): Result<VerifyOtpResponse> {
        val deviceId = deviceInfoProvider.getDeviceId()
        val deviceInfo = deviceInfoProvider.getDeviceInfo()

        return apiClient.verifyOtp(phoneNumber, code, deviceId, deviceInfo)
            .onSuccess { response ->
                tokenStorage.saveTokens(response.accessToken, response.refreshToken)
                _currentUser.value = response.user
                _isAuthenticated.value = true
            }
    }

    suspend fun refreshTokens(): Result<Pair<String, String>> {
        val refreshToken = tokenStorage.getRefreshToken()
            ?: return Result.failure(Exception("No refresh token"))

        return apiClient.refreshToken(refreshToken)
            .onSuccess { response ->
                tokenStorage.saveTokens(response.accessToken, response.refreshToken)
            }
            .onFailure {
                // If refresh fails, clear tokens and logout
                if (it.message?.contains("Invalid refresh token") == true) {
                    logout()
                }
            }
            .map { it.accessToken to it.refreshToken }
    }

    suspend fun logout() {
        tokenStorage.getRefreshToken()?.let { refreshToken ->
            apiClient.logout(refreshToken)
        }
        tokenStorage.clearTokens()
        _currentUser.value = null
        _isAuthenticated.value = false
    }

    suspend fun getCurrentUser(): Result<User> {
        val accessToken = tokenStorage.getAccessToken()
            ?: return Result.failure(Exception("Not authenticated"))

        return apiClient.getCurrentUser(accessToken)
            .onSuccess { user ->
                _currentUser.value = user
            }
            .recoverCatching { error ->
                if (error.message?.contains("401") == true || 
                    error.message?.contains("Unauthorized") == true) {
                    // Try to refresh token and retry
                    refreshTokens().getOrNull()?.let { (newAccessToken, _) ->
                        apiClient.getCurrentUser(newAccessToken)
                            .onSuccess { user ->
                                _currentUser.value = user
                            }
                    } ?: Result.failure(error)
                } else {
                    Result.failure(error)
                }
            }
    }

    suspend fun updateProfile(name: String, userType: String): Result<User> {
        val accessToken = tokenStorage.getAccessToken()
            ?: return Result.failure(Exception("Not authenticated"))

        return apiClient.updateProfile(name, userType, accessToken)
            .onSuccess { user ->
                _currentUser.value = user
            }
    }

    suspend fun getDevices(): Result<DevicesResponse> {
        val accessToken = tokenStorage.getAccessToken()
            ?: return Result.failure(Exception("Not authenticated"))

        return callWithAuth { token ->
            apiClient.getDevices(token)
        }
    }

    suspend fun logoutDevice(deviceId: String): Result<LogoutResponse> {
        return callWithAuth { token ->
            apiClient.logoutDevice(deviceId, token)
        }
    }

    suspend fun logoutAllOtherDevices(): Result<LogoutAllOtherDevicesResponse> {
        val currentDeviceId = deviceInfoProvider.getDeviceId()
        return callWithAuth { token ->
            apiClient.logoutAllOtherDevices(currentDeviceId, token)
        }
    }

    fun getAccessToken(): String? = tokenStorage.getAccessToken()

    private suspend fun <T> callWithAuth(
        block: suspend (String) -> Result<T>
    ): Result<T> {
        val token = tokenStorage.getAccessToken()
            ?: return Result.failure(Exception("Not authenticated"))

        return block(token).recoverCatching { error ->
            if (error.message?.contains("401") == true || 
                error.message?.contains("Unauthorized") == true) {
                // Token expired, refresh and retry
                refreshTokens().getOrNull()?.let { (newAccessToken, _) ->
                    block(newAccessToken)
                } ?: Result.failure(Exception("Failed to refresh token"))
            } else {
                Result.failure(error)
            }
        }
    }
}

Error Handling

Common Error Codes

Status Error Description
400 phone_number is required Missing phone number in request
400 Invalid or expired OTP Wrong code, OTP expired (10 min), or max attempts exceeded (5)
400 name and user_type are required Missing required fields in profile update
400 current_device_id is required Missing device ID for logout all devices
401 Invalid refresh token Token expired, revoked, or compromised
401 Missing Authorization header Access token not provided
401 Invalid or expired token Access token invalid or expired
404 User not found User account doesn't exist
403 Origin not allowed CORS restriction (production only)
500 Internal server error Server-side error
500 Failed to send OTP Twilio SMS sending failed

Error Response Format

{
  "error": "Error message here"
}

Security Best Practices

  1. Store tokens securely

    • Android: Use EncryptedSharedPreferences (Android Security Library)
    • iOS: Use Keychain Services
    • Never store tokens in plain SharedPreferences/UserDefaults
  2. Handle token expiration

    • Automatically refresh when access token expires (401 response)
    • Implement retry logic with token refresh
  3. Rotate refresh tokens

    • Always save the new refresh_token after refresh
    • Old refresh tokens are automatically revoked
  4. Validate device_id

    • Use consistent device identifier (Android ID, Installation ID, or Vendor ID)
    • Device ID should be 4-128 alphanumeric characters
    • Server will hash invalid device IDs
  5. Handle reuse detection

    • If refresh returns 401 with "Invalid refresh token", force re-login
    • This indicates potential token compromise
  6. Secure network communication

    • Always use HTTPS in production
    • Implement certificate pinning if needed
  7. Rate limiting awareness

    • Don't spam OTP requests
    • Implement client-side rate limiting if possible

Token Management

Token Expiration

  • Access Token: 15 minutes (default, configurable via JWT_ACCESS_TTL env var)
  • Refresh Token: 7 days (default, configurable via JWT_REFRESH_TTL env var)
  • Refresh Token Idle Timeout: 3 days (default, configurable via REFRESH_MAX_IDLE_MINUTES = 4320)
  • OTP: 10 minutes (fixed)

Auto-refresh Pattern

suspend fun <T> callWithAuth(
    block: suspend (String) -> Result<T>
): Result<T> {
    val token = tokenStorage.getAccessToken()
        ?: return Result.failure(Exception("Not authenticated"))
    
    return block(token).recoverCatching { error ->
        if (error.message?.contains("401") == true || 
            error.message?.contains("Unauthorized") == true) {
            // Token expired, refresh and retry
            refreshTokens().getOrNull()?.let { (newAccessToken, _) ->
                block(newAccessToken)
            } ?: Result.failure(Exception("Failed to refresh token"))
        } else {
            Result.failure(error)
        }
    }
}

Notes

  • Phone numbers are auto-normalized: 10-digit numbers → +91 prefix
  • Device ID should be 4-128 alphanumeric characters (server sanitizes if invalid)
  • Refresh tokens rotate on each use - always update stored token
  • If needs_profile: true, prompt user to complete profile before accessing app
  • is_new_device and is_new_account flags help with onboarding flows
  • Device management endpoints require authentication
  • All timestamps are in ISO 8601 format with timezone (UTC)

Example Usage (Android Activity)

class LoginActivity : AppCompatActivity() {
    private lateinit var authManager: AuthManager
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        val httpClient = HttpClient(Android) {
            install(ContentNegotiation) {
                json()
            }
        }
        
        val apiClient = AuthApiClient("http://your-api-url", httpClient)
        val tokenStorage = AndroidTokenStorage(this)
        val deviceInfoProvider = AndroidDeviceInfoProvider(this)
        
        authManager = AuthManager(apiClient, tokenStorage, deviceInfoProvider)
        
        // Observe authentication state
        lifecycleScope.launch {
            authManager.isAuthenticated.collect { isAuth ->
                if (isAuth) {
                    // Navigate to main screen
                }
            }
        }
    }
    
    private fun requestOtp() {
        lifecycleScope.launch {
            val phoneNumber = phoneInput.text.toString()
            authManager.requestOtp(phoneNumber)
                .onSuccess { 
                    showToast("OTP sent!") 
                }
                .onFailure { 
                    showError(it.message ?: "Failed to send OTP") 
                }
        }
    }
    
    private fun verifyOtp() {
        lifecycleScope.launch {
            val phoneNumber = phoneInput.text.toString()
            val code = otpInput.text.toString()
            
            authManager.verifyOtp(phoneNumber, code)
                .onSuccess { response ->
                    if (response.needsProfile) {
                        startActivity(Intent(this@LoginActivity, ProfileSetupActivity::class.java))
                    } else {
                        startActivity(Intent(this@LoginActivity, MainActivity::class.java))
                    }
                    finish()
                }
                .onFailure { 
                    showError("Invalid OTP") 
                }
        }
    }
}