Android Integration

Android Integration

This guide covers how to integrate Skycloak authentication into Android applications using the AppAuth library, which implements OAuth 2.0 and OpenID Connect best practices.

Prerequisites

  • Android Studio Arctic Fox or later
  • Minimum SDK version 21 (Android 5.0)
  • Skycloak cluster with configured realm and mobile client
  • Basic understanding of Android development and Kotlin

Quick Start

1. Add Dependencies

Add to your app’s build.gradle:

dependencies {
    implementation 'net.openid:appauth:0.11.1'
    implementation 'com.squareup.okhttp3:okhttp:4.11.0'
    implementation 'com.auth0.android:jwtdecode:2.0.2'
    
    // Coroutines for async operations
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
    
    // Security
    implementation 'androidx.security:security-crypto:1.1.0-alpha06'
    
    // Lifecycle
    implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2'
    implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.2'
}

2. Configure AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    
    <!-- Permissions -->
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    
    <application>
        <!-- ... other configurations ... -->
        
        <!-- AppAuth RedirectUriReceiverActivity -->
        <activity
            android:name="net.openid.appauth.RedirectUriReceiverActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />
                
                <!-- Configure your redirect URI -->
                <data
                    android:scheme="com.yourcompany.app"
                    android:host="oauth"
                    android:path="/callback" />
            </intent-filter>
        </activity>
        
        <!-- Auth Activity -->
        <activity
            android:name=".auth.AuthActivity"
            android:exported="false"
            android:launchMode="singleTask" />
            
    </application>
</manifest>

3. Create Auth Configuration

// auth/AuthConfig.kt
object AuthConfig {
    const val AUTH_URI = "https://your-cluster-id.app.skycloak.io/realms/your-realm/protocol/openid-connect/auth"
    const val TOKEN_URI = "https://your-cluster-id.app.skycloak.io/realms/your-realm/protocol/openid-connect/token"
    const val END_SESSION_URI = "https://your-cluster-id.app.skycloak.io/realms/your-realm/protocol/openid-connect/logout"
    const val REGISTRATION_URI = "https://your-cluster-id.app.skycloak.io/realms/your-realm/protocol/openid-connect/registrations"
    
    const val CLIENT_ID = "your-android-app"
    const val REDIRECT_URI = "com.yourcompany.app://oauth/callback"
    const val POST_LOGOUT_REDIRECT_URI = "com.yourcompany.app://oauth/logout"
    const val SCOPE = "openid profile email offline_access"
    
    const val RESPONSE_TYPE = ResponseTypeValues.CODE
    const val PROMPT = "login consent"
}

4. Create Auth Manager

// auth/AuthManager.kt
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.activity.result.ActivityResultLauncher
import androidx.browser.customtabs.CustomTabsIntent
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import net.openid.appauth.*
import org.json.JSONException

class AuthManager(private val context: Context) {
    
    private val authService: AuthorizationService = AuthorizationService(context)
    private val authStateManager = AuthStateManager(context)
    
    private val _authState = MutableStateFlow(AuthUiState())
    val authState: StateFlow<AuthUiState> = _authState.asStateFlow()
    
    init {
        // Check if user is already authenticated
        val currentState = authStateManager.current
        updateAuthState(currentState.isAuthorized)
    }
    
    // Build auth configuration
    private fun buildAuthConfig(): AuthorizationServiceConfiguration {
        return AuthorizationServiceConfiguration(
            Uri.parse(AuthConfig.AUTH_URI),
            Uri.parse(AuthConfig.TOKEN_URI),
            null,
            Uri.parse(AuthConfig.END_SESSION_URI)
        )
    }
    
    // Create auth request
    fun createAuthRequest(): AuthorizationRequest {
        val config = buildAuthConfig()
        
        return AuthorizationRequest.Builder(
            config,
            AuthConfig.CLIENT_ID,
            AuthConfig.RESPONSE_TYPE,
            Uri.parse(AuthConfig.REDIRECT_URI)
        ).apply {
            setScope(AuthConfig.SCOPE)
            setPrompt(AuthConfig.PROMPT)
            // Enable PKCE
            setCodeVerifier(CodeVerifierUtil.generateRandomCodeVerifier())
        }.build()
    }
    
