Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 8a12f73a authored by Ellen Poe's avatar Ellen Poe
Browse files

feat: user accounts and auth (wip)

parent dae43608
Loading
Loading
Loading
Loading
+4 −0
Original line number Diff line number Diff line
@@ -41,6 +41,9 @@ android {
        versionName = System.getenv("VERSION_NAME") ?: "debug"

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

        // AppAuth redirect scheme placeholder
        manifestPlaceholders["appAuthRedirectScheme"] = "earth.maps.cardinal"
    }

    flavorDimensions += "architecture"
@@ -182,6 +185,7 @@ dependencies {
    implementation(libs.ferrostar.composeui)
    implementation(libs.okhttp3)
    implementation(libs.androidaddressformatter)
    implementation(libs.appauth)

    // TODO: Migrate version to TOML (doesn't work). Likely related issue: https://github.com/gradle/gradle/issues/21267
    //noinspection UseTomlInstead
+1 −1
Original line number Diff line number Diff line
@@ -69,8 +69,8 @@

                <data android:scheme="geo" />
            </intent-filter>

        </activity>
    </application>


</manifest>
+72 −12
Original line number Diff line number Diff line
@@ -41,6 +41,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.rememberNavController
import com.google.gson.Gson
import dagger.hilt.android.AndroidEntryPoint
import earth.maps.cardinal.auth.AuthRepository
import earth.maps.cardinal.data.AppPreferenceRepository
import earth.maps.cardinal.data.LatLng
import earth.maps.cardinal.data.LocationRepository
@@ -58,6 +59,9 @@ import earth.maps.cardinal.ui.theme.AppTheme
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthorizationService
import java.lang.Double.parseDouble
import javax.inject.Inject

@@ -85,6 +89,9 @@ class MainActivity : ComponentActivity() {
    @Inject
    lateinit var savedListRepository: SavedListRepository

    @Inject
    lateinit var authRepository: AuthRepository

    private var localMapServerService: LocalMapServerService? = null
    private var bound by mutableStateOf(false)
    private var port by mutableStateOf<Int?>(null)
@@ -154,6 +161,19 @@ class MainActivity : ComponentActivity() {
        }
    }

    // Activity result launcher for auth flows (login/registration)
    private val authActivityResultLauncher = registerForActivityResult(
        ActivityResultContracts.StartActivityForResult()
    ) { result ->
        val intent = result.data
        if (result.resultCode == RESULT_OK && intent != null) {
            // Handle OAuth callback
            handleOAuthIntent(intent)
        } else {
            Log.d(TAG, "Auth activity canceled or failed")
        }
    }

    private val connection = object : ServiceConnection {
        override fun onServiceConnected(className: ComponentName, service: IBinder) {
            // We've bound to LocalMapServerService, cast the IBinder and get LocalMapServerService instance
@@ -190,17 +210,7 @@ class MainActivity : ComponentActivity() {
            savedListRepository.cleanupUnparentedElements()
        }

        intent?.takeIf { it.action == Intent.ACTION_VIEW }?.let { intent ->
            val data: Uri? = intent.data
            if (data != null && data.scheme != null && data.scheme.equals("geo")) {
                handleGeoIntent(data)
            }

            // Check for deep link destination
            if (deepLinkDestination == null) {
                deepLinkDestination = intent.getStringExtra(EXTRA_DEEP_LINK_DESTINATION)
            }
        }
        intent?.let { handleIntent(it) }

        setContent {
            val contrastLevel by appPreferenceRepository.contrastLevel.collectAsState()
@@ -225,7 +235,8 @@ class MainActivity : ComponentActivity() {
                    routeRepository = routeRepository,
                    appPreferenceRepository = appPreferenceRepository,
                    onRequestNotificationPermission = { requestNotificationPermission() },
                    hasNotificationPermission = hasNotificationPermission
                    hasNotificationPermission = hasNotificationPermission,
                    onStartActivityForResult = authActivityResultLauncher::launch
                )
            }
        }
@@ -255,6 +266,55 @@ class MainActivity : ComponentActivity() {
        return null
    }

    private fun handleIntent(intent: Intent) {
        val data: Uri? = intent.data
        if (data != null && intent.action == Intent.ACTION_VIEW) {
            if (data.scheme == "geo") {
                handleGeoIntent(data)
            } else if (data.scheme?.startsWith("earth.maps.cardinal") == true && data.host == "oauth2redirect") {
                // Handle OAuth callback
                handleOAuthIntent(intent)
            }

            // Check for deep link destination
            if (deepLinkDestination == null) {
                deepLinkDestination = intent.getStringExtra(EXTRA_DEEP_LINK_DESTINATION)
            }
        }
    }

    private fun handleOAuthIntent(intent: Intent) {
        Log.d(TAG, "Handling OAuth callback")
        val authorizationService = AuthorizationService(this)

        val authResponse = AuthorizationResponse.fromIntent(intent)
        val authException = AuthorizationException.fromIntent(intent)

        if (authResponse != null) {
            Log.d(TAG, "OAuth authorized: ${authResponse.authorizationCode}")
            authRepository.exchangeAuthorizationCode(authResponse) { result ->
                if (result.isSuccess) {
                    Log.d(TAG, "Token exchange successful")
                    // Tokens are stored in authRepository, state should update automatically
                } else {
                    Log.e(TAG, "Token exchange failed", result.exceptionOrNull())
                }
            }
        } else if (authException != null) {
            Log.e(TAG, "OAuth error: ${authException.message}")
            // Handle error, perhaps show a toast or navigate to error screen
        } else {
            Log.w(TAG, "No auth response")
        }

        authorizationService.dispose()
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        handleIntent(intent)
    }

    override fun onStart() {
        super.onStart()
        ferrostarWrapperRepository.androidTtsObserver.start()
+63 −0
Original line number Diff line number Diff line
package earth.maps.cardinal.auth

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch

class AuthManager(
    private val authRepository: AuthRepository,
    private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default + Job())
) {

    val authState: StateFlow<AuthState> = authRepository.authState.stateIn(
        scope = scope,
        started = SharingStarted.Eagerly,
        initialValue = AuthState.Unauthenticated
    )

    init {
        // Start monitoring and auto-refresh
        monitorTokenExpiry()
    }

    private fun monitorTokenExpiry() {
        scope.launch {
            while (true) {
                delay(60_000) // Check every minute
                when (val currentState = authState.value) {
                    is AuthState.Authenticated -> {
                        // TODO: Check if token is expiring soon (e.g., in less than 5 minutes)
                        // For now, naive refresh
                        authRepository.refreshAccessToken()
                    }
                    else -> {
                        // Do nothing if not authenticated
                    }
                }
            }
        }
    }

    fun startLogin() {
        // This could trigger the authorization flow externally
        // Actual launching is done from ViewModel/UI
    }

    fun startRegistration() {
        // Similar
    }

    fun logout() {
        authRepository.logout()
    }

    // Additional methods for common operations
    fun isAuthenticated(): Boolean = authState.value is AuthState.Authenticated

    fun getUserInfo(): UserInfo? = (authState.value as? AuthState.Authenticated)?.userInfo
}
+220 −0
Original line number Diff line number Diff line
/*
 *     Cardinal Maps
 *     Copyright (C) 2025 Cardinal Maps Authors
 *
 *     This program is free software: you can redistribute it and/or modify
 *     it under the terms of the GNU General Public License as published by
 *     the Free Software Foundation, either version 3 of the License, or
 *     (at your option) any later version.
 *
 *     This program is distributed in the hope that it will be useful,
 *     but WITHOUT ANY WARRANTY; without even the implied warranty of
 *     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *     GNU General Public License for more details.
 *
 *     You should have received a copy of the GNU General Public License
 *     along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

package earth.maps.cardinal.auth

import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.util.Base64
import android.util.Log
import androidx.core.content.edit
import androidx.core.net.toUri
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthorizationService
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.TokenRequest
import net.openid.appauth.TokenResponse

class AuthRepository(
    context: Context,
    private val authService: AuthorizationService
) {
    companion object {
        private const val PREFS_NAME = "auth_prefs"
        private const val KEY_ACCESS_TOKEN = "access_token"
        private const val KEY_REFRESH_TOKEN = "refresh_token"
        private const val KEY_TOKEN_EXPIRY = "token_expiry"
        private const val KEY_ID_TOKEN = "id_token"
        private const val TAG = "AuthRepository"

    }

    private val prefs: SharedPreferences =
        context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)

    private val serviceConfiguration = AuthorizationServiceConfiguration(
        KeycloakConfig.AUTHORIZATION_ENDPOINT.toUri(),
        KeycloakConfig.TOKEN_ENDPOINT.toUri(),
        KeycloakConfig.REGISTRATION_ENDPOINT.toUri(),
        KeycloakConfig.END_SESSION_ENDPOINT.toUri()
    )

    private val _authState = MutableStateFlow<AuthState>(AuthState.Unauthenticated)
    val authState: Flow<AuthState> = _authState

    init {
        loadInitialState()
    }

    private fun loadInitialState() {
        val accessToken = prefs.getString(KEY_ACCESS_TOKEN, null)
        val tokenExpiry = prefs.getLong(KEY_TOKEN_EXPIRY, 0L)
        val idToken = prefs.getString(KEY_ID_TOKEN, null)

        if (accessToken != null && System.currentTimeMillis() < tokenExpiry) {
            // Use ID token to get user info
            _authState.update { AuthState.Loading }
            if (idToken != null) {
                updateAuthStateFromIdToken(idToken)
            } else {
                _authState.update { AuthState.Unauthenticated }
            }
        } else {
            _authState.update { AuthState.Unauthenticated }
        }
    }

    private fun decodeIdToken(idToken: String): UserInfo {
        val parts = idToken.split(".")
        if (parts.size != 3) throw IllegalArgumentException("Invalid JWT")

        val payload =
            String(Base64.decode(parts[1], Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING))
        val jsonElement = Json.parseToJsonElement(payload)
        val jsonObject = jsonElement.jsonObject

        return UserInfo(
            sub = jsonObject["sub"]?.jsonPrimitive?.content ?: "",
            email = jsonObject["email"]?.jsonPrimitive?.content,
            name = jsonObject["name"]?.jsonPrimitive?.content,
            preferredUsername = jsonObject["preferred_username"]?.jsonPrimitive?.content
        )
    }

    private fun updateAuthStateFromIdToken(idToken: String) {
        try {
            val userInfo = decodeIdToken(idToken)
            _authState.update { AuthState.Authenticated(userInfo) }
        } catch (e: Exception) {
            Log.e(TAG, "Failed to decode ID token", e)
            _authState.update { AuthState.Error("Invalid token") }
        }
    }

    fun performAuthorizationRequest(isRegistration: Boolean): Intent? {
        val requestBuilder = AuthorizationRequest.Builder(
            serviceConfiguration,
            KeycloakConfig.CLIENT_ID,
            "code",
            KeycloakConfig.REDIRECT_URI.toUri()
        ).setScopes("openid", "profile", "email")

        if (isRegistration) {
            // Add parameter for registration
            requestBuilder.setAdditionalParameters(mapOf("kc_action" to "REGISTER"))
        }

        val request = requestBuilder.build()
        return try {
            authService.getAuthorizationRequestIntent(request)
        } catch (e: Exception) {
            Log.e(TAG, "Failed to perform authorization request", e)
            null
        }
    }

    fun exchangeAuthorizationCode(
        authResponse: AuthorizationResponse, callback: (Result<TokenResponse>) -> Unit
    ) {
        val tokenRequest = authResponse.createTokenExchangeRequest()

        authService.performTokenRequest(tokenRequest) { response, ex ->
            if (response != null) {
                // Store tokens
                prefs.edit {
                    putString(KEY_ACCESS_TOKEN, response.accessToken).putString(
                        KEY_REFRESH_TOKEN,
                        response.refreshToken
                    ).putLong(
                        KEY_TOKEN_EXPIRY,
                        response.accessTokenExpirationTime ?: 0
                    ).putString(KEY_ID_TOKEN, response.idToken)
                }

                // Token stored, now decode ID token to update state
                val idToken = response.idToken
                if (idToken != null) {
                    updateAuthStateFromIdToken(idToken)
                } else {
                    _authState.update { AuthState.Error("No ID token in response") }
                }
                callback(Result.success(response))
            } else {
                _authState.update { AuthState.Error(ex?.message ?: "Token exchange failed") }
                callback(Result.failure(ex ?: Exception("Unknown error")))
            }
        }
    }

    fun refreshAccessToken() {
        val refreshToken = prefs.getString(KEY_REFRESH_TOKEN, null)
        if (refreshToken == null) {
            _authState.update { AuthState.Unauthenticated }
            return
        }

        val tokenRequest = TokenRequest.Builder(serviceConfiguration, KeycloakConfig.CLIENT_ID)
            .setGrantType("refresh_token").setRefreshToken(refreshToken).build()

        authService.performTokenRequest(tokenRequest) { response, ex ->
            if (response != null) {
                // Update stored tokens
                prefs.edit {
                    putString(KEY_ACCESS_TOKEN, response.accessToken).putString(
                        KEY_REFRESH_TOKEN,
                        response.refreshToken
                    ).putLong(
                        KEY_TOKEN_EXPIRY,
                        response.accessTokenExpirationTime ?: 0
                    ).putString(KEY_ID_TOKEN, response.idToken)
                }

                // Update state with new user info from ID token if present
                val newIdToken = response.idToken
                if (newIdToken != null) {
                    updateAuthStateFromIdToken(newIdToken)
                } else {
                    // If no new ID token, keep current state or unauth
                    _authState.update { AuthState.Unauthenticated }
                }
            } else {
                logout()
            }
        }
    }

    fun logout() {
        prefs.edit {
            remove(KEY_ACCESS_TOKEN).remove(KEY_REFRESH_TOKEN).remove(KEY_TOKEN_EXPIRY)
                .remove(KEY_ID_TOKEN)
        }
        _authState.update { AuthState.Unauthenticated }
    }

    fun getAccessToken(): String? {
        return prefs.getString(KEY_ACCESS_TOKEN, null)
    }
}
Loading