Files
alfred-mobile/OAUTH_SETUP.md
jknapp 6d4ae2e5c3 Initial commit: Alfred Mobile - AI Assistant Android App
- OAuth authentication via Authentik
- WebSocket connection to OpenClaw gateway
- Configurable gateway URL with first-run setup
- User preferences sync across devices
- Multi-user support with custom assistant names
- ElevenLabs TTS integration (local + remote)
- FCM push notifications for alarms
- Voice input via Google Speech API
- No hardcoded secrets or internal IPs in tracked files
2026-02-09 11:12:51 -08:00

18 KiB

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:

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 version="1.0" encoding="utf-8"?>
<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" />
    <uses-permission android:name="android.permission.RECORD_AUDIO" />
    <uses-permission android:name="android.permission.WAKE_LOCK" />

    <application
        android:name=".AlfredApplication"
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/Theme.AlfredMobile">
        
        <!-- Main Activity -->
        <activity
            android:name=".ui.MainActivity"
            android:exported="true"
            android:theme="@style/Theme.AlfredMobile">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        
        <!-- OAuth Callback Activity - IMPORTANT! -->
        <activity
            android:name=".auth.OAuthCallbackActivity"
            android:exported="true"
            android:launchMode="singleTop">
            <intent-filter>
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />
                
                <data
                    android:scheme="alfredmobile"
                    android:host="oauth"
                    android:path="/callback" />
            </intent-filter>
        </activity>
        
    </application>

</manifest>

Step 3: Create Configuration Classes

app/src/main/java/com/example/alfredmobile/auth/OAuthConfig.kt:

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:

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:

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:

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:

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

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

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