Implement Google->Firebase->Backend auth flow
- Add authGoogleNew GraphQL mutation and token exchange in AuthServiceImpl - Add ApiTokenStore and SharedPreferencesApiTokenStore for API token persistence - Add ApolloAuthInterceptor to inject Bearer token on GraphQL requests - Introduce FirebaseSessionManager for testable Firebase auth orchestration - Update LoginViewModel to surface backend auth errors - Add unit tests for Firebase failure, backend failure, and auth state Made-with: Cursor
This commit is contained in:
@@ -78,7 +78,12 @@ class LoginViewModel @Inject constructor(
|
||||
if (signInResult.isSuccess) {
|
||||
_actions.tryEmit(Action.OpenManage)
|
||||
} else {
|
||||
_actions.tryEmit(Action.ShowMessage(appContext.getString(R.string.toast_oauth_failed)))
|
||||
val message = signInResult.exceptionOrNull()?.message
|
||||
_actions.tryEmit(
|
||||
Action.ShowMessage(
|
||||
message ?: appContext.getString(R.string.toast_oauth_failed)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ ksp = "2.0.21-1.0.27"
|
||||
google-services = "4.4.4"
|
||||
androidxCredentials = "1.5.0"
|
||||
googleid = "1.1.1"
|
||||
kotlinxCoroutinesTest = "1.9.0"
|
||||
mockk = "1.13.13"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -55,6 +57,8 @@ androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navig
|
||||
androidx-credentials = { group = "androidx.credentials", name = "credentials", version.ref = "androidxCredentials" }
|
||||
androidx-credentials-play-services-auth = { group = "androidx.credentials", name = "credentials-play-services-auth", version.ref = "androidxCredentials" }
|
||||
googleid = { group = "com.google.android.libraries.identity.googleid", name = "googleid", version.ref = "googleid" }
|
||||
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" }
|
||||
mockk-jvm = { group = "io.mockk", name = "mockk-jvm", version.ref = "mockk" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
@@ -62,4 +62,6 @@ dependencies {
|
||||
ksp(libs.hilt.compiler)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.mockk.jvm)
|
||||
}
|
||||
|
||||
10
sdk/src/main/graphql/AuthGoogleNew.graphql
Normal file
10
sdk/src/main/graphql/AuthGoogleNew.graphql
Normal file
@@ -0,0 +1,10 @@
|
||||
mutation AuthGoogleNew($accessToken: String!) {
|
||||
authGoogleNew(input: { accessToken: $accessToken }) {
|
||||
token
|
||||
name
|
||||
email
|
||||
roles
|
||||
_id
|
||||
regDate
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.db3.airmq.sdk.auth
|
||||
|
||||
interface ApiTokenStore {
|
||||
fun getToken(): String?
|
||||
fun saveToken(token: String): Result<Unit>
|
||||
fun clearToken(): Result<Unit>
|
||||
}
|
||||
@@ -1,25 +1,26 @@
|
||||
package org.db3.airmq.sdk.auth
|
||||
|
||||
import com.google.android.gms.tasks.Task
|
||||
import com.google.firebase.auth.FirebaseAuth
|
||||
import com.google.firebase.auth.FirebaseUser
|
||||
import com.google.firebase.auth.GoogleAuthProvider
|
||||
import com.apollographql.apollo.ApolloClient
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import org.db3.airmq.sdk.AuthGoogleNewMutation
|
||||
import org.db3.airmq.sdk.auth.model.AuthProvider
|
||||
import org.db3.airmq.sdk.auth.model.User
|
||||
|
||||
@Singleton
|
||||
class FirebaseAuthService @Inject constructor(
|
||||
private val firebaseAuth: FirebaseAuth
|
||||
private val firebaseSessionManager: FirebaseSessionManager,
|
||||
private val apolloClient: ApolloClient,
|
||||
private val apiTokenStore: ApiTokenStore
|
||||
) : AuthService {
|
||||
|
||||
override suspend fun getUser(): User? = firebaseAuth.currentUser?.toUser()
|
||||
override suspend fun getUser(): User? = firebaseSessionManager.getUser()
|
||||
|
||||
override suspend fun isAuthenticated(): Boolean = firebaseAuth.currentUser != null
|
||||
override suspend fun isAuthenticated(): Boolean {
|
||||
val hasFirebaseUser = firebaseSessionManager.isSignedIn()
|
||||
val hasApiToken = !apiTokenStore.getToken().isNullOrBlank()
|
||||
return hasFirebaseUser && hasApiToken
|
||||
}
|
||||
|
||||
override suspend fun signIn(provider: AuthProvider, token: String): Result<User> = runCatching {
|
||||
require(token.isNotBlank()) { "ID token is required for Google sign-in." }
|
||||
@@ -29,32 +30,26 @@ class FirebaseAuthService @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun signOut(): Result<Unit> = runCatching {
|
||||
firebaseAuth.signOut()
|
||||
firebaseSessionManager.signOut()
|
||||
apiTokenStore.clearToken().getOrThrow()
|
||||
}
|
||||
|
||||
private suspend fun signInWithGoogle(token: String): User {
|
||||
val credential = GoogleAuthProvider.getCredential(token, null)
|
||||
val authResult = firebaseAuth.signInWithCredential(credential).awaitResult()
|
||||
val firebaseUser = authResult.user ?: error("Google sign-in completed without a Firebase user.")
|
||||
return firebaseUser.toUser()
|
||||
val firebaseSessionUser = firebaseSessionManager.signInWithGoogle(token)
|
||||
val apiToken = exchangeFirebaseTokenWithBackend(firebaseSessionUser.firebaseAccessToken)
|
||||
apiTokenStore.saveToken(apiToken).getOrThrow()
|
||||
return firebaseSessionUser.user
|
||||
}
|
||||
|
||||
private fun FirebaseUser.toUser(): User = User(
|
||||
userId = uid,
|
||||
email = email,
|
||||
displayName = displayName,
|
||||
isAuthenticated = true
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun <T> Task<T>.awaitResult(): T = suspendCancellableCoroutine { continuation ->
|
||||
addOnSuccessListener { result ->
|
||||
continuation.resume(result)
|
||||
}
|
||||
addOnFailureListener { exception ->
|
||||
continuation.resumeWithException(exception)
|
||||
}
|
||||
addOnCanceledListener {
|
||||
continuation.cancel()
|
||||
private suspend fun exchangeFirebaseTokenWithBackend(firebaseAccessToken: String): String {
|
||||
val response = apolloClient
|
||||
.mutation(AuthGoogleNewMutation(accessToken = firebaseAccessToken))
|
||||
.execute()
|
||||
response.exception?.let { throw it }
|
||||
response.errors?.firstOrNull()?.let { gqlError ->
|
||||
throw IllegalStateException(gqlError.message)
|
||||
}
|
||||
return response.data?.authGoogleNew?.token
|
||||
?: error("Backend auth exchange succeeded without API token.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.db3.airmq.sdk.auth
|
||||
|
||||
import org.db3.airmq.sdk.auth.model.User
|
||||
|
||||
interface FirebaseSessionManager {
|
||||
suspend fun getUser(): User?
|
||||
suspend fun isSignedIn(): Boolean
|
||||
suspend fun signInWithGoogle(idToken: String): FirebaseSessionUser
|
||||
suspend fun signOut()
|
||||
}
|
||||
|
||||
data class FirebaseSessionUser(
|
||||
val user: User,
|
||||
val firebaseAccessToken: String
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
package org.db3.airmq.sdk.auth
|
||||
|
||||
import com.google.android.gms.tasks.Task
|
||||
import com.google.firebase.auth.FirebaseAuth
|
||||
import com.google.firebase.auth.FirebaseUser
|
||||
import com.google.firebase.auth.GoogleAuthProvider
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import org.db3.airmq.sdk.auth.model.User
|
||||
|
||||
@Singleton
|
||||
class FirebaseSessionManagerImpl @Inject constructor(
|
||||
private val firebaseAuth: FirebaseAuth
|
||||
) : FirebaseSessionManager {
|
||||
|
||||
override suspend fun getUser(): User? = firebaseAuth.currentUser?.toUser()
|
||||
|
||||
override suspend fun isSignedIn(): Boolean = firebaseAuth.currentUser != null
|
||||
|
||||
override suspend fun signInWithGoogle(idToken: String): FirebaseSessionUser {
|
||||
val credential = GoogleAuthProvider.getCredential(idToken, null)
|
||||
val authResult = firebaseAuth.signInWithCredential(credential).awaitResult()
|
||||
val firebaseUser = authResult.user ?: error("Google sign-in completed without a Firebase user.")
|
||||
val firebaseAccessToken = firebaseUser.getIdToken(true).awaitResult().token
|
||||
?: error("Firebase token exchange completed without an ID token.")
|
||||
return FirebaseSessionUser(
|
||||
user = firebaseUser.toUser(),
|
||||
firebaseAccessToken = firebaseAccessToken
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun signOut() {
|
||||
firebaseAuth.signOut()
|
||||
}
|
||||
|
||||
private fun FirebaseUser.toUser(): User = User(
|
||||
userId = uid,
|
||||
email = email,
|
||||
displayName = displayName,
|
||||
isAuthenticated = true
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun <T> Task<T>.awaitResult(): T = suspendCancellableCoroutine { continuation ->
|
||||
addOnSuccessListener { result ->
|
||||
continuation.resume(result)
|
||||
}
|
||||
addOnFailureListener { exception ->
|
||||
continuation.resumeWithException(exception)
|
||||
}
|
||||
addOnCanceledListener {
|
||||
continuation.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.db3.airmq.sdk.auth
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class SharedPreferencesApiTokenStore @Inject constructor(
|
||||
@ApplicationContext context: Context
|
||||
) : ApiTokenStore {
|
||||
|
||||
private val sharedPreferences =
|
||||
context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
override fun getToken(): String? = sharedPreferences.getString(KEY_API_TOKEN, null)
|
||||
|
||||
override fun saveToken(token: String): Result<Unit> = runCatching {
|
||||
require(token.isNotBlank()) { "API token cannot be blank." }
|
||||
sharedPreferences.edit().putString(KEY_API_TOKEN, token).apply()
|
||||
}
|
||||
|
||||
override fun clearToken(): Result<Unit> = runCatching {
|
||||
sharedPreferences.edit().remove(KEY_API_TOKEN).apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val PREFERENCES_NAME = "airmq_auth"
|
||||
private const val KEY_API_TOKEN = "api_token"
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,6 @@ import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
import org.db3.airmq.sdk.auth.ApiTokenStore
|
||||
import org.db3.airmq.sdk.auth.AuthService
|
||||
import org.db3.airmq.sdk.auth.BackendAuthGateway
|
||||
import org.db3.airmq.sdk.auth.BackendAuthGatewayImpl
|
||||
import org.db3.airmq.sdk.auth.FirebaseAuthService
|
||||
import org.db3.airmq.sdk.auth.FirebaseSessionManager
|
||||
import org.db3.airmq.sdk.auth.FirebaseSessionManagerImpl
|
||||
@@ -58,10 +56,6 @@ abstract class SDKBindModule {
|
||||
@Singleton
|
||||
abstract fun bindFirebaseSessionManager(impl: FirebaseSessionManagerImpl): FirebaseSessionManager
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindBackendAuthGateway(impl: BackendAuthGatewayImpl): BackendAuthGateway
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindMapService(impl: MapServiceImpl): MapService
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.db3.airmq.sdk.network
|
||||
|
||||
import com.apollographql.apollo.api.ApolloRequest
|
||||
import com.apollographql.apollo.api.ApolloResponse
|
||||
import com.apollographql.apollo.api.Operation
|
||||
import com.apollographql.apollo.interceptor.ApolloInterceptor
|
||||
import com.apollographql.apollo.interceptor.ApolloInterceptorChain
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.db3.airmq.sdk.auth.ApiTokenStore
|
||||
|
||||
class ApolloAuthInterceptor(
|
||||
private val apiTokenStore: ApiTokenStore
|
||||
) : ApolloInterceptor {
|
||||
override fun <D : Operation.Data> intercept(
|
||||
request: ApolloRequest<D>,
|
||||
chain: ApolloInterceptorChain
|
||||
): Flow<ApolloResponse<D>> {
|
||||
val token = apiTokenStore.getToken()
|
||||
val requestWithAuth = if (token.isNullOrBlank()) {
|
||||
request
|
||||
} else {
|
||||
request.newBuilder()
|
||||
.addHttpHeader(name = AUTH_HEADER, value = "$BEARER_PREFIX $token")
|
||||
.build()
|
||||
}
|
||||
return chain.proceed(requestWithAuth)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val AUTH_HEADER = "Authorization"
|
||||
private const val BEARER_PREFIX = "Bearer"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package org.db3.airmq.sdk.auth
|
||||
|
||||
import com.apollographql.apollo.ApolloClient
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.db3.airmq.sdk.AuthGoogleNewMutation
|
||||
import org.db3.airmq.sdk.auth.model.AuthProvider
|
||||
import org.db3.airmq.sdk.auth.model.User
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class FirebaseAuthServiceTest {
|
||||
|
||||
@Test
|
||||
fun signIn_google_failsWhenFirebaseSignInFails() = runTest {
|
||||
val sessionManager = FakeFirebaseSessionManager(signInError = IllegalStateException("firebase failed"))
|
||||
val apolloClient = mockk<ApolloClient>()
|
||||
val tokenStore = FakeApiTokenStore()
|
||||
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore)
|
||||
|
||||
val result = service.signIn(provider = AuthProvider.GOOGLE, token = "google-id-token")
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertEquals("firebase failed", result.exceptionOrNull()?.message)
|
||||
assertEquals(null, tokenStore.storedToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signIn_google_failsWhenBackendExchangeFails() = runTest {
|
||||
val sessionManager = FakeFirebaseSessionManager(
|
||||
signInResult = FirebaseSessionUser(
|
||||
user = User("uid-1", "user@airmq.cc", "User", true),
|
||||
firebaseAccessToken = "firebase-token"
|
||||
)
|
||||
)
|
||||
val apolloClient = mockk<ApolloClient>()
|
||||
val tokenStore = FakeApiTokenStore()
|
||||
mockAuthGoogleNewError(apolloClient, IllegalStateException("backend failed"))
|
||||
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore)
|
||||
|
||||
val result = service.signIn(provider = AuthProvider.GOOGLE, token = "google-id-token")
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertEquals("backend failed", result.exceptionOrNull()?.message)
|
||||
assertEquals(null, tokenStore.storedToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isAuthenticated_trueOnlyWhenFirebaseAndApiTokenPresent() = runTest {
|
||||
val sessionManager = FakeFirebaseSessionManager(isSignedIn = true)
|
||||
val apolloClient = mockk<ApolloClient>()
|
||||
val tokenStore = FakeApiTokenStore(storedToken = "api-token")
|
||||
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore)
|
||||
|
||||
assertTrue(service.isAuthenticated())
|
||||
|
||||
tokenStore.storedToken = null
|
||||
assertFalse(service.isAuthenticated())
|
||||
}
|
||||
|
||||
private fun mockAuthGoogleNewError(apolloClient: ApolloClient, error: Throwable) {
|
||||
val mockCall = mockk<com.apollographql.apollo.ApolloCall<AuthGoogleNewMutation.Data>>()
|
||||
every { apolloClient.mutation(any<AuthGoogleNewMutation>()) } returns mockCall
|
||||
coEvery { mockCall.execute() } throws error
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeFirebaseSessionManager(
|
||||
private val signInResult: FirebaseSessionUser? = null,
|
||||
private val signInError: Throwable? = null,
|
||||
private val isSignedIn: Boolean = true
|
||||
) : FirebaseSessionManager {
|
||||
override suspend fun getUser(): User? = signInResult?.user
|
||||
|
||||
override suspend fun isSignedIn(): Boolean = isSignedIn
|
||||
|
||||
override suspend fun signInWithGoogle(idToken: String): FirebaseSessionUser {
|
||||
signInError?.let { throw it }
|
||||
return signInResult ?: error("No signInResult configured")
|
||||
}
|
||||
|
||||
override suspend fun signOut() = Unit
|
||||
}
|
||||
|
||||
private class FakeApiTokenStore(
|
||||
var storedToken: String? = null,
|
||||
private val saveError: Throwable? = null
|
||||
) : ApiTokenStore {
|
||||
override fun getToken(): String? = storedToken
|
||||
|
||||
override fun saveToken(token: String): Result<Unit> {
|
||||
saveError?.let { return Result.failure(it) }
|
||||
storedToken = token
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
override fun clearToken(): Result<Unit> {
|
||||
storedToken = null
|
||||
return Result.success(Unit)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user