    // Perform authentication
    fun authenticate(launcher: ActivityResultLauncher<Intent>) {
        val authRequest = createAuthRequest()
        val authIntent = authService.getAuthorizationRequestIntent(authRequest)
        
        authStateManager.replace(AuthState(buildAuthConfig()))
        launcher.launch(authIntent)
    }
    
    // Handle auth response
    suspend fun handleAuthResponse(intent: Intent) {
        val response = AuthorizationResponse.fromIntent(intent)
        val exception = AuthorizationException.fromIntent(intent)
        
        authStateManager.updateAfterAuthorization(response, exception)
        
        if (response != null) {
            // Exchange code for tokens
            performTokenExchange(response)
        } else if (exception != null) {
            _authState.value = AuthUiState(
                isAuthenticated = false,
                error = exception.toJsonString()
            )
        }
    }
    
    // Exchange authorization code for tokens
    private suspend fun performTokenExchange(response: AuthorizationResponse) {
        val tokenRequest = response.createTokenExchangeRequest()
        
        authService.performTokenRequest(tokenRequest) { tokenResponse, exception ->
            authStateManager.updateAfterTokenResponse(tokenResponse, exception)
            
            if (tokenResponse != null) {
                // Successfully authenticated
                val userInfo = parseUserInfo(tokenResponse.idToken)
                updateAuthState(true, userInfo)
            } else if (exception != null) {
                updateAuthState(false, error = exception.toJsonString())
            }
        }
    }
    
    // Parse user info from ID token
    private fun parseUserInfo(idToken: String?): UserInfo? {
        if (idToken == null) return null
        
        return try {
            val jwt = JWT(idToken)
            UserInfo(
                id = jwt.subject ?: "",
                username = jwt.getClaim("preferred_username").asString() ?: "",
                email = jwt.getClaim("email").asString() ?: "",
                name = jwt.getClaim("name").asString() ?: "",
                roles = jwt.getClaim("realm_access")
                    .asObject(RealmAccess::class.java)?.roles ?: emptyList()
            )
        } catch (e: Exception) {
            null
        }
    }
    
    // Refresh token
    suspend fun refreshToken() {
        val currentState = authStateManager.current
        
        if (!currentState.needsTokenRefresh) return
        
        currentState.performActionWithFreshTokens(authService) { _, _, exception ->
            if (exception != null) {
                // Token refresh failed, need to re-authenticate
                updateAuthState(false, error = "Token refresh failed")
            }
        }
    }
    
    // Logout
    fun logout() {
        val currentState = authStateManager.current
        val idToken = currentState.idToken
        
        if (idToken != null) {
            val logoutRequest = EndSessionRequest.Builder(buildAuthConfig())
                .setIdTokenHint(idToken)
                .setPostLogoutRedirectUri(Uri.parse(AuthConfig.POST_LOGOUT_REDIRECT_URI))
                .build()
            
            val logoutIntent = authService.getEndSessionRequestIntent(logoutRequest)
            
            val customTabsIntent = CustomTabsIntent.Builder().build()
            customTabsIntent.launchUrl(context, logoutRequest.toUri())
        }
        
        // Clear local auth state
        authStateManager.clear()
        updateAuthState(false)
    }
    
    // Get access token
    suspend fun getAccessToken(): String? {
        val currentState = authStateManager.current
        
        return if (currentState.isAuthorized) {
            // Refresh if needed
            if (currentState.needsTokenRefresh) {
                refreshToken()
            }
            currentState.accessToken
        } else {
            null
        }
    }
    
    // Update auth state
    private fun updateAuthState(
        isAuthenticated: Boolean,
        userInfo: UserInfo? = null,
        error: String? = null
    ) {
        _authState.value = AuthUiState(
            isAuthenticated = isAuthenticated,
            userInfo = userInfo,
            error = error
        )
    }
    
    // Check if user has role
    fun hasRole(role: String): Boolean {
        return _authState.value.userInfo?.roles?.contains(role) ?: false
    }
    
    // Check if user has any of the roles
    fun hasAnyRole(vararg roles: String): Boolean {
        val userRoles = _authState.value.userInfo?.roles ?: return false
        return roles.any { it in userRoles }
    }
    
    fun dispose() {
        authService.dispose()
    }
}

// Data classes
data class AuthUiState(
    val isAuthenticated: Boolean = false,
    val userInfo: UserInfo? = null,
    val isLoading: Boolean = false,
    val error: String? = null
)

data class UserInfo(
    val id: String,
    val username: String,
    val email: String,
    val name: String,
    val roles: List<String> = emptyList()
)

