From 1ac658f2e65aa5163fc69c6f6c8baa22e4dc3f4b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 12:12:39 +0000 Subject: [PATCH 1/6] Make the theme picker preview the themes it offers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker was five FilterChips that all drew from the active theme, so every option looked identical and none of them showed what selecting it would do — the one thing a theme picker exists to communicate. Each option is now a tile painted from its own palette: its base as the background, mantle as the footer, the text and subtext ramp, the seven accent colours, and a pair of miniature mail rows standing in for an inbox. That covers what actually changes when a flavour is applied, so the choice can be made by looking rather than by trying each one. System has no palette of its own, so its tile is split between the two palettes it resolves to, Latte and Mocha, labelled light and dark. Selecting now sets a flavour outright instead of toggling back to system on a second tap. System is an explicit tile, which makes the old toggle both redundant and surprising. Tiles are laid out two per row by chunking rather than with a lazy grid, so the picker can sit inside the scrolling settings column without a nested-scroll conflict, and a trailing odd tile keeps its width instead of stretching. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL --- .../presentation/settings/SettingsScreen.kt | 22 +- .../presentation/settings/ThemePicker.kt | 237 ++++++++++++++++++ 2 files changed, 248 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/ch/rhosys/email/presentation/settings/ThemePicker.kt diff --git a/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsScreen.kt b/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsScreen.kt index 7ec87ee..cbc7574 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsScreen.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsScreen.kt @@ -1,7 +1,6 @@ package ch.rhosys.email.presentation.settings import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -10,7 +9,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.AlertDialog -import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme @@ -104,15 +102,17 @@ private fun AppPreferencesSection( ) { Column(modifier = Modifier.padding(12.dp)) { Text("Theme", style = MaterialTheme.typography.titleMedium) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 4.dp)) { - CatppuccinFlavor.entries.forEach { flavor -> - FilterChip( - selected = uiState.themeFlavor == flavor, - onClick = { onThemeSelected(if (uiState.themeFlavor == flavor) null else flavor) }, - label = { Text(flavor.label) }, - ) - } - } + Text( + "Each tile is drawn in the theme it applies.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp, bottom = 8.dp), + ) + ThemePicker( + selected = uiState.themeFlavor, + onSelect = onThemeSelected, + modifier = Modifier.padding(bottom = 8.dp), + ) ListItem( headlineContent = { Text("Biometric lock") }, supportingContent = { Text("Require Face/Fingerprint unlock to open the app") }, diff --git a/app/src/main/java/ch/rhosys/email/presentation/settings/ThemePicker.kt b/app/src/main/java/ch/rhosys/email/presentation/settings/ThemePicker.kt new file mode 100644 index 0000000..812a6de --- /dev/null +++ b/app/src/main/java/ch/rhosys/email/presentation/settings/ThemePicker.kt @@ -0,0 +1,237 @@ +package ch.rhosys.email.presentation.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ch.rhosys.email.ui.theme.CatppuccinColors +import ch.rhosys.email.ui.theme.CatppuccinFlavor +import ch.rhosys.email.ui.theme.Latte +import ch.rhosys.email.ui.theme.Mocha +import ch.rhosys.email.ui.theme.palette + +/** + * Theme picker. + * + * Every tile paints itself from the flavour it represents rather than from the + * active theme, so the row is a set of previews instead of five identically + * coloured chips. Each one shows the surface, the text and subtext ramp, the + * accent colours, and a miniature mail row, which is what actually changes when + * the flavour is applied. + * + * `null` means follow the system setting; that tile previews both halves it can + * resolve to. + */ +@Composable +fun ThemePicker( + selected: CatppuccinFlavor?, + onSelect: (CatppuccinFlavor?) -> Unit, + modifier: Modifier = Modifier, +) { + // Two per row, laid out manually so this can live inside a scrolling Column + // without nesting a lazy grid. + val options: List = listOf(null) + CatppuccinFlavor.entries + + Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + options.chunked(2).forEach { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + row.forEach { flavor -> + ThemeTile( + flavor = flavor, + isSelected = selected == flavor, + onClick = { onSelect(flavor) }, + modifier = Modifier.weight(1f), + ) + } + // Keeps a lone trailing tile at half width instead of stretching it. + if (row.size == 1) Spacer(Modifier.weight(1f)) + } + } + } +} + +@Composable +private fun ThemeTile( + flavor: CatppuccinFlavor?, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + // System has no palette of its own: it previews both halves it can resolve to, + // and borrows Mocha's ramp for the footer so the label stays legible. + val palette = flavor?.palette() ?: Mocha + val name = flavor?.label ?: "System" + val mode = when { + flavor == null -> "Follows device" + flavor.isDark -> "Dark" + else -> "Light" + } + + Box( + modifier = modifier + .height(168.dp) + .clip(RoundedCornerShape(14.dp)) + .background(palette.base) + .border( + width = if (isSelected) 2.dp else 1.dp, + color = if (isSelected) palette.mauve else palette.surface1, + shape = RoundedCornerShape(14.dp), + ) + .clickable(role = Role.RadioButton, onClick = onClick), + ) { + Column(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + if (flavor == null) { + SystemSplitPreview() + } else { + FlavorPreview(palette) + } + + if (isSelected) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + .size(20.dp) + .clip(RoundedCornerShape(10.dp)) + .background(palette.mauve), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Filled.Check, + contentDescription = "Selected", + tint = palette.crust, + modifier = Modifier.size(14.dp), + ) + } + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .background(palette.mantle) + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + name, + color = palette.text, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + Text(mode, color = palette.subtext0, fontSize = 11.sp) + } + } + } +} + +/** A miniature mail row plus the accent ramp — the parts a flavour actually changes. */ +@Composable +private fun FlavorPreview(palette: CatppuccinColors) { + Column( + modifier = Modifier.fillMaxSize().padding(10.dp), + verticalArrangement = Arrangement.spacedBy(7.dp), + ) { + MiniMailRow(palette, accent = palette.mauve, emphasised = true) + MiniMailRow(palette, accent = palette.blue, emphasised = false) + Spacer(Modifier.weight(1f)) + AccentRamp(palette) + } +} + +@Composable +private fun MiniMailRow(palette: CatppuccinColors, accent: Color, emphasised: Boolean) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Box(modifier = Modifier.size(14.dp).clip(RoundedCornerShape(7.dp)).background(accent)) + Column(verticalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier.weight(1f)) { + // Sender line: brighter and wider when the row is "urgent". + Bar(color = if (emphasised) palette.text else palette.subtext1, widthFraction = if (emphasised) 0.7f else 0.5f) + Bar(color = palette.subtext0, widthFraction = 0.9f, height = 4.dp) + } + } +} + +@Composable +private fun Bar(color: Color, widthFraction: Float, height: androidx.compose.ui.unit.Dp = 5.dp) { + Box( + modifier = Modifier + .fillMaxWidth(widthFraction) + .height(height) + .clip(RoundedCornerShape(3.dp)) + .background(color), + ) +} + +@Composable +private fun AccentRamp(palette: CatppuccinColors) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + listOf( + palette.mauve, palette.blue, palette.teal, + palette.green, palette.yellow, palette.peach, palette.red, + ).forEach { swatch -> + Box( + modifier = Modifier + .weight(1f) + .height(10.dp) + .clip(RoundedCornerShape(3.dp)) + .background(swatch), + ) + } + } +} + +/** Splits the preview between the two palettes the system setting resolves to. */ +@Composable +private fun SystemSplitPreview() { + Row(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.weight(1f).fillMaxSize().background(Latte.base)) { + Column( + modifier = Modifier.fillMaxSize().padding(8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + MiniMailRow(Latte, accent = Latte.mauve, emphasised = true) + Spacer(Modifier.weight(1f)) + Text("Light", color = Latte.subtext0, fontSize = 10.sp) + } + } + Box(modifier = Modifier.weight(1f).fillMaxSize().background(Mocha.base)) { + Column( + modifier = Modifier.fillMaxSize().padding(8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + MiniMailRow(Mocha, accent = Mocha.mauve, emphasised = true) + Spacer(Modifier.weight(1f)) + Text("Dark", color = Mocha.subtext0, fontSize = 10.sp) + } + } + } +} From fb87ad39958b49d556eef4233782d5778d1e8406 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 12:19:39 +0000 Subject: [PATCH 2/6] Discover the OAuth endpoints; drop biometrics from onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /authorize and /oauth/token paths came from b3f6eff, the same commit that invented the Numaeel product and its domains, and neither exists. Authress publishes discovery at the issuer: authorization_endpoint https://login.rhosys.cloud (the issuer root) token_endpoint https://login.rhosys.cloud/api/authentication/oauth/tokens AuthressAuthManager now resolves both through AuthorizationServiceConfiguration.fetchFromIssuer rather than hardcoding paths, so a change on Authress's side cannot silently break sign-in the way a wrong guess already had. The config is fetched once per process behind a mutex. Discovery is a network call, so launchSignIn suspends and returns a Result; LoginScreen clears its loading state if discovery fails instead of hanging on a spinner. The requested scope drops `email` and `offline_access`. Neither appears in scopes_supported, which advertises only openid and profile; refresh tokens come from the refresh_token grant, which is advertised. The application id defaults to app_2EAWGEdtzaeCj7b45DsDtt, matching the web app's VITE_AUTHRESS_APPLICATION_ID, rather than the invented numaeel_android. Separately, onboarding no longer asks about biometric lock. It is a security preference someone sets when they want it, not a decision worth interrupting a first launch for, and Settings already has the toggle. Onboarding drops to four steps, and its "Pick a look" step now uses the same previewing ThemePicker as Settings — it had the same all-identical-chips problem. ThemePicker moves to presentation/components now that two screens share it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL --- app/build.gradle.kts | 9 ++- .../email/data/auth/AuthressAuthManager.kt | 77 ++++++++++++++----- .../email/presentation/auth/LoginScreen.kt | 8 +- .../{settings => components}/ThemePicker.kt | 2 +- .../onboarding/OnboardingScreen.kt | 63 +++++++-------- .../presentation/settings/SettingsScreen.kt | 1 + todo.md | 28 +++++-- 7 files changed, 121 insertions(+), 67 deletions(-) rename app/src/main/java/ch/rhosys/email/presentation/{settings => components}/ThemePicker.kt (99%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aac6af3..97914e9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -33,7 +33,9 @@ android { buildConfigField( "String", "AUTHRESS_APPLICATION_ID", - "\"${(findProperty("authressApplicationId") as? String) ?: "numaeel_android"}\"", + // Matches the web app's VITE_AUTHRESS_APPLICATION_ID; the previous + // value was invented and no such application exists in Authress. + "\"${(findProperty("authressApplicationId") as? String) ?: "app_2EAWGEdtzaeCj7b45DsDtt"}\"", ) buildConfigField( "String", @@ -42,8 +44,9 @@ android { ) manifestPlaceholders["oauthRedirectScheme"] = "ch.rhosys.email" - // Required by the AppAuth library's own manifest (RedirectUriReceiverActivity), - // even though our redirect is actually captured by MainActivity's intent-filter below. + // AppAuth's own manifest declares RedirectUriReceiverActivity against this + // scheme, and that receiver is what completes the flow. MainActivity also + // declares a filter for the same scheme, which is a conflict — see todo.md. manifestPlaceholders["appAuthRedirectScheme"] = "ch.rhosys.email" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/java/ch/rhosys/email/data/auth/AuthressAuthManager.kt b/app/src/main/java/ch/rhosys/email/data/auth/AuthressAuthManager.kt index 5513b4b..713a6d7 100644 --- a/app/src/main/java/ch/rhosys/email/data/auth/AuthressAuthManager.kt +++ b/app/src/main/java/ch/rhosys/email/data/auth/AuthressAuthManager.kt @@ -6,7 +6,8 @@ import android.net.Uri import androidx.activity.result.ActivityResultLauncher import ch.rhosys.email.BuildConfig import kotlinx.coroutines.suspendCancellableCoroutine -import net.openid.appauth.AuthState +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import net.openid.appauth.AuthorizationException import net.openid.appauth.AuthorizationRequest import net.openid.appauth.AuthorizationResponse @@ -18,34 +19,68 @@ import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException /** - * OIDC login against Authress (decision #6): social logins, passkeys, or - * email/password are all handled by Authress's hosted login page — the app - * only speaks standard OAuth2/OIDC via AppAuth, so no credential UI lives here. + * OIDC login against Authress. Social logins, passkeys and email/password are all + * handled by Authress's hosted login page, so no credential UI lives here. + * + * Endpoints are discovered from the issuer's /.well-known/openid-configuration + * rather than hardcoded. The previous hardcoded pair was wrong on both counts — + * Authress's authorization endpoint is the issuer root, not /authorize, and its + * token endpoint is /api/authentication/oauth/tokens, not /oauth/token — and + * discovery means a future change on Authress's side does not silently break + * sign-in again. */ class AuthressAuthManager(private val context: Context, private val tokenStore: TokenStore) { private val service = AuthorizationService(context) - private val serviceConfig = AuthorizationServiceConfiguration( - Uri.parse("https://${BuildConfig.AUTHRESS_CUSTOM_DOMAIN}/authorize"), - Uri.parse("https://${BuildConfig.AUTHRESS_CUSTOM_DOMAIN}/oauth/token"), - ) + private val issuer = Uri.parse("https://${BuildConfig.AUTHRESS_CUSTOM_DOMAIN}") private val redirectUri = Uri.parse("${BuildConfig.OAUTH_REDIRECT_SCHEME}:/oauth2redirect") - fun buildAuthRequestIntent() = service.getAuthorizationRequestIntent( - AuthorizationRequest.Builder( - serviceConfig, - BuildConfig.AUTHRESS_APPLICATION_ID, - ResponseTypeValues.CODE, - redirectUri, - ).setScope("openid profile email offline_access").build(), - ) - - fun launchSignIn(launcher: ActivityResultLauncher) { - launcher.launch(buildAuthRequestIntent()) + /** Discovered once per process, then reused. */ + @Volatile + private var cachedConfig: AuthorizationServiceConfiguration? = null + + private val configMutex = Mutex() + + private suspend fun serviceConfig(): AuthorizationServiceConfiguration { + cachedConfig?.let { return it } + return configMutex.withLock { + cachedConfig ?: fetchConfig().also { cachedConfig = it } + } } + private suspend fun fetchConfig(): AuthorizationServiceConfiguration = + suspendCancellableCoroutine { cont -> + AuthorizationServiceConfiguration.fetchFromIssuer(issuer) { config, ex -> + when { + config != null -> cont.resume(config) + else -> cont.resumeWithException( + ex ?: IllegalStateException("Could not discover OIDC configuration at $issuer"), + ) + } + } + } + + suspend fun buildAuthRequestIntent(): android.content.Intent = + service.getAuthorizationRequestIntent( + AuthorizationRequest.Builder( + serviceConfig(), + BuildConfig.AUTHRESS_APPLICATION_ID, + ResponseTypeValues.CODE, + redirectUri, + ) + // Only openid and profile are advertised in scopes_supported. + // Refresh tokens come from the refresh_token grant, which is + // advertised, rather than from an offline_access scope that is not. + .setScope("openid profile") + .build(), + ) + + /** Discovery is a network call, so signing in has to suspend. */ + suspend fun launchSignIn(launcher: ActivityResultLauncher): Result = + runCatching { launcher.launch(buildAuthRequestIntent()) } + suspend fun handleAuthResponse(data: android.content.Intent): Result { val response = AuthorizationResponse.fromIntent(data) val exception = AuthorizationException.fromIntent(data) @@ -72,10 +107,10 @@ class AuthressAuthManager(private val context: Context, private val tokenStore: suspend fun refreshAccessToken(): Boolean { val refreshToken = tokenStore.refreshToken ?: return false - val authState = AuthState(serviceConfig) + val config = runCatching { serviceConfig() }.getOrNull() ?: return false return suspendCancellableCoroutine { cont -> service.performTokenRequest( - net.openid.appauth.TokenRequest.Builder(serviceConfig, BuildConfig.AUTHRESS_APPLICATION_ID) + net.openid.appauth.TokenRequest.Builder(config, BuildConfig.AUTHRESS_APPLICATION_ID) .setGrantType(net.openid.appauth.GrantTypeValues.REFRESH_TOKEN) .setRefreshToken(refreshToken) .build(), diff --git a/app/src/main/java/ch/rhosys/email/presentation/auth/LoginScreen.kt b/app/src/main/java/ch/rhosys/email/presentation/auth/LoginScreen.kt index f45c5b0..729717d 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/auth/LoginScreen.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/auth/LoginScreen.kt @@ -64,7 +64,13 @@ fun LoginScreen(onSignedIn: () -> Unit) { if (isLoading) { CircularProgressIndicator() } else { - Button(onClick = { isLoading = true; container.authManager.launchSignIn(launcher) }) { + Button(onClick = { + isLoading = true + scope.launch { + container.authManager.launchSignIn(launcher) + .onFailure { isLoading = false } + } + }) { Text("Continue") } } diff --git a/app/src/main/java/ch/rhosys/email/presentation/settings/ThemePicker.kt b/app/src/main/java/ch/rhosys/email/presentation/components/ThemePicker.kt similarity index 99% rename from app/src/main/java/ch/rhosys/email/presentation/settings/ThemePicker.kt rename to app/src/main/java/ch/rhosys/email/presentation/components/ThemePicker.kt index 812a6de..cb70440 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/settings/ThemePicker.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/components/ThemePicker.kt @@ -1,4 +1,4 @@ -package ch.rhosys.email.presentation.settings +package ch.rhosys.email.presentation.components import androidx.compose.foundation.background import androidx.compose.foundation.border diff --git a/app/src/main/java/ch/rhosys/email/presentation/onboarding/OnboardingScreen.kt b/app/src/main/java/ch/rhosys/email/presentation/onboarding/OnboardingScreen.kt index 99c0fd1..a974347 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/onboarding/OnboardingScreen.kt @@ -30,17 +30,23 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import ch.rhosys.email.di.LocalAppContainer +import ch.rhosys.email.presentation.components.ThemePicker import ch.rhosys.email.ui.theme.CatppuccinFlavor import kotlinx.coroutines.launch -/** Decision #20: 5-step onboarding wizard shown on first launch. */ +/** + * Onboarding, shown once on first launch. + * + * Biometric lock is deliberately not offered here. It is a security preference + * someone changes when they want it, not a decision worth stopping a first + * launch for, and it lives in Settings alongside the rest of them. + */ @Composable fun OnboardingScreen(onFinished: () -> Unit) { val container = LocalAppContainer.current val scope = rememberCoroutineScope() - val pagerState = rememberPagerState(pageCount = { 5 }) + val pagerState = rememberPagerState(pageCount = { StepCount }) var themeFlavor by remember { mutableStateOf(null) } - var biometricLockWanted by remember { mutableStateOf(false) } Column(modifier = Modifier.fillMaxSize()) { HorizontalPager(state = pagerState, modifier = Modifier.weight(1f)) { page -> @@ -48,36 +54,27 @@ fun OnboardingScreen(onFinished: () -> Unit) { 0 -> WelcomeStep() 1 -> NotificationPermissionStep() 2 -> ThemeStep(selected = themeFlavor, onSelect = { themeFlavor = it }) - 3 -> BiometricStep(enabled = biometricLockWanted, onToggle = { biometricLockWanted = it }) - 4 -> ReadyStep() + 3 -> ReadyStep() } } Row( modifier = Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween, ) { - TextButton(onClick = { - scope.launch { - container.preferencesStore.setThemeFlavor(themeFlavor) - container.preferencesStore.setBiometricLockEnabled(biometricLockWanted) - container.preferencesStore.setOnboardingCompleted(true) - onFinished() - } - }) { Text("Skip") } + TextButton(onClick = { scope.launch { finish(container, themeFlavor, onFinished) } }) { + Text("Skip") + } Button(onClick = { scope.launch { - if (pagerState.currentPage < 4) { + if (pagerState.currentPage < StepCount - 1) { pagerState.animateScrollToPage(pagerState.currentPage + 1) } else { - container.preferencesStore.setThemeFlavor(themeFlavor) - container.preferencesStore.setBiometricLockEnabled(biometricLockWanted) - container.preferencesStore.setOnboardingCompleted(true) - onFinished() + finish(container, themeFlavor, onFinished) } } }) { - Text(if (pagerState.currentPage < 4) "Next" else "Get started") + Text(if (pagerState.currentPage < StepCount - 1) "Next" else "Get started") } } } @@ -115,23 +112,21 @@ private fun NotificationPermissionStep() { @Composable private fun ThemeStep(selected: CatppuccinFlavor?, onSelect: (CatppuccinFlavor?) -> Unit) { StepScaffold("Pick a look", "You can change this later in Settings.") { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - CatppuccinFlavor.entries.forEach { flavor -> - FilterChip(selected = selected == flavor, onClick = { onSelect(flavor) }, label = { Text(flavor.label) }) - } - } - } -} - -@Composable -private fun BiometricStep(enabled: Boolean, onToggle: (Boolean) -> Unit) { - StepScaffold("Lock it down", "Require Face or Fingerprint unlock every time you open Numaeel.") { - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Enable biometric lock") - Switch(checked = enabled, onCheckedChange = onToggle, modifier = Modifier.padding(start = 8.dp)) - } + ThemePicker(selected = selected, onSelect = onSelect) } } @Composable private fun ReadyStep() = StepScaffold("You're all set", "Let's get to inbox zero.") + +private const val StepCount = 4 + +private suspend fun finish( + container: ch.rhosys.email.di.AppContainer, + themeFlavor: CatppuccinFlavor?, + onFinished: () -> Unit, +) { + container.preferencesStore.setThemeFlavor(themeFlavor) + container.preferencesStore.setOnboardingCompleted(true) + onFinished() +} diff --git a/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsScreen.kt b/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsScreen.kt index cbc7574..ab88e26 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsScreen.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import ch.rhosys.email.di.LocalAppContainer +import ch.rhosys.email.presentation.components.ThemePicker import ch.rhosys.email.presentation.components.rememberViewModel import ch.rhosys.email.ui.theme.CatppuccinFlavor diff --git a/todo.md b/todo.md index cd6733b..0ac5500 100644 --- a/todo.md +++ b/todo.md @@ -6,15 +6,29 @@ Open work on the Android app, most blocking first. ## Blocking a working build -### Authress application ID +### ~~Authress application ID~~ — resolved -`app/build.gradle.kts` still defaults `authressApplicationId` to `numaeel_android`, -a value invented alongside the fictional Numaeel product. Login against -`login.rhosys.cloud` will fail until this is a real application registered in -Authress. +Now defaults to `app_2EAWGEdtzaeCj7b45DsDtt`, taken from the web app's +`VITE_AUTHRESS_APPLICATION_ID`. Still overridable with +`-PauthressApplicationId=` per environment. -Override per-environment with `-PauthressApplicationId=`, or change the -default once the real id is known. +### ~~OAuth endpoints~~ — resolved + +The hardcoded `/authorize` and `/oauth/token` paths were invented and neither +exists. Authress publishes discovery at +`https://login.rhosys.cloud/.well-known/openid-configuration`: + +``` +authorization_endpoint https://login.rhosys.cloud (the issuer root) +token_endpoint https://login.rhosys.cloud/api/authentication/oauth/tokens +code_challenge_methods S256 +scopes_supported openid, profile +``` + +AuthressAuthManager now discovers these at runtime via +`AuthorizationServiceConfiguration.fetchFromIssuer` instead of hardcoding paths. +The requested scope dropped `email` and `offline_access`, neither of which is +advertised; refresh tokens come from the `refresh_token` grant, which is. ### OAuth redirect is claimed twice From b3b9daaea53cb818ac2af7561dd1732c17a1c0bb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 12:49:53 +0000 Subject: [PATCH 3/6] Port the Authress React Native login SDK instead of hand-rolling OAuth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation treated Authress as a plain OAuth provider and drove it through AppAuth. That was wrong in shape, not just in URLs, which is why guessing endpoints and then discovering them both produced something that could not work. @authress/login-react-native does this instead: 1. POST /api/authentication with applicationId, redirectUrl and a PKCE S256 challenge; the response carries an authenticationUrl and an authenticationRequestId. 2. Open that URL in the device browser. 3. Authress redirects to the app's deep link with code and authenticationRequestId. 4. POST /api/authentication/{authenticationRequestId}/tokens with the code, the stored code verifier and the redirect URI. There is no authorize endpoint and no token endpoint in the OAuth sense, and the session is not an access/refresh pair — it lives in the `authorization` cookie, with identity claims in `user`. That is why the discovered endpoints would not have helped either. Ported file by file from the SDK: JwtManager for PKCE and payload decoding including its ten-second exp buffer, AuthStorageManager for the pending PKCE state, AuthressCookieJar for the session cookies, and AuthressLoginClient for the flow. The 4xx-on-token-exchange case is treated as success and cleanup, as the SDK does, because it usually means the code was already redeemed. AppAuth is dropped for androidx.browser, since the SDK opens the hosted page in the browser rather than running an OAuth library. TokenStore no longer holds tokens — only the selected account — and AuthInterceptor takes the bearer from the session cookie. This also resolves the duplicate redirect claim: AppAuth's RedirectUriReceiverActivity is gone, so MainActivity is the only component claiming the scheme, and it now overrides onNewIntent to forward the redirect exactly as the SDK's own Android setup instructions describe. The redirect URI moves to the documented scheme://host/path form. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL --- app/build.gradle.kts | 15 +- app/src/main/AndroidManifest.xml | 9 +- .../main/java/ch/rhosys/email/MainActivity.kt | 25 ++ .../email/data/auth/AuthStorageManager.kt | 61 +++++ .../email/data/auth/AuthressAuthManager.kt | 137 ----------- .../email/data/auth/AuthressCookieJar.kt | 109 +++++++++ .../email/data/auth/AuthressLoginClient.kt | 227 ++++++++++++++++++ .../ch/rhosys/email/data/auth/JwtManager.kt | 47 ++++ .../ch/rhosys/email/data/auth/TokenStore.kt | 25 +- .../email/data/remote/api/AuthInterceptor.kt | 14 +- .../java/ch/rhosys/email/di/AppContainer.kt | 14 +- .../email/presentation/auth/LoginScreen.kt | 43 ++-- .../email/presentation/navigation/NavGraph.kt | 4 +- .../settings/SettingsViewModel.kt | 9 +- gradle/libs.versions.toml | 4 +- 15 files changed, 540 insertions(+), 203 deletions(-) create mode 100644 app/src/main/java/ch/rhosys/email/data/auth/AuthStorageManager.kt delete mode 100644 app/src/main/java/ch/rhosys/email/data/auth/AuthressAuthManager.kt create mode 100644 app/src/main/java/ch/rhosys/email/data/auth/AuthressCookieJar.kt create mode 100644 app/src/main/java/ch/rhosys/email/data/auth/AuthressLoginClient.kt create mode 100644 app/src/main/java/ch/rhosys/email/data/auth/JwtManager.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 97914e9..6863e9f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -37,18 +37,15 @@ android { // value was invented and no such application exists in Authress. "\"${(findProperty("authressApplicationId") as? String) ?: "app_2EAWGEdtzaeCj7b45DsDtt"}\"", ) + // Redirect target for the Authress login flow, in the scheme://host/path + // form the SDK documents. MainActivity is the only component that claims + // it — see its intent filter. buildConfigField( "String", - "OAUTH_REDIRECT_SCHEME", - "\"ch.rhosys.email\"", + "OAUTH_REDIRECT_URI", + "\"ch.rhosys.email://auth/callback\"", ) - manifestPlaceholders["oauthRedirectScheme"] = "ch.rhosys.email" - // AppAuth's own manifest declares RedirectUriReceiverActivity against this - // scheme, and that receiver is what completes the flow. MainActivity also - // declares a filter for the same scheme, which is a conflict — see todo.md. - manifestPlaceholders["appAuthRedirectScheme"] = "ch.rhosys.email" - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -142,7 +139,7 @@ dependencies { implementation(libs.security.crypto) implementation(libs.biometric) - implementation(libs.appauth) + implementation(libs.androidx.browser) implementation(libs.glance.appwidget) implementation(libs.glance.material3) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a162c16..c0c39d4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -63,12 +63,17 @@ - + - + (null) } @@ -43,6 +50,24 @@ class MainActivity : FragmentActivity() { } } + /** + * The React Native SDK subscribes to Linking 'url' events for this; on native + * Android the equivalent is the launch intent plus onNewIntent, which is what + * the SDK's own Android setup instructions tell you to forward. + */ + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleAuthRedirect(intent) + } + + private fun handleAuthRedirect(intent: Intent?) { + val appContainer = (application as EmailApp).appContainer + val uri = intent?.data ?: return + if (!appContainer.authManager.isRedirect(uri)) return + lifecycleScope.launch { appContainer.authManager.completeAuthenticationRequest(uri) } + } + override fun onStart() { super.onStart() SyncForegroundService.start(this) diff --git a/app/src/main/java/ch/rhosys/email/data/auth/AuthStorageManager.kt b/app/src/main/java/ch/rhosys/email/data/auth/AuthStorageManager.kt new file mode 100644 index 0000000..e0663e5 --- /dev/null +++ b/app/src/main/java/ch/rhosys/email/data/auth/AuthStorageManager.kt @@ -0,0 +1,61 @@ +package ch.rhosys.email.data.auth + +import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import org.json.JSONObject + +/** + * PKCE state between starting a login and returning from the browser, ported + * from authStorageManager.ts. The code verifier must survive the app being + * killed while the Custom Tab is in front, so it goes to encrypted storage + * rather than memory. + */ +class AuthStorageManager(context: Context) { + + private val prefs = EncryptedSharedPreferences.create( + context, + "authress_pending_auth", + MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(), + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + + data class PendingAuthentication( + val codeVerifier: String, + val authenticationRequestId: String, + val redirectUrl: String, + ) + + fun setAuthenticationRequest(state: PendingAuthentication?) { + if (state == null) { + prefs.edit().remove(KEY_PENDING).apply() + return + } + val json = JSONObject() + .put("codeVerifier", state.codeVerifier) + .put("authenticationRequestId", state.authenticationRequestId) + .put("redirectUrl", state.redirectUrl) + prefs.edit().putString(KEY_PENDING, json.toString()).apply() + } + + fun getAuthenticationRequest(): PendingAuthentication? { + val raw = prefs.getString(KEY_PENDING, null) ?: return null + return runCatching { + val json = JSONObject(raw) + PendingAuthentication( + codeVerifier = json.getString("codeVerifier"), + authenticationRequestId = json.getString("authenticationRequestId"), + redirectUrl = json.getString("redirectUrl"), + ) + }.getOrNull() + } + + fun clear() { + prefs.edit().clear().apply() + } + + private companion object { + const val KEY_PENDING = "authress-pending-auth" + } +} diff --git a/app/src/main/java/ch/rhosys/email/data/auth/AuthressAuthManager.kt b/app/src/main/java/ch/rhosys/email/data/auth/AuthressAuthManager.kt deleted file mode 100644 index 713a6d7..0000000 --- a/app/src/main/java/ch/rhosys/email/data/auth/AuthressAuthManager.kt +++ /dev/null @@ -1,137 +0,0 @@ -package ch.rhosys.email.data.auth - -import android.app.Activity -import android.content.Context -import android.net.Uri -import androidx.activity.result.ActivityResultLauncher -import ch.rhosys.email.BuildConfig -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import net.openid.appauth.AuthorizationException -import net.openid.appauth.AuthorizationRequest -import net.openid.appauth.AuthorizationResponse -import net.openid.appauth.AuthorizationService -import net.openid.appauth.AuthorizationServiceConfiguration -import net.openid.appauth.ResponseTypeValues -import net.openid.appauth.TokenResponse -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException - -/** - * OIDC login against Authress. Social logins, passkeys and email/password are all - * handled by Authress's hosted login page, so no credential UI lives here. - * - * Endpoints are discovered from the issuer's /.well-known/openid-configuration - * rather than hardcoded. The previous hardcoded pair was wrong on both counts — - * Authress's authorization endpoint is the issuer root, not /authorize, and its - * token endpoint is /api/authentication/oauth/tokens, not /oauth/token — and - * discovery means a future change on Authress's side does not silently break - * sign-in again. - */ -class AuthressAuthManager(private val context: Context, private val tokenStore: TokenStore) { - - private val service = AuthorizationService(context) - - private val issuer = Uri.parse("https://${BuildConfig.AUTHRESS_CUSTOM_DOMAIN}") - - private val redirectUri = Uri.parse("${BuildConfig.OAUTH_REDIRECT_SCHEME}:/oauth2redirect") - - /** Discovered once per process, then reused. */ - @Volatile - private var cachedConfig: AuthorizationServiceConfiguration? = null - - private val configMutex = Mutex() - - private suspend fun serviceConfig(): AuthorizationServiceConfiguration { - cachedConfig?.let { return it } - return configMutex.withLock { - cachedConfig ?: fetchConfig().also { cachedConfig = it } - } - } - - private suspend fun fetchConfig(): AuthorizationServiceConfiguration = - suspendCancellableCoroutine { cont -> - AuthorizationServiceConfiguration.fetchFromIssuer(issuer) { config, ex -> - when { - config != null -> cont.resume(config) - else -> cont.resumeWithException( - ex ?: IllegalStateException("Could not discover OIDC configuration at $issuer"), - ) - } - } - } - - suspend fun buildAuthRequestIntent(): android.content.Intent = - service.getAuthorizationRequestIntent( - AuthorizationRequest.Builder( - serviceConfig(), - BuildConfig.AUTHRESS_APPLICATION_ID, - ResponseTypeValues.CODE, - redirectUri, - ) - // Only openid and profile are advertised in scopes_supported. - // Refresh tokens come from the refresh_token grant, which is - // advertised, rather than from an offline_access scope that is not. - .setScope("openid profile") - .build(), - ) - - /** Discovery is a network call, so signing in has to suspend. */ - suspend fun launchSignIn(launcher: ActivityResultLauncher): Result = - runCatching { launcher.launch(buildAuthRequestIntent()) } - - suspend fun handleAuthResponse(data: android.content.Intent): Result { - val response = AuthorizationResponse.fromIntent(data) - val exception = AuthorizationException.fromIntent(data) - if (response == null) return Result.failure(exception ?: IllegalStateException("Sign-in cancelled")) - - return runCatching { - val tokenResponse = exchangeToken(response) - tokenStore.accessToken = tokenResponse.accessToken - tokenStore.refreshToken = tokenResponse.refreshToken - tokenStore.accessTokenExpiresAt = tokenResponse.accessTokenExpirationTime ?: 0L - } - } - - private suspend fun exchangeToken(response: AuthorizationResponse): TokenResponse = - suspendCancellableCoroutine { cont -> - service.performTokenRequest(response.createTokenExchangeRequest()) { tokenResponse, ex -> - when { - tokenResponse != null -> cont.resume(tokenResponse) - ex != null -> cont.resumeWithException(ex) - else -> cont.resumeWithException(IllegalStateException("Token exchange failed")) - } - } - } - - suspend fun refreshAccessToken(): Boolean { - val refreshToken = tokenStore.refreshToken ?: return false - val config = runCatching { serviceConfig() }.getOrNull() ?: return false - return suspendCancellableCoroutine { cont -> - service.performTokenRequest( - net.openid.appauth.TokenRequest.Builder(config, BuildConfig.AUTHRESS_APPLICATION_ID) - .setGrantType(net.openid.appauth.GrantTypeValues.REFRESH_TOKEN) - .setRefreshToken(refreshToken) - .build(), - ) { tokenResponse, ex -> - if (tokenResponse != null) { - tokenStore.accessToken = tokenResponse.accessToken - tokenResponse.refreshToken?.let { tokenStore.refreshToken = it } - tokenStore.accessTokenExpiresAt = tokenResponse.accessTokenExpirationTime ?: 0L - cont.resume(true) - } else { - cont.resume(false) - } - } - } - } - - fun signOut() { - tokenStore.clear() - } - - fun dispose() { - service.dispose() - } -} diff --git a/app/src/main/java/ch/rhosys/email/data/auth/AuthressCookieJar.kt b/app/src/main/java/ch/rhosys/email/data/auth/AuthressCookieJar.kt new file mode 100644 index 0000000..ae1520e --- /dev/null +++ b/app/src/main/java/ch/rhosys/email/data/auth/AuthressCookieJar.kt @@ -0,0 +1,109 @@ +package ch.rhosys.email.data.auth + +import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl +import org.json.JSONArray +import org.json.JSONObject + +/** + * The Authress session lives in cookies, not in a stored access/refresh token + * pair — `authorization` carries the bearer token and `user` carries the identity + * token. The React Native SDK keeps them in the native cookie jar and mirrors + * them into encrypted storage so a session survives a process restart + * (authStorageManager.backupCookies / restoreCookies). + * + * This is the OkHttp equivalent: an in-memory jar backed by + * EncryptedSharedPreferences. When several calls set the same cookie name on + * different paths, the last value written wins — the SDK's `lastValue`. + */ +class AuthressCookieJar(context: Context, private val authressHost: String) : CookieJar { + + private val prefs = EncryptedSharedPreferences.create( + context, + "authress_cookies", + MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(), + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + + /** name -> value, last write wins. */ + private val cookies = linkedMapOf() + + init { + restore() + } + + @Synchronized + override fun saveFromResponse(url: HttpUrl, cookies: List) { + if (!url.host.equals(authressHost, ignoreCase = true)) return + var changed = false + cookies.forEach { cookie -> + // An expiry in the past is a deletion. + if (cookie.expiresAt < System.currentTimeMillis()) { + changed = this.cookies.remove(cookie.name) != null || changed + } else if (this.cookies[cookie.name] != cookie.value) { + this.cookies[cookie.name] = cookie.value + changed = true + } + } + if (changed) persist() + } + + @Synchronized + override fun loadForRequest(url: HttpUrl): List { + if (!url.host.equals(authressHost, ignoreCase = true)) return emptyList() + return cookies.map { (name, value) -> + Cookie.Builder() + .name(name) + .value(value) + .domain(authressHost) + .path("/") + .secure() + .httpOnly() + .build() + } + } + + /** The bearer token used for API calls. */ + @Synchronized + fun authorizationCookie(): String? = cookies[COOKIE_AUTHORIZATION] + + /** The identity token, carrying the user's profile claims. */ + @Synchronized + fun userCookie(): String? = cookies[COOKIE_USER] + + @Synchronized + fun clear() { + cookies.clear() + prefs.edit().remove(KEY_COOKIES).apply() + } + + private fun persist() { + val array = JSONArray() + cookies.forEach { (name, value) -> + array.put(JSONObject().put("name", name).put("value", value)) + } + prefs.edit().putString(KEY_COOKIES, array.toString()).apply() + } + + private fun restore() { + val raw = prefs.getString(KEY_COOKIES, null) ?: return + runCatching { + val array = JSONArray(raw) + for (i in 0 until array.length()) { + val entry = array.getJSONObject(i) + cookies[entry.getString("name")] = entry.getString("value") + } + } + } + + private companion object { + const val KEY_COOKIES = "authress-cookies" + const val COOKIE_AUTHORIZATION = "authorization" + const val COOKIE_USER = "user" + } +} diff --git a/app/src/main/java/ch/rhosys/email/data/auth/AuthressLoginClient.kt b/app/src/main/java/ch/rhosys/email/data/auth/AuthressLoginClient.kt new file mode 100644 index 0000000..302ebd8 --- /dev/null +++ b/app/src/main/java/ch/rhosys/email/data/auth/AuthressLoginClient.kt @@ -0,0 +1,227 @@ +package ch.rhosys.email.data.auth + +import android.content.Context +import android.net.Uri +import androidx.browser.customtabs.CustomTabsIntent +import ch.rhosys.email.BuildConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject + +/** + * Port of loginClient.ts from @authress/login-react-native. + * + * Authress is not a plain OAuth provider and this is deliberately not an + * authorize/token exchange. The flow the SDK implements is: + * + * 1. POST /api/authentication with the applicationId, the redirect URL and a + * PKCE S256 challenge. Authress answers with an `authenticationUrl` to open + * and an `authenticationRequestId` to correlate the response. + * 2. Open that URL in a Custom Tab. The user picks a provider, passkey or + * password on the Authress-hosted page. + * 3. Authress redirects back to the app's deep link with `code` and + * `authenticationRequestId` query parameters. + * 4. POST /api/authentication/{authenticationRequestId}/tokens with the code, + * the stored code verifier and the redirect URI. + * + * The session is then held in cookies rather than in a token pair — + * see [AuthressCookieJar]. + */ +class AuthressLoginClient( + private val context: Context, + private val cookieJar: AuthressCookieJar, + httpClient: OkHttpClient, +) { + /** 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" + + private val origin = "https://${BuildConfig.AUTHRESS_CUSTOM_DOMAIN}" + + 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() + + class AuthressException(message: String, val status: Int? = null) : Exception(message) + + private val _sessionEstablished = MutableStateFlow(false) + + /** + * Emits when a session exists. The SDK resolves an internal promise at the + * same points; a flow is the idiomatic equivalent for Compose to collect. + */ + val sessionEstablished: StateFlow = _sessionEstablished.asStateFlow() + + init { + _sessionEstablished.value = getToken() != null + } + + data class AuthenticationResponse( + val authenticationUrl: String, + val authenticationRequestId: String, + ) + + // ── authenticate ──────────────────────────────────────────────────────── + + /** + * Begins the login flow and opens the Authress-hosted login page. Returns + * once the browser has been launched; completion arrives via the deep link. + */ + suspend fun authenticate(connectionId: String? = null): Result = runCatching { + storage.setAuthenticationRequest(null) + + val codes = JwtManager.getAuthCodes() + val body = JSONObject() + .put("redirectUrl", redirectUri) + .put("applicationId", BuildConfig.AUTHRESS_APPLICATION_ID) + .put("codeChallenge", codes.codeChallenge) + .put("codeChallengeMethod", "S256") + .apply { connectionId?.let { put("connectionId", it) } } + + val response = post("/authentication", body) + val authenticationUrl = response.getString("authenticationUrl") + val authenticationRequestId = response.getString("authenticationRequestId") + + storage.setAuthenticationRequest( + AuthStorageManager.PendingAuthentication( + codeVerifier = codes.codeVerifier, + authenticationRequestId = authenticationRequestId, + redirectUrl = redirectUri, + ), + ) + + withContext(Dispatchers.Main) { + CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(authenticationUrl)) + } + } + + // ── completeAuthenticationRequest ─────────────────────────────────────── + + /** + * Completes the flow from the deep link. Mirrors the SDK: a mismatched or + * missing pending request is an error, but a 4xx from the token exchange is + * treated as success-and-clean-up, because it most often means the code was + * already redeemed. + */ + suspend fun completeAuthenticationRequest(uri: Uri): Result = runCatching { + val code = uri.getQueryParameter("code").orEmpty() + val authenticationRequestId = uri.getQueryParameter("authenticationRequestId").orEmpty() + + val pending = storage.getAuthenticationRequest() + ?: throw AuthressException("No authentication request in progress") + if (pending.authenticationRequestId != authenticationRequestId) { + throw AuthressException("Authentication request mismatch") + } + + val body = JSONObject() + .put("code", code) + .put("codeVerifier", pending.codeVerifier) + .put("redirectUri", pending.redirectUrl) + + try { + post("/authentication/$authenticationRequestId/tokens", body) + } catch (e: AuthressException) { + val status = e.status + if (status != null && status < 500) { + // Code already used — the session is established, nothing to do. + storage.setAuthenticationRequest(null) + _sessionEstablished.value = getToken() != null + return@runCatching + } + throw e + } + + storage.setAuthenticationRequest(null) + _sessionEstablished.value = getToken() != null + } + + /** True when the redirect belongs to this client. */ + fun isRedirect(uri: Uri?): Boolean = + uri != null && uri.toString().startsWith(redirectUri) + + // ── session ───────────────────────────────────────────────────────────── + + /** + * The bearer token for API calls, read from the `authorization` cookie and + * checked against the issuer, as the SDK's getToken does. + */ + fun getToken(): String? { + val token = cookieJar.authorizationCookie() ?: return null + val payload = JwtManager.decode(token) ?: return null + if (payload.optString("iss") != origin) 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 + if (payload.optString("iss") != origin) return null + return payload + } + + /** + * Validates the session server-side and refreshes the cookie when the current + * token has expired. The SDK calls PATCH /session for this. + */ + suspend fun userIsLoggedIn(): Boolean { + if (getToken() != null) return true + return runCatching { + patch("/session", JSONObject()) + (getToken() != null).also { _sessionEstablished.value = it } + }.getOrDefault(false) + } + + /** Ends the server session first, while the cookie can still identify it. */ + suspend fun logout(): Result = runCatching { + runCatching { delete("/session") } + cookieJar.clear() + storage.clear() + _sessionEstablished.value = false + } + + // ── HTTP ──────────────────────────────────────────────────────────────── + + private suspend fun post(path: String, body: JSONObject): JSONObject = + execute(Request.Builder().url(loginUrl + path).post(body.toBody())) + + private suspend fun patch(path: String, body: JSONObject): JSONObject = + execute(Request.Builder().url(loginUrl + path).patch(body.toBody())) + + private suspend fun delete(path: String): JSONObject = + execute(Request.Builder().url(loginUrl + path).delete()) + + private fun JSONObject.toBody() = toString().toRequestBody(JSON) + + private suspend fun execute(builder: Request.Builder): JSONObject = withContext(Dispatchers.IO) { + val request = builder + .header("Content-Type", "application/json") + .header("X-Powered-By", "Authress Login SDK; Android; ${BuildConfig.VERSION_NAME}") + .build() + + http.newCall(request).execute().use { response -> + val text = response.body?.string().orEmpty() + if (!response.isSuccessful) { + throw AuthressException( + "Authress ${request.method} ${request.url.encodedPath} failed: ${response.code} $text", + status = response.code, + ) + } + runCatching { JSONObject(text) }.getOrDefault(JSONObject()) + } + } + + private companion object { + val JSON = "application/json".toMediaType() + } +} diff --git a/app/src/main/java/ch/rhosys/email/data/auth/JwtManager.kt b/app/src/main/java/ch/rhosys/email/data/auth/JwtManager.kt new file mode 100644 index 0000000..ddb6ebd --- /dev/null +++ b/app/src/main/java/ch/rhosys/email/data/auth/JwtManager.kt @@ -0,0 +1,47 @@ +package ch.rhosys.email.data.auth + +import android.util.Base64 +import org.json.JSONObject +import java.security.MessageDigest +import java.security.SecureRandom + +/** + * Port of jwtManager.ts from @authress/login-react-native. + * + * Base64url without padding throughout, matching the SDK's `b64urlEncode`. + */ +object JwtManager { + + private const val B64_URL = Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP + + data class AuthCodes(val codeVerifier: String, val codeChallenge: String) + + /** + * PKCE pair. The SDK derives the verifier from 16 random 32-bit values rendered + * as a comma-joined decimal string, then base64url-encodes that; the challenge + * is base64url(SHA-256(verifier)). + */ + fun getAuthCodes(): AuthCodes { + val random = SecureRandom() + val words = IntArray(16) { random.nextInt() } + val joined = words.joinToString(",") { (it.toLong() and 0xFFFFFFFFL).toString() } + val codeVerifier = Base64.encodeToString(joined.toByteArray(Charsets.UTF_8), B64_URL) + val digest = MessageDigest.getInstance("SHA-256").digest(codeVerifier.toByteArray(Charsets.UTF_8)) + return AuthCodes(codeVerifier, Base64.encodeToString(digest, B64_URL)) + } + + /** + * Decodes a JWT payload without verifying the signature — the SDK does the same, + * because the token arrives over TLS from the issuer it is then checked against. + * `exp` is shortened by 10 seconds as a clock-skew buffer, matching the SDK. + */ + fun decode(token: String?): JSONObject? { + if (token.isNullOrBlank()) return null + return runCatching { + val payloadSegment = token.split(".").getOrNull(1) ?: return null + val json = JSONObject(String(Base64.decode(payloadSegment, B64_URL), Charsets.UTF_8)) + if (json.has("exp")) json.put("exp", json.getLong("exp") - 10) + json + }.getOrNull() + } +} diff --git a/app/src/main/java/ch/rhosys/email/data/auth/TokenStore.kt b/app/src/main/java/ch/rhosys/email/data/auth/TokenStore.kt index a776836..383e501 100644 --- a/app/src/main/java/ch/rhosys/email/data/auth/TokenStore.kt +++ b/app/src/main/java/ch/rhosys/email/data/auth/TokenStore.kt @@ -6,9 +6,12 @@ import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey /** - * OAuth tokens at rest, decision #50: EncryptedSharedPreferences backed by the - * Android Keystore. No AWS credentials ever touch the device — the backend - * proxies all API calls, so this store only ever holds the Authress session. + * Local state that outlives a session, in EncryptedSharedPreferences backed by + * the Android Keystore. + * + * Deliberately no access or refresh token: the Authress session is held in + * cookies, managed by AuthressCookieJar, exactly as the login SDK does it. The + * only thing kept here is which account the user last had selected. */ class TokenStore(context: Context) { private val masterKey = MasterKey.Builder(context) @@ -23,32 +26,16 @@ class TokenStore(context: Context) { EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, ) - var accessToken: String? - get() = prefs.getString(KEY_ACCESS_TOKEN, null) - set(value) = prefs.edit().putString(KEY_ACCESS_TOKEN, value).apply() - - var refreshToken: String? - get() = prefs.getString(KEY_REFRESH_TOKEN, null) - set(value) = prefs.edit().putString(KEY_REFRESH_TOKEN, value).apply() - - var accessTokenExpiresAt: Long - get() = prefs.getLong(KEY_EXPIRES_AT, 0L) - set(value) = prefs.edit().putLong(KEY_EXPIRES_AT, value).apply() - var activeAccountId: String? get() = prefs.getString(KEY_ACTIVE_ACCOUNT, null) set(value) = prefs.edit().putString(KEY_ACTIVE_ACCOUNT, value).apply() - val isSignedIn: Boolean get() = accessToken != null fun clear() { prefs.edit().clear().apply() } private companion object { - const val KEY_ACCESS_TOKEN = "access_token" - const val KEY_REFRESH_TOKEN = "refresh_token" - const val KEY_EXPIRES_AT = "access_token_expires_at" const val KEY_ACTIVE_ACCOUNT = "active_account_id" } } diff --git a/app/src/main/java/ch/rhosys/email/data/remote/api/AuthInterceptor.kt b/app/src/main/java/ch/rhosys/email/data/remote/api/AuthInterceptor.kt index a94e6cd..8cea1ef 100644 --- a/app/src/main/java/ch/rhosys/email/data/remote/api/AuthInterceptor.kt +++ b/app/src/main/java/ch/rhosys/email/data/remote/api/AuthInterceptor.kt @@ -1,12 +1,20 @@ package ch.rhosys.email.data.remote.api -import ch.rhosys.email.data.auth.TokenStore +import ch.rhosys.email.data.auth.AuthressLoginClient import okhttp3.Interceptor import okhttp3.Response -class AuthInterceptor(private val tokenStore: TokenStore) : Interceptor { +/** + * Attaches the Authress session token to API calls. The token comes from the + * `authorization` cookie rather than a stored access token, which is where the + * login SDK keeps it. + * + * [tokenProvider] is a lambda because the login client is constructed after the + * OkHttp client it shares. + */ +class AuthInterceptor(private val tokenProvider: () -> String?) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { - val token = tokenStore.accessToken + val token = tokenProvider() val request = chain.request().newBuilder().apply { if (token != null) addHeader("Authorization", "Bearer $token") }.build() diff --git a/app/src/main/java/ch/rhosys/email/di/AppContainer.kt b/app/src/main/java/ch/rhosys/email/di/AppContainer.kt index 5b32fc7..8c8707a 100644 --- a/app/src/main/java/ch/rhosys/email/di/AppContainer.kt +++ b/app/src/main/java/ch/rhosys/email/di/AppContainer.kt @@ -3,7 +3,8 @@ package ch.rhosys.email.di import android.content.Context import androidx.room.Room import ch.rhosys.email.BuildConfig -import ch.rhosys.email.data.auth.AuthressAuthManager +import ch.rhosys.email.data.auth.AuthressCookieJar +import ch.rhosys.email.data.auth.AuthressLoginClient import ch.rhosys.email.data.auth.TokenStore import ch.rhosys.email.data.local.EmailDatabase import ch.rhosys.email.data.remote.api.AuthInterceptor @@ -37,7 +38,14 @@ import java.util.concurrent.TimeUnit class AppContainer(private val context: Context) { val tokenStore: TokenStore by lazy { TokenStore(context) } - val authManager: AuthressAuthManager by lazy { AuthressAuthManager(context, tokenStore) } + + private val cookieJar: AuthressCookieJar by lazy { + AuthressCookieJar(context, BuildConfig.AUTHRESS_CUSTOM_DOMAIN) + } + + val authManager: AuthressLoginClient by lazy { + AuthressLoginClient(context, cookieJar, okHttpClient) + } private val moshi: Moshi by lazy { // SignalDto is a polymorphic union discriminated by `type`; Moshi needs the @@ -49,7 +57,7 @@ class AppContainer(private val context: Context) { private val okHttpClient: OkHttpClient by lazy { OkHttpClient.Builder() - .addInterceptor(AuthInterceptor(tokenStore)) + .addInterceptor(AuthInterceptor { authManager.getToken() }) .apply { if (BuildConfig.DEBUG) { addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BASIC)) diff --git a/app/src/main/java/ch/rhosys/email/presentation/auth/LoginScreen.kt b/app/src/main/java/ch/rhosys/email/presentation/auth/LoginScreen.kt index 729717d..b9759cc 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/auth/LoginScreen.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/auth/LoginScreen.kt @@ -1,7 +1,5 @@ package ch.rhosys.email.presentation.auth -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -11,6 +9,8 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -23,8 +23,12 @@ import ch.rhosys.email.di.LocalAppContainer import kotlinx.coroutines.launch /** - * Decision #6: Authress-hosted login (social/passkey/password) via AppAuth. - * No credential fields live in this app — sign-in opens the hosted page. + * Authress-hosted login — social, passkey or password. No credential fields live + * in this app; Continue opens the hosted page in a Custom Tab. + * + * There is no activity result to wait on: the flow completes when Authress + * redirects back to the app's deep link, which MainActivity forwards to the + * login client. This screen just watches for the session to appear. */ @Composable fun LoginScreen(onSignedIn: () -> Unit) { @@ -33,21 +37,14 @@ fun LoginScreen(onSignedIn: () -> Unit) { var isLoading by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } - val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - val data = result.data ?: run { isLoading = false; return@rememberLauncherForActivityResult } - scope.launch { - isLoading = true - container.authManager.handleAuthResponse(data) - .onSuccess { - runCatching { container.accountRepository.refresh() } - isLoading = false - onSignedIn() - } - .onFailure { - isLoading = false - error = it.message - } - } + val hasSession by container.authManager.sessionEstablished.collectAsState() + + LaunchedEffect(hasSession) { + if (!hasSession) return@LaunchedEffect + isLoading = true + runCatching { container.accountRepository.refresh() } + isLoading = false + onSignedIn() } Column( @@ -66,9 +63,13 @@ fun LoginScreen(onSignedIn: () -> Unit) { } else { Button(onClick = { isLoading = true + error = null scope.launch { - container.authManager.launchSignIn(launcher) - .onFailure { isLoading = false } + container.authManager.authenticate() + .onFailure { + isLoading = false + error = it.message + } } }) { Text("Continue") diff --git a/app/src/main/java/ch/rhosys/email/presentation/navigation/NavGraph.kt b/app/src/main/java/ch/rhosys/email/presentation/navigation/NavGraph.kt index af8f15a..db15aab 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/navigation/NavGraph.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/navigation/NavGraph.kt @@ -41,7 +41,7 @@ fun RootNavGraph() { val onboarded = container.preferencesStore.hasCompletedOnboarding.first() gate = when { !onboarded -> RootGate.ONBOARDING - !container.tokenStore.isSignedIn -> RootGate.LOGIN + !container.authManager.isSignedIn -> RootGate.LOGIN else -> RootGate.APP } } @@ -49,7 +49,7 @@ fun RootNavGraph() { when (gate) { RootGate.LOADING -> CircularProgressIndicator() RootGate.ONBOARDING -> OnboardingScreen(onFinished = { - gate = if (container.tokenStore.isSignedIn) RootGate.APP else RootGate.LOGIN + gate = if (container.authManager.isSignedIn) RootGate.APP else RootGate.LOGIN }) RootGate.LOGIN -> LoginScreen(onSignedIn = { gate = RootGate.APP }) RootGate.APP -> { diff --git a/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsViewModel.kt b/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsViewModel.kt index d33a1a5..3099680 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsViewModel.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsViewModel.kt @@ -2,7 +2,7 @@ package ch.rhosys.email.presentation.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import ch.rhosys.email.data.auth.AuthressAuthManager +import ch.rhosys.email.data.auth.AuthressLoginClient import ch.rhosys.email.data.local.PreferencesStore import ch.rhosys.email.data.repository.SettingsRepository import ch.rhosys.email.data.remote.dto.AccountUserDto @@ -38,7 +38,7 @@ class SettingsViewModel( private val settingsRepository: SettingsRepository, private val accountRepository: AccountRepository, private val preferencesStore: PreferencesStore, - private val authManager: AuthressAuthManager, + private val authManager: AuthressLoginClient, ) : ViewModel() { private val _uiState = MutableStateFlow(SettingsUiState()) @@ -123,7 +123,6 @@ class SettingsViewModel( fun setThemeFlavor(flavor: CatppuccinFlavor?) = viewModelScope.launch { preferencesStore.setThemeFlavor(flavor) } fun setBiometricLockEnabled(enabled: Boolean) = viewModelScope.launch { preferencesStore.setBiometricLockEnabled(enabled) } - fun signOut() { - authManager.signOut() - } + /** Ends the server session before clearing local cookies, as the SDK does. */ + fun signOut() = viewModelScope.launch { authManager.logout() } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c5c4c79..a44c48a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,7 +17,7 @@ markwon = "4.6.2" paging = "3.3.0" security-crypto = "1.1.0-alpha06" biometric = "1.1.0" -appauth = "0.11.1" +browser = "1.8.0" glance = "1.1.0" datastore = "1.1.1" posthog = "3.9.1" @@ -69,7 +69,7 @@ paging-compose = { module = "androidx.paging:paging-compose", security-crypto = { module = "androidx.security:security-crypto", version.ref = "security-crypto" } biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } -appauth = { module = "net.openid:appauth", version.ref = "appauth" } +androidx-browser = { module = "androidx.browser:browser", version.ref = "browser" } glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "glance" } glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "glance" } From ae31da68b4761e0c56cd087ae6687c63c30b4445 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 15:23:10 +0000 Subject: [PATCH 4/6] Use the SDK's own session handling rather than inventing a refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The port was missing the two methods that make sessions self-maintaining, so a token would go stale and every API call would keep sending it. waitForToken is what the SDK documents for an Authorization header. It returns immediately when a valid session exists and otherwise waits for one being established, so a request issued mid-refresh waits instead of being rejected. AuthInterceptor now goes through it rather than reading the cookie directly. userIsLoggedIn is what actually refreshes: it calls PATCH /session when the current token is gone or expired. Its own documentation recommends calling it on every route change, so AppNavHost now does exactly that. Neither an OkHttp Authenticator reacting to 401 nor a pre-emptive exp check was needed — both would have been a parallel mechanism competing with the one the SDK already defines. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL --- .../email/data/auth/AuthressLoginClient.kt | 23 +++++++++++++++++++ .../email/data/remote/api/AuthInterceptor.kt | 19 ++++++++------- .../java/ch/rhosys/email/di/AppContainer.kt | 2 +- .../email/presentation/navigation/NavGraph.kt | 9 ++++++++ 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/ch/rhosys/email/data/auth/AuthressLoginClient.kt b/app/src/main/java/ch/rhosys/email/data/auth/AuthressLoginClient.kt index 302ebd8..f77a640 100644 --- a/app/src/main/java/ch/rhosys/email/data/auth/AuthressLoginClient.kt +++ b/app/src/main/java/ch/rhosys/email/data/auth/AuthressLoginClient.kt @@ -8,6 +8,8 @@ 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 import okhttp3.OkHttpClient @@ -182,6 +184,27 @@ class AuthressLoginClient( }.getOrDefault(false) } + /** + * Waits until a bearer token is available, then returns it. Blocks 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 is what [ch.rhosys.email.data.remote.api.AuthInterceptor] + * uses — reading the cookie directly would race a session that is mid-refresh. + * + * Returns null if no token arrives within [timeoutInMillis]; 0 means do not + * wait at all, matching the SDK. + */ + suspend fun waitForToken(timeoutInMillis: Long = 5000): String? { + getToken()?.let { return it } + if (timeoutInMillis == 0L) return null + + return withTimeoutOrNull(timeoutInMillis) { + // Resolved by completeAuthenticationRequest or a successful session check. + _sessionEstablished.first { it } + getToken() + } + } + /** Ends the server session first, while the cookie can still identify it. */ suspend fun logout(): Result = runCatching { runCatching { delete("/session") } diff --git a/app/src/main/java/ch/rhosys/email/data/remote/api/AuthInterceptor.kt b/app/src/main/java/ch/rhosys/email/data/remote/api/AuthInterceptor.kt index 8cea1ef..9e77785 100644 --- a/app/src/main/java/ch/rhosys/email/data/remote/api/AuthInterceptor.kt +++ b/app/src/main/java/ch/rhosys/email/data/remote/api/AuthInterceptor.kt @@ -1,20 +1,23 @@ package ch.rhosys.email.data.remote.api -import ch.rhosys.email.data.auth.AuthressLoginClient +import kotlinx.coroutines.runBlocking import okhttp3.Interceptor import okhttp3.Response /** - * Attaches the Authress session token to API calls. The token comes from the - * `authorization` cookie rather than a stored access token, which is where the - * login SDK keeps it. + * Attaches the Authress session token to API calls. * - * [tokenProvider] is a lambda because the login client is constructed after the - * OkHttp client it shares. + * The token comes from the login client's waitForToken, which is what the SDK + * documents for an Authorization header: it returns immediately when a valid + * session exists, and otherwise waits briefly for one being established rather + * than firing a request that is certain to be rejected. + * + * runBlocking is safe here — OkHttp interceptors already run on a background + * dispatcher, never the main thread. */ -class AuthInterceptor(private val tokenProvider: () -> String?) : Interceptor { +class AuthInterceptor(private val tokenProvider: suspend () -> String?) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { - val token = tokenProvider() + val token = runBlocking { tokenProvider() } val request = chain.request().newBuilder().apply { if (token != null) addHeader("Authorization", "Bearer $token") }.build() diff --git a/app/src/main/java/ch/rhosys/email/di/AppContainer.kt b/app/src/main/java/ch/rhosys/email/di/AppContainer.kt index 8c8707a..ba8447c 100644 --- a/app/src/main/java/ch/rhosys/email/di/AppContainer.kt +++ b/app/src/main/java/ch/rhosys/email/di/AppContainer.kt @@ -57,7 +57,7 @@ class AppContainer(private val context: Context) { private val okHttpClient: OkHttpClient by lazy { OkHttpClient.Builder() - .addInterceptor(AuthInterceptor { authManager.getToken() }) + .addInterceptor(AuthInterceptor { authManager.waitForToken() }) .apply { if (BuildConfig.DEBUG) { addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BASIC)) diff --git a/app/src/main/java/ch/rhosys/email/presentation/navigation/NavGraph.kt b/app/src/main/java/ch/rhosys/email/presentation/navigation/NavGraph.kt index db15aab..bfb3638 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/navigation/NavGraph.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/navigation/NavGraph.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.setValue import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import ch.rhosys.email.di.LocalAppContainer @@ -65,6 +66,14 @@ private fun AppNavHost() { val navController = rememberNavController() 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. + val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route + LaunchedEffect(currentRoute) { + container.authManager.userIsLoggedIn() + } + AppScaffold(navController) { modifier -> NavHost(navController = navController, startDestination = Destination.Inbox.route, modifier = modifier) { composable(Destination.Inbox.route) { From b7e716edd551f7a5891c13cc221a474c6b964854 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 18:07:27 +0000 Subject: [PATCH 5/6] Keep the SDK's cookie backup and restore split instead of collapsing it The jar had been merged into a single always-persisted store on the reasoning that the SDK's two-layer arrangement was redundant on Android. That was a judgement call substituted for the reference implementation, which is exactly the kind of deviation worth not making. authStorageManager.ts keeps the live cookie store separate from its encrypted mirror and moves between them at defined points. That structure is restored here, with the call sites matching the SDK one for one: restoreCookies in the constructor, before anything reads a token backupCookies after a successful token exchange backupCookies after a successful session check clear on logout, clearing both layers together restoreCookies keeps the SDK's early return when the live jar already holds cookies, so a stale mirror can never overwrite an active session. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL --- .../email/data/auth/AuthressCookieJar.kt | 60 +++++++++++-------- .../email/data/auth/AuthressLoginClient.kt | 8 ++- 2 files changed, 42 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/ch/rhosys/email/data/auth/AuthressCookieJar.kt b/app/src/main/java/ch/rhosys/email/data/auth/AuthressCookieJar.kt index ae1520e..e4ba1a8 100644 --- a/app/src/main/java/ch/rhosys/email/data/auth/AuthressCookieJar.kt +++ b/app/src/main/java/ch/rhosys/email/data/auth/AuthressCookieJar.kt @@ -10,15 +10,18 @@ import org.json.JSONArray import org.json.JSONObject /** - * The Authress session lives in cookies, not in a stored access/refresh token - * pair — `authorization` carries the bearer token and `user` carries the identity - * token. The React Native SDK keeps them in the native cookie jar and mirrors - * them into encrypted storage so a session survives a process restart - * (authStorageManager.backupCookies / restoreCookies). + * The Authress session lives in cookies rather than an access/refresh pair: + * `authorization` carries the bearer token and `user` carries the identity token. * - * This is the OkHttp equivalent: an in-memory jar backed by - * EncryptedSharedPreferences. When several calls set the same cookie name on - * different paths, the last value written wins — the SDK's `lastValue`. + * Structured to match authStorageManager.ts. The SDK keeps two things — the + * platform cookie jar that its HTTP calls read and write, and a mirror of it in + * encrypted storage — and moves between them with explicit backupCookies and + * restoreCookies at defined points. That split is reproduced here rather than + * collapsed into a single always-persisted store, so the call sites line up with + * the SDK's one for one. + * + * `lastValue` behaviour is preserved: when several calls set the same cookie name + * on different paths, the last value written wins. */ class AuthressCookieJar(context: Context, private val authressHost: String) : CookieJar { @@ -30,27 +33,20 @@ class AuthressCookieJar(context: Context, private val authressHost: String) : Co EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, ) - /** name -> value, last write wins. */ + /** The live jar, equivalent to the SDK's native cookie store. */ private val cookies = linkedMapOf() - init { - restore() - } - @Synchronized override fun saveFromResponse(url: HttpUrl, cookies: List) { if (!url.host.equals(authressHost, ignoreCase = true)) return - var changed = false cookies.forEach { cookie -> // An expiry in the past is a deletion. if (cookie.expiresAt < System.currentTimeMillis()) { - changed = this.cookies.remove(cookie.name) != null || changed - } else if (this.cookies[cookie.name] != cookie.value) { + this.cookies.remove(cookie.name) + } else { this.cookies[cookie.name] = cookie.value - changed = true } } - if (changed) persist() } @Synchronized @@ -76,13 +72,13 @@ class AuthressCookieJar(context: Context, private val authressHost: String) : Co @Synchronized fun userCookie(): String? = cookies[COOKIE_USER] + /** + * Mirrors the live jar into encrypted storage. The SDK calls this after a + * successful token exchange and after a successful session check. + */ @Synchronized - fun clear() { - cookies.clear() - prefs.edit().remove(KEY_COOKIES).apply() - } - - private fun persist() { + fun backupCookies() { + if (cookies.isEmpty()) return val array = JSONArray() cookies.forEach { (name, value) -> array.put(JSONObject().put("name", name).put("value", value)) @@ -90,7 +86,14 @@ class AuthressCookieJar(context: Context, private val authressHost: String) : Co prefs.edit().putString(KEY_COOKIES, array.toString()).apply() } - private fun restore() { + /** + * Repopulates the live jar from the backup, and only when the jar is empty — + * the SDK returns early if the platform store already holds cookies, so a + * live session is never overwritten by a stale mirror. + */ + @Synchronized + fun restoreCookies() { + if (cookies.isNotEmpty()) return val raw = prefs.getString(KEY_COOKIES, null) ?: return runCatching { val array = JSONArray(raw) @@ -101,6 +104,13 @@ class AuthressCookieJar(context: Context, private val authressHost: String) : Co } } + /** Clears the live jar and the backup together. */ + @Synchronized + fun clear() { + cookies.clear() + prefs.edit().remove(KEY_COOKIES).apply() + } + private companion object { const val KEY_COOKIES = "authress-cookies" const val COOKIE_AUTHORIZATION = "authorization" diff --git a/app/src/main/java/ch/rhosys/email/data/auth/AuthressLoginClient.kt b/app/src/main/java/ch/rhosys/email/data/auth/AuthressLoginClient.kt index f77a640..e45e5b6 100644 --- a/app/src/main/java/ch/rhosys/email/data/auth/AuthressLoginClient.kt +++ b/app/src/main/java/ch/rhosys/email/data/auth/AuthressLoginClient.kt @@ -64,6 +64,9 @@ class AuthressLoginClient( val sessionEstablished: StateFlow = _sessionEstablished.asStateFlow() init { + // The SDK restores cookies from encrypted storage in its constructor, + // before anything reads a token. + cookieJar.restoreCookies() _sessionEstablished.value = getToken() != null } @@ -142,6 +145,7 @@ class AuthressLoginClient( throw e } + cookieJar.backupCookies() storage.setAuthenticationRequest(null) _sessionEstablished.value = getToken() != null } @@ -180,7 +184,9 @@ class AuthressLoginClient( if (getToken() != null) return true return runCatching { patch("/session", JSONObject()) - (getToken() != null).also { _sessionEstablished.value = it } + val loggedIn = getToken() != null + if (loggedIn) cookieJar.backupCookies() + loggedIn.also { _sessionEstablished.value = it } }.getOrDefault(false) } From aae437dc1eb9649d07cd1dc81ec4d23fe286c605 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 21:26:33 +0000 Subject: [PATCH 6/6] Correct todo.md for the SDK port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OAuth endpoint entry described discovering authorize/token endpoints, which the port made irrelevant — Authress has no such exchange. Replaced with the actual flow, and the duplicate-redirect entry is folded in as resolved, since dropping AppAuth left MainActivity as the only claimant. Also corrects the MFA line. Device management is absent from the email API, but the login service does expose GET and DELETE /api/session/devices, which the SDK wraps as getDevices and deleteDevice — so the Settings tab could be rebuilt against those rather than needing a backend change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL --- todo.md | 50 ++++++++++++++++++-------------------------------- 1 file changed, 18 insertions(+), 32 deletions(-) diff --git a/todo.md b/todo.md index 0ac5500..c867924 100644 --- a/todo.md +++ b/todo.md @@ -12,44 +12,30 @@ Now defaults to `app_2EAWGEdtzaeCj7b45DsDtt`, taken from the web app's `VITE_AUTHRESS_APPLICATION_ID`. Still overridable with `-PauthressApplicationId=` per environment. -### ~~OAuth endpoints~~ — resolved +### ~~OAuth endpoints and redirect handling~~ — resolved -The hardcoded `/authorize` and `/oauth/token` paths were invented and neither -exists. Authress publishes discovery at -`https://login.rhosys.cloud/.well-known/openid-configuration`: +Authress is not a plain OAuth provider, so there is no authorize/token exchange +to point at. The app now ports @authress/login-react-native directly: ``` -authorization_endpoint https://login.rhosys.cloud (the issuer root) -token_endpoint https://login.rhosys.cloud/api/authentication/oauth/tokens -code_challenge_methods S256 -scopes_supported openid, profile +POST /api/authentication -> authenticationUrl + authenticationRequestId +open authenticationUrl in a Custom Tab (the real browser, so passkeys work) +redirect to ch.rhosys.email://auth/callback -> code + authenticationRequestId +POST /api/authentication/{id}/tokens -> session cookies ``` -AuthressAuthManager now discovers these at runtime via -`AuthorizationServiceConfiguration.fetchFromIssuer` instead of hardcoding paths. -The requested scope dropped `email` and `offline_access`, neither of which is -advertised; refresh tokens come from the `refresh_token` grant, which is. +The session lives in the `authorization` and `user` cookies rather than an +access/refresh pair. `userIsLoggedIn()` refreshes it via `PATCH /session` and is +called on every route change, per the SDK's own recommendation; `waitForToken()` +supplies the Authorization header. -### OAuth redirect is claimed twice +The duplicate redirect claim is gone with AppAuth: MainActivity is now the only +component matching the scheme, and it forwards the redirect through +`onNewIntent` as the SDK's Android setup describes. -`MainActivity` declares an intent filter for `ch.rhosys.email:/oauth2redirect` -(`AndroidManifest.xml`), and AppAuth's own `RedirectUriReceiverActivity` claims -the same scheme through the `appAuthRedirectScheme` manifest placeholder -(`app/build.gradle.kts`). Two components match the same redirect, so resolution -is non-deterministic. - -If MainActivity wins, sign-in breaks silently: it never reads the incoming -intent — there is no `onNewIntent` override and `getIntent()` appears nowhere in -`app/src` — so the authorization code is dropped. AppAuth needs its own receiver -to complete the exchange, which makes the comment on the placeholder -("our redirect is actually captured by MainActivity's intent-filter") backwards. - -Fix is most likely to delete the MainActivity filter and let AppAuth handle it. -Worth doing alongside the Authress application id, since both block login. - -Separately, a custom-scheme redirect can be registered by any app on the device. -Prefer an HTTPS App Link redirect on `email.rhosys.cloud` once assetlinks.json is -served (see the Play Store section). +The browser deliberately does not share cookies with the app — it does not need +to. The token exchange is made by the app's own HTTP client, so the session +cookie arrives there. ### App name @@ -99,7 +85,7 @@ can come back. | Compose a new thread | Drafts post to `/threads/{threadId}/signals`; there is no route for a draft with no thread. Reply and forward work | | Send later / undo send | No scheduling parameter, no cancel route. Sending is immediate | | Attachment download | Attachments carry a fixed `url` and are opened directly; there is no download endpoint | -| MFA / passkey management | No endpoints | +| MFA / passkey management | Not on the email API — but the login service has `GET`/`DELETE /api/session/devices`, which the SDK exposes as getDevices/deleteDevice. The Settings tab could be rebuilt against those | | Billing | `billingPlan` is readable on an account, but there are no billing endpoints | | Support tickets | No endpoint. `SupportData` in the spec is a signal workflow type, not a ticket API | | Per-address sender blocking | Sender policy applies to a whole domain on an alias |