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
-
Redirect URI Mismatch
- Ensure redirect URI in Android manifest matches Keycloak client configuration
- Check for correct scheme and host
- Verify deep link configuration
-
Token Refresh Failures
- Check if offline_access scope is included
- Verify refresh token expiration settings in Keycloak
- Ensure proper token storage
-
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
- Configure Security Settings - Add extra security layers including MFA options
- Set Up Applications - Configure your Android app client settings
- User Management - Manage users and their access
- Explore Other Integrations - See integration guides for other platforms