data class RealmAccess(
    val roles: List<String> = emptyList()
)

5. Create Auth State Manager

// auth/AuthStateManager.kt
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import net.openid.appauth.AuthState

class AuthStateManager(context: Context) {
    
    private val masterKey = MasterKey.Builder(context)
        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
        .build()
    
    private val sharedPreferences = EncryptedSharedPreferences.create(
        context,
        PREFS_NAME,
        masterKey,
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
    )
    
    var current: AuthState
        get() {
            val stateJson = sharedPreferences.getString(KEY_AUTH_STATE, null)
            return if (stateJson != null) {
                AuthState.jsonDeserialize(stateJson)
            } else {
                AuthState()
            }
        }
        private set(authState) {
            sharedPreferences.edit()
                .putString(KEY_AUTH_STATE, authState.jsonSerializeString())
                .apply()
        }
    
    fun replace(authState: AuthState) {
        current = authState
    }
    
    fun updateAfterAuthorization(response: AuthorizationResponse?, exception: AuthorizationException?) {
        val authState = current
        authState.update(response, exception)
        current = authState
    }
    
    fun updateAfterTokenResponse(response: TokenResponse?, exception: AuthorizationException?) {
        val authState = current
        authState.update(response, exception)
        current = authState
    }
    
    fun clear() {
        sharedPreferences.edit().clear().apply()
    }
    
    companion object {
        private const val PREFS_NAME = "auth_state_prefs"
        private const val KEY_AUTH_STATE = "auth_state"
    }
}

UI Implementation

Login Activity

// auth/LoginActivity.kt
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch

class LoginActivity : ComponentActivity() {
    
    private lateinit var authManager: AuthManager
    
    private val authLauncher = registerForActivityResult(
        ActivityResultContracts.StartActivityForResult()
    ) { result ->
        result.data?.let { intent ->
            lifecycleScope.launch {
                authManager.handleAuthResponse(intent)
            }
        }
    }
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        authManager = (application as MyApplication).authManager
        
        setContent {
            MyAppTheme {
                LoginScreen(
                    authState = authManager.authState.collectAsState().value,
                    onLoginClick = { performLogin() },
                    onRegisterClick = { performRegister() }
                )
            }
        }
        
        // Observe auth state
        lifecycleScope.launch {
            authManager.authState.collect { state ->
                if (state.isAuthenticated) {
                    navigateToMain()
                }
            }
        }
    }
    
    private fun performLogin() {
        authManager.authenticate(authLauncher)
    }
    
    private fun performRegister() {
        // Similar to login but with registration hint
        authManager.authenticate(authLauncher)
    }
    
    private fun navigateToMain() {
        startActivity(Intent(this, MainActivity::class.java))
        finish()
    }
}

@Composable
fun LoginScreen(
    authState: AuthUiState,
    onLoginClick: () -> Unit,
    onRegisterClick: () -> Unit
) {
    Surface(
        modifier = Modifier.fillMaxSize(),
        color = MaterialTheme.colorScheme.background
    ) {
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(24.dp),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center
        ) {
            Text(
                text = "Welcome to MyApp",
                style = MaterialTheme.typography.headlineLarge,
                modifier = Modifier.padding(bottom = 32.dp)
            )
            
            if (authState.error != null) {
                Card(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(bottom = 16.dp),
                    colors = CardDefaults.cardColors(
                        containerColor = MaterialTheme.colorScheme.errorContainer
                    )
                ) {
                    Text(
                        text = "Authentication failed: ${authState.error}",
                        modifier = Modifier.padding(16.dp),
                        color = MaterialTheme.colorScheme.onErrorContainer
                    )
                }
            }
            
            Button(
                onClick = onLoginClick,
                modifier = Modifier
                    .fillMaxWidth()
                    .height(56.dp),
                enabled = !authState.isLoading
            ) {
                if (authState.isLoading) {
                    CircularProgressIndicator(
                        modifier = Modifier.size(24.dp),
                        color = MaterialTheme.colorScheme.onPrimary
                    )
                } else {
                    Text("Sign In with Skycloak")
                }
            }
            
            Spacer(modifier = Modifier.height(16.dp))
            
            OutlinedButton(
                onClick = onRegisterClick,
                modifier = Modifier
                    .fillMaxWidth()
                    .height(56.dp),
                enabled = !authState.isLoading
            ) {
                Text("Create Account")
            }
        }
    }
}

