567 lines
14 KiB
Markdown
567 lines
14 KiB
Markdown
# Farm Auth Service - API Integration Guide
|
|
|
|
Quick reference for integrating the Farm Auth Service into Kotlin mobile applications.
|
|
|
|
## Base URL
|
|
|
|
```
|
|
http://localhost:3000 (development)
|
|
https://your-domain.com (production)
|
|
```
|
|
|
|
## Authentication Flow
|
|
|
|
1. **Request OTP** → User enters phone number
|
|
2. **Verify OTP** → User enters code, receives tokens
|
|
3. **Use Access Token** → Include in `Authorization` header for protected endpoints
|
|
4. **Refresh Token** → When access token expires, get new tokens
|
|
5. **Logout** → Revoke refresh token
|
|
|
|
---
|
|
|
|
## API Endpoints
|
|
|
|
### 1. Request OTP
|
|
|
|
**Endpoint:** `POST /auth/request-otp`
|
|
|
|
**Request:**
|
|
```json
|
|
{
|
|
"phone_number": "+919876543210"
|
|
}
|
|
```
|
|
|
|
**Response (200):**
|
|
```json
|
|
{
|
|
"ok": true
|
|
}
|
|
```
|
|
|
|
**Kotlin Example:**
|
|
```kotlin
|
|
data class RequestOtpRequest(val phone_number: String)
|
|
data class RequestOtpResponse(val ok: Boolean)
|
|
|
|
suspend fun requestOtp(phoneNumber: String): Result<RequestOtpResponse> {
|
|
val request = RequestOtpRequest(phoneNumber)
|
|
return apiClient.post("/auth/request-otp", request)
|
|
}
|
|
```
|
|
|
|
**Note:** Phone numbers are auto-normalized:
|
|
- `9876543210` → `+919876543210` (10-digit assumed as Indian)
|
|
- `+919876543210` → `+919876543210` (already formatted)
|
|
|
|
---
|
|
|
|
### 2. Verify OTP
|
|
|
|
**Endpoint:** `POST /auth/verify-otp`
|
|
|
|
**Request:**
|
|
```json
|
|
{
|
|
"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):**
|
|
```json
|
|
{
|
|
"user": {
|
|
"id": "uuid-here",
|
|
"phone_number": "+919876543210",
|
|
"name": null,
|
|
"role": "user",
|
|
"user_type": null
|
|
},
|
|
"access_token": "eyJhbGc...",
|
|
"refresh_token": "eyJhbGc...",
|
|
"needs_profile": true
|
|
}
|
|
```
|
|
|
|
**Kotlin Example:**
|
|
```kotlin
|
|
data class DeviceInfo(
|
|
val platform: String,
|
|
val model: String? = null,
|
|
val os_version: String? = null,
|
|
val app_version: String? = null,
|
|
val language_code: String? = null,
|
|
val timezone: String? = null
|
|
)
|
|
|
|
data class VerifyOtpRequest(
|
|
val phone_number: String,
|
|
val code: String,
|
|
val device_id: String,
|
|
val device_info: DeviceInfo? = null
|
|
)
|
|
|
|
data class User(
|
|
val id: String,
|
|
val phone_number: String,
|
|
val name: String?,
|
|
val role: String,
|
|
val user_type: String?
|
|
)
|
|
|
|
data class VerifyOtpResponse(
|
|
val user: User,
|
|
val access_token: String,
|
|
val refresh_token: String,
|
|
val needs_profile: Boolean
|
|
)
|
|
|
|
suspend fun verifyOtp(
|
|
phoneNumber: String,
|
|
code: String,
|
|
deviceId: String,
|
|
deviceInfo: DeviceInfo? = null
|
|
): Result<VerifyOtpResponse> {
|
|
val request = VerifyOtpRequest(phoneNumber, code, deviceId, deviceInfo)
|
|
return apiClient.post("/auth/verify-otp", request)
|
|
}
|
|
```
|
|
|
|
**Error (400):**
|
|
```json
|
|
{
|
|
"error": "Invalid or expired OTP"
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 3. Refresh Token
|
|
|
|
**Endpoint:** `POST /auth/refresh`
|
|
|
|
**Request:**
|
|
```json
|
|
{
|
|
"refresh_token": "eyJhbGc..."
|
|
}
|
|
```
|
|
|
|
**Response (200):**
|
|
```json
|
|
{
|
|
"access_token": "eyJhbGc...",
|
|
"refresh_token": "eyJhbGc..."
|
|
}
|
|
```
|
|
|
|
**Kotlin Example:**
|
|
```kotlin
|
|
data class RefreshRequest(val refresh_token: String)
|
|
data class RefreshResponse(val access_token: String, val refresh_token: String)
|
|
|
|
suspend fun refreshToken(refreshToken: String): Result<RefreshResponse> {
|
|
val request = RefreshRequest(refreshToken)
|
|
return apiClient.post("/auth/refresh", request)
|
|
}
|
|
```
|
|
|
|
**Note:** Refresh tokens rotate on each use. Always save the new `refresh_token`.
|
|
|
|
---
|
|
|
|
### 4. Update Profile
|
|
|
|
**Endpoint:** `PUT /users/me`
|
|
|
|
**Headers:**
|
|
```
|
|
Authorization: Bearer <access_token>
|
|
```
|
|
|
|
**Request:**
|
|
```json
|
|
{
|
|
"name": "John Doe",
|
|
"user_type": "seller"
|
|
}
|
|
```
|
|
|
|
**Response (200):**
|
|
```json
|
|
{
|
|
"id": "uuid-here",
|
|
"phone_number": "+919876543210",
|
|
"name": "John Doe",
|
|
"role": "user",
|
|
"user_type": "seller"
|
|
}
|
|
```
|
|
|
|
**user_type values:** `seller`, `buyer`, `service_provider`
|
|
|
|
**Kotlin Example:**
|
|
```kotlin
|
|
data class UpdateProfileRequest(val name: String, val user_type: String)
|
|
data class UpdateProfileResponse(
|
|
val id: String,
|
|
val phone_number: String,
|
|
val name: String?,
|
|
val role: String,
|
|
val user_type: String?
|
|
)
|
|
|
|
suspend fun updateProfile(
|
|
name: String,
|
|
userType: String,
|
|
accessToken: String
|
|
): Result<UpdateProfileResponse> {
|
|
val request = UpdateProfileRequest(name, userType)
|
|
return apiClient.put("/users/me", request, accessToken)
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 5. Logout
|
|
|
|
**Endpoint:** `POST /auth/logout`
|
|
|
|
**Request:**
|
|
```json
|
|
{
|
|
"refresh_token": "eyJhbGc..."
|
|
}
|
|
```
|
|
|
|
**Response (200):**
|
|
```json
|
|
{
|
|
"ok": true
|
|
}
|
|
```
|
|
|
|
**Kotlin Example:**
|
|
```kotlin
|
|
suspend fun logout(refreshToken: String): Result<Unit> {
|
|
val request = RefreshRequest(refreshToken)
|
|
return apiClient.post("/auth/logout", request)
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Complete Kotlin Integration Example
|
|
|
|
### 1. API Client Setup
|
|
|
|
```kotlin
|
|
import kotlinx.coroutines.*
|
|
import kotlinx.serialization.*
|
|
import kotlinx.serialization.json.*
|
|
import io.ktor.client.*
|
|
import io.ktor.client.call.*
|
|
import io.ktor.client.request.*
|
|
import io.ktor.client.statement.*
|
|
import io.ktor.http.*
|
|
|
|
class AuthApiClient(private val baseUrl: String) {
|
|
private val client = HttpClient {
|
|
install(JsonFeature) {
|
|
serializer = KotlinxSerializationJson()
|
|
}
|
|
}
|
|
|
|
suspend fun requestOtp(phoneNumber: String): Result<RequestOtpResponse> {
|
|
return try {
|
|
val response = client.post("$baseUrl/auth/request-otp") {
|
|
contentType(ContentType.Application.Json)
|
|
setBody(JsonObject(mapOf("phone_number" to JsonPrimitive(phoneNumber))))
|
|
}
|
|
Result.success(response.body())
|
|
} catch (e: Exception) {
|
|
Result.failure(e)
|
|
}
|
|
}
|
|
|
|
suspend fun verifyOtp(
|
|
phoneNumber: String,
|
|
code: String,
|
|
deviceId: String
|
|
): Result<VerifyOtpResponse> {
|
|
return try {
|
|
val request = VerifyOtpRequest(phoneNumber, code, deviceId, getDeviceInfo())
|
|
val response = client.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 = client.post("$baseUrl/auth/refresh") {
|
|
contentType(ContentType.Application.Json)
|
|
setBody(request)
|
|
}
|
|
Result.success(response.body())
|
|
} catch (e: Exception) {
|
|
Result.failure(e)
|
|
}
|
|
}
|
|
|
|
suspend fun updateProfile(
|
|
name: String,
|
|
userType: String,
|
|
accessToken: String
|
|
): Result<UpdateProfileResponse> {
|
|
return try {
|
|
val request = UpdateProfileRequest(name, userType)
|
|
val response = client.put("$baseUrl/users/me") {
|
|
contentType(ContentType.Application.Json)
|
|
header("Authorization", "Bearer $accessToken")
|
|
setBody(request)
|
|
}
|
|
Result.success(response.body())
|
|
} catch (e: Exception) {
|
|
Result.failure(e)
|
|
}
|
|
}
|
|
|
|
private fun getDeviceInfo(): DeviceInfo {
|
|
return DeviceInfo(
|
|
platform = "android",
|
|
model = Build.MODEL,
|
|
os_version = Build.VERSION.RELEASE,
|
|
app_version = BuildConfig.VERSION_NAME,
|
|
language_code = Locale.getDefault().toString(),
|
|
timezone = TimeZone.getDefault().id
|
|
)
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2. Token Storage (Secure SharedPreferences)
|
|
|
|
```kotlin
|
|
import android.content.Context
|
|
import android.content.SharedPreferences
|
|
import androidx.security.crypto.EncryptedSharedPreferences
|
|
import androidx.security.crypto.MasterKey
|
|
|
|
class TokenManager(context: Context) {
|
|
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
|
|
)
|
|
|
|
fun saveTokens(accessToken: String, refreshToken: String) {
|
|
prefs.edit().apply {
|
|
putString("access_token", accessToken)
|
|
putString("refresh_token", refreshToken)
|
|
apply()
|
|
}
|
|
}
|
|
|
|
fun getAccessToken(): String? = prefs.getString("access_token", null)
|
|
fun getRefreshToken(): String? = prefs.getString("refresh_token", null)
|
|
|
|
fun clearTokens() {
|
|
prefs.edit().clear().apply()
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3. Authentication Manager
|
|
|
|
```kotlin
|
|
class AuthManager(
|
|
private val apiClient: AuthApiClient,
|
|
private val tokenManager: TokenManager
|
|
) {
|
|
suspend fun login(phoneNumber: String, code: String): Result<User> {
|
|
val deviceId = getDeviceId()
|
|
return apiClient.verifyOtp(phoneNumber, code, deviceId)
|
|
.onSuccess { response ->
|
|
tokenManager.saveTokens(response.access_token, response.refresh_token)
|
|
}
|
|
.map { it.user }
|
|
}
|
|
|
|
suspend fun refreshTokens(): Result<Pair<String, String>> {
|
|
val refreshToken = tokenManager.getRefreshToken()
|
|
?: return Result.failure(Exception("No refresh token"))
|
|
|
|
return apiClient.refreshToken(refreshToken)
|
|
.onSuccess { response ->
|
|
tokenManager.saveTokens(response.access_token, response.refresh_token)
|
|
}
|
|
.map { it.access_token to it.refresh_token }
|
|
}
|
|
|
|
fun getAccessToken(): String? = tokenManager.getAccessToken()
|
|
|
|
suspend fun logout() {
|
|
tokenManager.getRefreshToken()?.let { refreshToken ->
|
|
apiClient.logout(refreshToken)
|
|
}
|
|
tokenManager.clearTokens()
|
|
}
|
|
|
|
private fun getDeviceId(): String {
|
|
// Use Android ID or Installation ID
|
|
return Settings.Secure.getString(
|
|
context.contentResolver,
|
|
Settings.Secure.ANDROID_ID
|
|
)
|
|
}
|
|
}
|
|
```
|
|
|
|
### 4. Usage in Activity/Fragment
|
|
|
|
```kotlin
|
|
class LoginActivity : AppCompatActivity() {
|
|
private val authManager by lazy {
|
|
val apiClient = AuthApiClient("http://your-api-url")
|
|
val tokenManager = TokenManager(this)
|
|
AuthManager(apiClient, tokenManager)
|
|
}
|
|
|
|
private fun requestOtp() {
|
|
lifecycleScope.launch {
|
|
val phoneNumber = phoneInput.text.toString()
|
|
authManager.requestOtp(phoneNumber)
|
|
.onSuccess { showToast("OTP sent!") }
|
|
.onFailure { showError(it.message) }
|
|
}
|
|
}
|
|
|
|
private fun verifyOtp() {
|
|
lifecycleScope.launch {
|
|
val phoneNumber = phoneInput.text.toString()
|
|
val code = otpInput.text.toString()
|
|
|
|
authManager.login(phoneNumber, code)
|
|
.onSuccess { user ->
|
|
if (user.needs_profile) {
|
|
startActivity(Intent(this, ProfileSetupActivity::class.java))
|
|
} else {
|
|
startActivity(Intent(this, MainActivity::class.java))
|
|
}
|
|
finish()
|
|
}
|
|
.onFailure { showError("Invalid OTP") }
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Error Handling
|
|
|
|
### Common Error Codes
|
|
|
|
| Status | Error | Description |
|
|
|--------|-------|-------------|
|
|
| 400 | `phone_number is required` | Missing phone number |
|
|
| 400 | `Invalid or expired OTP` | Wrong code or OTP expired (10 min) |
|
|
| 401 | `Invalid refresh token` | Token expired or revoked |
|
|
| 401 | `Missing Authorization header` | Access token not provided |
|
|
| 403 | `Origin not allowed` | CORS restriction (production) |
|
|
| 500 | `Internal server error` | Server issue |
|
|
|
|
### Error Response Format
|
|
|
|
```json
|
|
{
|
|
"error": "Error message here"
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Security Best Practices
|
|
|
|
1. **Store tokens securely** - Use `EncryptedSharedPreferences` (Android) or Keychain (iOS)
|
|
2. **Handle token expiration** - Automatically refresh when access token expires (401)
|
|
3. **Rotate refresh tokens** - Always save the new `refresh_token` after refresh
|
|
4. **Validate device_id** - Use consistent device identifier (Android ID, Installation ID)
|
|
5. **Handle reuse detection** - If refresh returns 401, force re-login (token compromised)
|
|
|
|
---
|
|
|
|
## Token Expiration
|
|
|
|
- **Access Token:** 15 minutes (default, configurable via `JWT_ACCESS_TTL`)
|
|
- **Refresh Token:** 7 days (default, configurable via `JWT_REFRESH_TTL`)
|
|
- **OTP:** 10 minutes (fixed)
|
|
|
|
---
|
|
|
|
## Example: Auto-refresh on 401
|
|
|
|
```kotlin
|
|
suspend fun <T> callWithAuth(block: suspend (String) -> Result<T>): Result<T> {
|
|
val token = tokenManager.getAccessToken() ?: return Result.failure(Exception("Not logged in"))
|
|
|
|
return block(token).recoverCatching { error ->
|
|
if (error is HttpException && error.response.status == HttpStatusCode.Unauthorized) {
|
|
// Token expired, refresh and retry
|
|
refreshTokens()
|
|
.getOrNull()
|
|
?.let { (newAccess, _) -> block(newAccess) }
|
|
?: Result.failure(Exception("Failed to refresh token"))
|
|
} else {
|
|
Result.failure(error)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Health Check
|
|
|
|
**Endpoint:** `GET /health`
|
|
|
|
**Response (200):**
|
|
```json
|
|
{
|
|
"ok": true
|
|
}
|
|
```
|
|
|
|
Use this to verify the service is running before attempting authentication.
|
|
|
|
---
|
|
|
|
## Notes
|
|
|
|
- Phone numbers must be in E.164 format (`+` prefix with country code)
|
|
- Device ID should be 4-128 alphanumeric characters, or it will be hashed
|
|
- Refresh tokens rotate on each use - always update stored token
|
|
- If `needs_profile: true`, prompt user to complete profile before accessing app
|