# Android App - OAuth Integration Guide ## Your Authentik Configuration ``` Authentik URL: https://auth.dnspegasus.net Client ID: QeSNaZPqZUz5pPClZMA2bakSsddkStiEhqbE4QZR Redirect URI: alfredmobile://oauth/callback Gateway URL: wss://alfred-app.dnspegasus.net ``` --- ## Step 1: Add Dependencies **`app/build.gradle.kts`:** ```kotlin dependencies { // Existing dependencies... // OAuth2 Authentication implementation("net.openid:appauth:0.11.1") // WebSocket (OkHttp) implementation("com.squareup.okhttp3:okhttp:4.12.0") // Coroutines implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") // Lifecycle implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") } ``` --- ## Step 2: Update AndroidManifest.xml Add the OAuth callback handler: **`app/src/main/AndroidManifest.xml`:** ```xml ``` --- ## Step 3: Create Configuration Classes **`app/src/main/java/com/example/alfredmobile/auth/OAuthConfig.kt`:** ```kotlin package com.example.alfredmobile.auth object OAuthConfig { const val AUTHENTIK_URL = "https://auth.dnspegasus.net" const val CLIENT_ID = "QeSNaZPqZUz5pPClZMA2bakSsddkStiEhqbE4QZR" const val REDIRECT_URI = "alfredmobile://oauth/callback" const val SCOPE = "openid profile email" const val AUTHORIZATION_ENDPOINT = "$AUTHENTIK_URL/application/o/authorize/" const val TOKEN_ENDPOINT = "$AUTHENTIK_URL/application/o/token/" const val USERINFO_ENDPOINT = "$AUTHENTIK_URL/application/o/userinfo/" } object AlfredConfig { const val GATEWAY_URL = "wss://alfred-app.dnspegasus.net" } ``` --- ## Step 4: Create Auth Manager **`app/src/main/java/com/example/alfredmobile/auth/AuthManager.kt`:** ```kotlin package com.example.alfredmobile.auth import android.app.Activity import android.content.Context import android.content.Intent import android.net.Uri import android.util.Log import net.openid.appauth.* import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException class AuthManager(private val context: Context) { companion object { private const val TAG = "AuthManager" const val AUTH_REQUEST_CODE = 1001 private const val PREFS_NAME = "alfred_auth" private const val KEY_ACCESS_TOKEN = "access_token" private const val KEY_REFRESH_TOKEN = "refresh_token" private const val KEY_ID_TOKEN = "id_token" private const val KEY_TOKEN_EXPIRY = "token_expiry" } private val serviceConfig = AuthorizationServiceConfiguration( Uri.parse(OAuthConfig.AUTHORIZATION_ENDPOINT), Uri.parse(OAuthConfig.TOKEN_ENDPOINT) ) private val authService = AuthorizationService(context) /** * Start the OAuth login flow * Opens the browser for user authentication */ fun startLogin(activity: Activity) { Log.d(TAG, "Starting OAuth login flow") val authRequest = AuthorizationRequest.Builder( serviceConfig, OAuthConfig.CLIENT_ID, ResponseTypeValues.CODE, Uri.parse(OAuthConfig.REDIRECT_URI) ) .setScope(OAuthConfig.SCOPE) .build() val authIntent = authService.getAuthorizationRequestIntent(authRequest) activity.startActivityForResult(authIntent, AUTH_REQUEST_CODE) } /** * Handle the OAuth redirect response * Call this from your Activity's onActivityResult or callback activity */ suspend fun handleAuthResponse(intent: Intent): AuthResult = suspendCancellableCoroutine { continuation -> val authResponse = AuthorizationResponse.fromIntent(intent) val authException = AuthorizationException.fromIntent(intent) when { authResponse != null -> { Log.d(TAG, "Authorization successful, exchanging code for token") // Exchange authorization code for access token val tokenRequest = authResponse.createTokenExchangeRequest() authService.performTokenRequest(tokenRequest) { tokenResponse, exception -> when { tokenResponse != null -> { val accessToken = tokenResponse.accessToken ?: "" val refreshToken = tokenResponse.refreshToken val idToken = tokenResponse.idToken val expiryTime = tokenResponse.accessTokenExpirationTime ?: 0L Log.d(TAG, "Token exchange successful") // Save tokens securely saveTokens(accessToken, refreshToken, idToken, expiryTime) continuation.resume( AuthResult.Success( accessToken = accessToken, refreshToken = refreshToken, idToken = idToken ) ) } exception != null -> { Log.e(TAG, "Token exchange failed", exception) continuation.resumeWithException( Exception("Token exchange failed: ${exception.message}") ) } } } } authException != null -> { Log.e(TAG, "Authorization failed", authException) continuation.resumeWithException( Exception("Authorization failed: ${authException.message}") ) } else -> { Log.e(TAG, "No auth response or exception found") continuation.resumeWithException( Exception("No auth response received") ) } } } /** * Get the stored access token */ fun getAccessToken(): String? { val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val token = prefs.getString(KEY_ACCESS_TOKEN, null) val expiry = prefs.getLong(KEY_TOKEN_EXPIRY, 0L) // Check if token is expired if (token != null && System.currentTimeMillis() < expiry) { return token } // Token expired or doesn't exist return null } /** * Check if user is logged in */ fun isLoggedIn(): Boolean { return getAccessToken() != null } /** * Clear stored tokens (logout) */ fun logout() { Log.d(TAG, "Logging out, clearing tokens") val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) prefs.edit() .clear() .apply() } /** * Save tokens to secure storage */ private fun saveTokens( accessToken: String, refreshToken: String?, idToken: String?, expiryTime: Long ) { Log.d(TAG, "Saving tokens to secure storage") val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) prefs.edit() .putString(KEY_ACCESS_TOKEN, accessToken) .putString(KEY_REFRESH_TOKEN, refreshToken) .putString(KEY_ID_TOKEN, idToken) .putLong(KEY_TOKEN_EXPIRY, expiryTime) .apply() } /** * Clean up resources */ fun dispose() { authService.dispose() } } /** * Result of authentication */ sealed class AuthResult { data class Success( val accessToken: String, val refreshToken: String?, val idToken: String? ) : AuthResult() data class Error(val message: String) : AuthResult() } ``` --- ## Step 5: Create OAuth Callback Activity **`app/src/main/java/com/example/alfredmobile/auth/OAuthCallbackActivity.kt`:** ```kotlin package com.example.alfredmobile.auth import android.content.Intent import android.os.Bundle import android.util.Log import android.widget.Toast import androidx.activity.ComponentActivity import androidx.lifecycle.lifecycleScope import com.example.alfredmobile.ui.MainActivity import kotlinx.coroutines.launch class OAuthCallbackActivity : ComponentActivity() { private lateinit var authManager: AuthManager override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) authManager = AuthManager(this) Log.d("OAuthCallback", "Received OAuth callback") // Handle the OAuth redirect lifecycleScope.launch { try { val result = authManager.handleAuthResponse(intent) when (result) { is AuthResult.Success -> { Log.d("OAuthCallback", "Login successful!") Toast.makeText( this@OAuthCallbackActivity, "Login successful!", Toast.LENGTH_SHORT ).show() // Navigate to main app val mainIntent = Intent(this@OAuthCallbackActivity, MainActivity::class.java) mainIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK startActivity(mainIntent) finish() } is AuthResult.Error -> { Log.e("OAuthCallback", "Login failed: ${result.message}") Toast.makeText( this@OAuthCallbackActivity, "Login failed: ${result.message}", Toast.LENGTH_LONG ).show() finish() } } } catch (e: Exception) { Log.e("OAuthCallback", "Error handling auth response", e) Toast.makeText( this@OAuthCallbackActivity, "Login error: ${e.message}", Toast.LENGTH_LONG ).show() finish() } } } override fun onDestroy() { super.onDestroy() authManager.dispose() } } ``` --- ## Step 6: Create Login Screen **`app/src/main/java/com/example/alfredmobile/ui/LoginScreen.kt`:** ```kotlin package com.example.alfredmobile.ui import android.app.Activity import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.example.alfredmobile.auth.AuthManager @Composable fun LoginScreen( onLoginClick: () -> Unit ) { val context = LocalContext.current Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { Column( modifier = Modifier .fillMaxSize() .padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Text( text = "🤵", style = MaterialTheme.typography.displayLarge, modifier = Modifier.padding(bottom = 16.dp) ) Text( text = "Alfred", style = MaterialTheme.typography.displayMedium, modifier = Modifier.padding(bottom = 8.dp) ) Text( text = "Your AI assistant", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 48.dp) ) Button( onClick = onLoginClick, modifier = Modifier .fillMaxWidth() .height(56.dp) ) { Text("Sign in with Authentik") } Spacer(modifier = Modifier.height(16.dp)) Text( text = "You'll be redirected to authenticate\nwith your Authentik account", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center ) } } } ``` --- ## Step 7: Update MainActivity **`app/src/main/java/com/example/alfredmobile/ui/MainActivity.kt`:** ```kotlin package com.example.alfredmobile.ui import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.runtime.* import com.example.alfredmobile.auth.AuthManager import com.example.alfredmobile.ui.theme.AlfredMobileTheme class MainActivity : ComponentActivity() { private lateinit var authManager: AuthManager override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) authManager = AuthManager(this) setContent { AlfredMobileTheme { var isLoggedIn by remember { mutableStateOf(authManager.isLoggedIn()) } if (isLoggedIn) { // Show main app UI MainScreen( onLogout = { authManager.logout() isLoggedIn = false } ) } else { // Show login screen LoginScreen( onLoginClick = { authManager.startLogin(this) } ) } } } } override fun onResume() { super.onResume() // Refresh login state when returning to activity setContent { AlfredMobileTheme { var isLoggedIn by remember { mutableStateOf(authManager.isLoggedIn()) } if (isLoggedIn) { MainScreen( onLogout = { authManager.logout() isLoggedIn = false } ) } else { LoginScreen( onLoginClick = { authManager.startLogin(this) } ) } } } } override fun onDestroy() { super.onDestroy() authManager.dispose() } } @Composable fun MainScreen(onLogout: () -> Unit) { // Placeholder main screen // You'll replace this with your actual chat UI Surface { Column( modifier = androidx.compose.ui.Modifier .fillMaxSize() .padding(16.dp) ) { Text("Main App Screen") Text("TODO: Add chat UI here") Spacer(modifier = androidx.compose.ui.Modifier.height(16.dp)) Button(onClick = onLogout) { Text("Logout") } } } } ``` --- ## Testing the OAuth Flow ### 1. Build and Install ```bash cd ~/.openclaw/workspace/alfred-mobile ./gradlew assembleDebug adb install app/build/outputs/apk/debug/app-debug.apk ``` ### 2. Test Login Flow 1. Open the app 2. Tap "Sign in with Authentik" 3. Browser opens to Authentik login 4. Enter your credentials 5. Browser redirects back to app 6. App should show "Login successful!" and navigate to main screen ### 3. Check Logs ```bash adb logcat | grep -E "AuthManager|OAuthCallback" ``` --- ## Next Steps After OAuth is working: 1. **Implement WebSocket connection** (see WEBSOCKET_INTEGRATION.md) 2. **Build chat UI** 3. **Add voice input** 4. **Implement lists, timers, notes** --- ## Troubleshooting ### "No browser available" Install a browser on your device/emulator (Chrome, Firefox, etc.) ### "Invalid redirect URI" Verify in Authentik that `alfredmobile://oauth/callback` is in the Redirect URIs list. ### "Token exchange failed" Check Client ID matches Authentik exactly: ``` QeSNaZPqZUz5pPClZMA2bakSsddkStiEhqbE4QZR ``` ### OAuth redirect doesn't return to app Check AndroidManifest.xml has the intent-filter for `alfredmobile://oauth/callback`