Main Activity with Protected Content

// MainActivity.kt
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import kotlinx.coroutines.launch

class MainActivity : ComponentActivity() {
    
    private lateinit var authManager: AuthManager
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        authManager = (application as MyApplication).authManager
        
        setContent {
            MyAppTheme {
                val authState = authManager.authState.collectAsState().value
                
                if (!authState.isAuthenticated) {
                    // Redirect to login if not authenticated
                    LaunchedEffect(Unit) {
                        navigateToLogin()
                    }
                } else {
                    MainScreen(
                        authState = authState,
                        authManager = authManager,
                        onLogout = { performLogout() }
                    )
                }
            }
        }
    }
    
    private fun performLogout() {
        authManager.logout()
        navigateToLogin()
    }
    
    private fun navigateToLogin() {
        startActivity(Intent(this, LoginActivity::class.java))
        finish()
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(
    authState: AuthUiState,
    authManager: AuthManager,
    onLogout: () -> Unit
) {
    val navController = rememberNavController()
    
    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("MyApp") },
                actions = {
                    IconButton(onClick = onLogout) {
                        Icon(Icons.Default.Logout, contentDescription = "Logout")
                    }
                }
            )
        },
        bottomBar = {
            NavigationBar {
                NavigationBarItem(
                    icon = { Icon(Icons.Default.Home, contentDescription = "Home") },
                    label = { Text("Home") },
                    selected = false,
                    onClick = { navController.navigate("home") }
                )
                NavigationBarItem(
                    icon = { Icon(Icons.Default.Person, contentDescription = "Profile") },
                    label = { Text("Profile") },
                    selected = false,
                    onClick = { navController.navigate("profile") }
                )
                if (authManager.hasRole("admin")) {
                    NavigationBarItem(
                        icon = { Icon(Icons.Default.Settings, contentDescription = "Admin") },
                        label = { Text("Admin") },
                        selected = false,
                        onClick = { navController.navigate("admin") }
                    )
                }
            }
        }
    ) { paddingValues ->
        NavHost(
            navController = navController,
            startDestination = "home",
            modifier = Modifier.padding(paddingValues)
        ) {
            composable("home") {
                HomeScreen(authState)
            }
            composable("profile") {
                ProfileScreen(authState)
            }
            composable("admin") {
                if (authManager.hasRole("admin")) {
                    AdminScreen()
                } else {
                    AccessDeniedScreen()
                }
            }
        }
    }
}

@Composable
fun HomeScreen(authState: AuthUiState) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    ) {
        Text(
            text = "Welcome, ${authState.userInfo?.name ?: "User"}!",
            style = MaterialTheme.typography.headlineMedium,
            modifier = Modifier.padding(bottom = 16.dp)
        )
        
        Card(
            modifier = Modifier.fillMaxWidth()
        ) {
            Column(
                modifier = Modifier.padding(16.dp)
            ) {
                Text(
                    text = "Your Dashboard",
                    style = MaterialTheme.typography.titleLarge,
                    modifier = Modifier.padding(bottom = 8.dp)
                )
                Text("This is your personalized dashboard content.")
            }
        }
    }
}

@Composable
fun ProfileScreen(authState: AuthUiState) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    ) {
        Text(
            text = "User Profile",
            style = MaterialTheme.typography.headlineMedium,
            modifier = Modifier.padding(bottom = 16.dp)
        )
        
        authState.userInfo?.let { user ->
            ProfileItem("Username", user.username)
            ProfileItem("Email", user.email)
            ProfileItem("Name", user.name)
            ProfileItem("ID", user.id)
            ProfileItem("Roles", user.roles.joinToString(", "))
        }
    }
}

@Composable
fun ProfileItem(label: String, value: String) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(vertical = 8.dp)
    ) {
        Text(
            text = "$label:",
            modifier = Modifier.weight(0.3f),
            style = MaterialTheme.typography.bodyMedium
        )
        Text(
            text = value,
            modifier = Modifier.weight(0.7f),
            style = MaterialTheme.typography.bodyLarge
        )
    }
}

API Integration

Authenticated API Client

// network/AuthenticatedApiClient.kt
import kotlinx.coroutines.runBlocking
import okhttp3.*
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit

