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
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@ dependencies {
testImplementation(libs.junit5.jupiter)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockwebserver)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core)

androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.androidx.espresso.core)
Expand Down
42 changes: 24 additions & 18 deletions app/src/main/java/ch/rhosys/email/data/auth/AuthressLoginClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
Expand Down Expand Up @@ -53,6 +52,8 @@ class AuthressLoginClient(
private val cookieJar: AuthressCookieJar,
httpClient: OkHttpClient,
private val logger: AppLogger,
/** Injectable like [cookieJar], so tests can substitute a fake instead of touching EncryptedSharedPreferences. */
private val storage: AuthStorageManager = AuthStorageManager(context),
) {
/** The SDK's HttpClient appends /api to the origin; every path below is relative to it. */
private val loginUrl = "https://${BuildConfig.AUTHRESS_CUSTOM_DOMAIN}/api"
Expand All @@ -61,8 +62,6 @@ class AuthressLoginClient(

private val redirectUri = BuildConfig.OAUTH_REDIRECT_URI

private val storage = AuthStorageManager(context)

/** The Authress calls carry the session cookie and must not carry our API bearer. */
private val http = httpClient.newBuilder().cookieJar(cookieJar).build()

Expand Down Expand Up @@ -364,17 +363,17 @@ class AuthressLoginClient(

/**
* The bearer token for API calls, read from the `authorization` cookie and
* checked against the issuer, as the SDK's getToken does.
* checked against the issuer and expiry, as the SDK's getToken does.
* [JwtManager.decode] already shortens `exp` by a 10s clock-skew buffer.
*/
fun getToken(): String? {
val token = cookieJar.authorizationCookie() ?: return null
val payload = JwtManager.decode(token) ?: return null
if (payload.optString("iss") != origin) return null
if (payload.has("exp") && payload.getLong("exp") * 1000 <= System.currentTimeMillis()) return null
return token
}

val isSignedIn: Boolean get() = getToken() != null

/** The identity token's claims, for showing who is signed in. */
fun getUserIdentity(): JSONObject? {
val payload = JwtManager.decode(cookieJar.userCookie()) ?: return null
Expand All @@ -398,15 +397,24 @@ class AuthressLoginClient(
}

/**
* Waits until a bearer token is available, then returns it. Suspends until
* [authenticate] plus [completeAuthenticationRequest], or [userIsLoggedIn],
* establishes a session. This is the SDK's documented way to obtain the value
* for an Authorization header, and its one legitimate caller is
* [ch.rhosys.email.data.remote.api.AuthInterceptor] — the HTTP call wrapper
* grabbing a token right before an Email API request goes out. It must never
* be called from Authress's own client: a call like `POST /authentication` is
* what establishes the session, so waiting on its own result here would just
* deadlock until the timeout.
* Waits until a bearer token is available, then returns it. When the cached
* token is missing or expired, this actively revalidates via
* [userIsLoggedIn] (PATCH /session) rather than passively waiting for some
* other caller to refresh it — every HTTP/WebSocket call goes through this
* function, so this is the one choke point that makes an expired token get
* refreshed instead of reused. [userIsLoggedIn] itself no-ops (no network
* call) whenever a valid cached token already exists, so a burst of
* concurrent callers only pays for a PATCH /session while none of them has
* one yet.
*
* This is the SDK's documented way to obtain the value for an Authorization
* header, and its legitimate callers are
* [ch.rhosys.email.data.remote.api.AuthInterceptor] (grabbing a token right
* before an Email API request goes out) and
* [ch.rhosys.email.data.realtime.RealtimeClient] (attaching a token to the
* WebSocket handshake). It must never be called from Authress's own client:
* a call like `POST /authentication` is what establishes the session, so
* waiting on its own result here would just deadlock until the timeout.
*
* Returns null if no token arrives within [timeoutInMillis]; 0 means do not
* wait at all, matching the SDK.
Expand All @@ -416,9 +424,7 @@ class AuthressLoginClient(
if (timeoutInMillis == 0L) return null

return withTimeoutOrNull(timeoutInMillis) {
// Resolved by completeAuthenticationRequest or a successful session check.
_sessionEstablished.first { it }
getToken()
if (userIsLoggedIn()) getToken() else null
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import okhttp3.Interceptor
import okhttp3.Response

/**
* Attaches the Authress session token to Email API calls — the one place that
* should ever call [ch.rhosys.email.data.auth.AuthressLoginClient.waitForToken]:
* this is the HTTP call wrapper, grabbing the token right before the request
* that needs it. It returns immediately when a token is already cached, and
* otherwise waits briefly for one being established rather than firing a
* request that's certain to be rejected — e.g. a token that expired between
* route changes, while [AppNavHost][ch.rhosys.email.presentation.navigation.AppNavHost]'s
* `userIsLoggedIn()` refresh is still in flight.
* Attaches the Authress session token to Email API calls — the HTTP call
* wrapper that grabs the token right before the request that needs it, via
* [ch.rhosys.email.data.auth.AuthressLoginClient.waitForToken]. (The Email
* API's WebSocket, [ch.rhosys.email.data.realtime.RealtimeClient], is the
* other legitimate caller, attaching a token to the connection handshake the
* same way.) It returns immediately when a token is already cached, and
* otherwise actively revalidates the session — e.g. a token that expired
* between route changes — rather than firing a request that's certain to be
* rejected.
*
* `runBlocking` is safe here — OkHttp interceptors run on OkHttp's own
* dispatcher, never the main thread. It only stays safe because this
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
Expand All @@ -32,19 +33,21 @@ import ch.rhosys.email.presentation.stats.StatsScreen
import ch.rhosys.email.presentation.templates.TemplatesScreen
import ch.rhosys.email.presentation.thread.ThreadScreen
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch

private enum class RootGate { LOADING, ONBOARDING, LOGIN, APP }

@Composable
fun RootNavGraph() {
val container = LocalAppContainer.current
var gate by remember { mutableStateOf(RootGate.LOADING) }
val scope = rememberCoroutineScope()

LaunchedEffect(Unit) {
val onboarded = container.preferencesStore.hasCompletedOnboarding.first()
gate = when {
!onboarded -> RootGate.ONBOARDING
!container.authManager.isSignedIn -> RootGate.LOGIN
!container.authManager.userIsLoggedIn() -> RootGate.LOGIN
else -> RootGate.APP
}
}
Expand All @@ -53,7 +56,9 @@ fun RootNavGraph() {
RootGate.LOADING -> CircularProgressIndicator()
RootGate.ONBOARDING -> DebugLogOverlay(container.appLogger) {
OnboardingScreen(onFinished = {
gate = if (container.authManager.isSignedIn) RootGate.APP else RootGate.LOGIN
scope.launch {
gate = if (container.authManager.userIsLoggedIn()) RootGate.APP else RootGate.LOGIN
}
})
}
RootGate.LOGIN -> DebugLogOverlay(container.appLogger) {
Expand All @@ -73,8 +78,11 @@ private fun AppNavHost() {
val container = LocalAppContainer.current

// The login SDK recommends calling userIsLoggedIn on every route change: it
// is what revalidates the session and refreshes an expired token, via
// PATCH /session. Without it a stale bearer is sent until the app restarts.
// revalidates the session and refreshes an expired token via PATCH /session.
// AuthressLoginClient.waitForToken() now does this too right before any API/
// WebSocket call, so this isn't the only thing standing between a stale
// bearer and a request — but it keeps the session fresh proactively, ahead
// of whatever the next screen is about to need.
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
LaunchedEffect(currentRoute) {
container.authManager.userIsLoggedIn()
Expand Down
Loading