Files
alfred-mobile/OAUTH_SETUP.md

632 lines
18 KiB
Markdown
Raw Permalink Normal View History

# 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
<?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`:**
```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`