class AuthenticatedApiClient(
    private val authManager: AuthManager,
    private val baseUrl: String
) {
    
    private val okHttpClient: OkHttpClient by lazy {
        OkHttpClient.Builder()
            .addInterceptor(AuthInterceptor(authManager))
            .addInterceptor(HttpLoggingInterceptor().apply {
                level = if (BuildConfig.DEBUG) {
                    HttpLoggingInterceptor.Level.BODY
                } else {
                    HttpLoggingInterceptor.Level.NONE
                }
            })
            .connectTimeout(30, TimeUnit.SECONDS)
            .readTimeout(30, TimeUnit.SECONDS)
            .writeTimeout(30, TimeUnit.SECONDS)
            .build()
    }
    
    val retrofit: Retrofit by lazy {
        Retrofit.Builder()
            .baseUrl(baseUrl)
            .client(okHttpClient)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }
    
    inline fun <reified T> createService(): T {
        return retrofit.create(T::class.java)
    }
}

class AuthInterceptor(
    private val authManager: AuthManager
) : Interceptor {
    
    override fun intercept(chain: Interceptor.Chain): Response {
        val originalRequest = chain.request()
        
        // Get access token
        val accessToken = runBlocking {
            authManager.getAccessToken()
        }
        
        return if (accessToken != null) {
            val authenticatedRequest = originalRequest.newBuilder()
                .header("Authorization", "Bearer $accessToken")
                .build()
            
            val response = chain.proceed(authenticatedRequest)
            
            // Handle 401 responses
            if (response.code == 401) {
                response.close()
                
                // Try to refresh token
                runBlocking {
                    authManager.refreshToken()
                }
                
                // Retry with new token
                val newToken = runBlocking {
                    authManager.getAccessToken()
                }
                
                if (newToken != null) {
                    val retryRequest = originalRequest.newBuilder()
                        .header("Authorization", "Bearer $newToken")
                        .build()
                    chain.proceed(retryRequest)
                } else {
                    // Refresh failed, return original 401 response
                    response
                }
            } else {
                response
            }
        } else {
            // No token available, proceed without auth header
            chain.proceed(originalRequest)
        }
    }
}

API Service Definition

// network/ApiService.kt
import retrofit2.http.*

interface ApiService {
    @GET("user/profile")
    suspend fun getUserProfile(): UserProfileResponse
    
    @PUT("user/profile")
    suspend fun updateUserProfile(@Body profile: UpdateProfileRequest): UserProfileResponse
    
    @GET("user/settings")
    suspend fun getUserSettings(): UserSettingsResponse
    
    @POST("user/change-password")
    suspend fun changePassword(@Body request: ChangePasswordRequest): GenericResponse
    
    @GET("admin/users")
    suspend fun getUsers(@Query("page") page: Int = 1): UsersResponse
    
    @DELETE("admin/users/{id}")
    suspend fun deleteUser(@Path("id") userId: String): GenericResponse
}

// Data classes
data class UserProfileResponse(
    val id: String,
    val username: String,
    val email: String,
    val name: String,
    val avatar: String?,
    val createdAt: String,
    val updatedAt: String
)

data class UpdateProfileRequest(
    val name: String,
    val avatar: String?
)

data class UserSettingsResponse(
    val notifications: NotificationSettings,
    val privacy: PrivacySettings
)

data class NotificationSettings(
    val email: Boolean,
    val push: Boolean,
    val sms: Boolean
)

data class PrivacySettings(
    val profileVisibility: String,
    val showEmail: Boolean
)

data class ChangePasswordRequest(
    val currentPassword: String,
    val newPassword: String
)

data class GenericResponse(
    val success: Boolean,
    val message: String
)

data class UsersResponse(
    val users: List<UserProfileResponse>,
    val totalPages: Int,
    val currentPage: Int
)

Testing

Unit Tests

