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.
Prerequisites
Section titled “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
Section titled “Quick Start”1. Add Dependencies
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “UI Implementation”Login Activity
Section titled “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
Section titled “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
Section titled “API Integration”Authenticated API Client
Section titled “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
Section titled “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
Section titled “Testing”Unit Tests
Section titled “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 = "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"))
}
}
UI Tests
Section titled “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
Section titled “Production Considerations”ProGuard Rules
Section titled “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
Section titled “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
Section titled “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
Section titled “Troubleshooting”Common Issues
Section titled “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
Section titled “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
Section titled “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