feat(auth): email sign-in and register via GraphQL
Add loginLocal and register Apollo mutations, LocalEmailAuthStore for profile snapshot when not using Firebase, extend AuthService/FirebaseAuthService with session handoff vs Google, wire EmailLoginViewModel, refresh Manage on resume, and expand unit tests. Made-with: Cursor
This commit is contained in:
11
sdk/src/main/graphql/LoginLocal.graphql
Normal file
11
sdk/src/main/graphql/LoginLocal.graphql
Normal file
@@ -0,0 +1,11 @@
|
||||
mutation LoginLocal($input: LocalAuthInput!) {
|
||||
loginLocal(input: $input) {
|
||||
token
|
||||
name
|
||||
email
|
||||
roles
|
||||
_id
|
||||
regDate
|
||||
emailVerified
|
||||
}
|
||||
}
|
||||
11
sdk/src/main/graphql/Register.graphql
Normal file
11
sdk/src/main/graphql/Register.graphql
Normal file
@@ -0,0 +1,11 @@
|
||||
mutation Register($input: RegisterInput!) {
|
||||
register(input: $input) {
|
||||
token
|
||||
name
|
||||
email
|
||||
roles
|
||||
_id
|
||||
regDate
|
||||
emailVerified
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.db3.airmq.sdk.auth
|
||||
|
||||
internal object AirMqAuthPreferences {
|
||||
const val FILE_NAME = "airmq_auth"
|
||||
}
|
||||
@@ -7,5 +7,7 @@ interface AuthService {
|
||||
suspend fun getUser(): User?
|
||||
suspend fun isAuthenticated(): Boolean
|
||||
suspend fun signIn(provider: AuthProvider, token: String): Result<User>
|
||||
suspend fun loginWithEmailPassword(email: String, password: String): Result<User>
|
||||
suspend fun registerWithEmail(email: String, password: String, name: String): Result<User>
|
||||
suspend fun signOut(): Result<Unit>
|
||||
}
|
||||
|
||||
@@ -1,25 +1,40 @@
|
||||
package org.db3.airmq.sdk.auth
|
||||
|
||||
import com.apollographql.apollo.ApolloClient
|
||||
import com.apollographql.apollo.api.Optional
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import org.db3.airmq.sdk.AuthGoogleNewMutation
|
||||
import org.db3.airmq.sdk.LoginLocalMutation
|
||||
import org.db3.airmq.sdk.RegisterMutation
|
||||
import org.db3.airmq.sdk.auth.model.AuthProvider
|
||||
import org.db3.airmq.sdk.auth.model.User
|
||||
import org.db3.airmq.sdk.type.LocalAuthInput
|
||||
import org.db3.airmq.sdk.type.RegisterInput
|
||||
|
||||
@Singleton
|
||||
class FirebaseAuthService @Inject constructor(
|
||||
private val firebaseSessionManager: FirebaseSessionManager,
|
||||
private val apolloClient: ApolloClient,
|
||||
private val apiTokenStore: ApiTokenStore
|
||||
private val apiTokenStore: ApiTokenStore,
|
||||
private val localEmailAuthStore: LocalEmailAuthStore
|
||||
) : AuthService {
|
||||
|
||||
override suspend fun getUser(): User? = firebaseSessionManager.getUser()
|
||||
override suspend fun getUser(): User? {
|
||||
firebaseSessionManager.getUser()?.let { return it }
|
||||
if (apiTokenStore.getToken().isNullOrBlank()) return null
|
||||
val profile = localEmailAuthStore.getProfile() ?: return null
|
||||
return User(
|
||||
userId = profile.userId,
|
||||
email = profile.email,
|
||||
displayName = profile.displayName,
|
||||
isAuthenticated = true
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun isAuthenticated(): Boolean {
|
||||
val hasFirebaseUser = firebaseSessionManager.isSignedIn()
|
||||
val hasApiToken = !apiTokenStore.getToken().isNullOrBlank()
|
||||
return hasFirebaseUser && hasApiToken
|
||||
if (apiTokenStore.getToken().isNullOrBlank()) return false
|
||||
return getUser() != null
|
||||
}
|
||||
|
||||
override suspend fun signIn(provider: AuthProvider, token: String): Result<User> = runCatching {
|
||||
@@ -29,18 +44,76 @@ class FirebaseAuthService @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun loginWithEmailPassword(email: String, password: String): Result<User> = runCatching {
|
||||
val response = apolloClient
|
||||
.mutation(LoginLocalMutation(LocalAuthInput(email = email, password = password)))
|
||||
.execute()
|
||||
response.exception?.let { throw it }
|
||||
response.errors?.firstOrNull()?.let { gqlError ->
|
||||
throw IllegalStateException(gqlError.message)
|
||||
}
|
||||
val auth = response.data?.loginLocal
|
||||
?: error("Login response missing data.")
|
||||
commitEmailAuthSession(auth.token, auth._id, auth.email, auth.name)
|
||||
}
|
||||
|
||||
override suspend fun registerWithEmail(email: String, password: String, name: String): Result<User> = runCatching {
|
||||
val input = RegisterInput(
|
||||
email = email,
|
||||
password = password,
|
||||
name = Optional.present(name)
|
||||
)
|
||||
val response = apolloClient
|
||||
.mutation(RegisterMutation(input))
|
||||
.execute()
|
||||
response.exception?.let { throw it }
|
||||
response.errors?.firstOrNull()?.let { gqlError ->
|
||||
throw IllegalStateException(gqlError.message)
|
||||
}
|
||||
val auth = response.data?.register
|
||||
?: error("Register response missing data.")
|
||||
commitEmailAuthSession(auth.token, auth._id, auth.email, auth.name)
|
||||
}
|
||||
|
||||
override suspend fun signOut(): Result<Unit> = runCatching {
|
||||
firebaseSessionManager.signOut()
|
||||
apiTokenStore.clearToken().getOrThrow()
|
||||
localEmailAuthStore.clearProfile().getOrThrow()
|
||||
}
|
||||
|
||||
private suspend fun signInWithGoogle(token: String): User {
|
||||
localEmailAuthStore.clearProfile().getOrThrow()
|
||||
val firebaseSessionUser = firebaseSessionManager.signInWithGoogle(token)
|
||||
val apiToken = exchangeFirebaseTokenWithBackend(firebaseSessionUser.firebaseAccessToken)
|
||||
apiTokenStore.saveToken(apiToken).getOrThrow()
|
||||
return firebaseSessionUser.user
|
||||
}
|
||||
|
||||
private suspend fun commitEmailAuthSession(
|
||||
token: String?,
|
||||
userId: String?,
|
||||
email: String?,
|
||||
displayName: String?
|
||||
): User {
|
||||
val resolvedToken = token?.takeIf { it.isNotBlank() }
|
||||
?: error("Backend auth succeeded without API token.")
|
||||
val resolvedUserId = userId?.takeIf { it.isNotBlank() }
|
||||
?: error("Backend auth succeeded without user id.")
|
||||
firebaseSessionManager.signOut()
|
||||
apiTokenStore.saveToken(resolvedToken).getOrThrow()
|
||||
localEmailAuthStore.saveProfile(
|
||||
userId = resolvedUserId,
|
||||
email = email,
|
||||
displayName = displayName
|
||||
).getOrThrow()
|
||||
return User(
|
||||
userId = resolvedUserId,
|
||||
email = email,
|
||||
displayName = displayName,
|
||||
isAuthenticated = true
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun exchangeFirebaseTokenWithBackend(firebaseAccessToken: String): String {
|
||||
val response = apolloClient
|
||||
.mutation(AuthGoogleNewMutation(accessToken = firebaseAccessToken))
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.db3.airmq.sdk.auth
|
||||
|
||||
data class LocalEmailAuthProfile(
|
||||
val userId: String,
|
||||
val email: String?,
|
||||
val displayName: String?
|
||||
)
|
||||
|
||||
interface LocalEmailAuthStore {
|
||||
fun saveProfile(userId: String, email: String?, displayName: String?): Result<Unit>
|
||||
fun getProfile(): LocalEmailAuthProfile?
|
||||
fun clearProfile(): Result<Unit>
|
||||
}
|
||||
@@ -11,7 +11,7 @@ class SharedPreferencesApiTokenStore @Inject constructor(
|
||||
) : ApiTokenStore {
|
||||
|
||||
private val sharedPreferences =
|
||||
context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
|
||||
context.getSharedPreferences(AirMqAuthPreferences.FILE_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
override fun getToken(): String? = sharedPreferences.getString(KEY_API_TOKEN, null)
|
||||
|
||||
@@ -25,7 +25,6 @@ class SharedPreferencesApiTokenStore @Inject constructor(
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val PREFERENCES_NAME = "airmq_auth"
|
||||
private const val KEY_API_TOKEN = "api_token"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
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 SharedPreferencesLocalEmailAuthStore @Inject constructor(
|
||||
@ApplicationContext context: Context
|
||||
) : LocalEmailAuthStore {
|
||||
|
||||
private val sharedPreferences =
|
||||
context.getSharedPreferences(AirMqAuthPreferences.FILE_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
override fun saveProfile(userId: String, email: String?, displayName: String?): Result<Unit> = runCatching {
|
||||
require(userId.isNotBlank()) { "User id cannot be blank." }
|
||||
sharedPreferences.edit()
|
||||
.putString(KEY_LOCAL_EMAIL_USER_ID, userId)
|
||||
.putString(KEY_LOCAL_EMAIL_ADDRESS, email)
|
||||
.putString(KEY_LOCAL_EMAIL_DISPLAY_NAME, displayName)
|
||||
.apply()
|
||||
}
|
||||
|
||||
override fun getProfile(): LocalEmailAuthProfile? {
|
||||
val userId = sharedPreferences.getString(KEY_LOCAL_EMAIL_USER_ID, null)
|
||||
?.takeIf { it.isNotBlank() } ?: return null
|
||||
return LocalEmailAuthProfile(
|
||||
userId = userId,
|
||||
email = sharedPreferences.getString(KEY_LOCAL_EMAIL_ADDRESS, null),
|
||||
displayName = sharedPreferences.getString(KEY_LOCAL_EMAIL_DISPLAY_NAME, null)
|
||||
)
|
||||
}
|
||||
|
||||
override fun clearProfile(): Result<Unit> = runCatching {
|
||||
sharedPreferences.edit()
|
||||
.remove(KEY_LOCAL_EMAIL_USER_ID)
|
||||
.remove(KEY_LOCAL_EMAIL_ADDRESS)
|
||||
.remove(KEY_LOCAL_EMAIL_DISPLAY_NAME)
|
||||
.apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val KEY_LOCAL_EMAIL_USER_ID = "local_email_user_id"
|
||||
private const val KEY_LOCAL_EMAIL_ADDRESS = "local_email_address"
|
||||
private const val KEY_LOCAL_EMAIL_DISPLAY_NAME = "local_email_display_name"
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,9 @@ import org.db3.airmq.sdk.auth.AuthService
|
||||
import org.db3.airmq.sdk.auth.FirebaseAuthService
|
||||
import org.db3.airmq.sdk.auth.FirebaseSessionManager
|
||||
import org.db3.airmq.sdk.auth.FirebaseSessionManagerImpl
|
||||
import org.db3.airmq.sdk.auth.LocalEmailAuthStore
|
||||
import org.db3.airmq.sdk.auth.SharedPreferencesApiTokenStore
|
||||
import org.db3.airmq.sdk.auth.SharedPreferencesLocalEmailAuthStore
|
||||
import org.db3.airmq.sdk.dashboard.DashboardMetricsRepository
|
||||
import org.db3.airmq.sdk.dashboard.DashboardMetricsRepositoryImpl
|
||||
import org.db3.airmq.sdk.map.MapServiceImpl
|
||||
@@ -54,6 +56,10 @@ abstract class SDKBindModule {
|
||||
@Singleton
|
||||
abstract fun bindApiTokenStore(impl: SharedPreferencesApiTokenStore): ApiTokenStore
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindLocalEmailAuthStore(impl: SharedPreferencesLocalEmailAuthStore): LocalEmailAuthStore
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindFirebaseSessionManager(impl: FirebaseSessionManagerImpl): FirebaseSessionManager
|
||||
|
||||
@@ -10,6 +10,8 @@ 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.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@@ -20,7 +22,8 @@ class FirebaseAuthServiceTest {
|
||||
val sessionManager = FakeFirebaseSessionManager(signInError = IllegalStateException("firebase failed"))
|
||||
val apolloClient = mockk<ApolloClient>()
|
||||
val tokenStore = FakeApiTokenStore()
|
||||
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore)
|
||||
val localEmailStore = FakeLocalEmailAuthStore()
|
||||
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
|
||||
|
||||
val result = service.signIn(provider = AuthProvider.GOOGLE, token = "google-id-token")
|
||||
|
||||
@@ -39,8 +42,9 @@ class FirebaseAuthServiceTest {
|
||||
)
|
||||
val apolloClient = mockk<ApolloClient>()
|
||||
val tokenStore = FakeApiTokenStore()
|
||||
val localEmailStore = FakeLocalEmailAuthStore()
|
||||
mockAuthGoogleNewError(apolloClient, IllegalStateException("backend failed"))
|
||||
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore)
|
||||
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
|
||||
|
||||
val result = service.signIn(provider = AuthProvider.GOOGLE, token = "google-id-token")
|
||||
|
||||
@@ -50,11 +54,18 @@ class FirebaseAuthServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isAuthenticated_trueOnlyWhenFirebaseAndApiTokenPresent() = runTest {
|
||||
val sessionManager = FakeFirebaseSessionManager(isSignedIn = true)
|
||||
fun isAuthenticated_trueWhenFirebaseUserAndApiTokenPresent() = runTest {
|
||||
val sessionManager = FakeFirebaseSessionManager(
|
||||
isSignedIn = true,
|
||||
signInResult = FirebaseSessionUser(
|
||||
user = User("uid-fb", "fb@test.dev", "FB", true),
|
||||
firebaseAccessToken = "ft"
|
||||
)
|
||||
)
|
||||
val apolloClient = mockk<ApolloClient>()
|
||||
val tokenStore = FakeApiTokenStore(storedToken = "api-token")
|
||||
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore)
|
||||
val localEmailStore = FakeLocalEmailAuthStore()
|
||||
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
|
||||
|
||||
assertTrue(service.isAuthenticated())
|
||||
|
||||
@@ -62,6 +73,52 @@ class FirebaseAuthServiceTest {
|
||||
assertFalse(service.isAuthenticated())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isAuthenticated_trueWhenLocalEmailProfileAndApiTokenPresent() = runTest {
|
||||
val sessionManager = FakeFirebaseSessionManager(isSignedIn = false, signInResult = null)
|
||||
val apolloClient = mockk<ApolloClient>()
|
||||
val tokenStore = FakeApiTokenStore(storedToken = "api-token")
|
||||
val localEmailStore = FakeLocalEmailAuthStore().apply {
|
||||
saveProfile("backend-id", "e@mail.test", "Name").getOrThrow()
|
||||
}
|
||||
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
|
||||
|
||||
assertTrue(service.isAuthenticated())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getUser_returnsLocalProfileWhenNoFirebaseUser() = runTest {
|
||||
val sessionManager = FakeFirebaseSessionManager(isSignedIn = false, signInResult = null)
|
||||
val apolloClient = mockk<ApolloClient>()
|
||||
val tokenStore = FakeApiTokenStore(storedToken = "api-token")
|
||||
val localEmailStore = FakeLocalEmailAuthStore().apply {
|
||||
saveProfile("bid", "local@test.dev", "Local User").getOrThrow()
|
||||
}
|
||||
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
|
||||
|
||||
val user = service.getUser()
|
||||
assertNotNull(user)
|
||||
assertEquals("bid", user!!.userId)
|
||||
assertEquals("local@test.dev", user.email)
|
||||
assertEquals("Local User", user.displayName)
|
||||
assertTrue(user.isAuthenticated)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signOut_clearsTokenAndLocalProfile() = runTest {
|
||||
val sessionManager = FakeFirebaseSessionManager(isSignedIn = false, signInResult = null)
|
||||
val apolloClient = mockk<ApolloClient>()
|
||||
val tokenStore = FakeApiTokenStore(storedToken = "api-token")
|
||||
val localEmailStore = FakeLocalEmailAuthStore().apply {
|
||||
saveProfile("id", "a@b.c", null).getOrThrow()
|
||||
}
|
||||
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
|
||||
|
||||
assertTrue(service.signOut().isSuccess)
|
||||
assertNull(tokenStore.storedToken)
|
||||
assertNull(localEmailStore.getProfile())
|
||||
}
|
||||
|
||||
private fun mockAuthGoogleNewError(apolloClient: ApolloClient, error: Throwable) {
|
||||
val mockCall = mockk<com.apollographql.apollo.ApolloCall<AuthGoogleNewMutation.Data>>()
|
||||
every { apolloClient.mutation(any<AuthGoogleNewMutation>()) } returns mockCall
|
||||
@@ -103,3 +160,17 @@ private class FakeApiTokenStore(
|
||||
return Result.success(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeLocalEmailAuthStore(
|
||||
private var profile: LocalEmailAuthProfile? = null
|
||||
) : LocalEmailAuthStore {
|
||||
override fun saveProfile(userId: String, email: String?, displayName: String?): Result<Unit> = runCatching {
|
||||
profile = LocalEmailAuthProfile(userId, email, displayName)
|
||||
}
|
||||
|
||||
override fun getProfile(): LocalEmailAuthProfile? = profile
|
||||
|
||||
override fun clearProfile(): Result<Unit> = runCatching {
|
||||
profile = null
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user