// test/AuthManagerTest.kt
import io.mockk.*
import kotlinx.coroutines.test.runTest
import net.openid.appauth.*
import org.junit.Before
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class AuthManagerTest {
    
    private lateinit var context: Context
    private lateinit var authService: AuthorizationService
    private lateinit var authStateManager: AuthStateManager
    private lateinit var authManager: AuthManager
    
    @Before
    fun setup() {
        context = mockk(relaxed = true)
        authService = mockk(relaxed = true)
        authStateManager = mockk(relaxed = true)
        
        mockkConstructor(AuthorizationService::class)
        every { anyConstructed<AuthorizationService>() } returns authService
        
        authManager = AuthManager(context)
    }
    
    @Test
    fun `test createAuthRequest creates valid request`() {
        val request = authManager.createAuthRequest()
        
        assertEquals(AuthConfig.CLIENT_ID, request.clientId)
        assertEquals(AuthConfig.REDIRECT_URI, request.redirectUri.toString())
        assertEquals(AuthConfig.SCOPE, request.scope)
        assertTrue(request.codeVerifier != null)
    }
    
    @Test
    fun `test hasRole returns correct value`() = runTest {
        val userInfo = UserInfo(
            id = "123",
            username = "testuser",
            email = "[email protected]",
            name = "Test User",
            roles = listOf("user", "admin")
        )
        
        authManager.updateAuthState(true, userInfo)
        
        assertTrue(authManager.hasRole("admin"))
        assertTrue(authManager.hasRole("user"))
        assertFalse(authManager.hasRole("superadmin"))
    }
    
    @Test
    fun `test hasAnyRole returns correct value`() = runTest {
        val userInfo = UserInfo(
            id = "123",
            username = "testuser",
            email = "[email protected]",
            name = "Test User",
            roles = listOf("editor")
        )
        
        authManager.updateAuthState(true, userInfo)
        
        assertTrue(authManager.hasAnyRole("admin", "editor"))
        assertFalse(authManager.hasAnyRole("admin", "manager"))
    }
}

UI Tests

// androidTest/LoginActivityTest.kt
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class LoginActivityTest {
    
    @get:Rule
    val composeTestRule = createAndroidComposeRule<LoginActivity>()
    
    @Test
    fun testLoginButtonDisplayed() {
        composeTestRule.onNodeWithText("Sign In with Skycloak")
            .assertIsDisplayed()
            .assertIsEnabled()
    }
    
    @Test
    fun testRegisterButtonDisplayed() {
        composeTestRule.onNodeWithText("Create Account")
            .assertIsDisplayed()
            .assertIsEnabled()
    }
    
    @Test
    fun testLoginButtonClick() {
        composeTestRule.onNodeWithText("Sign In with Skycloak")
            .performClick()
        
        // Verify that auth flow is initiated
        // This would typically involve mocking the AuthManager
    }
    
    @Test
    fun testErrorMessageDisplayed() {
        // Set error state
        composeTestRule.activity.runOnUiThread {
            // Update auth state with error
        }
        
        composeTestRule.onNodeWithText("Authentication failed", substring = true)
            .assertIsDisplayed()
    }
}

Production Considerations

ProGuard Rules

# AppAuth
-keep class net.openid.appauth.** { *; }
-keepattributes *Annotation*

# Retrofit
-keepattributes Signature, InnerClasses, EnclosingMethod
-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
-keepclassmembers,allowshrinking,allowobfuscation interface * {
    @retrofit2.http.* <methods>;
}

# Gson
-keepattributes Signature
-keepattributes *Annotation*
-dontwarn sun.misc.**
-keep class com.google.gson.examples.android.model.** { <fields>; }
-keep class * extends com.google.gson.TypeAdapter
-keep class * implements com.google.gson.TypeAdapterFactory
-keep class * implements com.google.gson.JsonSerializer
-keep class * implements com.google.gson.JsonDeserializer

# Your model classes
-keep class com.yourcompany.app.model.** { *; }
-keep class com.yourcompany.app.auth.** { *; }

Security Best Practices

// security/SecurityConfig.kt
object SecurityConfig {
    
    // Certificate pinning
    fun getCertificatePinner(): CertificatePinner {
        return CertificatePinner.Builder()
            .add("your-cluster-id.app.skycloak.io", "sha256/YOUR_PIN_HERE")
            .build()
    }
    
    // Network security config (res/xml/network_security_config.xml)
    /*
    <?xml version="1.0" encoding="utf-8"?>
    <network-security-config>
        <domain-config cleartextTrafficPermitted="false">
            <domain includeSubdomains="true">your-cluster-id.app.skycloak.io</domain>
            <pin-set expiration="2025-01-01">
                <pin digest="SHA-256">YOUR_PIN_HERE</pin>
                <pin digest="SHA-256">BACKUP_PIN_HERE</pin>
            </pin-set>
        </domain-config>
    </network-security-config>
    */
    
    // Root detection
    fun isDeviceRooted(): Boolean {
        return RootBeer(context).isRooted
    }
    
