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
18 changes: 9 additions & 9 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,19 @@ 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"}\"",
)
// 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"
// Required by the AppAuth library's own manifest (RedirectUriReceiverActivity),
// even though our redirect is actually captured by MainActivity's intent-filter below.
manifestPlaceholders["appAuthRedirectScheme"] = "ch.rhosys.email"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

Expand Down Expand Up @@ -139,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)
Expand Down
9 changes: 7 additions & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,17 @@
<data android:mimeType="text/plain" />
</intent-filter>

<!-- OAuth redirect capture for the Authress AppAuth flow. -->
<!-- Authress login redirect. Must match the redirectUri passed to
AuthressLoginClient, and MainActivity is now the only component
that claims it — AppAuth's RedirectUriReceiverActivity used to
claim the same scheme, which made resolution non-deterministic.
DEFAULT lets the activity receive implicit intents from outside
the app; BROWSABLE lets a Custom Tab trigger it. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="${oauthRedirectScheme}" android:path="/oauth2redirect" />
<data android:scheme="ch.rhosys.email" android:host="auth" android:pathPrefix="/callback" />
</intent-filter>

<meta-data
Expand Down
25 changes: 25 additions & 0 deletions app/src/main/java/ch/rhosys/email/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package ch.rhosys.email

import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.compose.runtime.CompositionLocalProvider
Expand All @@ -10,18 +11,24 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import ch.rhosys.email.di.LocalAppContainer
import ch.rhosys.email.presentation.auth.BiometricLockScreen
import ch.rhosys.email.presentation.navigation.RootNavGraph
import ch.rhosys.email.sync.SyncForegroundService
import ch.rhosys.email.ui.theme.EmailTheme
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch

class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val appContainer = (application as EmailApp).appContainer

// The Authress redirect can arrive either as the intent that started the
// activity or, with launchMode=singleTask, through onNewIntent.
handleAuthRedirect(intent)

setContent {
val themeFlavor by appContainer.preferencesStore.themeFlavor.collectAsStateWithLifecycle(initialValue = null)
var lockRequired by remember { mutableStateOf<Boolean?>(null) }
Expand All @@ -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)
Expand Down
61 changes: 61 additions & 0 deletions app/src/main/java/ch/rhosys/email/data/auth/AuthStorageManager.kt
Original file line number Diff line number Diff line change
@@ -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"
}
}
102 changes: 0 additions & 102 deletions app/src/main/java/ch/rhosys/email/data/auth/AuthressAuthManager.kt

This file was deleted.

119 changes: 119 additions & 0 deletions app/src/main/java/ch/rhosys/email/data/auth/AuthressCookieJar.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
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 rather than an access/refresh pair:
* `authorization` carries the bearer token and `user` carries the identity token.
*
* 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 {

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,
)

/** The live jar, equivalent to the SDK's native cookie store. */
private val cookies = linkedMapOf<String, String>()

@Synchronized
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
if (!url.host.equals(authressHost, ignoreCase = true)) return
cookies.forEach { cookie ->
// An expiry in the past is a deletion.
if (cookie.expiresAt < System.currentTimeMillis()) {
this.cookies.remove(cookie.name)
} else {
this.cookies[cookie.name] = cookie.value
}
}
}

@Synchronized
override fun loadForRequest(url: HttpUrl): List<Cookie> {
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]

/**
* Mirrors the live jar into encrypted storage. The SDK calls this after a
* successful token exchange and after a successful session check.
*/
@Synchronized
fun backupCookies() {
if (cookies.isEmpty()) return
val array = JSONArray()
cookies.forEach { (name, value) ->
array.put(JSONObject().put("name", name).put("value", value))
}
prefs.edit().putString(KEY_COOKIES, array.toString()).apply()
}

/**
* 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)
for (i in 0 until array.length()) {
val entry = array.getJSONObject(i)
cookies[entry.getString("name")] = entry.getString("value")
}
}
}

/** 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"
const val COOKIE_USER = "user"
}
}
Loading