Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 30 additions & 10 deletions composeApp/src/commonMain/kotlin/de/tabmates/composeapp/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,10 @@ import de.tabmates.core.presentation.navigation.ScreenWithFab
import de.tabmates.core.presentation.navigation.TopBarActionsController
import de.tabmates.core.presentation.navigation.TopLevelTab
import de.tabmates.core.presentation.util.ObserveAsEvents
import de.tabmates.features.authentication.presentation.emailverification.EmailVerificationRoot
import de.tabmates.features.authentication.presentation.forgotpassword.ForgotPasswordScreenRoot
import de.tabmates.features.authentication.presentation.navigation.EmailVerification
import de.tabmates.features.authentication.presentation.navigation.InAppEmailVerification
import de.tabmates.features.authentication.presentation.navigation.Reauth
import de.tabmates.features.authentication.presentation.navigation.ReauthForgotPassword
import de.tabmates.features.authentication.presentation.navigation.ResetPassword
Expand Down Expand Up @@ -224,18 +226,27 @@ fun App() {

DisposableEffect(Unit) {
DeepLinkHandler.listener = listener@{ uri ->
val navKey = resolveDeepLink(uri, deepLinks) ?: return@listener
if (navKey is LoggedIn && !mainViewModel.sessionState.value.hasLocalSession) {
val resolvedKey = resolveDeepLink(uri, deepLinks) ?: return@listener
val hasLocalSession = mainViewModel.sessionState.value.hasLocalSession
// A guest confirming their upgrade link is signed in the whole time and must
// come back to the app, not to a Welcome screen they never left, so the
// confirmation runs on the logged-in twin route the shell can render.
val navKey =
if (resolvedKey is EmailVerification && hasLocalSession) {
InAppEmailVerification(resolvedKey.token)
} else {
resolvedKey
}
if (navKey is LoggedIn && !hasLocalSession) {
mainViewModel.setPendingPostAuthNavKey(navKey)
backStack.clear()
backStack.add(Welcome)
} else {
backStack.clear()
if (navKey is LoggedIn) {
backStack.add(Home)
} else {
backStack.add(Welcome)
}
// What sits *under* the deep-linked screen is where closing it returns to.
// It follows the key's own graph, which is the only one whose entries the
// surrounding NavDisplay knows how to build.
backStack.add(if (navKey is LoggedIn) Home else Welcome)
backStack.add(navKey)
}
}
Expand Down Expand Up @@ -346,9 +357,9 @@ fun App() {
snackbarHostState = snackbarHostState,
appScope = appScope,
)
// Registered here rather than in `authGraph`: re-auth is
// reached *from* the logged-in shell, which is served by
// this entry provider.
// Registered here rather than in `authGraph`: these routes
// are reached *from* the logged-in shell, which is served
// by this entry provider.
entry<Reauth> {
ReauthRoot(
backStack = backStack,
Expand All @@ -358,6 +369,15 @@ fun App() {
entry<ReauthForgotPassword> {
ForgotPasswordScreenRoot()
}
entry<InAppEmailVerification> {
EmailVerificationRoot(
token = it.token,
backStack = backStack,
// The session outlives the link, so leaving the
// screen just uncovers the app underneath it.
onExitClick = { backStack.removeLastOrNull() },
)
}
},
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package de.tabmates.core.data.dto

import kotlinx.serialization.Serializable

@Serializable
data class UserWithPendingEmailSerializable(
val user: UserSerializable,
val pendingEmail: String? = null,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package de.tabmates.core.data.mappers

import de.tabmates.core.data.dto.UserWithPendingEmailSerializable
import de.tabmates.core.domain.auth.UserWithPendingEmail

fun UserWithPendingEmailSerializable.toDomain(): UserWithPendingEmail {
return UserWithPendingEmail(
user = user.toDomain(),
pendingEmail = pendingEmail,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package de.tabmates.core.domain.auth

/**
* A [User] together with an address they have been mailed a confirmation link for and not opened
* yet: the migration target for an anonymous user, the change-email target for a registered one.
* Read [pendingEmail] together with [User.userType] to tell the two apart — it is null when there
* is nothing outstanding.
*
* Unlike the [User] cached in [SessionStorage], this is always re-read from the server, so it also
* reports what was confirmed on another device.
*/
data class UserWithPendingEmail(
val user: User,
val pendingEmail: String?,
)
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ package de.tabmates.features.authentication.data

import de.tabmates.core.data.dto.AuthInfoSerializable
import de.tabmates.core.data.dto.UserSerializable
import de.tabmates.core.data.dto.UserWithPendingEmailSerializable
import de.tabmates.core.data.dto.requests.RefreshRequest
import de.tabmates.core.data.mappers.toDomain
import de.tabmates.core.data.networking.delete
import de.tabmates.core.data.networking.get
import de.tabmates.core.data.networking.post
import de.tabmates.core.domain.auth.AuthInfo
import de.tabmates.core.domain.auth.SessionStorage
import de.tabmates.core.domain.auth.UserWithPendingEmail
import de.tabmates.core.domain.util.DataError
import de.tabmates.core.domain.util.EmptyResult
import de.tabmates.core.domain.util.Result
Expand All @@ -19,8 +21,8 @@ import de.tabmates.features.authentication.data.dto.requests.ChangePasswordReque
import de.tabmates.features.authentication.data.dto.requests.ChangeUsernameRequest
import de.tabmates.features.authentication.data.dto.requests.DeleteAccountRequest
import de.tabmates.features.authentication.data.dto.requests.EmailRequest
import de.tabmates.features.authentication.data.dto.requests.LoginAnonymousRequest
import de.tabmates.features.authentication.data.dto.requests.LoginRequest
import de.tabmates.features.authentication.data.dto.requests.MigrateToRegisteredRequest
import de.tabmates.features.authentication.data.dto.requests.RegisterAnonymousRequest
import de.tabmates.features.authentication.data.dto.requests.RegisterRequest
import de.tabmates.features.authentication.data.dto.requests.ResetPasswordRequest
Expand Down Expand Up @@ -99,22 +101,36 @@ class KtorAuthService(
}
}

override suspend fun loginAnonymous(
userId: String,
override suspend fun migrateToRegistered(
email: String,
password: String,
): Result<AuthInfo, DataError.Remote> {
): EmptyResult<DataError.Remote> {
// Deliberately does not touch the session: the server only records the request until the
// emailed link is redeemed, so the account is still anonymous when this returns.
// The endpoint authenticates with the anonymous session's own token and is not
// Turnstile-protected, so no challenge token is attached here.
return httpClient.post(
route = "/api/auth/migrate-to-registered",
body =
MigrateToRegisteredRequest(
email = email,
password = password,
),
)
}

override suspend fun refreshAccount(): Result<UserWithPendingEmail, DataError.Remote> {
return httpClient
.post<LoginAnonymousRequest, AuthInfoSerializable>(
route = "/api/auth/login-anonymous",
body =
LoginAnonymousRequest(
userId = userId,
password = password,
),
).map { authInfoSerializable ->
authInfoSerializable.toDomain()
}.onSuccess { authInfo ->
sessionStorage.set(authInfo)
.get<UserWithPendingEmailSerializable>(route = "/api/auth/account")
.map { serializable ->
serializable.toDomain()
}.onSuccess { userWithPendingEmail ->
// Keep the cached session in sync so a migration confirmed on another device shows
// up everywhere `userType` is read. A failure leaves the session as it was: this is
// a background refresh, and a flaky network must not look like a signed-out user.
sessionStorage.get()?.let { current ->
sessionStorage.set(current.copy(user = userWithPendingEmail.user))
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package de.tabmates.features.authentication.data.dto.requests
import kotlinx.serialization.Serializable

@Serializable
data class LoginAnonymousRequest(
val userId: String,
data class MigrateToRegisteredRequest(
val email: String,
val password: String,
)
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,32 @@ import de.tabmates.core.domain.auth.SessionInvalidator
import de.tabmates.core.domain.auth.SessionStorage
import de.tabmates.core.domain.auth.User
import de.tabmates.core.domain.auth.UserType
import de.tabmates.core.domain.auth.UserWithPendingEmail
import de.tabmates.core.domain.environment.CustomEnvironment
import de.tabmates.core.domain.environment.EnvironmentConfig
import de.tabmates.core.domain.environment.EnvironmentRepository
import de.tabmates.core.domain.logging.TabMatesLogger
import de.tabmates.core.domain.update.UpgradeRequiredNotifier
import de.tabmates.core.domain.util.Result
import de.tabmates.features.authentication.domain.TurnstileTokenProvider
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.engine.mock.respondError
import io.ktor.client.engine.mock.respondOk
import io.ktor.client.request.HttpRequestData
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull

class KtorAuthServiceTest {
/**
Expand Down Expand Up @@ -67,6 +75,130 @@ class KtorAuthServiceTest {
assertEquals("Bearer token-b", authorizationHeaders.last())
}

/**
* Wire-level guard for `GET /api/auth/account`. The payload below is what the server actually
* sends; a field renamed on either side deserializes to a silent null rather than an error, so
* nothing above this layer can catch the drift.
*/
@Test
fun `refreshAccount reads the servers account payload`() =
runTest {
val sessionStorage = MutableSessionStorage(authInfo(userId = "user-a", token = "token-a"))
val service =
authService(
clientResponding(
"""
{
"user": {
"id": "user-a",
"email": "",
"username": "alice",
"hasVerifiedEmail": false,
"userType": "ANONYMOUS"
},
"pendingEmail": "new@example.com"
}
""".trimIndent(),
),
sessionStorage,
)

val result = service.refreshAccount()

val account = assertIs<Result.Success<UserWithPendingEmail>>(result).data
assertEquals("new@example.com", account.pendingEmail)
assertEquals(UserType.ANONYMOUS, account.user.userType)
assertEquals("/api/auth/account", requests.single().url.encodedPath)
// The cached session has to pick the change up, since that is what every `userType`
// check in the app reads.
assertEquals(UserType.ANONYMOUS, sessionStorage.get()?.user?.userType)
}

@Test
fun `refreshAccount reports no pending email when the field is absent`() =
runTest {
val sessionStorage = MutableSessionStorage(authInfo(userId = "user-a", token = "token-a"))
val service =
authService(
clientResponding(
"""
{
"user": {
"id": "user-a",
"email": "alice@example.com",
"username": "alice",
"hasVerifiedEmail": true,
"userType": "REGISTERED"
},
"pendingEmail": null
}
""".trimIndent(),
),
sessionStorage,
)

val account = assertIs<Result.Success<UserWithPendingEmail>>(service.refreshAccount()).data

assertNull(account.pendingEmail)
assertEquals(UserType.REGISTERED, account.user.userType)
}

/** The endpoint answers 202 with a body the client has no use for; that must still be a success. */
@Test
fun `migrateToRegistered accepts the servers accepted response`() =
runTest {
val sessionStorage = MutableSessionStorage(authInfo(userId = "user-a", token = "token-a"))
val service =
authService(
clientResponding(
body = """{"pendingEmail":"new@example.com"}""",
status = HttpStatusCode.Accepted,
),
sessionStorage,
)

val result = service.migrateToRegistered(email = "new@example.com", password = "password1")

assertIs<Result.Success<Unit>>(result)
assertEquals("/api/auth/migrate-to-registered", requests.single().url.encodedPath)
// Nothing about the session may change: the account is still anonymous until the
// emailed link is opened.
assertEquals("user-a", sessionStorage.get()?.user?.id)
}

private val requests = mutableListOf<HttpRequestData>()

private fun clientResponding(
body: String,
status: HttpStatusCode = HttpStatusCode.OK,
): HttpClient {
val engine =
MockEngine { request ->
requests += request
respond(
content = body,
status = status,
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()),
)
}
return HttpClientFactory(
tabMatesLogger = NoOpLogger(),
sessionStorage = EmptySessionStorage(),
json = Json { ignoreUnknownKeys = true },
upgradeRequiredNotifier = UpgradeRequiredNotifier(),
sessionInvalidator = NoOpSessionInvalidator(),
environmentRepository = FixedEnvironmentRepository(),
).create(engine)
}

private class EmptySessionStorage : SessionStorage {
override val authState: StateFlow<AuthInfo?> = MutableStateFlow(null)

override fun get(): AuthInfo? = null

override fun set(info: AuthInfo?) = Unit
}

private val authorizationHeaders = mutableListOf<String?>()

private fun clientAuthenticatedBy(
Expand Down
Loading