    // App integrity check
    fun verifyAppIntegrity(context: Context): Boolean {
        try {
            val packageInfo = context.packageManager
                .getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES)
            
            val signatures = packageInfo.signatures
            val expectedSignature = "YOUR_APP_SIGNATURE"
            
            for (signature in signatures) {
                val signatureHash = signature.toByteArray().toSHA256()
                if (signatureHash == expectedSignature) {
                    return true
                }
            }
        } catch (e: Exception) {
            e.printStackTrace()
        }
        return false
    }
}

Performance Monitoring

// monitoring/PerformanceMonitor.kt
import com.google.firebase.perf.FirebasePerformance
import com.google.firebase.perf.metrics.Trace

object PerformanceMonitor {
    
    private val performanceInstance = FirebasePerformance.getInstance()
    private val traces = mutableMapOf<String, Trace>()
    
    fun startTrace(name: String) {
        val trace = performanceInstance.newTrace(name)
        trace.start()
        traces[name] = trace
    }
    
    fun stopTrace(name: String) {
        traces[name]?.stop()
        traces.remove(name)
    }
    
    fun recordAuthEvent(event: String, attributes: Map<String, String> = emptyMap()) {
        val trace = performanceInstance.newTrace("auth_$event")
        attributes.forEach { (key, value) ->
            trace.putAttribute(key, value)
        }
        trace.start()
        trace.stop()
    }
    
    fun recordApiCall(endpoint: String, duration: Long, success: Boolean) {
        val trace = performanceInstance.newTrace("api_call")
        trace.putAttribute("endpoint", endpoint)
        trace.putAttribute("success", success.toString())
        trace.putMetric("duration_ms", duration)
        trace.start()
        trace.stop()
    }
}

// Usage in AuthManager
class AuthManager(context: Context) {
    
    fun authenticate(launcher: ActivityResultLauncher<Intent>) {
        PerformanceMonitor.startTrace("auth_flow")
        // ... existing code ...
    }
    
    suspend fun handleAuthResponse(intent: Intent) {
        // ... existing code ...
        PerformanceMonitor.stopTrace("auth_flow")
        PerformanceMonitor.recordAuthEvent("login_complete")
    }
}

Troubleshooting

Common Issues

  1. Redirect URI Mismatch

    • Ensure redirect URI in Android manifest matches Keycloak client configuration
    • Check for correct scheme and host
    • Verify deep link configuration
  2. Token Refresh Failures

    • Check if offline_access scope is included
    • Verify refresh token expiration settings in Keycloak
    • Ensure proper token storage
  3. Network Security Policy

    • For development, you may need to allow cleartext traffic
    • In production, always use HTTPS
    • Implement certificate pinning

Debug Utilities

// debug/AuthDebugger.kt
object AuthDebugger {
    
    fun logAuthState(authState: AuthState) {
        Log.d("AuthDebugger", "=== Auth State ===")
        Log.d("AuthDebugger", "Authorized: ${authState.isAuthorized}")
        Log.d("AuthDebugger", "Access Token: ${authState.accessToken?.substring(0, 20)}...")
        Log.d("AuthDebugger", "Expires At: ${authState.accessTokenExpirationTime}")
        Log.d("AuthDebugger", "Refresh Token: ${authState.refreshToken != null}")
        Log.d("AuthDebugger", "ID Token: ${authState.idToken?.substring(0, 20)}...")
    }
    
    fun logTokenClaims(idToken: String?) {
        if (idToken == null) return
        
        try {
            val jwt = JWT(idToken)
            Log.d("AuthDebugger", "=== Token Claims ===")
            Log.d("AuthDebugger", "Subject: ${jwt.subject}")
            Log.d("AuthDebugger", "Issuer: ${jwt.issuer}")
            Log.d("AuthDebugger", "Audience: ${jwt.audience}")
            Log.d("AuthDebugger", "Expires At: ${jwt.expiresAt}")
            
            jwt.claims.forEach { (key, claim) ->
                Log.d("AuthDebugger", "$key: ${claim.asString()}")
            }
        } catch (e: Exception) {
            Log.e("AuthDebugger", "Failed to parse token", e)
        }
    }
    
    fun checkConnectivity(context: Context): Boolean {
        val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
        val activeNetwork = cm.activeNetworkInfo
        return activeNetwork?.isConnected == true
    }
}

Next Steps