API Authentication and Authorization
Android: Authentication and Authorization
Authentication and authorization are fundamental aspects of many mobile applications that interact with backend services. Authentication verifies the identity of a user, while authorization determines what actions an authenticated user is allowed to perform.
1. Understanding the Concepts
- Authentication: The process of verifying who a user is. Common methods include username/password, social login (Google, Facebook), phone number verification, and biometric authentication.
- Authorization: The process of determining what an authenticated user is allowed to do. This is typically handled on the backend based on the user's role, permissions, or ownership of resources.
2. Common Authentication Flows
a) Username/Password Authentication
This is a classic approach:
- User enters username/email and password in the app.
- The app sends these credentials to the backend API (usually via a POST request to a login endpoint).
- The backend verifies the credentials against its user database.
- If valid, the backend sends back a response, often including an authentication token (like a JWT - JSON Web Token) and potentially user information.
- The app securely stores the authentication token.
- For subsequent API requests that require authentication, the app includes the token in the request headers (e.g.,
Authorization: Bearer [token]
). - The backend validates the token for each protected request.
Implementation Considerations:
- Secure Communication: Always use HTTPS for all authentication-related communication to prevent credentials from being intercepted.
- Password Hashing: The backend must never store passwords in plain text. Use strong hashing algorithms (like bcrypt, scrypt) to store password hashes.
- Token Storage: Store the authentication token securely on the device. The Android Keystore system or encrypted SharedPreferences are options. Avoid storing sensitive information directly in plain SharedPreferences.
- Token Expiration: Tokens should have an expiration time. The backend should handle token expiration and potentially provide a refresh token mechanism to obtain a new access token without requiring the user to log in again.
- Logout: When the user logs out, clear the stored token from the device and ideally invalidate the token on the backend.
b) OAuth 2.0 and OpenID Connect (Social Login)
OAuth 2.0 is an authorization framework, and OpenID Connect is an identity layer built on top of OAuth 2.0. They are commonly used for "Login with Google," "Login with Facebook," etc.
- The app redirects the user to the identity provider's (Google, Facebook) login screen (often in a browser or custom tab).
- The user logs in and grants the app permission to access certain information.
- The identity provider redirects the user back to your app with an authorization code or access token.
- Your app (or your backend) exchanges the authorization code for access and ID tokens from the identity provider's token endpoint.
- The ID token contains information about the authenticated user.
- Your backend can use the access token to fetch additional user information from the identity provider's APIs.
- Your backend can then issue your own application-specific authentication token (like a JWT) to the app.
- The app stores and uses your application's token for subsequent requests to your backend.
Implementation Considerations:
- Using SDKs: Use the official SDKs provided by Google, Facebook, etc., as they handle much of the complex OAuth flow.
- Handling Redirects: Configure deep linking or app links to handle the redirect back from the identity provider.
- Backend Integration: Your backend needs to communicate with the identity provider's token and user info endpoints.
- Security: Ensure you are using secure practices as outlined in the OAuth 2.0 and OpenID Connect specifications (e.g., PKCE - Proof Key for Code Exchange).
c) Phone Number Authentication (e.g., Firebase Authentication)
This involves sending an SMS code to the user's phone number to verify ownership.
- User enters their phone number in the app.
- The app requests a verification code from the backend (or a service like Firebase Authentication).
- The backend sends an SMS with a code to the user's phone.
- User enters the received code in the app.
- The app sends the phone number and code to the backend for verification.
- If valid, the backend authenticates the user and sends back an authentication token.
Implementation Considerations:
- Using Services: Services like Firebase Authentication significantly simplify this process by handling SMS sending, code verification, and token generation.
- Security: Protect against abuse (e.g., too many verification requests).
3. Implementing Authentication in Android (using Retrofit and Tokens)
a) Login API Call
Using Retrofit, define an API endpoint for login:
data class LoginRequest(val email: String, val password: String)
data class AuthResponse(val token: String, val userId: Int) // Example response
interface AuthApi {
@POST("login")
suspend fun login(@Body request: LoginRequest): AuthResponse
}
In your UI (e.g., Activity, Fragment, ViewModel), call this API when the user clicks the login button. Handle the response:
import kotlinx.coroutines.launch
import android.util.Log
import retrofit2.HttpException
// In a ViewModel
fun performLogin(email: String, password: String) {
viewModelScope.launch {
try {
val response = RetrofitClient.authInstance.login(LoginRequest(email, password))
val authToken = response.token
val userId = response.userId
// Securely store the token
AuthTokenManager.saveAuthToken(authToken)
AuthTokenManager.saveUserId(userId)
// Navigate to the main part of the app
_loginSuccess.value = true
} catch (e: HttpException) {
val errorBody = e.response()?.errorBody()?.string()
Log.e("Login", "Login failed: HTTP ${e.code()}, Error: $errorBody")
// Show error message to user (e.g., "Invalid credentials")
} catch (e: Exception) {
Log.e("Login", "Login failed: ${e.message}")
// Show generic error message
}
}
}
b) Secure Token Storage
Use encrypted storage for the authentication token. The AndroidX Security library provides EncryptedSharedPreferences
.
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
object AuthTokenManager {
private const val PREF_NAME = "auth_prefs"
private const val KEY_AUTH_TOKEN = "auth_token"
private const val KEY_USER_ID = "user_id"
private lateinit var encryptedPrefs: EncryptedSharedPreferences
fun initialize(context: Context) {
val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
encryptedPrefs = EncryptedSharedPreferences.create(
PREF_NAME,
masterKeyAlias,
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
) as EncryptedSharedPreferences
}
fun saveAuthToken(token: String) {
encryptedPrefs.edit().putString(KEY_AUTH_TOKEN, token).apply()
}
fun getAuthToken(): String? {
return encryptedPrefs.getString(KEY_AUTH_TOKEN, null)
}
fun saveUserId(userId: Int) {
encryptedPrefs.edit().putInt(KEY_USER_ID, userId).apply()
}
fun getUserId(): Int {
return encryptedPrefs.getInt(KEY_USER_ID, -1) // Return -1 or handle null appropriately
}
fun clearAuthData() {
encryptedPrefs.edit()
.remove(KEY_AUTH_TOKEN)
.remove(KEY_USER_ID)
.apply()
}
fun isAuthenticated(): Boolean {
return getAuthToken() != null
}
}
Initialize this manager in your Application class:
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
AuthTokenManager.initialize(this)
}
}
c) Attaching the Token to Subsequent Requests (Interceptor)
Use an OkHttp Interceptor to automatically add the authentication token to the headers of protected API calls.
import okhttp3.Interceptor
import okhttp3.Response
class AuthInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val token = AuthTokenManager.getAuthToken()
val requestBuilder = originalRequest.newBuilder()
// Add the token if it exists and the request doesn't already have an Authorization header
if (token != null && originalRequest.header("Authorization") == null) {
requestBuilder.header("Authorization", "Bearer $token")
}
val request = requestBuilder.build()
return chain.proceed(request)
}
}
// In your RetrofitClient:
/*
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor()) // Add your interceptor
// ... other configurations
.build()
private val retrofit: Retrofit by lazy {
Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
*/
Now, any API call made using this Retrofit instance will automatically include the stored authentication token in the headers.
d) Handling Token Expiration and Refresh
When an API request fails with an "Unauthorized" (HTTP 401) status code, it often means the token has expired or is invalid. Your app needs to handle this.
Using an Authenticator (for Token Refresh):
OkHttp's Authenticator
is designed for automatically retrying requests after authentication failures (like refreshing an access token). You'll need a separate API call to refresh the token.
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import kotlinx.coroutines.runBlocking // Use runBlocking carefully, preferably in a background thread context
// Define Refresh Token API (on your backend)
/*
data class RefreshRequest(val refreshToken: String)
data class RefreshResponse(val token: String, val refreshToken: String) // New tokens
interface AuthApi {
// ... login endpoint
@POST("refresh-token")
suspend fun refreshToken(@Body request: RefreshRequest): RefreshResponse
}
*/
class TokenAuthenticator(
private val authApi: AuthApi,
private val authTokenManager: AuthTokenManager
) : Authenticator {
// This method is called when a request returns a 401 Unauthorized response
override fun authenticate(route: Route?, response: Response): Request? {
val currentToken = authTokenManager.getAuthToken()
val currentRefreshToken = authTokenManager.getRefreshToken() // Assuming you also store refresh token
// If no token exists or we're already trying to authenticate, give up
if (currentToken == null || response.request.header("Authorization") == null) {
return null // Let the original request fail
}
// Prevent infinite loops if refresh also fails
if (response.request.header("Retry-After-Auth") != null) {
return null // Give up after one retry
}
synchronized(this) {
// Check if the token was refreshed by another thread while we were waiting
val newToken = authTokenManager.getAuthToken()
if (newToken != null && newToken != currentToken) {
// Token was refreshed, retry the request with the new token
return response.request.newBuilder()
.header("Authorization", "Bearer $newToken")
.removeHeader("Retry-After-Auth") // Remove the retry header
.build()
}
// Token hasn't been refreshed, attempt to refresh it
val refreshResponse = runBlocking { // Use runBlocking to make the refresh call synchronous
try {
// Call your refresh token API
authApi.refreshToken(RefreshRequest(currentRefreshToken!!)) // Assuming refresh token is not null
} catch (e: Exception) {
// Refresh failed (e.g., refresh token expired, network error)
// Clear auth data and potentially navigate to login screen
authTokenManager.clearAuthData()
// You might need a way to signal the UI thread to navigate to login
null // Let the original request fail
}
}
return if (refreshResponse != null) {
// Refresh successful, save new tokens
authTokenManager.saveAuthToken(refreshResponse.token)
authTokenManager.saveRefreshToken(refreshResponse.refreshToken) // Save the new refresh token
// Retry the original request with the new access token
response.request.newBuilder()
.header("Authorization", "Bearer ${refreshResponse.token}")
.removeHeader("Retry-After-Auth")
.build()
} else {
// Refresh failed, original request should fail
null
}
}
}
}
// In your RetrofitClient:
/*
// You'll need to provide the AuthApi instance to the Authenticator
private val authApi: AuthApi by lazy {
Retrofit.Builder()
.baseUrl(BASE_URL) // Use a Retrofit instance without the Authenticator itself to avoid circular dependencies
.client(OkHttpClient.Builder().addInterceptor(loggingInterceptor).build()) // Basic client for refresh call
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(AuthApi::class.java)
}
private val tokenAuthenticator = TokenAuthenticator(authApi, AuthTokenManager)
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor()) // Add the interceptor for initial token
.authenticator(tokenAuthenticator) // Add the authenticator for token refresh
// ... other configurations
.build()
private val retrofit: Retrofit by lazy {
Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
*/
Important Note on runBlocking
: Using runBlocking
in an Authenticator is necessary because OkHttp's authenticate
method is synchronous. However, using runBlocking
can block the thread. Ensure your refresh token logic is quick and doesn't perform long-running operations. Consider the implications of blocking the OkHttp thread pool.
Alternative (Manual Refresh): Instead of an Authenticator, you can manually check for 401 responses in your API call handling code and trigger a refresh flow, then retry the original request.
e) Handling Logout
When the user logs out, clear the stored authentication data and navigate them back to the login screen.
fun performLogout() {
// Optionally call a backend logout endpoint to invalidate the token server-side
// viewModelScope.launch {
// try {
// RetrofitClient.authInstance.logout() // Example logout API
// } catch (e: Exception) {
// Log.e("Logout", "Logout API call failed: ${e.message}")
// // Continue with clearing local data even if API call fails
// }
// }
AuthTokenManager.clearAuthData()
// Navigate to the login screen
_navigateToLogin.value = true // Use LiveData or other mechanism to trigger navigation
}
4. Authorization
Authorization is primarily handled on the backend. The backend uses the authenticated user's identity (obtained from the token) to check if they have permission to access a resource or perform an action.
In the Android app, you handle the results of authorization checks from the backend:
- API Responses: The backend might return different HTTP status codes (e.g., 403 Forbidden) or specific error payloads if an action is not authorized.
- Conditional UI: Based on the user's role or permissions (often included in the authentication response or a separate user profile API), you can enable/disable UI elements or show/hide content in the app.
5. Security Best Practices
- Always use HTTPS: For all network communication, especially authentication.
- Secure Token Storage: Use
EncryptedSharedPreferences
or Android Keystore. - Avoid Storing Sensitive Data Locally: Don't store plain text passwords or other highly sensitive information.
- Implement Token Expiration and Refresh: This limits the impact if a token is compromised.
- Validate Input: Sanitize and validate all user input on both the client and server side.
- Backend Security: Authentication and authorization are primarily backend concerns. Ensure your backend is secure.
- Consider Biometric Authentication: Add biometric login as an option after initial username/password login for convenience and security.
- Regular Security Audits: Periodically review your app's security implementation.
Implementing robust authentication and authorization is critical for protecting user data and ensuring the security of your application and backend services.