Zum Inhalt springen

Android Integration

Dieser Inhalt ist noch nicht in deiner Sprache verfügbar.

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.

  • 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

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'
}
<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>
// 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"
}
// 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()
)
// 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"
    }
}
// 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")
            }
        }
    }
}
// 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
        )
    }
}
// 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)
        }
    }
}
// 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
)
// 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 = "test@example.com",
            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 = "test@example.com",
            name = "Test User",
            roles = listOf("editor")
        )
        
        authManager.updateAuthState(true, userInfo)
        
        assertTrue(authManager.hasAnyRole("admin", "editor"))
        assertFalse(authManager.hasAnyRole("admin", "manager"))
    }
}
// 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()
    }
}
# 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/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
    }
}
// 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")
    }
}
  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/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
    }
}